├── .dockerignore ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── LICENSE_NOTICE ├── README.md ├── bundle ├── bundle_and_store.js ├── unix.sh └── windows.bat ├── docker-compose.test.yml ├── docker-compose.yml ├── docker └── README.md ├── docs ├── Plutus_runtime_and_interaction_model.md ├── README.md ├── contract_bundle.md └── images │ └── server_components_diagram.png ├── features ├── client_validation.feature ├── contract_interaction.feature ├── contract_loading.feature ├── contract_unloading.feature ├── list_loaded_contracts.feature └── signed_transaction_submission.feature ├── package-lock.json ├── package.json ├── puppeteer_evaluater.js ├── src ├── client │ ├── Client.ts │ ├── README.MD │ └── index.ts ├── core │ ├── Bundle.ts │ ├── Contract.ts │ ├── ContractCallInstruction.ts │ ├── ContractRepository.ts │ ├── Endpoint.ts │ ├── Engine.ts │ ├── EngineClient.ts │ ├── Events.ts │ ├── ExecutionEngines.ts │ ├── OperationMode.ts │ ├── PortAllocation.ts │ ├── PortAllocationRepository.ts │ ├── errors │ │ ├── AllPortsAllocated.ts │ │ ├── UnknownEntity.ts │ │ └── index.ts │ └── index.ts ├── execution_service │ ├── README.md │ ├── application │ │ ├── Api.ts │ │ ├── ExecutionEngine.ts │ │ ├── ExecutionEngineController.ts │ │ ├── ExecutionService.spec.ts │ │ ├── ExecutionService.ts │ │ └── index.ts │ ├── config.ts │ ├── errors │ │ ├── BadArgument.ts │ │ ├── ContainerFailedToStart.ts │ │ ├── ContractNotLoaded.ts │ │ ├── ExecutionFailure.ts │ │ ├── InvalidEndpoint.ts │ │ ├── MissingConfig.ts │ │ └── index.ts │ ├── index.ts │ ├── infrastructure │ │ ├── docker_client │ │ │ ├── DockerClient.spec.ts │ │ │ └── DockerClient.ts │ │ ├── execution_engines │ │ │ ├── DockerExecutionEngine.ts │ │ │ ├── NodeJsExecutionEngine.spec.ts │ │ │ ├── NodeJsExecutionEngine.ts │ │ │ ├── StubExecutionEngine.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── node_js │ │ │ ├── execute.spec.ts │ │ │ └── execute.ts │ └── test │ │ ├── docker-integration.spec.ts │ │ ├── node-integration.spec.ts │ │ └── security │ │ └── node_js │ │ ├── isolation_from_nodejs.spec.ts │ │ ├── load_test.spec.ts │ │ ├── network_attacks.spec.ts │ │ ├── page_boundaries.spec.ts │ │ └── resource_consumption_attack.spec.ts ├── lib │ ├── Entity.ts │ ├── NetworkInterface.ts │ ├── NumberRange.ts │ ├── PortMapper.spec.ts │ ├── PortMapper.ts │ ├── Repository.ts │ ├── compileContractSchema.spec.ts │ ├── compileContractSchema.ts │ ├── createEndpoint.spec.ts │ ├── createEndpoint.ts │ ├── expressEventPromiseHandler.ts │ ├── fsPromises.ts │ ├── httpEventPromiseHandler.ts │ ├── index.ts │ ├── repositories │ │ ├── InMemoryRepository.spec.ts │ │ ├── InMemoryRepository.ts │ │ └── index.ts │ └── test │ │ ├── RogueService.ts │ │ ├── checkPortIsFree.ts │ │ ├── index.ts │ │ ├── populatedContractRepository.ts │ │ └── testContracts.ts ├── server │ ├── README.md │ ├── application │ │ ├── Api.spec.ts │ │ ├── Api.ts │ │ ├── ContractController.spec.ts │ │ ├── ContractController.ts │ │ ├── Server.spec.ts │ │ ├── Server.ts │ │ ├── errors │ │ │ ├── ContractNotLoaded.ts │ │ │ └── index.ts │ │ └── index.ts │ ├── index.ts │ └── infrastructure │ │ ├── engine_clients │ │ ├── StubEngineClient.ts │ │ ├── index.ts │ │ └── plutus │ │ │ ├── PlutusEngineClient.spec.ts │ │ │ └── PlutusEngineClient.ts │ │ ├── index.ts │ │ └── pubsub_clients │ │ ├── MemoryPubSubClient.ts │ │ ├── RedisPubSubClient.ts │ │ └── index.ts ├── single_process.ts └── test │ └── e2e │ ├── steps │ ├── contract_interactions.ts │ ├── contract_loading.ts │ └── list_contracts.ts │ └── support │ ├── hooks.ts │ └── world.ts ├── test ├── bundles │ ├── create_docker_tar │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── index.js │ │ ├── package-lock.json │ │ └── package.json │ ├── docker │ │ ├── abcd │ │ └── plutusGuessingGame │ └── nodejs │ │ ├── abcd │ │ └── plutusGuessingGame └── mocha.opts ├── tsconfig.json ├── tsoa.json ├── wallaby.conf.js └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/execution_service/routes.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "extends": [ 7 | "standard" 8 | ], 9 | "plugins": [ 10 | "@typescript-eslint", 11 | "chai-friendly" 12 | ], 13 | "globals": { 14 | "it": "readonly", 15 | "describe": "readonly", 16 | "beforeEach": "readonly", 17 | "afterEach": "readonly" 18 | }, 19 | "rules": { 20 | "linebreak-style": [ 21 | 2, 22 | "unix" 23 | ], 24 | "no-unused-vars": 0, 25 | "no-unused-expressions": 0, 26 | "chai-friendly/no-unused-expressions": 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *node_modules* 2 | /contract_dist 3 | /dist 4 | /.nyc_output 5 | /coverage 6 | /src/execution_service/routes.ts 7 | /test/e2e/dist 8 | docker/* 9 | !docker/README.md 10 | .idea 11 | *.tsbuildinfo 12 | build -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | os: 5 | - windows 6 | - linux 7 | - osx 8 | script: 9 | - npm run bundle -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.15.3-alpine as builder 2 | RUN apk add --update git python krb5 krb5-libs gcc make g++ krb5-dev 3 | RUN mkdir /application 4 | COPY package.json /application/package.json 5 | WORKDIR /application 6 | RUN npm i 7 | COPY . /application 8 | RUN npm run build 9 | 10 | FROM node:10.15.3-alpine as test 11 | COPY --from=builder /application /application 12 | WORKDIR /application 13 | CMD ["npm", "test"] 14 | 15 | FROM node:10.15.3-alpine as production_deps 16 | RUN apk add --update git python krb5 krb5-libs gcc make g++ krb5-dev 17 | RUN mkdir /application 18 | COPY package.json /application/package.json 19 | WORKDIR /application 20 | RUN npm i --production 21 | 22 | FROM node:10.15.3-alpine as server 23 | RUN mkdir /application 24 | COPY --from=builder /application/src /application/src 25 | COPY --from=builder /application/dist/core /application/dist/core 26 | COPY --from=builder /application/dist/lib /application/dist/lib 27 | COPY --from=builder /application/dist/execution_service /application/dist/execution_service 28 | COPY --from=builder /application/dist/server /application/dist/server 29 | COPY --from=builder /application/webpack.config.js /application/webpack.config.js 30 | COPY --from=builder /application/tsconfig.json /application/tsconfig.json 31 | COPY --from=builder /application/node_modules /application/node_modules 32 | WORKDIR /application 33 | CMD ["./node_modules/.bin/pm2", "--no-daemon", "start", "dist/server/index.js"] 34 | 35 | FROM node:10.15.3-alpine as execution_service 36 | RUN mkdir /application 37 | RUN mkdir /application/docker 38 | COPY --from=builder /application/src /application/src 39 | COPY --from=builder /application/dist/core /application/dist/core 40 | COPY --from=builder /application/dist/lib /application/dist/lib 41 | COPY --from=builder /application/dist/execution_service /application/dist/execution_service 42 | COPY --from=builder /application/puppeteer_evaluater.js /application/puppeteer_evaluater.js 43 | COPY --from=builder /application/dist/swagger.json /application/dist/swagger.json 44 | COPY --from=production_deps /application/node_modules /application/node_modules 45 | WORKDIR /application 46 | CMD ["./node_modules/.bin/pm2", "--no-daemon", "start", "dist/execution_service/index.js"] -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | 4 | tools {nodejs "Node 10"} 5 | 6 | // Lock concurrent builds due to the docker dependency 7 | options { 8 | lock resource: 'DockerJob' 9 | disableConcurrentBuilds() 10 | } 11 | 12 | stages { 13 | stage('Install') { 14 | steps { 15 | sh 'npm i' 16 | } 17 | } 18 | stage('Unit/Integration Test') { 19 | steps { 20 | sh 'npm test' 21 | } 22 | } 23 | stage('E2E Single Process Setup') { 24 | steps { 25 | sh 'npm start' 26 | } 27 | } 28 | stage('E2E Single Process Test') { 29 | steps { 30 | sh 'npm run e2e:nodejs' 31 | } 32 | post { 33 | always { 34 | sh 'npm stop || true' 35 | sh 'git add -A && git reset --hard || true' 36 | } 37 | } 38 | } 39 | stage('E2E Docker Setup') { 40 | steps { 41 | sh 'docker-compose build' 42 | sh 'docker-compose -p smart-contract-backend up -d' 43 | } 44 | } 45 | stage('E2E Docker Test') { 46 | steps { 47 | sh 'npm run e2e:docker' 48 | } 49 | post { 50 | always { 51 | sh 'docker kill $(docker ps -q) || true' 52 | sh 'docker-compose -p smart-contract-backend down' 53 | sh 'docker system prune -a -f' 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /LICENSE_NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2019 IOHK 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License”). You may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.txt 4 | 5 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Smart Contract Backend 2 | 3 | [![Build Status](http://13.238.211.79:8080/buildStatus/icon?job=smart-contract-backend%2Fdevelop)](http://13.238.211.79:8080/blue/organizations/jenkins/smart-contract-backend/) 4 | 5 | Run off-chain smart contract executables server-side in isolation, accessible via a GraphQL interface. The [server](src/server/README.md) exposes a GraphQL control API for loading contracts and subscribing to signing requests for transactions generated by the contracts. An [execution service](src/execution_service/README.md) isolates potentially untrusted code execution, enabling interaction with the contracts via a [Docker](src/execution_service/infrastructure/execution_engines/DockerExecutionEngine.ts) or [NodeJS](src/execution_service/infrastructure/execution_engines/NodeJsExecutionEngine.ts) engine. 6 | 7 | For TypeScript/JavaScript applications the provided [client](src/client/README.MD) will serve as a good starting point, and provides a lightweight approach for most applications. 8 | 9 | The primary goal for the project is to deliver [a runtime and interaction model for Plutus](docs/Plutus_runtime_and_interaction_model.md), however there is no fixed coupling to any particular Smart Contract language. 10 | 11 | - [Features](features) 12 | - [More documentation](docs) 13 | 14 | ## Project State: Alpha 15 | This system is a work in progress, and dependent on external tooling efforts. The Docker-based engine will likely be first to reach stability, but development on both engines is happening in parallel. 16 | 17 | ## Development 18 | 19 | ### Docker Compose 20 | 1. Uncomment the volumes for the service you are working on in `docker-compose.yml` 21 | 2. docker-compose up 22 | 3. Run a TypeScript file watcher for live reloading of development changes 23 | 24 | Swagger API documentation for docker execution engine available at `/docs` 25 | 26 | ### Testing 27 | Unit tests are placed inline within the `src` directory. Integration tests are located in the `test` directory for each service. 28 | 29 | Run the test suit with `npm test` 30 | 31 | A running Docker daemon is required for the tests to run. 32 | 33 | Depending on network speed, you may need to run `docker pull samjeston/smart_contract_server_mock` prior to running the test suite to avoid timeouts. 34 | -------------------------------------------------------------------------------- /bundle/bundle_and_store.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const platform = os.platform() 3 | const { exec, execFile } = require('child_process') 4 | const { join } = require('path') 5 | const archiver = require('archiver') 6 | const AWS = require('aws-sdk') 7 | const fs = require('fs-extra') 8 | 9 | const S3_BUCKET = 'smart-contract-backend-builds' 10 | 11 | function commitHash() { 12 | return new Promise((resolve, reject) => { 13 | exec('git rev-parse HEAD', function (err, stdout) { 14 | if (err) return reject(err) 15 | resolve(stdout.split(/\r?\n/)[0]) 16 | }) 17 | }) 18 | } 19 | 20 | function createBundle() { 21 | return new Promise((resolve, reject) => { 22 | if (platform === 'win32') { 23 | execFile(join(process.cwd(), 'bundle', 'windows.bat'), function (err, stdout) { 24 | if (err) return reject(err) 25 | resolve(stdout) 26 | }) 27 | } else { 28 | execFile(join(process.cwd(), 'bundle', 'unix.sh'), function (err, stdout) { 29 | if (err) return reject(err) 30 | resolve(stdout) 31 | }) 32 | } 33 | }) 34 | } 35 | 36 | function createZip(bundlePath, zipPath, exeName, buildDeps) { 37 | return new Promise((resolve, reject) => { 38 | let output = fs.createWriteStream(zipPath) 39 | var archive = archiver('zip') 40 | 41 | output.on('close', resolve) 42 | 43 | archive.on('error', function (err) { 44 | return reject(err) 45 | }) 46 | 47 | archive.pipe(output) 48 | 49 | archive.file(join(bundlePath, exeName), { name: exeName }) 50 | 51 | buildDeps.forEach(dep => { 52 | const testExp = RegExp('.node*', 'g') 53 | 54 | if (testExp.test(dep)) { 55 | archive.file(join(bundlePath, dep), { name: dep }) 56 | } else { 57 | archive.directory(join(bundlePath, dep), dep) 58 | } 59 | }) 60 | 61 | archive.finalize() 62 | }) 63 | } 64 | 65 | function determineOsInfo(commitHash) { 66 | const winBuildPath = join(process.cwd(), 'build', 'Windows') 67 | const macOSBuildPath = join(process.cwd(), 'build', 'Darwin') 68 | const linuxBuildPath = join(process.cwd(), 'build', 'Linux') 69 | 70 | switch (platform) { 71 | case 'win32': 72 | return { bundlePath: winBuildPath, exeName: 'smart-contract-backend.exe', s3Path: `${commitHash}/Windows.zip` } 73 | case 'darwin': 74 | return { bundlePath: macOSBuildPath, exeName: 'smart-contract-backend', s3Path: `${commitHash}/Darwin.zip` } 75 | case 'linux': 76 | return { bundlePath: linuxBuildPath, exeName: 'smart-contract-backend', s3Path: `${commitHash}/Linux.zip` } 77 | default: 78 | throw new Error('Unsupported platform') 79 | } 80 | } 81 | 82 | async function uploadToS3(s3Path, zipPath) { 83 | const s3 = new AWS.S3() 84 | 85 | const deleteParams = { 86 | Bucket: S3_BUCKET, 87 | Key: s3Path 88 | } 89 | 90 | await s3.deleteObject(deleteParams).promise() 91 | 92 | const createParams = { 93 | Body: fs.readFileSync(zipPath), 94 | Bucket: S3_BUCKET, 95 | Key: s3Path 96 | } 97 | 98 | await s3.putObject(createParams).promise() 99 | } 100 | 101 | async function validateDependencies(buildOutput, bundlePath) { 102 | const testExpression = RegExp('path-to-executable*', 'g') 103 | const pkgDeclaredBuildDeps = buildOutput 104 | .split('\n') 105 | .filter(line => testExpression.test(line)) 106 | .map(filteredLine => filteredLine.split('path-to-executable/')[1]) 107 | 108 | const validatePaths = await Promise.all(pkgDeclaredBuildDeps.map(dep => { 109 | return fs.pathExists(join(bundlePath, dep)) 110 | })) 111 | 112 | if (new Set(validatePaths).has(false)) { 113 | throw new Error(`Missing dependencies. Ensure the build copies ${pkgDeclaredBuildDeps} from appropriate locations`) 114 | } 115 | 116 | return pkgDeclaredBuildDeps 117 | } 118 | 119 | async function main() { 120 | try { 121 | const hash = await commitHash() 122 | console.log(`Creating Build for commit: ${hash}`) 123 | const buildOutput = await createBundle() 124 | 125 | const { bundlePath, exeName, s3Path } = determineOsInfo(hash) 126 | 127 | // We use the output of the pkg and validate that all 128 | // of the native addons are included at the expected file 129 | // location before adding them to the zip 130 | const buildDeps = await validateDependencies(buildOutput, bundlePath) 131 | 132 | const zipPath = `${bundlePath}.zip` 133 | console.log(`Creating zip at ${zipPath}`) 134 | await createZip(bundlePath, zipPath, exeName, buildDeps) 135 | 136 | console.log('Uploading to S3...') 137 | await uploadToS3(s3Path, zipPath) 138 | } catch (e) { 139 | console.log('An error occurred while creating the bundle') 140 | console.log(e.message) 141 | throw e 142 | } 143 | } 144 | 145 | main() 146 | .then(() => console.log('Bundling complete')) 147 | .catch(() => process.exit(1)) 148 | -------------------------------------------------------------------------------- /bundle/unix.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "Install dependencies for building" 3 | npm i 4 | 5 | echo "Cleanup existing build dir" 6 | rm -rf build 7 | 8 | echo "Creating dist" 9 | npm run build 10 | 11 | echo "Make build dir" 12 | mkdir -p "build/$(uname)" 13 | 14 | echo "Copying deps" 15 | cp -r node_modules/puppeteer/.local-chromium "build/$(uname)/puppeteer" 16 | 17 | if [ "$(uname)" == "Linux" ] 18 | then 19 | echo "Creating Linux executable" 20 | npx pkg -t node10-linux . 21 | else 22 | echo "Creating macOS executable" 23 | npx pkg -t node10-macos . 24 | fi 25 | 26 | mv smart-contract-backend "build/$(uname)" -------------------------------------------------------------------------------- /bundle/windows.bat: -------------------------------------------------------------------------------- 1 | ECHO "Install dependencies for building" 2 | CALL npm i 3 | 4 | ECHO "Cleanup existing build dir" 5 | CALL npx rimraf build 6 | 7 | ECHO "Creating dist" 8 | CALL npm run build 9 | 10 | ECHO "Make build dir" 11 | MKDIR "build" 12 | MKDIR "build\Windows" 13 | 14 | ECHO "Copying deps" 15 | XCOPY node_modules\puppeteer\.local-chromium build\Windows\puppeteer /s/i 16 | 17 | ECHO "Creating Windows64 executable" 18 | CALL npx pkg -t node10-win . 19 | 20 | MOVE smart-contract-backend.exe build\Windows\smart-contract-backend.exe -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | smart_contract_backend_e2e_test: 4 | build: 5 | context: . 6 | target: test 7 | init: true 8 | environment: 9 | - APPLICATION_URI=http://server:8081 10 | - WS_URI=ws://server:8081/graphql -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | server: 4 | build: 5 | context: . 6 | target: server 7 | init: true 8 | environment: 9 | - API_PORT=8081 10 | - WALLET_SERVICE_URI=http://wallet:0000 11 | - EXECUTION_SERVICE_URI=http://execution_service:9000 12 | - CONTRACT_DIRECTORY=/application/bundles 13 | - REDIS_HOST=redis 14 | - REDIS_PORT=6379 15 | - OPERATION_MODE=distributed 16 | volumes: 17 | # - ./dist/server:/application/dist/server 18 | - ./test/bundles/docker:/application/bundles 19 | ports: 20 | - 8081:8081 21 | - 8082:8082 22 | depends_on: 23 | - execution_service 24 | execution_service: 25 | build: 26 | context: . 27 | target: execution_service 28 | init: true 29 | environment: 30 | - EXECUTION_API_PORT=9000 31 | - CONTAINER_LOWER_PORT_BOUND=11000 32 | - CONTAINER_UPPER_PORT_BOUND=12000 33 | - DOCKER_EXECUTION_ENGINE_CONTEXT=docker 34 | - EXECUTION_ENGINE=docker 35 | volumes: 36 | - /var/run/docker.sock:/var/run/docker.sock 37 | # - ./dist/swagger.json:/application/dist/swagger.json 38 | # - ./dist/execution_service:/application/dist/execution_service 39 | ports: 40 | - 9000:9000 41 | redis: 42 | image: redis:5.0.4 43 | ports: 44 | - 6379:6380 45 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | This directory is the target location for auto-generated dockerfiles and decoded binaries. -------------------------------------------------------------------------------- /docs/Plutus_runtime_and_interaction_model.md: -------------------------------------------------------------------------------- 1 | # A _Plutus_ runtime and interaction model 2 | [_Plutus_](https://github.com/input-output-hk/plutus) presents a new paradigm for Smart Contracts by moving some of the execution out of the ledger. This changes how we think about implementing Smart Contracts as they can now be considered an application service, taking user input, and generating transactions which contain the lifted _Plutus_ core blocks. Read more about the [extended UTXO model](https://github.com/input-output-hk/plutus/tree/master/docs/extended-utxo) 3 | 4 | ## What are the requirements to run a _**Plutus**_ contract? 5 | ### Loading 6 | 1. The contract must be loaded from the file system. 7 | 2. Dynamic [bundle](./contract_bundle.md) generation. 8 | 3. It needs to be made available to the consumer to call it's endpoints 9 | ### Interaction 10 | 1. Transactions generated must be sent to the client for signing and submission 11 | 2. Any off-chain state persisted 12 | 3. Any triggers defined by the contract must be setup and managed with assurance they will fire 13 | 14 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | - [_Plutus_ Runtime and interaction model](Plutus_runtime_and_interaction_model.md) 2 | - [Contract Bundle](contract_bundle.md) 3 | - [Server](../src/server/README.md) 4 | - [Execution Service](../src/execution_service/README.md) -------------------------------------------------------------------------------- /docs/contract_bundle.md: -------------------------------------------------------------------------------- 1 | # Contract Bundle 2 | The bundle contains the compiled executable and JavaScript executable [endpoints](../src/lib/createEndpoint.ts). This bundle is dynamically generated when a contract is loaded, as Plutus contracts are self-descriptive with their `schema` endpoint. When a usecase arises, the bundle generation can be lifted to an external module to use outside of the Smart Contract Backend. 3 | 4 | View the TypeScript [model](../src/core/Bundle.ts) for reference. -------------------------------------------------------------------------------- /docs/images/server_components_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/smart-contract-backend/ccef4dba46db20add0010c4267345c8e726fd0bd/docs/images/server_components_diagram.png -------------------------------------------------------------------------------- /features/client_validation.feature: -------------------------------------------------------------------------------- 1 | @Todo 2 | Feature: Client Validation 3 | As a client, to establish a connection to a smart contract 4 | A hash of the expected schema must be passed and verified 5 | To ensure the contract exposes the expected schema -------------------------------------------------------------------------------- /features/contract_interaction.feature: -------------------------------------------------------------------------------- 1 | Feature: Contract Interaction 2 | To interact with loaded Smart Contracts 3 | As a client of the platform 4 | I want to call the endpoints to access expressed behaviour 5 | 6 | Scenario Outline: contract interaction that generates a transaction 7 | 8 | When I load a contract by address
9 | And I subscribe by public key 10 | And I call the contract
with the method , arguments and public key 11 | Then should receive a signing request 12 | 13 | Examples: 14 | | address | method | methodArguments | pk | 15 | | "abcd" | "add" | "{\"number1\": 1, \"number2\": 2}" | "myPublicKey" | 16 | -------------------------------------------------------------------------------- /features/contract_loading.feature: -------------------------------------------------------------------------------- 1 | Feature: Contract Loading 2 | To interact with contracts on the platform 3 | As a client of the backend I must first request it be loaded 4 | So I can then interact with it 5 | 6 | 7 | Scenario Outline: a contract throws an error if called before it is loaded 8 | Given the contract is not loaded, calling contract
with the method and arguments throws an error 9 | 10 | Examples: 11 | | address | pk | method | methodArguments | 12 | | "someUnloadedContract" | "uniquePublicKey" | "noMethod" | "{}" | 13 | 14 | Scenario Outline: a contract is loaded only once, with subsequent requests ignored 15 | 16 | When I load a contract by address
17 | And I load a contract by address
18 | Then the contract
is listed once by the static contract endpoint 19 | 20 | Examples: 21 | | address | 22 | | "abcd" | 23 | 24 | Scenario Outline: multiple contracts can be loaded 25 | When I load a contract by address 26 | Then I load a contract by address 27 | 28 | Examples: 29 | | address1 | address2 | 30 | | "abcd" | "plutusGuessingGame" | -------------------------------------------------------------------------------- /features/contract_unloading.feature: -------------------------------------------------------------------------------- 1 | @Todo 2 | Feature: Contract Unloading 3 | Once all contract connections are drained 4 | The running contract instance should unload -------------------------------------------------------------------------------- /features/list_loaded_contracts.feature: -------------------------------------------------------------------------------- 1 | Feature: List Loaded Contracts 2 | As a client of the platform 3 | I want to be able to list loaded contracts to understand what I can already interact with 4 | 5 | Scenario Outline: a loaded contract is available in the contract list 6 | 7 | When I load a contract by address
8 | Then the contract
is listed once by the static contract endpoint 9 | 10 | Examples: 11 | | address | 12 | | "abcd" | 13 | -------------------------------------------------------------------------------- /features/signed_transaction_submission.feature: -------------------------------------------------------------------------------- 1 | @Todo 2 | Feature: Submitting a signed transaction to the network 3 | As a client of the platform 4 | I want to be able to submit transactions that have been signed -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smart-contract-backend", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "bin": "dist/single_process.js", 7 | "scripts": { 8 | "build": "rimraf dist && rimraf tsconfig.tsbuildinfo && npm run api-build && tsc", 9 | "lint": "eslint \"src/**/*.ts\"", 10 | "lint:fix": "eslint --fix \"src/**/*.ts\"", 11 | "test": "npm run lint && npm run build && NODE_ENV=test nyc mocha", 12 | "test:watch": "mocha -w", 13 | "test:coverage": "npm test && http-server coverage", 14 | "api-build": "tsoa swagger && tsoa routes", 15 | "start": "npm run build && pm2 start --name e2e-single-process dist/single_process.js", 16 | "stop": "pm2 stop e2e-single-process", 17 | "e2e:run": "cucumber-js --require \"dist/test/e2e/**/*.js\" --format node_modules/cucumber-pretty --exit --tags \"not @Todo\" features/**/*.feature", 18 | "e2e": "npm run build && npm run e2e:run", 19 | "e2e:docker": "cross-env TEST_MODE=docker APPLICATION_URI=http://127.0.0.1:8081 WS_URI=ws://localhost:8081/graphql npm run e2e", 20 | "e2e:nodejs": "cross-env TEST_MODE=nodejs APPLICATION_URI=http://127.0.0.1:8081 WS_URI=ws://localhost:8081/graphql npm run e2e", 21 | "bundle": "node bundle/bundle_and_store.js" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/input-output-hk/smart-contract-backend.git" 26 | }, 27 | "keywords": [ 28 | "Apollo", 29 | "ApolloServer", 30 | "GraphQL", 31 | "Cardano" 32 | ], 33 | "contributors": [ 34 | "Rhys Bartels-Waller (https://iohk.io)", 35 | "Sam Jeston (https://iohk.io)" 36 | ], 37 | "license": "Apache-2.0", 38 | "bugs": { 39 | "url": "https://github.com/input-output-hk/smart-contract-backend/issues" 40 | }, 41 | "homepage": "https://github.com/input-output-hk/smart-contract-backend#readme", 42 | "devDependencies": { 43 | "@types/body-parser": "^1.17.0", 44 | "@types/chai": "^4.1.7", 45 | "@types/chai-as-promised": "^7.1.0", 46 | "@types/cucumber": "^4.0.6", 47 | "@types/detect-node": "^2.0.0", 48 | "@types/dockerode": "^2.5.20", 49 | "@types/expect": "^1.20.4", 50 | "@types/express": "^4.17.0", 51 | "@types/fs-extra": "^8.0.0", 52 | "@types/graphql": "^14.2.2", 53 | "@types/hapi": "^18.0.2", 54 | "@types/ioredis": "^4.0.13", 55 | "@types/mocha": "^5.2.7", 56 | "@types/node": "^11.13.17", 57 | "@types/puppeteer": "^1.12.4", 58 | "@types/require-from-string": "^1.2.0", 59 | "@types/sinon": "^7.0.13", 60 | "@types/sinon-chai": "^3.2.2", 61 | "@types/supertest": "^2.0.8", 62 | "@types/tcp-port-used": "^1.0.0", 63 | "@types/web3": "^1.0.19", 64 | "@typescript-eslint/eslint-plugin": "^1.11.0", 65 | "@typescript-eslint/parser": "^1.11.0", 66 | "apollo-boost": "^0.3.1", 67 | "apollo-link-error": "^1.1.11", 68 | "apollo-link-ws": "^1.0.18", 69 | "apollo-utilities": "^1.3.2", 70 | "archiver": "^3.0.3", 71 | "aws-sdk": "^2.502.0", 72 | "chai": "^4.2.0", 73 | "chai-as-promised": "^7.1.1", 74 | "cross-env": "^5.2.0", 75 | "cross-fetch": "^3.0.4", 76 | "cucumber": "^5.1.0", 77 | "cucumber-pretty": "^1.5.2", 78 | "eslint": "^5.16.0", 79 | "eslint-config-standard": "^12.0.0", 80 | "eslint-plugin-chai-friendly": "^0.4.1", 81 | "eslint-plugin-import": "^2.18.0", 82 | "eslint-plugin-node": "^8.0.1", 83 | "eslint-plugin-promise": "^4.2.1", 84 | "eslint-plugin-standard": "^4.0.0", 85 | "graphql-tag": "^2.10.1", 86 | "http-server": "^0.11.1", 87 | "mocha": "^6.1.4", 88 | "nyc": "^13.3.0", 89 | "pkg": "^4.4.0", 90 | "rimraf": "^2.6.3", 91 | "sinon": "^7.3.2", 92 | "sinon-chai": "^3.3.0", 93 | "source-map-support": "^0.5.12", 94 | "supertest": "^4.0.2", 95 | "ts-mocha": "^6.0.0", 96 | "ts-node": "^8.3.0" 97 | }, 98 | "dependencies": { 99 | "apollo-link": "^1.2.12", 100 | "apollo-link-http": "^1.5.15", 101 | "apollo-server": "^2.6.9", 102 | "apollo-server-express": "^2.6.9", 103 | "apollo-server-testing": "^2.6.9", 104 | "axios": "^0.19.0", 105 | "body-parser": "^1.19.0", 106 | "decompress": "^4.2.0", 107 | "detect-node": "^2.0.4", 108 | "dockerode": "^2.5.8", 109 | "dotenv": "^6.2.0", 110 | "express": "^4.17.1", 111 | "find-free-port": "^2.0.0", 112 | "fp-ts": "^2.0.5", 113 | "fs-extra": "^8.1.0", 114 | "graphql": "^14.4.2", 115 | "graphql-redis-subscriptions": "^2.1.0", 116 | "io-ts": "^2.0.1", 117 | "ioredis": "^4.11.1", 118 | "nock": "^10.0.6", 119 | "ping": "^0.2.2", 120 | "pm2": "^3.5.1", 121 | "promise-exponential-retry": "^1.0.3", 122 | "puppeteer": "^1.18.1", 123 | "require-from-string": "^2.0.2", 124 | "swagger-ui-dist": "^3.23.0", 125 | "tcp-port-used": "^1.0.1", 126 | "ts-custom-error": "^3.1.1", 127 | "ts-loader": "^6.0.4", 128 | "tsoa": "^2.4.3", 129 | "typescript": "3.5.3", 130 | "uuid": "^3.3.3", 131 | "webpack": "^4.39.3", 132 | "webpack-cli": "^3.3.7", 133 | "ws": "^7.1.1" 134 | }, 135 | "nyc": { 136 | "extension": [ 137 | ".ts" 138 | ], 139 | "include": [ 140 | "src/**/*.ts" 141 | ], 142 | "exclude": [ 143 | "src/**/*.spec.ts", 144 | "src/execution_service/infrastructure/node_js/execute.ts" 145 | ], 146 | "reporter": [ 147 | "html" 148 | ], 149 | "all": true 150 | }, 151 | "pkg": { 152 | "scripts": [ 153 | "node_modules/puppeteer/lib/*.js" 154 | ], 155 | "assets": [ 156 | "*.js" 157 | ] 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /puppeteer_evaluater.js: -------------------------------------------------------------------------------- 1 | // Solves: https://github.com/zeit/pkg/issues/204 2 | // This is source code for the application 3 | module.exports = { 4 | evaluate: function (arg) { 5 | /* eslint-disable */ 6 | const exec = Function(`"use strict"; return (${arg.executable})`)() 7 | /* eslint-enable */ 8 | const w = window 9 | w.contract = exec 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/client/Client.ts: -------------------------------------------------------------------------------- 1 | import * as isNode from 'detect-node' 2 | import * as WebSocket from 'ws' 3 | import { fetch } from 'cross-fetch' 4 | import gql from 'graphql-tag' 5 | import { ApolloClient, InMemoryCache, HttpLink, split } from 'apollo-boost' 6 | import { Subscription } from 'apollo-client/util/Observable' 7 | import { WebSocketLink } from 'apollo-link-ws' 8 | import { getMainDefinition } from 'apollo-utilities' 9 | import { onError } from 'apollo-link-error' 10 | import { Contract, ContractCallInstruction, Engine } from '../core' 11 | 12 | export interface Config { 13 | apiUri: string, 14 | subscriptionUri: string, 15 | transactionHandler: (transaction: string, publicKey: string) => void 16 | } 17 | 18 | export function Client (config: Config) { 19 | let signingSubscription: Subscription 20 | 21 | const httpLink = new HttpLink({ 22 | uri: `${config.apiUri}/graphql`, 23 | fetch 24 | }) 25 | 26 | const envOptions = isNode ? { webSocketImpl: WebSocket } : {} 27 | const wsLink = new WebSocketLink({ 28 | uri: config.subscriptionUri, 29 | options: { 30 | reconnect: true 31 | }, 32 | ...envOptions 33 | }) 34 | 35 | const errorLink = onError(({ graphQLErrors, networkError }) => { 36 | if (graphQLErrors) { 37 | graphQLErrors.map(({ message, locations, path }) => 38 | console.log( 39 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` 40 | ) 41 | ) 42 | } 43 | if (networkError) { 44 | console.log(`[Network error]: ${networkError}`) 45 | } 46 | }) 47 | 48 | const link = errorLink.concat(split( 49 | ({ query }) => { 50 | const definition = getMainDefinition(query) 51 | return definition.kind === 'OperationDefinition' && definition.operation === 'subscription' 52 | }, 53 | wsLink, 54 | httpLink 55 | )) 56 | 57 | const apolloClient = new ApolloClient({ 58 | cache: new InMemoryCache(), 59 | defaultOptions: { 60 | query: { fetchPolicy: 'network-only' }, 61 | watchQuery: { fetchPolicy: 'network-only' } 62 | }, 63 | link 64 | }) 65 | 66 | return { 67 | apolloClient, 68 | connect (publicKey: string) { 69 | signingSubscription = apolloClient.subscribe({ 70 | query: gql`subscription { 71 | transactionSigningRequest(publicKey: "${publicKey}") { 72 | transaction 73 | } 74 | }` 75 | }).subscribe({ 76 | next (result) { 77 | const { data: { transactionSigningRequest: { transaction } } } = result 78 | config.transactionHandler(transaction, publicKey) 79 | }, 80 | error (err) { 81 | console.error('err', err) 82 | } 83 | }) 84 | }, 85 | disconnect () { 86 | if (signingSubscription && typeof signingSubscription.unsubscribe === 'function') { 87 | signingSubscription.unsubscribe() 88 | signingSubscription = undefined 89 | } 90 | }, 91 | async schema () { 92 | const result = await apolloClient.query({ 93 | query: gql`query { __schema { types { name } } }` 94 | }) 95 | return result.data 96 | }, 97 | async contracts () { 98 | const result = await apolloClient.query({ 99 | query: gql`query { contracts { contractAddress, description }}` 100 | }) 101 | return result.data.contracts 102 | }, 103 | async loadContract ( 104 | contractAddress: Contract['address'], 105 | engine = Engine.plutus 106 | ) { 107 | const result = await apolloClient.mutate({ 108 | mutation: gql`mutation { 109 | loadContract(contractAddress: "${contractAddress}", engine: "${engine}") 110 | }` 111 | }) 112 | return result.data 113 | }, 114 | async unloadContract (address: string) { 115 | const result = await apolloClient.mutate({ 116 | mutation: gql`mutation { 117 | unloadContract(contractAddress: "${address}") 118 | }` 119 | }) 120 | return result.data 121 | }, 122 | async callContract ({ originatorPk, contractAddress, method, methodArguments }: ContractCallInstruction) { 123 | return apolloClient.mutate({ 124 | mutation: gql`mutation { 125 | callContract(contractInstruction: {originatorPk: "${originatorPk}", contractAddress: "${contractAddress}", method: "${method}", methodArguments: ${JSON.stringify(methodArguments)}}) 126 | }` 127 | }) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/client/README.MD: -------------------------------------------------------------------------------- 1 | # Client 2 | 3 | Connect to the [server](../server/README.md) and subscribe to signing requests. Signing and submitting to the network are external concerns and should be handled by the application. The Apollo Client instance is exposed for DApp developers to utilise existing Apollo tools for interacting with the API. 4 | 5 | ## Test Coverage 6 | This component is tested as part of the [e2e test suite](../../test/e2e/src/support/world.ts) and [Server integration tests](../server/application/Server.spec.ts). -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | export { Client } from './Client' 2 | -------------------------------------------------------------------------------- /src/core/Bundle.ts: -------------------------------------------------------------------------------- 1 | export type Bundle = { 2 | executable: Buffer 3 | schema: string 4 | } 5 | -------------------------------------------------------------------------------- /src/core/Contract.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../lib' 2 | import { Bundle, Engine } from '.' 3 | 4 | export interface Contract extends Entity { 5 | address: string 6 | bundle: Bundle 7 | engine: Engine 8 | } 9 | -------------------------------------------------------------------------------- /src/core/ContractCallInstruction.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from '.' 2 | 3 | export interface ContractCallInstruction { 4 | originatorPk?: string 5 | method: string 6 | contractAddress: Contract['address'] 7 | methodArguments?: any 8 | } 9 | -------------------------------------------------------------------------------- /src/core/ContractRepository.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from '.' 2 | import { Repository } from '../lib' 3 | 4 | export type ContractRepository = Repository 5 | -------------------------------------------------------------------------------- /src/core/Endpoint.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts' 2 | 3 | export interface Endpoint { 4 | name: string 5 | call: ( 6 | args: t.TypeOf, 7 | resolver: (args: t.TypeOf, state: t.TypeOf) => Promise>, 8 | state?: t.TypeOf 9 | ) => Promise> 10 | describe: () => { argType: string, returnType: string, stateType?: string } 11 | validateArgs: (args: t.TypeOf) => boolean 12 | validateReturn: (returnValue: t.TypeOf) => boolean 13 | validateState: (state: t.TypeOf) => boolean 14 | } 15 | -------------------------------------------------------------------------------- /src/core/Engine.ts: -------------------------------------------------------------------------------- 1 | export enum Engine { 2 | stub = 'stub', 3 | plutus = 'plutus' 4 | } 5 | -------------------------------------------------------------------------------- /src/core/EngineClient.ts: -------------------------------------------------------------------------------- 1 | import { Bundle, Contract, ContractCallInstruction } from '.' 2 | 3 | export interface EngineClient { 4 | name: string 5 | loadExecutable: (params: { 6 | contractAddress: Contract['address'], 7 | executable: Bundle['executable'] 8 | }) => Promise 9 | unloadExecutable: (contractAddress: Contract['address']) => Promise 10 | call: (executionInstruction: ContractCallInstruction) => any 11 | } 12 | -------------------------------------------------------------------------------- /src/core/Events.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum Events { 3 | SIGNATURE_REQUIRED = 'SIGNATURE_REQUIRED' 4 | } 5 | -------------------------------------------------------------------------------- /src/core/ExecutionEngines.ts: -------------------------------------------------------------------------------- 1 | export enum ExecutionEngines { 2 | stub = 'stub', 3 | docker = 'docker', 4 | nodejs = 'nodejs' 5 | } 6 | -------------------------------------------------------------------------------- /src/core/OperationMode.ts: -------------------------------------------------------------------------------- 1 | export enum OperationMode { 2 | distributed = 'distributed', 3 | singleProcess = 'singleProcess' 4 | } 5 | -------------------------------------------------------------------------------- /src/core/PortAllocation.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../lib' 2 | 3 | export interface PortAllocation extends Entity { 4 | portNumber: number 5 | } 6 | -------------------------------------------------------------------------------- /src/core/PortAllocationRepository.ts: -------------------------------------------------------------------------------- 1 | import { PortAllocation } from '.' 2 | import { Repository } from '../lib' 3 | 4 | export type PortAllocationRepository = Repository 5 | -------------------------------------------------------------------------------- /src/core/errors/AllPortsAllocated.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | import { NumberRange } from '../../lib' 3 | 4 | export class AllPortsAllocated extends CustomError { 5 | public constructor (range: NumberRange) { 6 | super() 7 | this.message = `All ports in the range ${rangeString(range)} have been allocated` 8 | } 9 | } 10 | 11 | function rangeString (range: NumberRange) { 12 | return `${range.lower.toString()} - ${range.upper.toString()}` 13 | } 14 | -------------------------------------------------------------------------------- /src/core/errors/UnknownEntity.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class UnknownEntity extends CustomError { 4 | public constructor (id: string) { 5 | super() 6 | this.message = `Cannot find entity with the ID ${id}` 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/core/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { AllPortsAllocated } from './AllPortsAllocated' 2 | export { UnknownEntity } from './UnknownEntity' 3 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Bundle' 2 | export * from './Contract' 3 | export * from './ContractCallInstruction' 4 | export * from './ContractRepository' 5 | export * from './Endpoint' 6 | export * from './Engine' 7 | export * from './EngineClient' 8 | export * from './Events' 9 | export * from './ExecutionEngines' 10 | export * from './OperationMode' 11 | export * from './PortAllocation' 12 | -------------------------------------------------------------------------------- /src/execution_service/README.md: -------------------------------------------------------------------------------- 1 | # Execution Service 2 | 3 | _Run_ smart contracts in isolation using Chromium processes via NodeJS, or _Docker_ containers in a trusted scenario with access to a Docker daemon. 4 | 5 | _Interact_ via a HTTP API to call contract endpoints, with the invocation result data passed back in the response. 6 | 7 | ## Execution Engine 8 | 9 | ``` 10 | // This type will be refined as integrations with Plutus proceeed 11 | export type SmartContractResponse = any 12 | 13 | export interface ExecutionEngine { 14 | load: ({contractAddress: string, executable: string}) => Promise 15 | execute: ({contractAddress: string, method: string, methodArgs: string}) => Promise<{ data: SmartContractResponse }> 16 | unload: ({contractAddress: string}) => Promise 17 | } 18 | ``` 19 | 20 | The most up to date implementation of this interface in TypeScript can be seen [here](./application/ExecutionEngine.ts) 21 | 22 | ### Docker Image Target 23 | 24 | `executable`: A string reference to the Docker image to load 25 | 26 | Each image needs to satisfy the following HTTP interface for each contract endpoint: 27 | 28 | `POST /{contractEndpoint}` where method arguments are the body of the payload 29 | 30 | ### JavaScript Target 31 | 32 | `executable`: A string that parses to a JavaScript object, containing contract endpoints 33 | 34 | If a Plutus smart contract has two contract endpoint, `foo` & `bar`, both with a JSON argument, the `executable` string is as follows: 35 | 36 | ``` 37 | { 38 | foo: (fooJsonArgument) => ..., 39 | bar: (barJsonArgument) => ..., 40 | } 41 | ``` 42 | 43 | If this is not a manageable target for GHCJS, the below approach is a logical alternative to the above: 44 | 45 | ``` 46 | function foo (fooJsonArgument) { 47 | return ... 48 | } 49 | 50 | function bar (barJsonArgument) { 51 | return ... 52 | } 53 | 54 | function init() { 55 | return { foo, bar } 56 | } 57 | ``` 58 | 59 | The return type of these functions should be a JSON representation of the result, or a Promise that resolves to this same JSON 60 | 61 | ## Security 62 | 63 | Running untrusted code, when there is no risk to the author of the code, is hard. To achieve this as safely as possible, the Principle of Least Privilege is followed. 64 | 65 | ### Docker Image Target 66 | 67 | Docker should be considered a development target, or a target where only trusted contract images are to be run. Docker does not provide any kind of virtualization and as such exposes the system's kernel as a vulnerability. Namespaces and control groups can be used to greatly limit the resources and system of containers orchestrated through the Docker execution engine, however community consensus is that this is not a satisfactory paradigm for untrusted code execution. 68 | 69 | ### Javascript Target 70 | 71 | Untrusted contract code is not run in NodeJS due to the escalated privileges available to a Node process, even when run in a tightly restricted namespace. Instead, we use the Puppeteer API to pass the executable JS blob to Chromium which then runs the endpoint in an isolated fashion. Chromium is a good fit for security and the execution of arbitrary JavaScript because Chromium's "sandbox leverages the OS-provided security to allow code execution that cannot make persistent changes to the computer or access information that is confidential. The architecture and exact assurances that the sandbox provides are dependent on the operating system." 72 | 73 | #### Security Tests 74 | 75 | - [Memory boundaries between pages](test/security/node_js/page_boundaries.spec.ts) 76 | - [Isolation from NodeJS runtime and API](test/security/node_js/isolation_from_nodejs.spec.ts) 77 | - [Load tests](test/security/node_js/load_test.spec.ts) 78 | - [Network attacks](test/security/node_js/network_attacks.spec.ts) 79 | - [Resource consumption attack](test/security/node_js/resource_consumption_attack.spec.ts) 80 | 81 | #### Chromium Security Resources 82 | 83 | - Chromium Security Architecture: https://seclab.stanford.edu/websec/chromium/chromium-security-architecture.pdf 84 | - Chromium Sandbox Design (Linux): https://chromium.googlesource.com/chromium/src/+/HEAD/docs/linux_sandboxing.md 85 | - Chromium Sandbox Design (Windows): https://chromium.googlesource.com/chromium/src/+/master/docs/design/sandbox.md 86 | - Chromium Security Brag Sheet: https://www.chromium.org/Home/chromium-security/brag-sheet 87 | 88 | [More documentation](../../docs) 89 | -------------------------------------------------------------------------------- /src/execution_service/application/Api.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import * as bodyParser from 'body-parser' 3 | import { RegisterRoutes } from '../routes' 4 | import './ExecutionEngineController' 5 | import { ExecutionEngine } from '../application' 6 | const swaggerUiAssetPath = require('swagger-ui-dist').getAbsoluteFSPath() 7 | 8 | export function Api (engine: ExecutionEngine) { 9 | const app = express() 10 | app.use(bodyParser.json({ limit: '50mb' })) 11 | app.use('/documentation', express.static(swaggerUiAssetPath)) 12 | app.use('/documentation/swagger.json', (_, res) => { 13 | res.sendFile(process.cwd() + '/dist/swagger.json') 14 | }) 15 | 16 | app.get('/docs', (_, res) => { 17 | res.redirect('/documentation?url=swagger.json') 18 | }) 19 | 20 | app.use((req: any, _res, next) => { 21 | req.engine = engine 22 | next() 23 | }) 24 | 25 | RegisterRoutes(app) 26 | 27 | app.use(function (_req, res, _next) { 28 | res.status(404).json({ error: 'Route not found' }) 29 | }) 30 | 31 | app.use(function (err: any, _req: any, res: any, _next: Function) { 32 | if (err.status === 400) { 33 | return res.status(400).json({ error: err.fields }) 34 | } 35 | 36 | console.log(err) 37 | res.status(500).json({ error: 'Internal Server Error' }) 38 | }) 39 | 40 | return { app } 41 | } 42 | -------------------------------------------------------------------------------- /src/execution_service/application/ExecutionEngine.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionEngines } from '../../core' 2 | 3 | export interface LoadContractArgs { 4 | contractAddress: string 5 | executable: string 6 | } 7 | 8 | export interface UnloadContractArgs { 9 | contractAddress: string 10 | } 11 | 12 | export interface ExecuteContractArgs { 13 | contractAddress: string 14 | method: string 15 | methodArgs?: any 16 | } 17 | 18 | export type SmartContractResponse = any 19 | 20 | export interface ExecutionEngine { 21 | name: ExecutionEngines, 22 | load: (args: LoadContractArgs) => Promise 23 | execute: (args: ExecuteContractArgs) => Promise<{ data: SmartContractResponse }> 24 | unload: (args: UnloadContractArgs) => Promise 25 | } 26 | -------------------------------------------------------------------------------- /src/execution_service/application/ExecutionEngineController.ts: -------------------------------------------------------------------------------- 1 | import { Post, Route, Body, Request, SuccessResponse, Controller } from 'tsoa' 2 | import express from 'express' 3 | import { ExecutionEngine, LoadContractArgs, SmartContractResponse, UnloadContractArgs } from '../application' 4 | import { ContractNotLoaded } from '../errors' 5 | 6 | interface ExtendedExpressRequest extends express.Request { 7 | engine: ExecutionEngine 8 | } 9 | 10 | @Route('') 11 | export class ExecutionEngineController extends Controller { 12 | @SuccessResponse('204', 'No Content') 13 | @Post('loadSmartContract') 14 | public async loadSmartContract (@Request() request: ExtendedExpressRequest, @Body() { contractAddress, executable }: LoadContractArgs): Promise { 15 | contractAddress = contractAddress.toLowerCase() 16 | this.setStatus(204) 17 | await request.engine.load({ contractAddress, executable }) 18 | } 19 | 20 | @SuccessResponse('204', 'No Content') 21 | @Post('unloadSmartContract') 22 | public async unloadSmartContract (@Request() request: ExtendedExpressRequest, @Body() { contractAddress }: UnloadContractArgs): Promise { 23 | contractAddress = contractAddress.toLowerCase() 24 | this.setStatus(204) 25 | await request.engine.unload({ contractAddress }) 26 | } 27 | 28 | @SuccessResponse('200', 'Ok') 29 | @Post('execute/{contractAddress}/{method}') 30 | public async execute (@Request() request: ExtendedExpressRequest, contractAddress: string, method: string, @Body() methodArguments: any): Promise<{ data: SmartContractResponse } | { error: string }> { 31 | contractAddress = contractAddress.toLowerCase() 32 | 33 | return request.engine.execute({ contractAddress, method, methodArgs: methodArguments }) 34 | .catch(e => { 35 | if (e instanceof ContractNotLoaded) { 36 | this.setStatus(404) 37 | } else { 38 | this.setStatus(500) 39 | } 40 | 41 | return { error: e.message } 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/execution_service/application/ExecutionService.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import axios from 'axios' 4 | import { ExecutionService } from '.' 5 | import { StubExecutionEngine } from '../infrastructure' 6 | import { checkPortIsFree } from '../../lib/test' 7 | 8 | use(chaiAsPromised) 9 | 10 | describe('ExecutionService', () => { 11 | let executionService: ReturnType 12 | const API_PORT = 9999 13 | const API_URI = `http://localhost:${API_PORT}` 14 | 15 | beforeEach(async () => { 16 | await checkPortIsFree(9999) 17 | executionService = ExecutionService({ 18 | apiPort: API_PORT, 19 | engine: StubExecutionEngine() 20 | }) 21 | }) 22 | 23 | describe('Boot', () => { 24 | beforeEach(async () => executionService.boot()) 25 | afterEach(async () => executionService.shutdown()) 26 | 27 | it('Starts the API server', async () => { 28 | expect((await axios.get(`${API_URI}/docs`)).statusText).to.equal('OK') 29 | }) 30 | }) 31 | 32 | describe('Shutdown', () => { 33 | beforeEach(async () => { 34 | await executionService.boot() 35 | }) 36 | 37 | it('Closes the API server', async () => { 38 | await executionService.shutdown() 39 | await expect(axios.get(`${API_URI}/docs`)).to.be.rejected 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/execution_service/application/ExecutionService.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { httpEventPromiseHandler } from '../../lib' 3 | import { Api, ExecutionEngine } from '.' 4 | 5 | export interface Config { 6 | apiPort: number 7 | engine: ExecutionEngine 8 | } 9 | 10 | export function ExecutionService (config: Config) { 11 | const api = Api(config.engine) 12 | let server: http.Server 13 | return { 14 | engineName: config.engine.name, 15 | async boot (): Promise { 16 | server = await api.app.listen({ port: config.apiPort }) 17 | return server 18 | }, 19 | async shutdown (): Promise { 20 | await httpEventPromiseHandler.close(server) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/execution_service/application/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Api' 2 | export * from './ExecutionEngine' 3 | export * from './ExecutionService' 4 | -------------------------------------------------------------------------------- /src/execution_service/config.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionEngines, PortAllocation } from '../core' 2 | import { InMemoryRepository, PortMapper } from '../lib' 3 | import { DockerClient, DockerEngine, DockerExecutionEngineContext, NodeJsExecutionEngine } from './infrastructure' 4 | import { MissingConfig } from './errors' 5 | import { Config as ExecutionServiceConfig, ExecutionEngine } from './application' 6 | 7 | export function getConfig (): ExecutionServiceConfig { 8 | const { 9 | executionEngineName, 10 | apiPort, 11 | containerLowerPortBound, 12 | containerUpperPortBound, 13 | dockerExecutionEngineContext 14 | } = filterAndTypecastEnvs(process.env) 15 | 16 | if (!apiPort) throw new MissingConfig('EXECUTION_API_PORT env not set') 17 | let engine: ExecutionEngine 18 | switch (executionEngineName) { 19 | case ExecutionEngines.docker : 20 | if (!containerLowerPortBound || !containerUpperPortBound) { 21 | throw new MissingConfig('CONTAINER_LOWER_PORT_BOUND or CONTAINER_UPPER_PORT_BOUND env not set') 22 | } 23 | const portMapper = PortMapper({ 24 | repository: InMemoryRepository(), 25 | range: { 26 | lower: containerLowerPortBound, 27 | upper: containerUpperPortBound 28 | } 29 | }) 30 | const dockerClient = DockerClient({ 31 | executionContext: dockerExecutionEngineContext, 32 | pipeStdout: true 33 | }) 34 | engine = DockerEngine({ portMapper, dockerClient, dockerExecutionEngineContext }) 35 | break 36 | case ExecutionEngines.nodejs : 37 | engine = NodeJsExecutionEngine 38 | break 39 | } 40 | return { 41 | apiPort, 42 | engine 43 | } 44 | } 45 | 46 | function filterAndTypecastEnvs (env: any) { 47 | const { 48 | EXECUTION_ENGINE, 49 | EXECUTION_API_PORT, 50 | CONTAINER_LOWER_PORT_BOUND, 51 | CONTAINER_UPPER_PORT_BOUND, 52 | DOCKER_EXECUTION_ENGINE_CONTEXT 53 | } = env 54 | return { 55 | executionEngineName: EXECUTION_ENGINE as ExecutionEngines, 56 | apiPort: Number(EXECUTION_API_PORT), 57 | containerLowerPortBound: Number(CONTAINER_LOWER_PORT_BOUND), 58 | containerUpperPortBound: Number(CONTAINER_UPPER_PORT_BOUND), 59 | dockerExecutionEngineContext: DOCKER_EXECUTION_ENGINE_CONTEXT as DockerExecutionEngineContext 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/execution_service/errors/BadArgument.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class BadArgument extends CustomError { 4 | public constructor (typeOf: string) { 5 | super() 6 | this.message = `The JS Execution method argument must be a string. A ${typeOf} was passed instead.` 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/execution_service/errors/ContainerFailedToStart.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class ContainerFailedToStart extends CustomError { 4 | public constructor () { 5 | super() 6 | this.message = 'Contract container failed to start' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/execution_service/errors/ContractNotLoaded.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class ContractNotLoaded extends CustomError { 4 | public constructor () { 5 | super() 6 | this.message = 'Contract not loaded. Call /load and then try again' 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/execution_service/errors/ExecutionFailure.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class ExecutionFailure extends CustomError { 4 | public constructor (message: string) { 5 | super() 6 | this.message = message 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/execution_service/errors/InvalidEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class InvalidEndpoint extends CustomError { 4 | public constructor (validEndpoints: string[]) { 5 | super() 6 | this.message = `Endpoint does not exist. Options are: ${validEndpoints}` 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/execution_service/errors/MissingConfig.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class MissingConfig extends CustomError { 4 | public constructor (message: string) { 5 | super() 6 | this.message = message 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/execution_service/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { ExecutionFailure } from './ExecutionFailure' 2 | export { BadArgument } from './BadArgument' 3 | export { ContractNotLoaded } from './ContractNotLoaded' 4 | export { MissingConfig } from './MissingConfig' 5 | export { ContainerFailedToStart } from './ContainerFailedToStart' 6 | export { InvalidEndpoint } from './InvalidEndpoint' 7 | -------------------------------------------------------------------------------- /src/execution_service/index.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { AddressInfo } from 'net' 3 | import { ExecutionService } from './application' 4 | import { getConfig } from './config' 5 | 6 | const executionService = ExecutionService(getConfig()) 7 | 8 | executionService.boot() 9 | .then((apiServer: http.Server) => { 10 | const { address, port } = apiServer.address().valueOf() as AddressInfo 11 | console.log(`Execution service:${executionService.engineName} listening on port ${port}`) 12 | console.log(`API Documentation at ${address}:${port}/docs`) 13 | }) 14 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/docker_client/DockerClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Docker from 'dockerode' 3 | import axios from 'axios' 4 | import { DockerClient } from './DockerClient' 5 | import { DockerExecutionEngineContext } from '../execution_engines' 6 | import { Contract } from '../../../core' 7 | import { readFileSync } from 'fs-extra' 8 | import { RetryPromise } from 'promise-exponential-retry' 9 | const MOCK_IMAGE = readFileSync('test/bundles/docker/abcd').toString('base64') 10 | 11 | describe('DockerClient', () => { 12 | const dockerSpecItFn = process.env.DOCKER_EXECUTION_ENGINE_CONTEXT === DockerExecutionEngineContext.docker ? it : it.skip 13 | const hostSpecItFn = process.env.DOCKER_EXECUTION_ENGINE_CONTEXT !== DockerExecutionEngineContext.docker ? it : it.skip 14 | let dockerClient: ReturnType 15 | 16 | afterEach(async () => { 17 | await cleanupTestContainers() 18 | }) 19 | 20 | describe('findContainerPort', () => { 21 | beforeEach(() => { 22 | dockerClient = DockerClient({ 23 | executionContext: DockerExecutionEngineContext.host, 24 | pipeStdout: false 25 | }) 26 | }) 27 | it('returns the host port mapped to the container', async () => { 28 | await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress: 'abcd', hostPort: 4200 }) 29 | expect(await dockerClient.findContainerPort('abcd')).to.eql(4200) 30 | }) 31 | }) 32 | 33 | describe('loadContainer', () => { 34 | async function tryLoadingTwice (dockerClient: ReturnType, contractAddress: Contract['address']): Promise { 35 | await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress, hostPort: 4200 }) 36 | await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress, hostPort: 4201 }) 37 | const containers = await dockerClient.listContainers() 38 | const contractContainers = containers.filter(container => container.Image === 'mock-contract') 39 | expect(contractContainers.length).to.eql(1) 40 | } 41 | 42 | describe('Docker networking', () => { 43 | beforeEach(() => { 44 | dockerClient = DockerClient({ 45 | executionContext: DockerExecutionEngineContext.docker, 46 | pipeStdout: false 47 | }) 48 | }) 49 | dockerSpecItFn('successfully boots a container that accepts HTTP on the returned port', async () => { 50 | await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress: 'abcd', hostPort: 4200 }) 51 | const result = await RetryPromise.retryPromise('serverBooting', () => { 52 | return axios.post(`http://abcd:8080/add`, { 53 | number1: 1, 54 | number2: 2 55 | }) 56 | }, 3) 57 | expect(result.status).to.eql(200) 58 | }) 59 | dockerSpecItFn('does not boot a second container when a container with that address is already running', async () => { 60 | await tryLoadingTwice(dockerClient, 'abcd') 61 | }) 62 | }) 63 | 64 | describe('Host networking', () => { 65 | beforeEach(async () => { 66 | dockerClient = DockerClient({ 67 | executionContext: DockerExecutionEngineContext.host, 68 | pipeStdout: false 69 | }) 70 | }) 71 | 72 | hostSpecItFn('successfully boots a container that accepts HTTP on the returned port', async () => { 73 | const container = await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress: 'abcd', hostPort: 4200 }) 74 | const result = await RetryPromise.retryPromise('serverBooting', () => { 75 | return axios.post(`http://localhost:${container.port}/add`, { 76 | number1: 1, 77 | number2: 2 78 | }) 79 | }, 3) 80 | 81 | expect(result.status).to.eql(200) 82 | }) 83 | 84 | hostSpecItFn('does not boot a second container when a container with that address is already running', async () => { 85 | await tryLoadingTwice(dockerClient, 'abcd') 86 | }) 87 | }) 88 | }) 89 | describe('unloadContainer', () => { 90 | beforeEach(() => { 91 | dockerClient = DockerClient({ 92 | executionContext: DockerExecutionEngineContext.host, 93 | pipeStdout: false 94 | }) 95 | }) 96 | 97 | it('successfully terminates a contract instance for an address', async () => { 98 | await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress: 'abcd', hostPort: 4200 }) 99 | await dockerClient.unloadContainer('abcd') 100 | const containers = await dockerClient.listContainers() 101 | const contractContainers = containers.filter(container => container.Image === MOCK_IMAGE) 102 | expect(contractContainers.length).to.eql(0) 103 | }) 104 | 105 | it('resolves successfully if a contract instance for an address does not exist', async () => { 106 | await dockerClient.unloadContainer('abcd') 107 | const containers = await dockerClient.listContainers() 108 | const contractContainers = containers.filter(container => container.Image === MOCK_IMAGE) 109 | expect(contractContainers.length).to.eql(0) 110 | }) 111 | }) 112 | }) 113 | 114 | async function cleanupTestContainers () { 115 | const docker = new Docker({ socketPath: '/var/run/docker.sock' }) 116 | const containers = await docker.listContainers() 117 | const testContainers = containers.filter(container => container.Image === 'mock-contract') 118 | await Promise.all(testContainers.map(async (container) => { 119 | await docker.getContainer(container.Id).remove({ force: true }) 120 | })) 121 | } 122 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/docker_client/DockerClient.ts: -------------------------------------------------------------------------------- 1 | import * as Docker from 'dockerode' 2 | import { Contract } from '../../../core' 3 | import { DockerExecutionEngineContext } from '../execution_engines' 4 | import { Readable } from 'stream' 5 | 6 | interface Config { 7 | dockerOptions?: Docker.DockerOptions | { socketPath: '/var/run/docker.sock' }, 8 | executionContext: DockerExecutionEngineContext, 9 | pipeStdout: boolean 10 | } 11 | 12 | export function DockerClient (config: Config) { 13 | const docker = new Docker(config.dockerOptions) 14 | const findContainerId = async (contractAddress: Contract['address']): Promise<{ containerId: string }> => { 15 | const containers = await docker.listContainers() 16 | const targetContainer = containers.find((container) => container.Names[0] === `/${contractAddress}`) 17 | if (!targetContainer) return { containerId: '' } 18 | return { containerId: targetContainer.Id } 19 | } 20 | 21 | const loadContainer = async (image: string) => { 22 | const imageAsBuffer = Buffer.from(image, 'base64') 23 | const stream = new Readable() 24 | stream.push(imageAsBuffer) 25 | stream.push(null) 26 | 27 | return new Promise((resolve, reject) => { 28 | docker.loadImage(stream, (err: Error, stream: any) => { 29 | if (err) return reject(err) 30 | const onFinished = (err: Error, res: any) => { 31 | const outputString = res[0].stream 32 | const imageName = outputString.split('Loaded image: ')[1].split('\n')[0] 33 | if (err) return reject(err) 34 | resolve(imageName) 35 | } 36 | const onProgress = (): undefined => undefined 37 | docker.modem.followProgress(stream, onFinished, onProgress) 38 | }) 39 | }) 40 | } 41 | 42 | const createContainer = async ({ contractAddress, imageName, hostPort }: { contractAddress: Contract['address'], imageName: string, hostPort: number }) => { 43 | const baseHostConfig = { 44 | AutoRemove: true, 45 | PortBindings: { '8080/tcp': [{ 'HostPort': `${hostPort}` }] } 46 | } 47 | 48 | const targetHostConfig = config.executionContext === DockerExecutionEngineContext.docker 49 | ? { NetworkMode: 'smart-contract-backend_default', ...baseHostConfig } 50 | : baseHostConfig 51 | 52 | const containerOpts: Docker.ContainerCreateOptions = { 53 | Image: imageName.split(':')[0], 54 | name: contractAddress, 55 | ExposedPorts: { [`8080/tcp`]: {} }, 56 | HostConfig: targetHostConfig 57 | } 58 | 59 | const host = config.executionContext === DockerExecutionEngineContext.docker 60 | ? `http://${contractAddress}:8080` 61 | : `http://localhost:${hostPort}` 62 | const container = await docker.createContainer(containerOpts) 63 | if (config.pipeStdout) { 64 | container.attach({ stream: true, stdout: true, stderr: true }, function (_, stream) { 65 | stream.pipe(process.stdout) 66 | }) 67 | } 68 | await container.start() 69 | return { port: hostPort, host } 70 | } 71 | 72 | return { 73 | createContainer, 74 | findContainerId, 75 | async findContainerPort (contractAddress: Contract['address']): Promise { 76 | const { containerId } = await findContainerId(contractAddress) 77 | if (!containerId) return 0 78 | const container = docker.getContainer(containerId) 79 | const portInspection = (await container.inspect()).HostConfig.PortBindings 80 | const portMappings: any = Object.values(portInspection)[0] 81 | return Number(portMappings[0].HostPort) 82 | }, 83 | async listContainers () { 84 | return docker.listContainers() 85 | }, 86 | async loadContainer ({ image, contractAddress, hostPort }: { image: string, contractAddress: Contract['address'], hostPort: number }): Promise<{ port: number, host: string } | null> { 87 | contractAddress = contractAddress.toLowerCase() 88 | const containerRunning = (await findContainerId(contractAddress)).containerId 89 | if (containerRunning) return Promise.resolve(null) 90 | const imageName = await loadContainer(image) as string 91 | return createContainer({ contractAddress, imageName, hostPort }) 92 | }, 93 | async unloadContainer (contractAddress: Contract['address']) { 94 | contractAddress = contractAddress.toLowerCase() 95 | const { containerId } = await findContainerId(contractAddress) 96 | if (!containerId) return 97 | // The use of stop vs kill will depend on whether the binary has graceful handling of SIGINT 98 | return docker.getContainer(containerId).kill() 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/execution_engines/DockerExecutionEngine.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { PortMapper } from '../../../lib' 3 | import { ExecutionEngines } from '../../../core' 4 | import { ExecutionEngine } from '../../application' 5 | import { ContractNotLoaded } from '../../errors' 6 | import { DockerClient } from '..' 7 | import { RetryPromise } from 'promise-exponential-retry' 8 | 9 | export enum DockerExecutionEngineContext { 10 | docker = 'docker', 11 | host = 'host' 12 | } 13 | 14 | interface Config { 15 | dockerExecutionEngineContext: DockerExecutionEngineContext, 16 | dockerClient: ReturnType, 17 | portMapper: ReturnType 18 | } 19 | 20 | export function DockerEngine (config: Config): ExecutionEngine { 21 | const { dockerExecutionEngineContext, dockerClient, portMapper } = config 22 | return { 23 | name: ExecutionEngines.docker, 24 | load: async ({ contractAddress, executable }) => { 25 | const loadedContainer = await dockerClient.loadContainer({ 26 | contractAddress, 27 | image: executable, 28 | hostPort: (await portMapper.getAvailablePort()).portNumber 29 | }) 30 | 31 | if (!loadedContainer) return true 32 | await RetryPromise.retryPromise('contractLoading', () => axios.get(`${loadedContainer.host}/schema`), 5) 33 | return true 34 | }, 35 | execute: async ({ contractAddress, method, methodArgs }) => { 36 | let contractEndpoint: string 37 | if (dockerExecutionEngineContext !== DockerExecutionEngineContext.docker) { 38 | const associatedPort = await dockerClient.findContainerPort(contractAddress) 39 | if (associatedPort === 0) { 40 | throw new ContractNotLoaded() 41 | } 42 | 43 | contractEndpoint = `http://localhost:${associatedPort}` 44 | } else { 45 | const { containerId } = await dockerClient.findContainerId(contractAddress) 46 | if (!containerId) { 47 | throw new ContractNotLoaded() 48 | } 49 | 50 | contractEndpoint = `http://${contractAddress}:8080` 51 | } 52 | 53 | let result 54 | if (method === 'initialise' || method === 'schema') { 55 | result = await axios.get(`${contractEndpoint}/${method}`) 56 | } else { 57 | result = await axios.post(`${contractEndpoint}/${method}`, methodArgs) 58 | } 59 | 60 | return result.data 61 | }, 62 | unload: async ({ contractAddress }) => { 63 | await dockerClient.unloadContainer(contractAddress) 64 | return true 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/execution_engines/NodeJsExecutionEngine.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import { NodeJsExecutionEngine } from './NodeJsExecutionEngine' 4 | import { ContractNotLoaded, BadArgument, ExecutionFailure } from '../../errors' 5 | use(chaiAsPromised) 6 | 7 | describe('NodeJsExecutionEngine', () => { 8 | const mockModule = Buffer.from('{ foobar: (args) => args.a + args.b }').toString('base64') 9 | const mockAddress = 'abcd' 10 | 11 | afterEach(async () => { 12 | await NodeJsExecutionEngine.unload({ contractAddress: mockAddress }) 13 | }) 14 | 15 | describe('load', () => { 16 | it('returns true after loading a module', async () => { 17 | const res = await NodeJsExecutionEngine.load({ contractAddress: mockAddress, executable: mockModule }) 18 | expect(res).to.eql(true) 19 | }) 20 | }) 21 | 22 | describe('unload', () => { 23 | it('returns true after unloading a module', async () => { 24 | const res = await NodeJsExecutionEngine.unload({ contractAddress: mockAddress }) 25 | expect(res).to.eql(true) 26 | }) 27 | }) 28 | 29 | describe('execute', () => { 30 | it('throws if the contract is not loaded', () => { 31 | const res = NodeJsExecutionEngine.execute({ contractAddress: mockAddress, method: 'foobar', methodArgs: { a: 1, b: 2 } }) 32 | return expect(res).to.eventually.be.rejectedWith(ContractNotLoaded) 33 | }) 34 | 35 | it('throws if method arguments are of the wrong type', async () => { 36 | await NodeJsExecutionEngine.load({ contractAddress: mockAddress, executable: mockModule }) 37 | const res = NodeJsExecutionEngine.execute({ contractAddress: mockAddress, method: 'foobar', methodArgs: 1 }) 38 | return expect(res).to.eventually.be.rejectedWith(BadArgument) 39 | }) 40 | 41 | it('throws if the contract does not have the specified method', async () => { 42 | await NodeJsExecutionEngine.load({ contractAddress: mockAddress, executable: mockModule }) 43 | const res = NodeJsExecutionEngine.execute({ contractAddress: mockAddress, method: 'foobaz', methodArgs: { a: 1, b: 2 } }) 44 | return expect(res).to.eventually.be.rejectedWith(ExecutionFailure) 45 | }) 46 | 47 | it('executes the specified method and returns the result', async () => { 48 | await NodeJsExecutionEngine.load({ contractAddress: mockAddress, executable: mockModule }) 49 | const res = await NodeJsExecutionEngine.execute({ contractAddress: mockAddress, method: 'foobar', methodArgs: { a: 1, b: 2 } }) 50 | expect(res).to.eql({ data: 3 }) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/execution_engines/NodeJsExecutionEngine.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionEngines } from '../../../core' 2 | import { ExecutionEngine } from '../../application' 3 | import { executeInBrowser, deploy, unloadPage } from '../node_js/execute' 4 | import { BadArgument, ContractNotLoaded } from '../../errors' 5 | import { Page } from 'puppeteer' 6 | 7 | let contracts: { 8 | [contractAddress: string]: Page 9 | } = {} 10 | 11 | export const NodeJsExecutionEngine: ExecutionEngine = { 12 | name: ExecutionEngines.nodejs, 13 | load: async ({ contractAddress, executable }) => { 14 | const deployment = await deploy(Buffer.from(executable, 'base64').toString('utf8')) 15 | contracts[contractAddress] = deployment 16 | return true 17 | }, 18 | execute: async ({ contractAddress, method, methodArgs }) => { 19 | if (methodArgs && !(methodArgs instanceof Object)) { 20 | throw new BadArgument(typeof methodArgs) 21 | } 22 | 23 | const contract = contracts[contractAddress] 24 | if (!contract) throw new ContractNotLoaded() 25 | const data = await executeInBrowser(contract, method, methodArgs) 26 | return { data } 27 | }, 28 | unload: async ({ contractAddress }) => { 29 | const contract = contracts[contractAddress] 30 | if (!contract) return true 31 | await unloadPage(contract) 32 | return delete contracts[contractAddress] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/execution_engines/StubExecutionEngine.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionEngines } from '../../../core' 2 | import { 3 | ExecutionEngine, 4 | ExecuteContractArgs, 5 | LoadContractArgs, 6 | UnloadContractArgs 7 | } from '../../application' 8 | 9 | export function StubExecutionEngine (): ExecutionEngine { 10 | return { 11 | name: ExecutionEngines.stub, 12 | load (args: LoadContractArgs) { 13 | console.log('StubExecutionEngine:execute args:', args) 14 | return Promise.resolve(true) 15 | }, 16 | execute (args: ExecuteContractArgs) { 17 | console.log('StubExecutionEngine:execute args:', args) 18 | return Promise.resolve({ data: 'some-response' }) 19 | }, 20 | unload (args: UnloadContractArgs) { 21 | console.log('StubExecutionEngine:unload args:', args) 22 | return Promise.resolve(true) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/execution_engines/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DockerExecutionEngine' 2 | export * from './NodeJsExecutionEngine' 3 | export * from './StubExecutionEngine' 4 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './execution_engines' 2 | export * from './docker_client/DockerClient' 3 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/node_js/execute.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import * as puppeteer from 'puppeteer' 4 | import { executeInBrowser, deploy, unloadPage } from './execute' 5 | import { ExecutionFailure } from '../../errors' 6 | use(chaiAsPromised) 7 | 8 | describe('executeInBrowser', () => { 9 | let page: puppeteer.Page 10 | 11 | afterEach(() => unloadPage(page)) 12 | 13 | it('executes an arbitrary function against the context of chromium', async () => { 14 | const fn = ({ a, b }: { a: number, b: number }) => a + b 15 | const executable = `{endpoint1: ${fn}}` 16 | 17 | page = await deploy(executable) 18 | const res = await executeInBrowser(page, 'endpoint1', { a: 1, b: 2 }) 19 | expect(res).to.eql(3) 20 | }) 21 | 22 | it('throws if the function call falls', async () => { 23 | const fn = () => { throw new Error('failed') } 24 | const executable = `{endpoint1: ${fn}}` 25 | 26 | page = await deploy(executable) 27 | const res = executeInBrowser(page, 'endpoint1', { a: 1, b: 2 }) 28 | return expect(res).to.eventually.be.rejectedWith(ExecutionFailure) 29 | }) 30 | 31 | it('throws if the endpoint does not exist on the executable', async () => { 32 | const fn = ({ a, b }: { a: number, b: number }) => a + b 33 | const executable = `{endpoint1: ${fn}}` 34 | 35 | page = await deploy(executable) 36 | const res = executeInBrowser(page, 'endpoint2', { a: 1, b: 2 }) 37 | return expect(res).to.eventually.be.rejectedWith(ExecutionFailure) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/execution_service/infrastructure/node_js/execute.ts: -------------------------------------------------------------------------------- 1 | import * as puppeteer from 'puppeteer' 2 | import { ExecutionFailure } from '../../errors' 3 | import * as path from 'path' 4 | import { platform } from 'os' 5 | const evaluater = require('../../../../puppeteer_evaluater') 6 | 7 | let browser: puppeteer.Browser 8 | async function getBrowser () { 9 | const pAny: any = process 10 | const isPkg = typeof pAny.pkg !== 'undefined' 11 | const plat = platform() 12 | const windowsReplacement = /^.*?\\node_modules\\puppeteer\\\.local-chromium/ 13 | const unixReplacement = /^.*?\/node_modules\/puppeteer\/\.local-chromium/ 14 | const chromiumExecutablePath = isPkg 15 | ? puppeteer.executablePath().replace( 16 | plat === 'win32' ? windowsReplacement : unixReplacement, 17 | path.join(path.dirname(process.execPath), 'puppeteer') 18 | ) 19 | : puppeteer.executablePath() 20 | 21 | if (!browser) { 22 | browser = await puppeteer.launch({ executablePath: chromiumExecutablePath }) 23 | } 24 | 25 | return browser 26 | } 27 | 28 | export async function deploy (executable: string) { 29 | const browser = await getBrowser() 30 | const page = await browser.newPage() 31 | 32 | // Disallow all outgoing requests 33 | await page.setRequestInterception(true) 34 | page.on('request', interceptedRequest => { 35 | interceptedRequest.abort() 36 | }) 37 | 38 | await page.evaluate(evaluater.evaluate, { executable }) 39 | 40 | return page 41 | } 42 | 43 | export function unloadPage (page: puppeteer.Page) { 44 | return page.close() 45 | .catch(e => { 46 | if (e.message.match(/Protocol error: Connection closed/)) { 47 | return 48 | } 49 | 50 | throw e 51 | }) 52 | } 53 | 54 | export async function executeInBrowser (page: puppeteer.Page, endpoint: string, fnArgs: any) { 55 | try { 56 | // Primitive resource consumption protection 57 | // If endpoint execution takes more than 2 seconds, it is considered 58 | // an attack so the page is forcibly closed. 59 | const timer = setTimeout(async () => unloadPage(page), 2000) 60 | const result = await page.evaluate(({ endpoint, args }) => { 61 | const w: any = window 62 | const endpointFn = w.contract[endpoint] 63 | if (!endpointFn) throw new Error('Endpoint does not exist') 64 | 65 | if (args) { 66 | return endpointFn(JSON.parse(args)) 67 | } else { 68 | return endpointFn() 69 | } 70 | }, { endpoint, args: JSON.stringify(fnArgs) }) 71 | 72 | clearTimeout(timer) 73 | 74 | return result 75 | } catch (e) { 76 | throw new ExecutionFailure(e.message) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/execution_service/test/docker-integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Docker from 'dockerode' 3 | import * as request from 'supertest' 4 | import * as http from 'http' 5 | import { ExecutionEngines } from '../../core' 6 | import { getConfig } from '../config' 7 | import { ExecutionService } from '../application' 8 | import { DockerClient, DockerExecutionEngineContext } from '../infrastructure' 9 | import { checkPortIsFree } from '../../lib/test' 10 | import { readFileSync } from 'fs-extra' 11 | const MOCK_IMAGE = readFileSync('test/bundles/docker/abcd').toString('base64') 12 | 13 | describe('Docker Execution API Integration', () => { 14 | let executionService: ReturnType 15 | let app: http.Server 16 | let dockerClient: ReturnType 17 | 18 | beforeEach(async () => { 19 | await checkPortIsFree(4100) 20 | await checkPortIsFree(4200) 21 | await checkPortIsFree(4201) 22 | process.env.EXECUTION_ENGINE = ExecutionEngines.docker 23 | process.env.EXECUTION_API_PORT = '4100' 24 | process.env.CONTAINER_LOWER_PORT_BOUND = '4200' 25 | process.env.CONTAINER_UPPER_PORT_BOUND = '4300' 26 | executionService = ExecutionService(getConfig()) 27 | app = await executionService.boot() 28 | dockerClient = DockerClient({ 29 | executionContext: DockerExecutionEngineContext.host, 30 | pipeStdout: false 31 | }) 32 | }) 33 | 34 | afterEach(async () => { 35 | await executionService.shutdown() 36 | const docker = new Docker({ socketPath: '/var/run/docker.sock' }) 37 | const containers = await docker.listContainers() 38 | const testContainers = containers.filter(container => container.Image === 'mock-contract') 39 | await Promise.all(testContainers.map(container => docker.getContainer(container.Id).kill())) 40 | }) 41 | 42 | describe('/loadSmartContract', () => { 43 | it('creates a contract container with the correct name', async () => { 44 | return request(app) 45 | .post('/loadSmartContract') 46 | .send({ contractAddress: 'abcd', executable: MOCK_IMAGE }) 47 | .set('Accept', 'application/json') 48 | .expect(204) 49 | .then(async () => { 50 | const { containerId } = await dockerClient.findContainerId('abcd') 51 | expect(!!containerId).to.eql(true) 52 | }) 53 | }) 54 | 55 | it('throws a 400 if contract address is missing in the request body', () => { 56 | return request(app) 57 | .post('/loadSmartContract') 58 | .send({ executable: MOCK_IMAGE }) 59 | .set('Accept', 'application/json') 60 | .expect(400) 61 | }) 62 | 63 | it('throws a 400 if executable is missing in the request body', () => { 64 | return request(app) 65 | .post('/loadSmartContract') 66 | .send({ contractAddress: 'abcd' }) 67 | .set('Accept', 'application/json') 68 | .expect(400) 69 | }) 70 | }) 71 | 72 | describe('/unloadSmartContract', () => { 73 | it('removes a contract container with the corresponding name', async () => { 74 | await dockerClient.loadContainer({ image: MOCK_IMAGE, contractAddress: 'abcd', hostPort: 10000 }) 75 | 76 | return request(app) 77 | .post('/unloadSmartContract') 78 | .send({ contractAddress: 'abcd' }) 79 | .set('Accept', 'application/json') 80 | .expect(204) 81 | .then(async () => { 82 | const { containerId } = await dockerClient.findContainerId('abcd') 83 | expect(!!containerId).to.eql(false) 84 | }) 85 | }) 86 | 87 | it('throws a 400 if contract address is missing in the request body', async () => { 88 | return request(app) 89 | .post('/unloadSmartContract') 90 | .send({}) 91 | .set('Accept', 'application/json') 92 | .expect(400) 93 | }) 94 | }) 95 | 96 | describe('/execute', () => { 97 | it('successfully executes a method against a running contract', async () => { 98 | await request(app) 99 | .post('/loadSmartContract') 100 | .send({ contractAddress: 'abcd', executable: MOCK_IMAGE }) 101 | .set('Accept', 'application/json') 102 | .expect(204) 103 | 104 | return request(app) 105 | .post('/execute/abcd/add') 106 | .send({ number1: 1, number2: 2 }) 107 | .set('Accept', 'application/json') 108 | .expect('Content-Type', /json/) 109 | .expect(200) 110 | .then(response => { 111 | expect(response.body.data).to.eql(3) 112 | }) 113 | }) 114 | 115 | it('throws a 404 if the contract is not yet loaded', async () => { 116 | return request(app) 117 | .post('/execute/abcd/add') 118 | .set('Accept', 'application/json') 119 | .expect('Content-Type', /json/) 120 | .expect(404) 121 | }) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /src/execution_service/test/node-integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as request from 'supertest' 3 | import * as http from 'http' 4 | import { ExecutionEngines } from '../../core' 5 | import { getConfig } from '../config' 6 | import { ExecutionService } from '../application' 7 | import { NodeJsExecutionEngine } from '../infrastructure/execution_engines' 8 | import { checkPortIsFree } from '../../lib/test' 9 | 10 | describe('Node Execution API Integration', () => { 11 | let executionService: ReturnType 12 | let app: http.Server 13 | const mockModule = Buffer.from('{ foobar: (args) => { return {result: args.number1 + args.number2 }} }').toString('base64') 14 | const mockAddress = 'abcd' 15 | 16 | beforeEach(async () => { 17 | await checkPortIsFree(4100) 18 | process.env.EXECUTION_ENGINE = ExecutionEngines.nodejs 19 | process.env.EXECUTION_API_PORT = '4100' 20 | executionService = ExecutionService(getConfig()) 21 | app = await executionService.boot() 22 | }) 23 | 24 | afterEach(async () => { 25 | await executionService.shutdown() 26 | await NodeJsExecutionEngine.unload({ contractAddress: mockAddress }) 27 | }) 28 | 29 | describe('/loadSmartContract', () => { 30 | it('returns a healthy status code when called with the correct arguments', () => { 31 | return request(app) 32 | .post('/loadSmartContract') 33 | .send({ contractAddress: 'abcd', executable: mockModule }) 34 | .set('Accept', 'application/json') 35 | .expect(204) 36 | }) 37 | 38 | it('throws a 400 if contract address is missing in the request body', () => { 39 | return request(app) 40 | .post('/loadSmartContract') 41 | .send({ executable: mockModule }) 42 | .set('Accept', 'application/json') 43 | .expect(400) 44 | }) 45 | 46 | it('throws a 400 if executable is missing in the request body', () => { 47 | return request(app) 48 | .post('/loadSmartContract') 49 | .send({ contractAddress: 'abcd' }) 50 | .set('Accept', 'application/json') 51 | .expect(400) 52 | }) 53 | }) 54 | 55 | describe('/unloadSmartContract', () => { 56 | it('removes a contract by address', async () => { 57 | return request(app) 58 | .post('/unloadSmartContract') 59 | .send({ contractAddress: 'abcd' }) 60 | .set('Accept', 'application/json') 61 | .expect(204) 62 | }) 63 | 64 | it('throws a 400 if contract address is missing in the request body', () => { 65 | return request(app) 66 | .post('/unloadSmartContract') 67 | .send({}) 68 | .set('Accept', 'application/json') 69 | .expect(400) 70 | }) 71 | }) 72 | 73 | describe('/execute', () => { 74 | it('successfully executes a method against a contract', async () => { 75 | await request(app) 76 | .post('/loadSmartContract') 77 | .send({ contractAddress: 'abcd', executable: mockModule }) 78 | .set('Accept', 'application/json') 79 | .expect(204) 80 | 81 | return request(app) 82 | .post('/execute/abcd/foobar') 83 | .send({ number1: 1, number2: 2 }) 84 | .set('Accept', 'application/json') 85 | .expect('Content-Type', /json/) 86 | .expect(200) 87 | .then(response => { 88 | expect(response.body.data.result).to.eql(3) 89 | }) 90 | }) 91 | 92 | it('throws a 404 if the contract is not yet loaded', () => { 93 | return request(app) 94 | .post('/execute/abcd/add') 95 | .set('Accept', 'application/json') 96 | .expect('Content-Type', /json/) 97 | .expect(404) 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/execution_service/test/security/node_js/isolation_from_nodejs.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { NodeJsExecutionEngine } from '../../../infrastructure' 3 | 4 | describe('Puppeteer Isolation from NodeJS', () => { 5 | it('has no access to the Node API', async () => { 6 | const contract1 = Buffer.from(`{ 7 | foo: () => { 8 | const fs = require('fs') 9 | }, 10 | }`).toString('base64') 11 | 12 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 13 | 14 | const failedAccess = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 15 | await expect(failedAccess).to.eventually.be.rejectedWith(/require is not defined/) 16 | 17 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 18 | }) 19 | 20 | it('has no access to the Node process globals', async () => { 21 | const contract1 = Buffer.from(`{ 22 | foo: () => process, 23 | }`).toString('base64') 24 | 25 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 26 | 27 | const failedAccess = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 28 | await expect(failedAccess).to.eventually.be.rejectedWith(/process is not defined/) 29 | 30 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/execution_service/test/security/node_js/load_test.spec.ts: -------------------------------------------------------------------------------- 1 | import { NodeJsExecutionEngine } from '../../../infrastructure' 2 | 3 | describe('Puppeteer Load Test', () => { 4 | // Once we have an actual JS based contract, we can update this spec to use 5 | // it instead 6 | it('Executing a simple array map 500 times...', async () => { 7 | const contract1 = Buffer.from(`{ 8 | foo: (args) => { 9 | const targetData = args.data 10 | const mappedResult = targetData.map(a => a + 1) 11 | return mappedResult 12 | }, 13 | }`).toString('base64') 14 | 15 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 16 | 17 | const args = { 18 | data: Array.from(new Array(1000), () => 1) 19 | } 20 | 21 | let iter = 500 22 | const hrstart = process.hrtime() 23 | 24 | while (iter > 0) { 25 | await NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo', methodArgs: args }) 26 | iter-- 27 | } 28 | 29 | const hrend = process.hrtime(hrstart) 30 | console.info('Puppeteer Execution time - 500 calls: %ds %dms', hrend[0], hrend[1] / 1000000) 31 | 32 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/execution_service/test/security/node_js/network_attacks.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { NodeJsExecutionEngine } from '../../../infrastructure' 3 | 4 | describe('Puppeteer Network Security', () => { 5 | it('Prevents outgoing HTTP requests for a range of methods', async () => { 6 | const methods = [ 7 | 'GET', 8 | 'POST', 9 | 'HEAD', 10 | 'OPTIONS', 11 | 'DELETE', 12 | 'PUT', 13 | 'PATCH', 14 | 'CONNECT', 15 | 'TRACE' 16 | ] 17 | 18 | for (const method in methods) { 19 | await tryXhr(method) 20 | } 21 | }) 22 | }) 23 | 24 | async function tryXhr (httpMethod: string) { 25 | const contract1 = Buffer.from(`{ 26 | foo: async (args) => { 27 | return new Promise((resolve, reject) => { 28 | const maliciousEndpoint = args.maliciousEndpoint 29 | const httpMethod = args.httpMethod 30 | const httpRequest = new XMLHttpRequest() 31 | 32 | httpRequest.onreadystatechange = function () { 33 | if (httpRequest.readyState === XMLHttpRequest.DONE) { 34 | resolve() 35 | } else { 36 | reject(httpRequest.readyState) 37 | } 38 | } 39 | 40 | httpRequest.open(httpMethod, maliciousEndpoint, true) 41 | httpRequest.send() 42 | }) 43 | }, 44 | }`).toString('base64') 45 | 46 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 47 | 48 | const methodArgs = { maliciousEndpoint: 'http://google.com', httpMethod } 49 | const failedRequest = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo', methodArgs }) 50 | await expect(failedRequest).to.eventually.be.rejectedWith(/Evaluation failed: 1/) 51 | 52 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 53 | } 54 | -------------------------------------------------------------------------------- /src/execution_service/test/security/node_js/page_boundaries.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { NodeJsExecutionEngine } from '../../../infrastructure' 3 | 4 | describe('Puppeteer Page Boundaries', () => { 5 | describe('State isolation', () => { 6 | beforeEach(async () => { 7 | const contract1 = Buffer.from(`{ 8 | foo: () => window.a = 1, 9 | bar: () => window.a 10 | }`).toString('base64') 11 | 12 | const contract2 = Buffer.from(`{ 13 | bar: () => window.a 14 | }`).toString('base64') 15 | 16 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 17 | await NodeJsExecutionEngine.load({ contractAddress: 'contract2', executable: contract2 }) 18 | }) 19 | 20 | afterEach(async () => { 21 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 22 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract2' }) 23 | }) 24 | 25 | it('state set on the window by one contract cannot be read by another', async () => { 26 | await NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 27 | const contract1Result = await NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'bar' }) 28 | expect(contract1Result.data).to.eql(1) 29 | 30 | const contract2Result = await NodeJsExecutionEngine.execute({ contractAddress: 'contract2', method: 'bar' }) 31 | expect(contract2Result.data).to.eql(undefined) 32 | }) 33 | }) 34 | 35 | it('Local storage inaccessible', async () => { 36 | const contract1 = Buffer.from(`{ 37 | foo: () => localStorage.setItem('val', 1) 38 | }`).toString('base64') 39 | 40 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 41 | 42 | const failedAccess = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 43 | await expect(failedAccess).to.eventually.be.rejectedWith(/DOMException/) 44 | 45 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 46 | }) 47 | 48 | it('Cookies inaccessible', async () => { 49 | const contract1 = Buffer.from(`{ 50 | foo: () => document.cookie = "username=John Doe" 51 | }`).toString('base64') 52 | 53 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 54 | 55 | const failedAccess = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 56 | await expect(failedAccess).to.eventually.be.rejectedWith(/DOMException/) 57 | 58 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /src/execution_service/test/security/node_js/resource_consumption_attack.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { NodeJsExecutionEngine } from '../../../infrastructure' 3 | import { ExecutionFailure } from '../../../errors' 4 | 5 | describe('Puppeteer resource consumption protection', () => { 6 | it('aborts execution if it takes more than 2s', async () => { 7 | const contract1 = Buffer.from(`{ 8 | foo: () => { 9 | while (true) { } 10 | }, 11 | }`).toString('base64') 12 | 13 | await NodeJsExecutionEngine.load({ contractAddress: 'contract1', executable: contract1 }) 14 | 15 | const t1 = Date.now() 16 | const whileTrueCall = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 17 | await expect(whileTrueCall).to.eventually.be.rejectedWith(ExecutionFailure) 18 | 19 | const t2 = Date.now() 20 | expect(t2 - t1 > 2000).to.eql(true) 21 | 22 | const secondCallToInstantlyFail = NodeJsExecutionEngine.execute({ contractAddress: 'contract1', method: 'foo' }) 23 | await expect(secondCallToInstantlyFail).to.eventually.be.rejectedWith(ExecutionFailure) 24 | 25 | const t3 = Date.now() 26 | expect(t3 - t2 < 2000).to.eql(true) 27 | 28 | await NodeJsExecutionEngine.unload({ contractAddress: 'contract1' }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/lib/Entity.ts: -------------------------------------------------------------------------------- 1 | export type Entity = { 2 | id: string 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/NetworkInterface.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | // Inherit the type from Axios 3 | // We may wish to define this ourselves to remove the dependency in the application layer 4 | export type NetworkInterface = AxiosInstance 5 | -------------------------------------------------------------------------------- /src/lib/NumberRange.ts: -------------------------------------------------------------------------------- 1 | export type NumberRange = { 2 | lower: number, 3 | upper: number 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/PortMapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import { PortAllocation } from '../core' 4 | import { AllPortsAllocated } from '../core/errors' 5 | import { checkPortIsFree, RogueService } from './test' 6 | import { PortMapper, InMemoryRepository } from '.' 7 | 8 | use(chaiAsPromised) 9 | 10 | describe('PortMapper', () => { 11 | let portMapper: ReturnType 12 | beforeEach(async () => { 13 | await checkPortIsFree(8082) 14 | portMapper = PortMapper({ 15 | repository: InMemoryRepository(), 16 | range: { 17 | lower: 8082, 18 | upper: 8083 19 | } 20 | }) 21 | }) 22 | describe('getAvailablePort', () => { 23 | it('Returns the next port if available', async () => { 24 | const allocation1 = await portMapper.getAvailablePort() 25 | expect(allocation1.portNumber).to.eq(8082) 26 | const allocation2 = await portMapper.getAvailablePort() 27 | expect(allocation2.portNumber).to.eq(8083) 28 | }) 29 | it('Throws an error if all ports are allocated within the configured range', async () => { 30 | const allocation1 = await portMapper.getAvailablePort() 31 | expect(allocation1.portNumber).to.eq(8082) 32 | const allocation2 = await portMapper.getAvailablePort() 33 | expect(allocation2.portNumber).to.eq(8083) 34 | await expect(portMapper.getAvailablePort()).to.eventually.be.rejectedWith(AllPortsAllocated) 35 | }) 36 | describe('Graceful handling of port collision', () => { 37 | it('Selects the next available port', async () => { 38 | const rogueService = RogueService() 39 | await rogueService.listen(8082) 40 | const allocation1 = await portMapper.getAvailablePort() 41 | expect(allocation1.portNumber).to.eq(8083) 42 | rogueService.close() 43 | }) 44 | it('Throws an error if all ports are allocated within the configured range are not available on the host', async () => { 45 | const rogueService = RogueService() 46 | const rogueService2 = RogueService() 47 | await rogueService.listen(8082) 48 | await rogueService2.listen(8083) 49 | await expect(portMapper.getAvailablePort()).to.eventually.be.rejectedWith(AllPortsAllocated) 50 | rogueService.close() 51 | rogueService2.close() 52 | }) 53 | }) 54 | }) 55 | 56 | describe('isAvailable', () => { 57 | it('Returns true if the port is available', async () => { 58 | expect(await portMapper.isAvailable(8082)).to.be.true 59 | }) 60 | it('Returns false if the port has been allocated', async () => { 61 | const allocation1 = await portMapper.getAvailablePort() 62 | expect(allocation1.portNumber).to.eq(8082) 63 | expect(await portMapper.isAvailable(8082)).to.be.false 64 | }) 65 | }) 66 | 67 | describe('releasePort', () => { 68 | it('Makes the port available to assign again', async () => { 69 | const allocation1 = await portMapper.getAvailablePort() 70 | expect(allocation1.portNumber).to.eq(8082) 71 | expect(await portMapper.isAvailable(8082)).to.be.false 72 | const release = await portMapper.releasePort(8082) 73 | expect(release).to.be.true 74 | expect(await portMapper.isAvailable(8082)).to.be.true 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /src/lib/PortMapper.ts: -------------------------------------------------------------------------------- 1 | import { createServer, Server } from 'net' 2 | import { PortAllocation } from '../core' 3 | import { NumberRange } from '../lib' 4 | import { AllPortsAllocated } from '../core/errors' 5 | import { PortAllocationRepository } from '../core/PortAllocationRepository' 6 | 7 | export type Config ={ 8 | repository: PortAllocationRepository 9 | range: NumberRange 10 | } 11 | 12 | export function PortMapper ({ repository, range }: Config) { 13 | const startingPoolQty = range.upper - (range.lower - 1) 14 | return { 15 | isAvailable: async (port: number): Promise => { 16 | return !(await repository.has(port.toString())) 17 | }, 18 | getAvailablePort: async (): Promise => { 19 | const size = await repository.size() 20 | if (size === startingPoolQty) throw new AllPortsAllocated(range) 21 | let portNumber = size === 0 22 | ? range.lower 23 | : (await repository.getLast()).portNumber + 1 24 | while (!(await isAvailableOnHost(portNumber))) { 25 | if (portNumber === range.upper) throw new AllPortsAllocated(range) 26 | portNumber++ 27 | } 28 | const portAllocation = { 29 | id: portNumber.toString(), 30 | portNumber 31 | } 32 | await repository.add(portAllocation) 33 | return portAllocation 34 | }, 35 | releasePort: async (port: number): Promise => { 36 | if (await !repository.has(port.toString())) return true 37 | return repository.remove(port.toString()) 38 | } 39 | } 40 | } 41 | 42 | function isAvailableOnHost (port: PortAllocation['portNumber']) { 43 | return new Promise((resolve, reject) => { 44 | const tester: Server = createServer() 45 | .once('error', (error: NodeJS.ErrnoException) => (error.code === 'EADDRINUSE' ? resolve(false) : reject(error))) 46 | .once('listening', () => tester.once('close', () => resolve(true)).close()) 47 | .listen(port) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/Repository.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../lib' 2 | 3 | export interface Repository { 4 | add(entity: T): Promise 5 | find(id: T['id']): Promise 6 | findAll(): Promise 7 | getLast(): Promise 8 | has(id: T['id']): Promise 9 | remove(id: T['id']): Promise 10 | size(): Promise 11 | update(entity: T): Promise 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/compileContractSchema.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { compileContractSchema } from './compileContractSchema' 3 | const requireFromString = require('require-from-string') 4 | 5 | describe('compileContractSchema', () => { 6 | it('dynamically compiles an io-ts based schema with webpack', async () => { 7 | const contractSchema = ` 8 | const addArgs = t.type({ 9 | number1: t.number, 10 | number2: t.number, 11 | }) 12 | 13 | export const Add = createEndpoint('Add', addArgs, t.number) 14 | ` 15 | 16 | const schema = await compileContractSchema(contractSchema) 17 | const { Add } = requireFromString(schema) 18 | expect(Add.validateArgs({ number1: 1, number2: 2 })).to.eql(true) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/lib/compileContractSchema.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import { exec } from 'child_process' 3 | const uuidv4 = require('uuid/v4') 4 | 5 | // The uncompiled schema relies on the following imports but they are 6 | // not declared from the Plutus generator. 7 | 8 | // This function is called when dynamically loading straight off of 9 | // the Plutus contract, or we creating a bundle for distribution 10 | export async function compileContractSchema (uncompiledSchema: string) { 11 | const absolutePathToSrcLib = `${__dirname}/../../src/lib` 12 | const tempfileAllocator = uuidv4() 13 | const tscFileName = `${tempfileAllocator}.ts` 14 | const outputFileName = `${tempfileAllocator}.js` 15 | const tempTscFilePath = `${absolutePathToSrcLib}/${tscFileName}` 16 | const tempOutputFilePath = `${absolutePathToSrcLib}/${outputFileName}` 17 | 18 | try { 19 | const iotsPrefix = `import * as t from 'io-ts'` 20 | const createEndpointPrefix = `import { createEndpoint } from '${absolutePathToSrcLib}/createEndpoint'` 21 | 22 | const uncompiledSchemaWithImports = ` 23 | ${iotsPrefix} 24 | ${createEndpointPrefix} 25 | ${uncompiledSchema} 26 | ` 27 | 28 | await fs.writeFile(tempTscFilePath, uncompiledSchemaWithImports) 29 | await tscExecutionHandler(tempTscFilePath, tempOutputFilePath) 30 | 31 | const compiledSchema = (await fs.readFile(tempOutputFilePath)).toString() 32 | return compiledSchema 33 | } catch (e) { 34 | throw e 35 | } finally { 36 | await fs.remove(tempTscFilePath) 37 | await fs.remove(tempOutputFilePath) 38 | } 39 | } 40 | 41 | function tscExecutionHandler (tscFilePath: string, outputFilePath: string) { 42 | return new Promise(async (resolve, reject) => { 43 | exec(`npx webpack \ 44 | --entry ${tscFilePath} \ 45 | --output ${outputFilePath} \ 46 | --mode production \ 47 | --output-library-target commonjs 48 | `, (err, stdout, stderr) => { 49 | if (err) { 50 | console.error(stdout) 51 | reject(new Error('tsc error')) 52 | } 53 | 54 | if (stderr) { 55 | console.error(stdout) 56 | reject(new Error('tsc error')) 57 | } 58 | 59 | resolve() 60 | }) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/createEndpoint.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import * as t from 'io-ts' 4 | import { createEndpoint } from './createEndpoint' 5 | use(chaiAsPromised) 6 | 7 | describe('createEndpoint', () => { 8 | const addArgs = t.type({ 9 | number1: t.number, 10 | number2: t.number 11 | }) 12 | 13 | const addState = t.type({ 14 | lastAddition: t.number 15 | }) 16 | 17 | const addEndpoint = createEndpoint('Add', addArgs, t.number, addState) 18 | const resolver = ({ number1, number2 }: t.TypeOf) => Promise.resolve(number1 + number2) 19 | 20 | describe('name', () => { 21 | it('keeps a string reference to the contract endpoint name', () => { 22 | expect(addEndpoint.name).to.eql('Add') 23 | }) 24 | }) 25 | 26 | describe('call', () => { 27 | it('throws if arguments are of the wrong type', () => { 28 | const invalidArgs: any = { number1: 1, number3: 2 } 29 | return expect(addEndpoint.call(invalidArgs, resolver)).to.eventually.be.rejectedWith(Error) 30 | }) 31 | 32 | it('throws if state is of the wrong type', () => { 33 | const correctArgs = { number1: 1, number2: 2 } 34 | const invalidState: any = { someKey: 1 } 35 | return expect(addEndpoint.call(correctArgs, resolver, invalidState)).to.eventually.be.rejectedWith(Error) 36 | }) 37 | 38 | it('throws if the return type is of the wrong type', () => { 39 | const correctArgs = { number1: 1, number2: 2 } 40 | const invalidResolver: any = (_x: t.TypeOf) => Promise.resolve('string') 41 | return expect(addEndpoint.call(correctArgs, invalidResolver)).to.eventually.be.rejectedWith(Error) 42 | }) 43 | 44 | it('uses the resolver to call the contract', async () => { 45 | const correctArgs = { number1: 1, number2: 2 } 46 | const result = await addEndpoint.call(correctArgs, resolver, { lastAddition: 1 }) 47 | expect(result).to.eql(3) 48 | }) 49 | }) 50 | 51 | describe('describe', () => { 52 | it('returns a string representation of the AST, generated by io-ts', () => { 53 | const definition = addEndpoint.describe() 54 | expect(definition.argType).to.eql('{ number1: number, number2: number }') 55 | expect(definition.returnType).to.eql('number') 56 | expect(definition.stateType).to.eql('{ lastAddition: number }') 57 | }) 58 | }) 59 | 60 | describe('validateArgs', () => { 61 | it('throws if args are of an invalid type', () => { 62 | const invalidArgs: any = { number1: 1, number3: 2 } 63 | expect(() => addEndpoint.validateArgs(invalidArgs)).to.throw 64 | }) 65 | 66 | it('does not throw if args are of a valid type', () => { 67 | const validArgs = { number1: 1, number2: 2 } 68 | expect(() => addEndpoint.validateArgs(validArgs)).to.not.throw 69 | }) 70 | }) 71 | 72 | describe('validateState', () => { 73 | it('throws if state is of an invalid type', () => { 74 | const invalidState: any = { someKey: 1 } 75 | expect(() => addEndpoint.validateState(invalidState)).to.throw 76 | }) 77 | 78 | it('does not throw if state is of a valid type', () => { 79 | const validState = { lastAddition: 1 } 80 | expect(() => addEndpoint.validateState(validState)).to.not.throw 81 | }) 82 | }) 83 | 84 | describe('validateReturn', () => { 85 | it('throws if return value is of an invalid type', () => { 86 | const invalidReturnVal: any = 'x' 87 | expect(() => addEndpoint.validateReturn(invalidReturnVal)).to.throw 88 | }) 89 | 90 | it('does not throw if return value is of a valid type', () => { 91 | const returnValue = 1 92 | expect(() => addEndpoint.validateReturn(returnValue)).to.not.throw 93 | }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /src/lib/createEndpoint.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts' 2 | import { ThrowReporter } from 'io-ts/lib/ThrowReporter' 3 | import { Endpoint } from '../core' 4 | 5 | export function validateAgainstCodec (codec: t.Any, data: any) { 6 | const decodingResult = codec.decode(data) 7 | ThrowReporter.report(decodingResult) 8 | 9 | return true 10 | } 11 | 12 | export function createEndpoint (name: string, argsCodec: A, returnCodec: R, stateCodec?: S): Endpoint { 13 | return { 14 | name, 15 | call: async (data, resolverFunction, state) => { 16 | const validContractInput = validateAgainstCodec(argsCodec, data) 17 | if (!validContractInput) throw new Error('Bad input') 18 | 19 | if (state) { 20 | const validContractState = validateAgainstCodec(stateCodec, state) 21 | if (!validContractState) throw new Error('Bad contract state') 22 | } 23 | 24 | const contractResult = await resolverFunction(data, state) 25 | 26 | const validContractOutput = validateAgainstCodec(returnCodec, contractResult) 27 | if (!validContractOutput) throw new Error('Invalid contract return type') 28 | 29 | return contractResult 30 | }, 31 | describe: () => ({ 32 | argType: argsCodec.name, 33 | returnType: returnCodec.name, 34 | stateType: stateCodec ? stateCodec.name : '' 35 | }), 36 | validateArgs: (data) => validateAgainstCodec(argsCodec, data), 37 | validateReturn: (data) => validateAgainstCodec(returnCodec, data), 38 | validateState: (data) => validateAgainstCodec(stateCodec, data) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/expressEventPromiseHandler.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import http from 'http' 3 | 4 | export const expressEventPromiseHandler = { 5 | async listen (app: ReturnType, port: number): Promise { 6 | return new Promise((resolve, reject) => { 7 | const server: http.Server = app.listen({ port }) 8 | .on('listening', () => resolve(server)) 9 | .on('error', (error) => reject(error)) 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/fsPromises.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | const rimraf = require('rimraf') 3 | 4 | export function checkFolderExists (path: string): Promise { 5 | return new Promise((resolve) => { 6 | fs.stat(path, (err, stats) => { 7 | if (err) return resolve(false) 8 | if (stats.isDirectory()) return resolve(true) 9 | return resolve(false) 10 | }) 11 | }) 12 | } 13 | 14 | export function createDirectory (path: string) { 15 | return new Promise((resolve, reject) => { 16 | fs.mkdir(path, (err) => { 17 | if (err) return reject(err) 18 | return resolve() 19 | }) 20 | }) 21 | } 22 | 23 | export function removeDirectoryWithContents (path: string) { 24 | return new Promise((resolve, reject) => { 25 | rimraf(path, (err: Error, _: any) => { 26 | if (err) return reject(err) 27 | return resolve() 28 | }) 29 | }) 30 | } 31 | 32 | export function writeFile (path: string, payload: Buffer) { 33 | return new Promise((resolve, reject) => { 34 | fs.writeFile(path, payload, (err) => { 35 | if (err) return reject(err) 36 | return resolve() 37 | }) 38 | }) 39 | } 40 | 41 | export function readFile (path: string): Promise { 42 | return new Promise((resolve, reject) => { 43 | fs.readFile(path, (err, data) => { 44 | if (err) return reject(err) 45 | return resolve(data) 46 | }) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/httpEventPromiseHandler.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http' 2 | 3 | export const httpEventPromiseHandler = { 4 | async listen (server: http.Server, port: number): Promise { 5 | return new Promise((resolve, reject) => { 6 | server.listen({ port }) 7 | .on('listening', resolve) 8 | .on('error', reject) 9 | }) 10 | }, 11 | async close (server: http.Server): Promise { 12 | return new Promise((resolve, reject) => { 13 | server.close() 14 | .on('close', resolve) 15 | .on('error', reject) 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './compileContractSchema' 2 | export * from './createEndpoint' 3 | export * from './Entity' 4 | export * from './expressEventPromiseHandler' 5 | export * from './fsPromises' 6 | export * from './httpEventPromiseHandler' 7 | export * from './NetworkInterface' 8 | export * from './NumberRange' 9 | export { PortMapper, Config as PortMapperConfig } from './PortMapper' 10 | export * from './Repository' 11 | export * from './repositories' 12 | -------------------------------------------------------------------------------- /src/lib/repositories/InMemoryRepository.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import { InMemoryRepository } from './InMemoryRepository' 4 | import { Entity } from '..' 5 | import { UnknownEntity } from '../../core/errors' 6 | use(chaiAsPromised) 7 | 8 | type SomeEntity = Entity & { 9 | name: string 10 | } 11 | 12 | const someEntity1 = { 13 | id: 'someEntity1', 14 | name: 'some entity 1' 15 | } 16 | 17 | const someEntity2 = { 18 | id: 'someEntity2', 19 | name: 'some entity 2' 20 | } 21 | 22 | describe('In-memory repository', () => { 23 | let repository: ReturnType 24 | beforeEach(() => { 25 | repository = InMemoryRepository() 26 | }) 27 | describe('Initialization', () => { 28 | it('Is empty by default', async () => { 29 | expect(await repository.size()).to.eq(0) 30 | }) 31 | }) 32 | describe('add', () => { 33 | it('adds the entity if not already in the collection', async () => { 34 | expect(await repository.size()).to.eq(0) 35 | await repository.add(someEntity1) 36 | expect(await repository.size()).to.eq(1) 37 | await repository.add(someEntity1) 38 | expect(await repository.size()).to.eq(1) 39 | }) 40 | }) 41 | describe('find', () => { 42 | beforeEach(async () => { 43 | await repository.add(someEntity1) 44 | }) 45 | it('returns the entity by ID', async () => { 46 | expect(await repository.find(someEntity1.id)).to.eq(someEntity1) 47 | }) 48 | it('returns null if the entity is not in the repository', async () => { 49 | expect(await repository.find('someUnknownEntityId')).to.eq(null) 50 | }) 51 | }) 52 | describe('findAll', () => { 53 | it('returns an array of all the entities', async () => { 54 | await repository.add(someEntity1) 55 | await repository.add(someEntity2) 56 | expect(await repository.findAll()).to.have.members([someEntity1, someEntity2]) 57 | }) 58 | it('returns an empty array if the repository is empty', async () => { 59 | expect(await repository.size()).to.eq(0) 60 | expect(await repository.findAll()).to.be.an('array').that.is.empty 61 | }) 62 | }) 63 | describe('getLast', () => { 64 | it('returns the last added entity if the repository it not empty', async () => { 65 | await repository.add(someEntity1) 66 | await repository.add(someEntity2) 67 | expect(await repository.getLast()).to.eq(someEntity2) 68 | }) 69 | it('returns null if the repository is empty', async () => { 70 | expect(await repository.getLast()).to.eq(null) 71 | }) 72 | }) 73 | describe('has', () => { 74 | it('knows if the repository includes an entity by ID', async () => { 75 | await repository.add(someEntity1) 76 | expect(await repository.has(someEntity1.id)).to.eq(true) 77 | expect(await repository.has('someUnknownEntityId')).to.eq(false) 78 | }) 79 | }) 80 | describe('remove', () => { 81 | it('removes entity from the repository if it exists', async () => { 82 | await repository.add(someEntity1) 83 | expect(await repository.has('someEntity1')).to.eq(true) 84 | await repository.remove(someEntity1.id) 85 | expect(await repository.has('someUnknownEntityId')).to.eq(false) 86 | }) 87 | it('throws an error if the entity does not exist', async () => { 88 | expect(await repository.has(someEntity1.id)).to.eq(false) 89 | await expect(repository.remove(someEntity1.id)).to.be.rejectedWith(UnknownEntity) 90 | }) 91 | }) 92 | describe('size', () => { 93 | it('reports the total number of entities in the repository', async () => { 94 | expect(await repository.size()).to.eq(0) 95 | await repository.add(someEntity1) 96 | await repository.add(someEntity2) 97 | expect(await repository.size()).to.eq(2) 98 | await repository.remove(someEntity1.id) 99 | expect(await repository.size()).to.eq(1) 100 | }) 101 | }) 102 | describe('update', () => { 103 | it('can update the entity record', async () => { 104 | const someEntity1Updated = { 105 | id: someEntity1.id, 106 | name: 'some entity 1 with a new name' 107 | } 108 | await repository.add(someEntity1) 109 | expect(await repository.size()).to.eq(1) 110 | await repository.update(someEntity1Updated) 111 | expect(await repository.size()).to.eq(1) 112 | expect(await repository.find(someEntity1.id)).to.eq(someEntity1Updated) 113 | }) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /src/lib/repositories/InMemoryRepository.ts: -------------------------------------------------------------------------------- 1 | import { Entity, Repository } from '..' 2 | import { UnknownEntity } from '../../core/errors' 3 | 4 | export function InMemoryRepository (): Repository { 5 | const collection = new Map() 6 | return { 7 | async add (entity: T) { 8 | collection.set(entity.id, entity) 9 | }, 10 | async find (id: T['id']) { 11 | if (!await this.has(id)) return null 12 | return collection.get(id) 13 | }, 14 | async findAll () { 15 | return [...collection.values()] 16 | }, 17 | async getLast () { 18 | if (await this.size() === 0) return null 19 | return [...collection.values()].pop() 20 | }, 21 | async has (id: T['id']) { 22 | return collection.has(id) 23 | }, 24 | async remove (id: T['id']) { 25 | if (!await this.has(id)) throw new UnknownEntity(String(id)) 26 | return collection.delete(id) 27 | }, 28 | async size () { 29 | return collection.size 30 | }, 31 | async update (entity: T) { 32 | await this.remove(entity.id) 33 | this.add(entity) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export { InMemoryRepository } from './InMemoryRepository' 2 | -------------------------------------------------------------------------------- /src/lib/test/RogueService.ts: -------------------------------------------------------------------------------- 1 | import { createServer, Server } from 'net' 2 | 3 | export function RogueService () { 4 | const server: Server = createServer() 5 | return { 6 | async listen (port: number) { 7 | return new Promise((resolve, reject) => { 8 | server.listen(port) 9 | .once('listening', () => resolve(true)) 10 | .on('error', (error) => reject(error)) 11 | }) 12 | }, 13 | close () { 14 | server.close() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/test/checkPortIsFree.ts: -------------------------------------------------------------------------------- 1 | import * as tcpPortUsed from 'tcp-port-used' 2 | 3 | export async function checkPortIsFree (port: number) { 4 | if (await tcpPortUsed.check(port, '127.0.0.1') === true) throw new Error('Port already allocated on host') 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/test/index.ts: -------------------------------------------------------------------------------- 1 | export { checkPortIsFree } from './checkPortIsFree' 2 | export { RogueService } from './RogueService' 3 | export { populatedContractRepository } from './populatedContractRepository' 4 | export { testContracts } from './testContracts' 5 | -------------------------------------------------------------------------------- /src/lib/test/populatedContractRepository.ts: -------------------------------------------------------------------------------- 1 | import { Contract } from '../../core' 2 | import { InMemoryRepository } from '../repositories' 3 | import { testContracts } from '.' 4 | 5 | export async function populatedContractRepository () { 6 | const repository = InMemoryRepository() 7 | const contract = testContracts[0] 8 | await repository.add(contract) 9 | return repository 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/test/testContracts.ts: -------------------------------------------------------------------------------- 1 | import { Contract, Engine } from '../../core' 2 | 3 | export const testContracts: Contract[] = [{ 4 | id: 'testContract', 5 | address: 'testContract', 6 | engine: Engine.plutus, 7 | bundle: { 8 | executable: Buffer.from(''), 9 | schema: '' 10 | } 11 | }] 12 | -------------------------------------------------------------------------------- /src/server/README.md: -------------------------------------------------------------------------------- 1 | # Server 2 | 3 | Connect to an [Execution Service](../execution_service/README.md), configure transaction publication, and expose a GraphQL API over HTTP. See the [default configuration](index.ts) as an example. 4 | 5 | - [More documentation](../../docs) 6 | 7 | ![Server components diagram](../../docs/images/server_components_diagram.png) -------------------------------------------------------------------------------- /src/server/application/Api.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as http from 'http' 3 | import axios from 'axios' 4 | import { PubSub } from 'apollo-server' 5 | import { Engine } from '../../core' 6 | import { expressEventPromiseHandler, httpEventPromiseHandler } from '../../lib' 7 | import { checkPortIsFree, populatedContractRepository } from '../../lib/test' 8 | import { StubEngineClient } from '../infrastructure' 9 | import { Api, ContractController } from '.' 10 | 11 | describe('Api', () => { 12 | let api: ReturnType 13 | let apiServer: http.Server 14 | const API_PORT = 8081 15 | const API_URI = `http://localhost:${API_PORT}` 16 | 17 | beforeEach(async () => { 18 | await checkPortIsFree(8082) 19 | const pubSubClient = new PubSub() 20 | const contractRepository = await populatedContractRepository() 21 | const contractController = ContractController({ 22 | contractDirectory: 'test/bundles/nodejs', 23 | contractRepository, 24 | engineClients: new Map([[ 25 | Engine.stub, 26 | StubEngineClient() 27 | ]]), 28 | pubSubClient 29 | }) 30 | api = Api({ 31 | contractController, 32 | contractRepository, 33 | pubSubClient 34 | }) 35 | 36 | apiServer = await expressEventPromiseHandler.listen(api.app, API_PORT) 37 | }) 38 | 39 | afterEach(async () => { 40 | await httpEventPromiseHandler.close(apiServer) 41 | }) 42 | 43 | it('has a health check endpoint', async () => { 44 | const response = await axios.get(`${API_URI}/.well-known/apollo/server-health`) 45 | expect(response.status).to.eq(200) 46 | expect(response.data).to.eql({ status: 'pass' }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/server/application/Api.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import { ApolloServer } from 'apollo-server-express' 3 | import { gql, PubSubEngine } from 'apollo-server' 4 | import { Bundle, Contract, ContractRepository, ContractCallInstruction, Events, Endpoint, Engine } from '../../core' 5 | import { ContractController } from '.' 6 | import { ContractNotLoaded } from './errors' 7 | import requireFromString = require('require-from-string') 8 | 9 | export type Config = { 10 | contractController: ReturnType 11 | contractRepository: ContractRepository 12 | pubSubClient: PubSubEngine 13 | } 14 | 15 | export function Api (config: Config) { 16 | const app = express() 17 | app.use((err: Error, _req: express.Request, response: express.Response, next: express.NextFunction) => { 18 | if (err instanceof ContractNotLoaded) { 19 | return response.status(404).json({ error: err.message }) 20 | } 21 | 22 | next(err) 23 | }) 24 | 25 | const apolloServer = buildApolloServer(config) 26 | apolloServer.applyMiddleware({ app, path: '/graphql' }) 27 | return { apolloServer, app } 28 | } 29 | 30 | function buildApolloServer ({ contractController, contractRepository, pubSubClient }: Config) { 31 | return new ApolloServer({ 32 | typeDefs: gql` 33 | type SigningRequest { 34 | transaction: String! 35 | } 36 | type Contract { 37 | description: String! 38 | contractAddress: String! 39 | } 40 | type Query { 41 | contracts: [Contract]! 42 | } 43 | input ContractInstruction { 44 | originatorPk: String 45 | method: String! 46 | contractAddress: String! 47 | methodArguments: String 48 | } 49 | type Mutation { 50 | loadContract(contractAddress: String!, engine: String!): Boolean 51 | callContract(contractInstruction: ContractInstruction!): String 52 | unloadContract(contractAddress: String!): Boolean 53 | } 54 | type Subscription { 55 | transactionSigningRequest(publicKey: String!): SigningRequest 56 | } 57 | `, 58 | resolvers: { 59 | Query: { 60 | async contracts () { 61 | const contracts = await contractRepository.findAll() 62 | return contracts.map(({ address, bundle }: { address: Contract['address'], bundle: Bundle }) => { 63 | const schema = requireFromString(bundle.schema) 64 | const eps: [string, Endpoint][] = Object.entries(schema) 65 | const description = eps.reduce(( 66 | acc: {[name: string]: ReturnType['describe']>}, 67 | [_, ep] 68 | ) => { 69 | acc[ep.name] = ep.describe() 70 | return acc 71 | }, {}) 72 | 73 | return { contractAddress: address, description: JSON.stringify(description) } 74 | }) 75 | } 76 | }, 77 | Mutation: { 78 | loadContract (_: any, { contractAddress, engine }: { contractAddress: string, engine?: Engine }) { 79 | if (!engine) engine = Engine.plutus 80 | return contractController.load(contractAddress, engine) 81 | }, 82 | unloadContract (_: any, { contractAddress }: { contractAddress: string }) { 83 | return contractController.unload(contractAddress) 84 | }, 85 | callContract (_: any, { contractInstruction }: { contractInstruction: ContractCallInstruction }) { 86 | const instructionWithParsedArgs = contractInstruction.methodArguments 87 | ? { ...contractInstruction, methodArguments: JSON.parse(contractInstruction.methodArguments) } 88 | : contractInstruction 89 | 90 | return contractController.call(instructionWithParsedArgs) 91 | } 92 | }, 93 | Subscription: { 94 | transactionSigningRequest: { 95 | subscribe: (_: any, { publicKey }: { publicKey: string }) => pubSubClient.asyncIterator(`${Events.SIGNATURE_REQUIRED}.${publicKey}`) 96 | } 97 | } 98 | }, 99 | introspection: true 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /src/server/application/ContractController.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import { spy } from 'sinon' 3 | import * as sinonChai from 'sinon-chai' 4 | import { PubSub } from 'apollo-server' 5 | import { Contract, ContractRepository, Engine, EngineClient } from '../../core' 6 | import { InMemoryRepository } from '../../lib' 7 | import { ContractController } from '.' 8 | import { StubEngineClient } from '../infrastructure' 9 | 10 | use(sinonChai) 11 | 12 | describe('Contract Controller', () => { 13 | let repository: ContractRepository 14 | let engineClients: Map 15 | let controller: ReturnType 16 | const testContractAddress = 'abcd' 17 | 18 | beforeEach(async () => { 19 | repository = InMemoryRepository() 20 | engineClients = new Map([[ 21 | Engine.stub, 22 | StubEngineClient() 23 | ]]) 24 | controller = ContractController({ 25 | contractDirectory: 'test/bundles/nodejs', 26 | contractRepository: repository, 27 | engineClients, 28 | pubSubClient: new PubSub() 29 | }) 30 | }) 31 | 32 | describe('load', () => { 33 | let loadExecutable: ReturnType 34 | beforeEach(async () => { 35 | loadExecutable = spy(engineClients.get(Engine.stub), 'loadExecutable') 36 | expect(await repository.has(testContractAddress)).to.eq(false) 37 | }) 38 | it('fetches the bundle, adds the contract to the repository & loads the executable', async () => { 39 | const load = await controller.load(testContractAddress, Engine.stub) 40 | expect(load).to.be.true 41 | expect(loadExecutable).to.have.been.calledOnce 42 | expect(await repository.has(testContractAddress)).to.eq(true) 43 | }) 44 | it('uses the existing repository entry if present', async () => { 45 | await controller.load(testContractAddress, Engine.stub) 46 | const load = await controller.load(testContractAddress) 47 | expect(load).to.be.true 48 | expect(await repository.size()).to.eq(1) 49 | }) 50 | }) 51 | 52 | describe('unload', () => { 53 | describe('with loaded contracts', () => { 54 | let unloadExecutable: ReturnType 55 | beforeEach(async () => { 56 | unloadExecutable = spy(engineClients.get(Engine.stub), 'unloadExecutable') 57 | await controller.load(testContractAddress, Engine.stub) 58 | expect(await repository.has(testContractAddress)).to.eq(true) 59 | }) 60 | it('Unloads the executable and removes the contract from the repository', async () => { 61 | const unload = await controller.unload(testContractAddress) 62 | expect(unload).to.be.true 63 | expect(unloadExecutable).to.have.been.calledOnce 64 | expect(await repository.has(testContractAddress)).to.eq(false) 65 | }) 66 | }) 67 | describe('without loaded contracts', () => { 68 | it('returns false if the contract is not loaded', async () => { 69 | const unload = await controller.unload(testContractAddress) 70 | expect(unload).to.be.false 71 | }) 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/server/application/ContractController.ts: -------------------------------------------------------------------------------- 1 | import { PubSubEngine } from 'apollo-server' 2 | import { 3 | Contract, 4 | ContractRepository, 5 | Engine, 6 | EngineClient, 7 | ContractCallInstruction, 8 | Events, 9 | Endpoint 10 | } from '../../core' 11 | import { ContractNotLoaded, InvalidEndpoint } from '../../execution_service/errors' 12 | import * as fs from 'fs-extra' 13 | import { compileContractSchema } from '../../lib' 14 | import { join } from 'path' 15 | const requireFromString = require('require-from-string') 16 | 17 | type Config = { 18 | contractDirectory: string 19 | contractRepository: ContractRepository 20 | engineClients: Map 21 | pubSubClient: PubSubEngine 22 | } 23 | 24 | export function ContractController (config: Config) { 25 | const { 26 | contractDirectory, 27 | contractRepository, 28 | engineClients, 29 | pubSubClient 30 | } = config 31 | 32 | async function loadContract (contractAddress: Contract['address'], engine = Engine.plutus): Promise { 33 | let contract = await contractRepository.find(contractAddress) 34 | if (!contract) { 35 | const engineClient = engineClients.get(engine) 36 | const executable = await fs.readFile(join(contractDirectory, contractAddress)) 37 | await engineClient.loadExecutable({ contractAddress, executable }) 38 | 39 | const { data: { data: uncompiledContractSchema } } = await engineClient.call({ 40 | contractAddress, 41 | method: 'schema' 42 | }) 43 | 44 | const schema = await compileContractSchema(uncompiledContractSchema) 45 | 46 | contract = { 47 | id: contractAddress, 48 | address: contractAddress, 49 | engine, 50 | bundle: { 51 | executable, 52 | schema 53 | } 54 | } 55 | 56 | await contractRepository.add(contract) 57 | } 58 | 59 | return true 60 | } 61 | 62 | return { 63 | async loadAll () { 64 | const contracts = await fs.readdir(contractDirectory) 65 | return Promise.all(contracts.map(c => loadContract(c))) 66 | }, 67 | load: loadContract, 68 | async call (instruction: ContractCallInstruction) { 69 | const contract = await contractRepository.find(instruction.contractAddress) 70 | if (!contract) { 71 | throw new ContractNotLoaded() 72 | } 73 | 74 | const engineClient = engineClients.get(contract.engine) 75 | 76 | // As this is runtime, we don't know the relevant generics of Endpoint, 77 | // but we can still leverage the interface 78 | const contractEndpoints = requireFromString(contract.bundle.schema) 79 | const endpoint = contractEndpoints[instruction.method] as Endpoint 80 | if (!endpoint) { 81 | throw new InvalidEndpoint(Object.keys(contractEndpoints)) 82 | } 83 | 84 | const response = await endpoint.call( 85 | instruction.methodArguments, 86 | async (_args, _state) => { 87 | const { data: { data } } = await engineClient.call(instruction) 88 | return data 89 | } 90 | ) 91 | 92 | await pubSubClient.publish(`${Events.SIGNATURE_REQUIRED}.${instruction.originatorPk}`, { transactionSigningRequest: { transaction: JSON.stringify(response) } }) 93 | return response 94 | }, 95 | async unload (contractAddress: Contract['address']): Promise { 96 | let contract = await contractRepository.find(contractAddress) 97 | if (!contract) return false 98 | const engineClient = engineClients.get(contract.engine) 99 | await engineClient.unloadExecutable(contractAddress) 100 | return contractRepository.remove(contract.address) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/server/application/Server.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import * as chaiAsPromised from 'chai-as-promised' 3 | import axios from 'axios' 4 | import { PubSub } from 'apollo-server' 5 | import { Contract, Engine } from '../../core' 6 | import { InMemoryRepository } from '../../lib' 7 | import { checkPortIsFree } from '../../lib/test' 8 | import { Client } from '../../client' 9 | import { Server } from '.' 10 | import { StubEngineClient } from '../infrastructure' 11 | 12 | use(chaiAsPromised) 13 | 14 | describe('Server', () => { 15 | let server: ReturnType 16 | const API_PORT = 8081 17 | const client = Client({ 18 | apiUri: `http://localhost:${API_PORT}`, 19 | subscriptionUri: `ws://localhost:${API_PORT}`, 20 | transactionHandler: () => { } 21 | }) 22 | 23 | const testContractAddress = 'abcd' 24 | 25 | beforeEach(async () => { 26 | await checkPortIsFree(8082) 27 | server = Server({ 28 | apiPort: API_PORT, 29 | contractDirectory: 'test/bundles/nodejs', 30 | contractRepository: InMemoryRepository(), 31 | engineClients: new Map([[ 32 | Engine.stub, 33 | StubEngineClient() 34 | ]]), 35 | pubSubClient: new PubSub() 36 | }) 37 | await client.connect('abc') 38 | }) 39 | 40 | describe('Boot', () => { 41 | beforeEach(async () => server.boot()) 42 | afterEach(async () => server.shutdown()) 43 | 44 | it('Starts the API server', async () => { 45 | expect((await checkServer(API_PORT)).statusText).to.eq('OK') 46 | expect((await client.schema()).__schema).to.exist 47 | expect((await client.contracts()).length).to.eq(0) 48 | }) 49 | }) 50 | 51 | describe('Shutdown', () => { 52 | beforeEach(async () => { 53 | await server.boot() 54 | expect((await client.contracts()).length).to.eq(0) 55 | await client.loadContract(testContractAddress, Engine.stub) 56 | expect((await client.contracts()).length).to.eq(1) 57 | }) 58 | 59 | it('Closes the API server and loaded contracts', async () => { 60 | await server.shutdown() 61 | await expect(checkServer(API_PORT)).to.eventually.be.rejected 62 | }) 63 | }) 64 | }) 65 | 66 | function checkServer (port: number) { 67 | return axios({ 68 | url: `http://localhost:${port}/graphql`, 69 | method: 'post', 70 | data: { query: `{ __schema { types { name } } }` } 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /src/server/application/Server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { PubSubEngine } from 'apollo-server' 3 | import { ContractRepository, Engine, EngineClient } from '../../core' 4 | import { httpEventPromiseHandler } from '../../lib' 5 | import { 6 | Api, 7 | ContractController 8 | } from '.' 9 | 10 | export type Config = { 11 | apiPort: number 12 | contractDirectory: string 13 | contractRepository: ContractRepository 14 | engineClients: Map 15 | pubSubClient: PubSubEngine 16 | } 17 | 18 | export function Server (config: Config) { 19 | const { contractRepository, engineClients, pubSubClient, contractDirectory } = config 20 | const contractController = ContractController({ 21 | contractDirectory, 22 | contractRepository, 23 | engineClients, 24 | pubSubClient 25 | }) 26 | let api = Api({ 27 | contractController, 28 | contractRepository, 29 | pubSubClient 30 | }) 31 | let apiServer: http.Server 32 | return { 33 | preloadContracts (): Promise { 34 | return contractController.loadAll() 35 | }, 36 | async boot (): Promise { 37 | apiServer = await api.app.listen({ port: config.apiPort }) 38 | api.apolloServer.installSubscriptionHandlers(apiServer) 39 | }, 40 | async shutdown (): Promise { 41 | await Promise.all([ 42 | httpEventPromiseHandler.close(apiServer) 43 | ]) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/server/application/errors/ContractNotLoaded.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error' 2 | 3 | export class ContractNotLoaded extends CustomError { 4 | public constructor () { 5 | super() 6 | this.message = `Contract not loaded` 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/server/application/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { ContractNotLoaded } from './ContractNotLoaded' 2 | -------------------------------------------------------------------------------- /src/server/application/index.ts: -------------------------------------------------------------------------------- 1 | export { Api } from './Api' 2 | export { ContractController } from './ContractController' 3 | export { Server } from './Server' 4 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { Contract, Engine, OperationMode } from '../core' 3 | import { InMemoryRepository } from '../lib' 4 | import { Server } from './application' 5 | import { 6 | PlutusEngineClient, 7 | MemoryPubSubClient, 8 | RedisPubSubClient 9 | } from './infrastructure' 10 | 11 | const { 12 | API_PORT, 13 | EXECUTION_SERVICE_URI, 14 | WALLET_SERVICE_URI, 15 | OPERATION_MODE, 16 | REDIS_HOST, 17 | REDIS_PORT, 18 | CONTRACT_DIRECTORY, 19 | MAX_CONTRACT_SIZE 20 | } = process.env 21 | 22 | if ( 23 | !API_PORT || 24 | !EXECUTION_SERVICE_URI || 25 | !WALLET_SERVICE_URI || 26 | !OPERATION_MODE || 27 | !CONTRACT_DIRECTORY 28 | ) { 29 | throw new Error('Required ENVs not set') 30 | } 31 | 32 | // Default to allow ~200mb contract images 33 | const networkInterface = axios.create({ maxContentLength: Number(MAX_CONTRACT_SIZE) || 200000000 }) 34 | 35 | const server = Server({ 36 | apiPort: Number(API_PORT), 37 | contractDirectory: CONTRACT_DIRECTORY, 38 | contractRepository: InMemoryRepository(), 39 | engineClients: new Map([[ 40 | Engine.plutus, 41 | PlutusEngineClient({ 42 | executionEndpoint: EXECUTION_SERVICE_URI, 43 | networkInterface 44 | }) 45 | ]]), 46 | pubSubClient: OPERATION_MODE === OperationMode.distributed 47 | ? RedisPubSubClient({ host: REDIS_HOST, port: parseInt(REDIS_PORT) }) 48 | : MemoryPubSubClient() 49 | }) 50 | 51 | server.boot() 52 | .then(() => console.log(`Server booted. GraphQL Playground at http://localhost:${API_PORT}/graphql`)) 53 | .then(() => server.preloadContracts()) 54 | .then(() => console.log('Contracts preloaded')) 55 | .catch((error) => console.error(error.message)) 56 | -------------------------------------------------------------------------------- /src/server/infrastructure/engine_clients/StubEngineClient.ts: -------------------------------------------------------------------------------- 1 | import { Contract, ContractCallInstruction, EngineClient } from '../../../core' 2 | 3 | export function StubEngineClient (): EngineClient { 4 | return { 5 | name: 'stub', 6 | async loadExecutable ({ contractAddress }) { 7 | return Promise.resolve({ contractAddress, description: '' }) 8 | }, 9 | async unloadExecutable (contractAddress: Contract['address']) { 10 | return Promise.resolve(contractAddress) 11 | }, 12 | call ({ contractAddress, method, methodArguments }: ContractCallInstruction) { 13 | if (method === 'schema') { 14 | return { 15 | data: { 16 | data: ` 17 | const addArgs = t.type({ 18 | number1: t.number, 19 | number2: t.number, 20 | }) 21 | 22 | export const Add = createEndpoint('Add', addArgs, t.number) 23 | ` 24 | } 25 | } 26 | } 27 | 28 | return { contractAddress, method, methodArguments } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/server/infrastructure/engine_clients/index.ts: -------------------------------------------------------------------------------- 1 | export { StubEngineClient } from './StubEngineClient' 2 | export { PlutusEngineClient } from './plutus/PlutusEngineClient' 3 | -------------------------------------------------------------------------------- /src/server/infrastructure/engine_clients/plutus/PlutusEngineClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, use } from 'chai' 2 | import axios from 'axios' 3 | import * as chaiAsPromised from 'chai-as-promised' 4 | import { PlutusEngineClient } from './PlutusEngineClient' 5 | import { testContracts } from '../../../../lib/test' 6 | const nock = require('nock') 7 | 8 | use(chaiAsPromised) 9 | 10 | describe('PlutusEngineClient', () => { 11 | let engine: ReturnType 12 | const executionEndpoint = 'http://execution' 13 | const testContract = testContracts[0] 14 | 15 | beforeEach(async () => { 16 | engine = await PlutusEngineClient({ 17 | executionEndpoint, 18 | networkInterface: axios.create() 19 | }) 20 | 21 | nock(executionEndpoint) 22 | .post('/loadSmartContract') 23 | .reply(204) 24 | 25 | nock(executionEndpoint) 26 | .post('/unloadSmartContract') 27 | .reply(204) 28 | 29 | nock(executionEndpoint) 30 | .post(`/execute/${testContract.address}/add`) 31 | .reply(201) 32 | }) 33 | 34 | afterEach(() => nock.cleanAll()) 35 | 36 | describe('loadExecutable', () => { 37 | it('calls the execution service HTTP API with the executable', async () => { 38 | const { address: contractAddress, bundle: { executable } } = testContract 39 | const load = await engine.loadExecutable({ contractAddress, executable }) 40 | expect(load.status).to.eq(204) 41 | }) 42 | }) 43 | describe('unloadExecutable', () => { 44 | it('calls the execution service HTTP API with the contract address', async () => { 45 | const unload = await engine.unloadExecutable(testContract.address) 46 | expect(unload.status).to.eq(204) 47 | }) 48 | }) 49 | describe('call', () => { 50 | it('calls the execution service API with the method arguments', async () => { 51 | const response = await engine.call({ 52 | contractAddress: testContract.address, 53 | method: 'add', 54 | methodArguments: { number1: 5, number2: 10 } 55 | }) 56 | expect(response.status).to.eq(201) 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /src/server/infrastructure/engine_clients/plutus/PlutusEngineClient.ts: -------------------------------------------------------------------------------- 1 | import { EngineClient } from '../../../../core' 2 | import { NetworkInterface } from '../../../../lib' 3 | import { RetryPromise } from 'promise-exponential-retry' 4 | 5 | type Config = { 6 | executionEndpoint: string, 7 | networkInterface: NetworkInterface, 8 | } 9 | 10 | export function PlutusEngineClient (config: Config): EngineClient { 11 | const { executionEndpoint, networkInterface } = config 12 | return { 13 | name: 'plutus', 14 | // As we load the contracts at boot time, the execution service 15 | // may not yet be available, so we allow a connection buffer here 16 | async loadExecutable ({ contractAddress, executable }) { 17 | return RetryPromise.retryPromise('loadContract', () => { 18 | return networkInterface.post(`${executionEndpoint}/loadSmartContract`, 19 | { contractAddress, executable: executable.toString('base64') } 20 | ) 21 | }, 10) 22 | }, 23 | async unloadExecutable (contractAddress) { 24 | return networkInterface.post(`${executionEndpoint}/unloadSmartContract`, { contractAddress }) 25 | }, 26 | async call ({ contractAddress, method, methodArguments }) { 27 | return networkInterface.post(`${executionEndpoint}/execute/${contractAddress}/${method}`, methodArguments) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/server/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './engine_clients' 2 | export * from './pubsub_clients' 3 | -------------------------------------------------------------------------------- /src/server/infrastructure/pubsub_clients/MemoryPubSubClient.ts: -------------------------------------------------------------------------------- 1 | import { PubSub } from 'graphql-subscriptions' 2 | import { PubSubEngine } from 'apollo-server' 3 | 4 | let memoryClient: PubSubEngine 5 | 6 | export function MemoryPubSubClient (): PubSubEngine { 7 | if (memoryClient) { 8 | return memoryClient 9 | } 10 | 11 | return new PubSub() 12 | } 13 | -------------------------------------------------------------------------------- /src/server/infrastructure/pubsub_clients/RedisPubSubClient.ts: -------------------------------------------------------------------------------- 1 | import { RedisPubSub } from 'graphql-redis-subscriptions' 2 | import * as Redis from 'ioredis' 3 | import { PubSubEngine } from 'apollo-server' 4 | 5 | export function RedisPubSubClient (options: Redis.RedisOptions): PubSubEngine { 6 | return new RedisPubSub({ 7 | publisher: new Redis(options), 8 | subscriber: new Redis(options) 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/server/infrastructure/pubsub_clients/index.ts: -------------------------------------------------------------------------------- 1 | export { RedisPubSubClient } from './RedisPubSubClient' 2 | export { MemoryPubSubClient } from './MemoryPubSubClient' 3 | -------------------------------------------------------------------------------- /src/single_process.ts: -------------------------------------------------------------------------------- 1 | // This entrypoint is for operation in a single process when distributed 2 | // to client machines 3 | const { 4 | API_PORT, 5 | WALLET_SERVICE_URI, 6 | EXECUTION_SERVICE_URI, 7 | CONTRACT_SERVER_LOWER_PORT_BOUND, 8 | CONTRACT_SERVER_UPPER_PORT_BOUND, 9 | EXECUTION_API_PORT, 10 | CONTAINER_LOWER_PORT_BOUND, 11 | CONTAINER_UPPER_PORT_BOUND, 12 | EXECUTION_ENGINE, 13 | OPERATION_MODE, 14 | CONTRACT_DIRECTORY 15 | } = process.env 16 | 17 | // Server ENVs 18 | process.env.API_PORT = API_PORT || '8081' 19 | process.env.WALLET_SERVICE_URI = WALLET_SERVICE_URI || 'http://localhost:0000' 20 | process.env.EXECUTION_SERVICE_URI = EXECUTION_SERVICE_URI || 'http://localhost:9000' 21 | process.env.CONTRACT_SERVER_LOWER_PORT_BOUND = CONTRACT_SERVER_LOWER_PORT_BOUND || '8082' 22 | process.env.CONTRACT_SERVER_UPPER_PORT_BOUND = CONTRACT_SERVER_UPPER_PORT_BOUND || '8900' 23 | process.env.OPERATION_MODE = OPERATION_MODE || 'singleProcess' 24 | process.env.CONTRACT_DIRECTORY = CONTRACT_DIRECTORY || 'test/bundles/nodejs' 25 | 26 | // Execution Engine ENVs 27 | process.env.EXECUTION_API_PORT = EXECUTION_API_PORT || '9000' 28 | process.env.CONTAINER_LOWER_PORT_BOUND = CONTAINER_LOWER_PORT_BOUND || '11000' 29 | process.env.CONTAINER_UPPER_PORT_BOUND = CONTAINER_UPPER_PORT_BOUND || '12000' 30 | process.env.EXECUTION_ENGINE = EXECUTION_ENGINE || 'nodejs' 31 | 32 | require('./execution_service') 33 | require('./server') 34 | -------------------------------------------------------------------------------- /src/test/e2e/steps/contract_interactions.ts: -------------------------------------------------------------------------------- 1 | import { When, Then, Given } from 'cucumber' 2 | import { World } from '../support/world' 3 | import { expect } from 'chai' 4 | 5 | When('I subscribe by public key {string}', function (publicKey: string) { 6 | const world = this as World 7 | return world.client.connect(publicKey) 8 | }) 9 | 10 | When('I call the contract {string} with the method {string}, arguments {string} and public key {string}', function (contractAddress: string, method: string, methodArguments: string, originatorPk: string) { 11 | const world = this as World 12 | return world.client.callContract({ 13 | originatorPk, 14 | contractAddress, 15 | method, 16 | methodArguments 17 | }) 18 | }) 19 | 20 | Given('the contract is not loaded, calling contract {string} with the method {string} and arguments {string} throws an error', async function (contractAddress: string, method: string, methodArgs: string) { 21 | const world = this as World 22 | try { 23 | await world.client.callContract({ 24 | contractAddress, 25 | method, 26 | methodArguments: methodArgs 27 | }) 28 | throw new Error('Invalid error') 29 | } catch (e) { 30 | expect(e.message).to.eql('GraphQL error: Contract not loaded. Call /load and then try again') 31 | } 32 | }) 33 | 34 | Then('{string} should receive a signing request', async function (publicKey: string) { 35 | const world = this as World 36 | const transactionReceived = await world.validateTransactionReceived(publicKey, 1) 37 | expect(transactionReceived).to.eql(true) 38 | }) 39 | 40 | Then('{string} should not receive a signing request', async function (publicKey: string) { 41 | const world = this as World 42 | const transactionReceived = await world.validateTransactionReceived(publicKey, 1) 43 | expect(transactionReceived).to.eql(false) 44 | }) 45 | -------------------------------------------------------------------------------- /src/test/e2e/steps/contract_loading.ts: -------------------------------------------------------------------------------- 1 | import { World } from '../support/world' 2 | import { When } from 'cucumber' 3 | 4 | When('I load a contract by address {string}', { timeout: 30000 }, function (address: string) { 5 | const world = this as World 6 | return world.client.loadContract(address) 7 | }) 8 | -------------------------------------------------------------------------------- /src/test/e2e/steps/list_contracts.ts: -------------------------------------------------------------------------------- 1 | import { World } from '../support/world' 2 | import { Then } from 'cucumber' 3 | import { expect } from 'chai' 4 | 5 | Then('the contract {string} is listed once by the static contract endpoint', async function (contractAddress: string) { 6 | const world = this as World 7 | const contracts = await world.client.contracts() 8 | const targetContracts = contracts.filter((contract: { description: string, contractAddress: string }) => contract.contractAddress === contractAddress) 9 | expect(targetContracts.length).to.eql(1) 10 | }) 11 | -------------------------------------------------------------------------------- /src/test/e2e/support/hooks.ts: -------------------------------------------------------------------------------- 1 | import { Before, After } from 'cucumber' 2 | import { World } from './world' 3 | import axios from 'axios' 4 | 5 | Before({ timeout: 40000 }, async function () { 6 | const { APPLICATION_URI, WS_URI } = process.env 7 | if (!APPLICATION_URI || !WS_URI) throw new Error('Missing environment') 8 | 9 | let health = true 10 | const healthTimeout = setTimeout(() => { 11 | health = false 12 | throw new Error('Could not get healthy connection to platform') 13 | }, 30000) 14 | 15 | async function healthCheck (): Promise { 16 | try { 17 | await axios.get(`${APPLICATION_URI}/.well-known/apollo/server-health`) 18 | clearTimeout(healthTimeout) 19 | } catch (e) { 20 | console.log(e) 21 | await new Promise(resolve => setTimeout(resolve, 5000)) 22 | if (health) { 23 | return healthCheck() 24 | } 25 | } 26 | } 27 | 28 | await healthCheck() 29 | }) 30 | 31 | After(async function () { 32 | const world = this as World 33 | world.client.disconnect() 34 | 35 | const contracts = await world.client.contracts() 36 | await Promise.all(contracts.map((c: any) => world.client.unloadContract(c.contractAddress))) 37 | }) 38 | -------------------------------------------------------------------------------- /src/test/e2e/support/world.ts: -------------------------------------------------------------------------------- 1 | import { setWorldConstructor } from 'cucumber' 2 | import { Client } from '../../../client' 3 | 4 | export class World { 5 | client: ReturnType 6 | public receivedTransactions: { [publicKey: string]: string[] } = {} 7 | 8 | constructor () { 9 | const { APPLICATION_URI, WS_URI } = process.env 10 | 11 | this.client = Client({ 12 | apiUri: APPLICATION_URI, 13 | subscriptionUri: WS_URI, 14 | transactionHandler: (transaction: string, publicKey: string) => { 15 | const ctx = this 16 | let accessor = ctx.receivedTransactions[publicKey] 17 | if (accessor) { 18 | accessor.push(transaction) 19 | } else { 20 | ctx.receivedTransactions[publicKey] = [transaction] 21 | } 22 | } 23 | }) 24 | } 25 | 26 | async validateTransactionReceived (publicKey: string, attempts: number): Promise { 27 | const accessor = this.receivedTransactions[publicKey] 28 | if (!accessor) { 29 | if (attempts > 3) { 30 | return false 31 | } 32 | 33 | await new Promise(resolve => setTimeout(resolve, 200)) 34 | return this.validateTransactionReceived(publicKey, ++attempts) 35 | } 36 | 37 | if (accessor.length) { 38 | return true 39 | } 40 | } 41 | } 42 | 43 | setWorldConstructor(World) 44 | -------------------------------------------------------------------------------- /test/bundles/create_docker_tar/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.16-alpine 2 | 3 | RUN mkdir /application 4 | COPY index.js /application 5 | COPY package.json /application 6 | 7 | WORKDIR /application 8 | RUN npm i --production 9 | 10 | CMD ["node", "index.js"] -------------------------------------------------------------------------------- /test/bundles/create_docker_tar/README.md: -------------------------------------------------------------------------------- 1 | Manually edit the contents in index.js to create the required mock container, then run `npm run docker-tar` 2 | 3 | Copy the output.tar.gz to the required location -------------------------------------------------------------------------------- /test/bundles/create_docker_tar/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const bodyParser = require('body-parser') 3 | const app = express() 4 | const port = 8080 5 | 6 | app.use(bodyParser.json()) 7 | app.post('/add', (req, res) => res.json({ data: req.body.number1 + req.body.number2 })) 8 | app.get('/schema', (req, res) => { 9 | const schema = ` 10 | const addArgs = t.type({ 11 | number1: t.number, 12 | number2: t.number, 13 | }) 14 | 15 | export const add = createEndpoint('add', addArgs, t.number) 16 | ` 17 | 18 | return res.json({ data: schema }) 19 | }) 20 | 21 | app.listen(port, () => console.log(`Mock contract listening on port ${port}!`)) 22 | -------------------------------------------------------------------------------- /test/bundles/create_docker_tar/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create_docker_tar", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "accepts": { 8 | "version": "1.3.7", 9 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", 10 | "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", 11 | "requires": { 12 | "mime-types": "~2.1.24", 13 | "negotiator": "0.6.2" 14 | } 15 | }, 16 | "array-flatten": { 17 | "version": "1.1.1", 18 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 19 | "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" 20 | }, 21 | "body-parser": { 22 | "version": "1.19.0", 23 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", 24 | "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", 25 | "requires": { 26 | "bytes": "3.1.0", 27 | "content-type": "~1.0.4", 28 | "debug": "2.6.9", 29 | "depd": "~1.1.2", 30 | "http-errors": "1.7.2", 31 | "iconv-lite": "0.4.24", 32 | "on-finished": "~2.3.0", 33 | "qs": "6.7.0", 34 | "raw-body": "2.4.0", 35 | "type-is": "~1.6.17" 36 | } 37 | }, 38 | "bytes": { 39 | "version": "3.1.0", 40 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", 41 | "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" 42 | }, 43 | "content-disposition": { 44 | "version": "0.5.3", 45 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", 46 | "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", 47 | "requires": { 48 | "safe-buffer": "5.1.2" 49 | } 50 | }, 51 | "content-type": { 52 | "version": "1.0.4", 53 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", 54 | "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" 55 | }, 56 | "cookie": { 57 | "version": "0.4.0", 58 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", 59 | "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" 60 | }, 61 | "cookie-signature": { 62 | "version": "1.0.6", 63 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 64 | "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" 65 | }, 66 | "debug": { 67 | "version": "2.6.9", 68 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 69 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 70 | "requires": { 71 | "ms": "2.0.0" 72 | } 73 | }, 74 | "depd": { 75 | "version": "1.1.2", 76 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", 77 | "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" 78 | }, 79 | "destroy": { 80 | "version": "1.0.4", 81 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", 82 | "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" 83 | }, 84 | "ee-first": { 85 | "version": "1.1.1", 86 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", 87 | "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" 88 | }, 89 | "encodeurl": { 90 | "version": "1.0.2", 91 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 92 | "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" 93 | }, 94 | "escape-html": { 95 | "version": "1.0.3", 96 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", 97 | "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" 98 | }, 99 | "etag": { 100 | "version": "1.8.1", 101 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", 102 | "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" 103 | }, 104 | "express": { 105 | "version": "4.17.1", 106 | "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", 107 | "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", 108 | "requires": { 109 | "accepts": "~1.3.7", 110 | "array-flatten": "1.1.1", 111 | "body-parser": "1.19.0", 112 | "content-disposition": "0.5.3", 113 | "content-type": "~1.0.4", 114 | "cookie": "0.4.0", 115 | "cookie-signature": "1.0.6", 116 | "debug": "2.6.9", 117 | "depd": "~1.1.2", 118 | "encodeurl": "~1.0.2", 119 | "escape-html": "~1.0.3", 120 | "etag": "~1.8.1", 121 | "finalhandler": "~1.1.2", 122 | "fresh": "0.5.2", 123 | "merge-descriptors": "1.0.1", 124 | "methods": "~1.1.2", 125 | "on-finished": "~2.3.0", 126 | "parseurl": "~1.3.3", 127 | "path-to-regexp": "0.1.7", 128 | "proxy-addr": "~2.0.5", 129 | "qs": "6.7.0", 130 | "range-parser": "~1.2.1", 131 | "safe-buffer": "5.1.2", 132 | "send": "0.17.1", 133 | "serve-static": "1.14.1", 134 | "setprototypeof": "1.1.1", 135 | "statuses": "~1.5.0", 136 | "type-is": "~1.6.18", 137 | "utils-merge": "1.0.1", 138 | "vary": "~1.1.2" 139 | } 140 | }, 141 | "finalhandler": { 142 | "version": "1.1.2", 143 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", 144 | "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", 145 | "requires": { 146 | "debug": "2.6.9", 147 | "encodeurl": "~1.0.2", 148 | "escape-html": "~1.0.3", 149 | "on-finished": "~2.3.0", 150 | "parseurl": "~1.3.3", 151 | "statuses": "~1.5.0", 152 | "unpipe": "~1.0.0" 153 | } 154 | }, 155 | "forwarded": { 156 | "version": "0.1.2", 157 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", 158 | "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" 159 | }, 160 | "fresh": { 161 | "version": "0.5.2", 162 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 163 | "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" 164 | }, 165 | "http-errors": { 166 | "version": "1.7.2", 167 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", 168 | "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", 169 | "requires": { 170 | "depd": "~1.1.2", 171 | "inherits": "2.0.3", 172 | "setprototypeof": "1.1.1", 173 | "statuses": ">= 1.5.0 < 2", 174 | "toidentifier": "1.0.0" 175 | } 176 | }, 177 | "iconv-lite": { 178 | "version": "0.4.24", 179 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 180 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 181 | "requires": { 182 | "safer-buffer": ">= 2.1.2 < 3" 183 | } 184 | }, 185 | "inherits": { 186 | "version": "2.0.3", 187 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", 188 | "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" 189 | }, 190 | "ipaddr.js": { 191 | "version": "1.9.0", 192 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", 193 | "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==" 194 | }, 195 | "media-typer": { 196 | "version": "0.3.0", 197 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", 198 | "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" 199 | }, 200 | "merge-descriptors": { 201 | "version": "1.0.1", 202 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", 203 | "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" 204 | }, 205 | "methods": { 206 | "version": "1.1.2", 207 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 208 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" 209 | }, 210 | "mime": { 211 | "version": "1.6.0", 212 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 213 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" 214 | }, 215 | "mime-db": { 216 | "version": "1.40.0", 217 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", 218 | "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" 219 | }, 220 | "mime-types": { 221 | "version": "2.1.24", 222 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", 223 | "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", 224 | "requires": { 225 | "mime-db": "1.40.0" 226 | } 227 | }, 228 | "ms": { 229 | "version": "2.0.0", 230 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 231 | "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" 232 | }, 233 | "negotiator": { 234 | "version": "0.6.2", 235 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", 236 | "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" 237 | }, 238 | "on-finished": { 239 | "version": "2.3.0", 240 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", 241 | "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", 242 | "requires": { 243 | "ee-first": "1.1.1" 244 | } 245 | }, 246 | "parseurl": { 247 | "version": "1.3.3", 248 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", 249 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 250 | }, 251 | "path-to-regexp": { 252 | "version": "0.1.7", 253 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", 254 | "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" 255 | }, 256 | "proxy-addr": { 257 | "version": "2.0.5", 258 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", 259 | "integrity": "sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==", 260 | "requires": { 261 | "forwarded": "~0.1.2", 262 | "ipaddr.js": "1.9.0" 263 | } 264 | }, 265 | "qs": { 266 | "version": "6.7.0", 267 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", 268 | "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" 269 | }, 270 | "range-parser": { 271 | "version": "1.2.1", 272 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", 273 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 274 | }, 275 | "raw-body": { 276 | "version": "2.4.0", 277 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", 278 | "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", 279 | "requires": { 280 | "bytes": "3.1.0", 281 | "http-errors": "1.7.2", 282 | "iconv-lite": "0.4.24", 283 | "unpipe": "1.0.0" 284 | } 285 | }, 286 | "safe-buffer": { 287 | "version": "5.1.2", 288 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 289 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 290 | }, 291 | "safer-buffer": { 292 | "version": "2.1.2", 293 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 294 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 295 | }, 296 | "send": { 297 | "version": "0.17.1", 298 | "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", 299 | "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", 300 | "requires": { 301 | "debug": "2.6.9", 302 | "depd": "~1.1.2", 303 | "destroy": "~1.0.4", 304 | "encodeurl": "~1.0.2", 305 | "escape-html": "~1.0.3", 306 | "etag": "~1.8.1", 307 | "fresh": "0.5.2", 308 | "http-errors": "~1.7.2", 309 | "mime": "1.6.0", 310 | "ms": "2.1.1", 311 | "on-finished": "~2.3.0", 312 | "range-parser": "~1.2.1", 313 | "statuses": "~1.5.0" 314 | }, 315 | "dependencies": { 316 | "ms": { 317 | "version": "2.1.1", 318 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", 319 | "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" 320 | } 321 | } 322 | }, 323 | "serve-static": { 324 | "version": "1.14.1", 325 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", 326 | "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", 327 | "requires": { 328 | "encodeurl": "~1.0.2", 329 | "escape-html": "~1.0.3", 330 | "parseurl": "~1.3.3", 331 | "send": "0.17.1" 332 | } 333 | }, 334 | "setprototypeof": { 335 | "version": "1.1.1", 336 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", 337 | "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" 338 | }, 339 | "statuses": { 340 | "version": "1.5.0", 341 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", 342 | "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" 343 | }, 344 | "toidentifier": { 345 | "version": "1.0.0", 346 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", 347 | "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" 348 | }, 349 | "type-is": { 350 | "version": "1.6.18", 351 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", 352 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", 353 | "requires": { 354 | "media-typer": "0.3.0", 355 | "mime-types": "~2.1.24" 356 | } 357 | }, 358 | "unpipe": { 359 | "version": "1.0.0", 360 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", 361 | "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" 362 | }, 363 | "utils-merge": { 364 | "version": "1.0.1", 365 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 366 | "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" 367 | }, 368 | "vary": { 369 | "version": "1.1.2", 370 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", 371 | "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" 372 | } 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /test/bundles/create_docker_tar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create_docker_tar", 3 | "version": "1.0.0", 4 | "description": "Manually edit the contents in index.js to create the required mock container, then run `npm run docker-tar`", 5 | "main": "index.js", 6 | "scripts": { 7 | "docker-tar": "docker build -t mock-contract . && docker save mock-contract | gzip > output.tar.gz" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "body-parser": "^1.19.0", 13 | "express": "^4.17.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/bundles/docker/abcd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/smart-contract-backend/ccef4dba46db20add0010c4267345c8e726fd0bd/test/bundles/docker/abcd -------------------------------------------------------------------------------- /test/bundles/docker/plutusGuessingGame: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/input-output-hk/smart-contract-backend/ccef4dba46db20add0010c4267345c8e726fd0bd/test/bundles/docker/plutusGuessingGame -------------------------------------------------------------------------------- /test/bundles/nodejs/abcd: -------------------------------------------------------------------------------- 1 | { 2 | add: (args) => args.number1 + args.number2, 3 | schema: () => { 4 | return ` 5 | const addArgs = t.type({ 6 | number1: t.number, 7 | number2: t.number, 8 | }) 9 | 10 | export const add = createEndpoint('add', addArgs, t.number) 11 | ` 12 | } 13 | } -------------------------------------------------------------------------------- /test/bundles/nodejs/plutusGuessingGame: -------------------------------------------------------------------------------- 1 | { 2 | initialise: () => true, 3 | lock: () => true, 4 | schema: () => { 5 | return ` 6 | export const Lock = createEndpoint('Lock', t.null, t.null) 7 | export const Initialise = createEndpoint('Initialise', t.null, t.null) 8 | ` 9 | } 10 | } -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --require source-map-support/register 3 | --watch-extensions ts 4 | --timeout 60000 5 | --recursive 6 | --exit 7 | src/**/*.spec.ts -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2015", 5 | "noEmitOnError": true, 6 | "noImplicitAny": true, 7 | "noUnusedParameters": true, 8 | "noUnusedLocals": true, 9 | "removeComments": true, 10 | "preserveConstEnums": true, 11 | "outDir": "dist/", 12 | "sourceMap": true, 13 | "rootDir": "src", 14 | "allowSyntheticDefaultImports": true, 15 | "experimentalDecorators": true 16 | }, 17 | "include": [ 18 | "src/**/*" 19 | ], 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } -------------------------------------------------------------------------------- /tsoa.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": { 3 | "outputDirectory": "./dist", 4 | "entryFile": "./src/execution_service/index.ts", 5 | "basePath": "/" 6 | }, 7 | "routes": { 8 | "basePath": "/", 9 | "entryFile": "./src/execution_service/application/Api.ts", 10 | "routesDir": "./src/execution_service" 11 | }, 12 | "compilerOptions": { 13 | "baseUrl": "src" 14 | } 15 | } -------------------------------------------------------------------------------- /wallaby.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function () { 2 | return { 3 | files: [ 4 | 'src/**/*.ts', 5 | '!src/**/*.spec.ts', 6 | 'test/**/*.js', 7 | 'test/**/*.tar.gz' 8 | ], 9 | 10 | tests: [ 11 | 'src/**/*.spec.ts' 12 | ], 13 | env: { 14 | type: 'node' 15 | }, 16 | testFramework: 'mocha', 17 | setup: function (wallaby) { 18 | wallaby.testFramework.timeout(60000) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.ts?$/, 8 | use: 'ts-loader', 9 | exclude: /node_modules/ 10 | } 11 | ] 12 | }, 13 | resolve: { 14 | extensions: [ '.ts', '.ts', '.js' ] 15 | }, 16 | output: { 17 | filename: process.env.OUTPUT, 18 | path: path.resolve(__dirname, 'contract_dist') 19 | } 20 | } --------------------------------------------------------------------------------