From 04d3c3f57f179d0778d6da7626072cf646253383 Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Wed, 12 Apr 2023 16:05:53 +0800 Subject: [PATCH 1/7] add mgm dynamic network kotlin example --- .../mgm-dynamic-network/.ci/Jenkinsfile | 11 + .../.ci/nightly/JenkinsfileSnykScan | 6 + kotlin-samples/mgm-dynamic-network/.gitignore | 83 +++ .../runConfigurations/DebugCorDapp.run.xml | 15 + kotlin-samples/mgm-dynamic-network/.snyk | 22 + kotlin-samples/mgm-dynamic-network/README.md | 115 ++++ .../mgm-dynamic-network/Step1-mgm-deploy.sh | 148 ++++++ .../Step2-notary-onboard.sh | 92 ++++ .../Step3-first-member-onboard.sh | 83 +++ .../Step4-more-member-onboard.sh | 63 +++ .../mgm-dynamic-network/build.gradle | 65 +++ .../mgm-dynamic-network/buildSrc/build.gradle | 22 + .../buildSrc/gradle.properties | 6 + .../buildSrc/settings.gradle | 1 + .../buildSrc/src/main/groovy/csde.gradle | 248 +++++++++ .../java/com/r3/csde/BuildCPIsHelper.java | 276 ++++++++++ .../com/r3/csde/CordaLifeCycleHelper.java | 93 ++++ .../java/com/r3/csde/CordaStatusQueries.java | 64 +++ .../main/java/com/r3/csde/CsdeException.java | 10 + .../java/com/r3/csde/DeployCPIsHelper.java | 187 +++++++ .../main/java/com/r3/csde/NetworkConfig.java | 40 ++ .../main/java/com/r3/csde/ProjectContext.java | 84 +++ .../main/java/com/r3/csde/ProjectUtils.java | 36 ++ .../src/main/java/com/r3/csde/VNode.java | 25 + .../main/java/com/r3/csde/VNodesHelper.java | 288 ++++++++++ .../com/r3/csde/dtos/CPIFileStatusDTO.java | 16 + .../com/r3/csde/dtos/CpiIdentifierDTO.java | 23 + .../java/com/r3/csde/dtos/CpiMetadataDTO.java | 17 + .../com/r3/csde/dtos/GetCPIsResponseDTO.java | 14 + .../com/r3/csde/dtos/HoldingIdentityDTO.java | 27 + .../dtos/RegistrationRequestProgressDTO.java | 17 + .../com/r3/csde/dtos/VirtualNodeInfoDTO.java | 17 + .../com/r3/csde/dtos/VirtualNodesDTO.java | 16 + kotlin-samples/mgm-dynamic-network/clean.sh | 15 + .../config/gradle-plugin-default-key.pem | 13 + .../mgm-dynamic-network/config/log4j2.xml | 51 ++ .../config/static-network-config.json | 23 + .../contracts/build.gradle | 88 ++++ .../utxoexample/contracts/ChatContract.kt | 62 +++ .../utxoexample/states/ChatState.kt | 37 ++ .../mgm-dynamic-network/gradle.properties | 43 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59821 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + kotlin-samples/mgm-dynamic-network/gradlew | 185 +++++++ .../mgm-dynamic-network/gradlew.bat | 89 ++++ .../register-mgm/MgmGroupPolicy.json | 6 + .../mgm-dynamic-network/settings.gradle | 25 + kotlin-samples/mgm-dynamic-network/test.sh | 9 + .../workflows/build.gradle | 89 ++++ .../workflows/CreateNewChatFlow.kt | 113 ++++ .../workflows/FinalizeChatSubFlow.kt | 103 ++++ .../utxoexample/workflows/GetChatFlow.kt | 116 ++++ .../utxoexample/workflows/ListChatsFlow.kt | 61 +++ .../workflows/ResponderValidations.kt | 24 + .../utxoexample/workflows/TestContractFlow.kt | 497 ++++++++++++++++++ .../utxoexample/workflows/UpdateChatFlow.kt | 109 ++++ .../flowexample/workflows/MyFirstFlowTest.kt | 48 ++ 57 files changed, 4041 insertions(+) create mode 100644 kotlin-samples/mgm-dynamic-network/.ci/Jenkinsfile create mode 100644 kotlin-samples/mgm-dynamic-network/.ci/nightly/JenkinsfileSnykScan create mode 100644 kotlin-samples/mgm-dynamic-network/.gitignore create mode 100644 kotlin-samples/mgm-dynamic-network/.run/runConfigurations/DebugCorDapp.run.xml create mode 100644 kotlin-samples/mgm-dynamic-network/.snyk create mode 100644 kotlin-samples/mgm-dynamic-network/README.md create mode 100644 kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh create mode 100644 kotlin-samples/mgm-dynamic-network/Step2-notary-onboard.sh create mode 100644 kotlin-samples/mgm-dynamic-network/Step3-first-member-onboard.sh create mode 100644 kotlin-samples/mgm-dynamic-network/Step4-more-member-onboard.sh create mode 100644 kotlin-samples/mgm-dynamic-network/build.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/build.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/gradle.properties create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/settings.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java create mode 100644 kotlin-samples/mgm-dynamic-network/clean.sh create mode 100644 kotlin-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem create mode 100644 kotlin-samples/mgm-dynamic-network/config/log4j2.xml create mode 100644 kotlin-samples/mgm-dynamic-network/config/static-network-config.json create mode 100644 kotlin-samples/mgm-dynamic-network/contracts/build.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.kt create mode 100644 kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/states/ChatState.kt create mode 100644 kotlin-samples/mgm-dynamic-network/gradle.properties create mode 100644 kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.jar create mode 100644 kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties create mode 100755 kotlin-samples/mgm-dynamic-network/gradlew create mode 100644 kotlin-samples/mgm-dynamic-network/gradlew.bat create mode 100644 kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json create mode 100644 kotlin-samples/mgm-dynamic-network/settings.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/test.sh create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/build.gradle create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ResponderValidations.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/TestContractFlow.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.kt create mode 100644 kotlin-samples/mgm-dynamic-network/workflows/src/test/kotlin/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.kt diff --git a/kotlin-samples/mgm-dynamic-network/.ci/Jenkinsfile b/kotlin-samples/mgm-dynamic-network/.ci/Jenkinsfile new file mode 100644 index 0000000..bbb01e4 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/.ci/Jenkinsfile @@ -0,0 +1,11 @@ +@Library('corda-shared-build-pipeline-steps@5.0') _ + +cordaPipeline( + nexusAppId: 'com.corda.CSDE-kotlin.5.0', + publishRepoPrefix: '', + slimBuild: true, + runUnitTests: false, + dedicatedJobForSnykDelta: false, + slackChannel: '#corda-corda5-dev-ex-build-notifications', + gitHubComments: false + ) diff --git a/kotlin-samples/mgm-dynamic-network/.ci/nightly/JenkinsfileSnykScan b/kotlin-samples/mgm-dynamic-network/.ci/nightly/JenkinsfileSnykScan new file mode 100644 index 0000000..c07efb7 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/.ci/nightly/JenkinsfileSnykScan @@ -0,0 +1,6 @@ +@Library('corda-shared-build-pipeline-steps@5.0') _ + +cordaSnykScanPipeline ( + snykTokenId: 'r3-snyk-corda5', + snykAdditionalCommands: "--all-sub-projects -d" +) diff --git a/kotlin-samples/mgm-dynamic-network/.gitignore b/kotlin-samples/mgm-dynamic-network/.gitignore new file mode 100644 index 0000000..65a33ef --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/.gitignore @@ -0,0 +1,83 @@ + +# Eclipse, ctags, Mac metadata, log files +.classpath +.project +.settings +tags +.DS_Store +*.log +*.orig + +# Created by .ignore support plugin (hsz.mobi) + +.gradle +local.properties +.gradletasknamecache + +# General build files +**/build/* + +lib/quasar.jar + +**/logs/* + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/*.xml +.idea/.name +.idea/copyright +.idea/inspectionProfiles +.idea/libraries +.idea/shelf +.idea/dataSources +.idea/markdown-navigator +.idea/runConfigurations +.idea/dictionaries + + +# Include the -parameters compiler option by default in IntelliJ required for serialization. +!.idea/codeStyleSettings.xml + + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +**/out/ +/classes/ + + + +# vim +*.swp +*.swn +*.swo + + + +# Directory generated during Resolve and TestOSGi gradle tasks +bnd/ + +# Ignore Gradle build output directory +build +/.idea/codeStyles/codeStyleConfig.xml +/.idea/codeStyles/Project.xml + + + +# Ignore Visual studio directory +bin/ + + + +*.cpi +*.cpb +*.cpk +workspace/** \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/.run/runConfigurations/DebugCorDapp.run.xml b/kotlin-samples/mgm-dynamic-network/.run/runConfigurations/DebugCorDapp.run.xml new file mode 100644 index 0000000..1d8da82 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/.run/runConfigurations/DebugCorDapp.run.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/.snyk b/kotlin-samples/mgm-dynamic-network/.snyk new file mode 100644 index 0000000..8c3989f --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/.snyk @@ -0,0 +1,22 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.25.0 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JAVA-ORGJETBRAINSKOTLIN-2393744: + - '*': + reason: >- + This vulnerability relates to information exposure via creation of + temporary files (via Kotlin functions) with insecure permissions. + Corda does not use any of the vulnerable functions so it is not + susceptible to this vulnerability + expires: 2023-06-19T17:08:41.029Z + created: 2023-02-02T17:08:41.032Z + SNYK-JAVA-ORGJETBRAINSKOTLIN-2628385: + - '*': + reason: >- + corda-simulator-runtime is a testRuntimeOnly dependency, as such this + dependency will not be included in any cordaApp produced by the CSDE + project Template. + expires: 2023-06-19T17:09:11.451Z + created: 2023-02-02T17:09:11.455Z +patch: {} diff --git a/kotlin-samples/mgm-dynamic-network/README.md b/kotlin-samples/mgm-dynamic-network/README.md new file mode 100644 index 0000000..aa5b9fe --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/README.md @@ -0,0 +1,115 @@ +# CSDE-cordapp-template-kotlin + + +To help make the process of prototyping CorDapps on Corda 5 beta 1.1 release more straight forward we have developed the Cordapp Standard Development Environment (CSDE). + +The CSDE is obtained by cloning this CSDE-Cordapp-Template-Kotlin to your local machine. The CSDE provides: + +- A pre-setup Cordapp Project which you can use as a starting point to develop your own prototypes. + +- A base Gradle configuration which brings in the dependencies you need to write and test a Corda 5 Cordapp. + +- A set of Gradle helper tasks which speed up and simplify the development and deployment process. + +- Debug configuration for debugging a local Corda cluster. + +- The MyFirstFlow code which forms the basis of this getting started documentation, this is located in package com.r3.developers.csdetemplate.flowexample + +- A UTXO example in package com.r3.developers.csdetemplate.utxoexample packages + +- Ability to configure the Members of the Local Corda Network. + +Note, the CSDE is experimental, we may or may not release it as part of Corda 5.0, in part based on developer feedback using it. + +To find out how to use the CSDE please refer to the getting started section in the Corda 5 Beta 2 documentation at https://docs.r3.com/ + + + +## Chat app +We have built a simple one to one chat app to demo some functionalities of the next gen Corda platform. + +In this app you can: +1. Create a new chat with a counterparty. `CreateNewChatFlow` +2. List out the chat entries you had. `ListChatsFlow` +3. Individually query out the history of one chat entry. `GetChatFlowArgs` +4. Continue chatting within the chat entry with the counterparty. `UpdateChatFlow` + +### Setting up + +1. We will begin our test deployment with clicking the `startCorda`. This task will load up the combined Corda workers in docker. + A successful deployment will allow you to open the REST APIs at: https://localhost:8888/api/v1/swagger#. You can test out some of the + functions to check connectivity. (GET /cpi function call should return an empty list as for now.) +2. We will now deploy the cordapp with a click of `5-vNodeSetup` task. Upon successful deployment of the CPI, the GET /cpi function call should now return the meta data of the cpi you just upload + + + +### Running the chat app + +In Corda 5, flows will be triggered via `POST /flow/{holdingidentityshorthash}` and flow result will need to be view at `GET /flow/{holdingidentityshorthash}/{clientrequestid}` +* holdingidentityshorthash: the id of the network participants, ie Bob, Alice, Charlie. You can view all the short hashes of the network member with another gradle task called `ListVNodes` +* clientrequestid: the id you specify in the flow requestBody when you trigger a flow. + +#### Step 1: Create Chat Entry +Pick a VNode identity to initiate the chat, and get its short hash. (Let's pick Alice. Dont pick Bob because Bob is the person who we will have the chat with). + +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} +``` + +After trigger the create-chat flow, hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the short hash(Alice's hash) and clientrequestid to view the flow result + +#### Step 2: List the chat +In order to continue the chat, we would need the chat ID. This step will bring out all the chat entries this entity (Alice) has. +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.ListChatsFlow", + "requestBody": {} +} +``` +After trigger the list-chats flow, again, we need to hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and check the result. As the screenshot shows, in the response body, +we will see a list of chat entries, but it currently only has one entry. And we can see the id of the chat entry. Let's record that id. + + +#### Step 3: Continue the chat with `UpdateChatFlow` +In this step, we will continue the chat between Alice and Bob. +Goto `POST /flow/{holdingidentityshorthash}`, enter the identity short hash and request body. Note that here we can have either Alice or Bob's short hash. If you enter Alice's hash, +this message will be recorded as a message from Alice, vice versa. And the id field is the chat entry id we got from the previous step. +``` +{ + "clientRequestId": "update-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.UpdateChatFlow", + "requestBody": { + "id":" ** fill in id **", + "message": "How are you today?" + } +} +``` +And as for the result of this flow, go to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the required fields. + +#### Step 4: See the whole chat history of one chat entry +After a few back and forth of the messaging, you can view entire chat history by calling GetChatFlow. + +``` +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.GetChatFlow", + "requestBody": { + "id":" ** fill in id **", + "numberOfRecords":"4" + } +} +``` +And as for the result, you need to go to the Get API again and enter the short hash and client request ID. + +Thus, we have concluded a full run through of the chat app. diff --git a/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh b/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh new file mode 100644 index 0000000..d36d466 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh @@ -0,0 +1,148 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/kotlin-samples/mgm-dynamic-network/register-mgm +mkdir -p "$WORK_DIR" +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + +echo "\n---Create a mock CA and signing keys---" +cd "$WORK_DIR" +#Generate a signing key: +keytool -genkeypair -alias "signing key 1" -keystore signingkeys.pfx -storepass "keystore password" -dname "cn=CPI Plugin Example - Signing Key 1, o=R3, L=London, c=GB" -keyalg RSA -storetype pkcs12 -validity 4000 +#Import gradle-plugin-default-key.pem into the keystore +keytool -importcert -keystore signingkeys.pfx -storepass "keystore password" -noprompt -alias gradle-plugin-default-key -file gradle-plugin-default-key.pem +cd "$RUNTIME_OS" +./gradlew :applications:tools:p2p-test:fake-ca:clean :applications:tools:p2p-test:fake-ca:appJar +java -jar ./applications/tools/p2p-test/fake-ca/build/bin/corda-fake-ca-5.0.0.0-Gecko-SNAPSHOT.jar -m /tmp/ca -a RSA -s 3072 ca +cd "$WORK_DIR" +#default signing key +echo '-----BEGIN CERTIFICATE----- +MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC +MQ8wDQYDVQQHDAZMb25kb24xDjAMBgNVBAoMBUNvcmRhMQswCQYDVQQLDAJSMzEe +MBwGA1UEAwwVQ29yZGEgRGV2IENvZGUgU2lnbmVyMB4XDTIwMDYyNTE4NTI1NFoX +DTMwMDYyMzE4NTI1NFowWzELMAkGA1UEBhMCR0IxDzANBgNVBAcTBkxvbmRvbjEO +MAwGA1UEChMFQ29yZGExCzAJBgNVBAsTAlIzMR4wHAYDVQQDExVDb3JkYSBEZXYg +Q29kZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQDjSJtzQ+ldDFt +pHiqdSJebOGPZcvZbmC/PIJRsZZUF1bl3PfMqyG3EmAe0CeFAfLzPQtf2qTAnmJj +lGTkkQhxo0MwQTATBgNVHSUEDDAKBggrBgEFBQcDAzALBgNVHQ8EBAMCB4AwHQYD +VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh +ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL +zU+Rc5yMtcOY4/moZUq36r0Ilg== +-----END CERTIFICATE-----' > ./gradle-plugin-default-key.pem + +echo "\n---Build mgm CPB---" +cd "$RUNTIME_OS" +./gradlew testing:cpbs:mgm:build +cp testing/cpbs/mgm/build/libs/mgm-5.0.0.0-Gecko-SNAPSHOT-package.cpb "$WORK_DIR" +echo '{ + "fileFormatVersion" : 1, + "groupId" : "CREATE_ID", + "registrationProtocol" :"net.corda.membership.impl.registration.dynamic.mgm.MGMRegistrationService", + "synchronisationProtocol": "net.corda.membership.impl.synchronisation.MgmSynchronisationServiceImpl" +}' > "$WORK_DIR"/MgmGroupPolicy.json +cd "$WORK_DIR" +mv ./mgm-5.0.0.0-Gecko-SNAPSHOT-package.cpb mgm.cpb + + +echo "\n---Build and upload MGM CPI---" +cd "$WORK_DIR" +#Run this command to turn a CPB into a CPI +sh ~/.corda/cli/corda-cli.sh package create-cpi --cpb mgm.cpb --group-policy MgmGroupPolicy.json --cpi-name "mgm cpi" --cpi-version "1.0.0.0-SNAPSHOT" --file mgm.cpi --keystore signingkeys.pfx --storepass "keystore password" --key "signing key 1" +#Import the gradle plugin default key into Corda +curl --insecure -u admin:admin -X PUT -F alias="gradle-plugin-default-key" -F certificate=@gradle-plugin-default-key.pem https://localhost:8888/api/v1/certificates/cluster/code-signer +#Export the signing key certificate from the key store +keytool -exportcert -rfc -alias "signing key 1" -keystore signingkeys.pfx -storepass "keystore password" -file signingkey1.pem +#Import the signing key into Corda +curl --insecure -u admin:admin -X PUT -F alias="signingkey1-2022" -F certificate=@signingkey1.pem https://localhost:8888/api/v1/certificates/cluster/code-signer +CPI_PATH=./mgm.cpi +curl --insecure -u admin:admin -F upload=@$CPI_PATH $API_URL/cpi/ +echo "\n" +read -p "Enter the CPI_ID from the returned body:" CPI_ID +echo "CPI_ID:" $CPI_ID + + +echo "---Create MGM VNode---" +curl --insecure -u admin:admin $API_URL/cpi/status/$CPI_ID +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM +curl --insecure -u admin:admin -d '{ "request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "C=GB, L=London, O=MGM"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the MGM_HOLDING_ID from the returned body:" MGM_HOLDING_ID +echo "MGM_HOLDING_ID:" $MGM_HOLDING_ID + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$MGM_HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$MGM_HOLDING_ID/alias/$MGM_HOLDING_ID-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$MGM_HOLDING_ID/PRE_AUTH +echo "\nECDH_KEY_ID: " +curl --insecure -u admin:admin -X POST $API_URL/keys/$MGM_HOLDING_ID/alias/$MGM_HOLDING_ID-auth/category/PRE_AUTH/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the ECDH_KEY_ID from the returned body:" ECDH_KEY_ID +echo "ECDH_KEY_ID:" $ECDH_KEY_ID + + +echo "\n--Set up the TLS key pair and certificate---" +curl -k -u admin:admin -X POST -H "Content-Type: application/json" $API_URL/keys/p2p/alias/p2p-TLS/category/TLS/scheme/CORDA.RSA +echo "\n" +read -p "Enter the TLS_KEY_ID from the returned body:" TLS_KEY_ID +echo "TLS_KEY_ID:" $TLS_KEY_ID +curl -k -u admin:admin -X POST -H "Content-Type: application/json" -d '{"x500Name": "CN=CordaOperator, C=GB, L=London, O=Org", "subjectAlternativeNames": ["'$P2P_GATEWAY_HOST'"]}' $API_URL"/certificates/p2p/"$TLS_KEY_ID > "$WORK_DIR"/request1.csr +read -p "Wait for download to be finished, Then press any key to continue..." ANY +cd "$RUNTIME_OS" +java -jar ./applications/tools/p2p-test/fake-ca/build/bin/corda-fake-ca-5.0.0.0-Gecko-SNAPSHOT.jar -m /tmp/ca csr "$WORK_DIR"/request1.csr +cd "$WORK_DIR" +curl -k -u admin:admin -X PUT -F certificate=@/tmp/ca/request1/certificate.pem -F alias=p2p-tls-cert $API_URL/certificates/cluster/p2p-tls + + +echo "---Disable revocation checks---" +curl --insecure -u admin:admin -X GET $API_URL/config/corda.p2p.gateway +echo "\n" +read -p "Enter the CONFIG_VERSION from the returned body:" CONFIG_VERSION +echo "CONFIG_VERSION:" $CONFIG_VERSION +curl -k -u admin:admin -X PUT -d '{"section":"corda.p2p.gateway", "version":"'$CONFIG_VERSION'", "config":"{ \"sslConfig\": { \"revocationCheck\": { \"mode\": \"OFF\" } } }", "schemaVersion": {"major": 1, "minor": 0}}' $API_URL"/config" + + +echo "\n---Register MGM---" +TLS_CA_CERT=$(cat /tmp/ca/ca/root-certificate.pem | awk '{printf "%s\\n", $0}') +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.ecdh.key.id": "'$ECDH_KEY_ID'", + "corda.group.protocol.registration": "net.corda.membership.impl.registration.dynamic.member.DynamicMemberRegistrationService", + "corda.group.protocol.synchronisation": "net.corda.membership.impl.synchronisation.MemberSynchronisationServiceImpl", + "corda.group.protocol.p2p.mode": "Authenticated_Encryption", + "corda.group.key.session.policy": "Combined", + "corda.group.pki.session": "NoPKI", + "corda.group.pki.tls": "Standard", + "corda.group.tls.version": "1.3", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1", + "corda.group.truststore.tls.0" : "'$TLS_CA_CERT'" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$MGM_HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$MGM_HOLDING_ID/$REGISTRATION_ID +echo "\n" + + +echo "---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$MGM_HOLDING_ID +echo "\n" + + +echo "---Export group policy for group---" +cd "$WORK_DIR" +mkdir -p "./register-member" +curl --insecure -u admin:admin -X GET $API_URL/mgm/$MGM_HOLDING_ID/info > "$WORK_DIR/register-member/GroupPolicy.json" diff --git a/kotlin-samples/mgm-dynamic-network/Step2-notary-onboard.sh b/kotlin-samples/mgm-dynamic-network/Step2-notary-onboard.sh new file mode 100644 index 0000000..5e0d864 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/Step2-notary-onboard.sh @@ -0,0 +1,92 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/kotlin-samples/mgm-dynamic-network/register-mgm +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + + +echo "\n---Build and upload Notary CPI---" +cd $WORK_DIR +cp ./notary.cpb ./register-member +cd "$WORK_DIR/register-member/" +##Run this command to turn a CPB into a CPI +sh ~/.corda/cli/corda-cli.sh package create-cpi --cpb notary.cpb --group-policy GroupPolicy.json --cpi-name "notary cpi" --cpi-version "1.0.0.0-SNAPSHOT" --file notary.cpi --keystore ../signingkeys.pfx --storepass "keystore password" --key "signing key 1" +CPI_PATH="$WORK_DIR/register-member/notary.cpi" +curl --insecure -u admin:admin -F upload=@$CPI_PATH $API_URL/cpi/ +echo "\n" +read -p "Enter the Notary CPI_ID from the returned body:" CPI_ID +echo "CPI_ID:" $CPI_ID +curl --insecure -u admin:admin $API_URL/cpi/status/$CPI_ID +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM + + +echo "\n---Create a Member virtual node---" +echo "\n" +read -p "Enter the X500_NAME from the returned body (Formatt: C=GB,L=London,O=NotaryRep1):" X500_NAME +curl --insecure -u admin:admin -d '{"request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "'$X500_NAME'"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the HOLDING_ID from the returned body:" HOLDING_ID +echo "HOLDING_ID:" $HOLDING_ID +echo "\n" + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL'/keys/'$HOLDING_ID'/alias/'$HOLDING_ID'-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1' +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/LEDGER +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-ledger/category/LEDGER/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the LEDGER_KEY_ID from the returned body:" LEDGER_KEY_ID +echo "LEDGER_KEY_ID:" $LEDGER_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/NOTARY +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-notary/category/NOTARY/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the NOTARY_KEY_ID from the returned body:" NOTARY_KEY_ID +echo "NOTARY_KEY_ID:" $NOTARY_KEY_ID + + +echo "\n---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$HOLDING_ID +echo "\n" + + +echo "\n---Build Notary registration context---" +echo "\n" +read -p "Enter the NOTARY_SERVICE_NAME (Formatt: C=GB,L=London,O=NotaryServiceA):" NOTARY_SERVICE_NAME +echo "NOTARY_SERVICE_NAME:" $NOTARY_SERVICE_NAME +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.session.key.signature.spec": "SHA256withECDSA", + "corda.ledger.keys.0.id": "'$LEDGER_KEY_ID'", + "corda.ledger.keys.0.signature.spec": "SHA256withECDSA", + "corda.notary.keys.0.id": "'$NOTARY_KEY_ID'", + "corda.notary.keys.0.signature.spec": "SHA256withECDSA", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1", + "corda.roles.0" : "notary", + "corda.notary.service.name" : "'$NOTARY_SERVICE_NAME'", + "corda.notary.service.plugin" : "net.corda.notary.NonValidatingNotary" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' + + +echo "\n---Register Notary VNode---" +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$HOLDING_ID/$REGISTRATION_ID +echo "\n" +curl --insecure -u admin:admin -X GET $API_URL/members/$HOLDING_ID diff --git a/kotlin-samples/mgm-dynamic-network/Step3-first-member-onboard.sh b/kotlin-samples/mgm-dynamic-network/Step3-first-member-onboard.sh new file mode 100644 index 0000000..98a2aaa --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/Step3-first-member-onboard.sh @@ -0,0 +1,83 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/kotlin-samples/mgm-dynamic-network/register-mgm +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + + +echo "\n---Build and upload chat CPI---" +./gradlew jar +./gradlew cpb +cd ./workflows/build/libs +mv ./workflows-1.0-SNAPSHOT-package.cpb chat.cpb +cd $WORK_DIR/.. +cp ./workflows/build/libs/chat.cpb ./register-mgm/register-member +##Run this command to turn a CPB into a CPI +cd "$WORK_DIR/register-member/" +sh ~/.corda/cli/corda-cli.sh package create-cpi --cpb chat.cpb --group-policy GroupPolicy.json --cpi-name "chat cpi" --cpi-version "1.0.0.0-SNAPSHOT" --file chat.cpi --keystore ../signingkeys.pfx --storepass "keystore password" --key "signing key 1" +CPI_PATH="$WORK_DIR/register-member/chat.cpi" +curl --insecure -u admin:admin -F upload=@$CPI_PATH $API_URL/cpi/ +echo "\n" +read -p "Enter the chat CPI_ID from the returned body:" CPI_ID +echo "CPI_ID:" $CPI_ID +curl --insecure -u admin:admin $API_URL/cpi/status/$CPI_ID +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM + + +echo "\n---Create a Member virtual node---" +echo "\n" +read -p "Enter the X500_NAME from the returned body (Formatt: C=GB,L=London,O=Alice):" X500_NAME +curl --insecure -u admin:admin -d '{"request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "'$X500_NAME'"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the HOLDING_ID from the returned body:" HOLDING_ID +echo "HOLDING_ID:" $HOLDING_ID + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL'/keys/'$HOLDING_ID'/alias/'$HOLDING_ID'-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1' +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/LEDGER +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-ledger/category/LEDGER/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the LEDGER_KEY_ID from the returned body:" LEDGER_KEY_ID +echo "LEDGER_KEY_ID:" $LEDGER_KEY_ID + + +echo "\n---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$HOLDING_ID + + +echo "\n---Build registration context---" +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.session.key.signature.spec": "SHA256withECDSA", + "corda.ledger.keys.0.id": "'$LEDGER_KEY_ID'", + "corda.ledger.keys.0.signature.spec": "SHA256withECDSA", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' + + +echo "\n---Register Member VNode---" +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$HOLDING_ID/$REGISTRATION_ID +echo "\n" +curl --insecure -u admin:admin -X GET $API_URL/members/$HOLDING_ID +echo "\n" +echo "\n---The Chat app CPI_CHECKSUM is : " +echo $CPI_CHECKSUM diff --git a/kotlin-samples/mgm-dynamic-network/Step4-more-member-onboard.sh b/kotlin-samples/mgm-dynamic-network/Step4-more-member-onboard.sh new file mode 100644 index 0000000..e531cf7 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/Step4-more-member-onboard.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/kotlin-samples/mgm-dynamic-network/register-mgm +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + + +echo "\n---Create a Member virtual node---" +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM +echo "\n" +read -p "Enter the X500_NAME from the returned body (Formatt: C=GB,L=London,O=Alice):" X500_NAME +curl --insecure -u admin:admin -d '{"request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "'$X500_NAME'"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the HOLDING_ID from the returned body:" HOLDING_ID +echo "HOLDING_ID:" $HOLDING_ID + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL'/keys/'$HOLDING_ID'/alias/'$HOLDING_ID'-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1' +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/LEDGER +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-ledger/category/LEDGER/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the LEDGER_KEY_ID from the returned body:" LEDGER_KEY_ID +echo "LEDGER_KEY_ID:" $LEDGER_KEY_ID + + +echo "\n---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$HOLDING_ID + + +echo "\n---Build registration context---" +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.session.key.signature.spec": "SHA256withECDSA", + "corda.ledger.keys.0.id": "'$LEDGER_KEY_ID'", + "corda.ledger.keys.0.signature.spec": "SHA256withECDSA", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' + + +echo "\n---Register Member VNode---" +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$HOLDING_ID/$REGISTRATION_ID +echo "\n" +curl --insecure -u admin:admin -X GET $API_URL/members/$HOLDING_ID + diff --git a/kotlin-samples/mgm-dynamic-network/build.gradle b/kotlin-samples/mgm-dynamic-network/build.gradle new file mode 100644 index 0000000..9708874 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/build.gradle @@ -0,0 +1,65 @@ +import static org.gradle.api.JavaVersion.VERSION_11 + +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'net.corda.cordapp.cordapp-configuration' + id 'org.jetbrains.kotlin.plugin.jpa' + id 'java' + id 'maven-publish' + id 'csde' +} + +allprojects { + group 'com.r3.developers.csdetemplate' + version '1.0-SNAPSHOT' + + def javaVersion = VERSION_11 + + // Declare the set of Kotlin compiler options we need to build a CorDapp. + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + allWarningsAsErrors = false + + // Specify the version of Kotlin that we are that we will be developing. + languageVersion = '1.7' + // Specify the Kotlin libraries that code is compatible with + apiVersion = '1.7' + // Note that we Need to use a version of Kotlin that will be compatible with the Corda API. + // Currently that is developed in Kotlin 1.7 so picking the same version ensures compatibility with that. + + // Specify the version of Java to target. + jvmTarget = javaVersion + + // Needed for reflection to work correctly. + javaParameters = true + + // -Xjvm-default determines how Kotlin supports default methods. + // JetBrains currently recommends developers use -Xjvm-default=all + // https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/-jvm-default/ + freeCompilerArgs += [ + "-Xjvm-default=all" + ] + } + } + + repositories { + // All dependencies are held in Maven Central + mavenCentral() + mavenLocal() + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } + +} + +publishing { + publications { + maven(MavenPublication) { + artifactId "corda-mgm" + groupId project.group + artifact jar + } + } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/build.gradle b/kotlin-samples/mgm-dynamic-network/buildSrc/build.gradle new file mode 100644 index 0000000..d21dbee --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/build.gradle @@ -0,0 +1,22 @@ + +plugins { + id 'groovy-gradle-plugin' + id 'java' +} + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' + + implementation "com.konghq:unirest-java:$unirestVersion" + implementation "com.konghq:unirest-objectmapper-jackson:$unirestVersion" + implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion" + + implementation "net.corda:corda-base:$cordaApiVersion" +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/gradle.properties b/kotlin-samples/mgm-dynamic-network/buildSrc/gradle.properties new file mode 100644 index 0000000..1d2a0ce --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/gradle.properties @@ -0,0 +1,6 @@ +jacksonVersion = 2.13.4 +unirestVersion=3.13.10 + +# todo: why is this set in two places. +cordaApiVersion=5.0.0.665-Gecko1.0 + diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/settings.gradle b/kotlin-samples/mgm-dynamic-network/buildSrc/settings.gradle new file mode 100644 index 0000000..86ac012 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/settings.gradle @@ -0,0 +1 @@ +// File intentionally left blank diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle new file mode 100644 index 0000000..a94ffa9 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle @@ -0,0 +1,248 @@ +// Note, IntelliJ does not recognise the imported Java Classes, hence they are +// highlighted in Red. However, they are recognised in the gradle compilation. + + +// todo: look at the declaration of the script variables, can they be combined with the declaration of the Project Context +// todo: investigate adding corda-cli to the class path then executing it directly - might not work as gradle has to set up the jar file, so its not their when you start. +// Todo: write a test flow runner helper function?? +// todo: rename deployCPIsHelper +// todo: add proper logging, rather than reading Stdout +// todo: add test corda running/live task +// todo: add a test to check docker is running and display error if not + halt start corda +// todo: add a clean corda task. +// todo: fix logging level and make it configurable. + + +import com.r3.csde.CordaLifeCycleHelper +import com.r3.csde.ProjectContext +import com.r3.csde.DeployCPIsHelper +import com.r3.csde.BuildCPIsHelper +import com.r3.csde.ProjectUtils +import com.r3.csde.CordaStatusQueries +import com.r3.csde.VNodesHelper +import com.r3.csde.NetworkConfig + +plugins { + id 'java-library' + id 'groovy' + id 'java' +} + + +configurations { + combinedWorker{ + canBeConsumed = false + canBeResolved= true + } + + myPostgresJDBC { + canBeConsumed = false + canBeResolved = true + } + + notaryServerCPB { + canBeConsumed = false + canBeResolved = true + } +} + +// Dependencies for supporting tools +dependencies { + combinedWorker "net.corda:corda-combined-worker:$combinedWorkerVersion" + myPostgresJDBC "org.postgresql:postgresql:$postgresqlVersion" + notaryServerCPB("com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-server:$cordaNotaryPluginsVersion") { + artifact { + classifier = 'package' + extension = 'cpb' + } + } + + implementation "org.codehaus.groovy:groovy-json:3.0.9" +} + +// task groupings +def cordaGroup = 'csde-corda' // corda lifecycle tasks +def cordappGroup = 'csde-cordapp' // tasks to build and deploy corDapps +def queriesGroup = 'csde-queries' // tasks which query corda status +def supportingGroup = 'supporting' // tasks which should be hidden from the csde user + + +def cordaBinDir = System.getenv("CSDE_CORDA_BIN") ?: System.getProperty('user.home') + "/.corda/corda5" +def cordaCliBinDir = System.getenv("CSDE_CORDA_CLI") ?:System.getProperty('user.home') + "/.corda/cli" +def cordaJDBCDir = cordaBinDir + "/jdbcDrivers" +def cordaNotaryServerDir = cordaBinDir + "/notaryserver" +def signingCertAlias="gradle-plugin-default-key" +// Get error if this is not a autotyped object +// def signingCertFName = "$rootDir/config/gradle-plugin-default-key.pem" +def signingCertFName = rootDir.toString() + "/config/gradle-plugin-default-key.pem" +def keystoreAlias = "my-signing-key" +def keystoreFName = devEnvWorkspace + "/signingkeys.pfx" +def keystoreCertFName = devEnvWorkspace + "/signingkey1.pem" +def combiWorkerPidCacheFile = devEnvWorkspace + "/CordaPID.dat" +// todo: can we rely on the build directory always being /workflow/build? aslo, is the +// workflow directory the correct place to build the cpb to. shoudl it be the main build directory. +def workflowBuildDir = rootDir.toString() + "/workflows/build" + + +// todo: Need to read things from cordapp plugin - the cordapp names will be changed by the user +def appCpiName = 'cpi name' +def notaryCpiName = 'CSDE Notary Server CPI' + + +// todo: there should be a better way to set up these project context variables. +def projectContext = new ProjectContext(project, + cordaClusterURL.toString(), + cordaRpcUser, + cordaRpcPasswd, + devEnvWorkspace, + // todo: why is this not obtained in the groovy def's abouve - its inconsistent. + new String("${System.getProperty("java.home")}/bin"), + dbContainerName, + cordaJDBCDir, + combiWorkerPidCacheFile, + signingCertAlias, + signingCertFName, + keystoreAlias, + keystoreFName, + keystoreCertFName, + appCpiName, + notaryCpiName, + devEnvWorkspace, + cordaCliBinDir, + cordaNotaryServerDir, + workflowBuildDir, + cordaNotaryPluginsVersion +) + +def networkConfig = new NetworkConfig("config/static-network-config.json") + +def utils = new ProjectUtils() + +// Initiate workspace folder + +tasks.register('projInit') { + group = supportingGroup + doLast { + mkdir devEnvWorkspace + } +} + + +// CordaLifeCycle tasks + +def cordaLifeCycle = new CordaLifeCycleHelper(projectContext) + +tasks.register("startCorda") { + group = cordaGroup + dependsOn('getDevCordaLite', 'getPostgresJDBC') + doLast { + mkdir devEnvWorkspace + cordaLifeCycle.startCorda() + } +} + +tasks.register("stopCorda") { + group = cordaGroup + doLast { + cordaLifeCycle.stopCorda() + } +} + +tasks.register("getPostgresJDBC") { + group = supportingGroup + doLast { + copy { + from configurations.myPostgresJDBC + into "$cordaJDBCDir" + } + } +} + +tasks.register("getDevCordaLite", Copy) { + group = supportingGroup + from configurations.combinedWorker + into cordaBinDir +} + + +// Corda status queries + +def cordaStatusQueries = new CordaStatusQueries(projectContext) + + +tasks.register('listVNodes') { + group = queriesGroup + doLast { + cordaStatusQueries.listVNodes() + } +} + +tasks.register('listCPIs') { + group = queriesGroup + doLast { + cordaStatusQueries.listCPIs() + } +} + +// Build CPI tasks + +def buildCPIsHelper = new BuildCPIsHelper(projectContext, networkConfig) + +tasks.register("1-createGroupPolicy") { + group = cordappGroup + dependsOn('projInit') + doLast { + buildCPIsHelper.createGroupPolicy() + } +} + +tasks.register("getNotaryServerCPB", Copy) { + group = supportingGroup + from configurations.notaryServerCPB + into cordaNotaryServerDir +} + +tasks.register('2-createKeystore') { + group = cordappGroup + dependsOn('projInit') + doLast { + buildCPIsHelper.createKeyStore() + } +} + +tasks.register('3-buildCPIs') { + group = cordappGroup + def dependsOnTasks = subprojects.collect {it.tasks.findByName("build") } + dependsOnTasks.add('1-createGroupPolicy') + dependsOnTasks.add('2-createKeystore') + dependsOnTasks.add('getNotaryServerCPB') + dependsOn dependsOnTasks + doLast{ + buildCPIsHelper.buildCPIs() + } +} + + +// deploy CPI tasks + +def deployCPIsHelper = new DeployCPIsHelper(projectContext) + +tasks.register('4-deployCPIs') { + group = cordappGroup + dependsOn('3-buildCPIs') + doLast { + deployCPIsHelper.deployCPIs() + } +} + +// Setup VNodes tasks + +def vNodesHelper = new VNodesHelper(projectContext, networkConfig ) + +tasks.register('5-vNodeSetup') { + group = cordappGroup + dependsOn('4-deployCPIs') + doLast { + vNodesHelper.vNodesSetup() + } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java new file mode 100644 index 0000000..6675087 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java @@ -0,0 +1,276 @@ +package com.r3.csde; + +import java.io.*; +import java.util.LinkedList; +import java.util.List; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class BuildCPIsHelper { + + public ProjectContext pc; + public ProjectUtils utils; + + public NetworkConfig config; + public BuildCPIsHelper(ProjectContext _pc, NetworkConfig _config) { + pc = _pc; + utils = new ProjectUtils(pc); + config = _config; + } + + public void createGroupPolicy() throws IOException { + + File groupPolicyFile = new File(String.format("%s/GroupPolicy.json", pc.devEnvWorkspace)); + File devnetFile = new File(pc.project.getRootDir() + "/" + config.getConfigFilePath()); + + + if (!groupPolicyFile.exists() || groupPolicyFile.lastModified() < devnetFile.lastModified()) { + + pc.out.println("createGroupPolicy: Creating a GroupPolicy"); + + List configX500Ids = config.getX500Names(); + LinkedList commandList = new LinkedList<>(); + + commandList.add(String.format("%s/java", pc.javaBinDir)); + commandList.add(String.format("-Dpf4j.pluginsDir=%s/plugins/", pc.cordaCliBinDir)); + commandList.add("-jar"); + commandList.add(String.format("%s/corda-cli.jar", pc.cordaCliBinDir)); + commandList.add("mgm"); + commandList.add("groupPolicy"); + for (String id : configX500Ids) { + commandList.add("--name"); + commandList.add(id); + } + commandList.add("--endpoint-protocol=1"); + commandList.add("--endpoint=http://localhost:1080"); + + ProcessBuilder pb = new ProcessBuilder(commandList); + pb.redirectErrorStream(true); + Process proc = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream())); + + // todo add exception catching + FileWriter fileWriter = new FileWriter(groupPolicyFile); + String line; + while (( line = reader.readLine()) != null){ + fileWriter.write(line + "\n"); + } + fileWriter.close(); + + } else { + pc.out.println("createPolicyTask: everything up to date; nothing to do."); + } + + } + + public void createKeyStore() throws IOException, InterruptedException { + + File keystoreFile = new File(pc.keystoreFName); + if(!keystoreFile.exists()) { + pc.out.println("createKeystore: Create a keystore"); + + generateKeyPair(); + addDefaultSigningKey(); + exportCert(); + + } else { + pc.out.println("createKeystore: keystore already created; nothing to do."); + } + + } + + private void generateKeyPair() throws IOException, InterruptedException { + + LinkedList cmdArray = new LinkedList<>(); + + cmdArray.add(pc.javaBinDir + "/keytool"); + cmdArray.add("-genkeypair"); + cmdArray.add("-alias"); + cmdArray.add(pc.keystoreAlias); + cmdArray.add("-keystore"); + cmdArray.add(pc.keystoreFName); + cmdArray.add("-storepass"); + cmdArray.add("keystore password"); + cmdArray.add("-dname"); + cmdArray.add("CN=CPI Example - My Signing Key, O=CorpOrgCorp, L=London, C=GB"); + cmdArray.add("-keyalg"); + cmdArray.add("RSA"); + cmdArray.add("-storetype"); + cmdArray.add("pkcs12"); + cmdArray.add("-validity"); + cmdArray.add("4000"); + + ProcessBuilder pb = new ProcessBuilder(cmdArray); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + + } + + private void addDefaultSigningKey() throws IOException, InterruptedException { + + LinkedList cmdArray = new LinkedList<>(); + + cmdArray.add(pc.javaBinDir + "/keytool"); + cmdArray.add("-importcert"); + cmdArray.add("-keystore"); + cmdArray.add(pc.keystoreFName); + cmdArray.add("-storepass"); + cmdArray.add("keystore password"); + cmdArray.add("-noprompt"); + cmdArray.add("-alias"); + cmdArray.add(pc.signingCertAlias); + cmdArray.add("-file"); + cmdArray.add(pc.signingCertFName); + + ProcessBuilder pb = new ProcessBuilder(cmdArray); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + } + + private void exportCert() throws IOException, InterruptedException { + + LinkedList cmdArray = new LinkedList<>(); + + cmdArray.add(pc.javaBinDir + "/keytool"); + cmdArray.add("-exportcert"); + cmdArray.add("-rfc"); + cmdArray.add("-alias"); + cmdArray.add(pc.keystoreAlias); + cmdArray.add("-keystore"); + cmdArray.add(pc.keystoreFName); + cmdArray.add("-storepass"); + cmdArray.add("keystore password"); + cmdArray.add("-file"); + cmdArray.add(pc.keystoreCertFName); + + ProcessBuilder pb = new ProcessBuilder(cmdArray); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + + } + + public void buildCPIs() throws IOException, InterruptedException, CsdeException { + createCorDappCPI(); + createNotaryCPI(); + } + + private void createCorDappCPI() throws IOException, InterruptedException, CsdeException { + + String appCPIFilePath = pc.workflowBuildDir + "/" + + pc.project.getRootProject().getName() + "-" + + pc.project.getVersion() + ".cpi"; + + File appCPIFile = new File(appCPIFilePath); + appCPIFile.delete(); + + File srcDir = new File(pc.workflowBuildDir + "/libs"); + File[] appCPBs = srcDir.listFiles(( x , name ) -> name.endsWith(".cpb")); + if (appCPBs == null) throw new CsdeException("Expecting exactly one CPB but no CPB found."); + if (appCPBs.length != 1) throw new CsdeException("Expecting exactly one CPB but more than one found."); + + pc.out.println("appCpbs:"); + pc.out.println(appCPBs[0].getAbsolutePath()); + + LinkedList commandList = new LinkedList<>(); + + commandList.add(String.format("%s/java", pc.javaBinDir)); + commandList.add(String.format("-Dpf4j.pluginsDir=%s/plugins/", pc.cordaCliBinDir)); + commandList.add("-jar"); + commandList.add(String.format("%s/corda-cli.jar", pc.cordaCliBinDir)); + commandList.add("package"); + commandList.add("create-cpi"); + commandList.add("--cpb"); + commandList.add(appCPBs[0].getAbsolutePath()); + commandList.add("--group-policy"); + commandList.add(pc.devEnvWorkspace + "/GroupPolicy.json"); + commandList.add("--cpi-name"); + commandList.add(pc.appCPIName); + commandList.add("--cpi-version"); + commandList.add(pc.project.getVersion().toString()); + commandList.add("--file"); + commandList.add(appCPIFilePath); + commandList.add("--keystore"); + commandList.add(pc.devEnvWorkspace + "/signingkeys.pfx"); + commandList.add("--storepass"); + commandList.add("keystore password"); + commandList.add("--key"); + commandList.add("my-signing-key"); // todo: should be passed as context property + + ProcessBuilder pb = new ProcessBuilder(commandList); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + +// todo: work out how to capture error code better than the following code + +// BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream())); +// File tempOutputFile = new File(String.format("%s/tempOutput.txt", pc.devEnvWorkspace)); +// tempOutputFile.delete(); +// FileWriter fileWriter = new FileWriter(tempOutputFile); +// String line; +// while (( line = reader.readLine()) != null){ +// fileWriter.write(line + "\n"); +// } +// fileWriter.close(); + + } + + private void createNotaryCPI() throws CsdeException, IOException, InterruptedException { + + String notaryCPIFilePath = pc.workflowBuildDir + "/" + + pc.notaryCPIName.replace(' ', '-').toLowerCase() + "-" + + pc.project.getVersion() + ".cpi"; + + File notaryCPIFile = new File(notaryCPIFilePath); + notaryCPIFile.delete(); + + File srcDir = new File(pc.cordaNotaryServiceDir); + File[] notaryCPBs = srcDir.listFiles(( x , name ) -> name.endsWith(".cpb") && name.contains(pc.cordaNotaryPluginsVersion)); + if (notaryCPBs == null) throw new CsdeException("Expecting exactly one notary CPB but no CPB found."); + if (notaryCPBs.length != 1) throw new CsdeException("Expecting exactly one notary CPB but more than one found."); + + pc.out.println("notaryCpbs:"); + pc.out.println(notaryCPBs[0]); + + LinkedList commandList = new LinkedList<>(); + + commandList.add(String.format("%s/java", pc.javaBinDir)); + commandList.add(String.format("-Dpf4j.pluginsDir=%s/plugins/", pc.cordaCliBinDir)); + commandList.add("-jar"); + commandList.add(String.format("%s/corda-cli.jar", pc.cordaCliBinDir)); + commandList.add("package"); + commandList.add("create-cpi"); + commandList.add("--cpb"); + commandList.add(notaryCPBs[0].getAbsolutePath()); + commandList.add("--group-policy"); + commandList.add(pc.devEnvWorkspace + "/GroupPolicy.json"); + commandList.add("--cpi-name"); + commandList.add(pc.notaryCPIName); + commandList.add("--cpi-version"); + commandList.add(pc.project.getVersion().toString()); + commandList.add("--file"); + commandList.add(notaryCPIFilePath); + commandList.add("--keystore"); + commandList.add(pc.devEnvWorkspace + "/signingkeys.pfx"); + commandList.add("--storepass"); + commandList.add("keystore password"); + commandList.add("--key"); + commandList.add("my-signing-key"); + + ProcessBuilder pb = new ProcessBuilder(commandList); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + + } + + // todo: this might be needed for improved logging + private void printCmdArray(LinkedList cmdArray) { + for (int i = 0; i < cmdArray.size(); i++) { + pc.out.print(cmdArray.get(i) + " "); + } + } + +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java new file mode 100644 index 0000000..daa78a6 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java @@ -0,0 +1,93 @@ +package com.r3.csde; + +import kong.unirest.Unirest; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.util.Scanner; + +/** + * Manages Bringing corda up, testing for liveness and taking corda down + */ +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class CordaLifeCycleHelper { + + ProjectContext pc; + ProjectUtils utils; + + public CordaLifeCycleHelper(ProjectContext _pc) { + pc = _pc; + utils = new ProjectUtils(pc); + Unirest.config().verifySsl(false); + } + + public void startCorda() throws IOException { + PrintStream pidStore = new PrintStream(new FileOutputStream(pc.cordaPidCache)); + File combinedWorkerJar = pc.project.getConfigurations().getByName("combinedWorker").getSingleFile(); + + // Manual version of the command to start postgres (for reference): + // docker run -d --rm -p5432:5432 --name CSDEpostgresql -e POSTGRES_DB=cordacluster -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password postgres:latest + + new ProcessBuilder( + "docker", + "run", "-d", "--rm", + "-p", "5432:5432", + "--name", pc.dbContainerName, + "-e", "POSTGRES_DB=cordacluster", + "-e", "POSTGRES_USER=postgres", + "-e", "POSTGRES_PASSWORD=password", + "postgres:latest").start(); + + // todo: we should poll for readiness not wait 10 seconds, see https://r3-cev.atlassian.net/browse/CORE-11626 + utils.rpcWait(10000); + + ProcessBuilder procBuild = new ProcessBuilder(pc.javaBinDir + "/java", + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", + "-DsecurityMangerEnabled=false", + "-Dlog4j.configurationFile=" + pc.project.getRootDir() + "/config/log4j2.xml", + "-Dco.paralleluniverse.fibers.verifyInstrumentation=true", + "-jar", + combinedWorkerJar.toString(), + "--instance-id=0", + "-mbus.busType=DATABASE", + "-spassphrase=password", + "-ssalt=salt", + "-ddatabase.user=user", + "-ddatabase.pass=password", + "-ddatabase.jdbc.url=jdbc:postgresql://localhost:5432/cordacluster", + "-ddatabase.jdbc.directory="+pc.JDBCDir); + + procBuild.redirectErrorStream(true); + Process proc = procBuild.start(); + pidStore.print(proc.pid()); + pc.out.println("Corda Process-id="+proc.pid()); + proc.getInputStream().transferTo(pc.out); + + // todo: we should poll for readiness before completing the startCorda task, see https://r3-cev.atlassian.net/browse/CORE-11625 + } + + + public void stopCorda() throws IOException, CsdeException { + File cordaPIDFile = new File(pc.cordaPidCache); + if(cordaPIDFile.exists()) { + Scanner sc = new Scanner(cordaPIDFile); + long pid = sc.nextLong(); + pc.out.println("pid to kill=" + pid); + + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + new ProcessBuilder("Powershell", "-Command", "Stop-Process", "-Id", Long.toString(pid), "-PassThru").start(); + } else { + new ProcessBuilder("kill", "-9", Long.toString(pid)).start(); + } + + Process proc = new ProcessBuilder("docker", "stop", pc.dbContainerName).start(); + + cordaPIDFile.delete(); + } + else { + throw new CsdeException("Cannot stop the Combined worker\nCached process ID file " + pc.cordaPidCache + " missing.\nWas the combined worker not started?"); + } + } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java new file mode 100644 index 0000000..95072ba --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java @@ -0,0 +1,64 @@ +package com.r3.csde; + +import kong.unirest.JsonNode; +import kong.unirest.Unirest; +import kong.unirest.json.JSONArray; +import kong.unirest.json.JSONObject; +import kong.unirest.HttpResponse; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class CordaStatusQueries { + + ProjectContext pc; + public CordaStatusQueries(ProjectContext _pc){ pc = _pc; } + + + public HttpResponse getVNodeInfo() { + Unirest.config().verifySsl(false); + return Unirest.get(pc.baseURL + "/api/v1/virtualnode/") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + } + public void listVNodesVerbose() { + HttpResponse vnodeResponse = getVNodeInfo(); + pc.out.println("VNodes:\n" + vnodeResponse.getBody().toPrettyString()); + } + + // X500Name, shorthash, cpiname + public void listVNodes() { + HttpResponse vnodeResponse = getVNodeInfo(); + + JSONArray virtualNodesJson = (JSONArray) vnodeResponse.getBody().getObject().get("virtualNodes"); + pc.out.println("X500 Name\tHolding identity short hash\tCPI Name"); + for(Object o: virtualNodesJson){ + if(o instanceof JSONObject) { + JSONObject idObj = ((JSONObject) o).getJSONObject("holdingIdentity"); + JSONObject cpiObj = ((JSONObject) o).getJSONObject("cpiIdentifier"); + pc.out.print("\"" + idObj.get("x500Name") + "\""); + pc.out.print("\t\"" + idObj.get("shortHash") + "\""); + pc.out.println("\t\"" + cpiObj.get("cpiName") + "\""); + } + } + } + + public HttpResponse getCpiInfo() { + Unirest.config().verifySsl(false); + return Unirest.get(pc.baseURL + "/api/v1/cpi/") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + } + + public void listCPIs() { + HttpResponse cpiResponse = getCpiInfo(); + JSONArray jArray = (JSONArray) cpiResponse.getBody().getObject().get("cpis"); + + for(Object o: jArray){ + if(o instanceof JSONObject) { + JSONObject idObj = ((JSONObject) o).getJSONObject("id"); + pc.out.print("cpiName=" + idObj.get("cpiName")); + pc.out.println(", cpiVersion=" + idObj.get("cpiVersion")); + } + } + } + +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java new file mode 100644 index 0000000..72f8fea --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java @@ -0,0 +1,10 @@ +package com.r3.csde; + +public class CsdeException extends Exception { + public CsdeException(String message, Throwable cause) { + super(message, cause); + } + public CsdeException(String message){ + super(message); + } +} \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java new file mode 100644 index 0000000..fe1362b --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java @@ -0,0 +1,187 @@ +package com.r3.csde; + +import kong.unirest.JsonNode; +import kong.unirest.Unirest; +import kong.unirest.json.JSONArray; +import kong.unirest.json.JSONObject; +import kong.unirest.HttpResponse; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.PrintStream; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_OK; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class DeployCPIsHelper { + + public DeployCPIsHelper() { + } + ProjectContext pc; + CordaStatusQueries queries; + ProjectUtils utils; + + public DeployCPIsHelper(ProjectContext _pc) { + pc = _pc; + queries = new CordaStatusQueries(pc); + utils = new ProjectUtils(pc); + } + + public void deployCPIs() throws FileNotFoundException, CsdeException{ + + uploadCertificate(pc.signingCertAlias, pc.signingCertFName); + uploadCertificate(pc.keystoreAlias, pc.keystoreCertFName); + + // todo: make consistent with other string building code - remove String.format + String appCPILocation = String.format("%s/%s-%s.cpi", + pc.workflowBuildDir, + pc.project.getName(), + pc.project.getVersion()); + deployCPI(appCPILocation, pc.appCPIName,pc.project.getVersion().toString()); + + String notaryCPILocation = String.format("%s/%s-%s.cpi", + pc.workflowBuildDir, + pc.notaryCPIName.replace(' ','-').toLowerCase(), + pc.project.getVersion()); + deployCPI(notaryCPILocation, + pc.notaryCPIName, + pc.project.getVersion().toString(), + "-NotaryServer" ); + + } + + public void uploadCertificate(String certAlias, String certFName) { + Unirest.config().verifySsl(false); + HttpResponse uploadResponse = Unirest.put(pc.baseURL + "/api/v1/certificates/cluster/code-signer") + .field("alias", certAlias) + .field("certificate", new File(certFName)) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + pc.out.println("Certificate/key upload, alias "+certAlias+" certificate/key file "+certFName); + pc.out.println(uploadResponse.getBody().toPrettyString()); + } + + public void forceuploadCPI(String cpiFName) throws FileNotFoundException, CsdeException { + forceuploadCPI(cpiFName, ""); + } + + public void forceuploadCPI(String cpiFName, String uploadStatusQualifier) throws FileNotFoundException, CsdeException { + Unirest.config().verifySsl(false); + HttpResponse jsonResponse = Unirest.post(pc.baseURL + "/api/v1/maintenance/virtualnode/forcecpiupload/") + .field("upload", new File(cpiFName)) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if(jsonResponse.getStatus() == HTTP_OK) { + String id = (String) jsonResponse.getBody().getObject().get("id"); + pc.out.println("get id:\n" +id); + HttpResponse statusResponse = uploadStatus(id); + + if (statusResponse.getStatus() == HTTP_OK) { + PrintStream cpiUploadStatus = new PrintStream(new FileOutputStream( + pc.CPIUploadStatusFName.replace(".json", uploadStatusQualifier + ".json" ))); + cpiUploadStatus.print(statusResponse.getBody()); + pc.out.println("Caching CPI file upload status:\n" + statusResponse.getBody()); + } else { + utils.reportError(statusResponse); + } + } + else { + utils.reportError(jsonResponse); + } + } + + private boolean uploadStatusRetry(HttpResponse response) { + int status = response.getStatus(); + JsonNode body = response.getBody(); + // Do not retry on success // todo: need to think through the possible outcomes here - what if the bodyTitle is null, it won't retry + if(status == HTTP_OK) { + // Keep retrying until we get "OK" may move through "Validating upload", "Persisting CPI" + return !(body.getObject().get("status").equals("OK")); + } + else if (status == HTTP_BAD_REQUEST){ + String bodyTitle = response.getBody().getObject().getString("title"); + return bodyTitle != null && bodyTitle.matches("No such requestId=[-0-9a-f]+"); + } + return false; + } + + public HttpResponse uploadStatus(String requestId) { + HttpResponse statusResponse = null; + do { + utils.rpcWait(1000); + statusResponse = Unirest + .get(pc.baseURL + "/api/v1/cpi/status/" + requestId + "/") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + pc.out.println("Upload status="+statusResponse.getStatus()+", status query response:\n"+statusResponse.getBody().toPrettyString()); + } + while(uploadStatusRetry(statusResponse)); + + return statusResponse; + } + + public void deployCPI(String cpiFName, String cpiName, String cpiVersion) throws FileNotFoundException, CsdeException { + deployCPI(cpiFName, cpiName, cpiVersion, ""); + } + + public void deployCPI(String cpiFName, + String cpiName, + String cpiVersion, + String uploadStatusQualifier) throws FileNotFoundException, CsdeException { + // todo: where is the primary instance declared? + Unirest.config().verifySsl(false); + + HttpResponse cpiResponse = queries.getCpiInfo(); + JSONArray jArray = (JSONArray) cpiResponse.getBody().getObject().get("cpis"); + + int matches = 0; + for(Object o: jArray.toList() ) { + if(o instanceof JSONObject) { + JSONObject idObj = ((JSONObject) o).getJSONObject("id"); + if((idObj.get("cpiName").toString().equals(cpiName) + && idObj.get("cpiVersion").toString().equals(cpiVersion))) { + matches++; + } + } + } + pc.out.println("Matching CPIS="+matches); + + if(matches == 0) { + HttpResponse uploadResponse = Unirest.post(pc.baseURL + "/api/v1/cpi/") + .field("upload", new File(cpiFName)) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + JsonNode body = uploadResponse.getBody(); + + int status = uploadResponse.getStatus(); + + pc.out.println("Upload Status:" + status); + pc.out.println("Pretty print the body\n" + body.toPrettyString()); + + // We expect the id field to be a string. + if (status == HTTP_OK) { + String id = (String) body.getObject().get("id"); + pc.out.println("get id:\n" + id); + + HttpResponse statusResponse = uploadStatus(id); + if (statusResponse.getStatus() == HTTP_OK) { + PrintStream cpiUploadStatus = new PrintStream(new FileOutputStream( + pc.CPIUploadStatusFName.replace(".json", uploadStatusQualifier + ".json" ))); + cpiUploadStatus.print(statusResponse.getBody()); + pc.out.println("Caching CPI file upload status:\n" + statusResponse.getBody()); + } else { + utils.reportError(statusResponse); + } + } else { + utils.reportError(uploadResponse); + } + } + else { + pc.out.println("CPI already uploaded doing a 'force' upload."); + forceuploadCPI(cpiFName, uploadStatusQualifier); + } + } + +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java new file mode 100644 index 0000000..e2aa2fe --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java @@ -0,0 +1,40 @@ +package com.r3.csde; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + + +/** + * This class reads the network config from the json file and makes it available as a list of VNodes. + */ +public class NetworkConfig { + + private List vNodes; + private String configFilePath; + + public NetworkConfig(String _configFilePath) throws CsdeException { + configFilePath = _configFilePath; + + ObjectMapper mapper = new ObjectMapper(); + try { + FileInputStream in = new FileInputStream(configFilePath); + vNodes = mapper.readValue(in, new TypeReference>() { + }); + } catch (Exception e) { + throw new CsdeException("Failed to read static network configuration file, with exception: " + e); + } + } + + String getConfigFilePath() { return configFilePath; } + + List getVNodes() { return vNodes; } + + List getX500Names() { + return vNodes.stream().map(vn -> vn.getX500Name()).collect(Collectors.toList()); + } + +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java new file mode 100644 index 0000000..6610697 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java @@ -0,0 +1,84 @@ +package com.r3.csde; + +import org.gradle.api.Project; +import java.io.PrintStream; +import java.util.Map; + +public class ProjectContext { + Project project; + String baseURL = "https://localhost:8888"; + String rpcUser = "admin"; + String rpcPasswd = "admin"; + String workspaceDir = "workspace"; + int retryWaitMs = 1000; + PrintStream out = System.out; + String CPIUploadStatusBaseName = "CPIFileStatus.json"; + String NotaryCPIUploadBaseName = "CPIFileStatus-NotaryServer.json"; + String CPIUploadStatusFName; + String NotaryCPIUploadStatusFName; + String javaBinDir; + String cordaPidCache = "CordaPIDCache.dat"; + String dbContainerName; + String JDBCDir; + String combinedWorkerBinRe; + Map notaryRepresentatives = null; + String signingCertAlias; + String signingCertFName; + String keystoreAlias; + String keystoreFName; + String keystoreCertFName; + String appCPIName; + String notaryCPIName; + String devEnvWorkspace; + String cordaCliBinDir; + String cordaNotaryServiceDir; + String workflowBuildDir; + String cordaNotaryPluginsVersion; + + public ProjectContext (Project inProject, + String inBaseUrl, + String inRpcUser, + String inRpcPasswd, + String inWorkspaceDir, + String inJavaBinDir, + String inDbContainerName, + String inJDBCDir, + String inCordaPidCache, + String inSigningCertAlias, + String inSigningCertFName, + String inKeystoreAlias, + String inKeystoreFName, + String inKeystoreCertFName, + String inAppCPIName, + String inNotaryCPIName, + String inDevEnvWorkspace, + String inCordaCLiBinDir, + String inCordaNotaryServiceDir, + String inWorkflowBuildDir, + String inCordaNotaryPluginsVersion + ) { + project = inProject; + baseURL = inBaseUrl; + rpcUser = inRpcUser; + rpcPasswd = inRpcPasswd; + workspaceDir = inWorkspaceDir; + javaBinDir = inJavaBinDir; + cordaPidCache = inCordaPidCache; + dbContainerName = inDbContainerName; + JDBCDir = inJDBCDir; + CPIUploadStatusFName = workspaceDir + "/" + CPIUploadStatusBaseName; + NotaryCPIUploadStatusFName = workspaceDir + "/" + NotaryCPIUploadBaseName; + signingCertAlias = inSigningCertAlias; + signingCertFName = inSigningCertFName; + keystoreAlias = inKeystoreAlias; + keystoreFName = inKeystoreFName; + keystoreCertFName = inKeystoreCertFName; + appCPIName = inAppCPIName; + notaryCPIName = inNotaryCPIName; + devEnvWorkspace = inDevEnvWorkspace; + cordaCliBinDir = inCordaCLiBinDir; + cordaNotaryServiceDir = inCordaNotaryServiceDir; + workflowBuildDir = inWorkflowBuildDir; + cordaNotaryPluginsVersion = inCordaNotaryPluginsVersion; + } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java new file mode 100644 index 0000000..dbc3dd8 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java @@ -0,0 +1,36 @@ +package com.r3.csde; + +import kong.unirest.HttpResponse; +import kong.unirest.JsonNode; + +import static java.lang.Thread.sleep; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class ProjectUtils { + + ProjectContext pc; + + ProjectUtils(ProjectContext _pc) { + pc = _pc; + } + + void rpcWait(int millis) { + try { + sleep(millis); + } + catch(InterruptedException e) { + throw new UnsupportedOperationException("Interrupts not supported.", e); + } + } + + public void reportError(HttpResponse response) throws CsdeException { + + pc.out.println("*** *** ***"); + pc.out.println("Unexpected response from Corda"); + pc.out.println("Status="+ response.getStatus()); + pc.out.println("*** Headers ***\n"+ response.getHeaders()); + pc.out.println("*** Body ***\n"+ response.getBody()); + pc.out.println("*** *** ***"); + throw new CsdeException("Error: unexpected response from Corda."); + } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java new file mode 100644 index 0000000..d77d85c --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java @@ -0,0 +1,25 @@ +package com.r3.csde; + + +/** + * This class is a representation of a Vnode used to express the vNodes required on the network. + */ + +public class VNode { + private String x500Name; + private String cpi; + + private String serviceX500Name; + + public VNode() { } + + public String getX500Name(){ return x500Name; } + public void setX500Name(String _x500Name) { x500Name = _x500Name; } + + public String getCpi() { return cpi; } + public void setCpi(String _cpi) { cpi = _cpi; } + + public String getServiceX500Name() { return serviceX500Name; } + public void setServiceX500Name(String _name) { serviceX500Name = _name; } + +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java new file mode 100644 index 0000000..4656ce9 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java @@ -0,0 +1,288 @@ +package com.r3.csde; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.r3.csde.dtos.*; +import kong.unirest.HttpResponse; +import kong.unirest.JsonNode; +import kong.unirest.Unirest; +import java.io.FileInputStream; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import static java.net.HttpURLConnection.HTTP_OK; + +/** + * The VNodesHelper class is used to create and register the Vnodes specified in the static-network-config.json file. + */ + +public class VNodesHelper { + private ProjectContext pc; + private ProjectUtils utils; + private NetworkConfig config; + private ObjectMapper mapper; + + public VNodesHelper(ProjectContext _pc, NetworkConfig _config) { + pc = _pc; + utils = new ProjectUtils(pc); + config = _config; + Unirest.config().verifySsl(false); + mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Entry point for setting up vnodes, called from the 5-vNodeSetup task in csde.gradle + */ + public void vNodesSetup() throws CsdeException { + List requiredNodes = config.getVNodes(); + createVNodes(requiredNodes); + registerVNodes(requiredNodes); + } + + /** + * Creates vnodes specified in the config if they don't already exist. + * @param requiredNodes represents the list of VNodes as specified in the network Config json file (static-network-config.json) + */ + private void createVNodes(List requiredNodes) throws CsdeException { + + // Get existing Nodes. + List existingVNodes = getExistingNodes(); + + // Check if each required vnode already exist, if not create it. + for (VNode vn : requiredNodes) { + // Match on x500 and + List matches = existingVNodes + .stream() + .filter(existing -> + existing.getHoldingIdentity().getX500Name().equals( vn.getX500Name()) && + existing.getCpiIdentifier().getCpiName().equals(vn.getCpi())) + .collect(Collectors.toList()); + + if (matches.size() == 0) { + createVNode(vn); + } + } + } + + /** + * Gets a list of the virtual nodes which have already been created. + * @return a list of the virtual nodes which have already been created. + */ + private List getExistingNodes () throws CsdeException { + + HttpResponse response = Unirest.get(pc.baseURL + "/api/v1/virtualnode") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) { + throw new CsdeException("Failed to get Existing vNodes, response status: "+ response.getStatus()); + } + + try { + return mapper.readValue(response.getBody().toString(), VirtualNodesDTO.class).getVirtualNodes(); + } catch (Exception e){ + throw new CsdeException("Failed to get Existing vNodes with exception: " + e); + } + } + + /** + * Creates a vnode on the Corda cluster from the VNode info. + * @param vNode represents a virtual node using VNode Class. + */ + private void createVNode(VNode vNode) throws CsdeException { + + pc.out.println("Creating virtual node for "+ vNode.getX500Name()); + + // Reads the current CPIFileChecksum value and checks it has been uploaded. + String cpiCheckSum = getCpiCheckSum(vNode); + if (!checkCpiUploaded(cpiCheckSum)) throw new CsdeException("Cpi " + cpiCheckSum + " not uploaded."); + + // Creates the vnode on Cluster + HttpResponse response = Unirest.post(pc.baseURL + "/api/v1/virtualnode") + .body("{ \"request\" : { \"cpiFileChecksum\": \"" + cpiCheckSum + "\", \"x500Name\": \"" + vNode.getX500Name() + "\" } }") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) { + throw new CsdeException("Creation of virtual node failed with response status: " + response.getStatus()); + } + } + + /** + * Reads the latest CPI checksums from file. + * @param vNode represents a virtual node using VNode Class. + */ + private String getCpiCheckSum(VNode vNode) throws CsdeException { + + try { + String file = (vNode.getServiceX500Name() == null) ? pc.CPIUploadStatusFName : pc.NotaryCPIUploadStatusFName; + FileInputStream in = new FileInputStream(file); + CPIFileStatusDTO statusDTO = mapper.readValue(in, CPIFileStatusDTO.class); + return statusDTO.getCpiFileChecksum().toString(); + } catch (Exception e) { + throw new CsdeException("Failed to read CPI checksum from file, with error: " + e); + } + } + + private boolean checkCpiUploaded(String cpiCheckSum) throws CsdeException { + + HttpResponse response = Unirest.get(pc.baseURL + "/api/v1/cpi") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) { + throw new CsdeException("Failed to check cpis, response status: " + response.getStatus()); + } + + try { + GetCPIsResponseDTO cpisResponse = mapper.readValue( + response.getBody().toString(), GetCPIsResponseDTO.class); + + for (CpiMetadataDTO cpi: cpisResponse.getCpis()) { + if (Objects.equals(cpi.getCpiFileChecksum(), cpiCheckSum)) { + return true; + } + } + // Returns false if no cpis were returned or the cpiCheckSum didnt' match ay results. + return false; + } catch (Exception e) { + throw new CsdeException("Failed to check cpis with exception: " + e); + } + } + + /** + * Checks if the required virtual nodes have been registered and if not registers them. + * @param requiredNodes represents the list of VNodes as specified in the network Config json file (static-network-config.json) + */ + private void registerVNodes(List requiredNodes) throws CsdeException { + + // There appears to be a delay between the successful post /virtualnodes synchronous call and the + // vnodes being returned in the GET /virtualnodes call. Putting a thread wait here as a quick fix + // as this will move to async mechanism post beta2. + utils.rpcWait(2000); + List existingVNodes = getExistingNodes(); + + for (VNode vn: requiredNodes) { + // Match on x500 and cpi name + List matches = existingVNodes + .stream() + .filter(existing -> + existing.getHoldingIdentity().getX500Name().equals( vn.getX500Name()) && + existing.getCpiIdentifier().getCpiName().equals(vn.getCpi())) + .collect(Collectors.toList()); + + if (matches.size() == 0) { + throw new CsdeException("Registration failed because virtual node for '" + vn.getX500Name() + "' not found."); + } else if (matches.size() >1 ) { + throw new CsdeException(("Registration failed because more than virtual node for '" + vn.getX500Name() + "'")); + } + + String shortHash = matches.get(0).getHoldingIdentity().getShortHash(); + + if (!checkVNodeIsRegistered(shortHash)) { + registerVnode(vn, shortHash); + } + } + } + + /** + * Registers a virtual node. + * @param vnode represents the vnode to be registered. + * @param shortHash the shortHash of the virtual node to register. + */ + private void registerVnode(VNode vnode, String shortHash) throws CsdeException { + + pc.out.println("Registering vNode: " + vnode.getX500Name() + " with shortHash: " + shortHash); + + // Configure the registration body (notary vs non notary) + String registrationBody; + if (vnode.getServiceX500Name() == null) { + registrationBody = + "{ \"action\" : \"requestJoin\"," + + " \"context\" : {" + + " \"corda.key.scheme\" : \"CORDA.ECDSA.SECP256R1\" } }"; + } else { + registrationBody = + "{ \"action\" : \"requestJoin\"," + + " \"context\" : {" + + " \"corda.key.scheme\" : \"CORDA.ECDSA.SECP256R1\", " + + " \"corda.roles.0\" : \"notary\", " + + " \"corda.notary.service.name\" : \"" + vnode.getServiceX500Name() + "\", " + + " \"corda.notary.service.plugin\" : \"net.corda.notary.NonValidatingNotary\" } }"; + } + + HttpResponse response = Unirest.post(pc.baseURL + "/api/v1/membership/" + shortHash) + .body(registrationBody) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() == HTTP_OK) { + pc.out.println("Membership requested for node " + shortHash); + } else { + throw new CsdeException("Failed to register virtual node " + shortHash + ", response status: " + response.getStatus() ); + } + + // wait until Vnode registered + pollForRegistration(shortHash, 30000, 1000); + + } + + /** + * Checks if a virtual node with given shortHash has been registered + * @param shortHash shortHash of the node which is being checked. + * @return returns true if the vnode is registered + */ + private boolean checkVNodeIsRegistered(String shortHash) throws CsdeException { + + // Queries registration status for vnode. + HttpResponse response = Unirest.get(pc.baseURL + "/api/v1/membership/" + shortHash) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) + throw new CsdeException("Failed to check registration status for virtual node '" + shortHash + + "' response status: " + response.getStatus()); + + try { + // If the response body is not empty check all previous requests for an "APPROVED" + if (!response.getBody().getArray().isEmpty()) { + List requests = mapper.readValue( + response.getBody().toString(), new TypeReference<>() { + }); + for (RegistrationRequestProgressDTO request : requests) { + if (Objects.equals(request.getRegistrationStatus(), "APPROVED")) { + return true; + } + } + } + // Returns false if array was empty or "APPROVED" wasn't found + return false; + + } catch (Exception e) { + throw new CsdeException("Failed to check registration status for " + shortHash + + " with exception " + e); + } + } + + /** + * Polls for registration of a vnode + * @param shortHash short hash of the node which is being poled for + * @param duration the number of milliseconds before the pollign times out + * @param cooldown the number of milliseconds in between poll attempts. + */ + private void pollForRegistration(String shortHash, int duration, int cooldown) throws CsdeException { + + int timer = 0; + while (timer < duration ) { + if (checkVNodeIsRegistered(shortHash)){ + pc.out.println("Vnode " + shortHash +" registered."); + return; + } + utils.rpcWait(cooldown); + timer += cooldown; + } + throw new CsdeException("Vnode " + shortHash + " failed to register in " + duration + " milliseconds"); + } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java new file mode 100644 index 0000000..1c6e118 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java @@ -0,0 +1,16 @@ +package com.r3.csde.dtos; + +public class CPIFileStatusDTO { + private String status; + private String cpiFileChecksum; + + public CPIFileStatusDTO() {} + + public String getStatus() { return status; } + + public void setStatus(String status) { this.status = status; } + + public String getCpiFileChecksum() { return cpiFileChecksum; } + + public void setCpiFileChecksum(String cpiFileChecksum) { this.cpiFileChecksum = cpiFileChecksum; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java new file mode 100644 index 0000000..e5d5dde --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java @@ -0,0 +1,23 @@ +package com.r3.csde.dtos; + +public class CpiIdentifierDTO { + + // Note, these DTOs don't cover all returned values, just the ones required for CSDE + private String cpiName; + private String cpiVersion; + private String signerSummaryHash; + + public CpiIdentifierDTO() { } + + public String getCpiName() { return cpiName; } + + public void setCpiName(String cpiName) { this.cpiName = cpiName; } + + public String getCpiVersion() { return cpiVersion; } + + public void setCpiVersion(String cpiVersion) { this.cpiVersion = cpiVersion; } + + public String getSignerSummaryHash() { return signerSummaryHash; } + + public void setSignerSummaryHash(String signerSummaryHash) { this.signerSummaryHash = signerSummaryHash; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java new file mode 100644 index 0000000..ef89c7d --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java @@ -0,0 +1,17 @@ +package com.r3.csde.dtos; + +public class CpiMetadataDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private String cpiFileChecksum; + private CpiIdentifierDTO id; + + public CpiMetadataDTO() {} + + public String getCpiFileChecksum() { return cpiFileChecksum; } + + public void setCpiFileChecksum(String cpiFileChecksum) { this.cpiFileChecksum = cpiFileChecksum; } + + public CpiIdentifierDTO getId() { return id; } + + public void setId(CpiIdentifierDTO id) { this.id = id; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java new file mode 100644 index 0000000..a16e9a1 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java @@ -0,0 +1,14 @@ +package com.r3.csde.dtos; + +import java.util.List; + +public class GetCPIsResponseDTO { + + private List cpis; + + public GetCPIsResponseDTO() {} + + public List getCpis() { return cpis; } + + public void setCpis(List cpis) { this.cpis = cpis; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java new file mode 100644 index 0000000..48c12c0 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java @@ -0,0 +1,27 @@ +package com.r3.csde.dtos; + +public class HoldingIdentityDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private String fullHash; + private String groupId; + private String shortHash; + private String x500Name; + + public HoldingIdentityDTO() {} + + public String getFullHash() { return fullHash; } + + public void setFullHash(String fullHash) { this.fullHash = fullHash; } + + public String getGroupId() { return groupId; } + + public void setGroupID(String groupID) { this.groupId = groupId; } + + public String getShortHash() { return shortHash; } + + public void setShortHash(String shortHash) { this.shortHash = shortHash; } + + public String getX500Name() { return x500Name; } + + public void setX500Name(String x500Name) { this.x500Name = x500Name; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java new file mode 100644 index 0000000..b3e63b0 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java @@ -0,0 +1,17 @@ +package com.r3.csde.dtos; + +public class RegistrationRequestProgressDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private String registrationStatus; + private String reason; + + public RegistrationRequestProgressDTO() {} + + public String getRegistrationStatus() { return registrationStatus; } + + public void setRegistrationStatus(String registrationStatus) { this.registrationStatus = registrationStatus; } + + public String getReason() { return reason; } + + public void setReason(String reason) { this.reason = reason; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java new file mode 100644 index 0000000..152cf3e --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java @@ -0,0 +1,17 @@ +package com.r3.csde.dtos; + +public class VirtualNodeInfoDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private HoldingIdentityDTO holdingIdentity; + private CpiIdentifierDTO cpiIdentifier; + + public VirtualNodeInfoDTO() {} + + public HoldingIdentityDTO getHoldingIdentity() { return holdingIdentity; } + + public void setHoldingIdentity(HoldingIdentityDTO holdingIdentity) { this.holdingIdentity = holdingIdentity; } + + public CpiIdentifierDTO getCpiIdentifier() { return cpiIdentifier; } + + public void setCpiIdentifier(CpiIdentifierDTO cpiIdentifier) { this.cpiIdentifier = cpiIdentifier; } +} diff --git a/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java new file mode 100644 index 0000000..52c00fe --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java @@ -0,0 +1,16 @@ +package com.r3.csde.dtos; + +import com.r3.csde.dtos.VirtualNodeInfoDTO; + +import java.util.List; + +public class VirtualNodesDTO { + + private List virtualNodes; + + public VirtualNodesDTO() {} + + public List getVirtualNodes() { return virtualNodes; } + + public void setVirtualNodes(List virtualNodes) { this.virtualNodes = virtualNodes; } +} diff --git a/kotlin-samples/mgm-dynamic-network/clean.sh b/kotlin-samples/mgm-dynamic-network/clean.sh new file mode 100644 index 0000000..f6f6851 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/clean.sh @@ -0,0 +1,15 @@ +#!/bin/sh +rm -rf ./workspace +rm -rf ./logs + +echo "---Clean Up Env---" +WORK_DIR=./register-mgm +cd $WORK_DIR + +rm signingkey1.pem +rm signingkeys.pfx +rm mgm.cpi +rm request1.csr +rm gradle-plugin-default-key.pem + +rm -rf register-member \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem b/kotlin-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem new file mode 100644 index 0000000..5294bbd --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC +MQ8wDQYDVQQHDAZMb25kb24xDjAMBgNVBAoMBUNvcmRhMQswCQYDVQQLDAJSMzEe +MBwGA1UEAwwVQ29yZGEgRGV2IENvZGUgU2lnbmVyMB4XDTIwMDYyNTE4NTI1NFoX +DTMwMDYyMzE4NTI1NFowWzELMAkGA1UEBhMCR0IxDzANBgNVBAcTBkxvbmRvbjEO +MAwGA1UEChMFQ29yZGExCzAJBgNVBAsTAlIzMR4wHAYDVQQDExVDb3JkYSBEZXYg +Q29kZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQDjSJtzQ+ldDFt +pHiqdSJebOGPZcvZbmC/PIJRsZZUF1bl3PfMqyG3EmAe0CeFAfLzPQtf2qTAnmJj +lGTkkQhxo0MwQTATBgNVHSUEDDAKBggrBgEFBQcDAzALBgNVHQ8EBAMCB4AwHQYD +VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh +ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL +zU+Rc5yMtcOY4/moZUq36r0Ilg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/config/log4j2.xml b/kotlin-samples/mgm-dynamic-network/config/log4j2.xml new file mode 100644 index 0000000..909222c --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/config/log4j2.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/config/static-network-config.json b/kotlin-samples/mgm-dynamic-network/config/static-network-config.json new file mode 100644 index 0000000..9adde9b --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/config/static-network-config.json @@ -0,0 +1,23 @@ +[ + { + "x500Name" : "CN=Alice, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=Charlie, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=Dave, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=NotaryRep1, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "CSDE Notary Server CPI", + "serviceX500Name": "CN=NotaryService, OU=Test Dept, O=R3, L=London, C=GB" + } +] diff --git a/kotlin-samples/mgm-dynamic-network/contracts/build.gradle b/kotlin-samples/mgm-dynamic-network/contracts/build.gradle new file mode 100644 index 0000000..52ca6f0 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/contracts/build.gradle @@ -0,0 +1,88 @@ + +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // This are shared so should be here. + // Dependencies Required By Test Tooling + // Todo: these are commented out as the simulator UTXO work has not been merged into Gecko yet. +// testImplementation "net.corda:corda-simulator-api:$simulatorVersion" +// testRuntimeOnly "net.corda:corda-simulator-runtime:$simulatorVersion" + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:2.0.0" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by exmaple tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + contract { + name "ContractsModuleNameHere" + versionId 1 + vendor "VendorNameHere" + } +} + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.kt b/kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.kt new file mode 100644 index 0000000..285a43b --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.kt @@ -0,0 +1,62 @@ +package com.r3.developers.csdetemplate.utxoexample.contracts + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.ledger.utxo.Command +import net.corda.v5.ledger.utxo.Contract +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction + +class ChatContract: Contract { + + // Command Class used to indicate that the transaction should start a new chat. + class Create: Command + // Command Class used to indicate that the transaction should append a new ChatState to an existing chat. + class Update: Command + + // verify() function is used to apply contract rules to the transaction. + override fun verify(transaction: UtxoLedgerTransaction) { + + // Ensures that there is only one command in the transaction + val command = transaction.commands.singleOrNull() ?: throw CordaRuntimeException("Requires a single command.") + + // Applies a universal constraint (applies to all transactions irrespective of command) + "The output state should have two and only two participants." using { + val output = transaction.outputContractStates.first() as ChatState + output.participants.size== 2 + } + // Switches case based on the command + when(command) { + // Rules applied only to transactions with the Create Command. + is Create -> { + "When command is Create there should be no input states." using (transaction.inputContractStates.isEmpty()) + "When command is Create there should be one and only one output state." using (transaction.outputContractStates.size == 1) + } + // Rules applied only to transactions with the Update Command. + is Update -> { + "When command is Update there should be one and only one input state." using (transaction.inputContractStates.size == 1) + "When command is Update there should be one and only one output state." using (transaction.outputContractStates.size == 1) + + val input = transaction.inputContractStates.single() as ChatState + val output = transaction.outputContractStates.single() as ChatState + "When command is Update id must not change." using (input.id == output.id) + "When command is Update chatName must not change." using (input.chatName == output.chatName) + "When command is Update participants must not change." using ( + input.participants.toSet().intersect(output.participants.toSet()).size == 2) + } + else -> { + throw CordaRuntimeException("Command not allowed.") + } + } + } + + // Helper function to allow writing constraints in the Corda 4 '"text" using (boolean)' style + private infix fun String.using(expr: Boolean) { + if (!expr) throw CordaRuntimeException("Failed requirement: $this") + } + + // Helper function to allow writing constraints in '"text" using {lambda}' style where the last expression + // in the lambda is a boolean. + private infix fun String.using(expr: () -> Boolean) { + if (!expr.invoke()) throw CordaRuntimeException("Failed requirement: $this") + } +} \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/states/ChatState.kt b/kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/states/ChatState.kt new file mode 100644 index 0000000..2fc17c9 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/contracts/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/states/ChatState.kt @@ -0,0 +1,37 @@ +package com.r3.developers.csdetemplate.utxoexample.states + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.utxo.BelongsToContract +import net.corda.v5.ledger.utxo.ContractState +import java.security.PublicKey +import java.util.* + + +// The ChatState represents data stored on ledger. A chat consists of a linear series of messages between two +// participants and is represented by a UUID. Any given pair of participants can have multiple chats +// Each ChatState stores one message between the two participants in the chat. The backchain of ChatStates +// represents the history of the chat. + +@BelongsToContract(ChatContract::class) +data class ChatState( + // Unique identifier for the chat. + val id : UUID = UUID.randomUUID(), + // Non-unique name for the chat. + val chatName: String, + // The MemberX500Name of the participant who sent the message. + val messageFrom: MemberX500Name, + // The message + val message: String, + // The participants to the chat, represented by their public key. + private val participants: List) : ContractState { + + override fun getParticipants(): List { + return participants + } + + // Helper function to create a new ChatState from the previous (input) ChatState. + fun updateMessage(messageFrom: MemberX500Name, message: String) = + copy(messageFrom = messageFrom, message = message) +} + diff --git a/kotlin-samples/mgm-dynamic-network/gradle.properties b/kotlin-samples/mgm-dynamic-network/gradle.properties new file mode 100644 index 0000000..4bd8d38 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/gradle.properties @@ -0,0 +1,43 @@ +kotlin.code.style=official + +# Specify the version of the Corda-API to use. +# This needs to match the version supported by the Corda Cluster the CorDapp will run on. + +cordaApiVersion=5.0.0.665-Gecko1.0 + +# Settings For Development Utilities +combinedWorkerVersion=5.0.0.0-Gecko1.0 + +# Specify the version of the notary plugins to use. +# Currently packaged as part of corda-runtime-os, so should be set to a corda-runtime-os version. +cordaNotaryPluginsVersion=5.0.0.0-Gecko1.0 + + + +# Specify the version of the cordapp-cpb and cordapp-cpk plugins +cordaPluginsVersion=7.0.1 + +# For the time being this just needs to be set to a dummy value. +platformVersion = 999 + +# Version of Kotlin to use. +# We recommend using a version close to that used by Corda-API. +kotlinVersion = 1.7.21 + +# Do not use default dependencies. +kotlin.stdlib.default.dependency=false + +# Test Tooling Dependency Versions +junitVersion = 5.8.2 +mockitoKotlinVersion=4.0.0 +mockitoVersion=4.6.1 +hamcrestVersion=2.2 + +postgresqlVersion=42.4.3 + +cordaClusterURL=https://localhost:8888 +cordaRpcUser=admin +cordaRpcPasswd=admin +devEnvWorkspace=workspace +dbContainerName=CSDEpostgresql + diff --git a/kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.jar b/kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..41d9927a4d4fb3f96a785543079b8df6723c946b GIT binary patch literal 59821 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 literal 0 HcmV?d00001 diff --git a/kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties b/kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5ec4b8e --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kotlin-samples/mgm-dynamic-network/gradlew b/kotlin-samples/mgm-dynamic-network/gradlew new file mode 100755 index 0000000..744e882 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/kotlin-samples/mgm-dynamic-network/gradlew.bat b/kotlin-samples/mgm-dynamic-network/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json b/kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json new file mode 100644 index 0000000..6147c89 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json @@ -0,0 +1,6 @@ +{ + "fileFormatVersion" : 1, + "groupId" : "CREATE_ID", + "registrationProtocol" :"net.corda.membership.impl.registration.dynamic.mgm.MGMRegistrationService", + "synchronisationProtocol": "net.corda.membership.impl.synchronisation.MgmSynchronisationServiceImpl" +} diff --git a/kotlin-samples/mgm-dynamic-network/settings.gradle b/kotlin-samples/mgm-dynamic-network/settings.gradle new file mode 100644 index 0000000..5b92951 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + // Declare the repositories where plugins are stored. + repositories { + gradlePluginPortal() + mavenCentral() + mavenLocal() + } + + // The plugin dependencies with versions of the plugins congruent with the specified CorDapp plugin version, + // Corda API version, and Kotlin version. + plugins { + id 'net.corda.plugins.cordapp-cpk2' version cordaPluginsVersion + id 'net.corda.plugins.cordapp-cpb2' version cordaPluginsVersion + id 'net.corda.cordapp.cordapp-configuration' version cordaApiVersion + id 'org.jetbrains.kotlin.jvm' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.jpa' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.allopen' version kotlinVersion + } +} + +// Root project name, used in naming the project as a whole and used in naming objects built by the project. +rootProject.name = 'mgm-dynamic-network' +include ':workflows' +include ':contracts' + diff --git a/kotlin-samples/mgm-dynamic-network/test.sh b/kotlin-samples/mgm-dynamic-network/test.sh new file mode 100644 index 0000000..5e70356 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/test.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +echo "---Set Env---" + + +WORK_DIR=./hello +mkdir -p "$WORK_DIR" + + diff --git a/kotlin-samples/mgm-dynamic-network/workflows/build.gradle b/kotlin-samples/mgm-dynamic-network/workflows/build.gradle new file mode 100644 index 0000000..4196b47 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/build.gradle @@ -0,0 +1,89 @@ +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + // From other subprojects: + cordapp project(':contracts') + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // Dependencies Required By Simulator Tests + // Todo: these are commented out as the simulator UTXO work has not been merged into Gecko yet. +// testImplementation "net.corda:corda-simulator-api:$simulatorVersion" +// testRuntimeOnly "net.corda:corda-simulator-runtime:$simulatorVersion" + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:2.0.0" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by exmaple tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" + +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + workflow { + name "WorkflowsModuleNameHere" + versionId 1 + vendor "VendorNameHere" + } +} + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.kt new file mode 100644 index 0000000..6819342 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.kt @@ -0,0 +1,113 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +import net.corda.v5.application.flows.* +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.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.common.Party +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.Instant + +// A class to hold the deserialized arguments required to start the flow. +data class CreateNewChatFlowArgs(val chatName: String, val message: String, val otherMember: String) + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class CreateNewChatFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + // FlowEngine service is required to run SubFlows. + @CordaInject + lateinit var flowEngine: FlowEngine + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("CreateNewChatFlow.call() called") + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, CreateNewChatFlowArgs::class.java) + + // Get MemberInfos for the Vnode running the flow and the otherMember. + // Good practice in Kotlin CorDapps is to only throw RuntimeException. + // Note, in Java CorDapps only unchecked RuntimeExceptions can be thrown not + // declared checked exceptions as this changes the method signature and breaks override. + val myInfo = memberLookup.myInfo() + val otherMember = memberLookup.lookup(MemberX500Name.parse(flowArgs.otherMember)) ?: + throw CordaRuntimeException("MemberLookup can't find otherMember specified in flow arguments.") + + // Create the ChatState from the input arguments and member information. + val chatState = ChatState( + chatName = flowArgs.chatName, + messageFrom = myInfo.name, + message = flowArgs.message, + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + // Obtain the notary. + val notary = notaryLookup.notaryServices.single() + + // Use UTXOTransactionBuilder to build up the draft transaction. + val txBuilder= ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notary.publicKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + val signedTransaction = txBuilder.toSignedTransaction() + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(FinalizeChatSubFlow(signedTransaction, otherMember.name)) + + + } + // Catch any exceptions, log them and rethrow the exception. + catch (e: Exception) { + log.warn("Failed to process utxo flow for request body '$requestBody' because:'${e.message}'") + throw e + } + } +} + + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} + */ \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.kt new file mode 100644 index 0000000..d5d91f2 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.kt @@ -0,0 +1,103 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +import net.corda.v5.application.flows.* +import net.corda.v5.application.messaging.FlowMessaging +import net.corda.v5.application.messaging.FlowSession +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.utxo.UtxoLedgerService +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction +import org.slf4j.LoggerFactory + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +// @InitiatingFlow declares the protocol which will be used to link the initiator to the responder. +@InitiatingFlow(protocol = "finalize-chat-protocol") +class FinalizeChatSubFlow(private val signedTransaction: UtxoSignedTransaction, private val otherMember: MemberX500Name): SubFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @CordaInject + lateinit var flowMessaging: FlowMessaging + + @Suspendable + override fun call(): String { + + log.info("FinalizeChatFlow.call() called") + + // Initiates a session with the other Member. + val session = flowMessaging.initiateFlow(otherMember) + + return try { + // Calls the Corda provided finalise() function which gather signatures from the counterparty, + // notarises the transaction and persists the transaction to each party's vault. + // On success returns the id of the transaction created. (This is different to the ChatState id) + val finalizedSignedTransaction = ledgerService.finalize( + signedTransaction, + listOf(session) + ) + // Returns the transaction id converted to a string. + finalizedSignedTransaction.id.toString().also { + log.info("Success! Response: $it") + } + } + // Soft fails the flow and returns the error message without throwing a flow exception. + catch (e: Exception) { + log.warn("Finality failed", e) + "Finality failed, ${e.message}" + } + } +} + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +//@InitiatingBy declares the protocol which will be used to link the initiator to the responder. +@InitiatedBy(protocol = "finalize-chat-protocol") +class FinalizeChatResponderFlow: ResponderFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(session: FlowSession) { + + log.info("FinalizeChatResponderFlow.call() called") + + try { + // Calls receiveFinality() function which provides the responder to the finalise() function + // in the Initiating Flow. Accepts a lambda validator containing the business logic to decide whether + // responder should sign the Transaction. + val finalizedSignedTransaction = ledgerService.receiveFinality(session) { ledgerTransaction -> + + // Note, this exception will only be shown in the logs if Corda Logging is set to debug. + val state = ledgerTransaction.getOutputStates(ChatState::class.java).singleOrNull() ?: + throw CordaRuntimeException("Failed verification - transaction did not have exactly one output ChatState.") + + // Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions + // to check whether to sign the transaction. + checkForBannedWords(state.message) + checkMessageFromMatchesCounterparty(state, session.counterparty) + + log.info("Verified the transaction- ${ledgerTransaction.id}") + } + log.info("Finished responder flow - ${finalizedSignedTransaction.id}") + } + // Soft fails the flow and log the exception. + catch (e: Exception) { + log.warn("Exceptionally finished responder flow", e) + } + } +} \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.kt new file mode 100644 index 0000000..d79e351 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.kt @@ -0,0 +1,116 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +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.marshalling.JsonMarshallingService +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.ledger.utxo.StateAndRef +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.util.* + +// A class to hold the deserialized arguments required to start the flow. +data class GetChatFlowArgs(val id: UUID, val numberOfRecords: Int) + +// A class to pair the messageFrom and message together. +data class MessageAndSender(val messageFrom: String, val message: String) + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class GetChatFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("GetChatFlow.call() called") + + // Obtain the deserialized input arguments to the flow from the requestBody. + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, GetChatFlowArgs::class.java) + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + val states = ledgerService.findUnconsumedStatesByType(ChatState::class.java) + val state = states.singleOrNull {it.state.contractState.id == flowArgs.id} + ?: throw CordaRuntimeException("Did not find an unique unconsumed ChatState with id ${flowArgs.id}") + + // Calls resolveMessagesFromBackchain() which retrieves the chat history from the backchain. + return jsonMarshallingService.format(resolveMessagesFromBackchain(state, flowArgs.numberOfRecords )) + } + + // resoveMessageFromBackchain() starts at the stateAndRef provided, which represents the unconsumed head of the + // backchain for this particular chat, then walks the chain backwards for the number of transaction specified in + // the numberOfRecords argument. For each transaction it adds the MessageAndSender representing the + // message and who sent it to a list which is then returned. + @Suspendable + private fun resolveMessagesFromBackchain(stateAndRef: StateAndRef, numberOfRecords: Int): List{ + + // Set up a MutableList to collect the MessageAndSender(s) + val messages = mutableListOf() + + // Set up initial conditions for walking the backchain. + var currentStateAndRef = stateAndRef + var recordsToFetch = numberOfRecords + var moreBackchain = true + + // Continue to loop until the start of the backchain or enough records have been retrieved. + while (moreBackchain) { + + // Obtain the transaction id from the current StateAndRef and fetch the transaction from the vault. + val transactionId = currentStateAndRef.ref.transactionId + val transaction = ledgerService.findLedgerTransaction(transactionId) + ?: throw CordaRuntimeException("Transaction $transactionId not found.") + + // Get the output state from the transaction and use it to create a MessageAndSender Object which + // is appended to the mutable list. + val output = transaction.getOutputStates(ChatState::class.java).singleOrNull() + ?: throw CordaRuntimeException("Expecting one and only one ChatState output for transaction $transactionId.") + messages.add(MessageAndSender(output.messageFrom.toString(), output.message)) + // Decrement the number of records to fetch. + recordsToFetch-- + + // Get the reference to the input states. + val inputStateAndRefs = transaction.inputStateAndRefs + + // Check if there are no more input states (start of chain) or we have retrieved enough records. + // Check the transaction is not malformed by having too many input states. + // Set the currentStateAndRef to the input StateAndRef, then repeat the loop. + if (inputStateAndRefs.isEmpty() || recordsToFetch == 0) { + moreBackchain = false + } else if (inputStateAndRefs.size > 1) { + throw CordaRuntimeException("More than one input state found for transaction $transactionId.") + } else { + @Suppress("UNCHECKED_CAST") + currentStateAndRef = inputStateAndRefs.single() as StateAndRef + } + } + // Convert to an immutable List. + return messages.toList() + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.GetChatFlow", + "requestBody": { + "id":"** fill in id **", + "numberOfRecords":"4" + } +} + */ \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.kt new file mode 100644 index 0000000..6a211b4 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.kt @@ -0,0 +1,61 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +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.marshalling.JsonMarshallingService +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.util.* + + +// Data class to hold the Flow results. +// The ChatState(s) cannot be returned directly as the JsonMarshallingService can only serialize simple classes +// that the underlying Jackson serializer recognises, hence creating a DTO style object which consists only of Strings +// and a UUID. It is possible to create custom serializers for the JsonMarshallingService, but this beyond the scope +// of this simple example. +data class ChatStateResults(val id: UUID, val chatName: String,val messageFromName: String, val message: String) + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class ListChatsFlow : ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("ListChatsFlow.call() called") + + // Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO. + val states = ledgerService.findUnconsumedStatesByType(ChatState::class.java) + val results = states.map { + ChatStateResults( + it.state.contractState.id, + it.state.contractState.chatName, + it.state.contractState.messageFrom.toString(), + it.state.contractState.message) } + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results) + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.ListChatsFlow", + "requestBody": {} +} +*/ diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ResponderValidations.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ResponderValidations.kt new file mode 100644 index 0000000..b784788 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/ResponderValidations.kt @@ -0,0 +1,24 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +import net.corda.v5.base.annotations.Suspendable +import net.corda.v5.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name + +// Note, these exceptions will only be visible in the logs if Corda logging is set to debug. + +// Checks that the message does not contain banned words and throws and exception if it does. +@Suspendable +fun checkForBannedWords(str: String) { + val bannedWords = listOf("banana", "apple", "pear") + if (bannedWords.any { str.contains(it) }) + throw CordaRuntimeException("Failed verification - message contains banned words") +} + +// Checks that the messageFrom field in the ChatState matches the initiators (otherMember) +// memberX500Name, if not it throws an exception. +@Suspendable +fun checkMessageFromMatchesCounterparty(state: ChatState, otherMember: MemberX500Name) { + if( state.messageFrom != otherMember) + throw CordaRuntimeException("Failed verification - messageFrom does not equal flow initiator memberX500Name") +} \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/TestContractFlow.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/TestContractFlow.kt new file mode 100644 index 0000000..dad0fe0 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/TestContractFlow.kt @@ -0,0 +1,497 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +import net.corda.v5.application.flows.* +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.base.exceptions.CordaRuntimeException +import net.corda.v5.base.types.MemberX500Name +import net.corda.v5.ledger.common.NotaryLookup +import net.corda.v5.ledger.common.Party +import net.corda.v5.ledger.utxo.Command +import net.corda.v5.ledger.utxo.StateRef +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.Instant +import java.util.* + + +data class TestContractFlowArgs(val otherMember: String) + +class TestContractFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + @CordaInject + lateinit var notaryLookup: NotaryLookup + + @CordaInject + lateinit var flowEngine: FlowEngine + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + + val results = mutableMapOf() + + log.info("TestContractFlow.call() called") + + class FakeCommand : Command + + try { + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, TestContractFlowArgs::class.java) + + val myInfo = memberLookup.myInfo() + + val otherMember = memberLookup.lookup(MemberX500Name.parse(flowArgs.otherMember)) ?: + throw CordaRuntimeException("MemberLookup can't find otherMember specified in flow arguments.") + + // Obtain the Notary name and public key. + val notary = notaryLookup.notaryServices.first() + val notaryKey = memberLookup.lookup().first { + it.memberProvidedContext["corda.notary.service.name"] == notary.name.toString() + }.ledgerKeys.first() + + + // Create a well formed transaction with an output State which can be referenced + // as an input StateRef in the tests + lateinit var inputStateRef: StateRef + lateinit var chatId: UUID + + try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + chatId = chatState.id + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + inputStateRef = StateRef(signedTransaction.id, 0) + flowEngine.subFlow(FinalizeChatSubFlow(signedTransaction, otherMember.name)) + + } catch (e:Exception) { + throw CordaRuntimeException("Set up transaction could not be created because of exception: ${e.message}") + } + + + + + // ************* START TESTS **************** + + // Multiple Commands not permitted + results["Multiple Commands not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addCommand(FakeCommand()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("Requires a single command.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // ChatState with 3 Participants not permitted + results["ChatState with 3 Participants not permitted"] = try { + + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("The output state should have two and only two participants.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Input State on Create not permitted + results["Input State on Create not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Create there should be no input states.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + // Zero output States on Create not permitted + + // Test omitted as it would fail on + // "The output state should have two and only two participants." first + + + // Two output States on Create not permitted + results["Two output States on Create not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addOutputState(chatState) + .addCommand(ChatContract.Create()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Create there should be one and only one output state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Zero input State on Update not permitted + results["Zero input State on Update not permitted"] = try { + + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update there should be one and only one input state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Two Input State on Update not permitted + results["Two Input State on Update not permitted"] = try { + + log.info("MB: test change") + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update there should be one and only one input state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // Zero output States on Update not permitted + + // Test omitted as it would fail on + // "The output state should have two and only two participants." first + + + // Two output States on Update not permitted + results["Two output States on Update not permitted"] = try { + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update there should be one and only one output state.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // On Update id must not change + results["On Update id must not change"] = try { + val chatState = ChatState( + id = UUID.randomUUID(), + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update id must not change")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // On Update chatName must not change + results["On Update chatName must not change"] = try { + val chatState = ChatState( + id = chatId, + chatName = "DummyChat Name has changed", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update chatName must not change.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // On Update participants must not change + results["On Update participants must not change"] = try { + val chatState = ChatState( + id = chatId, + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), myInfo.ledgerKeys.first()) + ) + + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addInputState(inputStateRef) + .addOutputState(chatState) + .addCommand(ChatContract.Update()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("When command is Update participants must not change.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + // FakeCommand not permitted + results["FakeCommand not permitted"] = try { + val chatState = ChatState( + chatName = "DummyChat", + messageFrom = myInfo.name, + message = "Dummy Message", + participants = listOf(myInfo.ledgerKeys.first(), otherMember.ledgerKeys.first()) + ) + + + // Use UTXOTransactionBuilder to build up the draft transaction. + val txBuilder = ledgerService.getTransactionBuilder() + .setNotary(Party(notary.name, notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(FakeCommand()) + .addSignatories(chatState.participants) + + @Suppress("DEPRECATION", "UNUSED_VARIABLE") + val signedTransaction = txBuilder.toSignedTransaction() + + "Fail" + + } catch (e:Exception) { + val exceptionMessage = e.message ?: "No exception message" + if (exceptionMessage.contains("Command not allowed.")) { + "Pass" } + else { + "Contract failed but with a different Exception: ${e.message}" + } + } + + + + return results.toString() + + // Catch any exceptions, log them and rethrow the exception. + } catch (e: Exception) { + log.warn("Failed to process utxo flow for request body '$requestBody' because:'${e.message}'") + throw e + } + } + +} +/* +{ + "clientRequestId": "dummy-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.TestContractFlow", + "requestBody": { + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB" + } +} + + */ \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.kt new file mode 100644 index 0000000..ab67032 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/main/kotlin/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.kt @@ -0,0 +1,109 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract +import com.r3.developers.csdetemplate.utxoexample.states.ChatState +import net.corda.v5.application.flows.* +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.base.exceptions.CordaRuntimeException +import net.corda.v5.ledger.utxo.UtxoLedgerService +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.Instant +import java.util.* + +// A class to hold the deserialized arguments required to start the flow. +data class UpdateChatFlowArgs(val id: UUID, val message: String) + + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +class UpdateChatFlow: ClientStartableFlow { + + private companion object { + val log = LoggerFactory.getLogger(this::class.java.enclosingClass) + } + + @CordaInject + lateinit var jsonMarshallingService: JsonMarshallingService + + @CordaInject + lateinit var memberLookup: MemberLookup + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + lateinit var ledgerService: UtxoLedgerService + + // FlowEngine service is required to run SubFlows. + @CordaInject + lateinit var flowEngine: FlowEngine + + @Suspendable + override fun call(requestBody: ClientRequestBody): String { + + log.info("UpdateNewChatFlow.call() called") + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + val flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, UpdateChatFlowArgs::class.java) + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + val stateAndRef = ledgerService.findUnconsumedStatesByType(ChatState::class.java).singleOrNull { + it.state.contractState.id == flowArgs.id + } ?: throw CordaRuntimeException("Multiple or zero Chat states with id ${flowArgs.id} found.") + + // Get MemberInfos for the Vnode running the flow and the otherMember. + val myInfo = memberLookup.myInfo() + val state = stateAndRef.state.contractState + + val members = state.participants.map { + memberLookup.lookup(it) ?: throw CordaRuntimeException("Member not found from public key $it.")} + val otherMember = (members - myInfo).singleOrNull() + ?: throw CordaRuntimeException("Should be only one participant other than the initiator.") + + // Create a new ChatState using the updateMessage helper function. + val newChatState = state.updateMessage(myInfo.name, flowArgs.message) + + // Use UTXOTransactionBuilder to build up the draft transaction. + val txBuilder= ledgerService.getTransactionBuilder() + .setNotary(stateAndRef.state.notary) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(newChatState) + .addInputState(stateAndRef.ref) + .addCommand(ChatContract.Update()) + .addSignatories(newChatState.participants) + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + val signedTransaction = txBuilder.toSignedTransaction() + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(FinalizeChatSubFlow(signedTransaction, otherMember.name)) + + + } + // Catch any exceptions, log them and rethrow the exception. + catch (e: Exception) { + log.warn("Failed to process utxo flow for request body '$requestBody' because:'${e.message}'") + throw e + } + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "update-2", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.UpdateChatFlow", + "requestBody": { + "id":"** fill in id **", + "message": "How are you today?" + } +} + */ \ No newline at end of file diff --git a/kotlin-samples/mgm-dynamic-network/workflows/src/test/kotlin/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.kt b/kotlin-samples/mgm-dynamic-network/workflows/src/test/kotlin/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.kt new file mode 100644 index 0000000..4cbc637 --- /dev/null +++ b/kotlin-samples/mgm-dynamic-network/workflows/src/test/kotlin/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.kt @@ -0,0 +1,48 @@ +package com.r3.developers.csdetemplate.flowexample.workflows + +//import com.r3.developers.csdetemplate.flowexample.workflows.MyFirstFlow +//import com.r3.developers.csdetemplate.flowexample.workflows.MyFirstFlowResponder +//import com.r3.developers.csdetemplate.flowexample.workflows.MyFirstFlowStartArgs +//import net.corda.simulator.RequestData +//import net.corda.simulator.Simulator +//import net.corda.v5.base.types.MemberX500Name +//import org.junit.jupiter.api.Test + +// Note: this simulator test has been commented out pending the merging of the UTXO code into the Gecko Branch. + + + +//class MyFirstFlowTest { +// +// // Names picked to match the corda network in config/static-network-config.json +// private val aliceX500 = MemberX500Name.parse("CN=Alice, OU=Test Dept, O=R3, L=London, C=GB") +// private val bobX500 = MemberX500Name.parse("CN=Bob, OU=Test Dept, O=R3, L=London, C=GB") +// +// @Test +// fun `test that MyFirstFLow returns correct message`() { +// +// // Instantiate an instance of the Simulator +// val simulator = Simulator() +// +// // Create Alice and Bob's virtual nodes, including the Class's of the flows which will be registered on each node. +// // We don't assign Bob's virtual node to a val because we don't need it for this particular test. +// val aliceVN = simulator.createVirtualNode(aliceX500, MyFirstFlow::class.java) +// simulator.createVirtualNode(bobX500, MyFirstFlowResponder::class.java) +// +// // Create an instance of the MyFirstFlowStartArgs which contains the request arguments for starting the flow +// val myFirstFlowStartArgs = MyFirstFlowStartArgs(bobX500) +// +// // Create a requestData object +// val requestData = RequestData.create( +// "request no 1", // A unique reference for the instance of the flow request +// MyFirstFlow::class.java, // The name of the flow class which is to be started +// myFirstFlowStartArgs // The object which contains the start arguments of the flow +// ) +// +// // Call the Flow on Alice's virtual node and capture the response from the flow +// val flowResponse = aliceVN.callFlow(requestData) +// +// // Check that the flow has returned the expected string +// assert(flowResponse == "Hello Alice, best wishes from Bob") +// } +//} From 221b577a64da1a6226d43edbe9053556c1bf3774 Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Wed, 12 Apr 2023 16:46:40 +0800 Subject: [PATCH 2/7] Update README.md --- kotlin-samples/mgm-dynamic-network/README.md | 98 ++++---------------- 1 file changed, 18 insertions(+), 80 deletions(-) diff --git a/kotlin-samples/mgm-dynamic-network/README.md b/kotlin-samples/mgm-dynamic-network/README.md index aa5b9fe..d30e3ee 100644 --- a/kotlin-samples/mgm-dynamic-network/README.md +++ b/kotlin-samples/mgm-dynamic-network/README.md @@ -1,56 +1,27 @@ -# CSDE-cordapp-template-kotlin +# MGM-Dynamic-Network +This demo CorDapp is to show the use of the MGM tool on setting up a dynamic Corda5 application network. This app will use the Corda 5 combined worker as the foundation. We will then use the MGM tool to deploy the dynamic Corda5 application network on it. All the instructions are written into 4 shell scripts, which you can simply run them. -To help make the process of prototyping CorDapps on Corda 5 beta 1.1 release more straight forward we have developed the Cordapp Standard Development Environment (CSDE). +## Before running the app +* You need to prepare a self-generate CA for create certificates for keys generated within Corda. This CA will be external to Corda. +* You would need to reset the path for both `WORK_DIR` and `RUNTIME_OS` variables in the script. -The CSDE is obtained by cloning this CSDE-Cordapp-Template-Kotlin to your local machine. The CSDE provides: +[R3 INTERAL NOTES]: If you are an R3 employee, you can use the mock ca tool in Corda-runtime-os. Get the `release/os/5.0-Beta2` branch of the Corda-runtime-os. -- A pre-setup Cordapp Project which you can use as a starting point to develop your own prototypes. -- A base Gradle configuration which brings in the dependencies you need to write and test a Corda 5 Cordapp. - -- A set of Gradle helper tasks which speed up and simplify the development and deployment process. - -- Debug configuration for debugging a local Corda cluster. - -- The MyFirstFlow code which forms the basis of this getting started documentation, this is located in package com.r3.developers.csdetemplate.flowexample - -- A UTXO example in package com.r3.developers.csdetemplate.utxoexample packages - -- Ability to configure the Members of the Local Corda Network. - -Note, the CSDE is experimental, we may or may not release it as part of Corda 5.0, in part based on developer feedback using it. - -To find out how to use the CSDE please refer to the getting started section in the Corda 5 Beta 2 documentation at https://docs.r3.com/ - - - -## Chat app -We have built a simple one to one chat app to demo some functionalities of the next gen Corda platform. - -In this app you can: -1. Create a new chat with a counterparty. `CreateNewChatFlow` -2. List out the chat entries you had. `ListChatsFlow` -3. Individually query out the history of one chat entry. `GetChatFlowArgs` -4. Continue chatting within the chat entry with the counterparty. `UpdateChatFlow` - -### Setting up - -1. We will begin our test deployment with clicking the `startCorda`. This task will load up the combined Corda workers in docker. - A successful deployment will allow you to open the REST APIs at: https://localhost:8888/api/v1/swagger#. You can test out some of the - functions to check connectivity. (GET /cpi function call should return an empty list as for now.) -2. We will now deploy the cordapp with a click of `5-vNodeSetup` task. Upon successful deployment of the CPI, the GET /cpi function call should now return the meta data of the cpi you just upload - - - -### Running the chat app - -In Corda 5, flows will be triggered via `POST /flow/{holdingidentityshorthash}` and flow result will need to be view at `GET /flow/{holdingidentityshorthash}/{clientrequestid}` -* holdingidentityshorthash: the id of the network participants, ie Bob, Alice, Charlie. You can view all the short hashes of the network member with another gradle task called `ListVNodes` -* clientrequestid: the id you specify in the flow requestBody when you trigger a flow. +## Running the demo +Find the gradle task `startCorda` and wait for the combined worker to be fully started. Once the swagger page is loaded, run the shell script one by one, and follow the prompts. +``` +. +├── Step1-mgm-deploy.sh +├── Step2-notary-onboard.sh +├── Step3-first-member-onboard.sh +└── Step4-more-member-onboard.sh +``` +## Test your deployment #### Step 1: Create Chat Entry -Pick a VNode identity to initiate the chat, and get its short hash. (Let's pick Alice. Dont pick Bob because Bob is the person who we will have the chat with). +Pick a VNode identity to initiate the chat, and get its short hash. (Let's pick Alice. Don't pick Bob because Bob is the person who we will have the chat with). Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: ``` @@ -59,7 +30,7 @@ Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Ali "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.CreateNewChatFlow", "requestBody": { "chatName":"Chat with Bob", - "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "otherMember":"THE-NODE-YOU-CREATED", "message": "Hello Bob" } } @@ -80,36 +51,3 @@ Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Ali After trigger the list-chats flow, again, we need to hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and check the result. As the screenshot shows, in the response body, we will see a list of chat entries, but it currently only has one entry. And we can see the id of the chat entry. Let's record that id. - -#### Step 3: Continue the chat with `UpdateChatFlow` -In this step, we will continue the chat between Alice and Bob. -Goto `POST /flow/{holdingidentityshorthash}`, enter the identity short hash and request body. Note that here we can have either Alice or Bob's short hash. If you enter Alice's hash, -this message will be recorded as a message from Alice, vice versa. And the id field is the chat entry id we got from the previous step. -``` -{ - "clientRequestId": "update-1", - "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.UpdateChatFlow", - "requestBody": { - "id":" ** fill in id **", - "message": "How are you today?" - } -} -``` -And as for the result of this flow, go to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the required fields. - -#### Step 4: See the whole chat history of one chat entry -After a few back and forth of the messaging, you can view entire chat history by calling GetChatFlow. - -``` -{ - "clientRequestId": "get-1", - "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.GetChatFlow", - "requestBody": { - "id":" ** fill in id **", - "numberOfRecords":"4" - } -} -``` -And as for the result, you need to go to the Get API again and enter the short hash and client request ID. - -Thus, we have concluded a full run through of the chat app. From 1e9f290228df40ad160faebf8c25359266c8f4da Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:16:33 +0800 Subject: [PATCH 3/7] add java mgm dynamic network smaple --- .../mgm-dynamic-network/Step1-mgm-deploy.sh | 17 +++++++++-------- .../register-mgm/MgmGroupPolicy.json | 6 ------ .../mgm-dynamic-network/settings.gradle | 2 +- kotlin-samples/mgm-dynamic-network/test.sh | 9 --------- 4 files changed, 10 insertions(+), 24 deletions(-) delete mode 100644 kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json delete mode 100644 kotlin-samples/mgm-dynamic-network/test.sh diff --git a/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh b/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh index d36d466..b334c02 100644 --- a/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh +++ b/kotlin-samples/mgm-dynamic-network/Step1-mgm-deploy.sh @@ -12,14 +12,6 @@ RUNTIME_OS=~/Corda/corda5/corda-runtime-os echo "\n---Create a mock CA and signing keys---" cd "$WORK_DIR" -#Generate a signing key: -keytool -genkeypair -alias "signing key 1" -keystore signingkeys.pfx -storepass "keystore password" -dname "cn=CPI Plugin Example - Signing Key 1, o=R3, L=London, c=GB" -keyalg RSA -storetype pkcs12 -validity 4000 -#Import gradle-plugin-default-key.pem into the keystore -keytool -importcert -keystore signingkeys.pfx -storepass "keystore password" -noprompt -alias gradle-plugin-default-key -file gradle-plugin-default-key.pem -cd "$RUNTIME_OS" -./gradlew :applications:tools:p2p-test:fake-ca:clean :applications:tools:p2p-test:fake-ca:appJar -java -jar ./applications/tools/p2p-test/fake-ca/build/bin/corda-fake-ca-5.0.0.0-Gecko-SNAPSHOT.jar -m /tmp/ca -a RSA -s 3072 ca -cd "$WORK_DIR" #default signing key echo '-----BEGIN CERTIFICATE----- MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC @@ -34,6 +26,15 @@ VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL zU+Rc5yMtcOY4/moZUq36r0Ilg== -----END CERTIFICATE-----' > ./gradle-plugin-default-key.pem +#Generate a signing key: +keytool -genkeypair -alias "signing key 1" -keystore signingkeys.pfx -storepass "keystore password" -dname "cn=CPI Plugin Example - Signing Key 1, o=R3, L=London, c=GB" -keyalg RSA -storetype pkcs12 -validity 4000 +#Import gradle-plugin-default-key.pem into the keystore +keytool -importcert -keystore signingkeys.pfx -storepass "keystore password" -noprompt -alias gradle-plugin-default-key -file gradle-plugin-default-key.pem +cd "$RUNTIME_OS" +./gradlew :applications:tools:p2p-test:fake-ca:clean :applications:tools:p2p-test:fake-ca:appJar +java -jar ./applications/tools/p2p-test/fake-ca/build/bin/corda-fake-ca-5.0.0.0-Gecko-SNAPSHOT.jar -m /tmp/ca -a RSA -s 3072 ca +cd "$WORK_DIR" + echo "\n---Build mgm CPB---" cd "$RUNTIME_OS" diff --git a/kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json b/kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json deleted file mode 100644 index 6147c89..0000000 --- a/kotlin-samples/mgm-dynamic-network/register-mgm/MgmGroupPolicy.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "fileFormatVersion" : 1, - "groupId" : "CREATE_ID", - "registrationProtocol" :"net.corda.membership.impl.registration.dynamic.mgm.MGMRegistrationService", - "synchronisationProtocol": "net.corda.membership.impl.synchronisation.MgmSynchronisationServiceImpl" -} diff --git a/kotlin-samples/mgm-dynamic-network/settings.gradle b/kotlin-samples/mgm-dynamic-network/settings.gradle index 5b92951..57ff1e7 100644 --- a/kotlin-samples/mgm-dynamic-network/settings.gradle +++ b/kotlin-samples/mgm-dynamic-network/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { } // Root project name, used in naming the project as a whole and used in naming objects built by the project. -rootProject.name = 'mgm-dynamic-network' +rootProject.name = 'mgm-dynamic-network-kotlin' include ':workflows' include ':contracts' diff --git a/kotlin-samples/mgm-dynamic-network/test.sh b/kotlin-samples/mgm-dynamic-network/test.sh deleted file mode 100644 index 5e70356..0000000 --- a/kotlin-samples/mgm-dynamic-network/test.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -echo "---Set Env---" - - -WORK_DIR=./hello -mkdir -p "$WORK_DIR" - - From e140e4d03727a3e4dc3f643eb3c432eea6d036c3 Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:18:01 +0800 Subject: [PATCH 4/7] add java code --- java-samples/mgm-dynamic-network | 1 + 1 file changed, 1 insertion(+) create mode 160000 java-samples/mgm-dynamic-network diff --git a/java-samples/mgm-dynamic-network b/java-samples/mgm-dynamic-network new file mode 160000 index 0000000..4ed8c7b --- /dev/null +++ b/java-samples/mgm-dynamic-network @@ -0,0 +1 @@ +Subproject commit 4ed8c7b8cbe56d54945ac76c0f101d1c69285a8e From 63e41bc2f09d83f62f73184d5ca314539ac8ef1d Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:25:03 +0800 Subject: [PATCH 5/7] remove PR title regex limit --- .github/workflows/check-pr-title.yaml | 14 -------------- java-samples/README.md | 3 ++- kotlin-samples/README.md | 3 ++- 3 files changed, 4 insertions(+), 16 deletions(-) delete mode 100644 .github/workflows/check-pr-title.yaml diff --git a/.github/workflows/check-pr-title.yaml b/.github/workflows/check-pr-title.yaml deleted file mode 100644 index 66f4ddd..0000000 --- a/.github/workflows/check-pr-title.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: 'Check PR Title' -on: - pull_request: - types: [opened, edited, reopened] - -jobs: - check-pr-title: - runs-on: ubuntu-latest - steps: - - uses: morrisoncole/pr-lint-action@v1.6.1 - with: - title-regex: '^([A-Z]{2,}-\d+)(.*)' - on-failed-regex-comment: "PR title failed to match regex -> `%regex%`" - repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/java-samples/README.md b/java-samples/README.md index a198dde..a46fc5d 100644 --- a/java-samples/README.md +++ b/java-samples/README.md @@ -11,6 +11,7 @@ This is the Java Samples folder. ``` . ├── README.md -└── corda5-obligation-cordapp +├── corda5-obligation-cordapp +└── mgm-dynamic-network ``` diff --git a/kotlin-samples/README.md b/kotlin-samples/README.md index 917688d..dfe2550 100644 --- a/kotlin-samples/README.md +++ b/kotlin-samples/README.md @@ -11,6 +11,7 @@ This is the Kotlin Samples folder. ``` . ├── README.md -└── corda5-obligation-cordapp +├── corda5-obligation-cordapp +└── mgm-dynamic-network ``` From 762e0bb9c0f79f48c1f7c6ba704975b325b2d084 Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:26:51 +0800 Subject: [PATCH 6/7] Delete mgm-dynamic-network --- java-samples/mgm-dynamic-network | 1 - 1 file changed, 1 deletion(-) delete mode 160000 java-samples/mgm-dynamic-network diff --git a/java-samples/mgm-dynamic-network b/java-samples/mgm-dynamic-network deleted file mode 160000 index 4ed8c7b..0000000 --- a/java-samples/mgm-dynamic-network +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4ed8c7b8cbe56d54945ac76c0f101d1c69285a8e From b057817ab605659ff3566b4d6bd00fa66bb1be23 Mon Sep 17 00:00:00 2001 From: peterli-r3 <51169685+peterli-r3@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:27:47 +0800 Subject: [PATCH 7/7] add java code --- java-samples/mgm-dynamic-network/README.md | 53 ++++ .../mgm-dynamic-network/Step1-mgm-deploy.sh | 150 +++++++++ .../Step2-notary-onboard.sh | 92 ++++++ .../Step3-first-member-onboard.sh | 83 +++++ .../Step4-more-member-onboard.sh | 63 ++++ java-samples/mgm-dynamic-network/build.gradle | 47 +++ .../mgm-dynamic-network/buildSrc/build.gradle | 22 ++ .../buildSrc/gradle.properties | 4 + .../buildSrc/settings.gradle | 1 + .../buildSrc/src/main/groovy/csde.gradle | 248 +++++++++++++++ .../java/com/r3/csde/BuildCPIsHelper.java | 278 +++++++++++++++++ .../com/r3/csde/CordaLifeCycleHelper.java | 93 ++++++ .../java/com/r3/csde/CordaStatusQueries.java | 64 ++++ .../main/java/com/r3/csde/CsdeException.java | 10 + .../java/com/r3/csde/DeployCPIsHelper.java | 187 ++++++++++++ .../main/java/com/r3/csde/NetworkConfig.java | 40 +++ .../main/java/com/r3/csde/ProjectContext.java | 84 +++++ .../main/java/com/r3/csde/ProjectUtils.java | 36 +++ .../src/main/java/com/r3/csde/VNode.java | 25 ++ .../main/java/com/r3/csde/VNodesHelper.java | 288 ++++++++++++++++++ .../com/r3/csde/dtos/CPIFileStatusDTO.java | 16 + .../com/r3/csde/dtos/CpiIdentifierDTO.java | 23 ++ .../java/com/r3/csde/dtos/CpiMetadataDTO.java | 17 ++ .../com/r3/csde/dtos/GetCPIsResponseDTO.java | 14 + .../com/r3/csde/dtos/HoldingIdentityDTO.java | 27 ++ .../dtos/RegistrationRequestProgressDTO.java | 17 ++ .../com/r3/csde/dtos/VirtualNodeInfoDTO.java | 17 ++ .../com/r3/csde/dtos/VirtualNodesDTO.java | 14 + java-samples/mgm-dynamic-network/clean.sh | 15 + .../config/gradle-plugin-default-key.pem | 13 + .../mgm-dynamic-network/config/log4j2.xml | 51 ++++ .../config/static-network-config.json | 23 ++ .../contracts/build.gradle | 88 ++++++ .../utxoexample/contracts/ChatContract.java | 55 ++++ .../utxoexample/states/ChatState.java | 55 ++++ .../mgm-dynamic-network/gradle.properties | 40 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59821 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + java-samples/mgm-dynamic-network/gradlew | 185 +++++++++++ java-samples/mgm-dynamic-network/gradlew.bat | 89 ++++++ .../register-mgm/notary.cpb | Bin 0 -> 47547 bytes .../mgm-dynamic-network/settings.gradle | 24 ++ .../workflows/build.gradle | 90 ++++++ .../workflows/ChatStateResults.java | 41 +++ .../workflows/CreateNewChatFlow.java | 133 ++++++++ .../workflows/CreateNewChatFlowArgs.java | 30 ++ .../workflows/FinalizeChatResponderFlow.java | 75 +++++ .../workflows/FinalizeChatSubFlow.java | 71 +++++ .../utxoexample/workflows/GetChatFlow.java | 119 ++++++++ .../workflows/GetChatFlowArgs.java | 24 ++ .../utxoexample/workflows/ListChatsFlow.java | 58 ++++ .../workflows/MessageAndSender.java | 22 ++ .../utxoexample/workflows/UpdateChatFlow.java | 120 ++++++++ .../workflows/UpdateChatFlowArgs.java | 24 ++ .../workflows/MyFirstFlowTest.java | 41 +++ 55 files changed, 3504 insertions(+) create mode 100644 java-samples/mgm-dynamic-network/README.md create mode 100644 java-samples/mgm-dynamic-network/Step1-mgm-deploy.sh create mode 100644 java-samples/mgm-dynamic-network/Step2-notary-onboard.sh create mode 100644 java-samples/mgm-dynamic-network/Step3-first-member-onboard.sh create mode 100644 java-samples/mgm-dynamic-network/Step4-more-member-onboard.sh create mode 100644 java-samples/mgm-dynamic-network/build.gradle create mode 100644 java-samples/mgm-dynamic-network/buildSrc/build.gradle create mode 100644 java-samples/mgm-dynamic-network/buildSrc/gradle.properties create mode 100644 java-samples/mgm-dynamic-network/buildSrc/settings.gradle create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java create mode 100644 java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java create mode 100644 java-samples/mgm-dynamic-network/clean.sh create mode 100644 java-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem create mode 100644 java-samples/mgm-dynamic-network/config/log4j2.xml create mode 100644 java-samples/mgm-dynamic-network/config/static-network-config.json create mode 100644 java-samples/mgm-dynamic-network/contracts/build.gradle create mode 100644 java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.java create mode 100644 java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/states/ChatState.java create mode 100644 java-samples/mgm-dynamic-network/gradle.properties create mode 100644 java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.jar create mode 100644 java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties create mode 100755 java-samples/mgm-dynamic-network/gradlew create mode 100644 java-samples/mgm-dynamic-network/gradlew.bat create mode 100644 java-samples/mgm-dynamic-network/register-mgm/notary.cpb create mode 100644 java-samples/mgm-dynamic-network/settings.gradle create mode 100644 java-samples/mgm-dynamic-network/workflows/build.gradle create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ChatStateResults.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlowArgs.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatResponderFlow.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlowArgs.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/MessageAndSender.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlowArgs.java create mode 100644 java-samples/mgm-dynamic-network/workflows/src/test/java/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.java diff --git a/java-samples/mgm-dynamic-network/README.md b/java-samples/mgm-dynamic-network/README.md new file mode 100644 index 0000000..dbe6a87 --- /dev/null +++ b/java-samples/mgm-dynamic-network/README.md @@ -0,0 +1,53 @@ +# MGM-Dynamic-Network + +This demo CorDapp is to show the use of the MGM tool on setting up a dynamic Corda5 application network. This app will use the Corda 5 combined worker as the foundation. We will then use the MGM tool to deploy the dynamic Corda5 application network on it. All the instructions are written into 4 shell scripts, which you can simply run them. + +## Before running the app +* You need to prepare a self-generate CA for create certificates for keys generated within Corda. This CA will be external to Corda. +* You would need to reset the path for both `WORK_DIR` and `RUNTIME_OS` variables in the script. + +[R3 INTERAL NOTES]: If you are an R3 employee, you can use the mock ca tool in Corda-runtime-os. Get the `release/os/5.0-Beta2` branch of the Corda-runtime-os. + + +## Running the demo +Find the gradle task `startCorda` and wait for the combined worker to be fully started. Once the swagger page is loaded, run the shell script one by one, and follow the prompts. +``` +. +├── Step1-mgm-deploy.sh +├── Step2-notary-onboard.sh +├── Step3-first-member-onboard.sh +└── Step4-more-member-onboard.sh +``` + +## Test your deployment +#### Step 1: Create Chat Entry +Pick a VNode identity to initiate the chat, and get its short hash. (Let's pick Alice. Don't pick Bob because Bob is the person who we will have the chat with). + +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"THE-NODE-YOU-CREATED", + "message": "Hello Bob" + } +} +``` + +After trigger the create-chat flow, hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and enter the short hash(Alice's hash) and clientrequestid to view the flow result + +#### Step 2: List the chat +In order to continue the chat, we would need the chat ID. This step will bring out all the chat entries this entity (Alice) has. +Go to `POST /flow/{holdingidentityshorthash}`, enter the identity short hash(Alice's hash) and request body: +``` +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.ListChatsFlow", + "requestBody": {} +} +``` +After trigger the list-chats flow, again, we need to hop to `GET /flow/{holdingidentityshorthash}/{clientrequestid}` and check the result. As the screenshot shows, in the response body, +we will see a list of chat entries, but it currently only has one entry. And we can see the id of the chat entry. Let's record that id. + diff --git a/java-samples/mgm-dynamic-network/Step1-mgm-deploy.sh b/java-samples/mgm-dynamic-network/Step1-mgm-deploy.sh new file mode 100644 index 0000000..d7249d2 --- /dev/null +++ b/java-samples/mgm-dynamic-network/Step1-mgm-deploy.sh @@ -0,0 +1,150 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/java-samples/mgm-dynamic-network/register-mgm +mkdir -p "$WORK_DIR" +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + +echo "\n---Create a mock CA and signing keys---" +cd "$WORK_DIR" +#default signing key +echo '-----BEGIN CERTIFICATE----- +MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC +MQ8wDQYDVQQHDAZMb25kb24xDjAMBgNVBAoMBUNvcmRhMQswCQYDVQQLDAJSMzEe +MBwGA1UEAwwVQ29yZGEgRGV2IENvZGUgU2lnbmVyMB4XDTIwMDYyNTE4NTI1NFoX +DTMwMDYyMzE4NTI1NFowWzELMAkGA1UEBhMCR0IxDzANBgNVBAcTBkxvbmRvbjEO +MAwGA1UEChMFQ29yZGExCzAJBgNVBAsTAlIzMR4wHAYDVQQDExVDb3JkYSBEZXYg +Q29kZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQDjSJtzQ+ldDFt +pHiqdSJebOGPZcvZbmC/PIJRsZZUF1bl3PfMqyG3EmAe0CeFAfLzPQtf2qTAnmJj +lGTkkQhxo0MwQTATBgNVHSUEDDAKBggrBgEFBQcDAzALBgNVHQ8EBAMCB4AwHQYD +VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh +ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL +zU+Rc5yMtcOY4/moZUq36r0Ilg== +-----END CERTIFICATE-----' > ./gradle-plugin-default-key.pem + +#Generate a signing key: +keytool -genkeypair -alias "signing key 1" -keystore signingkeys.pfx -storepass "keystore password" -dname "cn=CPI Plugin Example - Signing Key 1, o=R3, L=London, c=GB" -keyalg RSA -storetype pkcs12 -validity 4000 +#Import gradle-plugin-default-key.pem into the keystore +keytool -importcert -keystore signingkeys.pfx -storepass "keystore password" -noprompt -alias gradle-plugin-default-key -file gradle-plugin-default-key.pem +#cd "$RUNTIME_OS" +#./gradlew :applications:tools:p2p-test:fake-ca:clean :applications:tools:p2p-test:fake-ca:appJar +#java -jar ./applications/tools/p2p-test/fake-ca/build/bin/corda-fake-ca-5.0.0.0-Gecko-SNAPSHOT.jar -m /tmp/ca -a RSA -s 3072 ca +cd "$WORK_DIR" + + +echo "\n---Build mgm CPB---" +#cd "$RUNTIME_OS" +#./gradlew testing:cpbs:mgm:build +#cp testing/cpbs/mgm/build/libs/mgm-5.0.0.0-Gecko-SNAPSHOT-package.cpb "$WORK_DIR" +echo '{ + "fileFormatVersion" : 1, + "groupId" : "CREATE_ID", + "registrationProtocol" :"net.corda.membership.impl.registration.dynamic.mgm.MGMRegistrationService", + "synchronisationProtocol": "net.corda.membership.impl.synchronisation.MgmSynchronisationServiceImpl" +}' > "$WORK_DIR"/MgmGroupPolicy.json +cd "$WORK_DIR" +mv ./mgm-5.0.0.0-Gecko-SNAPSHOT-package.cpb mgm.cpb + + +echo "\n---Build and upload MGM CPI---" +cd "$WORK_DIR" +#Run this command to turn a CPB into a CPI +sh ~/.corda/cli/corda-cli.sh package create-cpi --cpb mgm.cpb --group-policy MgmGroupPolicy.json --cpi-name "mgm cpi" --cpi-version "1.0.0.0-SNAPSHOT" --file mgm.cpi --keystore signingkeys.pfx --storepass "keystore password" --key "signing key 1" +#Import the gradle plugin default key into Corda +curl --insecure -u admin:admin -X PUT -F alias="gradle-plugin-default-key" -F certificate=@gradle-plugin-default-key.pem https://localhost:8888/api/v1/certificates/cluster/code-signer +#Export the signing key certificate from the key store +keytool -exportcert -rfc -alias "signing key 1" -keystore signingkeys.pfx -storepass "keystore password" -file signingkey1.pem +#Import the signing key into Corda +curl --insecure -u admin:admin -X PUT -F alias="signingkey1-2022" -F certificate=@signingkey1.pem https://localhost:8888/api/v1/certificates/cluster/code-signer +CPI_PATH=./mgm.cpi +curl --insecure -u admin:admin -F upload=@$CPI_PATH $API_URL/cpi/ +echo "\n" +read -p "Enter the CPI_ID from the returned body:" CPI_ID +echo "CPI_ID:" $CPI_ID + + +echo "---Create MGM VNode---" +curl --insecure -u admin:admin $API_URL/cpi/status/$CPI_ID +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM +curl --insecure -u admin:admin -d '{ "request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "C=GB, L=London, O=MGM"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the MGM_HOLDING_ID from the returned body:" MGM_HOLDING_ID +echo "MGM_HOLDING_ID:" $MGM_HOLDING_ID + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$MGM_HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$MGM_HOLDING_ID/alias/$MGM_HOLDING_ID-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$MGM_HOLDING_ID/PRE_AUTH +echo "\nECDH_KEY_ID: " +curl --insecure -u admin:admin -X POST $API_URL/keys/$MGM_HOLDING_ID/alias/$MGM_HOLDING_ID-auth/category/PRE_AUTH/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the ECDH_KEY_ID from the returned body:" ECDH_KEY_ID +echo "ECDH_KEY_ID:" $ECDH_KEY_ID + + +echo "\n--Set up the TLS key pair and certificate---" +curl -k -u admin:admin -X POST -H "Content-Type: application/json" $API_URL/keys/p2p/alias/p2p-TLS/category/TLS/scheme/CORDA.RSA +echo "\n" +read -p "Enter the TLS_KEY_ID from the returned body:" TLS_KEY_ID +echo "TLS_KEY_ID:" $TLS_KEY_ID +curl -k -u admin:admin -X POST -H "Content-Type: application/json" -d '{"x500Name": "CN=CordaOperator, C=GB, L=London, O=Org", "subjectAlternativeNames": ["'$P2P_GATEWAY_HOST'"]}' $API_URL"/certificates/p2p/"$TLS_KEY_ID > "$WORK_DIR"/request1.csr +read -p "Wait for download to be finished, Then press any key to continue..." ANY +cd "$RUNTIME_OS" +java -jar ./applications/tools/p2p-test/fake-ca/build/bin/corda-fake-ca-5.0.0.0-Gecko-SNAPSHOT.jar -m /tmp/ca csr "$WORK_DIR"/request1.csr +cd "$WORK_DIR" +curl -k -u admin:admin -X PUT -F certificate=@/tmp/ca/request1/certificate.pem -F alias=p2p-tls-cert $API_URL/certificates/cluster/p2p-tls + + +echo "---Disable revocation checks---" +curl --insecure -u admin:admin -X GET $API_URL/config/corda.p2p.gateway +echo "\n" +read -p "Enter the CONFIG_VERSION from the returned body:" CONFIG_VERSION +echo "CONFIG_VERSION:" $CONFIG_VERSION +curl -k -u admin:admin -X PUT -d '{"section":"corda.p2p.gateway", "version":"'$CONFIG_VERSION'", "config":"{ \"sslConfig\": { \"revocationCheck\": { \"mode\": \"OFF\" } } }", "schemaVersion": {"major": 1, "minor": 0}}' $API_URL"/config" + + +echo "\n---Register MGM---" +TLS_CA_CERT=$(cat /tmp/ca/ca/root-certificate.pem | awk '{printf "%s\\n", $0}') +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.ecdh.key.id": "'$ECDH_KEY_ID'", + "corda.group.protocol.registration": "net.corda.membership.impl.registration.dynamic.member.DynamicMemberRegistrationService", + "corda.group.protocol.synchronisation": "net.corda.membership.impl.synchronisation.MemberSynchronisationServiceImpl", + "corda.group.protocol.p2p.mode": "Authenticated_Encryption", + "corda.group.key.session.policy": "Combined", + "corda.group.pki.session": "NoPKI", + "corda.group.pki.tls": "Standard", + "corda.group.tls.version": "1.3", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1", + "corda.group.truststore.tls.0" : "'$TLS_CA_CERT'" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$MGM_HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$MGM_HOLDING_ID/$REGISTRATION_ID +echo "\n" + + +echo "---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$MGM_HOLDING_ID +echo "\n" + + +echo "---Export group policy for group---" +cd "$WORK_DIR" +mkdir -p "./register-member" +curl --insecure -u admin:admin -X GET $API_URL/mgm/$MGM_HOLDING_ID/info > "$WORK_DIR/register-member/GroupPolicy.json" diff --git a/java-samples/mgm-dynamic-network/Step2-notary-onboard.sh b/java-samples/mgm-dynamic-network/Step2-notary-onboard.sh new file mode 100644 index 0000000..a1d774b --- /dev/null +++ b/java-samples/mgm-dynamic-network/Step2-notary-onboard.sh @@ -0,0 +1,92 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/java-samples/mgm-dynamic-network/register-mgm +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + + +echo "\n---Build and upload Notary CPI---" +cd $WORK_DIR +cp ./notary.cpb ./register-member +cd "$WORK_DIR/register-member/" +##Run this command to turn a CPB into a CPI +sh ~/.corda/cli/corda-cli.sh package create-cpi --cpb notary.cpb --group-policy GroupPolicy.json --cpi-name "notary cpi" --cpi-version "1.0.0.0-SNAPSHOT" --file notary.cpi --keystore ../signingkeys.pfx --storepass "keystore password" --key "signing key 1" +CPI_PATH="$WORK_DIR/register-member/notary.cpi" +curl --insecure -u admin:admin -F upload=@$CPI_PATH $API_URL/cpi/ +echo "\n" +read -p "Enter the Notary CPI_ID from the returned body:" CPI_ID +echo "CPI_ID:" $CPI_ID +curl --insecure -u admin:admin $API_URL/cpi/status/$CPI_ID +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM + + +echo "\n---Create a Member virtual node---" +echo "\n" +read -p "Enter the X500_NAME from the returned body (Formatt: C=GB,L=London,O=NotaryRep1):" X500_NAME +curl --insecure -u admin:admin -d '{"request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "'$X500_NAME'"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the HOLDING_ID from the returned body:" HOLDING_ID +echo "HOLDING_ID:" $HOLDING_ID +echo "\n" + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL'/keys/'$HOLDING_ID'/alias/'$HOLDING_ID'-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1' +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/LEDGER +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-ledger/category/LEDGER/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the LEDGER_KEY_ID from the returned body:" LEDGER_KEY_ID +echo "LEDGER_KEY_ID:" $LEDGER_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/NOTARY +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-notary/category/NOTARY/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the NOTARY_KEY_ID from the returned body:" NOTARY_KEY_ID +echo "NOTARY_KEY_ID:" $NOTARY_KEY_ID + + +echo "\n---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$HOLDING_ID +echo "\n" + + +echo "\n---Build Notary registration context---" +echo "\n" +read -p "Enter the NOTARY_SERVICE_NAME (Formatt: C=GB,L=London,O=NotaryServiceA):" NOTARY_SERVICE_NAME +echo "NOTARY_SERVICE_NAME:" $NOTARY_SERVICE_NAME +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.session.key.signature.spec": "SHA256withECDSA", + "corda.ledger.keys.0.id": "'$LEDGER_KEY_ID'", + "corda.ledger.keys.0.signature.spec": "SHA256withECDSA", + "corda.notary.keys.0.id": "'$NOTARY_KEY_ID'", + "corda.notary.keys.0.signature.spec": "SHA256withECDSA", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1", + "corda.roles.0" : "notary", + "corda.notary.service.name" : "'$NOTARY_SERVICE_NAME'", + "corda.notary.service.plugin" : "net.corda.notary.NonValidatingNotary" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' + + +echo "\n---Register Notary VNode---" +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$HOLDING_ID/$REGISTRATION_ID +echo "\n" +curl --insecure -u admin:admin -X GET $API_URL/members/$HOLDING_ID diff --git a/java-samples/mgm-dynamic-network/Step3-first-member-onboard.sh b/java-samples/mgm-dynamic-network/Step3-first-member-onboard.sh new file mode 100644 index 0000000..20817ca --- /dev/null +++ b/java-samples/mgm-dynamic-network/Step3-first-member-onboard.sh @@ -0,0 +1,83 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/java-samples/mgm-dynamic-network/register-mgm +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + + +echo "\n---Build and upload chat CPI---" +./gradlew jar +./gradlew cpb +cd ./workflows/build/libs +mv ./workflows-1.0-SNAPSHOT-package.cpb chat.cpb +cd $WORK_DIR/.. +cp ./workflows/build/libs/chat.cpb ./register-mgm/register-member +##Run this command to turn a CPB into a CPI +cd "$WORK_DIR/register-member/" +sh ~/.corda/cli/corda-cli.sh package create-cpi --cpb chat.cpb --group-policy GroupPolicy.json --cpi-name "chat cpi" --cpi-version "1.0.0.0-SNAPSHOT" --file chat.cpi --keystore ../signingkeys.pfx --storepass "keystore password" --key "signing key 1" +CPI_PATH="$WORK_DIR/register-member/chat.cpi" +curl --insecure -u admin:admin -F upload=@$CPI_PATH $API_URL/cpi/ +echo "\n" +read -p "Enter the chat CPI_ID from the returned body:" CPI_ID +echo "CPI_ID:" $CPI_ID +curl --insecure -u admin:admin $API_URL/cpi/status/$CPI_ID +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM + + +echo "\n---Create a Member virtual node---" +echo "\n" +read -p "Enter the X500_NAME from the returned body (Formatt: C=GB,L=London,O=Alice):" X500_NAME +curl --insecure -u admin:admin -d '{"request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "'$X500_NAME'"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the HOLDING_ID from the returned body:" HOLDING_ID +echo "HOLDING_ID:" $HOLDING_ID + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL'/keys/'$HOLDING_ID'/alias/'$HOLDING_ID'-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1' +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/LEDGER +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-ledger/category/LEDGER/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the LEDGER_KEY_ID from the returned body:" LEDGER_KEY_ID +echo "LEDGER_KEY_ID:" $LEDGER_KEY_ID + + +echo "\n---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$HOLDING_ID + + +echo "\n---Build registration context---" +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.session.key.signature.spec": "SHA256withECDSA", + "corda.ledger.keys.0.id": "'$LEDGER_KEY_ID'", + "corda.ledger.keys.0.signature.spec": "SHA256withECDSA", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' + + +echo "\n---Register Member VNode---" +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$HOLDING_ID/$REGISTRATION_ID +echo "\n" +curl --insecure -u admin:admin -X GET $API_URL/members/$HOLDING_ID +echo "\n" +echo "\n---The Chat app CPI_CHECKSUM is : " +echo $CPI_CHECKSUM diff --git a/java-samples/mgm-dynamic-network/Step4-more-member-onboard.sh b/java-samples/mgm-dynamic-network/Step4-more-member-onboard.sh new file mode 100644 index 0000000..c89c19d --- /dev/null +++ b/java-samples/mgm-dynamic-network/Step4-more-member-onboard.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +echo "---Set Env---" +RPC_HOST=localhost +RPC_PORT=8888 +P2P_GATEWAY_HOST=localhost +P2P_GATEWAY_PORT=8080 +API_URL="https://$RPC_HOST:$RPC_PORT/api/v1" +WORK_DIR=~/Corda/corda5/corda5-samples/java-samples/mgm-dynamic-network/register-mgm +RUNTIME_OS=~/Corda/corda5/corda-runtime-os + + +echo "\n---Create a Member virtual node---" +echo "\n" +read -p "Enter the CPI_CHECKSUM from the returned body:" CPI_CHECKSUM +echo "\n" +read -p "Enter the X500_NAME from the returned body (Formatt: C=GB,L=London,O=Alice):" X500_NAME +curl --insecure -u admin:admin -d '{"request": {"cpiFileChecksum": "'$CPI_CHECKSUM'", "x500Name": "'$X500_NAME'"}}' $API_URL/virtualnode +echo "\n" +read -p "Enter the HOLDING_ID from the returned body:" HOLDING_ID +echo "HOLDING_ID:" $HOLDING_ID + + +echo "---Assign soft HSM---" +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/SESSION_INIT +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL'/keys/'$HOLDING_ID'/alias/'$HOLDING_ID'-session/category/SESSION_INIT/scheme/CORDA.ECDSA.SECP256R1' +echo "\n" +read -p "Enter the SESSION_KEY_ID from the returned body:" SESSION_KEY_ID +echo "SESSION_KEY_ID:" $SESSION_KEY_ID +curl --insecure -u admin:admin -X POST $API_URL/hsm/soft/$HOLDING_ID/LEDGER +echo "\n" +curl --insecure -u admin:admin -X POST $API_URL/keys/$HOLDING_ID/alias/$HOLDING_ID-ledger/category/LEDGER/scheme/CORDA.ECDSA.SECP256R1 +echo "\n" +read -p "Enter the LEDGER_KEY_ID from the returned body:" LEDGER_KEY_ID +echo "LEDGER_KEY_ID:" $LEDGER_KEY_ID + + +echo "\n---Configure virtual node as network participant---" +curl -k -u admin:admin -X PUT -d '{"p2pTlsCertificateChainAlias": "p2p-tls-cert", "useClusterLevelTlsCertificateAndKey": true, "sessionKeyId": "'$SESSION_KEY_ID'"}' $API_URL/network/setup/$HOLDING_ID + + +echo "\n---Build registration context---" +REGISTRATION_CONTEXT='{ + "corda.session.key.id": "'$SESSION_KEY_ID'", + "corda.session.key.signature.spec": "SHA256withECDSA", + "corda.ledger.keys.0.id": "'$LEDGER_KEY_ID'", + "corda.ledger.keys.0.signature.spec": "SHA256withECDSA", + "corda.endpoints.0.connectionURL": "https://'$P2P_GATEWAY_HOST':'$P2P_GATEWAY_PORT'", + "corda.endpoints.0.protocolVersion": "1" +}' +REGISTRATION_REQUEST='{"memberRegistrationRequest":{"action": "requestJoin", "context": '$REGISTRATION_CONTEXT'}}' + + +echo "\n---Register Member VNode---" +curl --insecure -u admin:admin -d "$REGISTRATION_REQUEST" $API_URL/membership/$HOLDING_ID +echo "\n" +read -p "Enter the REGISTRATION_ID from the returned body:" REGISTRATION_ID +echo "REGISTRATION_ID:" $REGISTRATION_ID +curl --insecure -u admin:admin -X GET $API_URL/membership/$HOLDING_ID/$REGISTRATION_ID +echo "\n" +curl --insecure -u admin:admin -X GET $API_URL/members/$HOLDING_ID + diff --git a/java-samples/mgm-dynamic-network/build.gradle b/java-samples/mgm-dynamic-network/build.gradle new file mode 100644 index 0000000..5f374e9 --- /dev/null +++ b/java-samples/mgm-dynamic-network/build.gradle @@ -0,0 +1,47 @@ +import static org.gradle.api.JavaVersion.VERSION_11 + +plugins { + id 'org.jetbrains.kotlin.jvm' + id 'net.corda.cordapp.cordapp-configuration' + id 'org.jetbrains.kotlin.plugin.jpa' + id 'java' + id 'maven-publish' + id 'csde' +} + +allprojects { + group 'com.r3.developers.csdetemplate' + version '1.0-SNAPSHOT' + + def javaVersion = VERSION_11 + + // Declare the set of Java compiler options we need to build a CorDapp. + tasks.withType(JavaCompile) { + // -parameters - Needed for reflection and serialization to work correctly. + options.compilerArgs += [ + "-parameters" + ] + } + + repositories { + // All dependencies are held in Maven Central + mavenCentral() + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } + +} + +publishing { + publications { + maven(MavenPublication) { + artifactId "corda-mgm" + groupId project.group + artifact jar + } + + } +} + diff --git a/java-samples/mgm-dynamic-network/buildSrc/build.gradle b/java-samples/mgm-dynamic-network/buildSrc/build.gradle new file mode 100644 index 0000000..d21dbee --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/build.gradle @@ -0,0 +1,22 @@ + +plugins { + id 'groovy-gradle-plugin' + id 'java' +} + +repositories { + mavenCentral() + mavenLocal() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' + + implementation "com.konghq:unirest-java:$unirestVersion" + implementation "com.konghq:unirest-objectmapper-jackson:$unirestVersion" + implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion" + + implementation "net.corda:corda-base:$cordaApiVersion" +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/gradle.properties b/java-samples/mgm-dynamic-network/buildSrc/gradle.properties new file mode 100644 index 0000000..7ab643a --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/gradle.properties @@ -0,0 +1,4 @@ +jacksonVersion = 2.13.4 +unirestVersion=3.13.10 + +cordaApiVersion=5.0.0.665-Gecko1.0 diff --git a/java-samples/mgm-dynamic-network/buildSrc/settings.gradle b/java-samples/mgm-dynamic-network/buildSrc/settings.gradle new file mode 100644 index 0000000..86ac012 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/settings.gradle @@ -0,0 +1 @@ +// File intentionally left blank diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle b/java-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle new file mode 100644 index 0000000..a94ffa9 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/groovy/csde.gradle @@ -0,0 +1,248 @@ +// Note, IntelliJ does not recognise the imported Java Classes, hence they are +// highlighted in Red. However, they are recognised in the gradle compilation. + + +// todo: look at the declaration of the script variables, can they be combined with the declaration of the Project Context +// todo: investigate adding corda-cli to the class path then executing it directly - might not work as gradle has to set up the jar file, so its not their when you start. +// Todo: write a test flow runner helper function?? +// todo: rename deployCPIsHelper +// todo: add proper logging, rather than reading Stdout +// todo: add test corda running/live task +// todo: add a test to check docker is running and display error if not + halt start corda +// todo: add a clean corda task. +// todo: fix logging level and make it configurable. + + +import com.r3.csde.CordaLifeCycleHelper +import com.r3.csde.ProjectContext +import com.r3.csde.DeployCPIsHelper +import com.r3.csde.BuildCPIsHelper +import com.r3.csde.ProjectUtils +import com.r3.csde.CordaStatusQueries +import com.r3.csde.VNodesHelper +import com.r3.csde.NetworkConfig + +plugins { + id 'java-library' + id 'groovy' + id 'java' +} + + +configurations { + combinedWorker{ + canBeConsumed = false + canBeResolved= true + } + + myPostgresJDBC { + canBeConsumed = false + canBeResolved = true + } + + notaryServerCPB { + canBeConsumed = false + canBeResolved = true + } +} + +// Dependencies for supporting tools +dependencies { + combinedWorker "net.corda:corda-combined-worker:$combinedWorkerVersion" + myPostgresJDBC "org.postgresql:postgresql:$postgresqlVersion" + notaryServerCPB("com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-server:$cordaNotaryPluginsVersion") { + artifact { + classifier = 'package' + extension = 'cpb' + } + } + + implementation "org.codehaus.groovy:groovy-json:3.0.9" +} + +// task groupings +def cordaGroup = 'csde-corda' // corda lifecycle tasks +def cordappGroup = 'csde-cordapp' // tasks to build and deploy corDapps +def queriesGroup = 'csde-queries' // tasks which query corda status +def supportingGroup = 'supporting' // tasks which should be hidden from the csde user + + +def cordaBinDir = System.getenv("CSDE_CORDA_BIN") ?: System.getProperty('user.home') + "/.corda/corda5" +def cordaCliBinDir = System.getenv("CSDE_CORDA_CLI") ?:System.getProperty('user.home') + "/.corda/cli" +def cordaJDBCDir = cordaBinDir + "/jdbcDrivers" +def cordaNotaryServerDir = cordaBinDir + "/notaryserver" +def signingCertAlias="gradle-plugin-default-key" +// Get error if this is not a autotyped object +// def signingCertFName = "$rootDir/config/gradle-plugin-default-key.pem" +def signingCertFName = rootDir.toString() + "/config/gradle-plugin-default-key.pem" +def keystoreAlias = "my-signing-key" +def keystoreFName = devEnvWorkspace + "/signingkeys.pfx" +def keystoreCertFName = devEnvWorkspace + "/signingkey1.pem" +def combiWorkerPidCacheFile = devEnvWorkspace + "/CordaPID.dat" +// todo: can we rely on the build directory always being /workflow/build? aslo, is the +// workflow directory the correct place to build the cpb to. shoudl it be the main build directory. +def workflowBuildDir = rootDir.toString() + "/workflows/build" + + +// todo: Need to read things from cordapp plugin - the cordapp names will be changed by the user +def appCpiName = 'cpi name' +def notaryCpiName = 'CSDE Notary Server CPI' + + +// todo: there should be a better way to set up these project context variables. +def projectContext = new ProjectContext(project, + cordaClusterURL.toString(), + cordaRpcUser, + cordaRpcPasswd, + devEnvWorkspace, + // todo: why is this not obtained in the groovy def's abouve - its inconsistent. + new String("${System.getProperty("java.home")}/bin"), + dbContainerName, + cordaJDBCDir, + combiWorkerPidCacheFile, + signingCertAlias, + signingCertFName, + keystoreAlias, + keystoreFName, + keystoreCertFName, + appCpiName, + notaryCpiName, + devEnvWorkspace, + cordaCliBinDir, + cordaNotaryServerDir, + workflowBuildDir, + cordaNotaryPluginsVersion +) + +def networkConfig = new NetworkConfig("config/static-network-config.json") + +def utils = new ProjectUtils() + +// Initiate workspace folder + +tasks.register('projInit') { + group = supportingGroup + doLast { + mkdir devEnvWorkspace + } +} + + +// CordaLifeCycle tasks + +def cordaLifeCycle = new CordaLifeCycleHelper(projectContext) + +tasks.register("startCorda") { + group = cordaGroup + dependsOn('getDevCordaLite', 'getPostgresJDBC') + doLast { + mkdir devEnvWorkspace + cordaLifeCycle.startCorda() + } +} + +tasks.register("stopCorda") { + group = cordaGroup + doLast { + cordaLifeCycle.stopCorda() + } +} + +tasks.register("getPostgresJDBC") { + group = supportingGroup + doLast { + copy { + from configurations.myPostgresJDBC + into "$cordaJDBCDir" + } + } +} + +tasks.register("getDevCordaLite", Copy) { + group = supportingGroup + from configurations.combinedWorker + into cordaBinDir +} + + +// Corda status queries + +def cordaStatusQueries = new CordaStatusQueries(projectContext) + + +tasks.register('listVNodes') { + group = queriesGroup + doLast { + cordaStatusQueries.listVNodes() + } +} + +tasks.register('listCPIs') { + group = queriesGroup + doLast { + cordaStatusQueries.listCPIs() + } +} + +// Build CPI tasks + +def buildCPIsHelper = new BuildCPIsHelper(projectContext, networkConfig) + +tasks.register("1-createGroupPolicy") { + group = cordappGroup + dependsOn('projInit') + doLast { + buildCPIsHelper.createGroupPolicy() + } +} + +tasks.register("getNotaryServerCPB", Copy) { + group = supportingGroup + from configurations.notaryServerCPB + into cordaNotaryServerDir +} + +tasks.register('2-createKeystore') { + group = cordappGroup + dependsOn('projInit') + doLast { + buildCPIsHelper.createKeyStore() + } +} + +tasks.register('3-buildCPIs') { + group = cordappGroup + def dependsOnTasks = subprojects.collect {it.tasks.findByName("build") } + dependsOnTasks.add('1-createGroupPolicy') + dependsOnTasks.add('2-createKeystore') + dependsOnTasks.add('getNotaryServerCPB') + dependsOn dependsOnTasks + doLast{ + buildCPIsHelper.buildCPIs() + } +} + + +// deploy CPI tasks + +def deployCPIsHelper = new DeployCPIsHelper(projectContext) + +tasks.register('4-deployCPIs') { + group = cordappGroup + dependsOn('3-buildCPIs') + doLast { + deployCPIsHelper.deployCPIs() + } +} + +// Setup VNodes tasks + +def vNodesHelper = new VNodesHelper(projectContext, networkConfig ) + +tasks.register('5-vNodeSetup') { + group = cordappGroup + dependsOn('4-deployCPIs') + doLast { + vNodesHelper.vNodesSetup() + } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java new file mode 100644 index 0000000..96140ef --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/BuildCPIsHelper.java @@ -0,0 +1,278 @@ +package com.r3.csde; + +import java.io.*; +import java.util.LinkedList; +import java.util.List; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class BuildCPIsHelper { + + public ProjectContext pc; + public ProjectUtils utils; + + public NetworkConfig config; + public BuildCPIsHelper(ProjectContext _pc, NetworkConfig _config) { + pc = _pc; + utils = new ProjectUtils(pc); + config = _config; + } + + public void createGroupPolicy() throws IOException { + + File groupPolicyFile = new File(String.format("%s/GroupPolicy.json", pc.devEnvWorkspace)); + File devnetFile = new File(pc.project.getRootDir() + "/" + config.getConfigFilePath()); + + + if (!groupPolicyFile.exists() || groupPolicyFile.lastModified() < devnetFile.lastModified()) { + + pc.out.println("createGroupPolicy: Creating a GroupPolicy"); + + List configX500Ids = config.getX500Names(); + LinkedList commandList = new LinkedList<>(); + + commandList.add(String.format("%s/java", pc.javaBinDir)); + commandList.add(String.format("-Dpf4j.pluginsDir=%s/plugins/", pc.cordaCliBinDir)); + commandList.add("-jar"); + commandList.add(String.format("%s/corda-cli.jar", pc.cordaCliBinDir)); + commandList.add("mgm"); + commandList.add("groupPolicy"); + for (String id : configX500Ids) { + commandList.add("--name"); + commandList.add(id); + } + commandList.add("--endpoint-protocol=1"); + commandList.add("--endpoint=http://localhost:1080"); + + ProcessBuilder pb = new ProcessBuilder(commandList); + pb.redirectErrorStream(true); + Process proc = pb.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream())); + + // todo add exception catching + FileWriter fileWriter = new FileWriter(groupPolicyFile); + String line; + while (( line = reader.readLine()) != null){ + fileWriter.write(line + "\n"); + } + fileWriter.close(); + + } else { + pc.out.println("createPolicyTask: everything up to date; nothing to do."); + } + + } + + public void createKeyStore() throws IOException, InterruptedException { + + File keystoreFile = new File(pc.keystoreFName); + if(!keystoreFile.exists()) { + pc.out.println("createKeystore: Create a keystore"); + + generateKeyPair(); + addDefaultSigningKey(); + exportCert(); + + } else { + pc.out.println("createKeystore: keystore already created; nothing to do."); + } + + } + + private void generateKeyPair() throws IOException, InterruptedException { + + LinkedList cmdArray = new LinkedList<>(); + + cmdArray.add(pc.javaBinDir + "/keytool"); + cmdArray.add("-genkeypair"); + cmdArray.add("-alias"); + cmdArray.add(pc.keystoreAlias); + cmdArray.add("-keystore"); + cmdArray.add(pc.keystoreFName); + cmdArray.add("-storepass"); + cmdArray.add("keystore password"); + cmdArray.add("-dname"); + cmdArray.add("CN=CPI Example - My Signing Key, O=CorpOrgCorp, L=London, C=GB"); + cmdArray.add("-keyalg"); + cmdArray.add("RSA"); + cmdArray.add("-storetype"); + cmdArray.add("pkcs12"); + cmdArray.add("-validity"); + cmdArray.add("4000"); + + ProcessBuilder pb = new ProcessBuilder(cmdArray); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + + } + + private void addDefaultSigningKey() throws IOException, InterruptedException { + + LinkedList cmdArray = new LinkedList<>(); + + cmdArray.add(pc.javaBinDir + "/keytool"); + cmdArray.add("-importcert"); + cmdArray.add("-keystore"); + cmdArray.add(pc.keystoreFName); + cmdArray.add("-storepass"); + cmdArray.add("keystore password"); + cmdArray.add("-noprompt"); + cmdArray.add("-alias"); + cmdArray.add(pc.signingCertAlias); + cmdArray.add("-file"); + cmdArray.add(pc.signingCertFName); + + ProcessBuilder pb = new ProcessBuilder(cmdArray); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + } + + private void exportCert() throws IOException, InterruptedException { + + LinkedList cmdArray = new LinkedList<>(); + + cmdArray.add(pc.javaBinDir + "/keytool"); + cmdArray.add("-exportcert"); + cmdArray.add("-rfc"); + cmdArray.add("-alias"); + cmdArray.add(pc.keystoreAlias); + cmdArray.add("-keystore"); + cmdArray.add(pc.keystoreFName); + cmdArray.add("-storepass"); + cmdArray.add("keystore password"); + cmdArray.add("-file"); + cmdArray.add(pc.keystoreCertFName); + + ProcessBuilder pb = new ProcessBuilder(cmdArray); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + + } + + public void buildCPIs() throws IOException, InterruptedException, CsdeException { + createCorDappCPI(); + createNotaryCPI(); + } + + private void createCorDappCPI() throws IOException, InterruptedException, CsdeException { + + String appCPIFilePath = pc.workflowBuildDir + "/" + + pc.project.getRootProject().getName() + "-" + + pc.project.getVersion() + ".cpi"; + + File appCPIFile = new File(appCPIFilePath); + appCPIFile.delete(); + + File srcDir = new File(pc.workflowBuildDir + "/libs"); + File[] appCPBs = srcDir.listFiles(( x , name ) -> name.endsWith(".cpb")); + if (appCPBs == null) throw new CsdeException("Expecting exactly one CPB but no CPB found."); + if (appCPBs.length != 1) throw new CsdeException("Expecting exactly one CPB but more than one found."); + + pc.out.println("appCpbs:"); + pc.out.println(appCPBs[0].getAbsolutePath()); + + LinkedList commandList = new LinkedList<>(); + + commandList.add(String.format("%s/java", pc.javaBinDir)); + commandList.add(String.format("-Dpf4j.pluginsDir=%s/plugins/", pc.cordaCliBinDir)); + commandList.add("-jar"); + commandList.add(String.format("%s/corda-cli.jar", pc.cordaCliBinDir)); + commandList.add("package"); + commandList.add("create-cpi"); + commandList.add("--cpb"); + commandList.add(appCPBs[0].getAbsolutePath()); + commandList.add("--group-policy"); + commandList.add(pc.devEnvWorkspace + "/GroupPolicy.json"); + commandList.add("--cpi-name"); + commandList.add(pc.appCPIName); + commandList.add("--cpi-version"); + commandList.add(pc.project.getVersion().toString()); + commandList.add("--file"); + commandList.add(appCPIFilePath); + commandList.add("--keystore"); + commandList.add(pc.devEnvWorkspace + "/signingkeys.pfx"); + commandList.add("--storepass"); + commandList.add("keystore password"); + commandList.add("--key"); + commandList.add("my-signing-key"); // todo: should be passed as context property + + + + ProcessBuilder pb = new ProcessBuilder(commandList); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + +// todo: work out how to capture error code better than the following code + +// BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream())); +// File tempOutputFile = new File(String.format("%s/tempOutput.txt", pc.devEnvWorkspace)); +// tempOutputFile.delete(); +// FileWriter fileWriter = new FileWriter(tempOutputFile); +// String line; +// while (( line = reader.readLine()) != null){ +// fileWriter.write(line + "\n"); +// } +// fileWriter.close(); + + } + + private void createNotaryCPI() throws CsdeException, IOException, InterruptedException { + + String notaryCPIFilePath = pc.workflowBuildDir + "/" + + pc.notaryCPIName.replace(' ', '-').toLowerCase() + "-" + + pc.project.getVersion() + ".cpi"; + + File notaryCPIFile = new File(notaryCPIFilePath); + notaryCPIFile.delete(); + + File srcDir = new File(pc.cordaNotaryServiceDir); + File[] notaryCPBs = srcDir.listFiles(( x , name ) -> name.endsWith(".cpb") && name.contains(pc.cordaNotaryPluginsVersion)); + if (notaryCPBs == null) throw new CsdeException("Expecting exactly one notary CPB but no CPB found."); + if (notaryCPBs.length != 1) throw new CsdeException("Expecting exactly one notary CPB but more than one found."); + + pc.out.println("notaryCpbs:"); + pc.out.println(notaryCPBs[0]); + + LinkedList commandList = new LinkedList<>(); + + commandList.add(String.format("%s/java", pc.javaBinDir)); + commandList.add(String.format("-Dpf4j.pluginsDir=%s/plugins/", pc.cordaCliBinDir)); + commandList.add("-jar"); + commandList.add(String.format("%s/corda-cli.jar", pc.cordaCliBinDir)); + commandList.add("package"); + commandList.add("create-cpi"); + commandList.add("--cpb"); + commandList.add(notaryCPBs[0].getAbsolutePath()); + commandList.add("--group-policy"); + commandList.add(pc.devEnvWorkspace + "/GroupPolicy.json"); + commandList.add("--cpi-name"); + commandList.add(pc.notaryCPIName); + commandList.add("--cpi-version"); + commandList.add(pc.project.getVersion().toString()); + commandList.add("--file"); + commandList.add(notaryCPIFilePath); + commandList.add("--keystore"); + commandList.add(pc.devEnvWorkspace + "/signingkeys.pfx"); + commandList.add("--storepass"); + commandList.add("keystore password"); + commandList.add("--key"); + commandList.add("my-signing-key"); + + ProcessBuilder pb = new ProcessBuilder(commandList); + pb.redirectErrorStream(true); + Process proc = pb.start(); + proc.waitFor(); + + } + + // todo: this might be needed for improved logging + private void printCmdArray(LinkedList cmdArray) { + for (int i = 0; i < cmdArray.size(); i++) { + pc.out.print(cmdArray.get(i) + " "); + } + } + +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java new file mode 100644 index 0000000..daa78a6 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaLifeCycleHelper.java @@ -0,0 +1,93 @@ +package com.r3.csde; + +import kong.unirest.Unirest; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.util.Scanner; + +/** + * Manages Bringing corda up, testing for liveness and taking corda down + */ +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class CordaLifeCycleHelper { + + ProjectContext pc; + ProjectUtils utils; + + public CordaLifeCycleHelper(ProjectContext _pc) { + pc = _pc; + utils = new ProjectUtils(pc); + Unirest.config().verifySsl(false); + } + + public void startCorda() throws IOException { + PrintStream pidStore = new PrintStream(new FileOutputStream(pc.cordaPidCache)); + File combinedWorkerJar = pc.project.getConfigurations().getByName("combinedWorker").getSingleFile(); + + // Manual version of the command to start postgres (for reference): + // docker run -d --rm -p5432:5432 --name CSDEpostgresql -e POSTGRES_DB=cordacluster -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password postgres:latest + + new ProcessBuilder( + "docker", + "run", "-d", "--rm", + "-p", "5432:5432", + "--name", pc.dbContainerName, + "-e", "POSTGRES_DB=cordacluster", + "-e", "POSTGRES_USER=postgres", + "-e", "POSTGRES_PASSWORD=password", + "postgres:latest").start(); + + // todo: we should poll for readiness not wait 10 seconds, see https://r3-cev.atlassian.net/browse/CORE-11626 + utils.rpcWait(10000); + + ProcessBuilder procBuild = new ProcessBuilder(pc.javaBinDir + "/java", + "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", + "-DsecurityMangerEnabled=false", + "-Dlog4j.configurationFile=" + pc.project.getRootDir() + "/config/log4j2.xml", + "-Dco.paralleluniverse.fibers.verifyInstrumentation=true", + "-jar", + combinedWorkerJar.toString(), + "--instance-id=0", + "-mbus.busType=DATABASE", + "-spassphrase=password", + "-ssalt=salt", + "-ddatabase.user=user", + "-ddatabase.pass=password", + "-ddatabase.jdbc.url=jdbc:postgresql://localhost:5432/cordacluster", + "-ddatabase.jdbc.directory="+pc.JDBCDir); + + procBuild.redirectErrorStream(true); + Process proc = procBuild.start(); + pidStore.print(proc.pid()); + pc.out.println("Corda Process-id="+proc.pid()); + proc.getInputStream().transferTo(pc.out); + + // todo: we should poll for readiness before completing the startCorda task, see https://r3-cev.atlassian.net/browse/CORE-11625 + } + + + public void stopCorda() throws IOException, CsdeException { + File cordaPIDFile = new File(pc.cordaPidCache); + if(cordaPIDFile.exists()) { + Scanner sc = new Scanner(cordaPIDFile); + long pid = sc.nextLong(); + pc.out.println("pid to kill=" + pid); + + if (System.getProperty("os.name").toLowerCase().contains("windows")) { + new ProcessBuilder("Powershell", "-Command", "Stop-Process", "-Id", Long.toString(pid), "-PassThru").start(); + } else { + new ProcessBuilder("kill", "-9", Long.toString(pid)).start(); + } + + Process proc = new ProcessBuilder("docker", "stop", pc.dbContainerName).start(); + + cordaPIDFile.delete(); + } + else { + throw new CsdeException("Cannot stop the Combined worker\nCached process ID file " + pc.cordaPidCache + " missing.\nWas the combined worker not started?"); + } + } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java new file mode 100644 index 0000000..95072ba --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CordaStatusQueries.java @@ -0,0 +1,64 @@ +package com.r3.csde; + +import kong.unirest.JsonNode; +import kong.unirest.Unirest; +import kong.unirest.json.JSONArray; +import kong.unirest.json.JSONObject; +import kong.unirest.HttpResponse; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class CordaStatusQueries { + + ProjectContext pc; + public CordaStatusQueries(ProjectContext _pc){ pc = _pc; } + + + public HttpResponse getVNodeInfo() { + Unirest.config().verifySsl(false); + return Unirest.get(pc.baseURL + "/api/v1/virtualnode/") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + } + public void listVNodesVerbose() { + HttpResponse vnodeResponse = getVNodeInfo(); + pc.out.println("VNodes:\n" + vnodeResponse.getBody().toPrettyString()); + } + + // X500Name, shorthash, cpiname + public void listVNodes() { + HttpResponse vnodeResponse = getVNodeInfo(); + + JSONArray virtualNodesJson = (JSONArray) vnodeResponse.getBody().getObject().get("virtualNodes"); + pc.out.println("X500 Name\tHolding identity short hash\tCPI Name"); + for(Object o: virtualNodesJson){ + if(o instanceof JSONObject) { + JSONObject idObj = ((JSONObject) o).getJSONObject("holdingIdentity"); + JSONObject cpiObj = ((JSONObject) o).getJSONObject("cpiIdentifier"); + pc.out.print("\"" + idObj.get("x500Name") + "\""); + pc.out.print("\t\"" + idObj.get("shortHash") + "\""); + pc.out.println("\t\"" + cpiObj.get("cpiName") + "\""); + } + } + } + + public HttpResponse getCpiInfo() { + Unirest.config().verifySsl(false); + return Unirest.get(pc.baseURL + "/api/v1/cpi/") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + } + + public void listCPIs() { + HttpResponse cpiResponse = getCpiInfo(); + JSONArray jArray = (JSONArray) cpiResponse.getBody().getObject().get("cpis"); + + for(Object o: jArray){ + if(o instanceof JSONObject) { + JSONObject idObj = ((JSONObject) o).getJSONObject("id"); + pc.out.print("cpiName=" + idObj.get("cpiName")); + pc.out.println(", cpiVersion=" + idObj.get("cpiVersion")); + } + } + } + +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java new file mode 100644 index 0000000..72f8fea --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/CsdeException.java @@ -0,0 +1,10 @@ +package com.r3.csde; + +public class CsdeException extends Exception { + public CsdeException(String message, Throwable cause) { + super(message, cause); + } + public CsdeException(String message){ + super(message); + } +} \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java new file mode 100644 index 0000000..fe1362b --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/DeployCPIsHelper.java @@ -0,0 +1,187 @@ +package com.r3.csde; + +import kong.unirest.JsonNode; +import kong.unirest.Unirest; +import kong.unirest.json.JSONArray; +import kong.unirest.json.JSONObject; +import kong.unirest.HttpResponse; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.PrintStream; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_OK; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class DeployCPIsHelper { + + public DeployCPIsHelper() { + } + ProjectContext pc; + CordaStatusQueries queries; + ProjectUtils utils; + + public DeployCPIsHelper(ProjectContext _pc) { + pc = _pc; + queries = new CordaStatusQueries(pc); + utils = new ProjectUtils(pc); + } + + public void deployCPIs() throws FileNotFoundException, CsdeException{ + + uploadCertificate(pc.signingCertAlias, pc.signingCertFName); + uploadCertificate(pc.keystoreAlias, pc.keystoreCertFName); + + // todo: make consistent with other string building code - remove String.format + String appCPILocation = String.format("%s/%s-%s.cpi", + pc.workflowBuildDir, + pc.project.getName(), + pc.project.getVersion()); + deployCPI(appCPILocation, pc.appCPIName,pc.project.getVersion().toString()); + + String notaryCPILocation = String.format("%s/%s-%s.cpi", + pc.workflowBuildDir, + pc.notaryCPIName.replace(' ','-').toLowerCase(), + pc.project.getVersion()); + deployCPI(notaryCPILocation, + pc.notaryCPIName, + pc.project.getVersion().toString(), + "-NotaryServer" ); + + } + + public void uploadCertificate(String certAlias, String certFName) { + Unirest.config().verifySsl(false); + HttpResponse uploadResponse = Unirest.put(pc.baseURL + "/api/v1/certificates/cluster/code-signer") + .field("alias", certAlias) + .field("certificate", new File(certFName)) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + pc.out.println("Certificate/key upload, alias "+certAlias+" certificate/key file "+certFName); + pc.out.println(uploadResponse.getBody().toPrettyString()); + } + + public void forceuploadCPI(String cpiFName) throws FileNotFoundException, CsdeException { + forceuploadCPI(cpiFName, ""); + } + + public void forceuploadCPI(String cpiFName, String uploadStatusQualifier) throws FileNotFoundException, CsdeException { + Unirest.config().verifySsl(false); + HttpResponse jsonResponse = Unirest.post(pc.baseURL + "/api/v1/maintenance/virtualnode/forcecpiupload/") + .field("upload", new File(cpiFName)) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if(jsonResponse.getStatus() == HTTP_OK) { + String id = (String) jsonResponse.getBody().getObject().get("id"); + pc.out.println("get id:\n" +id); + HttpResponse statusResponse = uploadStatus(id); + + if (statusResponse.getStatus() == HTTP_OK) { + PrintStream cpiUploadStatus = new PrintStream(new FileOutputStream( + pc.CPIUploadStatusFName.replace(".json", uploadStatusQualifier + ".json" ))); + cpiUploadStatus.print(statusResponse.getBody()); + pc.out.println("Caching CPI file upload status:\n" + statusResponse.getBody()); + } else { + utils.reportError(statusResponse); + } + } + else { + utils.reportError(jsonResponse); + } + } + + private boolean uploadStatusRetry(HttpResponse response) { + int status = response.getStatus(); + JsonNode body = response.getBody(); + // Do not retry on success // todo: need to think through the possible outcomes here - what if the bodyTitle is null, it won't retry + if(status == HTTP_OK) { + // Keep retrying until we get "OK" may move through "Validating upload", "Persisting CPI" + return !(body.getObject().get("status").equals("OK")); + } + else if (status == HTTP_BAD_REQUEST){ + String bodyTitle = response.getBody().getObject().getString("title"); + return bodyTitle != null && bodyTitle.matches("No such requestId=[-0-9a-f]+"); + } + return false; + } + + public HttpResponse uploadStatus(String requestId) { + HttpResponse statusResponse = null; + do { + utils.rpcWait(1000); + statusResponse = Unirest + .get(pc.baseURL + "/api/v1/cpi/status/" + requestId + "/") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + pc.out.println("Upload status="+statusResponse.getStatus()+", status query response:\n"+statusResponse.getBody().toPrettyString()); + } + while(uploadStatusRetry(statusResponse)); + + return statusResponse; + } + + public void deployCPI(String cpiFName, String cpiName, String cpiVersion) throws FileNotFoundException, CsdeException { + deployCPI(cpiFName, cpiName, cpiVersion, ""); + } + + public void deployCPI(String cpiFName, + String cpiName, + String cpiVersion, + String uploadStatusQualifier) throws FileNotFoundException, CsdeException { + // todo: where is the primary instance declared? + Unirest.config().verifySsl(false); + + HttpResponse cpiResponse = queries.getCpiInfo(); + JSONArray jArray = (JSONArray) cpiResponse.getBody().getObject().get("cpis"); + + int matches = 0; + for(Object o: jArray.toList() ) { + if(o instanceof JSONObject) { + JSONObject idObj = ((JSONObject) o).getJSONObject("id"); + if((idObj.get("cpiName").toString().equals(cpiName) + && idObj.get("cpiVersion").toString().equals(cpiVersion))) { + matches++; + } + } + } + pc.out.println("Matching CPIS="+matches); + + if(matches == 0) { + HttpResponse uploadResponse = Unirest.post(pc.baseURL + "/api/v1/cpi/") + .field("upload", new File(cpiFName)) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + JsonNode body = uploadResponse.getBody(); + + int status = uploadResponse.getStatus(); + + pc.out.println("Upload Status:" + status); + pc.out.println("Pretty print the body\n" + body.toPrettyString()); + + // We expect the id field to be a string. + if (status == HTTP_OK) { + String id = (String) body.getObject().get("id"); + pc.out.println("get id:\n" + id); + + HttpResponse statusResponse = uploadStatus(id); + if (statusResponse.getStatus() == HTTP_OK) { + PrintStream cpiUploadStatus = new PrintStream(new FileOutputStream( + pc.CPIUploadStatusFName.replace(".json", uploadStatusQualifier + ".json" ))); + cpiUploadStatus.print(statusResponse.getBody()); + pc.out.println("Caching CPI file upload status:\n" + statusResponse.getBody()); + } else { + utils.reportError(statusResponse); + } + } else { + utils.reportError(uploadResponse); + } + } + else { + pc.out.println("CPI already uploaded doing a 'force' upload."); + forceuploadCPI(cpiFName, uploadStatusQualifier); + } + } + +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java new file mode 100644 index 0000000..9b3f0ca --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/NetworkConfig.java @@ -0,0 +1,40 @@ +package com.r3.csde; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.FileInputStream; +import java.util.List; +import java.util.stream.Collectors; + + +/** + * This class reads the network config from the json file and makes it available as a list of VNodes. + */ +public class NetworkConfig { + + private List vNodes; + private String configFilePath; + + public NetworkConfig(String _configFilePath) throws CsdeException { + configFilePath = _configFilePath; + + ObjectMapper mapper = new ObjectMapper(); + try { + FileInputStream in = new FileInputStream(configFilePath); + vNodes = mapper.readValue(in, new TypeReference>() { + }); + } catch (Exception e) { + throw new CsdeException("Failed to read static network configuration file, with exception: " + e); + } + } + + String getConfigFilePath() { return configFilePath; } + + List getVNodes() { return vNodes; } + + List getX500Names() { + return vNodes.stream().map(vn -> vn.getX500Name()).collect(Collectors.toList()); + } + +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java new file mode 100644 index 0000000..0e84567 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectContext.java @@ -0,0 +1,84 @@ +package com.r3.csde; + +import org.gradle.api.Project; +import java.io.PrintStream; +import java.util.Map; + +public class ProjectContext { + Project project; + String baseURL = "https://localhost:8888"; + String rpcUser = "admin"; + String rpcPasswd = "admin"; + String workspaceDir = "workspace"; + int retryWaitMs = 1000; + PrintStream out = System.out; + String CPIUploadStatusBaseName = "CPIFileStatus.json"; + String NotaryCPIUploadBaseName = "CPIFileStatus-NotaryServer.json"; + String CPIUploadStatusFName; + String NotaryCPIUploadStatusFName; + String javaBinDir; + String cordaPidCache = "CordaPIDCache.dat"; + String dbContainerName; + String JDBCDir; + String combinedWorkerBinRe; + Map notaryRepresentatives = null; + String signingCertAlias; + String signingCertFName; + String keystoreAlias; + String keystoreFName; + String keystoreCertFName; + String appCPIName; + String notaryCPIName; + String devEnvWorkspace; + String cordaCliBinDir; + String cordaNotaryServiceDir; + String workflowBuildDir; + String cordaNotaryPluginsVersion; + + public ProjectContext (Project inProject, + String inBaseUrl, + String inRpcUser, + String inRpcPasswd, + String inWorkspaceDir, + String inJavaBinDir, + String inDbContainerName, + String inJDBCDir, + String inCordaPidCache, + String inSigningCertAlias, + String inSigningCertFName, + String inKeystoreAlias, + String inKeystoreFName, + String inKeystoreCertFName, + String inAppCPIName, + String inNotaryCPIName, + String inDevEnvWorkspace, + String inCordaCLiBinDir, + String inCordaNotaryServiceDir, + String inWorkflowBuildDir, + String inCordaNotaryPluginsVersion + ) { + project = inProject; + baseURL = inBaseUrl; + rpcUser = inRpcUser; + rpcPasswd = inRpcPasswd; + workspaceDir = inWorkspaceDir; + javaBinDir = inJavaBinDir; + cordaPidCache = inCordaPidCache; + dbContainerName = inDbContainerName; + JDBCDir = inJDBCDir; + CPIUploadStatusFName = workspaceDir + "/" + CPIUploadStatusBaseName; + NotaryCPIUploadStatusFName = workspaceDir + "/" + NotaryCPIUploadBaseName; + signingCertAlias = inSigningCertAlias; + signingCertFName = inSigningCertFName; + keystoreAlias = inKeystoreAlias; + keystoreFName = inKeystoreFName; + keystoreCertFName = inKeystoreCertFName; + appCPIName = inAppCPIName; + notaryCPIName = inNotaryCPIName; + devEnvWorkspace = inDevEnvWorkspace; + cordaCliBinDir = inCordaCLiBinDir; + cordaNotaryServiceDir = inCordaNotaryServiceDir; + workflowBuildDir = inWorkflowBuildDir; + cordaNotaryPluginsVersion = inCordaNotaryPluginsVersion; + } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java new file mode 100644 index 0000000..dbc3dd8 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/ProjectUtils.java @@ -0,0 +1,36 @@ +package com.r3.csde; + +import kong.unirest.HttpResponse; +import kong.unirest.JsonNode; + +import static java.lang.Thread.sleep; + +// todo: This class needs refactoring, see https://r3-cev.atlassian.net/browse/CORE-11624 +public class ProjectUtils { + + ProjectContext pc; + + ProjectUtils(ProjectContext _pc) { + pc = _pc; + } + + void rpcWait(int millis) { + try { + sleep(millis); + } + catch(InterruptedException e) { + throw new UnsupportedOperationException("Interrupts not supported.", e); + } + } + + public void reportError(HttpResponse response) throws CsdeException { + + pc.out.println("*** *** ***"); + pc.out.println("Unexpected response from Corda"); + pc.out.println("Status="+ response.getStatus()); + pc.out.println("*** Headers ***\n"+ response.getHeaders()); + pc.out.println("*** Body ***\n"+ response.getBody()); + pc.out.println("*** *** ***"); + throw new CsdeException("Error: unexpected response from Corda."); + } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java new file mode 100644 index 0000000..d77d85c --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNode.java @@ -0,0 +1,25 @@ +package com.r3.csde; + + +/** + * This class is a representation of a Vnode used to express the vNodes required on the network. + */ + +public class VNode { + private String x500Name; + private String cpi; + + private String serviceX500Name; + + public VNode() { } + + public String getX500Name(){ return x500Name; } + public void setX500Name(String _x500Name) { x500Name = _x500Name; } + + public String getCpi() { return cpi; } + public void setCpi(String _cpi) { cpi = _cpi; } + + public String getServiceX500Name() { return serviceX500Name; } + public void setServiceX500Name(String _name) { serviceX500Name = _name; } + +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java new file mode 100644 index 0000000..bafdc75 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/VNodesHelper.java @@ -0,0 +1,288 @@ +package com.r3.csde; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.r3.csde.dtos.*; +import kong.unirest.HttpResponse; +import kong.unirest.JsonNode; +import kong.unirest.Unirest; +import java.io.FileInputStream; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import static java.net.HttpURLConnection.HTTP_OK; + +/** + * The VNodesHelper class is used to create and register the Vnodes specified in the static-network-config.json file. + */ + +public class VNodesHelper { + private ProjectContext pc; + private ProjectUtils utils; + private NetworkConfig config; + private ObjectMapper mapper; + + public VNodesHelper(ProjectContext _pc, NetworkConfig _config) { + pc = _pc; + utils = new ProjectUtils(pc); + config = _config; + Unirest.config().verifySsl(false); + mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + /** + * Entry point for setting up vnodes, called from the 5-vNodeSetup task in csde.gradle + */ + public void vNodesSetup() throws CsdeException { + List requiredNodes = config.getVNodes(); + createVNodes(requiredNodes); + registerVNodes(requiredNodes); + } + + /** + * Creates vnodes specified in the config if they don't already exist. + * @param requiredNodes represents the list of VNodes as specified in the network Config json file (static-network-config.json) + */ + private void createVNodes(List requiredNodes) throws CsdeException { + + // Get existing Nodes. + List existingVNodes = getExistingNodes(); + + // Check if each required vnode already exist, if not create it. + for (VNode vn : requiredNodes) { + // Match on x500 and cpi name + List matches = existingVNodes + .stream() + .filter(existing -> + existing.getHoldingIdentity().getX500Name().equals( vn.getX500Name()) && + existing.getCpiIdentifier().getCpiName().equals(vn.getCpi())) + .collect(Collectors.toList()); + + if (matches.size() == 0) { + createVNode(vn); + } + } + } + + /** + * Gets a list of the virtual nodes which have already been created. + * @return a list of the virtual nodes which have already been created. + */ + private List getExistingNodes () throws CsdeException { + + HttpResponse response = Unirest.get(pc.baseURL + "/api/v1/virtualnode") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) { + throw new CsdeException("Failed to get Existing vNodes, response status: "+ response.getStatus()); + } + + try { + return mapper.readValue(response.getBody().toString(), VirtualNodesDTO.class).getVirtualNodes(); + } catch (Exception e){ + throw new CsdeException("Failed to get Existing vNodes with exception: " + e); + } + } + + /** + * Creates a vnode on the Corda cluster from the VNode info. + * @param vNode represents a virtual node using VNode Class. + */ + private void createVNode(VNode vNode) throws CsdeException { + + pc.out.println("Creating virtual node for "+ vNode.getX500Name()); + + // Reads the current CPIFileChecksum value and checks it has been uploaded. + String cpiCheckSum = getCpiCheckSum(vNode); + if (!checkCpiUploaded(cpiCheckSum)) throw new CsdeException("Cpi " + cpiCheckSum + " not uploaded."); + + // Creates the vnode on Cluster + HttpResponse response = Unirest.post(pc.baseURL + "/api/v1/virtualnode") + .body("{ \"request\" : { \"cpiFileChecksum\": \"" + cpiCheckSum + "\", \"x500Name\": \"" + vNode.getX500Name() + "\" } }") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) { + throw new CsdeException("Creation of virtual node failed with response status: " + response.getStatus()); + } + } + + /** + * Reads the latest CPI checksums from file. + * @param vNode represents a virtual node using VNode Class. + */ + private String getCpiCheckSum(VNode vNode) throws CsdeException { + + try { + String file = (vNode.getServiceX500Name() == null) ? pc.CPIUploadStatusFName : pc.NotaryCPIUploadStatusFName; + FileInputStream in = new FileInputStream(file); + CPIFileStatusDTO statusDTO = mapper.readValue(in, CPIFileStatusDTO.class); + return statusDTO.getCpiFileChecksum().toString(); + } catch (Exception e) { + throw new CsdeException("Failed to read CPI checksum from file, with error: " + e); + } + } + + private boolean checkCpiUploaded(String cpiCheckSum) throws CsdeException { + + HttpResponse response = Unirest.get(pc.baseURL + "/api/v1/cpi") + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) { + throw new CsdeException("Failed to check cpis, response status: " + response.getStatus()); + } + + try { + GetCPIsResponseDTO cpisResponse = mapper.readValue( + response.getBody().toString(), GetCPIsResponseDTO.class); + + for (CpiMetadataDTO cpi: cpisResponse.getCpis()) { + if (Objects.equals(cpi.getCpiFileChecksum(), cpiCheckSum)) { + return true; + } + } + // Returns false if no cpis were returned or the cpiCheckSum didnt' match ay results. + return false; + } catch (Exception e) { + throw new CsdeException("Failed to check cpis with exception: " + e); + } + } + + /** + * Checks if the required virtual nodes have been registered and if not registers them. + * @param requiredNodes represents the list of VNodes as specified in the network Config json file (static-network-config.json) + */ + private void registerVNodes(List requiredNodes) throws CsdeException { + + // There appears to be a delay between the successful post /virtualnodes synchronous call and the + // vnodes being returned in the GET /virtualnodes call. Putting a thread wait here as a quick fix + // as this will move to async mechanism post beta2. + utils.rpcWait(2000); + List existingVNodes = getExistingNodes(); + + for (VNode vn: requiredNodes) { + // Match on x500 and cpi name + List matches = existingVNodes + .stream() + .filter(existing -> + existing.getHoldingIdentity().getX500Name().equals( vn.getX500Name()) && + existing.getCpiIdentifier().getCpiName().equals(vn.getCpi())) + .collect(Collectors.toList()); + + if (matches.size() == 0) { + throw new CsdeException("Registration failed because virtual node for '" + vn.getX500Name() + "' not found."); + } else if (matches.size() >1 ) { + throw new CsdeException(("Registration failed because more than virtual node for '" + vn.getX500Name() + "'")); + } + + String shortHash = matches.get(0).getHoldingIdentity().getShortHash(); + + if (!checkVNodeIsRegistered(shortHash)) { + registerVnode(vn, shortHash); + } + } + } + + /** + * Registers a virtual node. + * @param vnode represents the vnode to be registered. + * @param shortHash the shortHash of the virtual node to register. + */ + private void registerVnode(VNode vnode, String shortHash) throws CsdeException { + + pc.out.println("Registering vNode: " + vnode.getX500Name() + " with shortHash: " + shortHash); + + // Configure the registration body (notary vs non notary) + String registrationBody; + if (vnode.getServiceX500Name() == null) { + registrationBody = + "{ \"action\" : \"requestJoin\"," + + " \"context\" : {" + + " \"corda.key.scheme\" : \"CORDA.ECDSA.SECP256R1\" } }"; + } else { + registrationBody = + "{ \"action\" : \"requestJoin\"," + + " \"context\" : {" + + " \"corda.key.scheme\" : \"CORDA.ECDSA.SECP256R1\", " + + " \"corda.roles.0\" : \"notary\", " + + " \"corda.notary.service.name\" : \"" + vnode.getServiceX500Name() + "\", " + + " \"corda.notary.service.plugin\" : \"net.corda.notary.NonValidatingNotary\" } }"; + } + + HttpResponse response = Unirest.post(pc.baseURL + "/api/v1/membership/" + shortHash) + .body(registrationBody) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() == HTTP_OK) { + pc.out.println("Membership requested for node " + shortHash); + } else { + throw new CsdeException("Failed to register virtual node " + shortHash + ", response status: " + response.getStatus() ); + } + + // wait until Vnode registered + pollForRegistration(shortHash, 30000, 1000); + + } + + /** + * Checks if a virtual node with given shortHash has been registered + * @param shortHash shortHash of the node which is being checked. + * @return returns true if the vnode is registered + */ + private boolean checkVNodeIsRegistered(String shortHash) throws CsdeException { + + // Queries registration status for vnode. + HttpResponse response = Unirest.get(pc.baseURL + "/api/v1/membership/" + shortHash) + .basicAuth(pc.rpcUser, pc.rpcPasswd) + .asJson(); + + if (response.getStatus() != HTTP_OK) + throw new CsdeException("Failed to check registration status for virtual node '" + shortHash + + "' response status: " + response.getStatus()); + + try { + // If the response body is not empty check all previous requests for an "APPROVED" + if (!response.getBody().getArray().isEmpty()) { + List requests = mapper.readValue( + response.getBody().toString(), new TypeReference<>() { + }); + for (RegistrationRequestProgressDTO request : requests) { + if (Objects.equals(request.getRegistrationStatus(), "APPROVED")) { + return true; + } + } + } + // Returns false if array was empty or "APPROVED" wasn't found + return false; + + } catch (Exception e) { + throw new CsdeException("Failed to check registration status for " + shortHash + + " with exception " + e); + } + } + + /** + * Polls for registration of a vnode + * @param shortHash short hash of the node which is being poled for + * @param duration the number of milliseconds before the pollign times out + * @param cooldown the number of milliseconds in between poll attempts. + */ + private void pollForRegistration(String shortHash, int duration, int cooldown) throws CsdeException { + + int timer = 0; + while (timer < duration ) { + if (checkVNodeIsRegistered(shortHash)){ + pc.out.println("Vnode " + shortHash +" registered."); + return; + } + utils.rpcWait(cooldown); + timer += cooldown; + } + throw new CsdeException("Vnode " + shortHash + " failed to register in " + duration + " milliseconds"); + } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java new file mode 100644 index 0000000..1c6e118 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CPIFileStatusDTO.java @@ -0,0 +1,16 @@ +package com.r3.csde.dtos; + +public class CPIFileStatusDTO { + private String status; + private String cpiFileChecksum; + + public CPIFileStatusDTO() {} + + public String getStatus() { return status; } + + public void setStatus(String status) { this.status = status; } + + public String getCpiFileChecksum() { return cpiFileChecksum; } + + public void setCpiFileChecksum(String cpiFileChecksum) { this.cpiFileChecksum = cpiFileChecksum; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java new file mode 100644 index 0000000..e5d5dde --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiIdentifierDTO.java @@ -0,0 +1,23 @@ +package com.r3.csde.dtos; + +public class CpiIdentifierDTO { + + // Note, these DTOs don't cover all returned values, just the ones required for CSDE + private String cpiName; + private String cpiVersion; + private String signerSummaryHash; + + public CpiIdentifierDTO() { } + + public String getCpiName() { return cpiName; } + + public void setCpiName(String cpiName) { this.cpiName = cpiName; } + + public String getCpiVersion() { return cpiVersion; } + + public void setCpiVersion(String cpiVersion) { this.cpiVersion = cpiVersion; } + + public String getSignerSummaryHash() { return signerSummaryHash; } + + public void setSignerSummaryHash(String signerSummaryHash) { this.signerSummaryHash = signerSummaryHash; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java new file mode 100644 index 0000000..ef89c7d --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/CpiMetadataDTO.java @@ -0,0 +1,17 @@ +package com.r3.csde.dtos; + +public class CpiMetadataDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private String cpiFileChecksum; + private CpiIdentifierDTO id; + + public CpiMetadataDTO() {} + + public String getCpiFileChecksum() { return cpiFileChecksum; } + + public void setCpiFileChecksum(String cpiFileChecksum) { this.cpiFileChecksum = cpiFileChecksum; } + + public CpiIdentifierDTO getId() { return id; } + + public void setId(CpiIdentifierDTO id) { this.id = id; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java new file mode 100644 index 0000000..a16e9a1 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/GetCPIsResponseDTO.java @@ -0,0 +1,14 @@ +package com.r3.csde.dtos; + +import java.util.List; + +public class GetCPIsResponseDTO { + + private List cpis; + + public GetCPIsResponseDTO() {} + + public List getCpis() { return cpis; } + + public void setCpis(List cpis) { this.cpis = cpis; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java new file mode 100644 index 0000000..48c12c0 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/HoldingIdentityDTO.java @@ -0,0 +1,27 @@ +package com.r3.csde.dtos; + +public class HoldingIdentityDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private String fullHash; + private String groupId; + private String shortHash; + private String x500Name; + + public HoldingIdentityDTO() {} + + public String getFullHash() { return fullHash; } + + public void setFullHash(String fullHash) { this.fullHash = fullHash; } + + public String getGroupId() { return groupId; } + + public void setGroupID(String groupID) { this.groupId = groupId; } + + public String getShortHash() { return shortHash; } + + public void setShortHash(String shortHash) { this.shortHash = shortHash; } + + public String getX500Name() { return x500Name; } + + public void setX500Name(String x500Name) { this.x500Name = x500Name; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java new file mode 100644 index 0000000..b3e63b0 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/RegistrationRequestProgressDTO.java @@ -0,0 +1,17 @@ +package com.r3.csde.dtos; + +public class RegistrationRequestProgressDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private String registrationStatus; + private String reason; + + public RegistrationRequestProgressDTO() {} + + public String getRegistrationStatus() { return registrationStatus; } + + public void setRegistrationStatus(String registrationStatus) { this.registrationStatus = registrationStatus; } + + public String getReason() { return reason; } + + public void setReason(String reason) { this.reason = reason; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java new file mode 100644 index 0000000..152cf3e --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodeInfoDTO.java @@ -0,0 +1,17 @@ +package com.r3.csde.dtos; + +public class VirtualNodeInfoDTO { + // Note, these DTOs don't cover all returned values, just the ones required for CSDE. + private HoldingIdentityDTO holdingIdentity; + private CpiIdentifierDTO cpiIdentifier; + + public VirtualNodeInfoDTO() {} + + public HoldingIdentityDTO getHoldingIdentity() { return holdingIdentity; } + + public void setHoldingIdentity(HoldingIdentityDTO holdingIdentity) { this.holdingIdentity = holdingIdentity; } + + public CpiIdentifierDTO getCpiIdentifier() { return cpiIdentifier; } + + public void setCpiIdentifier(CpiIdentifierDTO cpiIdentifier) { this.cpiIdentifier = cpiIdentifier; } +} diff --git a/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java new file mode 100644 index 0000000..86966b7 --- /dev/null +++ b/java-samples/mgm-dynamic-network/buildSrc/src/main/java/com/r3/csde/dtos/VirtualNodesDTO.java @@ -0,0 +1,14 @@ +package com.r3.csde.dtos; + +import java.util.List; + +public class VirtualNodesDTO { + + private List virtualNodes; + + public VirtualNodesDTO() {} + + public List getVirtualNodes() { return virtualNodes; } + + public void setVirtualNodes(List virtualNodes) { this.virtualNodes = virtualNodes; } +} diff --git a/java-samples/mgm-dynamic-network/clean.sh b/java-samples/mgm-dynamic-network/clean.sh new file mode 100644 index 0000000..f6f6851 --- /dev/null +++ b/java-samples/mgm-dynamic-network/clean.sh @@ -0,0 +1,15 @@ +#!/bin/sh +rm -rf ./workspace +rm -rf ./logs + +echo "---Clean Up Env---" +WORK_DIR=./register-mgm +cd $WORK_DIR + +rm signingkey1.pem +rm signingkeys.pfx +rm mgm.cpi +rm request1.csr +rm gradle-plugin-default-key.pem + +rm -rf register-member \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem b/java-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem new file mode 100644 index 0000000..5294bbd --- /dev/null +++ b/java-samples/mgm-dynamic-network/config/gradle-plugin-default-key.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7zCCAZOgAwIBAgIEFyV7dzAMBggqhkjOPQQDAgUAMFsxCzAJBgNVBAYTAkdC +MQ8wDQYDVQQHDAZMb25kb24xDjAMBgNVBAoMBUNvcmRhMQswCQYDVQQLDAJSMzEe +MBwGA1UEAwwVQ29yZGEgRGV2IENvZGUgU2lnbmVyMB4XDTIwMDYyNTE4NTI1NFoX +DTMwMDYyMzE4NTI1NFowWzELMAkGA1UEBhMCR0IxDzANBgNVBAcTBkxvbmRvbjEO +MAwGA1UEChMFQ29yZGExCzAJBgNVBAsTAlIzMR4wHAYDVQQDExVDb3JkYSBEZXYg +Q29kZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQDjSJtzQ+ldDFt +pHiqdSJebOGPZcvZbmC/PIJRsZZUF1bl3PfMqyG3EmAe0CeFAfLzPQtf2qTAnmJj +lGTkkQhxo0MwQTATBgNVHSUEDDAKBggrBgEFBQcDAzALBgNVHQ8EBAMCB4AwHQYD +VR0OBBYEFLMkL2nlYRLvgZZq7GIIqbe4df4pMAwGCCqGSM49BAMCBQADSAAwRQIh +ALB0ipx6EplT1fbUKqgc7rjH+pV1RQ4oKF+TkfjPdxnAAiArBdAI15uI70wf+xlL +zU+Rc5yMtcOY4/moZUq36r0Ilg== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/config/log4j2.xml b/java-samples/mgm-dynamic-network/config/log4j2.xml new file mode 100644 index 0000000..909222c --- /dev/null +++ b/java-samples/mgm-dynamic-network/config/log4j2.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/config/static-network-config.json b/java-samples/mgm-dynamic-network/config/static-network-config.json new file mode 100644 index 0000000..9adde9b --- /dev/null +++ b/java-samples/mgm-dynamic-network/config/static-network-config.json @@ -0,0 +1,23 @@ +[ + { + "x500Name" : "CN=Alice, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=Charlie, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=Dave, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "cpi name" + }, + { + "x500Name" : "CN=NotaryRep1, OU=Test Dept, O=R3, L=London, C=GB", + "cpi" : "CSDE Notary Server CPI", + "serviceX500Name": "CN=NotaryService, OU=Test Dept, O=R3, L=London, C=GB" + } +] diff --git a/java-samples/mgm-dynamic-network/contracts/build.gradle b/java-samples/mgm-dynamic-network/contracts/build.gradle new file mode 100644 index 0000000..52ca6f0 --- /dev/null +++ b/java-samples/mgm-dynamic-network/contracts/build.gradle @@ -0,0 +1,88 @@ + +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // This are shared so should be here. + // Dependencies Required By Test Tooling + // Todo: these are commented out as the simulator UTXO work has not been merged into Gecko yet. +// testImplementation "net.corda:corda-simulator-api:$simulatorVersion" +// testRuntimeOnly "net.corda:corda-simulator-runtime:$simulatorVersion" + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:2.0.0" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by exmaple tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + contract { + name "ContractsModuleNameHere" + versionId 1 + vendor "VendorNameHere" + } +} + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.java b/java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.java new file mode 100644 index 0000000..04a289e --- /dev/null +++ b/java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/contracts/ChatContract.java @@ -0,0 +1,55 @@ +package com.r3.developers.csdetemplate.utxoexample.contracts; + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.Command; +import net.corda.v5.ledger.utxo.Contract; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class ChatContract implements Contract { + + private final static Logger log = LoggerFactory.getLogger(ChatContract.class); + + public static class Create implements Command { } + public static class Update implements Command { } + + @Override + public void verify(UtxoLedgerTransaction transaction) { + + requireThat( transaction.getCommands().size() == 1, "Require a single command."); + Command command = transaction.getCommands().get(0); + + ChatState output = transaction.getOutputStates(ChatState.class).get(0); + + requireThat(output.getParticipants().size() == 2, "The output state should have two and only two participants."); + + if(command.getClass() == Create.class) { + requireThat(transaction.getInputContractStates().isEmpty(), "When command is Create there should be no input states."); + requireThat(transaction.getOutputContractStates().size() == 1, "When command is Create there should be one and only one output state."); + } + else if(command.getClass() == Update.class) { + requireThat(transaction.getInputContractStates().size() == 1, "When command is Update there should be one and only one input state."); + requireThat(transaction.getOutputContractStates().size() == 1, "When command is Update there should be one and only one output state."); + + ChatState input = transaction.getInputStates(ChatState.class).get(0); + requireThat(input.getId().equals(output.getId()), "When command is Update id must not change."); + requireThat(input.getChatName().equals(output.getChatName()), "When command is Update chatName must not change."); + requireThat( + input.getParticipants().containsAll(output.getParticipants()) && + output.getParticipants().containsAll(input.getParticipants()), + "When command is Update participants must not change."); + } + else { + throw new CordaRuntimeException("Unsupported command"); + } + } + + private void requireThat(boolean asserted, String errorMessage) { + if(!asserted) { + throw new CordaRuntimeException("Failed requirement: " + errorMessage); + } + } +} diff --git a/java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/states/ChatState.java b/java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/states/ChatState.java new file mode 100644 index 0000000..bc85e7f --- /dev/null +++ b/java-samples/mgm-dynamic-network/contracts/src/main/java/com/r3/developers/csdetemplate/utxoexample/states/ChatState.java @@ -0,0 +1,55 @@ +package com.r3.developers.csdetemplate.utxoexample.states; + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract; +import net.corda.v5.base.annotations.ConstructorForDeserialization; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.BelongsToContract; +import net.corda.v5.ledger.utxo.ContractState; + +import java.security.PublicKey; +import java.util.*; + +@BelongsToContract(ChatContract.class) +public class ChatState implements ContractState { + + private UUID id; + private String chatName; + private MemberX500Name messageFrom; + private String message; + public List participants; + + // Allows serialisation and to use a specified UUID. + @ConstructorForDeserialization + public ChatState(UUID id, + String chatName, + MemberX500Name messageFrom, + String message, + List participants) { + this.id = id; + this.chatName = chatName; + this.messageFrom = messageFrom; + this.message = message; + this.participants = participants; + } + + public UUID getId() { + return id; + } + public String getChatName() { + return chatName; + } + public MemberX500Name getMessageFrom() { + return messageFrom; + } + public String getMessage() { + return message; + } + + public List getParticipants() { + return participants; + } + + public ChatState updateMessage(MemberX500Name name, String message) { + return new ChatState(id, chatName, name, message, participants); + } +} \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/gradle.properties b/java-samples/mgm-dynamic-network/gradle.properties new file mode 100644 index 0000000..e2ceeaa --- /dev/null +++ b/java-samples/mgm-dynamic-network/gradle.properties @@ -0,0 +1,40 @@ +kotlin.code.style=official + +# Specify the version of the Corda-API to use. +# This needs to match the version supported by the Corda Cluster the CorDapp will run on. +cordaApiVersion=5.0.0.665-Gecko1.0 + +# Settings For Development Utilities +combinedWorkerVersion=5.0.0.0-Gecko1.0 +simulatorVersion=5.0.0.0-Gecko1.0 + +# Specify the version of the notary plugins to use. +# Currently packaged as part of corda-runtime-os, so should be set to a corda-runtime-os version. +cordaNotaryPluginsVersion=5.0.0.0-Gecko1.0 + +# Specify the version of the cordapp-cpb and cordapp-cpk plugins +cordaPluginsVersion=7.0.1 + +# For the time being this just needs to be set to a dummy value. +platformVersion = 999 + +# Version of Kotlin to use. +# We recommend using a version close to that used by Corda-API. +kotlinVersion = 1.7.21 + +# Do not use default dependencies. +kotlin.stdlib.default.dependency=false + +# Test Tooling Dependency Versions +junitVersion = 5.8.2 +mockitoKotlinVersion=4.0.0 +mockitoVersion=4.6.1 +hamcrestVersion=2.2 + +postgresqlVersion=42.4.3 + +cordaClusterURL=https://localhost:8888 +cordaRpcUser=admin +cordaRpcPasswd=admin +devEnvWorkspace=workspace +dbContainerName=CSDEpostgresql \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.jar b/java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..41d9927a4d4fb3f96a785543079b8df6723c946b GIT binary patch literal 59821 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 literal 0 HcmV?d00001 diff --git a/java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties b/java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5ec4b8e --- /dev/null +++ b/java-samples/mgm-dynamic-network/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/java-samples/mgm-dynamic-network/gradlew b/java-samples/mgm-dynamic-network/gradlew new file mode 100755 index 0000000..744e882 --- /dev/null +++ b/java-samples/mgm-dynamic-network/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/java-samples/mgm-dynamic-network/gradlew.bat b/java-samples/mgm-dynamic-network/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/java-samples/mgm-dynamic-network/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/java-samples/mgm-dynamic-network/register-mgm/notary.cpb b/java-samples/mgm-dynamic-network/register-mgm/notary.cpb new file mode 100644 index 0000000000000000000000000000000000000000..4ee92090c0a97035eebaa508c64004e32cdc7427 GIT binary patch literal 47547 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 z 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

