From 4745df0bee6b9ab5fb9e57bb603ae95d6baeb391 Mon Sep 17 00:00:00 2001 From: im8a Date: Sun, 29 Aug 2021 15:36:32 -0500 Subject: [PATCH] feat(connector-iroha): adds connector plugin 1. Enables Cactus to manipulate Iroha ledger 2. Supports 19/20 commands and 14/15 queries of the Iroha ledger 3. Possibility of starting Iroha and Postgres with random ports 4. Possibility of passing arbitrary Iroha admin/node keypairs to the test ledger 5. Offers a comprehensive transaction test suite to validate support for Iroha commands and queries 6. Provides an example of asset transfer between two separate Iroha nodes Signed-off-by: im8a --- .cspell.json | 1 + docs/source/support/iroha.md | 17 + package.json | 1 + .../http/http-status-code-errors.ts | 1 + .../src/main/typescript/public-api.ts | 1 + .../Dockerfile | 5 + .../README.md | 235 ++++ .../run-transaction-endpoint-transact.png | Bin 0 -> 43185 bytes .../images/run-transaction-endpoint.png | Bin 0 -> 38372 bytes .../run-transaction-endpoint-transact.puml | 28 + .../run-transaction-endpoint.puml | 29 + .../openapitools.json | 7 + .../package.json | 93 ++ .../src/main/json/openapi.json | 359 ++++++ .../.openapi-generator-ignore | 27 + .../typescript-axios/.openapi-generator/FILES | 5 + .../.openapi-generator/VERSION | 1 + .../generated/openapi/typescript-axios/api.ts | 546 ++++++++ .../openapi/typescript-axios/base.ts | 71 + .../openapi/typescript-axios/common.ts | 138 ++ .../openapi/typescript-axios/configuration.ts | 101 ++ .../openapi/typescript-axios/index.ts | 18 + .../src/main/typescript/index.ts | 1 + .../src/main/typescript/index.web.ts | 1 + .../plugin-factory-ledger-connector.ts | 20 + .../plugin-ledger-connector-iroha.ts | 636 +++++++++ .../prometheus-exporter/data-fetcher.ts | 10 + .../typescript/prometheus-exporter/metrics.ts | 10 + .../prometheus-exporter.ts | 39 + .../prometheus-exporter/response.type.ts | 3 + .../src/main/typescript/public-api.ts | 17 + ...prometheus-exporter-metrics-endpoint-v1.ts | 94 ++ .../web-services/run-transaction-endpoint.ts | 119 ++ .../integration/api-surface.test.ts | 8 + .../iroha-iroha-transfer-example.test.ts | 353 +++++ .../run-transaction-endpoint-v1.test.ts | 1148 +++++++++++++++++ .../test/typescript/unit/api-surface.test.ts | 8 + .../unit/iroha-test-ledger-parameters.test.ts | 35 + ...postgres-test-container-parameters.test.ts | 29 + .../tsconfig.json | 32 + .../typescript/iroha/iroha-test-ledger.ts | 52 +- tsconfig.json | 1 + webpack.prod.node.js | 1 + 43 files changed, 4295 insertions(+), 6 deletions(-) create mode 100644 docs/source/support/iroha.md create mode 100644 packages/cactus-common/src/main/typescript/http/http-status-code-errors.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/Dockerfile create mode 100644 packages/cactus-plugin-ledger-connector-iroha/README.md create mode 100644 packages/cactus-plugin-ledger-connector-iroha/docs/architecture/images/run-transaction-endpoint-transact.png create mode 100644 packages/cactus-plugin-ledger-connector-iroha/docs/architecture/images/run-transaction-endpoint.png create mode 100644 packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint-transact.puml create mode 100644 packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint.puml create mode 100644 packages/cactus-plugin-ledger-connector-iroha/openapitools.json create mode 100644 packages/cactus-plugin-ledger-connector-iroha/package.json create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/json/openapi.json create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/api.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/base.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/common.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/configuration.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/index.ts create mode 100755 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.ts create mode 100755 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.web.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-factory-ledger-connector.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-ledger-connector-iroha.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/data-fetcher.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/metrics.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/prometheus-exporter.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/response.type.ts create mode 100755 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/public-api.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/get-prometheus-exporter-metrics-endpoint-v1.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/run-transaction-endpoint.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/api-surface.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/iroha-iroha-transfer-example.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/run-transaction-endpoint-v1.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/api-surface.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/iroha-test-ledger-parameters.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/postgres-test-container-parameters.test.ts create mode 100644 packages/cactus-plugin-ledger-connector-iroha/tsconfig.json diff --git a/.cspell.json b/.cspell.json index b5329905bf..a74cd44451 100644 --- a/.cspell.json +++ b/.cspell.json @@ -65,6 +65,7 @@ "myapp", "mychannel", "myroot", + "mysecretpassword", "myvolume", "Nerc", "NETWORKSCOPEALLFORTX", diff --git a/docs/source/support/iroha.md b/docs/source/support/iroha.md new file mode 100644 index 0000000000..90767397d1 --- /dev/null +++ b/docs/source/support/iroha.md @@ -0,0 +1,17 @@ +Iroha Support +----------------- + +```{note} +The deployContract feature not yet implemented since Iroha lacks full smart contract support during the initial development stage of the Iroha connector plugin. +``` + +
+ Hyperledger Cactus v0.9.0 + + | Iroha version | runTransaction | + | --- | :---: | + | Iroha 1.2.0 and Postgres 9.5 | ✅ [test](https://github.com/hyperledger/cactus/blob/v0.9.0/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/run-transaction-endpoint-v1.test.ts) | + +
+ +
\ No newline at end of file diff --git a/package.json b/package.json index 670d15d8f0..ed0d43ad53 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:plugin-htlc-besu-erc20": "tap --jobs=1 --timeout=600 \"packages/*htlc-eth-besu-erc20/src/test/typescript/{unit,integration}/\"", "test:plugin": "tap --jobs=1 --timeout=600 \"packages/*test-plugin-htlc-eth-besu/src/test/typescript/{unit,integration}/\"", "test:plugin-ledger-connector-quorum": "tap --ts --jobs=1 --timeout=60 \"packages/cactus-*-quorum/src/test/typescript/{unit,integration}/\"", + "test:plugin-ledger-connector-iroha": "tap --ts --jobs=1 --timeout=600 \"packages/cactus-*-iroha/src/test/typescript/{unit,integration}/\"", "test:plugin-htlc-besu": "tap --jobs=1 --timeout=600 \"packages/*htlc-eth-besu/src/test/typescript/{integration}/\"", "test:plugin-ledger-connector-corda": "tap --ts --jobs=1 --timeout=600 \"packages/cactus-*-corda/src/test/typescript/{unit,integration}/\"", "webpack": "npm-run-all webpack:dev webpack:prod", diff --git a/packages/cactus-common/src/main/typescript/http/http-status-code-errors.ts b/packages/cactus-common/src/main/typescript/http/http-status-code-errors.ts new file mode 100644 index 0000000000..700a538700 --- /dev/null +++ b/packages/cactus-common/src/main/typescript/http/http-status-code-errors.ts @@ -0,0 +1 @@ +export class Http405NotAllowedError extends Error {} diff --git a/packages/cactus-common/src/main/typescript/public-api.ts b/packages/cactus-common/src/main/typescript/public-api.ts index 168497b6af..209b91f6dc 100755 --- a/packages/cactus-common/src/main/typescript/public-api.ts +++ b/packages/cactus-common/src/main/typescript/public-api.ts @@ -19,3 +19,4 @@ export { ISignerKeyPair } from "./signer-key-pair"; export { Secp256k1Keys } from "./secp256k1-keys"; export { KeyFormat, KeyConverter } from "./key-converter"; export { IAsyncProvider } from "./i-async-provider"; +export { Http405NotAllowedError } from "./http/http-status-code-errors"; diff --git a/packages/cactus-plugin-ledger-connector-iroha/Dockerfile b/packages/cactus-plugin-ledger-connector-iroha/Dockerfile new file mode 100644 index 0000000000..3fc79988f1 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/Dockerfile @@ -0,0 +1,5 @@ +FROM ghcr.io/hyperledger/cactus-cmd-api-server:2021-08-15--refactor-1222 + +ARG NPM_PKG_VERSION=latest + +RUN npm i @hyperledger/cactus-plugin-ledger-connector-iroha@${NPM_PKG_VERSION} --production diff --git a/packages/cactus-plugin-ledger-connector-iroha/README.md b/packages/cactus-plugin-ledger-connector-iroha/README.md new file mode 100644 index 0000000000..c7ed49ad7d --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/README.md @@ -0,0 +1,235 @@ +# `@hyperledger/cactus-plugin-ledger-connector-iroha` + +This plugin provides `Cactus` a way to interact with Iroha networks. Using this we can perform: +* Run various Iroha leger commands and queries. +* Build and sign transactions using any arbitrary credential. +## Summary + + - [Getting Started](#getting-started) + - [Architecture](#architecture) + - [Usage](#usage) + - [Runing the tests](#running-the-tests) + - [Built With](#built-with) + - [Contributing](#contributing) + - [License](#license) + - [Acknowledgments](#acknowledgments) + +## Getting Started + +Clone the git repository on your local machine. Follow these instructions that will get you a copy of the project up and running on your local machine for development and testing purposes. + +### Prerequisites + +In the root of the project, execute the command to install the dependencies: +```sh +yarn run configure +``` + +### Compiling + +In the project root folder, run this command to compile the plugin and create the dist directory: +```sh +# For one off builds: +yarn run build:dev:backend +``` + +```sh +# For continuous watching of the source code with +# automatic recompilation (more convenient) +yarn run watch +``` + +### Architecture +The sequence diagrams for various endpoints are mentioned below + +#### run-transaction-endpoint +![run-transaction-endpoint sequence diagram](docs/architecture/images/run-transaction-endpoint.png) + +The above diagram shows the sequence diagram of run-transaction-endpoint. User A (One of the many Users) interacts with the API Client which in turn, calls the API server. The API server then executes `transact()` method which is explained in detailed in the subsequent diagrams. + +![run-transaction-endpoint transact() method](docs/architecture/images/run-transaction-endpoint-transact.png) + +The above diagram shows the sequence diagram of `transact()` method of the `PluginLedgerConnectorIroha` class. The caller to this function, which in reference to the above sequence diagram is API server, sends `RunTransactionRequestV1` object as an argument to the `transact()` method. Based on the exact command name of the request, corresponsing response `RunTransactionResponse` is sent back to the caller. + +### Usage + +To use this import public-api and create new **PluginFactoryLedgerConnector**. Then use it to create a connector. +```typescript + const factory = new PluginFactoryLedgerConnector({ + pluginImportType: PluginImportType.Local, + }); + + const connector: PluginLedgerConnectorIroha = await factory.create({ + rpcToriiPortHost, + instanceId: uuidv4(), + pluginRegistry: new PluginRegistry(), + }); +``` +You can make calls through the connector to the plugin API: + +```typescript +async transact(req: RunTransactionRequestV1):Promise; +``` + +Call example to run an Iroha execute account command: +```typescript + const req = { + commandName: IrohaCommand.CreateAccount, + baseConfig: { + irohaHost: "localhost", + irohaPort: 50051, + creatorAccountId: "admin@test", + privKey: ["f101537e319568c765b2cc89698325604991dca57b9716b58016b253506cab70"], + quorum: 1, + timeoutLimit: 5000, + }, + const res = await apiClient.runTransactionV1(req); +``` +Call example to run an Iroha get account query: +```typescript + const req = { + commandName: IrohaQuery.GetAccount, + baseConfig: { + irohaHost: "localhost", + irohaPort: 50051, + creatorAccountId: "admin@test", + privKey: ["f101537e319568c765b2cc89698325604991dca57b9716b58016b253506cab70"], + quorum: 1, + timeoutLimit: 5000, + }, + params: ["admin@test"], + }; + const res = await apiClient.runTransactionV1(req); +``` +> Extensive documentation and examples in the [readthedocs](https://readthedocs.org/projects/hyperledger-cactus/) (WIP) + + +### Building/running the container image locally + +In the Cactus project root say: + +```sh +DOCKER_BUILDKIT=1 docker build -f ./packages/cactus-plugin-ledger-connector-iroha/Dockerfile . -t cplcb +``` + +Build with a specific version of the npm package: +```sh +DOCKER_BUILDKIT=1 docker build --build-arg NPM_PKG_VERSION=latest -f ./packages/cactus-plugin-ledger-connector-iroha/Dockerfile . -t cplcb +``` + +#### Running the container + +Launch container with plugin configuration as an **environment variable**: +```sh +docker run \ + --rm \ + --publish 3000:3000 \ + --publish 4000:4000 \ + --publish 5000:5000 \ + --env PLUGINS='[{"packageName": "@hyperledger/cactus-plugin-ledger-connector-iroha", "type": "org.hyperledger.cactus.plugin_import_type.LOCAL", "options": {"rpcApiHttpHost": "http://localhost:8545", "instanceId": "some-unique-iroha-connector-instance-id"}}]' \ + cplcb +``` + +Launch container with plugin configuration as a **CLI argument**: +```sh +docker run \ + --rm \ + --publish 3000:3000 \ + --publish 4000:4000 \ + --publish 5000:5000 \ + cplcb \ + ./node_modules/.bin/cactusapi \ + --plugins='[{"packageName": "@hyperledger/cactus-plugin-ledger-connector-iroha", "type": "org.hyperledger.cactus.plugin_import_type.LOCAL", "options": {"rpcApiHttpHost": "http://localhost:8545", "instanceId": "some-unique-iroha-connector-instance-id"}}]' +``` + +Launch container with **configuration file** mounted from host machine: +```sh + +echo '[{"packageName": "@hyperledger/cactus-plugin-ledger-connector-iroha", "type": "org.hyperledger.cactus.plugin_import_type.LOCAL", "options": {"rpcApiHttpHost": "http://localhost:8545", "instanceId": "some-unique-iroha-connector-instance-id"}}]' > cactus.json + +docker run \ + --rm \ + --publish 3000:3000 \ + --publish 4000:4000 \ + --publish 5000:5000 \ + --mount type=bind,source="$(pwd)"/cactus.json,target=/cactus.json \ + cplcb \ + ./node_modules/.bin/cactusapi \ + --config-file=/cactus.json +``` + +#### Testing API calls with the container + +Don't have an Iroha network on hand to test with? Test or develop against our Iroha All-In-One dockerfile! + +**Terminal Window 1 (Ledger)** +```sh +docker run -p 0.0.0.0:8545:8545/tcp -p 0.0.0.0:8546:8546/tcp -p 0.0.0.0:8888:8888/tcp -p 0.0.0.0:9001:9001/tcp -p 0.0.0.0:9545:9545/tcp hyperledger/cactus-iroha-all-in-one:latest +``` + +**Terminal Window 2 (Cactus API Server)** +```sh +docker run \ + --network host \ + --rm \ + --publish 3000:3000 \ + --publish 4000:4000 \ + --publish 5000:5000 \ + --env PLUGINS='[{"packageName": "@hyperledger/cactus-plugin-ledger-connector-iroha", "type": "org.hyperledger.cactus.plugin_import_type.LOCAL", "options": {"rpcApiHttpHost": "http://localhost:8545", "instanceId": "some-unique-iroha-connector-instance-id"}}]' \ + cplcb +``` + +**Terminal Window 3 (curl - replace transaction request as needed)** +```sh +curl --location --request POST 'http://127.0.0.1:4000/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/run-transaction' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + commandName: 'createAsset', + baseConfig: { + irohaHost: 'localhost', + irohaPort: 50051, + creatorAccountId: 'admin@test', + privKey: [ + 'f101537e319568c765b2cc89698325604991dca57b9716b58016b253506cab70' + ], + quorum: 1, + timeoutLimit: 5000 + }, + params: [ 'coolcoin', 'test', 3] +}' +``` + +The above should produce a response that looks similar to this: + +```json +{ + "success": true, + "data": { + "transactionReceipt": { + "txHash": "c3ffd772f26950243aa357ab4f21b9703d5172490b66ddc285355230d6df60b8", + "status": "COMMITTED" + } + } +} +``` + +## Running the tests + +To check that all has been installed correctly and that the plugin has no errors run the tests: + +* Run this command at the project's root: +```sh +yarn run test:plugin-ledger-connector-iroha +``` + +## Contributing + +We welcome contributions to Hyperledger Cactus in many forms, and there’s always plenty to do! + +Please review [CONTIRBUTING.md](../../CONTRIBUTING.md) to get started. + +## License + +This distribution is published under the Apache License Version 2.0 found in the [LICENSE](../../LICENSE) file. + +## Acknowledgments diff --git a/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/images/run-transaction-endpoint-transact.png b/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/images/run-transaction-endpoint-transact.png new file mode 100644 index 0000000000000000000000000000000000000000..70dcf461860ba86e7980ad20f709856e2f0520e3 GIT binary patch literal 43185 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

NjzB8@5^3K(N)%1;1Yat|V)SKmKA20Z(D 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 literal 0 HcmV?d00001 diff --git a/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint-transact.puml b/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint-transact.puml new file mode 100644 index 0000000000..6557546360 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint-transact.puml @@ -0,0 +1,28 @@ +@startuml +title Hyperledger Cactus\nSequence Diagram\nRun Transaction Endpoint\ntransact() method + +skinparam sequenceArrowThickness 2 +skinparam roundcorner 20 +skinparam maxmessagesize 120 +skinparam sequenceParticipant underline + +actor "Caller" as caller +participant "PluginLedgerConnectorIroha" as t << (C,#ADD1B2) class >> + +autoactivate on + +activate caller +caller -> t: transact(RunTransactionRequest) + +alt #LightBlue commandName is an Iroha command + t -> t: generate Iroha commandOptions + return RunTransactionResponse + t --> caller: return RunTransactionResponse +else #LightGreen commandName is an Iroha query + t -> t: generate Iroha queryOptions + return RunTransactionResponse + t --> caller: return RunTransactionResponse +else #LightCoral default + t --> caller: throw RuntimeError("command or query does not exist) +end +@enduml \ No newline at end of file diff --git a/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint.puml b/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint.puml new file mode 100644 index 0000000000..91c3724d8e --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/docs/architecture/run-transaction-endpoint.puml @@ -0,0 +1,29 @@ +@startuml Sequence Diagram - Transaction + +title Hyperledger Cactus\nSequence Diagram\nRun Transaction Endpoint + +skinparam sequenceArrowThickness 2 +skinparam roundcorner 20 +skinparam maxmessagesize 120 +skinparam sequenceParticipant underline + +box "Users" #LightBlue +actor "User A" as a +end box + +box "Hyperledger Cactus" #LightGray +entity "API Client" as apic +entity "API Server" as apis +end box + +box "Ledger Connector" #LightGreen +database "Iroha" as irohacon +end box + +a --> apic : Tx Iroha Ledger +apic --> apis: Request +apis --> irohacon: transact() +irohacon --> apis: Response +apis --> apic: Formatted Response +apic --> a: RunTransactionResponse +@enduml diff --git a/packages/cactus-plugin-ledger-connector-iroha/openapitools.json b/packages/cactus-plugin-ledger-connector-iroha/openapitools.json new file mode 100644 index 0000000000..d2fdbae832 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "5.1.1" + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/package.json b/packages/cactus-plugin-ledger-connector-iroha/package.json new file mode 100644 index 0000000000..fada22b48b --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/package.json @@ -0,0 +1,93 @@ +{ + "name": "@hyperledger/cactus-plugin-ledger-connector-iroha", + "version": "0.8.0", + "description": "Allows Cactus nodes to connect to an Iroha ledger.", + "main": "dist/lib/main/typescript/index.js", + "mainMinified": "dist/cactus-plugin-ledger-connector-iroha.node.umd.min.js", + "browser": "dist/cactus-plugin-ledger-connector-iroha.web.umd.js", + "browserMinified": "dist/cactus-plugin-ledger-connector-iroha.web.umd.min.js", + "module": "dist/lib/main/typescript/index.js", + "types": "dist/types/main/typescript/index.d.ts", + "files": [ + "dist/*" + ], + "scripts": { + "generate-sdk": "openapi-generator-cli generate -i ./src/main/json/openapi.json -g typescript-axios -o ./src/main/typescript/generated/openapi/typescript-axios/ --reserved-words-mappings protected=protected", + "codegen:openapi": "npm run generate-sdk", + "codegen": "run-p 'codegen:*'", + "watch": "npm-watch", + "webpack": "npm-run-all webpack:dev webpack:prod", + "webpack:dev": "npm-run-all webpack:dev:node webpack:dev:web", + "webpack:dev:web": "webpack --env=dev --target=web --config ../../webpack.config.js", + "webpack:dev:node": "webpack --env=dev --target=node --config ../../webpack.config.js", + "webpack:prod": "npm-run-all webpack:prod:node webpack:prod:web", + "webpack:prod:web": "webpack --env=prod --target=web --config ../../webpack.config.js", + "webpack:prod:node": "webpack --env=prod --target=node --config ../../webpack.config.js" + }, + "watch": { + "codegen:openapi": { + "patterns": [ + "./src/main/json/openapi.json" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/hyperledger/cactus.git" + }, + "keywords": [ + "Hyperledger", + "Cactus", + "Iroha", + "Integration", + "Blockchain", + "Distributed Ledger Technology" + ], + "author": { + "name": "Hyperledger Cactus Contributors", + "email": "cactus@lists.hyperledger.org", + "url": "https://www.hyperledger.org/use/cactus" + }, + "contributors": [ + { + "name": "Peter Somogyvari", + "email": "peter.somogyvari@accenture.com", + "url": "https://accenture.com" + }, + { + "name": "Han Xu", + "email": "hanxu8@illinois.edu", + "url": "https://github.com/hxlaf" + } + ], + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/hyperledger/cactus/issues" + }, + "homepage": "https://github.com/hyperledger/cactus#readme", + "dependencies": { + "@hyperledger/cactus-common": "0.8.0", + "@hyperledger/cactus-core": "0.8.0", + "@hyperledger/cactus-core-api": "0.8.0", + "@types/google-protobuf": "3.15.3", + "axios": "0.21.1", + "express": "4.17.1", + "grpc": "1.24.11", + "iroha-helpers-ts": "0.9.25-ss", + "openapi-types": "7.0.1", + "prom-client": "13.1.0", + "typescript-optional": "2.0.1" + }, + "devDependencies": { + "@hyperledger/cactus-plugin-keychain-memory": "0.8.0", + "@hyperledger/cactus-test-tooling": "0.8.0", + "@types/express": "4.17.8" + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/json/openapi.json b/packages/cactus-plugin-ledger-connector-iroha/src/main/json/openapi.json new file mode 100644 index 0000000000..2b99054d8e --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/json/openapi.json @@ -0,0 +1,359 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Hyperledger Cactus Plugin - Connector Iroha", + "description": "Can perform basic tasks on a Iroha ledger", + "version": "0.0.1", + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "https://www.cactus.stream/{basePath}", + "description": "Public test instance", + "variables": { + "basePath": { + "default": "" + } + } + }, + { + "url": "http://localhost:4000/{basePath}", + "description": "Local test instance", + "variables": { + "basePath": { + "default": "" + } + } + } + ], + "components": { + "schemas": { + "IrohaCommand": { + "type": "string", + "enum": [ + "createAccount", + "setAccountDetail", + "setAccountQuorum", + "compareAndSetAccountDetail", + "createAsset", + "addAssetQuantity", + "subtractAssetQuantity", + "transferAsset", + "createDomain", + "createRole", + "detachRole", + "appendRole", + "addSignatory", + "removeSignatory", + "grantPermission", + "revokePermission", + "addPeer", + "removePeer", + "setSettingValue", + "callEngine" + ], + "x-enum-descriptions": [ + "Make entity in the system, capable of sending transactions or queries, storing signatories, personal data and identifiers.", + "Set key-value information for a given account.", + "Set the number of signatories required to confirm the identity of a user, who creates the transaction.", + "Set key-value information for a given account if the old value matches the value passed.", + "Create a new type of asset, unique in a domain. An asset is a countable representation of a commodity.", + "Increase the quantity of an asset on account of transaction creator.", + "Decrease the number of assets on account of transaction creator.", + "Share assets within the account in peer network: in the way that source account transfers assets to the target account.", + "Make new domain in Iroha network, which is a group of accounts.", + "Create a new role in the system from the set of permissions.", + "Detach a role from the set of roles of an account.", + "Promote an account to some created role in the system, where a role is a set of permissions account has to perform an action (command or query).", + "Add an identifier to the account. Such identifier is a public key of another device or a public key of another user.", + "Remove a public key, associated with an identity, from an account", + "Give another account rights to perform actions on the account of transaction sender (give someone right to do something with my account).", + "Revoke or dismiss given granted permission from another account in the network.", + "Write into ledger the fact of peer addition into the peer network.", + "Write into ledger the fact of peer removal from the network.", + "This command is not available for use, it was added for backward compatibility with Iroha.", + "This command is not availalbe for use because it is related to smart contract." + ], + "x-enum-varnames": [ + "CreateAccount", + "SetAccountDetail", + "SetAccountQuorum", + "CompareAndSetAccountDetail", + "CreateAsset", + "AddAssetQuantity", + "SubtractAssetQuantity", + "TransferAsset", + "CreateDomain", + "CreateRole", + "DetachRole", + "AppendRole", + "AddSignatory", + "RemoveSignatory", + "GrantPermission", + "RevokePermission", + "AddPeer", + "RemovePeer", + "SetSettingValue", + "CallEngine" + ] + }, + "IrohaQuery": { + "type": "string", + "enum": [ + "getAccount", + "getAccountDetail", + "getAssetInfo", + "getAccountAssets", + "getTransactions", + "getPendingTransactions", + "getAccountTransactions", + "getAccountAssetTransactions", + "getRoles", + "getSignatories", + "getRolePermissions", + "getBlock", + "getEngineReceipts", + "fetchCommits", + "getPeers" + ], + "x-enum-descriptions": [ + "To get the state of an account", + "To get details of the account.", + "To get information on the given asset (as for now - its precision).", + "To get the state of all assets in an account (a balance).", + "To retrieve information about transactions, based on their hashes.", + "To retrieve a list of pending (not fully signed) multisignature transactions or batches of transactions issued by account of query creator.", + "To retrieve a list of transactions per account.", + "To retrieve all transactions associated with given account and asset.", + "To get existing roles in the system.", + "To get signatories, which act as an identity of the account.", + "To get available permissions per role in the system.", + "To get a specific block, using its height as an identifier.", + "To retrieve a receipt of a CallEngine command. Allows to access the event log created during computations inside the EVM.", + "To get new blocks as soon as they are committed, a user can invoke FetchCommits RPC call to Iroha network.", + "A query that returns a list of peers in Iroha network." + ], + "x-enum-varnames": [ + "GetAccount", + "GetAccountDetail", + "GetAssetInfo", + "GetAccountAssets", + "GetTransactions", + "GetPendingTransactions", + "GetAccountTransactions", + "GetAccountAssetTransactions", + "GetRoles", + "GetSignatories", + "GetRolePermissions", + "GetBlock", + "GetEngineReceipts", + "FetchCommits", + "GetPeers" + ] + }, + "KeyPair": { + "type": "object", + "required": ["publicKey", "privateKey"], + "properties": { + "publicKey": { + "description": "SHA-3 ed25519 public keys of length 64 are recommended.", + "example": "313a07e6384776ed95447710d15e59148473ccfc052a681317a72a69f2a49910", + "type": "string", + "nullable": false + }, + "privateKey": { + "description": "SHA-3 ed25519 private keys of length 64 are recommended.", + "example": "f101537e319568c765b2cc89698325604991dca57b9716b58016b253506cab70", + "type": "string", + "nullable": false + } + } + }, + "RunTransactionRequestV1": { + "type": "object", + "required": ["commandName", "params"], + "properties": { + "commandName": { + "type": "string", + "nullable": false + }, + "baseConfig": { + "$ref": "#/components/schemas/IrohaBaseConfig", + "nullable": false + }, + "params": { + "description": "The list of arguments to pass in to the transaction request.", + "type": "array", + "default": [], + "items": {} + } + } + }, + "IrohaBaseConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "irohaHost": { + "type": "string", + "nullable": false + }, + "irohaPort": { + "type": "number", + "nullable": false + }, + "creatorAccountId": { + "type": "string", + "nullable": false + }, + "privKey": { + "type": "array", + "items": {}, + "default": [], + "nullable": false + }, + "quorum": { + "type": "number", + "nullable": false + }, + "timeoutLimit": { + "type": "number", + "nullable": false + }, + "tls": { + "type": "boolean", + "nullable": false, + "description": "Can only be set to false for an insecure grpc connection." + } + } + }, + "RunTransactionResponse": { + "type": "object", + "required": ["transactionReceipt"], + "properties": { + "transactionReceipt": {} + } + }, + "InvokeContractV1Request": { + "type": "object", + "properties": { + "contractName": {} + } + }, + "InvokeContractV1Response": { + "type": "object", + "required": ["success"], + "properties": {} + }, + "PrometheusExporterMetricsResponse": { + "type": "string", + "nullable": false + } + } + }, + "paths": { + "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/run-transaction": { + "post": { + "x-hyperledger-cactus": { + "http": { + "verbLowerCase": "post", + "path": "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/run-transaction" + } + }, + "operationId": "runTransactionV1", + "summary": "Executes a transaction on a Iroha ledger", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunTransactionRequestV1" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunTransactionResponse" + } + } + } + } + } + } + }, + "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/invoke-contract": { + "post": { + "x-hyperledger-cactus": { + "http": { + "verbLowerCase": "post", + "path": "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/invoke-contract" + } + }, + "operationId": "invokeContractV1", + "summary": "Invokes a contract on a Iroha ledger", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvokeContractV1Request" + } + } + } + }, + "responses": { + "501": { + "description": "Not implemented", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "nullable": false, + "minLength": 1, + "maxLength": 2048 + } + } + } + } + } + } + } + } + }, + "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/get-prometheus-exporter-metrics": { + "get": { + "x-hyperledger-cactus": { + "http": { + "verbLowerCase": "get", + "path": "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/get-prometheus-exporter-metrics" + } + }, + "operationId": "getPrometheusMetricsV1", + "summary": "Get the Prometheus Metrics", + "parameters": [], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PrometheusExporterMetricsResponse" + } + } + } + } + } + } + } + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore new file mode 100644 index 0000000000..57cdd7b74b --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator-ignore @@ -0,0 +1,27 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +git_push.sh +.npmignore +.gitignore diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES new file mode 100644 index 0000000000..53250c0269 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/FILES @@ -0,0 +1,5 @@ +api.ts +base.ts +common.ts +configuration.ts +index.ts diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION new file mode 100644 index 0000000000..3bff059174 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/.openapi-generator/VERSION @@ -0,0 +1 @@ +5.1.1 \ No newline at end of file diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/api.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/api.ts new file mode 100644 index 0000000000..2fe0aa5388 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/api.ts @@ -0,0 +1,546 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Connector Iroha + * Can perform basic tasks on a Iroha ledger + * + * The version of the OpenAPI document: 0.0.1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { Configuration } from './configuration'; +import globalAxios, { AxiosPromise, AxiosInstance } from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from './common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from './base'; + +/** + * + * @export + * @interface InlineResponse501 + */ +export interface InlineResponse501 { + /** + * + * @type {string} + * @memberof InlineResponse501 + */ + message?: string; +} +/** + * + * @export + * @interface InvokeContractV1Request + */ +export interface InvokeContractV1Request { + /** + * + * @type {any} + * @memberof InvokeContractV1Request + */ + contractName?: any | null; +} +/** + * + * @export + * @interface IrohaBaseConfig + */ +export interface IrohaBaseConfig { + [key: string]: object | any; + + /** + * + * @type {string} + * @memberof IrohaBaseConfig + */ + irohaHost?: string; + /** + * + * @type {number} + * @memberof IrohaBaseConfig + */ + irohaPort?: number; + /** + * + * @type {string} + * @memberof IrohaBaseConfig + */ + creatorAccountId?: string; + /** + * + * @type {Array} + * @memberof IrohaBaseConfig + */ + privKey?: Array; + /** + * + * @type {number} + * @memberof IrohaBaseConfig + */ + quorum?: number; + /** + * + * @type {number} + * @memberof IrohaBaseConfig + */ + timeoutLimit?: number; + /** + * Can only be set to false for an insecure grpc connection. + * @type {boolean} + * @memberof IrohaBaseConfig + */ + tls?: boolean; +} +/** + * + * @export + * @enum {string} + */ +export enum IrohaCommand { + /** + * Make entity in the system, capable of sending transactions or queries, storing signatories, personal data and identifiers. + */ + CreateAccount = 'createAccount', + /** + * Set key-value information for a given account. + */ + SetAccountDetail = 'setAccountDetail', + /** + * Set the number of signatories required to confirm the identity of a user, who creates the transaction. + */ + SetAccountQuorum = 'setAccountQuorum', + /** + * Set key-value information for a given account if the old value matches the value passed. + */ + CompareAndSetAccountDetail = 'compareAndSetAccountDetail', + /** + * Create a new type of asset, unique in a domain. An asset is a countable representation of a commodity. + */ + CreateAsset = 'createAsset', + /** + * Increase the quantity of an asset on account of transaction creator. + */ + AddAssetQuantity = 'addAssetQuantity', + /** + * Decrease the number of assets on account of transaction creator. + */ + SubtractAssetQuantity = 'subtractAssetQuantity', + /** + * Share assets within the account in peer network: in the way that source account transfers assets to the target account. + */ + TransferAsset = 'transferAsset', + /** + * Make new domain in Iroha network, which is a group of accounts. + */ + CreateDomain = 'createDomain', + /** + * Create a new role in the system from the set of permissions. + */ + CreateRole = 'createRole', + /** + * Detach a role from the set of roles of an account. + */ + DetachRole = 'detachRole', + /** + * Promote an account to some created role in the system, where a role is a set of permissions account has to perform an action (command or query). + */ + AppendRole = 'appendRole', + /** + * Add an identifier to the account. Such identifier is a public key of another device or a public key of another user. + */ + AddSignatory = 'addSignatory', + /** + * Remove a public key, associated with an identity, from an account + */ + RemoveSignatory = 'removeSignatory', + /** + * Give another account rights to perform actions on the account of transaction sender (give someone right to do something with my account). + */ + GrantPermission = 'grantPermission', + /** + * Revoke or dismiss given granted permission from another account in the network. + */ + RevokePermission = 'revokePermission', + /** + * Write into ledger the fact of peer addition into the peer network. + */ + AddPeer = 'addPeer', + /** + * Write into ledger the fact of peer removal from the network. + */ + RemovePeer = 'removePeer', + /** + * This command is not available for use, it was added for backward compatibility with Iroha. + */ + SetSettingValue = 'setSettingValue', + /** + * This command is not availalbe for use because it is related to smart contract. + */ + CallEngine = 'callEngine' +} + +/** + * + * @export + * @enum {string} + */ +export enum IrohaQuery { + /** + * To get the state of an account + */ + GetAccount = 'getAccount', + /** + * To get details of the account. + */ + GetAccountDetail = 'getAccountDetail', + /** + * To get information on the given asset (as for now - its precision). + */ + GetAssetInfo = 'getAssetInfo', + /** + * To get the state of all assets in an account (a balance). + */ + GetAccountAssets = 'getAccountAssets', + /** + * To retrieve information about transactions, based on their hashes. + */ + GetTransactions = 'getTransactions', + /** + * To retrieve a list of pending (not fully signed) multisignature transactions or batches of transactions issued by account of query creator. + */ + GetPendingTransactions = 'getPendingTransactions', + /** + * To retrieve a list of transactions per account. + */ + GetAccountTransactions = 'getAccountTransactions', + /** + * To retrieve all transactions associated with given account and asset. + */ + GetAccountAssetTransactions = 'getAccountAssetTransactions', + /** + * To get existing roles in the system. + */ + GetRoles = 'getRoles', + /** + * To get signatories, which act as an identity of the account. + */ + GetSignatories = 'getSignatories', + /** + * To get available permissions per role in the system. + */ + GetRolePermissions = 'getRolePermissions', + /** + * To get a specific block, using its height as an identifier. + */ + GetBlock = 'getBlock', + /** + * To retrieve a receipt of a CallEngine command. Allows to access the event log created during computations inside the EVM. + */ + GetEngineReceipts = 'getEngineReceipts', + /** + * To get new blocks as soon as they are committed, a user can invoke FetchCommits RPC call to Iroha network. + */ + FetchCommits = 'fetchCommits', + /** + * A query that returns a list of peers in Iroha network. + */ + GetPeers = 'getPeers' +} + +/** + * + * @export + * @interface KeyPair + */ +export interface KeyPair { + /** + * SHA-3 ed25519 public keys of length 64 are recommended. + * @type {string} + * @memberof KeyPair + */ + publicKey: string; + /** + * SHA-3 ed25519 private keys of length 64 are recommended. + * @type {string} + * @memberof KeyPair + */ + privateKey: string; +} +/** + * + * @export + * @interface RunTransactionRequestV1 + */ +export interface RunTransactionRequestV1 { + /** + * + * @type {string} + * @memberof RunTransactionRequestV1 + */ + commandName: string; + /** + * + * @type {IrohaBaseConfig} + * @memberof RunTransactionRequestV1 + */ + baseConfig?: IrohaBaseConfig; + /** + * The list of arguments to pass in to the transaction request. + * @type {Array} + * @memberof RunTransactionRequestV1 + */ + params: Array; +} +/** + * + * @export + * @interface RunTransactionResponse + */ +export interface RunTransactionResponse { + /** + * + * @type {any} + * @memberof RunTransactionResponse + */ + transactionReceipt: any | null; +} + +/** + * DefaultApi - axios parameter creator + * @export + */ +export const DefaultApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary Get the Prometheus Metrics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPrometheusMetricsV1: async (options: any = {}): Promise => { + const localVarPath = `/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/get-prometheus-exporter-metrics`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Invokes a contract on a Iroha ledger + * @param {InvokeContractV1Request} [invokeContractV1Request] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + invokeContractV1: async (invokeContractV1Request?: InvokeContractV1Request, options: any = {}): Promise => { + const localVarPath = `/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/invoke-contract`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(invokeContractV1Request, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Executes a transaction on a Iroha ledger + * @param {RunTransactionRequestV1} [runTransactionRequestV1] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runTransactionV1: async (runTransactionRequestV1?: RunTransactionRequestV1, options: any = {}): Promise => { + const localVarPath = `/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/run-transaction`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(runTransactionRequestV1, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * DefaultApi - functional programming interface + * @export + */ +export const DefaultApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = DefaultApiAxiosParamCreator(configuration) + return { + /** + * + * @summary Get the Prometheus Metrics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getPrometheusMetricsV1(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getPrometheusMetricsV1(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary Invokes a contract on a Iroha ledger + * @param {InvokeContractV1Request} [invokeContractV1Request] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async invokeContractV1(invokeContractV1Request?: InvokeContractV1Request, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.invokeContractV1(invokeContractV1Request, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary Executes a transaction on a Iroha ledger + * @param {RunTransactionRequestV1} [runTransactionRequestV1] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async runTransactionV1(runTransactionRequestV1?: RunTransactionRequestV1, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.runTransactionV1(runTransactionRequestV1, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * DefaultApi - factory interface + * @export + */ +export const DefaultApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = DefaultApiFp(configuration) + return { + /** + * + * @summary Get the Prometheus Metrics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPrometheusMetricsV1(options?: any): AxiosPromise { + return localVarFp.getPrometheusMetricsV1(options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Invokes a contract on a Iroha ledger + * @param {InvokeContractV1Request} [invokeContractV1Request] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + invokeContractV1(invokeContractV1Request?: InvokeContractV1Request, options?: any): AxiosPromise { + return localVarFp.invokeContractV1(invokeContractV1Request, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Executes a transaction on a Iroha ledger + * @param {RunTransactionRequestV1} [runTransactionRequestV1] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + runTransactionV1(runTransactionRequestV1?: RunTransactionRequestV1, options?: any): AxiosPromise { + return localVarFp.runTransactionV1(runTransactionRequestV1, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * DefaultApi - object-oriented interface + * @export + * @class DefaultApi + * @extends {BaseAPI} + */ +export class DefaultApi extends BaseAPI { + /** + * + * @summary Get the Prometheus Metrics + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public getPrometheusMetricsV1(options?: any) { + return DefaultApiFp(this.configuration).getPrometheusMetricsV1(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Invokes a contract on a Iroha ledger + * @param {InvokeContractV1Request} [invokeContractV1Request] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public invokeContractV1(invokeContractV1Request?: InvokeContractV1Request, options?: any) { + return DefaultApiFp(this.configuration).invokeContractV1(invokeContractV1Request, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Executes a transaction on a Iroha ledger + * @param {RunTransactionRequestV1} [runTransactionRequestV1] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DefaultApi + */ + public runTransactionV1(runTransactionRequestV1?: RunTransactionRequestV1, options?: any) { + return DefaultApiFp(this.configuration).runTransactionV1(runTransactionRequestV1, options).then((request) => request(this.axios, this.basePath)); + } +} + + diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/base.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/base.ts new file mode 100644 index 0000000000..e859f2286a --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/base.ts @@ -0,0 +1,71 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Connector Iroha + * Can perform basic tasks on a Iroha ledger + * + * The version of the OpenAPI document: 0.0.1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { Configuration } from "./configuration"; +// Some imports not used depending on template conditions +// @ts-ignore +import globalAxios, { AxiosPromise, AxiosInstance } from 'axios'; + +export const BASE_PATH = "https://www.cactus.stream".replace(/\/+$/, ""); + +/** + * + * @export + */ +export const COLLECTION_FORMATS = { + csv: ",", + ssv: " ", + tsv: "\t", + pipes: "|", +}; + +/** + * + * @export + * @interface RequestArgs + */ +export interface RequestArgs { + url: string; + options: any; +} + +/** + * + * @export + * @class BaseAPI + */ +export class BaseAPI { + protected configuration: Configuration | undefined; + + constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) { + if (configuration) { + this.configuration = configuration; + this.basePath = configuration.basePath || this.basePath; + } + } +}; + +/** + * + * @export + * @class RequiredError + * @extends {Error} + */ +export class RequiredError extends Error { + name: "RequiredError" = "RequiredError"; + constructor(public field: string, msg?: string) { + super(msg); + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/common.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/common.ts new file mode 100644 index 0000000000..7be7eb826f --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/common.ts @@ -0,0 +1,138 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Connector Iroha + * Can perform basic tasks on a Iroha ledger + * + * The version of the OpenAPI document: 0.0.1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import { Configuration } from "./configuration"; +import { RequiredError, RequestArgs } from "./base"; +import { AxiosInstance } from 'axios'; + +/** + * + * @export + */ +export const DUMMY_BASE_URL = 'https://example.com' + +/** + * + * @throws {RequiredError} + * @export + */ +export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) { + if (paramValue === null || paramValue === undefined) { + throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`); + } +} + +/** + * + * @export + */ +export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) { + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? await configuration.apiKey(keyParamName) + : await configuration.apiKey; + object[keyParamName] = localVarApiKeyValue; + } +} + +/** + * + * @export + */ +export const setBasicAuthToObject = function (object: any, configuration?: Configuration) { + if (configuration && (configuration.username || configuration.password)) { + object["auth"] = { username: configuration.username, password: configuration.password }; + } +} + +/** + * + * @export + */ +export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const accessToken = typeof configuration.accessToken === 'function' + ? await configuration.accessToken() + : await configuration.accessToken; + object["Authorization"] = "Bearer " + accessToken; + } +} + +/** + * + * @export + */ +export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) { + if (configuration && configuration.accessToken) { + const localVarAccessTokenValue = typeof configuration.accessToken === 'function' + ? await configuration.accessToken(name, scopes) + : await configuration.accessToken; + object["Authorization"] = "Bearer " + localVarAccessTokenValue; + } +} + +/** + * + * @export + */ +export const setSearchParams = function (url: URL, ...objects: any[]) { + const searchParams = new URLSearchParams(url.search); + for (const object of objects) { + for (const key in object) { + if (Array.isArray(object[key])) { + searchParams.delete(key); + for (const item of object[key]) { + searchParams.append(key, item); + } + } else { + searchParams.set(key, object[key]); + } + } + } + url.search = searchParams.toString(); +} + +/** + * + * @export + */ +export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) { + const nonString = typeof value !== 'string'; + const needsSerialization = nonString && configuration && configuration.isJsonMime + ? configuration.isJsonMime(requestOptions.headers['Content-Type']) + : nonString; + return needsSerialization + ? JSON.stringify(value !== undefined ? value : {}) + : (value || ""); +} + +/** + * + * @export + */ +export const toPathString = function (url: URL) { + return url.pathname + url.search + url.hash +} + +/** + * + * @export + */ +export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { + return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { + const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url}; + return axios.request(axiosRequestArgs); + }; +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/configuration.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/configuration.ts new file mode 100644 index 0000000000..416649445c --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/configuration.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Connector Iroha + * Can perform basic tasks on a Iroha ledger + * + * The version of the OpenAPI document: 0.0.1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface ConfigurationParameters { + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + username?: string; + password?: string; + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + basePath?: string; + baseOptions?: any; + formDataCtor?: new () => any; +} + +export class Configuration { + /** + * parameter for apiKey security + * @param name security name + * @memberof Configuration + */ + apiKey?: string | Promise | ((name: string) => string) | ((name: string) => Promise); + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + username?: string; + /** + * parameter for basic security + * + * @type {string} + * @memberof Configuration + */ + password?: string; + /** + * parameter for oauth2 security + * @param name security name + * @param scopes oauth2 scope + * @memberof Configuration + */ + accessToken?: string | Promise | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise); + /** + * override base path + * + * @type {string} + * @memberof Configuration + */ + basePath?: string; + /** + * base options for axios calls + * + * @type {any} + * @memberof Configuration + */ + baseOptions?: any; + /** + * The FormData constructor that will be used to create multipart form data + * requests. You can inject this here so that execution environments that + * do not support the FormData class can still run the generated client. + * + * @type {new () => FormData} + */ + formDataCtor?: new () => any; + + constructor(param: ConfigurationParameters = {}) { + this.apiKey = param.apiKey; + this.username = param.username; + this.password = param.password; + this.accessToken = param.accessToken; + this.basePath = param.basePath; + this.baseOptions = param.baseOptions; + this.formDataCtor = param.formDataCtor; + } + + /** + * Check if the given MIME is a JSON MIME. + * JSON MIME examples: + * application/json + * application/json; charset=UTF8 + * APPLICATION/JSON + * application/vnd.company+json + * @param mime - MIME (Multipurpose Internet Mail Extensions) + * @return True if the given MIME is JSON, false otherwise. + */ + public isJsonMime(mime: string): boolean { + const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i'); + return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json'); + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/index.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/index.ts new file mode 100644 index 0000000000..56c4647743 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/generated/openapi/typescript-axios/index.ts @@ -0,0 +1,18 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Hyperledger Cactus Plugin - Connector Iroha + * Can perform basic tasks on a Iroha ledger + * + * The version of the OpenAPI document: 0.0.1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export * from "./api"; +export * from "./configuration"; + diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.ts new file mode 100755 index 0000000000..87cb558397 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.ts @@ -0,0 +1 @@ +export * from "./public-api"; diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.web.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.web.ts new file mode 100755 index 0000000000..bdf54028d2 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/index.web.ts @@ -0,0 +1 @@ +export * from "./generated/openapi/typescript-axios/index"; diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-factory-ledger-connector.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-factory-ledger-connector.ts new file mode 100644 index 0000000000..7734d3d514 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-factory-ledger-connector.ts @@ -0,0 +1,20 @@ +import { + IPluginFactoryOptions, + PluginFactory, +} from "@hyperledger/cactus-core-api"; +import { + IPluginLedgerConnectorIrohaOptions, + PluginLedgerConnectorIroha, +} from "./plugin-ledger-connector-iroha"; + +export class PluginFactoryLedgerConnector extends PluginFactory< + PluginLedgerConnectorIroha, + IPluginLedgerConnectorIrohaOptions, + IPluginFactoryOptions +> { + async create( + pluginOptions: IPluginLedgerConnectorIrohaOptions, + ): Promise { + return new PluginLedgerConnectorIroha(pluginOptions); + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-ledger-connector-iroha.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-ledger-connector-iroha.ts new file mode 100644 index 0000000000..7e1a03dc6c --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/plugin-ledger-connector-iroha.ts @@ -0,0 +1,636 @@ +import { Server } from "http"; +import * as grpc from "grpc"; +import { Server as SecureServer } from "https"; +import { CommandService_v1Client as CommandService } from "iroha-helpers-ts/lib/proto/endpoint_grpc_pb"; +import { QueryService_v1Client as QueryService } from "iroha-helpers-ts/lib/proto/endpoint_grpc_pb"; +import commands from "iroha-helpers-ts/lib/commands/index"; +import queries from "iroha-helpers-ts/lib/queries"; +import type { Express } from "express"; +import { promisify } from "util"; +import { Optional } from "typescript-optional"; +import { + GrantablePermission, + GrantablePermissionMap, +} from "iroha-helpers-ts/lib/proto/primitive_pb"; + +import { + ConsensusAlgorithmFamily, + IPluginLedgerConnector, + IWebServiceEndpoint, + IPluginWebService, + ICactusPlugin, + ICactusPluginOptions, +} from "@hyperledger/cactus-core-api"; + +import { + PluginRegistry, + consensusHasTransactionFinality, +} from "@hyperledger/cactus-core"; + +import { + Checks, + Logger, + LoggerProvider, + LogLevelDesc, + Http405NotAllowedError, +} from "@hyperledger/cactus-common"; +import { RuntimeError } from "run-time-error"; +import { + IrohaCommand, + IrohaQuery, + RunTransactionRequestV1, + RunTransactionResponse, +} from "./generated/openapi/typescript-axios"; + +import { RunTransactionEndpoint } from "./web-services/run-transaction-endpoint"; +import { PrometheusExporter } from "./prometheus-exporter/prometheus-exporter"; +import { + GetPrometheusExporterMetricsEndpointV1, + IGetPrometheusExporterMetricsEndpointV1Options, +} from "./web-services/get-prometheus-exporter-metrics-endpoint-v1"; + +export const E_KEYCHAIN_NOT_FOUND = "cactus.connector.iroha.keychain_not_found"; + +export interface IPluginLedgerConnectorIrohaOptions + extends ICactusPluginOptions { + rpcToriiPortHost: string; + pluginRegistry: PluginRegistry; + prometheusExporter?: PrometheusExporter; + logLevel?: LogLevelDesc; +} + +export class PluginLedgerConnectorIroha + implements + IPluginLedgerConnector< + never, + never, + RunTransactionRequestV1, + RunTransactionResponse + >, + ICactusPlugin, + IPluginWebService { + private readonly instanceId: string; + public prometheusExporter: PrometheusExporter; + private readonly log: Logger; + private readonly pluginRegistry: PluginRegistry; + + private endpoints: IWebServiceEndpoint[] | undefined; + private httpServer: Server | SecureServer | null = null; + + public static readonly CLASS_NAME = "PluginLedgerConnectorIroha"; + + public get className(): string { + return PluginLedgerConnectorIroha.CLASS_NAME; + } + + constructor(public readonly options: IPluginLedgerConnectorIrohaOptions) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + Checks.truthy( + options.rpcToriiPortHost, + `${fnTag} options.rpcToriiPortHost`, + ); + Checks.truthy(options.pluginRegistry, `${fnTag} options.pluginRegistry`); + Checks.truthy(options.instanceId, `${fnTag} options.instanceId`); + + const level = this.options.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + + this.instanceId = options.instanceId; + this.pluginRegistry = options.pluginRegistry; + this.prometheusExporter = + options.prometheusExporter || + new PrometheusExporter({ pollingIntervalInMin: 1 }); + Checks.truthy( + this.prometheusExporter, + `${fnTag} options.prometheusExporter`, + ); + + this.prometheusExporter.startMetricsCollection(); + } + + deployContract(): Promise { + throw new RuntimeError("Method not implemented."); + } + + public getPrometheusExporter(): PrometheusExporter { + return this.prometheusExporter; + } + + public async getPrometheusExporterMetrics(): Promise { + const res: string = await this.prometheusExporter.getPrometheusMetrics(); + this.log.debug(`getPrometheusExporterMetrics() response: %o`, res); + return res; + } + + public getInstanceId(): string { + return this.instanceId; + } + + public async onPluginInit(): Promise { + return; + } + + public getHttpServer(): Optional { + return Optional.ofNullable(this.httpServer); + } + + public async shutdown(): Promise { + const serverMaybe = this.getHttpServer(); + if (serverMaybe.isPresent()) { + const server = serverMaybe.get(); + await promisify(server.close.bind(server))(); + } + } + + async registerWebServices(app: Express): Promise { + const webServices = await this.getOrCreateWebServices(); + await Promise.all(webServices.map((ws) => ws.registerExpress(app))); + return webServices; + } + + public async getOrCreateWebServices(): Promise { + if (Array.isArray(this.endpoints)) { + return this.endpoints; + } + const endpoints: IWebServiceEndpoint[] = []; + { + const endpoint = new RunTransactionEndpoint({ + connector: this, + logLevel: this.options.logLevel, + }); + endpoints.push(endpoint); + } + { + const opts: IGetPrometheusExporterMetricsEndpointV1Options = { + connector: this, + logLevel: this.options.logLevel, + }; + const endpoint = new GetPrometheusExporterMetricsEndpointV1(opts); + endpoints.push(endpoint); + } + this.endpoints = endpoints; + return endpoints; + } + + public getPackageName(): string { + return `@hyperledger/cactus-plugin-ledger-connector-iroha`; + } + + public async getConsensusAlgorithmFamily(): Promise< + ConsensusAlgorithmFamily + > { + return ConsensusAlgorithmFamily.Authority; + } + public async hasTransactionFinality(): Promise { + const currentConsensusAlgorithmFamily = await this.getConsensusAlgorithmFamily(); + + return consensusHasTransactionFinality(currentConsensusAlgorithmFamily); + } + + public async transact( + req: RunTransactionRequestV1, + ): Promise { + const { baseConfig } = req; + if ( + !baseConfig || + !baseConfig.privKey || + !baseConfig.creatorAccountId || + !baseConfig.irohaHost || + !baseConfig.irohaPort || + !baseConfig.quorum || + !baseConfig.timeoutLimit + ) { + this.log.debug( + "Certain field within the Iroha basic configuration is missing!", + ); + throw new RuntimeError("Some fields in baseConfig is undefined"); + } + const irohaHostPort = `${baseConfig.irohaHost}:${baseConfig.irohaPort}`; + + let grpcCredentials; + if (baseConfig.tls) { + throw new RuntimeError("TLS option is not supported"); + } else { + grpcCredentials = grpc.credentials.createInsecure(); + } + const commandService = new CommandService( + irohaHostPort, + //TODO:do something in the production environment + grpcCredentials, + ); + const queryService = new QueryService(irohaHostPort, grpcCredentials); + const commandOptions = { + privateKeys: baseConfig.privKey, //need an array of keys for command + creatorAccountId: baseConfig.creatorAccountId, + quorum: baseConfig.quorum, + commandService: commandService, + timeoutLimit: baseConfig.timeoutLimit, + }; + const queryOptions = { + privateKey: baseConfig.privKey[0], //only need 1 key for query + creatorAccountId: baseConfig.creatorAccountId as string, + queryService: queryService, + timeoutLimit: baseConfig.timeoutLimit, + }; + + switch (req.commandName) { + case IrohaCommand.CreateAccount: { + try { + const response = await commands.createAccount(commandOptions, { + accountName: req.params[0], + domainId: req.params[1], + publicKey: req.params[2], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.SetAccountDetail: { + try { + const response = await commands.setAccountDetail(commandOptions, { + accountId: req.params[0], + key: req.params[1], + value: req.params[2], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.CompareAndSetAccountDetail: { + try { + const response = await commands.compareAndSetAccountDetail( + commandOptions, + { + accountId: req.params[0], + key: req.params[1], + value: req.params[2], + oldValue: req.params[3], + }, + ); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.CreateAsset: { + try { + const response = await commands // (coolcoin#test; precision:3) + .createAsset(commandOptions, { + assetName: req.params[0], + domainId: req.params[1], + precision: req.params[2], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.CreateDomain: { + try { + const response = await commands.createDomain(commandOptions, { + domainId: req.params[0], + defaultRole: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.SetAccountQuorum: { + try { + const response = await commands.setAccountQuorum(commandOptions, { + accountId: req.params[0], + quorum: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.AddAssetQuantity: { + try { + const response = await commands.addAssetQuantity(commandOptions, { + assetId: req.params[0], + amount: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.SubtractAssetQuantity: { + try { + const response = await commands.subtractAssetQuantity( + commandOptions, + { + assetId: req.params[0], + amount: req.params[1], + }, + ); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.TransferAsset: { + try { + const response = await commands.transferAsset(commandOptions, { + srcAccountId: req.params[0], + destAccountId: req.params[1], + assetId: req.params[2], + description: req.params[3], + amount: req.params[4], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetSignatories: { + try { + const queryRes = await queries.getSignatories(queryOptions, { + accountId: req.params[0], + }); + return { transactionReceipt: queryRes }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetAccount: { + try { + const queryRes = await queries.getAccount(queryOptions, { + accountId: req.params[0], + }); + return { transactionReceipt: queryRes }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetAccountDetail: { + try { + const queryRes = await queries.getAccountDetail(queryOptions, { + accountId: req.params[0], + key: req.params[1], + writer: req.params[2], + pageSize: req.params[3], + paginationKey: req.params[4], + paginationWriter: req.params[5], + }); + return { transactionReceipt: queryRes }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetAssetInfo: { + try { + const queryRes = await queries.getAssetInfo(queryOptions, { + assetId: req.params[0], + }); + return { transactionReceipt: queryRes }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetAccountAssets: { + try { + const queryRes = await queries.getAccountAssets(queryOptions, { + accountId: req.params[0], + pageSize: req.params[1], + firstAssetId: req.params[2], + }); + return { transactionReceipt: queryRes }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.AddSignatory: { + try { + const response = await commands.addSignatory(commandOptions, { + accountId: req.params[0], + publicKey: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.RemoveSignatory: { + try { + const response = await commands.removeSignatory(commandOptions, { + accountId: req.params[0], + publicKey: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetRoles: { + try { + const response = await queries.getRoles(queryOptions); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.CreateRole: { + try { + const response = await commands.createRole(commandOptions, { + roleName: req.params[0], + permissionsList: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.AppendRole: { + try { + const response = await commands.appendRole(commandOptions, { + accountId: req.params[0], + roleName: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.DetachRole: { + try { + const response = await commands.detachRole(commandOptions, { + accountId: req.params[0], + roleName: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetRolePermissions: { + try { + const response = await queries.getRolePermissions(queryOptions, { + roleId: req.params[0], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.GrantPermission: { + try { + type permission = keyof GrantablePermissionMap; + const response = await commands.grantPermission(commandOptions, { + accountId: req.params[0], + permission: GrantablePermission[req.params[1] as permission], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.RevokePermission: { + try { + type permission = keyof GrantablePermissionMap; + const response = await commands.revokePermission(commandOptions, { + accountId: req.params[0], + permission: GrantablePermission[req.params[1] as permission], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.SetSettingValue: { + throw new Http405NotAllowedError("SetSettingValue is not supported."); + } + case IrohaQuery.GetTransactions: { + try { + const response = await queries.getTransactions(queryOptions, { + txHashesList: req.params[0], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetPendingTransactions: { + try { + const response = await queries.getPendingTransactions(queryOptions, { + pageSize: req.params[0], + firstTxHash: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetAccountTransactions: { + try { + const response = await queries.getAccountTransactions(queryOptions, { + accountId: req.params[0], + pageSize: req.params[1], + firstTxHash: req.params[2], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetAccountAssetTransactions: { + try { + const response = await queries.getAccountAssetTransactions( + queryOptions, + { + accountId: req.params[0], + assetId: req.params[1], + pageSize: req.params[2], + firstTxHash: req.params[3], + }, + ); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetBlock: { + try { + const response = await queries.getBlock(queryOptions, { + height: req.params[0], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.CallEngine: { + try { + const response = await commands.callEngine(commandOptions, { + type: req.params[0], + caller: req.params[1], + callee: req.params[2], + input: req.params[3], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetEngineReceipts: { + try { + const response = await queries.getEngineReceipts(queryOptions, { + txHash: req.params[0], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.FetchCommits: { + try { + const response = await queries.fetchCommits(queryOptions); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.AddPeer: { + try { + const response = await commands.addPeer(commandOptions, { + address: req.params[0], + peerKey: req.params[1], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaCommand.RemovePeer: { + try { + const response = await commands.removePeer(commandOptions, { + publicKey: req.params[0], + }); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + case IrohaQuery.GetPeers: { + try { + const response = await queries.getPeers(queryOptions); + return { transactionReceipt: response }; + } catch (err) { + throw new RuntimeError(err); + } + } + default: { + throw new RuntimeError( + "command or query does not exist, or is not supported in current version", + ); + } + } + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/data-fetcher.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/data-fetcher.ts new file mode 100644 index 0000000000..fc752190f8 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/data-fetcher.ts @@ -0,0 +1,10 @@ +import { Transactions } from "./response.type"; + +import { totalTxCount, K_CACTUS_IROHA_TOTAL_TX_COUNT } from "./metrics"; + +export async function collectMetrics( + transactions: Transactions, +): Promise { + transactions.counter++; + totalTxCount.labels(K_CACTUS_IROHA_TOTAL_TX_COUNT).set(transactions.counter); +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/metrics.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/metrics.ts new file mode 100644 index 0000000000..05f055d5ae --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/metrics.ts @@ -0,0 +1,10 @@ +import { Gauge } from "prom-client"; + +export const K_CACTUS_IROHA_TOTAL_TX_COUNT = "cactus_iroha_total_tx_count"; + +export const totalTxCount = new Gauge({ + registers: [], + name: "cactus_iroha_total_tx_count", + help: "Total transactions executed", + labelNames: ["type"], +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/prometheus-exporter.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/prometheus-exporter.ts new file mode 100644 index 0000000000..c4d2db9bc1 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/prometheus-exporter.ts @@ -0,0 +1,39 @@ +import promClient, { Registry } from "prom-client"; +import { Transactions } from "./response.type"; +import { collectMetrics } from "./data-fetcher"; +import { K_CACTUS_IROHA_TOTAL_TX_COUNT } from "./metrics"; +import { totalTxCount } from "./metrics"; + +export interface IPrometheusExporterOptions { + pollingIntervalInMin?: number; +} + +export class PrometheusExporter { + public readonly metricsPollingIntervalInMin: number; + public readonly transactions: Transactions = { counter: 0 }; + public readonly registry: Registry; + + constructor( + public readonly prometheusExporterOptions: IPrometheusExporterOptions, + ) { + this.metricsPollingIntervalInMin = + prometheusExporterOptions.pollingIntervalInMin || 1; + this.registry = new Registry(); + } + + public addCurrentTransaction(): void { + collectMetrics(this.transactions); + } + + public async getPrometheusMetrics(): Promise { + const result = await this.registry.getSingleMetricAsString( + K_CACTUS_IROHA_TOTAL_TX_COUNT, + ); + return result; + } + + public startMetricsCollection(): void { + this.registry.registerMetric(totalTxCount); + promClient.collectDefaultMetrics({ register: this.registry }); + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/response.type.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/response.type.ts new file mode 100644 index 0000000000..3f1bc7f491 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/prometheus-exporter/response.type.ts @@ -0,0 +1,3 @@ +export type Transactions = { + counter: number; +}; diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/public-api.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/public-api.ts new file mode 100755 index 0000000000..a36b765481 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/public-api.ts @@ -0,0 +1,17 @@ +export { + E_KEYCHAIN_NOT_FOUND, + IPluginLedgerConnectorIrohaOptions, + PluginLedgerConnectorIroha, +} from "./plugin-ledger-connector-iroha"; +export { PluginFactoryLedgerConnector } from "./plugin-factory-ledger-connector"; + +import { IPluginFactoryOptions } from "@hyperledger/cactus-core-api"; +import { PluginFactoryLedgerConnector } from "./plugin-factory-ledger-connector"; + +export * from "./generated/openapi/typescript-axios/api"; + +export async function createPluginFactory( + pluginFactoryOptions: IPluginFactoryOptions, +): Promise { + return new PluginFactoryLedgerConnector(pluginFactoryOptions); +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/get-prometheus-exporter-metrics-endpoint-v1.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/get-prometheus-exporter-metrics-endpoint-v1.ts new file mode 100644 index 0000000000..f1cbeb8266 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/get-prometheus-exporter-metrics-endpoint-v1.ts @@ -0,0 +1,94 @@ +import type { Express, Request, Response } from "express"; + +import { registerWebServiceEndpoint } from "@hyperledger/cactus-core"; + +import OAS from "../../json/openapi.json"; + +import { + IWebServiceEndpoint, + IExpressRequestHandler, + IEndpointAuthzOptions, +} from "@hyperledger/cactus-core-api"; + +import { + LogLevelDesc, + Logger, + LoggerProvider, + Checks, + IAsyncProvider, +} from "@hyperledger/cactus-common"; + +import { PluginLedgerConnectorIroha } from "../plugin-ledger-connector-iroha"; + +export interface IGetPrometheusExporterMetricsEndpointV1Options { + connector: PluginLedgerConnectorIroha; + logLevel?: LogLevelDesc; +} + +export class GetPrometheusExporterMetricsEndpointV1 + implements IWebServiceEndpoint { + private readonly log: Logger; + + constructor( + public readonly options: IGetPrometheusExporterMetricsEndpointV1Options, + ) { + const fnTag = "GetPrometheusExporterMetricsEndpointV1#constructor()"; + + Checks.truthy(options, `${fnTag} options`); + Checks.truthy(options.connector, `${fnTag} options.connector`); + + const label = "get-prometheus-exporter-metrics-endpoint"; + const level = options.logLevel || "INFO"; + this.log = LoggerProvider.getOrCreate({ label, level }); + } + + getAuthorizationOptionsProvider(): IAsyncProvider { + // TODO: make this an injectable dependency in the constructor + return { + get: async () => ({ + isProtected: true, + requiredRoles: [], + }), + }; + } + + public getExpressRequestHandler(): IExpressRequestHandler { + return this.handleRequest.bind(this); + } + + getPath(): string { + return OAS.paths[ + "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/get-prometheus-exporter-metrics" + ].get["x-hyperledger-cactus"].http.path; + } + + getVerbLowerCase(): string { + return OAS.paths[ + "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/get-prometheus-exporter-metrics" + ].get["x-hyperledger-cactus"].http.verbLowerCase; + } + + public async registerExpress( + expressApp: Express, + ): Promise { + await registerWebServiceEndpoint(expressApp, this); + return this; + } + + async handleRequest(req: Request, res: Response): Promise { + const fnTag = "GetPrometheusExporterMetrics#handleRequest()"; + const verbUpper = this.getVerbLowerCase().toUpperCase(); + this.log.debug(`${verbUpper} ${this.getPath()}`); + + try { + const resBody = await this.options.connector.getPrometheusExporterMetrics(); + res.status(200); + res.send(resBody); + } catch (ex) { + this.log.error(`${fnTag} failed to serve request`, ex); + res.status(500); + res.statusMessage = ex.message; + res.json({ error: ex.stack }); + } + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/run-transaction-endpoint.ts b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/run-transaction-endpoint.ts new file mode 100644 index 0000000000..53519852c4 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/main/typescript/web-services/run-transaction-endpoint.ts @@ -0,0 +1,119 @@ +import type { Express, Request, Response } from "express"; + +import { + Logger, + Checks, + LogLevelDesc, + LoggerProvider, + IAsyncProvider, + Http405NotAllowedError, +} from "@hyperledger/cactus-common"; +import { + IEndpointAuthzOptions, + IExpressRequestHandler, + IWebServiceEndpoint, +} from "@hyperledger/cactus-core-api"; +import { registerWebServiceEndpoint } from "@hyperledger/cactus-core"; + +import { PluginLedgerConnectorIroha } from "../plugin-ledger-connector-iroha"; + +import OAS from "../../json/openapi.json"; + +export interface IRunTransactionEndpointOptions { + logLevel?: LogLevelDesc; + connector: PluginLedgerConnectorIroha; +} + +export class RunTransactionEndpoint implements IWebServiceEndpoint { + public static readonly CLASS_NAME = "RunTransactionEndpoint"; + + private readonly log: Logger; + + public get className(): string { + return RunTransactionEndpoint.CLASS_NAME; + } + + constructor(public readonly options: IRunTransactionEndpointOptions) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + Checks.truthy(options.connector, `${fnTag} arg options.connector`); + + const level = this.options.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + } + + public getOasPath() { + return OAS.paths[ + "/api/v1/plugins/@hyperledger/cactus-plugin-ledger-connector-iroha/run-transaction" + ]; + } + + public getPath(): string { + const apiPath = this.getOasPath(); + return apiPath.post["x-hyperledger-cactus"].http.path; + } + + public getVerbLowerCase(): string { + const apiPath = this.getOasPath(); + return apiPath.post["x-hyperledger-cactus"].http.verbLowerCase; + } + + public getOperationId(): string { + return this.getOasPath().post.operationId; + } + + getAuthorizationOptionsProvider(): IAsyncProvider { + // TODO: make this an injectable dependency in the constructor + return { + get: async () => ({ + isProtected: true, + requiredRoles: [], + }), + }; + } + + public async registerExpress( + expressApp: Express, + ): Promise { + await registerWebServiceEndpoint(expressApp, this); + return this; + } + + public getExpressRequestHandler(): IExpressRequestHandler { + return this.handleRequest.bind(this); + } + + public async handleRequest(req: Request, res: Response): Promise { + const reqTag = `${this.getVerbLowerCase()} - ${this.getPath()}`; + this.log.debug(reqTag); + const reqBody = req.body; + try { + const resBody = await this.options.connector.transact(reqBody); + res.json(resBody); + } catch (ex) { + if (ex instanceof Http405NotAllowedError) { + this.log.debug("Sending back HTTP405 Method Not Allowed error."); + res.status(405); + res.json(ex); + return; + } + /** + * An example output of the error message looks like: + * "Error: Error: Command response error: expected=COMMITTED, actual=REJECTED" + * @see https://iroha.readthedocs.io/en/main/develop/api/commands.html?highlight=CallEngine#id18 + */ + if (ex.message.includes("Error: Command response error")) { + this.log.debug("Sending back HTTP400 Bad Request error."); + res.status(400); + res.json(ex); + return; + } + this.log.error(`Crash while serving ${reqTag}`, ex); + res.status(500).json({ + message: "Internal Server Error", + error: ex?.stack || ex?.message, + }); + } + } +} diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/api-surface.test.ts b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/api-surface.test.ts new file mode 100644 index 0000000000..a77b09a829 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/api-surface.test.ts @@ -0,0 +1,8 @@ +import test, { Test } from "tape-promise/tape"; + +import * as apiSurface from "../../../main/typescript/public-api"; + +test("Library can be loaded", (t: Test) => { + t.ok(apiSurface, "apiSurface truthy OK"); + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/iroha-iroha-transfer-example.test.ts b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/iroha-iroha-transfer-example.test.ts new file mode 100644 index 0000000000..0d6b7c8f30 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/iroha-iroha-transfer-example.test.ts @@ -0,0 +1,353 @@ +import http from "http"; +import { AddressInfo } from "net"; +import test, { Test } from "tape-promise/tape"; +import { v4 as uuidv4 } from "uuid"; +import { v4 as internalIpV4 } from "internal-ip"; +import bodyParser from "body-parser"; +import express from "express"; +import { + Containers, + pruneDockerAllIfGithubAction, + PostgresTestContainer, + IrohaTestLedger, +} from "@hyperledger/cactus-test-tooling"; +import { PluginRegistry } from "@hyperledger/cactus-core"; +import { PluginImportType } from "@hyperledger/cactus-core-api"; + +import { + IListenOptions, + LogLevelDesc, + Servers, +} from "@hyperledger/cactus-common"; +import { RuntimeError } from "run-time-error"; +import { + PluginLedgerConnectorIroha, + DefaultApi as IrohaApi, + PluginFactoryLedgerConnector, +} from "../../../main/typescript/public-api"; + +import { Configuration } from "@hyperledger/cactus-core-api"; + +import { + IrohaCommand, + IrohaQuery, + KeyPair, +} from "../../../main/typescript/generated/openapi/typescript-axios"; +import cryptoHelper from "iroha-helpers-ts/lib/cryptoHelper"; + +const testCase = "runs tx on an Iroha v1.2.0 ledger"; +const logLevel: LogLevelDesc = "ERROR"; + +test.onFailure(async () => { + await Containers.logDiagnostics({ logLevel }); +}); + +test("BEFORE " + testCase, async (t: Test) => { + const pruning = pruneDockerAllIfGithubAction({ logLevel }); + await t.doesNotReject(pruning, "Pruning didn't throw OK"); + t.end(); +}); + +//Start Postgres databases. +test(testCase, async (t: Test) => { + const postgres1 = new PostgresTestContainer({ logLevel }); + const postgres2 = new PostgresTestContainer({ logLevel }); + test.onFinish(async () => { + await postgres1.stop(); + await postgres2.stop(); + }); + + await postgres1.start(); + await postgres2.start(); + const postgresHost1 = await internalIpV4(); + const postgresPort1 = await postgres1.getPostgresPort(); + const postgresHost2 = await internalIpV4(); + const postgresPort2 = await postgres2.getPostgresPort(); + if (!postgresHost1 || !postgresHost2) { + throw new RuntimeError("Could not determine the internal IPV4 address."); + } + + //Start the 1st Iroha ledger with default priv/pub key pairs. + const iroha1 = new IrohaTestLedger({ + postgresHost: postgresHost1, + postgresPort: postgresPort1, + logLevel: logLevel, + }); + + //Start the 2nd Iroha ledger with randomly generated priv/pub key pairs. + const keyPairA: KeyPair = cryptoHelper.generateKeyPair(); + const adminPriv2 = keyPairA.privateKey; + const adminPub2 = keyPairA.publicKey; + const keyPairB: KeyPair = cryptoHelper.generateKeyPair(); + const nodePriv2 = keyPairB.privateKey; + const nodePub2 = keyPairB.publicKey; + const iroha2 = new IrohaTestLedger({ + adminPriv: adminPriv2, + adminPub: adminPub2, + nodePriv: nodePriv2, + nodePub: nodePub2, + postgresHost: postgresHost2, + postgresPort: postgresPort2, + logLevel: logLevel, + }); + + test.onFinish(async () => { + await iroha1.stop(); + await iroha2.stop(); + }); + await iroha1.start(); + await iroha2.start(); + const irohaHost1 = await internalIpV4(); + const irohaHost2 = await internalIpV4(); + if (!irohaHost1 || !irohaHost2) { + throw new RuntimeError("Could not determine the internal IPV4 address."); + } + const irohaPort1 = await iroha1.getRpcToriiPort(); + const rpcToriiPortHost1 = await iroha1.getRpcToriiPortHost(); + const irohaPort2 = await iroha2.getRpcToriiPort(); + const rpcToriiPortHost2 = await iroha2.getRpcToriiPortHost(); + + //Start 2 connectors for 2 Iroha ledgers. + const factory1 = new PluginFactoryLedgerConnector({ + pluginImportType: PluginImportType.Local, + }); + const connector1: PluginLedgerConnectorIroha = await factory1.create({ + rpcToriiPortHost: rpcToriiPortHost1, + instanceId: uuidv4(), + pluginRegistry: new PluginRegistry(), + }); + const factory2 = new PluginFactoryLedgerConnector({ + pluginImportType: PluginImportType.Local, + }); + const connector2: PluginLedgerConnectorIroha = await factory2.create({ + rpcToriiPortHost: rpcToriiPortHost2, + instanceId: uuidv4(), + pluginRegistry: new PluginRegistry(), + }); + //register the 2 connectors with 2 express services + const expressApp1 = express(); + expressApp1.use(bodyParser.json({ limit: "250mb" })); + const server1 = http.createServer(expressApp1); + const listenOptions1: IListenOptions = { + hostname: "0.0.0.0", + port: 0, + server: server1, + }; + const addressInfo1 = (await Servers.listen(listenOptions1)) as AddressInfo; + test.onFinish(async () => await Servers.shutdown(server1)); + const apiHost1 = `http://${addressInfo1.address}:${addressInfo1.port}`; + const apiConfig1 = new Configuration({ basePath: apiHost1 }); + const apiClient1 = new IrohaApi(apiConfig1); + + const expressApp2 = express(); + expressApp2.use(bodyParser.json({ limit: "250mb" })); + const server2 = http.createServer(expressApp2); + const listenOptions2: IListenOptions = { + hostname: "0.0.0.0", + port: 0, + server: server2, + }; + const addressInfo2 = (await Servers.listen(listenOptions2)) as AddressInfo; + test.onFinish(async () => await Servers.shutdown(server2)); + const apiHost2 = `http://${addressInfo2.address}:${addressInfo2.port}`; + const apiConfig2 = new Configuration({ basePath: apiHost2 }); + const apiClient2 = new IrohaApi(apiConfig2); + + await connector1.getOrCreateWebServices(); + await connector1.registerWebServices(expressApp1); + await connector2.getOrCreateWebServices(); + await connector2.registerWebServices(expressApp2); + + const adminPriv1 = await iroha1.getGenesisAccountPrivKey(); + const admin1 = iroha1.getDefaultAdminAccount(); + const domain1 = iroha1.getDefaultDomain(); + const adminID1 = `${admin1}@${domain1}`; + const admin2 = iroha2.getDefaultAdminAccount(); + const domain2 = iroha2.getDefaultDomain(); + const adminID2 = `${admin2}@${domain2}`; + + //Setup: create coolcoin#test for Iroha1 + const asset = "coolcoin"; + const assetID1 = `${asset}#${domain1}`; + const assetID2 = `${asset}#${domain1}`; + { + const req = { + commandName: IrohaCommand.CreateAsset, + baseConfig: { + irohaHost: irohaHost1, + irohaPort: irohaPort1, + creatorAccountId: adminID1, + privKey: [adminPriv1], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [asset, domain1, 3], + }; + const res = await apiClient1.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + //Verify the generated priv/pub keys are equivalent to those pulled from the ledger. + { + const adminPriv2_ = await iroha2.getGenesisAccountPrivKey(); + const adminPub2_ = await iroha2.getGenesisAccountPubKey(); + const { publicKey, privateKey } = await iroha2.getNodeKeyPair(); + t.equal(adminPriv2, adminPriv2_); + t.equal(adminPub2, adminPub2_); + t.equal(nodePriv2, privateKey); + t.equal(nodePub2, publicKey); + } + + //Setup: create coolcoin#test for Iroha2 + { + const req = { + commandName: IrohaCommand.CreateAsset, + baseConfig: { + irohaHost: irohaHost2, + irohaPort: irohaPort2, + creatorAccountId: adminID2, + privKey: [adminPriv2], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [asset, domain2, 3], + }; + const res = await apiClient2.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + //Iroha1's admin is initialized with 100 (coolcoin#test). + { + const req = { + commandName: IrohaCommand.AddAssetQuantity, + baseConfig: { + irohaHost: irohaHost1, + irohaPort: irohaPort1, + creatorAccountId: adminID1, + privKey: [adminPriv1], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [assetID1, "100.000"], + }; + const res = await apiClient1.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + // Iroha1's admin transfers 30 (coolcoin#test) to Iroha2's admin. + // i.e., Iroha1's admin subtracts 30 (coolcoin#test). + { + const req = { + commandName: IrohaCommand.SubtractAssetQuantity, + baseConfig: { + irohaHost: irohaHost1, + irohaPort: irohaPort1, + creatorAccountId: adminID1, + privKey: [adminPriv1], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [assetID1, "30.000"], + }; + const res = await apiClient1.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + //i.e., Iroha2's admin adds 30 (coolcoin#test). + { + const req = { + commandName: IrohaCommand.AddAssetQuantity, + baseConfig: { + irohaHost: irohaHost2, + irohaPort: irohaPort2, + creatorAccountId: adminID2, + privKey: [adminPriv2], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [assetID2, "30.000"], + }; + const res = await apiClient2.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + //Verification: iroha1's admin has 70 (coolcoin#test). + { + const req = { + commandName: IrohaQuery.GetAccountAssets, + baseConfig: { + irohaHost: irohaHost1, + irohaPort: irohaPort1, + creatorAccountId: adminID1, + privKey: [adminPriv1], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID1, 10, assetID1], + }; + const res = await apiClient1.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + { + assetId: assetID1, + accountId: adminID1, + balance: "70.000", + }, + ]); + } + //Verification: iroha2's admin has 30 (coolcoin#test). + { + const req = { + commandName: IrohaQuery.GetAccountAssets, + baseConfig: { + irohaHost: irohaHost2, + irohaPort: irohaPort2, + creatorAccountId: adminID2, + privKey: [adminPriv2], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID2, 10, assetID2], + }; + const res = await apiClient2.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + { + assetId: assetID2, + accountId: adminID2, + balance: "30.000", + }, + ]); + } + + t.end(); +}); + +test("AFTER " + testCase, async (t: Test) => { + const pruning = pruneDockerAllIfGithubAction({ logLevel }); + await t.doesNotReject(pruning, "Pruning didn't throw OK"); + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/run-transaction-endpoint-v1.test.ts b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/run-transaction-endpoint-v1.test.ts new file mode 100644 index 0000000000..47f4139551 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/integration/run-transaction-endpoint-v1.test.ts @@ -0,0 +1,1148 @@ +import http from "http"; +import { AddressInfo } from "net"; +import test, { Test } from "tape-promise/tape"; +import { v4 as uuidv4 } from "uuid"; +import { v4 as internalIpV4 } from "internal-ip"; +import bodyParser from "body-parser"; +import express from "express"; + +import { + Containers, + pruneDockerAllIfGithubAction, + PostgresTestContainer, + IrohaTestLedger, +} from "@hyperledger/cactus-test-tooling"; +import { PluginRegistry } from "@hyperledger/cactus-core"; +import { PluginImportType } from "@hyperledger/cactus-core-api"; + +import { + IListenOptions, + LogLevelDesc, + Servers, +} from "@hyperledger/cactus-common"; +import { RuntimeError } from "run-time-error"; +import { + PluginLedgerConnectorIroha, + DefaultApi as IrohaApi, + PluginFactoryLedgerConnector, +} from "../../../main/typescript/public-api"; + +import { Configuration } from "@hyperledger/cactus-core-api"; + +import { + IrohaCommand, + IrohaQuery, + KeyPair, +} from "../../../main/typescript/generated/openapi/typescript-axios"; +import cryptoHelper from "iroha-helpers-ts/lib/cryptoHelper"; + +const testCase = "runs tx on an Iroha v1.2.0 ledger"; +const logLevel: LogLevelDesc = "INFO"; + +test.onFailure(async () => { + await Containers.logDiagnostics({ logLevel }); +}); + +test("BEFORE " + testCase, async (t: Test) => { + const pruning = pruneDockerAllIfGithubAction({ logLevel }); + await t.doesNotReject(pruning, "Pruning didn't throw OK"); + t.end(); +}); + +test(testCase, async (t: Test) => { + const postgres = new PostgresTestContainer({ logLevel }); + + test.onFinish(async () => { + await postgres.stop(); + }); + + await postgres.start(); + const postgresHost = await internalIpV4(); + const postgresPort = await postgres.getPostgresPort(); + const irohaHost = await internalIpV4(); + if (!postgresHost || !irohaHost) { + throw new RuntimeError("Could not determine the internal IPV4 address."); + } + + const keyPair1: KeyPair = cryptoHelper.generateKeyPair(); + const adminPriv = keyPair1.privateKey; + const adminPubA = keyPair1.publicKey; + const keyPair2: KeyPair = cryptoHelper.generateKeyPair(); + const nodePrivA = keyPair2.privateKey; + const nodePubA = keyPair2.publicKey; + const keyPair3: KeyPair = cryptoHelper.generateKeyPair(); + const userPub = keyPair3.publicKey; + const iroha = new IrohaTestLedger({ + adminPriv: adminPriv, + adminPub: adminPubA, + nodePriv: nodePrivA, + nodePub: nodePubA, + postgresHost: postgresHost, + postgresPort: postgresPort, + logLevel: logLevel, + }); + + test.onFinish(async () => { + await iroha.stop(); + }); + await iroha.start(); + const irohaPort = await iroha.getRpcToriiPort(); + const rpcToriiPortHost = await iroha.getRpcToriiPortHost(); + const internalAddr = iroha.getInternalAddr(); + const factory = new PluginFactoryLedgerConnector({ + pluginImportType: PluginImportType.Local, + }); + + const connector: PluginLedgerConnectorIroha = await factory.create({ + rpcToriiPortHost, + instanceId: uuidv4(), + pluginRegistry: new PluginRegistry(), + }); + + const expressApp = express(); + expressApp.use(bodyParser.json({ limit: "250mb" })); + const server = http.createServer(expressApp); + const listenOptions: IListenOptions = { + hostname: "0.0.0.0", + port: 0, + server, + }; + const addressInfo = (await Servers.listen(listenOptions)) as AddressInfo; + test.onFinish(async () => await Servers.shutdown(server)); + const { address, port } = addressInfo; + const apiHost = `http://${address}:${port}`; + const apiConfig = new Configuration({ basePath: apiHost }); + const apiClient = new IrohaApi(apiConfig); + + await connector.getOrCreateWebServices(); + await connector.registerWebServices(expressApp); + + let firstTxHash; + const admin = iroha.getDefaultAdminAccount(); + const domain = iroha.getDefaultDomain(); + const adminID = `${admin}@${domain}`; + const user = uuidv4().substring(0, 5); + /** + * An account in Iroha ledger is formatted as: `account_name@domain_id` + * @see https://iroha.readthedocs.io/en/main/concepts_architecture/er_model.html?highlight=%3Casset_name%3E%23%3Cdomain_id%3E#account + */ + const userID = `${user}@${domain}`; + { + const req = { + commandName: IrohaCommand.CreateAccount, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [user, domain, userPub], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetAccount, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, { + accountId: adminID, + domainId: domain, + quorum: 1, + jsonData: "{}", + }); + } + + const moneyCreatorRole = "money_creator"; + const newDomain = "test2"; + { + const req = { + commandName: IrohaCommand.CreateDomain, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [newDomain, moneyCreatorRole], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + const asset = "coolcoin"; + /** + * An asset in Iroha ledger is formatted as: `asset_name#domain_id` + * @see https://iroha.readthedocs.io/en/main/concepts_architecture/er_model.html?highlight=%3Casset_name%3E%23%3Cdomain_id%3E#asset + */ + const assetID = `${asset}#${domain}`; + { + const req = { + commandName: IrohaCommand.CreateAsset, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [asset, domain, 3], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetAssetInfo, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [assetID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, { + assetId: assetID, + domainId: domain, + precision: 3, + }); + } + + { + const req = { + commandName: IrohaCommand.AddAssetQuantity, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [assetID, "123.123"], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + const txDescription = uuidv4().substring(0, 5) + Date.now(); + { + const req = { + commandName: IrohaCommand.TransferAsset, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, userID, assetID, txDescription, "57.75"], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + firstTxHash = res.data.transactionReceipt.txHash; + console.log(firstTxHash); + } + + { + const req = { + commandName: IrohaQuery.GetAccountAssets, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, 100, assetID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + { + assetId: assetID, + accountId: adminID, + balance: "65.373", + }, + ]); + } + + { + const req = { + commandName: IrohaQuery.GetAccountAssets, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, 100, assetID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + { + assetId: assetID, + accountId: userID, + balance: "57.75", + }, + ]); + } + + { + const req = { + commandName: IrohaCommand.SubtractAssetQuantity, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [assetID, "30.123"], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetAccountAssets, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, 100, assetID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + { + assetId: assetID, + accountId: adminID, + balance: "35.250", + }, + ]); + } + + { + const req = { + commandName: IrohaQuery.GetSignatories, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEquals(res.data.transactionReceipt, [adminPubA]); + } + + const keyPair4: KeyPair = cryptoHelper.generateKeyPair(); + const adminPubB = keyPair4.publicKey; + { + const req = { + commandName: IrohaCommand.AddSignatory, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, adminPubB], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetSignatories, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.true(res.data.transactionReceipt.includes(adminPubA)); + t.true(res.data.transactionReceipt.includes(adminPubB)); + } + + { + const req = { + commandName: IrohaCommand.RemoveSignatory, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, adminPubB], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetSignatories, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [adminPubA]); + } + + { + const req = { + commandName: IrohaQuery.GetRoles, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + "cactus_test", + "cactus_test_full", + "admin", + "user", + moneyCreatorRole, + ]); + } + + { + const req = { + commandName: IrohaQuery.GetRolePermissions, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [moneyCreatorRole], + }; + const res = await apiClient.runTransactionV1(req); + console.log(res.data.transactionReceipt); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + /** + * Iroha Javascript SDK maps each permission to an index number + * @see https://github.com/hyperledger/iroha-javascript/blob/master/src/proto/primitive_pb.d.ts#L193-L247 + */ + const permissionArr = [3, 11, 12, 13]; + t.deepEqual(res.data.transactionReceipt, permissionArr); + } + + { + const req = { + commandName: IrohaQuery.GetTransactions, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + /** + * param[0] needs to be an array of transactions + * Example: [[TxHash1, TxHash2, TxHash3]] + * @see https://iroha.readthedocs.io/en/main/develop/api/queries.html?highlight=GetTransactions#id25 + */ + params: [[firstTxHash]], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual( + res.data.transactionReceipt.array[0][0][0][0][0][0].slice(-1)[0], + [adminID, userID, assetID, txDescription, "57.75"], + ); + } + + { + const req = { + commandName: IrohaQuery.GetAccountTransactions, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, 100, firstTxHash], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual( + res.data.transactionReceipt.transactionsList[0].payload.reducedPayload + .commandsList, + [ + { + transferAsset: { + srcAccountId: adminID, + destAccountId: userID, + assetId: assetID, + description: txDescription, + amount: "57.75", + }, + }, + ], + ); + t.equal( + res.data.transactionReceipt.transactionsList[0].signaturesList[0] + .publicKey, + adminPubA, + ); + } + + { + const req = { + commandName: IrohaQuery.GetAccountAssetTransactions, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, assetID, 100, undefined], + }; + const res = await apiClient.runTransactionV1(req); + t.deepEqual( + res.data.transactionReceipt.transactionsList[0].payload.reducedPayload + .commandsList, + [ + { + transferAsset: { + srcAccountId: adminID, + destAccountId: userID, + assetId: assetID, + description: txDescription, + amount: "57.75", + }, + }, + ], + ); + t.equal( + res.data.transactionReceipt.transactionsList[0].signaturesList[0] + .publicKey, + adminPubA, + ); + } + + { + const req = { + commandName: IrohaQuery.GetPeers, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, [ + { + address: internalAddr, + peerKey: nodePubA, + tlsCertificate: "", + }, + ]); + } + + { + const req = { + commandName: IrohaQuery.GetBlock, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [1], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual( + res.data.transactionReceipt.payload.transactionsList[0].payload + .reducedPayload.commandsList[0].addPeer.peer, + { + address: internalAddr, + peerKey: nodePubA, + tlsCertificate: "", + }, + ); + } + + { + const req = { + commandName: IrohaCommand.AppendRole, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, moneyCreatorRole], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaCommand.DetachRole, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, moneyCreatorRole], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + const testRole = uuidv4().substring(0, 5); + { + const req = { + commandName: IrohaCommand.CreateRole, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [testRole, [6, 7]], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaCommand.GrantPermission, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, "CAN_CALL_ENGINE_ON_MY_BEHALF"], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaCommand.RevokePermission, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, "CAN_CALL_ENGINE_ON_MY_BEHALF"], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaCommand.SetAccountDetail, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, "age", "18"], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetAccountDetail, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, "age", adminID, 1, "age", adminID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, { + "admin@test": { age: "18" }, + }); + } + + { + const req = { + commandName: IrohaCommand.CompareAndSetAccountDetail, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, "age", "118", "18"], //change age from 18 to 118 + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaQuery.GetAccountDetail, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [userID, "age", adminID, 1, "age", adminID], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, { + "admin@test": { age: "118" }, + }); + } + + { + const req = { + commandName: IrohaQuery.GetEngineReceipts, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [firstTxHash], + }; + const res = await apiClient.runTransactionV1(req); + t.deepEqual(res.data.transactionReceipt.array, [[]]); + } + + const key = uuidv4().substring(0, 5) + Date.now(); + const value = uuidv4().substring(0, 5) + Date.now(); + { + const req = { + commandName: IrohaCommand.SetSettingValue, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [key, value], + }; + await t.rejects( + apiClient.runTransactionV1(req), + /[\s\S]*/, + "SetSettingValue transaction is rejected OK", + ); + } + + /** + * The callee and input values are taken from Iroha doc's example. + * At some point, we should generate it on our own. + * @see https://iroha.readthedocs.io/en/main/develop/api/commands.html?highlight=CallEngine#id18 + */ + const callee = "7C370993FD90AF204FD582004E2E54E6A94F2651"; + const input = + "40c10f19000000000000000000000000969453762b0c739dd285b31635efa00e24c2562800000000000000000000000000000000000000000000000000000000000004d2"; + { + const req = { + commandName: IrohaCommand.CallEngine, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [undefined, adminID, callee, input], + }; + await t.rejects( + apiClient.runTransactionV1(req), + /[\s\S]*/, + "CallEngine transaction is rejected OK", + ); + } + + /** + * FIXME - the Iroha Javascript SDK does not give any output if we try to produce a pending transaction + * This results in an infinite loop and thus the following code cannot be executed. + * Once the Iroha Javascript SDK is justitied. We can safely produce a pending transaction. + * @see https://github.com/hyperledger/iroha-javascript/issues/66 + * Dealing with it will cause the test suite fail, so only testing against an empty pending transaction case. + */ + { + const req = { + commandName: IrohaQuery.GetPendingTransactions, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [5, undefined], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.deepEqual(res.data.transactionReceipt, []); + } + + const keyPair5: KeyPair = cryptoHelper.generateKeyPair(); + const adminPubC = keyPair5.publicKey; + const adminPrivC = keyPair5.privateKey; + { + const req = { + commandName: IrohaCommand.AddSignatory, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, adminPubC], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + { + const req = { + commandName: IrohaCommand.SetAccountQuorum, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv], + quorum: 1, + timeoutLimit: 5000, + tls: false, + }, + params: [adminID, 2], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + console.log(res.data.transactionReceipt); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + const keyPair6: KeyPair = cryptoHelper.generateKeyPair(); + const nodePubB = keyPair6.publicKey; + /** + * Take advantage of Postgres's address to fake it as the peer address + * since it is different from existing Iroha node's address + */ + const peerAddr = `${postgresHost}:${postgresPort}`; + { + const req = { + commandName: IrohaCommand.AddPeer, + baseConfig: { + irohaHost: irohaHost, + irohaPort: irohaPort, + creatorAccountId: adminID, + privKey: [adminPriv, adminPrivC], + quorum: 2, + timeoutLimit: 5000, + tls: false, + }, + params: [peerAddr, nodePubB], + }; + const res = await apiClient.runTransactionV1(req); + t.ok(res); + t.ok(res.data); + t.equal(res.status, 200); + t.equal(res.data.transactionReceipt.status, "COMMITTED"); + } + + // // Use Promise.race to cancel the promise + // { + // const req1 = { + // commandName: "producePendingTx", + // params: [], + // }; + // Promise.race([ + // //FIXME - the Iroha Javascript SDK does not give any output if we try to produce a pending transaction + // // This results in an infinite loop and thus the following code cannot be executed. + // // This fix is not perfect. It cancels the request with a timeout, but will result in grpc "Error: 14 UNAVAILABLE: GOAWAY received + // // Once the Iroha Javascript SDK is justitied. We can safely produce a pending transaction. + // apiClient.runTransactionV1(req1), + // new Promise((resolve) => setTimeout(resolve, 1000)), + // ]); + // } + + // use bluebird to cancel Promise + // { + // const req1 = { + // commandName: "producePendingTx", + // params: [], + // }; + // const promise = apiClient.runTransactionV1(req1); + // const p2 = new Promise((onCancel) => { + // promise; + // onCancel(() => console.log("p2 canceled")); + // }); + // p2.cancel(); + // } + + // // { + // // const req = { + // // commandName: "removePeer", + // // params: [ + // // "0000000000000000000000000000000000000000000000000000000000000002", + // // ], + // // }; + // // const res = await apiClient.runTransactionV1(req); + // // console.log(res.data.transactionReceipt); + // // } + + // // { + // // const req = { + // // commandName: "fetchCommits", + // // params: [], + // // }; + // // const res = await apiClient.runTransactionV1(req); + // // t.ok(res); + // // t.ok(res.data); + // // t.equal(res.status, 200); + // // console.log(res.data.transactionReceipt); + // // } + t.end(); +}); + +test("AFTER " + testCase, async (t: Test) => { + const pruning = pruneDockerAllIfGithubAction({ logLevel }); + await t.doesNotReject(pruning, "Pruning didn't throw OK"); + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/api-surface.test.ts b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/api-surface.test.ts new file mode 100644 index 0000000000..a77b09a829 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/api-surface.test.ts @@ -0,0 +1,8 @@ +import test, { Test } from "tape-promise/tape"; + +import * as apiSurface from "../../../main/typescript/public-api"; + +test("Library can be loaded", (t: Test) => { + t.ok(apiSurface, "apiSurface truthy OK"); + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/iroha-test-ledger-parameters.test.ts b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/iroha-test-ledger-parameters.test.ts new file mode 100644 index 0000000000..e066d2fb2c --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/iroha-test-ledger-parameters.test.ts @@ -0,0 +1,35 @@ +import test, { Test } from "tape"; +import { IrohaTestLedger } from "@hyperledger/cactus-test-tooling"; + +test("constructor does not throw with the default config", async (t: Test) => { + t.plan(1); + + // No options + const irohaTestLedger = new IrohaTestLedger({ + postgresHost: "127.0.0.1", + postgresPort: 5432, + }); + + t.ok(irohaTestLedger); + t.end(); +}); + +test("Iroha environment variables passed correctly", async (t: Test) => { + t.plan(2); + const simpleEnvVars = [ + "IROHA_POSTGRES_USER=postgres", + "IROHA_POSTGRES_PASSWORD=mysecretpassword", + "KEY=node0", + ]; + + const irohaOptions = { + postgresHost: "localhost", + postgresPort: 5432, + envVars: simpleEnvVars, + }; + const irohaTestLedger = new IrohaTestLedger(irohaOptions); + + t.equal(irohaTestLedger.envVars, simpleEnvVars); + t.ok(irohaTestLedger); + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/postgres-test-container-parameters.test.ts b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/postgres-test-container-parameters.test.ts new file mode 100644 index 0000000000..ad8f124108 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/src/test/typescript/unit/postgres-test-container-parameters.test.ts @@ -0,0 +1,29 @@ +import test, { Test } from "tape"; +import { PostgresTestContainer } from "@hyperledger/cactus-test-tooling"; + +test("constructor does not throw with the default config", async (t: Test) => { + t.plan(1); + + // No options + const postgresTestContainer = new PostgresTestContainer(); + + t.ok(postgresTestContainer); + t.end(); +}); + +test("Postgres environment variables passed correctly", async (t: Test) => { + t.plan(2); + const simpleEnvVars = [ + "POSTGRES_USER=postgres", + "POSTGRES_PASSWORD=mysecretpassword", + ]; + + const postgresOptions = { + envVars: simpleEnvVars, + }; + const postgresTestLedger = new PostgresTestContainer(postgresOptions); + + t.equal(postgresTestLedger.envVars, simpleEnvVars); + t.ok(postgresTestLedger); + t.end(); +}); diff --git a/packages/cactus-plugin-ledger-connector-iroha/tsconfig.json b/packages/cactus-plugin-ledger-connector-iroha/tsconfig.json new file mode 100644 index 0000000000..36b99e2d19 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-iroha/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist/lib/", + "declarationDir": "dist/types", + "resolveJsonModule": true, + "rootDir": "./src", + "tsBuildInfoFile": "../../.build-cache/cactus-plugin-ledger-connector-iroha.tsbuildinfo" + }, + "include": [ + "./src", + "src/**/*.json" + ], + "references": [ + { + "path": "../cactus-common/tsconfig.json" + }, + { + "path": "../cactus-core/tsconfig.json" + }, + { + "path": "../cactus-core-api/tsconfig.json" + }, + { + "path": "../cactus-plugin-keychain-memory/tsconfig.json" + }, + { + "path": "../cactus-test-tooling/tsconfig.json" + } + ] +} \ No newline at end of file diff --git a/packages/cactus-test-tooling/src/main/typescript/iroha/iroha-test-ledger.ts b/packages/cactus-test-tooling/src/main/typescript/iroha/iroha-test-ledger.ts index 46b8c7870e..8a3d74bd21 100644 --- a/packages/cactus-test-tooling/src/main/typescript/iroha/iroha-test-ledger.ts +++ b/packages/cactus-test-tooling/src/main/typescript/iroha/iroha-test-ledger.ts @@ -20,6 +20,9 @@ export interface IIrohaTestLedgerOptions { readonly adminPub?: string; readonly nodePriv?: string; readonly nodePub?: string; + readonly tlsCert?: string; + readonly tlsKey?: string; + readonly toriiTlsPort?: number; readonly postgresHost: string; readonly postgresPort: number; readonly imageVersion?: string; @@ -40,7 +43,10 @@ export const IROHA_TEST_LEDGER_DEFAULT_OPTIONS = Object.freeze({ adminPub: " ", nodePriv: " ", nodePub: " ", + tlsCert: " ", + tlsKey: " ", rpcToriiPort: 50051, + toriiTlsPort: 55552, envVars: [ "IROHA_POSTGRES_USER=postgres", "IROHA_POSTGRES_PASSWORD=my-secret-password", @@ -57,11 +63,14 @@ export const IROHA_TEST_LEDGER_OPTIONS_JOI_SCHEMA: Joi.Schema = Joi.object().key adminPub: Joi.string().min(1).max(64).required(), nodePriv: Joi.string().min(1).max(64).required(), nodePub: Joi.string().min(1).max(64).required(), + tlsCert: Joi.string().min(1).required(), + tlsKey: Joi.string().min(1).required(), + toriiTlsPort: Joi.number().port().required(), postgresPort: Joi.number().port().required(), postgresHost: Joi.string().hostname().required(), imageVersion: Joi.string().min(5).required(), imageName: Joi.string().min(1).required(), - rpcToriiPort: Joi.number().min(1024).max(65535).required(), + rpcToriiPort: Joi.number().port().required(), envVars: Joi.array().allow(null).required(), }, ); @@ -78,6 +87,9 @@ export class IrohaTestLedger implements ITestLedger { public readonly adminPub: string; public readonly nodePriv: string; public readonly nodePub: string; + public readonly tlsCert?: string; + public readonly tlsKey?: string; + public readonly toriiTlsPort?: number; private readonly log: Logger; private container: Container | undefined; @@ -110,6 +122,11 @@ export class IrohaTestLedger implements ITestLedger { this.envVars = options.envVars || [ ...IROHA_TEST_LEDGER_DEFAULT_OPTIONS.envVars, ]; + this.tlsCert = options.tlsCert || IROHA_TEST_LEDGER_DEFAULT_OPTIONS.tlsCert; + this.tlsKey = options.tlsKey || IROHA_TEST_LEDGER_DEFAULT_OPTIONS.tlsKey; + this.toriiTlsPort = + options.toriiTlsPort || IROHA_TEST_LEDGER_DEFAULT_OPTIONS.toriiTlsPort; + this.envVars.push(`IROHA_POSTGRES_HOST=${this.postgresHost}`); this.envVars.push(`IROHA_POSTGRES_PORT=${this.postgresPort}`); this.envVars.push(`ADMIN_PRIV=${this.adminPriv}`); @@ -156,12 +173,30 @@ export class IrohaTestLedger implements ITestLedger { } /** - * Output is based on the standard Iroha genesis.block contents. + * Output is based on the standard Iroha genesis.block content. + * + * @see https://github.com/hyperledger/iroha/blob/main/example/genesis.block + */ + public getInternalAddr(): string { + return "127.0.0.1:10001"; + } + + /** + * Output is based on the standard Iroha genesis.block content. + * + * @see https://github.com/hyperledger/iroha/blob/main/example/genesis.block + */ + public getDefaultAdminAccount(): string { + return "admin"; + } + + /** + * Output is based on the standard Iroha genesis.block content. * * @see https://github.com/hyperledger/iroha/blob/main/example/genesis.block */ - public getGenesisAdminAccount(): string { - return "admin@test"; + public getDefaultDomain(): string { + return "test"; } /** @@ -234,7 +269,9 @@ export class IrohaTestLedger implements ITestLedger { } return new Promise((resolve, reject) => { - const userID = this.getGenesisAdminAccount(); + const admin = this.getDefaultAdminAccount(); + const domain = this.getDefaultDomain(); + const adminID = `${admin}@${domain}`; const toriiPort = this.getDefaultToriiPort(); const eventEmitter: EventEmitter = docker.run( this.imageFqn, @@ -249,7 +286,7 @@ export class IrohaTestLedger implements ITestLedger { //Healthcheck script usage: python3 /healthcheck.py userID toriiPort Test: [ "CMD-SHELL", - `python3 /healthcheck.py ${userID} ${toriiPort}`, + `python3 /healthcheck.py ${adminID} ${toriiPort}`, ], Interval: 1000000000, // 1 second Timeout: 3000000000, // 3 seconds @@ -391,6 +428,9 @@ export class IrohaTestLedger implements ITestLedger { adminPub: this.adminPub, nodePriv: this.nodePriv, nodePub: this.nodePub, + tlsCert: this.tlsCert, + tlsKey: this.tlsKey, + toriiTlsPort: this.toriiTlsPort, postgresHost: this.postgresHost, postgresPort: this.postgresPort, imageVersion: this.imageVersion, diff --git a/tsconfig.json b/tsconfig.json index f76dc8a947..380834b19a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,7 @@ { "path": "./packages/cactus-plugin-ledger-connector-fabric/tsconfig.json" }, + { "path": "./packages/cactus-plugin-ledger-connector-iroha/tsconfig.json" }, { "path": "./packages/cactus-plugin-ledger-connector-quorum/tsconfig.json" }, diff --git a/webpack.prod.node.js b/webpack.prod.node.js index a1e8b89690..884f7f9e89 100644 --- a/webpack.prod.node.js +++ b/webpack.prod.node.js @@ -69,6 +69,7 @@ module.exports = { externals: { "swarm-js": "swarm-js", "node-ssh": "node-ssh", + "grpc": "grpc", npm: "npm", "fabric-client": "fabric-client", "fabric-ca-client": "fabric-ca-client",