JL<9g|%N;{d(+r0552 z$Fl3Wjr?RwS%sC`<&#YoPH;dS7DOhowWo{}gK*Z-o4R<`i7Sz2%;xsFvp6RU z*2@^j55yPnnlXT(-op0vzi*moB0RJybY``ZY*h8c;s~1b-ppKF<8$&5;XJa_jbjhd z*B~k9G!HFF_Hj(o&&UO03{=}**9tsM<%58Vej-DGDf~oR`XbqQ?PN{Y4Cm_9spW9` zC5Amh6EEVkdy-vRKz|JF&MtP`;1X1_k%Fbk;p5vV`X#uwh$$B77PNFpDlrL-R96*`lO-ut)A|r@GcV2U;Qg`q zd#*KrA=Q5KVwZYQfccH&*)qa^HpDMZSXsj&<_Z9l-xHX8-FW}qG*ui zPLYTx-pftW&?ZFzs!}8BkoKUK7W@sM1@RIMaSct6jN0S}UVM5@4^Sq)5JU}&BX{((C4($|<%)-c zcEKXYA0k-|`4D27fr`z>;WcqL^wNf>pu8WUgI3QhLsur9Lw|2X4ojgT$L-i4j1%#< zNkJut>VYSpgb3YPg3fYsz7rI6U4te`CxnZ^wO;vZ!)f&;gVr_2ney-_9(fSDHDQbz zdjSgkBieSo_Z@Ak7WW?EUimTq0>{E^E4c3`toF^HGcBcbd3JIHm2z_}1$L0xE6yZ3 z#U~t3e-Gbll4ezN|JJ<4|MxXd(ag$7#mw5!#zn@-QO20x#>v|7pSJKcDoOnnar;d$ zTAmog7iU!?1pvXSJC1At6#(=ToY}cT@J|mt+*oUbiht*lxk@7kg)I!()hi(3aE85P z89&h=o{-%{w0NP3${%Ii#OVHuhlhub)8`_Sr)M7RKOj5o$Y@8B&1SADQ1g#x8@8IG zomTlqpcqL4UB>fH5rZ>q?+b`jX020Yi-@pP#aG;F5C?PnW-Q--Klq5;3$WFn<~j_P za@%lQzeHs)K{EE^*2M zPuj=WkPASnt9GRZY0@%A{oY9(c;lpz;6#Wv#i(f(FRO7`q{k-j3ey~#{$QGF6gM@D z1~MU~$IN~*zOgUG=L?0xQMS6VN}IIQ9MGCCds0dk0}YSsPib$K!K*D2mYc^&DhLJX zSOolRhsVAs5x6Q!Do10hri&2BOD3zTQqQumqRf980}Ar1Lv}cdIDcmP;M6?JsD*hga9zWK!ymgN zSs38BWUSpXjSrrHY~aQV#NAM90vAcmpsb9AYRMZDD1OM8ZPIu%~_l|iBcl(>g`qCA`bnI!1)fHPy`mE*p(A@!C0*UdbfUE}War0c7 z*EbKUq(v2bIm$>!ek|#eQcL$j8vR7P#87vC@a(KD{E}_kOAjW zLKAiD;Vq;?wk@;0=W4b`1koeAhmt`P)!h@gHvk!dEI}jt5t?U^l7G#%M(6*2GAIDG z1DS#Ef% zBmSInE#I(UUvryL(x~ZvB!&|CuaF9h1#jNka?*A4sqDLQ?O<*3YMHp(UpBFLYek+yKbn&*gx3qHk zb5Pb_!R3yxlC6uottU|Bd(qa|--347|6A~Ya;_BuAr6aufm2^6(V(e~_RkgVUj}Qr&l+-T;55JnN)(*EtvOt~ zPzwC3OYbbKSyESTfmc)t2JGfQAk>oSE$d3LW4Pm^g1)YruVH4bzV6;e(7}`9N)n(v?l1O z#qLg=&vHY5ak`UQ7tGD&%@`Fl7La*|oWoVkL?RAOP}Dm>N=v8Gow?G-l9Zn&=yYBz z0UjGmJ;F0Vq*hhMy~@;dV0};m3ty&8DY;IQS7@DRdd}Y|E{s8fMJ7Bh;P?QL{VSIf#C)k`w zbfWH5hI;^-GAum|LXLmV@UDF5dI5(K)Jpmx{%0DVCRqf<>klz&?_3#E>PZk5bB8Vz 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 Ltb9!4QK0_;4d|!f literal 0 HcmV?d00001 diff --git a/java-samples/mgm-dynamic-network/settings.gradle b/java-samples/mgm-dynamic-network/settings.gradle new file mode 100644 index 0000000..0440221 --- /dev/null +++ b/java-samples/mgm-dynamic-network/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + // Declare the repositories where plugins are stored. + repositories { + gradlePluginPortal() + mavenCentral() + } + + // The plugin dependencies with versions of the plugins congruent with the specified CorDapp plugin version, + // Corda API version, and Kotlin version. + plugins { + id 'net.corda.plugins.cordapp-cpk2' version cordaPluginsVersion + id 'net.corda.plugins.cordapp-cpb2' version cordaPluginsVersion + id 'net.corda.cordapp.cordapp-configuration' version cordaApiVersion + id 'org.jetbrains.kotlin.jvm' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.jpa' version kotlinVersion + id 'org.jetbrains.kotlin.plugin.allopen' version kotlinVersion + } +} + +// Root project name, used in naming the project as a whole and used in naming objects built by the project. +rootProject.name = 'mgm-dynamic-network-java' +include ':workflows' +include ':contracts' + diff --git a/java-samples/mgm-dynamic-network/workflows/build.gradle b/java-samples/mgm-dynamic-network/workflows/build.gradle new file mode 100644 index 0000000..d3d21e5 --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/build.gradle @@ -0,0 +1,90 @@ +plugins { + // Include the cordapp-cpb plugin. This automatically includes the cordapp-cpk plugin as well. + // These extend existing build environment so that CPB and CPK files can be built. + // This includes a CorDapp DSL that allows the developer to supply metadata for the CorDapp + // required by Corda. + id 'net.corda.plugins.cordapp-cpb2' + id 'org.jetbrains.kotlin.jvm' + id 'maven-publish' +} + +// Declare dependencies for the modules we will use. +// A cordaProvided declaration is required for anything that we use that the Corda API provides. +// This is required to allow us to build CorDapp modules as OSGi bundles that CPI and CPB files are built on. +dependencies { + // From other subprojects: + cordapp project(':contracts') + + cordaProvided 'org.jetbrains.kotlin:kotlin-osgi-bundle' + + // Declare a "platform" so that we use the correct set of dependency versions for the version of the + // Corda API specified. + cordaProvided platform("net.corda:corda-api:$cordaApiVersion") + + // If using transistive dependencies this will provide most of Corda-API: + // cordaProvided 'net.corda:corda-application' + + // Alternatively we can explicitly specify all our Corda-API dependencies: + cordaProvided 'net.corda:corda-base' + cordaProvided 'net.corda:corda-application' + cordaProvided 'net.corda:corda-crypto' + cordaProvided 'net.corda:corda-membership' + // cordaProvided 'net.corda:corda-persistence' + cordaProvided 'net.corda:corda-serialization' + cordaProvided 'net.corda:corda-ledger-utxo' + cordaProvided 'net.corda:corda-ledger-consensual' + + // CorDapps that use the UTXO ledger must include at least one notary client plugin + cordapp "com.r3.corda.notary.plugin.nonvalidating:notary-plugin-non-validating-client:$cordaNotaryPluginsVersion" + + // The CorDapp uses the slf4j logging framework. Corda-API provides this so we need a 'cordaProvided' declaration. + cordaProvided 'org.slf4j:slf4j-api' + + // This are shared so should be here. + // Dependencies Required By Test Tooling + // Todo: these are commented out as the simulator UTXO work has not been merged into Gecko yet. +// testImplementation "net.corda:corda-simulator-api:$simulatorVersion" +// testRuntimeOnly "net.corda:corda-simulator-runtime:$simulatorVersion" + + // 3rd party libraries + // Required + testImplementation "org.slf4j:slf4j-simple:2.0.0" + testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" + + // Optional but used by exmaple tests. + testImplementation "org.mockito:mockito-core:$mockitoVersion" + testImplementation "org.mockito.kotlin:mockito-kotlin:$mockitoKotlinVersion" + testImplementation "org.hamcrest:hamcrest-library:$hamcrestVersion" + +} + +// The CordApp section. +// This is part of the DSL provided by the corda plugins to define metadata for our CorDapp. +// Each component of the CorDapp would get its own CorDapp section in the build.gradle file for the component’s +// subproject. +// This is required by the corda plugins to build the CorDapp. +cordapp { + // "targetPlatformVersion" and "minimumPlatformVersion" are intended to specify the preferred + // and earliest versions of the Corda platform that the CorDapp will run on respectively. + // Enforced versioning has not implemented yet so we need to pass in a dummy value for now. + // The platform version will correspond to and be roughly equivalent to the Corda API version. + targetPlatformVersion = platformVersion.toInteger() + minimumPlatformVersion = platformVersion.toInteger() + + // The cordapp section contains either a workflow or contract subsection depending on the type of component. + // Declares the type and metadata of the CPK (this CPB has one CPK). + workflow { + name "WorkflowsModuleNameHere" + versionId 1 + vendor "VendorNameHere" + } +} + +publishing { + publications { + maven(MavenPublication) { + from components.cordapp + } + } +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ChatStateResults.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ChatStateResults.java new file mode 100644 index 0000000..5c3f37f --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ChatStateResults.java @@ -0,0 +1,41 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import java.util.UUID; + +// Class to hold the ListChatFlow results. +// The ChatState(s) cannot be returned directly as the JsonMarshallingService can only serialize simple classes +// that the underlying Jackson serializer recognises, hence creating a DTO style object which consists only of Strings +// and a UUID. It is possible to create custom serializers for the JsonMarshallingService, but this beyond the scope +// of this simple example. +public class ChatStateResults { + + private UUID id; + private String chatName; + private String messageFromName; + private String message; + + public ChatStateResults() {} + + public ChatStateResults(UUID id, String chatName, String messageFromName, String message) { + this.id = id; + this.chatName = chatName; + this.messageFromName = messageFromName; + this.message = message; + } + + public UUID getId() { + return id; + } + + public String getChatName() { + return chatName; + } + + public String getMessageFromName() { + return messageFromName; + } + + public String getMessage() { + return message; + } +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.java new file mode 100644 index 0000000..9a8e11c --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlow.java @@ -0,0 +1,133 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract; +import com.r3.developers.csdetemplate.utxoexample.states.ChatState; +import net.corda.v5.application.flows.*; +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.base.exceptions.CordaRuntimeException; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.common.NotaryLookup; +import net.corda.v5.ledger.common.Party; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionBuilder; +import net.corda.v5.membership.MemberInfo; +import net.corda.v5.membership.NotaryInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.PublicKey; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; + +import static java.util.Objects.*; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class CreateNewChatFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(CreateNewChatFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public MemberLookup memberLookup; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + @CordaInject + public NotaryLookup notaryLookup; + + // FlowEngine service is required to run SubFlows. + @CordaInject + public FlowEngine flowEngine; + + + @Suspendable + @Override + public String call( ClientRequestBody requestBody) { + + log.info("CreateNewChatFlow.call() called"); + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + CreateNewChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, CreateNewChatFlowArgs.class); + + // Get MemberInfos for the Vnode running the flow and the otherMember. + MemberInfo myInfo = memberLookup.myInfo(); + MemberInfo otherMember = requireNonNull( + memberLookup.lookup(MemberX500Name.parse(flowArgs.getOtherMember())), + "MemberLookup can't find otherMember specified in flow arguments." + ); + + // Create the ChatState from the input arguments and member information. + ChatState chatState = new ChatState( + UUID.randomUUID(), + flowArgs.getChatName(), + myInfo.getName(), + flowArgs.getMessage(), + Arrays.asList(myInfo.getLedgerKeys().get(0), otherMember.getLedgerKeys().get(0)) + ); + + // Obtain the Notary name and public key. + NotaryInfo notary = notaryLookup.getNotaryServices().iterator().next(); + PublicKey notaryKey = null; + for(MemberInfo memberInfo: memberLookup.lookup()){ + if(Objects.equals( + memberInfo.getMemberProvidedContext().get("corda.notary.service.name"), + notary.getName().toString())) { + notaryKey = memberInfo.getLedgerKeys().get(0); + break; + } + } + // Note, in Java CorDapps only unchecked RuntimeExceptions can be thrown not + // declared checked exceptions as this changes the method signature and breaks override. + if(notaryKey == null) { + throw new CordaRuntimeException("No notary PublicKey found"); + } + + // Use UTXOTransactionBuilder to build up the draft transaction. + UtxoTransactionBuilder txBuilder = ledgerService.getTransactionBuilder() + .setNotary(new Party(notary.getName(), notaryKey)) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(chatState) + .addCommand(new ChatContract.Create()) + .addSignatories(chatState.getParticipants()); + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(); + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(new FinalizeChatSubFlow(signedTransaction, otherMember.getName())); + } + // Catch any exceptions, log them and rethrow the exception. + catch (Exception e) { + log.warn("Failed to process utxo flow for request body " + requestBody + " because: " + e.getMessage()); + throw new CordaRuntimeException(e.getMessage()); + } + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "create-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.CreateNewChatFlow", + "requestBody": { + "chatName":"Chat with Bob", + "otherMember":"CN=Bob, OU=Test Dept, O=R3, L=London, C=GB", + "message": "Hello Bob" + } +} + */ diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlowArgs.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlowArgs.java new file mode 100644 index 0000000..c2b815e --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/CreateNewChatFlowArgs.java @@ -0,0 +1,30 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +// A class to hold the deserialized arguments required to start the flow. +public class CreateNewChatFlowArgs{ + + // Serialisation service requires a default constructor + public CreateNewChatFlowArgs() {} + + private String chatName; + private String message; + private String otherMember; + + public CreateNewChatFlowArgs(String chatName, String message, String otherMember) { + this.chatName = chatName; + this.message = message; + this.otherMember = otherMember; + } + + public String getChatName() { + return chatName; + } + + public String getMessage() { + return message; + } + + public String getOtherMember() { + return otherMember; + } +} \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatResponderFlow.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatResponderFlow.java new file mode 100644 index 0000000..01f4b31 --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatResponderFlow.java @@ -0,0 +1,75 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState; +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.base.exceptions.CordaRuntimeException; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; + + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +//@InitiatingBy declares the protocol which will be used to link the initiator to the responder. +@InitiatedBy(protocol = "finalize-chat-protocol") +public class FinalizeChatResponderFlow implements ResponderFlow { + private final static Logger log = LoggerFactory.getLogger(FinalizeChatResponderFlow.class); + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public void call(FlowSession session) { + + log.info("FinalizeChatResponderFlow.call() called"); + + try { + // Defines the lambda validator used in receiveFinality below. + UtxoTransactionValidator txValidator = ledgerTransaction -> { + ChatState state = (ChatState) ledgerTransaction.getOutputContractStates().get(0); + // Uses checkForBannedWords() and checkMessageFromMatchesCounterparty() functions + // to check whether to sign the transaction. + if (checkForBannedWords(state.getMessage()) || !checkMessageFromMatchesCounterparty(state, session.getCounterparty())) { + throw new CordaRuntimeException("Failed verification"); + } + log.info("Verified the transaction - " + ledgerTransaction.getId()); + }; + + // Calls receiveFinality() function which provides the responder to the finalise() function + // in the Initiating Flow. Accepts a lambda validator containing the business logic to decide whether + // responder should sign the Transaction. + UtxoSignedTransaction finalizedSignedTransaction = utxoLedgerService.receiveFinality(session, txValidator); + log.info("Finished responder flow - " + finalizedSignedTransaction.getId()); + } + // Soft fails the flow and log the exception. + catch(Exception e) + { + log.warn("Exceptionally finished responder flow", e); + } + } + + + @Suspendable + Boolean checkForBannedWords(String str) { + List bannedWords = Arrays.asList("banana", "apple", "pear"); + return bannedWords.stream().anyMatch(str::contains); + } + + @Suspendable + Boolean checkMessageFromMatchesCounterparty(ChatState state, MemberX500Name otherMember) { + return state.getMessageFrom().equals(otherMember); + } + +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.java new file mode 100644 index 0000000..12f8e98 --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/FinalizeChatSubFlow.java @@ -0,0 +1,71 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import net.corda.v5.application.flows.*; +import net.corda.v5.application.messaging.FlowMessaging; +import net.corda.v5.application.messaging.FlowSession; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.types.MemberX500Name; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. + +// @InitiatingFlow declares the protocol which will be used to link the initiator to the responder. +@InitiatingFlow(protocol = "finalize-chat-protocol") +public class FinalizeChatSubFlow implements SubFlow { + + private final static Logger log = LoggerFactory.getLogger(FinalizeChatSubFlow.class); + private final UtxoSignedTransaction signedTransaction; + private final MemberX500Name otherMember; + + public FinalizeChatSubFlow(UtxoSignedTransaction signedTransaction, MemberX500Name otherMember) { + this.signedTransaction = signedTransaction; + this.otherMember = otherMember; + } + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + @CordaInject + public FlowMessaging flowMessaging; + + @Override + @Suspendable + public String call() { + + log.info("FinalizeChatFlow.call() called"); + + // Initiates a session with the other Member. + FlowSession session = flowMessaging.initiateFlow(otherMember); + + // Calls the Corda provided finalise() function which gather signatures from the counterparty, + // notarises the transaction and persists the transaction to each party's vault. + // On success returns the id of the transaction created. (This is different to the ChatState id) + String result; + try { + List sessionsList = Arrays.asList(session); + + UtxoSignedTransaction finalizedSignedTransaction = ledgerService.finalize( + signedTransaction, + sessionsList + ); + + result = finalizedSignedTransaction.getId().toString(); + log.info("Success! Response: " + result); + + } + // Soft fails the flow and returns the error message without throwing a flow exception. + catch (Exception e) { + log.warn("Finality failed", e); + result = "Finality failed, " + e.getMessage(); + } + // Returns the transaction id converted as a string + return result; + } +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.java new file mode 100644 index 0000000..6f192ea --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlow.java @@ -0,0 +1,119 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState; +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.marshalling.JsonMarshallingService; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.base.exceptions.CordaRuntimeException; +import net.corda.v5.crypto.SecureHash; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoLedgerTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import static java.util.Objects.*; +import static java.util.stream.Collectors.toList; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class GetChatFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(GetChatFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + @Override + @Suspendable + public String call(ClientRequestBody requestBody) { + + // Obtain the deserialized input arguments to the flow from the requestBody. + GetChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, GetChatFlowArgs.class); + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + List> chatStateAndRefs = ledgerService.findUnconsumedStatesByType(ChatState.class); + List> chatStateAndRefsWithId = chatStateAndRefs.stream() + .filter(sar -> sar.getState().getContractState().getId().equals(flowArgs.getId())).collect(toList()); + if (chatStateAndRefsWithId.size() != 1) throw new CordaRuntimeException("Multiple or zero Chat states with id " + flowArgs.getId() + " found"); + StateAndRef chatStateAndRef = chatStateAndRefsWithId.get(0); + + // Calls resolveMessagesFromBackchain() which retrieves the chat history from the backchain. + return jsonMarshallingService.format(resolveMessagesFromBackchain(chatStateAndRef, flowArgs.getNumberOfRecords() )); + } + + // resoveMessageFromBackchain() starts at the stateAndRef provided, which represents the unconsumed head of the + // backchain for this particular chat, then walks the chain backwards for the number of transaction specified in + // the numberOfRecords argument. For each transaction it adds the MessageAndSender representing the + // message and who sent it to a list which is then returned. + @Suspendable + private List resolveMessagesFromBackchain(StateAndRef stateAndRef, int numberOfRecords) { + + // Set up a Mutable List to collect the MessageAndSender(s) + List messages = new LinkedList<>(); + + // Set up initial conditions for walking the backchain. + StateAndRef currentStateAndRef = stateAndRef; + int recordsToFetch = numberOfRecords; + boolean moreBackchain = true; + + // Continue to loop until the start of the backchain or enough records have been retrieved. + while (moreBackchain) { + + // Obtain the transaction id from the current StateAndRef and fetch the transaction from the vault. + SecureHash transactionId = currentStateAndRef.getRef().getTransactionId(); + UtxoLedgerTransaction transaction = requireNonNull( + ledgerService.findLedgerTransaction(transactionId), + "Transaction " + transactionId + " not found." + ); + + // Get the output state from the transaction and use it to create a MessageAndSender Object which + // is appended to the mutable list. + List chatStates = transaction.getOutputStates(ChatState.class); + if (chatStates.size() != 1) throw new CordaRuntimeException( + "Expecting one and only one ChatState output for transaction " + transactionId + "."); + ChatState output = chatStates.get(0); + + messages.add(new MessageAndSender(output.getMessageFrom().toString(), output.getMessage())); + // Decrement the number of records to fetch. + recordsToFetch--; + + // Get the reference to the input states. + List> inputStateAndRefs = transaction.getInputStateAndRefs(); + + // Check if there are no more input states (start of chain) or we have retrieved enough records. + // Check the transaction is not malformed by having too many input states. + // Set the currentStateAndRef to the input StateAndRef, then repeat the loop. + if (inputStateAndRefs.isEmpty() || recordsToFetch == 0) { + moreBackchain = false; + } else if (inputStateAndRefs.size() > 1) { + throw new CordaRuntimeException("More than one input state found for transaction " + transactionId + "."); + } else { + currentStateAndRef = inputStateAndRefs.get(0); + } + } + return messages; + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "get-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.GetChatFlow", + "requestBody": { + "id":"** fill in id **", + "numberOfRecords":"4" + } +} + */ + diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlowArgs.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlowArgs.java new file mode 100644 index 0000000..d1bac81 --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/GetChatFlowArgs.java @@ -0,0 +1,24 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import java.util.UUID; + +// A class to hold the deserialized arguments required to start the flow. +public class GetChatFlowArgs { + + private UUID id; + private int numberOfRecords; + public GetChatFlowArgs() {} + + public GetChatFlowArgs(UUID id, int numberOfRecords ) { + this.id = id; + this.numberOfRecords = numberOfRecords; + } + + public UUID getId() { + return id; + } + + public int getNumberOfRecords() { + return numberOfRecords; + } +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.java new file mode 100644 index 0000000..1416b7c --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/ListChatsFlow.java @@ -0,0 +1,58 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import com.r3.developers.csdetemplate.utxoexample.states.ChatState; +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.marshalling.JsonMarshallingService; +import net.corda.v5.base.annotations.Suspendable; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class ListChatsFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(ListChatsFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService utxoLedgerService; + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + + log.info("ListChatsFlow.call() called"); + + // Queries the VNode's vault for unconsumed states and converts the result to a serializable DTO. + List> states = utxoLedgerService.findUnconsumedStatesByType(ChatState.class); + List results = states.stream().map( stateAndRef -> + new ChatStateResults( + stateAndRef.getState().getContractState().getId(), + stateAndRef.getState().getContractState().getChatName(), + stateAndRef.getState().getContractState().getMessageFrom().toString(), + stateAndRef.getState().getContractState().getMessage() + ) + ).collect(Collectors.toList()); + + // Uses the JsonMarshallingService's format() function to serialize the DTO to Json. + return jsonMarshallingService.format(results); + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "list-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.ListChatsFlow", + "requestBody": {} +} +*/ \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/MessageAndSender.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/MessageAndSender.java new file mode 100644 index 0000000..7be54fd --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/MessageAndSender.java @@ -0,0 +1,22 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +// A class to pair the messageFrom and message together. +public class MessageAndSender { + + private String messageFrom; + private String message; + public MessageAndSender() {} + + public MessageAndSender(String messageFrom, String message) { + this.messageFrom = messageFrom; + this.message = message; + } + + public String getMessageFrom() { + return messageFrom; + } + + public String getMessage() { + return message; + } +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.java new file mode 100644 index 0000000..79d9609 --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlow.java @@ -0,0 +1,120 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import com.r3.developers.csdetemplate.utxoexample.contracts.ChatContract; +import com.r3.developers.csdetemplate.utxoexample.states.ChatState; +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.FlowEngine; +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.base.exceptions.CordaRuntimeException; +import net.corda.v5.ledger.utxo.StateAndRef; +import net.corda.v5.ledger.utxo.UtxoLedgerService; +import net.corda.v5.ledger.utxo.transaction.UtxoSignedTransaction; +import net.corda.v5.ledger.utxo.transaction.UtxoTransactionBuilder; +import net.corda.v5.membership.MemberInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static java.util.Objects.*; +import static java.util.stream.Collectors.toList; + +// See Chat CorDapp Design section of the getting started docs for a description of this flow. +public class UpdateChatFlow implements ClientStartableFlow { + + private final static Logger log = LoggerFactory.getLogger(UpdateChatFlow.class); + + @CordaInject + public JsonMarshallingService jsonMarshallingService; + + @CordaInject + public MemberLookup memberLookup; + + // Injects the UtxoLedgerService to enable the flow to make use of the Ledger API. + @CordaInject + public UtxoLedgerService ledgerService; + + // FlowEngine service is required to run SubFlows. + @CordaInject + public FlowEngine flowEngine; + + @Suspendable + @Override + public String call(ClientRequestBody requestBody) { + + log.info("UpdateNewChatFlow.call() called"); + + try { + // Obtain the deserialized input arguments to the flow from the requestBody. + UpdateChatFlowArgs flowArgs = requestBody.getRequestBodyAs(jsonMarshallingService, UpdateChatFlowArgs.class); + + // Look up the latest unconsumed ChatState with the given id. + // Note, this code brings all unconsumed states back, then filters them. + // This is an inefficient way to perform this operation when there are a large number of chats. + // Note, you will get this error if you input an id which has no corresponding ChatState (common error). + List> chatStateAndRefs = ledgerService.findUnconsumedStatesByType(ChatState.class); + List> chatStateAndRefsWithId = chatStateAndRefs.stream() + .filter(sar -> sar.getState().getContractState().getId().equals(flowArgs.getId())).collect(toList()); + if (chatStateAndRefsWithId.size() != 1) throw new CordaRuntimeException("Multiple or zero Chat states with id " + flowArgs.getId() + " found"); + StateAndRef chatStateAndRef = chatStateAndRefsWithId.get(0); + + // Get MemberInfos for the Vnode running the flow and the otherMember. + MemberInfo myInfo = memberLookup.myInfo(); + ChatState state = chatStateAndRef.getState().getContractState(); + + List members = state.getParticipants().stream().map( + it -> requireNonNull(memberLookup.lookup(it), "Member not found from public Key "+ it + ".") + ).collect(toList()); + members.remove(myInfo); + if(members.size() != 1) throw new RuntimeException("Should be only one participant other than the initiator"); + MemberInfo otherMember = members.get(0); + + // Create a new ChatState using the updateMessage helper function. + ChatState newChatState = state.updateMessage(myInfo.getName(), flowArgs.getMessage()); + + // Use UTXOTransactionBuilder to build up the draft transaction. + UtxoTransactionBuilder txBuilder = ledgerService.getTransactionBuilder() + .setNotary(chatStateAndRef.getState().getNotary()) + .setTimeWindowBetween(Instant.now(), Instant.now().plusMillis(Duration.ofDays(1).toMillis())) + .addOutputState(newChatState) + .addInputState(chatStateAndRef.getRef()) + .addCommand(new ChatContract.Update()) + .addSignatories(newChatState.getParticipants()); + + // Convert the transaction builder to a UTXOSignedTransaction. Verifies the content of the + // UtxoTransactionBuilder and signs the transaction with any required signatories that belong to + // the current node. + UtxoSignedTransaction signedTransaction = txBuilder.toSignedTransaction(); + + // Call FinalizeChatSubFlow which will finalise the transaction. + // If successful the flow will return a String of the created transaction id, + // if not successful it will return an error message. + return flowEngine.subFlow(new FinalizeChatSubFlow(signedTransaction, otherMember.getName())); + + + } + // Catch any exceptions, log them and rethrow the exception. + catch (Exception e) { + log.warn("Failed to process utxo flow for request body " + requestBody + " because: " + e.getMessage()); + throw e; + } + } +} + +/* +RequestBody for triggering the flow via REST: +{ + "clientRequestId": "update-1", + "flowClassName": "com.r3.developers.csdetemplate.utxoexample.workflows.UpdateChatFlow", + "requestBody": { + "id":" ** fill in id **", + "message": "How are you today?" + } +} + */ \ No newline at end of file diff --git a/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlowArgs.java b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlowArgs.java new file mode 100644 index 0000000..8ac7009 --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/main/java/com/r3/developers/csdetemplate/utxoexample/workflows/UpdateChatFlowArgs.java @@ -0,0 +1,24 @@ +package com.r3.developers.csdetemplate.utxoexample.workflows; + +import java.util.UUID; + +// A class to hold the deserialized arguments required to start the flow. +public class UpdateChatFlowArgs { + public UpdateChatFlowArgs() {} + + private UUID id; + private String message; + + public UpdateChatFlowArgs(UUID id, String message) { + this.id = id; + this.message = message; + } + + public UUID getId() { + return id; + } + + public String getMessage() { + return message; + } +} diff --git a/java-samples/mgm-dynamic-network/workflows/src/test/java/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.java b/java-samples/mgm-dynamic-network/workflows/src/test/java/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.java new file mode 100644 index 0000000..9dea76c --- /dev/null +++ b/java-samples/mgm-dynamic-network/workflows/src/test/java/com/r3/developers/csdetemplate/flowexample/workflows/MyFirstFlowTest.java @@ -0,0 +1,41 @@ +//package com.r3.developers.csdetemplate.flowexample.workflows; +// +//import net.corda.simulator.RequestData; +//import net.corda.simulator.SimulatedVirtualNode; +//import net.corda.simulator.Simulator; +//import net.corda.v5.base.types.MemberX500Name; +//import org.junit.jupiter.api.Test; +// +//class MyFirstFlowTest { +// // Names picked to match the corda network in config/dev-net.json +// private MemberX500Name aliceX500 = MemberX500Name.parse("CN=Alice, OU=Test Dept, O=R3, L=London, C=GB"); +// private MemberX500Name bobX500 = MemberX500Name.parse("CN=Bob, OU=Test Dept, O=R3, L=London, C=GB"); +// +// @Test +// @SuppressWarnings("unchecked") +// public void test_that_MyFirstFLow_returns_correct_message() { +// // Instantiate an instance of the simulator. +// Simulator simulator = new Simulator(); +// +// // Create Alice's and Bob's virtual nodes, including the classes of the flows which will be registered on each node. +// // Don't assign Bob's virtual node to a value. You don't need it for this particular test. +// SimulatedVirtualNode aliceVN = simulator.createVirtualNode(aliceX500, MyFirstFlow.class); +// simulator.createVirtualNode(bobX500, MyFirstFlowResponder.class); +// +// // Create an instance of the MyFirstFlowStartArgs which contains the request arguments for starting the flow. +// MyFirstFlowStartArgs myFirstFlowStartArgs = new MyFirstFlowStartArgs(bobX500); +// +// // Create a requestData object. +// RequestData requestData = RequestData.Companion.create( +// "request no 1", // A unique reference for the instance of the flow request. +// MyFirstFlow.class, // The name of the flow class which is to be started. +// myFirstFlowStartArgs // The object which contains the start arguments of the flow. +// ); +// +// // Call the flow on Alice's virtual node and capture the response. +// String flowResponse = aliceVN.callFlow(requestData); +// +// // Check that the flow has returned the expected string. +// assert(flowResponse.equals("Hello Alice, best wishes from Bob")); +// } +//}