diff --git a/.babel.config.js b/.babel.config.js index de6aae5ea..422df09ab 100644 --- a/.babel.config.js +++ b/.babel.config.js @@ -18,7 +18,8 @@ module.exports = { ] }, exclude: ['transform-regenerator', '@babel/plugin-transform-regenerator'] - }] + }], + ['@babel/preset-typescript'] ], plugins: [ "add-module-exports", diff --git a/.babel.node.config.js b/.babel.node.config.js index 6af312e3b..245b492cd 100644 --- a/.babel.node.config.js +++ b/.babel.node.config.js @@ -10,7 +10,8 @@ module.exports = { targets: { node: true } - }] + }], + ['@babel/preset-typescript'] ], plugins: [ 'add-module-exports', diff --git a/.eslintrc.js b/.eslintrc.js index fdfe91128..543def847 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,8 @@ module.exports = { - parser: 'babel-eslint', + parser: '@typescript-eslint/parser', + plugins: [ + '@typescript-eslint' + ], extends: [ 'streamr-nodejs' ], @@ -31,5 +34,21 @@ module.exports = { 'no-restricted-syntax': [ 'error', 'ForInStatement', 'LabeledStatement', 'WithStatement' ], + 'import/extensions': ['error', 'never', { json: 'always' }], + 'lines-between-class-members': 'off', + 'padded-blocks': 'off', + 'no-use-before-define': 'off', + 'import/order': 'off', + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error'] + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.ts'] + } + } } } diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index cda454975..5051e46c5 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -157,9 +157,14 @@ jobs: uses: streamr-dev/streamr-docker-dev-action@v1.0.0-alpha.2 with: services-to-start: "mysql redis engine-and-editor cassandra parity-node0 parity-sidechain-node0 bridge broker-node-storage-1 nginx smtp" - - name: test-browser - timeout-minutes: 2 - run: npm run test-browser + + - uses: nick-invision/retry@v2 + name: Run Test + with: + max_attempts: 3 + timeout_minutes: 3 + retry_on: error + command: npm run test-browser benchmarks: name: Test Benchmark using Node ${{ matrix.node-version }} diff --git a/jest.config.js b/jest.config.js index 7ec99a872..1086a1106 100644 --- a/jest.config.js +++ b/jest.config.js @@ -163,7 +163,7 @@ module.exports = { // A map from regular expressions to paths to transformers transform: { - '\\.js$': ['babel-jest', { + '\\.(js|ts)$': ['babel-jest', { configFile: path.resolve(__dirname, '.babel.node.config.js'), babelrc: false, }] diff --git a/package-lock.json b/package-lock.json index ce22166ce..59d71601e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -585,6 +585,23 @@ "@babel/helper-plugin-utils": "^7.10.4" } }, + "@babel/plugin-syntax-typescript": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.12.13.tgz", + "integrity": "sha512-cHP3u1JiUiG2LFDKbXnwVad81GvfyIOmCD6HIEId6ojrY0Drfy2q1jw7BwN7dE84+kTnBjLkXoL3IEy/3JPu2w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.12.13.tgz", + "integrity": "sha512-C+10MXCXJLiR6IeG9+Wiejt9jmtFpxUc3MQqCmPY8hfCjyUGl9kT+B2okzEZrtykiwrc4dbCPdDoz0A/HQbDaA==", + "dev": true + } + } + }, "@babel/plugin-transform-arrow-functions": { "version": "7.12.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz", @@ -890,6 +907,173 @@ "@babel/helper-plugin-utils": "^7.10.4" } }, + "@babel/plugin-transform-typescript": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.12.13.tgz", + "integrity": "sha512-z1VWskPJxK9tfxoYvePWvzSJC+4pxXr8ArmRm5ofqgi+mwpKg6lvtomkIngBYMJVnKhsFYVysCQLDn//v2RHcg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-typescript": "^7.12.13" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz", + "integrity": "sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.12.13" + } + }, + "@babel/generator": { + "version": "7.12.15", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.12.15.tgz", + "integrity": "sha512-6F2xHxBiFXWNSGb7vyCUTBF8RCLY66rS0zEPcP8t/nQyXjha5EuK4z7H5o7fWG8B4M7y6mqVWq1J+1PuwRhecQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.13.tgz", + "integrity": "sha512-Vs/e9wv7rakKYeywsmEBSRC9KtmE7Px+YBlESekLeJOF0zbGUicGfXSNi3o+tfXSNS48U/7K9mIOOCR79Cl3+Q==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-member-expression-to-functions": "^7.12.13", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/helper-replace-supers": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13" + } + }, + "@babel/helper-function-name": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz", + "integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.12.13", + "@babel/template": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz", + "integrity": "sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.13.tgz", + "integrity": "sha512-B+7nN0gIL8FZ8SvMcF+EPyB21KnCcZHQZFczCxbiNGV/O0rsrSBlWGLzmtBJ3GMjSVMIm4lpFhR+VdVBuIsUcQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz", + "integrity": "sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.12.13.tgz", + "integrity": "sha512-C+10MXCXJLiR6IeG9+Wiejt9jmtFpxUc3MQqCmPY8hfCjyUGl9kT+B2okzEZrtykiwrc4dbCPdDoz0A/HQbDaA==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.12.13.tgz", + "integrity": "sha512-pctAOIAMVStI2TMLhozPKbf5yTEXc0OJa0eENheb4w09SrgOWEs+P4nTOZYJQCqs8JlErGLDPDJTiGIp3ygbLg==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.12.13", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/traverse": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz", + "integrity": "sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/highlight": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.12.13.tgz", + "integrity": "sha512-kocDQvIbgMKlWxXe9fof3TQ+gkIPOUSEYhJjqUjvKMez3krV7vbzYCDq39Oj11UAVK7JqPVGQPlgE85dPNlQww==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.12.15", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.15.tgz", + "integrity": "sha512-AQBOU2Z9kWwSZMd6lNjCX0GUgFonL1wAM1db8L8PMk9UDaGsRCArBkU4Sc+UCM3AE4hjbXx+h58Lb3QT4oRmrA==", + "dev": true + }, + "@babel/template": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz", + "integrity": "sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/parser": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/traverse": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.13.tgz", + "integrity": "sha512-3Zb4w7eE/OslI0fTp8c7b286/cQps3+vdLW3UcwC8VSJC6GbKn55aeVVu2QJNuCDoeKyptLOFrPq8WqZZBodyA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.12.13", + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13", + "@babel/parser": "^7.12.13", + "@babel/types": "^7.12.13", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.19" + } + }, + "@babel/types": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.13.tgz", + "integrity": "sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + } + } + }, "@babel/plugin-transform-unicode-escapes": { "version": "7.12.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz", @@ -996,6 +1180,25 @@ "esutils": "^2.0.2" } }, + "@babel/preset-typescript": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.12.13.tgz", + "integrity": "sha512-gYry7CeXwD2wtw5qHzrtzKaShEhOfTmKb4i0ZxeYBcBosN5VuAudsNbjX7Oj5EAfQ3K4s4HsVMQRRcqGsPvs2A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-validator-option": "^7.12.11", + "@babel/plugin-transform-typescript": "^7.12.13" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.12.13.tgz", + "integrity": "sha512-C+10MXCXJLiR6IeG9+Wiejt9jmtFpxUc3MQqCmPY8hfCjyUGl9kT+B2okzEZrtykiwrc4dbCPdDoz0A/HQbDaA==", + "dev": true + } + } + }, "@babel/runtime": { "version": "7.12.5", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", @@ -1042,9 +1245,9 @@ } }, "@babel/types": { - "version": "7.12.12", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.12.tgz", - "integrity": "sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ==", + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.13.tgz", + "integrity": "sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ==", "dev": true, "requires": { "@babel/helper-validator-identifier": "^7.12.11", @@ -2290,6 +2493,12 @@ "@babel/types": "^7.3.0" } }, + "@types/debug": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz", + "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.4.tgz", @@ -2353,6 +2562,12 @@ "integrity": "sha512-6gOkRe7OIioWAXfnO/2lFiv+SJichKVSys1mSsgyrYHSEjk8Ctv4tSR/Odvnu+HWlH2C8j53dahU03XmQdd5fA==", "dev": true }, + "@types/qs": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz", + "integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz", @@ -2384,6 +2599,111 @@ "@types/node": "*" } }, + "@typescript-eslint/eslint-plugin": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.15.1.tgz", + "integrity": "sha512-yW2epMYZSpNJXZy22Biu+fLdTG8Mn6b22kR3TqblVk50HGNV8Zya15WAXuQCr8tKw4Qf1BL4QtI6kv6PCkLoJw==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "4.15.1", + "@typescript-eslint/scope-manager": "4.15.1", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "lodash": "^4.17.15", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "dependencies": { + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@typescript-eslint/experimental-utils": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.1.tgz", + "integrity": "sha512-9LQRmOzBRI1iOdJorr4jEnQhadxK4c9R2aEAsm7WE/7dq8wkKD1suaV0S/JucTL8QlYUPU1y2yjqg+aGC0IQBQ==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/scope-manager": "4.15.1", + "@typescript-eslint/types": "4.15.1", + "@typescript-eslint/typescript-estree": "4.15.1", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + } + }, + "@typescript-eslint/parser": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.15.1.tgz", + "integrity": "sha512-V8eXYxNJ9QmXi5ETDguB7O9diAXlIyS+e3xzLoP/oVE4WCAjssxLIa0mqCLsCGXulYJUfT+GV70Jv1vHsdKwtA==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "4.15.1", + "@typescript-eslint/types": "4.15.1", + "@typescript-eslint/typescript-estree": "4.15.1", + "debug": "^4.1.1" + } + }, + "@typescript-eslint/scope-manager": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.15.1.tgz", + "integrity": "sha512-ibQrTFcAm7yG4C1iwpIYK7vDnFg+fKaZVfvyOm3sNsGAerKfwPVFtYft5EbjzByDJ4dj1WD8/34REJfw/9wdVA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.15.1", + "@typescript-eslint/visitor-keys": "4.15.1" + } + }, + "@typescript-eslint/types": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.15.1.tgz", + "integrity": "sha512-iGsaUyWFyLz0mHfXhX4zO6P7O3sExQpBJ2dgXB0G5g/8PRVfBBsmQIc3r83ranEQTALLR3Vko/fnCIVqmH+mPw==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.1.tgz", + "integrity": "sha512-z8MN3CicTEumrWAEB2e2CcoZa3KP9+SMYLIA2aM49XW3cWIaiVSOAGq30ffR5XHxRirqE90fgLw3e6WmNx5uNw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.15.1", + "@typescript-eslint/visitor-keys": "4.15.1", + "debug": "^4.1.1", + "globby": "^11.0.1", + "is-glob": "^4.0.1", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "dependencies": { + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@typescript-eslint/visitor-keys": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.1.tgz", + "integrity": "sha512-tYzaTP9plooRJY8eNlpAewTOqtWW/4ff/5wBjNVaJ0S0wC4Gpq/zDVRTJa5bq2v1pCNQ08xxMCndcvR+h7lMww==", + "dev": true, + "requires": { + "@typescript-eslint/types": "4.15.1", + "eslint-visitor-keys": "^2.0.0" + } + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -3006,20 +3326,6 @@ } } }, - "babel-eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", - "integrity": "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "@babel/parser": "^7.7.0", - "@babel/traverse": "^7.7.0", - "@babel/types": "^7.7.0", - "eslint-visitor-keys": "^1.0.0", - "resolve": "^1.12.0" - } - }, "babel-helper-function-name": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", @@ -5165,12 +5471,12 @@ } }, "eslint": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.18.0.tgz", - "integrity": "sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ==", + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.20.0.tgz", + "integrity": "sha512-qGi0CTcOGP2OtCQBgWZlQjcTuP0XkIpYFj25XtRTQSHC+umNnp7UMshr2G8SLsRFYDdAPFeHOsiteadmMH02Yw==", "dev": true, "requires": { - "@babel/code-frame": "^7.0.0", + "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.3.0", "ajv": "^6.10.0", "chalk": "^4.0.0", @@ -5182,7 +5488,7 @@ "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^2.0.0", "espree": "^7.3.1", - "esquery": "^1.2.0", + "esquery": "^1.4.0", "esutils": "^2.0.2", "file-entry-cache": "^6.0.0", "functional-red-black-tree": "^1.0.1", @@ -5249,12 +5555,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "eslint-visitor-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", - "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", - "dev": true - }, "glob-parent": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", @@ -5551,12 +5851,20 @@ "dev": true, "requires": { "eslint-visitor-keys": "^1.1.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } } }, "eslint-visitor-keys": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz", + "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", "dev": true }, "espree": { @@ -5568,6 +5876,14 @@ "acorn": "^7.4.0", "acorn-jsx": "^5.3.1", "eslint-visitor-keys": "^1.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true + } } }, "esprima": { @@ -5577,9 +5893,9 @@ "dev": true }, "esquery": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.3.1.tgz", - "integrity": "sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", "dev": true, "requires": { "estraverse": "^5.1.0" @@ -6295,9 +6611,9 @@ } }, "flatted": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.0.tgz", - "integrity": "sha512-tW+UkmtNg/jv9CSofAKvgVcO7c2URjhTdW1ZTkcAritblu8tajiYy7YisnIflEwtKssCtOxpnBRoCB7iap0/TA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", + "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", "dev": true }, "flush-write-stream": { @@ -13052,9 +13368,9 @@ }, "dependencies": { "ajv": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz", - "integrity": "sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.1.0.tgz", + "integrity": "sha512-svS9uILze/cXbH0z2myCK2Brqprx/+JJYK5pHicT/GQiBfzzhUVAIT6MwqJg8y4xV/zoGsUeuPuwtoiKSGE15g==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -13397,6 +13713,23 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, + "tsutils": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.20.0.tgz", + "integrity": "sha512-RYbuQuvkhuqVeXweWT3tJLKOEJ/UUw9GjNEZGWdrLLlM+611o1gwLHBpxoFJKKl25fLprp2eVthtKs5JOrNeXg==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } + } + }, "tty-browserify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", @@ -13464,6 +13797,11 @@ "is-typedarray": "^1.0.0" } }, + "typescript": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.4.tgz", + "integrity": "sha512-+Uru0t8qIRgjuCpiSPpfGuhHecMllk5Zsazj5LZvVsEStEjmIRRBZe+jHjGQvsgS7M1wONy2PQXd67EMyV6acg==" + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", diff --git a/package.json b/package.json index 97499c96b..c86672f1a 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ }, "main": "dist/streamr-client.nodejs.js", "browser": "dist/streamr-client.web.min.js", + "types": "dist/types/src/StreamrClient.d.ts", "directories": { "example": "examples", "test": "test" }, "scripts": { - "build": "rm dist/*; NODE_ENV=production webpack --mode=production --progress", + "build": "rm -rf dist; NODE_ENV=production webpack --mode=production --progress && npm run build:types", + "build:types": "tsc --emitDeclarationOnly", "benchmarks": "node test/benchmarks/publish.js && node test/benchmarks/subscribe.js", "prebuild-benchmark": "npm run build -- --config-name=node-lib", "build-benchmark": "npm run benchmarks", @@ -22,14 +24,14 @@ "prepack": "npm run build", "prebuild": "npm run eslint -- --cache", "dev": "webpack --progress --colors --watch --mode=development", - "eslint": "eslint --cache-location=node_modules/.cache/.eslintcache/ . ", + "eslint": "eslint --cache-location=node_modules/.cache/.eslintcache/ '*/**/*.{js,ts}'", "test": "jest --detectOpenHandles", "test-unit": "jest test/unit --detectOpenHandles", "coverage": "jest --coverage", "test-integration": "jest --forceExit test/integration", "test-integration-no-resend": "jest --testTimeout=10000 --testPathIgnorePatterns='resend|Resend' --testNamePattern='^((?!(resend|Resend|resent|Resent)).)*$' test/integration/*.test.js", "test-integration-resend": "jest --testTimeout=15000 --testNamePattern='(resend|Resend|resent|Resent)' test/integration/*.test.js", - "test-integration-dataunions": "jest --testTimeout=15000 test/integration/DataUnionEndpoints", + "test-integration-dataunions": "jest --testTimeout=15000 --runInBand test/integration/DataUnionEndpoints", "test-flakey": "jest --forceExit test/flakey/*", "test-browser": "node ./test/browser/server.js & node node_modules/nightwatch/bin/nightwatch ./test/browser/browser.js && pkill -f server.js", "install-example": "cd examples/webpack && npm ci", @@ -49,8 +51,12 @@ "@babel/plugin-transform-modules-commonjs": "^7.12.1", "@babel/plugin-transform-runtime": "^7.12.10", "@babel/preset-env": "^7.12.11", + "@babel/preset-typescript": "^7.12.13", + "@types/debug": "^4.1.5", + "@types/qs": "^6.9.5", + "@typescript-eslint/eslint-plugin": "^4.15.1", + "@typescript-eslint/parser": "^4.15.1", "async-mutex": "^0.2.6", - "babel-eslint": "^10.1.0", "babel-loader": "^8.2.2", "babel-plugin-add-module-exports": "^1.0.4", "babel-plugin-transform-class-properties": "^6.24.1", @@ -58,7 +64,7 @@ "buffer": "^6.0.3", "chromedriver": "^88.0.0", "core-js": "^3.8.3", - "eslint": "^7.18.0", + "eslint": "^7.20.0", "eslint-config-airbnb": "^18.2.1", "eslint-config-streamr-nodejs": "^1.3.0", "eslint-loader": "^4.0.2", @@ -107,6 +113,7 @@ "quick-lru": "^5.1.1", "readable-stream": "^3.6.0", "streamr-client-protocol": "^7.1.2", + "typescript": "^4.1.4", "uuid": "^8.3.2", "webpack-node-externals": "^2.5.2", "ws": "^7.4.2" diff --git a/src/Config.js b/src/Config.ts similarity index 85% rename from src/Config.js rename to src/Config.ts index 0894bc733..65442c4c5 100644 --- a/src/Config.js +++ b/src/Config.ts @@ -1,16 +1,18 @@ import qs from 'qs' +// @ts-expect-error import { ControlLayer, MessageLayer } from 'streamr-client-protocol' import Debug from 'debug' import { getVersionString, counterId } from './utils' +import { StreamrClientOptions } from './StreamrClient' const { ControlMessage } = ControlLayer const { StreamMessage } = MessageLayer -export default function ClientConfig(opts = {}) { +export default function ClientConfig(opts: StreamrClientOptions = {}) { const { id = counterId('StreamrClient') } = opts - const options = { + const options: StreamrClientOptions = { debug: Debug(id), // Authentication: identity used by this StreamrClient instance auth: {}, // can contain member privateKey or (window.)ethereum @@ -39,15 +41,20 @@ export default function ClientConfig(opts = {}) { // For ethers.js provider params, see https://docs.ethers.io/ethers.js/v5-beta/api-providers.html#provider mainnet: null, // Default to ethers.js default provider settings sidechain: { + // @ts-expect-error url: null, // TODO: add our default public service sidechain node, also find good PoA params below // timeout: // pollingInterval: }, + // @ts-expect-error dataUnion: null, // Give a "default target" of all data union endpoint operations (no need to pass argument every time) tokenAddress: '0x0Cf0Ee63788A0849fE5297F3407f701E122cC023', minimumWithdrawTokenWei: '1000000', // Threshold value set in AMB configs, smallest token amount to pass over the bridge + // @ts-expect-error sidechainTokenAddress: null, // TODO // sidechain token + // @ts-expect-error factoryMainnetAddress: null, // TODO // Data Union factory that creates a new Data Union + // @ts-expect-error sidechainAmbAddress: null, // Arbitrary Message-passing Bridge (AMB), see https://github.com/poanetwork/tokenbridge payForSignatureTransport: true, // someone must pay for transporting the withdraw tx to mainnet, either us or bridge operator ...opts, @@ -58,7 +65,7 @@ export default function ClientConfig(opts = {}) { } } - const parts = options.url.split('?') + const parts = options.url!.split('?') if (parts.length === 1) { // there is no query string const controlLayer = `controlLayerVersion=${ControlMessage.LATEST_VERSION}` const messageLayer = `messageLayerVersion=${StreamMessage.LATEST_VERSION}` @@ -78,16 +85,20 @@ export default function ClientConfig(opts = {}) { options.url = `${options.url}&streamrClient=${getVersionString()}` // Backwards compatibility for option 'authKey' => 'apiKey' + // @ts-expect-error if (options.authKey && !options.apiKey) { + // @ts-expect-error options.apiKey = options.authKey } + // @ts-expect-error if (options.apiKey) { + // @ts-expect-error options.auth.apiKey = options.apiKey } - if (options.auth.privateKey && !options.auth.privateKey.startsWith('0x')) { - options.auth.privateKey = `0x${options.auth.privateKey}` + if (options.auth!.privateKey && !options.auth!.privateKey.startsWith('0x')) { + options.auth!.privateKey = `0x${options.auth!.privateKey}` } return options diff --git a/src/Session.js b/src/Session.ts similarity index 50% rename from src/Session.js rename to src/Session.ts index d4c5eaad6..f9522f131 100644 --- a/src/Session.js +++ b/src/Session.ts @@ -1,29 +1,58 @@ import EventEmitter from 'eventemitter3' import { Wallet } from '@ethersproject/wallet' -import { Web3Provider } from '@ethersproject/providers' +import { ExternalProvider, JsonRpcFetchFunc, Web3Provider } from '@ethersproject/providers' +import StreamrClient from './StreamrClient' + +enum State { + LOGGING_OUT = 'logging out', + LOGGED_OUT = 'logged out', + LOGGING_IN ='logging in', + LOGGED_IN = 'logged in', +} + +export interface SessionOptions { + privateKey?: string + ethereum?: ExternalProvider|JsonRpcFetchFunc + apiKey?: string + username?: string + password?: string + sessionToken?: string + unauthenticated?: boolean +} + +export interface TokenObject { + token: string +} export default class Session extends EventEmitter { - constructor(client, options = {}) { + + _client: StreamrClient + options: SessionOptions + state: State + loginFunction: () => Promise + sessionTokenPromise?: Promise + + constructor(client: StreamrClient, options: SessionOptions = {}) { super() this._client = client this.options = { ...options } - this.state = Session.State.LOGGED_OUT + this.state = State.LOGGED_OUT // TODO: move loginFunction to StreamrClient constructor where "auth type" is checked if (typeof this.options.privateKey !== 'undefined') { const wallet = new Wallet(this.options.privateKey) - this.loginFunction = async () => this._client.loginWithChallengeResponse((d) => wallet.signMessage(d), wallet.address) + this.loginFunction = async () => this._client.loginEndpoints.loginWithChallengeResponse((d: string) => wallet.signMessage(d), wallet.address) } else if (typeof this.options.ethereum !== 'undefined') { const provider = new Web3Provider(this.options.ethereum) const signer = provider.getSigner() - this.loginFunction = async () => this._client.loginWithChallengeResponse((d) => signer.signMessage(d), await signer.getAddress()) + this.loginFunction = async () => this._client.loginEndpoints.loginWithChallengeResponse((d: string) => signer.signMessage(d), await signer.getAddress()) } else if (typeof this.options.apiKey !== 'undefined') { - this.loginFunction = async () => this._client.loginWithApiKey(this.options.apiKey) + this.loginFunction = async () => this._client.loginEndpoints.loginWithApiKey(this.options.apiKey!) } else if (typeof this.options.username !== 'undefined' && typeof this.options.password !== 'undefined') { - this.loginFunction = async () => this._client.loginWithUsernamePassword(this.options.username, this.options.password) + this.loginFunction = async () => this._client.loginEndpoints.loginWithUsernamePassword(this.options.username!, this.options.password!) } else { if (!this.options.sessionToken) { this.options.unauthenticated = true @@ -38,7 +67,7 @@ export default class Session extends EventEmitter { return this.options.unauthenticated } - updateState(newState) { + updateState(newState: State) { this.state = newState this.emit(newState) } @@ -52,19 +81,19 @@ export default class Session extends EventEmitter { return undefined } - if (this.state !== Session.State.LOGGING_IN) { - if (this.state === Session.State.LOGGING_OUT) { + if (this.state !== State.LOGGING_IN) { + if (this.state === State.LOGGING_OUT) { this.sessionTokenPromise = new Promise((resolve) => { - this.once(Session.State.LOGGED_OUT, () => resolve(this.getSessionToken(requireNewToken))) + this.once(State.LOGGED_OUT, () => resolve(this.getSessionToken(requireNewToken))) }) } else { - this.updateState(Session.State.LOGGING_IN) - this.sessionTokenPromise = this.loginFunction().then((tokenObj) => { + this.updateState(State.LOGGING_IN) + this.sessionTokenPromise = this.loginFunction().then((tokenObj: TokenObject) => { this.options.sessionToken = tokenObj.token - this.updateState(Session.State.LOGGED_IN) + this.updateState(State.LOGGED_IN) return tokenObj.token - }, (err) => { - this.updateState(Session.State.LOGGED_OUT) + }, (err: Error) => { + this.updateState(State.LOGGED_OUT) throw err }) } @@ -73,31 +102,24 @@ export default class Session extends EventEmitter { } async logout() { - if (this.state === Session.State.LOGGED_OUT) { + if (this.state === State.LOGGED_OUT) { throw new Error('Already logged out!') } - if (this.state === Session.State.LOGGING_OUT) { + if (this.state === State.LOGGING_OUT) { throw new Error('Already logging out!') } - if (this.state === Session.State.LOGGING_IN) { + if (this.state === State.LOGGING_IN) { await new Promise((resolve) => { - this.once(Session.State.LOGGED_IN, () => resolve(this.logout())) + this.once(State.LOGGED_IN, () => resolve(this.logout())) }) return } - this.updateState(Session.State.LOGGING_OUT) - await this._client.logoutEndpoint() + this.updateState(State.LOGGING_OUT) + await this._client.loginEndpoints.logoutEndpoint() this.options.sessionToken = undefined - this.updateState(Session.State.LOGGED_OUT) + this.updateState(State.LOGGED_OUT) } } - -Session.State = { - LOGGING_OUT: 'logging out', - LOGGED_OUT: 'logged out', - LOGGING_IN: 'logging in', - LOGGED_IN: 'logged in', -} diff --git a/src/StreamrClient.js b/src/StreamrClient.js deleted file mode 100644 index ac7a898b1..000000000 --- a/src/StreamrClient.js +++ /dev/null @@ -1,326 +0,0 @@ -import EventEmitter from 'eventemitter3' -import { ControlLayer } from 'streamr-client-protocol' -import Debug from 'debug' - -import { counterId, uuid, CacheAsyncFn } from './utils' -import { validateOptions } from './stream/utils' -import Config from './Config' -import StreamrEthereum from './Ethereum' -import Session from './Session' -import Connection from './Connection' -import Publisher from './publish' -import Subscriber from './subscribe' -import { getUserId } from './user' - -/** - * Wrap connection message events with message parsing. - */ - -class StreamrConnection extends Connection { - constructor(...args) { - super(...args) - this.on('message', this.onConnectionMessage) - } - - // eslint-disable-next-line class-methods-use-this - parse(messageEvent) { - return ControlLayer.ControlMessage.deserialize(messageEvent.data) - } - - onConnectionMessage(messageEvent) { - let controlMessage - try { - controlMessage = this.parse(messageEvent) - } catch (err) { - this.debug('(%o) << %o', this.getState(), messageEvent && messageEvent.data) - this.debug('deserialize error', err) - this.emit('error', err) - return - } - - if (!controlMessage) { - return - } - - this.debug('(%o) << %o', this.getState(), controlMessage) - this.emit(controlMessage.type, controlMessage) - } -} - -class StreamrCached { - constructor(client) { - this.client = client - const cacheOptions = client.options.cache - this.getStream = CacheAsyncFn(client.getStream.bind(client), { - ...cacheOptions, - cacheKey([maybeStreamId]) { - const { streamId } = validateOptions(maybeStreamId) - return streamId - } - }) - this.getUserInfo = CacheAsyncFn(client.getUserInfo.bind(client), cacheOptions) - this.isStreamPublisher = CacheAsyncFn(client.isStreamPublisher.bind(client), { - ...cacheOptions, - cacheKey([maybeStreamId, ethAddress]) { - const { streamId } = validateOptions(maybeStreamId) - return `${streamId}|${ethAddress}` - } - }) - - this.isStreamSubscriber = CacheAsyncFn(client.isStreamSubscriber.bind(client), { - ...cacheOptions, - cacheKey([maybeStreamId, ethAddress]) { - const { streamId } = validateOptions(maybeStreamId) - return `${streamId}|${ethAddress}` - } - }) - - this.getUserId = CacheAsyncFn(client.getUserId.bind(client), cacheOptions) - } - - clearStream(streamId) { - this.getStream.clear() - this.isStreamPublisher.clearMatching((s) => s.startsWith(streamId)) - this.isStreamSubscriber.clearMatching((s) => s.startsWith(streamId)) - } - - clearUser() { - this.getUserInfo.clear() - this.getUserId.clear() - } - - clear() { - this.clearUser() - this.clearStream() - } -} - -// use process id in node uid -const uid = process.pid != null ? process.pid : `${uuid().slice(-4)}${uuid().slice(0, 4)}` - -export default class StreamrClient extends EventEmitter { - constructor(options = {}, connection) { - super() - this.id = counterId(`${this.constructor.name}:${uid}`) - this.debug = Debug(this.id) - - this.options = Config({ - id: this.id, - debug: this.debug, - ...options, - }) - - this.debug('new StreamrClient %s: %o', this.id, { - version: process.env.version, - GIT_VERSION: process.env.GIT_VERSION, - GIT_COMMITHASH: process.env.GIT_COMMITHASH, - GIT_BRANCH: process.env.GIT_BRANCH, - }) - - // bind event handlers - this.getUserInfo = this.getUserInfo.bind(this) - this.onConnectionConnected = this.onConnectionConnected.bind(this) - this.onConnectionDisconnected = this.onConnectionDisconnected.bind(this) - this._onError = this._onError.bind(this) - this.onConnectionError = this.onConnectionError.bind(this) - this.getErrorEmitter = this.getErrorEmitter.bind(this) - - this.on('error', this._onError) // attach before creating sub-components incase they fire error events - - this.session = new Session(this, this.options.auth) - this.connection = connection || new StreamrConnection(this.options) - - this.connection - .on('connected', this.onConnectionConnected) - .on('disconnected', this.onConnectionDisconnected) - .on('error', this.onConnectionError) - - this.publisher = new Publisher(this) - this.subscriber = new Subscriber(this) - this.cached = new StreamrCached(this) - this.ethereum = new StreamrEthereum(this) - } - - async onConnectionConnected() { - this.debug('Connected!') - this.emit('connected') - } - - async onConnectionDisconnected() { - this.debug('Disconnected.') - this.emit('disconnected') - } - - onConnectionError(err) { - this.emit('error', new Connection.ConnectionError(err)) - } - - getErrorEmitter(source) { - return (err) => { - if (!(err instanceof Connection.ConnectionError || err.reason instanceof Connection.ConnectionError)) { - // emit non-connection errors - this.emit('error', err) - } else { - source.debug(err) - } - } - } - - _onError(err, ...args) { - this.onError(err, ...args) - } - - async send(request) { - return this.connection.send(request) - } - - /** - * Override to control output - */ - - onError(error) { // eslint-disable-line class-methods-use-this - console.error(error) - } - - isConnected() { - return this.connection.isConnected() - } - - isConnecting() { - return this.connection.isConnecting() - } - - isDisconnecting() { - return this.connection.isDisconnecting() - } - - isDisconnected() { - return this.connection.isDisconnected() - } - - async connect() { - return this.connection.connect() - } - - async nextConnection() { - return this.connection.nextConnection() - } - - disconnect() { - this.publisher.stop() - return Promise.all([ - this.subscriber.subscriptions.removeAll(), - this.connection.disconnect() - ]) - } - - getSubscriptions(...args) { - return this.subscriber.getAll(...args) - } - - getSubscription(...args) { - return this.subscriber.get(...args) - } - - async ensureConnected() { - return this.connect() - } - - async ensureDisconnected() { - return this.disconnect() - } - - logout() { - return this.session.logout() - } - - async publish(...args) { - return this.publisher.publish(...args) - } - - async getUserId() { - return getUserId(this) - } - - setNextGroupKey(...args) { - return this.publisher.setNextGroupKey(...args) - } - - rotateGroupKey(...args) { - return this.publisher.rotateGroupKey(...args) - } - - async subscribe(opts, onMessage) { - let subTask - let sub - const hasResend = !!(opts.resend || opts.from || opts.to || opts.last) - const onEnd = () => { - if (sub && typeof onMessage === 'function') { - sub.off('message', onMessage) - } - } - - if (hasResend) { - subTask = this.subscriber.resendSubscribe(opts, onEnd) - } else { - subTask = this.subscriber.subscribe(opts, onEnd) - } - - if (typeof onMessage === 'function') { - Promise.resolve(subTask).then(async (s) => { - sub = s - sub.on('message', onMessage) - for await (const msg of sub) { - sub.emit('message', msg.getParsedContent(), msg) - } - return sub - }).catch((err) => { - this.emit('error', err) - }) - } - return subTask - } - - async unsubscribe(opts) { - await this.subscriber.unsubscribe(opts) - } - - async resend(opts, onMessage) { - const task = this.subscriber.resend(opts) - if (typeof onMessage !== 'function') { - return task - } - - Promise.resolve(task).then(async (sub) => { - for await (const msg of sub) { - await onMessage(msg.getParsedContent(), msg) - } - - return sub - }).catch((err) => { - this.emit('error', err) - }) - - return task - } - - enableAutoConnect(...args) { - return this.connection.enableAutoConnect(...args) - } - - enableAutoDisconnect(...args) { - return this.connection.enableAutoDisconnect(...args) - } - - getAddress() { - return this.ethereum.getAddress() - } - - async getPublisherId() { - return this.getAddress() - } - - static generateEthereumAccount() { - return StreamrEthereum.generateEthereumAccount() - } -} diff --git a/src/StreamrClient.ts b/src/StreamrClient.ts new file mode 100644 index 000000000..a5a045960 --- /dev/null +++ b/src/StreamrClient.ts @@ -0,0 +1,583 @@ +import EventEmitter from 'eventemitter3' +// @ts-expect-error +import { ControlLayer } from 'streamr-client-protocol' +import Debug from 'debug' + +import { counterId, uuid, CacheAsyncFn } from './utils' +import { validateOptions } from './stream/utils' +import Config from './Config' +import StreamrEthereum from './Ethereum' +import Session from './Session' +import Connection from './Connection' +import Publisher from './publish' +import Subscriber from './subscribe' +import { getUserId } from './user' +import { Todo } from './types' +import { StreamEndpoints, StreamListQuery } from './rest/StreamEndpoints' +import { LoginEndpoints } from './rest/LoginEndpoints' +import { DataUnionEndpoints, DataUnionOptions } from './rest/DataUnionEndpoints' +import { BigNumber } from '@ethersproject/bignumber' +import Stream, { StreamProperties } from './stream' +import { ExternalProvider, JsonRpcFetchFunc } from '@ethersproject/providers' + +export interface StreamrClientOptions { + id?: string + debug?: Debug.Debugger, + auth?: { + privateKey?: string + ethereum?: ExternalProvider|JsonRpcFetchFunc, + apiKey?: string + username?: string + password?: string + } + url?: string + restUrl?: string + streamrNodeAddress?: string + autoConnect?: boolean + autoDisconnect?: boolean + orderMessages?: boolean, + retryResendAfter?: number, + gapFillTimeout?: number, + maxPublishQueueSize?: number, + publishWithSignature?: Todo, + verifySignatures?: Todo, + publisherStoreKeyHistory?: boolean, + groupKeys?: Todo + keyExchange?: Todo + mainnet?: Todo + sidechain?: { + url?: string + }, + dataUnion?: string + tokenAddress?: string, + minimumWithdrawTokenWei?: BigNumber|number|string, + sidechainTokenAddress?: string + factoryMainnetAddress?: string + sidechainAmbAddress?: string + payForSignatureTransport?: boolean + cache?: { + maxSize?: number, + maxAge?: number + } +} + +// TODO get metadata type from streamr-protocol-js project (it doesn't export the type definitions yet) +export type OnMessageCallback = (message: any, metadata: any) => void + +interface MessageEvent { + data: any +} + +/** + * Wrap connection message events with message parsing. + */ + +class StreamrConnection extends Connection { + // TODO define args type when we convert Connection class to TypeScript + constructor(...args: any) { + super(...args) + this.on('message', this.onConnectionMessage) + } + + // eslint-disable-next-line class-methods-use-this + parse(messageEvent: MessageEvent) { + return ControlLayer.ControlMessage.deserialize(messageEvent.data) + } + + onConnectionMessage(messageEvent: MessageEvent) { + let controlMessage + try { + controlMessage = this.parse(messageEvent) + } catch (err) { + this.debug('(%o) << %o', this.getState(), messageEvent && messageEvent.data) + this.debug('deserialize error', err) + this.emit('error', err) + return + } + + if (!controlMessage) { + return + } + + this.debug('(%o) << %o', this.getState(), controlMessage) + this.emit(controlMessage.type, controlMessage) + } +} + +class StreamrCached { + + client: StreamrClient + // TODO change all "any" types in this class to valid types when CacheAsyncFn is converted to TypeScript + getStream: any + getUserInfo: any + isStreamPublisher: any + isStreamSubscriber: any + getUserId: any + + constructor(client: StreamrClient) { + this.client = client + const cacheOptions: Todo = client.options.cache + this.getStream = CacheAsyncFn(client.getStream.bind(client), { + ...cacheOptions, + cacheKey([maybeStreamId]: any) { + const { streamId } = validateOptions(maybeStreamId) + return streamId + } + }) + this.getUserInfo = CacheAsyncFn(client.getUserInfo.bind(client), cacheOptions) + this.isStreamPublisher = CacheAsyncFn(client.isStreamPublisher.bind(client), { + ...cacheOptions, + cacheKey([maybeStreamId, ethAddress]: any) { + const { streamId } = validateOptions(maybeStreamId) + return `${streamId}|${ethAddress}` + } + }) + + this.isStreamSubscriber = CacheAsyncFn(client.isStreamSubscriber.bind(client), { + ...cacheOptions, + cacheKey([maybeStreamId, ethAddress]: any) { + const { streamId } = validateOptions(maybeStreamId) + return `${streamId}|${ethAddress}` + } + }) + + this.getUserId = CacheAsyncFn(client.getUserId.bind(client), cacheOptions) + } + + clearStream(streamId: string) { + this.getStream.clear() + this.isStreamPublisher.clearMatching((s: string) => s.startsWith(streamId)) + this.isStreamSubscriber.clearMatching((s: string) => s.startsWith(streamId)) + } + + clearUser() { + this.getUserInfo.clear() + this.getUserId.clear() + } + + clear() { + this.clearUser() + // @ts-expect-error + this.clearStream() + } +} + +// use process id in node uid +const uid = process.pid != null ? process.pid : `${uuid().slice(-4)}${uuid().slice(0, 4)}` + +export default class StreamrClient extends EventEmitter { + + id: string + debug: Debug.Debugger + options: StreamrClientOptions + session: Session + connection: StreamrConnection + publisher: Todo + subscriber: Subscriber + cached: StreamrCached + ethereum: StreamrEthereum + streamEndpoints: StreamEndpoints + loginEndpoints: LoginEndpoints + dataUnionEndpoints: DataUnionEndpoints + + constructor(options: StreamrClientOptions = {}, connection?: StreamrConnection) { + super() + this.id = counterId(`${this.constructor.name}:${uid}`) + this.debug = Debug(this.id) + + this.options = Config({ + id: this.id, + debug: this.debug, + ...options, + }) + + this.debug('new StreamrClient %s: %o', this.id, { + version: process.env.version, + GIT_VERSION: process.env.GIT_VERSION, + GIT_COMMITHASH: process.env.GIT_COMMITHASH, + GIT_BRANCH: process.env.GIT_BRANCH, + }) + + // bind event handlers + this.onConnectionConnected = this.onConnectionConnected.bind(this) + this.onConnectionDisconnected = this.onConnectionDisconnected.bind(this) + this._onError = this._onError.bind(this) + this.onConnectionError = this.onConnectionError.bind(this) + this.getErrorEmitter = this.getErrorEmitter.bind(this) + + this.on('error', this._onError) // attach before creating sub-components incase they fire error events + + this.session = new Session(this, this.options.auth) + this.connection = connection || new StreamrConnection(this.options) + + this.connection + .on('connected', this.onConnectionConnected) + .on('disconnected', this.onConnectionDisconnected) + .on('error', this.onConnectionError) + + // @ts-expect-error + this.publisher = new Publisher(this) + this.subscriber = new Subscriber(this) + this.cached = new StreamrCached(this) + this.ethereum = new StreamrEthereum(this) + + this.streamEndpoints = new StreamEndpoints(this) + this.loginEndpoints = new LoginEndpoints(this) + this.dataUnionEndpoints = new DataUnionEndpoints(this) + } + + async onConnectionConnected() { + this.debug('Connected!') + this.emit('connected') + } + + async onConnectionDisconnected() { + this.debug('Disconnected.') + this.emit('disconnected') + } + + onConnectionError(err: Todo) { + this.emit('error', new Connection.ConnectionError(err)) + } + + getErrorEmitter(source: Todo) { + return (err: Todo) => { + if (!(err instanceof Connection.ConnectionError || err.reason instanceof Connection.ConnectionError)) { + // emit non-connection errors + this.emit('error', err) + } else { + source.debug(err) + } + } + } + + _onError(err: Todo, ...args: Todo) { + // @ts-expect-error + this.onError(err, ...args) + } + + async send(request: Todo) { + return this.connection.send(request) + } + + /** + * Override to control output + */ + + onError(error: Todo) { // eslint-disable-line class-methods-use-this + console.error(error) + } + + isConnected() { + return this.connection.isConnected() + } + + isConnecting() { + return this.connection.isConnecting() + } + + isDisconnecting() { + return this.connection.isDisconnecting() + } + + isDisconnected() { + return this.connection.isDisconnected() + } + + async connect() { + return this.connection.connect() + } + + async nextConnection() { + return this.connection.nextConnection() + } + + disconnect() { + this.publisher.stop() + return Promise.all([ + this.subscriber.subscriptions.removeAll(), + this.connection.disconnect() + ]) + } + + getSubscriptions(...args: Todo) { + return this.subscriber.getAll(...args) + } + + getSubscription(...args: Todo) { + // @ts-expect-error + return this.subscriber.get(...args) + } + + async ensureConnected() { + return this.connect() + } + + async ensureDisconnected() { + return this.disconnect() + } + + logout() { + return this.session.logout() + } + + async publish(...args: Todo) { + return this.publisher.publish(...args) + } + + async getUserId() { + return getUserId(this) + } + + setNextGroupKey(...args: Todo) { + return this.publisher.setNextGroupKey(...args) + } + + rotateGroupKey(...args: Todo) { + return this.publisher.rotateGroupKey(...args) + } + + async subscribe(opts: Todo, onMessage: OnMessageCallback) { + let subTask: Todo + let sub: Todo + const hasResend = !!(opts.resend || opts.from || opts.to || opts.last) + const onEnd = () => { + if (sub && typeof onMessage === 'function') { + sub.off('message', onMessage) + } + } + + if (hasResend) { + subTask = this.subscriber.resendSubscribe(opts, onEnd) + } else { + subTask = this.subscriber.subscribe(opts, onEnd) + } + + if (typeof onMessage === 'function') { + Promise.resolve(subTask).then(async (s) => { + sub = s + sub.on('message', onMessage) + for await (const msg of sub) { + sub.emit('message', msg.getParsedContent(), msg) + } + return sub + }).catch((err) => { + this.emit('error', err) + }) + } + return subTask + } + + async unsubscribe(opts: Todo) { + await this.subscriber.unsubscribe(opts) + } + + async resend(opts: Todo, onMessage: OnMessageCallback) { + const task = this.subscriber.resend(opts) + if (typeof onMessage !== 'function') { + return task + } + + Promise.resolve(task).then(async (sub) => { + for await (const msg of sub) { + await onMessage(msg.getParsedContent(), msg) + } + + return sub + }).catch((err) => { + this.emit('error', err) + }) + + return task + } + + enableAutoConnect(...args: Todo) { + return this.connection.enableAutoConnect(...args) + } + + enableAutoDisconnect(...args: Todo) { + return this.connection.enableAutoDisconnect(...args) + } + + getAddress() { + return this.ethereum.getAddress() + } + + async getPublisherId() { + return this.getAddress() + } + + static generateEthereumAccount() { + return StreamrEthereum.generateEthereumAccount() + } + + // TODO many of these methods that use streamEndpoints/loginEndpoints/dataUnionEndpoints are private: remove those + + async getStream(streamId: string) { + return this.streamEndpoints.getStream(streamId) + } + + async listStreams(query: StreamListQuery = {}) { + return this.streamEndpoints.listStreams(query) + } + + async getStreamByName(name: string) { + return this.streamEndpoints.getStreamByName(name) + } + + async createStream(props: StreamProperties) { + return this.streamEndpoints.createStream(props) + } + + async getOrCreateStream(props: { id?: string, name?: string }) { + return this.streamEndpoints.getOrCreateStream(props) + } + + async getStreamPublishers(streamId: string) { + return this.streamEndpoints.getStreamPublishers(streamId) + } + + async isStreamPublisher(streamId: string, ethAddress: string) { + return this.streamEndpoints.isStreamPublisher(streamId, ethAddress) + } + + async getStreamSubscribers(streamId: string) { + return this.streamEndpoints.getStreamSubscribers(streamId) + } + + async isStreamSubscriber(streamId: string, ethAddress: string) { + return this.streamEndpoints.isStreamSubscriber(streamId, ethAddress) + } + + async getStreamValidationInfo(streamId: string) { + return this.streamEndpoints.getStreamValidationInfo(streamId) + } + + async getStreamLast(streamObjectOrId: Stream|string) { + return this.streamEndpoints.getStreamLast(streamObjectOrId) + } + + async getStreamPartsByStorageNode(address: string) { + return this.streamEndpoints.getStreamPartsByStorageNode(address) + } + + async publishHttp(streamObjectOrId: Stream|string, data: Todo, requestOptions: Todo = {}, keepAlive: boolean = true) { + return this.streamEndpoints.publishHttp(streamObjectOrId, data, requestOptions, keepAlive) + } + + async getUserInfo() { + return this.loginEndpoints.getUserInfo() + } + + async calculateDataUnionMainnetAddress(dataUnionName: string, deployerAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.calculateDataUnionMainnetAddress(dataUnionName, deployerAddress, options) + } + + async calculateDataUnionSidechainAddress(duMainnetAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.calculateDataUnionSidechainAddress(duMainnetAddress, options) + } + + async deployDataUnion(options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.deployDataUnion(options) + } + + async getDataUnionContract(options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.getDataUnionContract(options) + } + + async createSecret(dataUnionMainnetAddress: string, name: string = 'Untitled Data Union Secret') { + return this.dataUnionEndpoints.createSecret(dataUnionMainnetAddress, name) + } + + async kick(memberAddressList: string[], options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.kick(memberAddressList, options) + } + + async addMembers(memberAddressList: string[], options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.addMembers(memberAddressList, options) + } + + async withdrawMember(memberAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.withdrawMember(memberAddress, options) + } + + async getWithdrawMemberTx(memberAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.getWithdrawMemberTx(memberAddress, options) + } + + async withdrawToSigned(memberAddress: string, recipientAddress: string, signature: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.withdrawToSigned(memberAddress, recipientAddress, signature, options) + } + + async getWithdrawToSignedTx(memberAddress: string, recipientAddress: string, signature: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.getWithdrawToSignedTx(memberAddress, recipientAddress, signature, options) + } + + async setAdminFee(newFeeFraction: number, options: DataUnionOptions) { + return this.dataUnionEndpoints.setAdminFee(newFeeFraction, options) + } + + async getAdminFee(options: DataUnionOptions) { + return this.dataUnionEndpoints.getAdminFee(options) + } + + async getAdminAddress(options: DataUnionOptions) { + return this.dataUnionEndpoints.getAdminAddress(options) + } + + async joinDataUnion(options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.joinDataUnion(options) + } + + async hasJoined(memberAddress: string, options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.hasJoined(memberAddress, options) + } + + async getMembers(options: DataUnionOptions) { + return this.dataUnionEndpoints.getMembers(options) + } + + async getDataUnionStats(options: DataUnionOptions) { + return this.dataUnionEndpoints.getDataUnionStats(options) + } + + async getMemberStats(memberAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.getMemberStats(memberAddress, options) + } + + async getMemberBalance(memberAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.getMemberBalance(memberAddress, options) + } + + async getTokenBalance(address: string|null|undefined, options: DataUnionOptions) { + return this.dataUnionEndpoints.getTokenBalance(address, options) + } + + async getDataUnionVersion(contractAddress: string) { + return this.dataUnionEndpoints.getDataUnionVersion(contractAddress) + } + + async withdraw(options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.withdraw(options) + } + + async getWithdrawTx(options: DataUnionOptions) { + return this.dataUnionEndpoints.getWithdrawTx(options) + } + + async withdrawTo(recipientAddress: string, options: DataUnionOptions = {}) { + return this.dataUnionEndpoints.withdrawTo(recipientAddress, options) + } + + async getWithdrawTxTo(recipientAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.getWithdrawTxTo(recipientAddress, options) + } + + async signWithdrawTo(recipientAddress: string, options: DataUnionOptions) { + return this.dataUnionEndpoints.signWithdrawTo(recipientAddress, options) + } + + async signWithdrawAmountTo(recipientAddress: string, amountTokenWei: BigNumber|number|string, options: DataUnionOptions) { + return this.dataUnionEndpoints.signWithdrawAmountTo(recipientAddress, amountTokenWei, options) + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index a70115695..000000000 --- a/src/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import StreamrClient from './StreamrClient' -import * as StreamEndpoints from './rest/StreamEndpoints' -import * as LoginEndpoints from './rest/LoginEndpoints' -import * as DataUnionEndpoints from './rest/DataUnionEndpoints' - -// Mixin the rest endpoints to the StreamrClient -Object.assign(StreamrClient.prototype, { - ...StreamEndpoints, - ...LoginEndpoints, - ...DataUnionEndpoints, -}) - -export default StreamrClient diff --git a/src/rest/DataUnionEndpoints.js b/src/rest/DataUnionEndpoints.js deleted file mode 100644 index ea1b28e8f..000000000 --- a/src/rest/DataUnionEndpoints.js +++ /dev/null @@ -1,1094 +0,0 @@ -/** - * Streamr Data Union related functions - * - * Table of Contents: - * ABIs - * helper utils - * admin: DEPLOY AND SETUP DATA UNION Functions for deploying the contract and adding secrets for smooth joining - * admin: MANAGE DATA UNION Kick and add members - * member: JOIN & QUERY DATA UNION Publicly available info about dataunions and their members (with earnings and proofs) - * member: WITHDRAW EARNINGS Withdrawing functions, there's many: normal, agent, donate - */ - -import { getAddress, isAddress } from '@ethersproject/address' -import { BigNumber } from '@ethersproject/bignumber' -import { arrayify, hexZeroPad } from '@ethersproject/bytes' -import { Contract } from '@ethersproject/contracts' -import { keccak256 } from '@ethersproject/keccak256' -import { verifyMessage } from '@ethersproject/wallet' -import debug from 'debug' - -import { until, getEndpointUrl } from '../utils' - -import authFetch from './authFetch' - -const log = debug('StreamrClient::DataUnionEndpoints') -// const log = console.log // useful for debugging sometimes - -// /////////////////////////////////////////////////////////////////////// -// ABIs: contract functions we want to call within the client -// /////////////////////////////////////////////////////////////////////// - -const dataUnionMainnetABI = [{ - name: 'sendTokensToBridge', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'token', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'owner', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'setAdminFee', - inputs: [{ type: 'uint256' }], - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'adminFeeFraction', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}] - -const dataUnionSidechainABI = [{ - name: 'addMembers', - inputs: [{ type: 'address[]', internalType: 'address payable[]', }], - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'partMembers', - inputs: [{ type: 'address[]' }], - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'withdrawAll', - inputs: [{ type: 'address' }, { type: 'bool' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'withdrawAllTo', - inputs: [{ type: 'address' }, { type: 'bool' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'withdrawAllToSigned', - inputs: [{ type: 'address' }, { type: 'address' }, { type: 'bool' }, { type: 'bytes' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - // enum ActiveStatus {None, Active, Inactive, Blocked} - // struct MemberInfo { - // ActiveStatus status; - // uint256 earnings_before_last_join; - // uint256 lme_at_join; - // uint256 withdrawnEarnings; - // } - name: 'memberData', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint8' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - inputs: [], - name: 'getStats', - outputs: [{ type: 'uint256[6]' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'getEarnings', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'getWithdrawableEarnings', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'lifetimeMemberEarnings', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'totalWithdrawable', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'totalEarnings', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'activeMemberCount', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - // this event is emitted by withdrawing process, - // see https://github.com/poanetwork/tokenbridge-contracts/blob/master/contracts/upgradeable_contracts/arbitrary_message/HomeAMB.sol - name: 'UserRequestForSignature', - inputs: [ - { indexed: true, name: 'messageId', type: 'bytes32' }, - { indexed: false, name: 'encodedData', type: 'bytes' } - ], - anonymous: false, - type: 'event' -}] - -// Only the part of ABI that is needed by deployment (and address resolution) -const factoryMainnetABI = [{ - type: 'constructor', - inputs: [{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'uint256' }], - stateMutability: 'nonpayable' -}, { - name: 'sidechainAddress', - inputs: [{ type: 'address' }], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'mainnetAddress', - inputs: [{ type: 'address' }, { type: 'string' }], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'deployNewDataUnion', - inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'address[]' }, { type: 'string' }], - outputs: [{ type: 'address' }], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'amb', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'data_union_sidechain_factory', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}] - -const mainnetAmbABI = [{ - name: 'executeSignatures', - inputs: [{ type: 'bytes' }, { type: 'bytes' }], // data, signatures - outputs: [], - stateMutability: 'nonpayable', - type: 'function' -}, { - name: 'messageCallStatus', - inputs: [{ type: 'bytes32' }], // messageId - outputs: [{ type: 'bool' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'failedMessageSender', - inputs: [{ type: 'bytes32' }], // messageId - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'relayedMessages', - inputs: [{ type: 'bytes32' }], // messageId, was called "_txhash" though?! - outputs: [{ name: '', type: 'bool' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'validatorContract', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' -}] - -const sidechainAmbABI = [{ - name: 'signature', - inputs: [{ type: 'bytes32' }, { type: 'uint256' }], // messageHash, index - outputs: [{ type: 'bytes' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'message', - inputs: [{ type: 'bytes32' }], // messageHash - outputs: [{ type: 'bytes' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'requiredSignatures', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}, { - name: 'numMessagesSigned', - inputs: [{ type: 'bytes32' }], // messageHash (TODO: double check) - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' -}] - -// ////////////////////////////////////////////////////////////////// -// Contract utils -// ////////////////////////////////////////////////////////////////// - -/** @typedef {String} EthereumAddress */ - -function throwIfBadAddress(address, variableDescription) { - try { - return getAddress(address) - } catch (e) { - throw new Error(`${variableDescription || 'Error'}: Bad Ethereum address ${address}. Original error: ${e.stack}.`) - } -} - -/** - * Parse address, or use this client's auth address if input not given - * @param {StreamrClient} this - * @param {EthereumAddress} inputAddress from user (NOT case sensitive) - * @returns {EthereumAddress} with checksum case - */ -function parseAddress(client, inputAddress) { - if (isAddress(inputAddress)) { - return getAddress(inputAddress) - } - return client.getAddress() -} - -// Find the Asyncronous Message-passing Bridge sidechain ("home") contract -let cachedSidechainAmb -async function getSidechainAmb(client, options) { - if (!cachedSidechainAmb) { - const getAmbPromise = async () => { - const mainnetProvider = client.ethereum.getMainnetProvider() - const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress - const factoryMainnet = new Contract(factoryMainnetAddress, factoryMainnetABI, mainnetProvider) - const sidechainProvider = client.ethereum.getSidechainProvider() - const factorySidechainAddress = await factoryMainnet.data_union_sidechain_factory() - const factorySidechain = new Contract(factorySidechainAddress, [{ - name: 'amb', - inputs: [], - outputs: [{ type: 'address' }], - stateMutability: 'view', - type: 'function' - }], sidechainProvider) - const sidechainAmbAddress = await factorySidechain.amb() - return new Contract(sidechainAmbAddress, sidechainAmbABI, sidechainProvider) - } - cachedSidechainAmb = getAmbPromise() - cachedSidechainAmb = await cachedSidechainAmb // eslint-disable-line require-atomic-updates - } - return cachedSidechainAmb -} - -async function getMainnetAmb(client, options) { - const mainnetProvider = client.ethereum.getMainnetProvider() - const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress - const factoryMainnet = new Contract(factoryMainnetAddress, factoryMainnetABI, mainnetProvider) - const mainnetAmbAddress = await factoryMainnet.amb() - return new Contract(mainnetAmbAddress, mainnetAmbABI, mainnetProvider) -} - -async function requiredSignaturesHaveBeenCollected(client, messageHash, options = {}) { - const sidechainAmb = await getSidechainAmb(client, options) - const requiredSignatureCount = await sidechainAmb.requiredSignatures() - - // Bit 255 is set to mark completion, double check though - const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) - const collectedSignatureCount = sigCountStruct.mask(255) - const markedComplete = sigCountStruct.shr(255).gt(0) - - log(`${collectedSignatureCount.toString()} out of ${requiredSignatureCount.toString()} collected`) - if (markedComplete) { log('All signatures collected') } - return markedComplete -} - -// move signatures from sidechain to mainnet -async function transportSignatures(client, messageHash, options) { - const sidechainAmb = await getSidechainAmb(client, options) - const message = await sidechainAmb.message(messageHash) - const messageId = '0x' + message.substr(2, 64) - const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) - const collectedSignatureCount = sigCountStruct.mask(255).toNumber() - - log(`${collectedSignatureCount} signatures reported, getting them from the sidechain AMB...`) - const signatures = await Promise.all(Array(collectedSignatureCount).fill(0).map(async (_, i) => sidechainAmb.signature(messageHash, i))) - - const [vArray, rArray, sArray] = [[], [], []] - signatures.forEach((signature, i) => { - log(` Signature ${i}: ${signature} (len=${signature.length}=${signature.length / 2 - 1} bytes)`) - rArray.push(signature.substr(2, 64)) - sArray.push(signature.substr(66, 64)) - vArray.push(signature.substr(130, 2)) - }) - const packedSignatures = BigNumber.from(signatures.length).toHexString() + vArray.join('') + rArray.join('') + sArray.join('') - log(`All signatures packed into one: ${packedSignatures}`) - - // Gas estimation also checks that the transaction would succeed, and provides a helpful error message in case it would fail - const mainnetAmb = await getMainnetAmb(client, options) - log(`Estimating gas using mainnet AMB @ ${mainnetAmb.address}, message=${message}`) - let gasLimit - try { - // magic number suggested by https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/utils/constants.js - gasLimit = await mainnetAmb.estimateGas.executeSignatures(message, packedSignatures) + 200000 - log(`Calculated gas limit: ${gasLimit.toString()}`) - } catch (e) { - // Failure modes from https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/events/processAMBCollectedSignatures/estimateGas.js - log('Gas estimation failed: Check if the message was already processed') - const alreadyProcessed = await mainnetAmb.relayedMessages(messageId) - if (alreadyProcessed) { - log(`WARNING: Tried to transport signatures but they have already been transported (Message ${messageId} has already been processed)`) - log('This could happen if payForSignatureTransport=true, but bridge operator also pays for signatures, and got there before your client') - return null - } - - log('Gas estimation failed: Check if number of signatures is enough') - const mainnetProvider = client.ethereum.getMainnetProvider() - const validatorContractAddress = await mainnetAmb.validatorContract() - const validatorContract = new Contract(validatorContractAddress, [{ - name: 'isValidator', - inputs: [{ type: 'address' }], - outputs: [{ type: 'bool' }], - stateMutability: 'view', - type: 'function' - }, { - name: 'requiredSignatures', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - }], mainnetProvider) - const requiredSignatures = await validatorContract.requiredSignatures() - if (requiredSignatures.gt(signatures.length)) { - throw new Error('The number of required signatures does not match between sidechain(' - + signatures.length + ' and mainnet( ' + requiredSignatures.toString()) - } - - log('Gas estimation failed: Check if all the signatures were made by validators') - log(` Recover signer addresses from signatures [${signatures.join(', ')}]`) - const signers = signatures.map((signature) => verifyMessage(arrayify(message), signature)) - log(` Check that signers are validators [[${signers.join(', ')}]]`) - const isValidatorArray = await Promise.all(signers.map((address) => [address, validatorContract.isValidator(address)])) - const nonValidatorSigners = isValidatorArray.filter(([, isValidator]) => !isValidator) - if (nonValidatorSigners.length > 0) { - throw new Error(`Following signers are not listed as validators in mainnet validator contract at ${validatorContractAddress}:\n - ` - + nonValidatorSigners.map(([address]) => address).join('\n - ')) - } - - throw new Error(`Gas estimation failed: Unknown error while processing message ${message} with ${e.stack}`) - } - - const signer = client.ethereum.getSigner() - log(`Sending message from signer=${await signer.getAddress()}`) - const txAMB = await mainnetAmb.connect(signer).executeSignatures(message, packedSignatures) - const trAMB = await txAMB.wait() - return trAMB -} - -// template for withdraw functions -// client could be replaced with AMB (mainnet and sidechain) -async function untilWithdrawIsComplete(client, getWithdrawTxFunc, getBalanceFunc, options = {}) { - const { - pollingIntervalMs = 1000, - retryTimeoutMs = 60000, - } = options - const balanceBefore = await getBalanceFunc(options) - const tx = await getWithdrawTxFunc(options) - const tr = await tx.wait() - - if (options.payForSignatureTransport) { - log(`Got receipt, filtering UserRequestForSignature from ${tr.events.length} events...`) - // event UserRequestForSignature(bytes32 indexed messageId, bytes encodedData); - const sigEventArgsArray = tr.events.filter((e) => e.event === 'UserRequestForSignature').map((e) => e.args) - if (sigEventArgsArray.length < 1) { - throw new Error("No UserRequestForSignature events emitted from withdraw transaction, can't transport withdraw to mainnet") - } - /* eslint-disable no-await-in-loop */ - // eslint-disable-next-line no-restricted-syntax - for (const eventArgs of sigEventArgsArray) { - const messageId = eventArgs[0] - const messageHash = keccak256(eventArgs[1]) - - log(`Waiting until sidechain AMB has collected required signatures for hash=${messageHash}...`) - await until(async () => requiredSignaturesHaveBeenCollected(client, messageHash, options), pollingIntervalMs, retryTimeoutMs) - - log(`Checking mainnet AMB hasn't already processed messageId=${messageId}`) - const mainnetAmb = await getMainnetAmb(client, options) - const alreadySent = await mainnetAmb.messageCallStatus(messageId) - const failAddress = await mainnetAmb.failedMessageSender(messageId) - if (alreadySent || failAddress !== '0x0000000000000000000000000000000000000000') { // zero address means no failed messages - log(`WARNING: Mainnet bridge has already processed withdraw messageId=${messageId}`) - log([ - 'This could happen if payForSignatureTransport=true, but bridge operator also pays for', - 'signatures, and got there before your client', - ].join(' ')) - continue - } - - log(`Transporting signatures for hash=${messageHash}`) - await transportSignatures(client, messageHash, options) - } - /* eslint-enable no-await-in-loop */ - } - - log(`Waiting for balance ${balanceBefore.toString()} to change`) - await until(async () => !(await getBalanceFunc(options)).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs) - - return tr -} - -// TODO: calculate addresses in JS instead of asking over RPC, see data-union-solidity/contracts/CloneLib.sol -// key the cache with name only, since PROBABLY one StreamrClient will ever use only one private key -const mainnetAddressCache = {} // mapping: "name" -> mainnet address -/** @returns {Promise} Mainnet address for Data Union */ -async function getDataUnionMainnetAddress(client, dataUnionName, deployerAddress, options = {}) { - if (!mainnetAddressCache[dataUnionName]) { - const provider = client.ethereum.getMainnetProvider() - const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress - const factoryMainnet = new Contract(factoryMainnetAddress, factoryMainnetABI, provider) - const addressPromise = factoryMainnet.mainnetAddress(deployerAddress, dataUnionName) - mainnetAddressCache[dataUnionName] = addressPromise - mainnetAddressCache[dataUnionName] = await addressPromise // eslint-disable-line require-atomic-updates - } - return mainnetAddressCache[dataUnionName] -} - -// TODO: calculate addresses in JS -const sidechainAddressCache = {} // mapping: mainnet address -> sidechain address -/** @returns {Promise} Sidechain address for Data Union */ -async function getDataUnionSidechainAddress(client, duMainnetAddress, options = {}) { - if (!sidechainAddressCache[duMainnetAddress]) { - const provider = client.ethereum.getMainnetProvider() - const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress - const factoryMainnet = new Contract(factoryMainnetAddress, factoryMainnetABI, provider) - const addressPromise = factoryMainnet.sidechainAddress(duMainnetAddress) - sidechainAddressCache[duMainnetAddress] = addressPromise - sidechainAddressCache[duMainnetAddress] = await addressPromise // eslint-disable-line require-atomic-updates - } - return sidechainAddressCache[duMainnetAddress] -} - -function getMainnetContractReadOnly(client, options = {}) { - let dataUnion = options.dataUnion || options.dataUnionAddress || client.options.dataUnion - if (isAddress(dataUnion)) { - const provider = client.ethereum.getMainnetProvider() - dataUnion = new Contract(dataUnion, dataUnionMainnetABI, provider) - } - - if (!(dataUnion instanceof Contract)) { - throw new Error(`Option dataUnion=${dataUnion} was not a good Ethereum address or Contract`) - } - return dataUnion -} - -function getMainnetContract(client, options = {}) { - const du = getMainnetContractReadOnly(client, options) - const signer = client.ethereum.getSigner() - return du.connect(signer) -} - -async function getSidechainContract(client, options = {}) { - const signer = await client.ethereum.getSidechainSigner() - const duMainnet = getMainnetContractReadOnly(client, options) - const duSidechainAddress = await getDataUnionSidechainAddress(client, duMainnet.address, options) - const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, signer) - return duSidechain -} - -async function getSidechainContractReadOnly(client, options = {}) { - const provider = await client.ethereum.getSidechainProvider() - const duMainnet = getMainnetContractReadOnly(client, options) - const duSidechainAddress = await getDataUnionSidechainAddress(client, duMainnet.address, options) - const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, provider) - return duSidechain -} - -// ////////////////////////////////////////////////////////////////// -// admin: DEPLOY AND SETUP DATA UNION -// ////////////////////////////////////////////////////////////////// - -export async function calculateDataUnionMainnetAddress(dataUnionName, deployerAddress, options) { - const address = getAddress(deployerAddress) // throws if bad address - return getDataUnionMainnetAddress(this, dataUnionName, address, options) -} - -export async function calculateDataUnionSidechainAddress(duMainnetAddress, options) { - const address = getAddress(duMainnetAddress) // throws if bad address - return getDataUnionSidechainAddress(this, address, options) -} - -/** - * TODO: update this comment - * @typedef {object} EthereumOptions all optional, hence "options" - * @property {Wallet | string} wallet or private key, default is currently logged in StreamrClient (if auth: privateKey) - * @property {string} key private key, alias for String wallet - * @property {string} privateKey, alias for String wallet - * @property {providers.Provider} provider to use in case wallet was a String, or omitted - * @property {number} confirmations, default is 1 - * @property {BigNumber} gasPrice in wei (part of ethers overrides), default is whatever the network recommends (ethers.js default) - * @see https://docs.ethers.io/ethers.js/html/api-contract.html#overrides - */ -/** - * @typedef {object} AdditionalDeployOptions for deployDataUnion - * @property {EthereumAddress} owner new data union owner, defaults to StreamrClient authenticated user - * @property {Array} joinPartAgents defaults to just the owner - * @property {number} adminFee fraction (number between 0...1 where 1 means 100%) - * @property {EthereumAddress} factoryMainnetAddress defaults to StreamrClient options - * @property {string} dataUnionName unique (to the DataUnionFactory) identifier of the new data union, must not exist yet - */ -/** - * @typedef {EthereumOptions & AdditionalDeployOptions} DeployOptions - */ -// TODO: gasPrice to overrides (not needed for browser, but would be useful in node.js) - -/** - * Create a new DataUnionMainnet contract to mainnet with DataUnionFactoryMainnet - * This triggers DataUnionSidechain contract creation in sidechain, over the bridge (AMB) - * @param {DeployOptions} options such as adminFee (default: 0) - * @return {Promise} that resolves when the new DU is deployed over the bridge to side-chain - */ -export async function deployDataUnion(options = {}) { - const { - owner, - joinPartAgents, - dataUnionName, - adminFee = 0, - sidechainPollingIntervalMs = 1000, - sidechainRetryTimeoutMs = 600000, - } = options - - let duName = dataUnionName - if (!duName) { - duName = `DataUnion-${Date.now()}` // TODO: use uuid - log(`dataUnionName generated: ${duName}`) - } - - if (adminFee < 0 || adminFee > 1) { throw new Error('options.adminFeeFraction must be a number between 0...1, got: ' + adminFee) } - const adminFeeBN = BigNumber.from((adminFee * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish - - const mainnetProvider = this.ethereum.getMainnetProvider() - const mainnetWallet = this.ethereum.getSigner() - const sidechainProvider = this.ethereum.getSidechainProvider() - - // parseAddress defaults to authenticated user (also if "owner" is not an address) - const ownerAddress = parseAddress(this, owner) - - let agentAddressList - if (Array.isArray(joinPartAgents)) { - // getAddress throws if there's an invalid address in the array - agentAddressList = joinPartAgents.map(getAddress) - } else { - // streamrNode needs to be joinPartAgent so that EE join with secret works (and join approvals from Marketplace UI) - agentAddressList = [ownerAddress] - if (this.options.streamrNodeAddress) { - agentAddressList.push(getAddress(this.options.streamrNodeAddress)) - } - } - - const duMainnetAddress = await getDataUnionMainnetAddress(this, duName, ownerAddress, options) - const duSidechainAddress = await getDataUnionSidechainAddress(this, duMainnetAddress, options) - - if (await mainnetProvider.getCode(duMainnetAddress) !== '0x') { - throw new Error(`Mainnet data union "${duName}" contract ${duMainnetAddress} already exists!`) - } - - const factoryMainnetAddress = throwIfBadAddress( - options.factoryMainnetAddress || this.options.factoryMainnetAddress, - 'StreamrClient.options.factoryMainnetAddress' - ) - if (await mainnetProvider.getCode(factoryMainnetAddress) === '0x') { - throw new Error(`Data union factory contract not found at ${factoryMainnetAddress}, check StreamrClient.options.factoryMainnetAddress!`) - } - - // function deployNewDataUnion(address owner, uint256 adminFeeFraction, address[] agents, string duName) - const factoryMainnet = new Contract(factoryMainnetAddress, factoryMainnetABI, mainnetWallet) - const tx = await factoryMainnet.deployNewDataUnion( - ownerAddress, - adminFeeBN, - agentAddressList, - duName, - ) - const tr = await tx.wait() - - log(`Data Union "${duName}" (mainnet: ${duMainnetAddress}, sidechain: ${duSidechainAddress}) deployed to mainnet, waiting for side-chain...`) - await until( - async () => await sidechainProvider.getCode(duSidechainAddress) !== '0x', - sidechainRetryTimeoutMs, - sidechainPollingIntervalMs - ) - - const dataUnion = new Contract(duMainnetAddress, dataUnionMainnetABI, mainnetWallet) - dataUnion.deployTxReceipt = tr - dataUnion.sidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, sidechainProvider) - return dataUnion -} - -export async function getDataUnionContract(options = {}) { - const ret = getMainnetContract(this, options) - ret.sidechain = await getSidechainContract(this, options) - return ret -} - -/** - * Add a new data union secret - * @param {EthereumAddress} dataUnionMainnetAddress - * @param {String} name describes the secret - * @returns {String} the server-generated secret - */ -export async function createSecret(dataUnionMainnetAddress, name = 'Untitled Data Union Secret') { - const duAddress = getAddress(dataUnionMainnetAddress) // throws if bad address - const url = getEndpointUrl(this.options.restUrl, 'dataunions', duAddress, 'secrets') - const res = await authFetch( - url, - this.session, - { - method: 'POST', - body: JSON.stringify({ - name - }), - headers: { - 'Content-Type': 'application/json', - }, - }, - ) - return res.secret -} - -// ////////////////////////////////////////////////////////////////// -// admin: MANAGE DATA UNION -// ////////////////////////////////////////////////////////////////// - -/** - * Kick given members from data union - * @param {List} memberAddressList to kick - * @returns {Promise} partMembers sidechain transaction - */ -export async function kick(memberAddressList, options = {}) { - const members = memberAddressList.map(getAddress) // throws if there are bad addresses - const duSidechain = await getSidechainContract(this, options) - const tx = await duSidechain.partMembers(members) - // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) - return tx.wait(options.confirmations || 1) -} - -/** - * Add given Ethereum addresses as data union members - * @param {List} memberAddressList to add - * @returns {Promise} addMembers sidechain transaction - */ -export async function addMembers(memberAddressList, options = {}) { - const members = memberAddressList.map(getAddress) // throws if there are bad addresses - const duSidechain = await getSidechainContract(this, options) - const tx = await duSidechain.addMembers(members) - // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) - return tx.wait(options.confirmations || 1) -} - -/** - * Admin: withdraw earnings (pay gas) on behalf of a member - * TODO: add test - * @param {EthereumAddress} memberAddress the other member who gets their tokens out of the Data Union - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {Promise} get receipt once withdraw transaction is confirmed - */ -export async function withdrawMember(memberAddress, options) { - const address = getAddress(memberAddress) // throws if bad address - const tr = await untilWithdrawIsComplete( - this, - this.getWithdrawMemberTx.bind(this, address), - this.getTokenBalance.bind(this, address), - { ...this.options, ...options } - ) - return tr -} - -/** - * Admin: get the tx promise for withdrawing all earnings on behalf of a member - * @param {EthereumAddress} memberAddress the other member who gets their tokens out of the Data Union - * @param {EthereumAddress} dataUnion to withdraw my earnings from - * @param {EthereumOptions} options - * @returns {Promise} await on call .wait to actually send the tx - */ -export async function getWithdrawMemberTx(memberAddress, options) { - const a = getAddress(memberAddress) // throws if bad address - const duSidechain = await getSidechainContract(this, options) - return duSidechain.withdrawAll(a, true) // sendToMainnet=true -} - -/** - * Admin: Withdraw a member's earnings to another address, signed by the member - * @param {EthereumAddress} dataUnion to withdraw my earnings from - * @param {EthereumAddress} memberAddress the member whose earnings are sent out - * @param {EthereumAddress} recipientAddress the address to receive the tokens in mainnet - * @param {string} signature from member, produced using signWithdrawTo - * @param {EthereumOptions} options - * @returns {Promise} get receipt once withdraw transaction is confirmed - */ -export async function withdrawToSigned(memberAddress, recipientAddress, signature, options) { - const from = getAddress(memberAddress) // throws if bad address - const to = getAddress(recipientAddress) - const tr = await untilWithdrawIsComplete( - this, - this.getWithdrawToSignedTx.bind(this, from, to, signature), - this.getTokenBalance.bind(this, to), - { ...this.options, ...options } - ) - return tr -} - -/** - * Admin: Withdraw a member's earnings to another address, signed by the member - * @param {EthereumAddress} dataUnion to withdraw my earnings from - * @param {EthereumAddress} memberAddress the member whose earnings are sent out - * @param {EthereumAddress} recipientAddress the address to receive the tokens in mainnet - * @param {string} signature from member, produced using signWithdrawTo - * @param {EthereumOptions} options - * @returns {Promise} await on call .wait to actually send the tx - */ -export async function getWithdrawToSignedTx(memberAddress, recipientAddress, signature, options) { - const duSidechain = await getSidechainContract(this, options) - return duSidechain.withdrawAllToSigned(memberAddress, recipientAddress, true, signature) // sendToMainnet=true -} - -/** - * Admin: set admin fee for the data union - * @param {number} newFeeFraction between 0.0 and 1.0 - * @param {EthereumOptions} options - */ -export async function setAdminFee(newFeeFraction, options) { - if (newFeeFraction < 0 || newFeeFraction > 1) { - throw new Error('newFeeFraction argument must be a number between 0...1, got: ' + newFeeFraction) - } - const adminFeeBN = BigNumber.from((newFeeFraction * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish - const duMainnet = getMainnetContract(this, options) - const tx = await duMainnet.setAdminFee(adminFeeBN) - return tx.wait() -} - -/** - * Get data union admin fee fraction that admin gets from each revenue event - * @returns {number} between 0.0 and 1.0 - */ -export async function getAdminFee(options) { - const duMainnet = getMainnetContractReadOnly(this, options) - const adminFeeBN = await duMainnet.adminFeeFraction() - return +adminFeeBN.toString() / 1e18 -} - -export async function getAdminAddress(options) { - const duMainnet = getMainnetContractReadOnly(this, options) - return duMainnet.owner() -} - -// ////////////////////////////////////////////////////////////////// -// member: JOIN & QUERY DATA UNION -// ////////////////////////////////////////////////////////////////// - -/** - * Send a joinRequest, or get into data union instantly with a data union secret - * @param {JoinOptions} options - * - * @typedef {object} JoinOptions - * @property {String} dataUnion Ethereum mainnet address of the data union. If not given, use one given when creating StreamrClient - * @property {String} member Ethereum mainnet address of the joining member. If not given, use StreamrClient authentication key - * @property {String} secret if given, and correct, join the data union immediately - */ -export async function joinDataUnion(options = {}) { - const { - member, - secret, - } = options - const dataUnion = getMainnetContractReadOnly(this, options) - - const body = { - memberAddress: parseAddress(this, member) - } - if (secret) { body.secret = secret } - - const url = getEndpointUrl(this.options.restUrl, 'dataunions', dataUnion.address, 'joinRequests') - return authFetch( - url, - this.session, - { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - }, - }, - ) -} - -/** - * Await this function when you want to make sure a member is accepted in the data union - * @param {EthereumAddress} memberAddress (optional, default is StreamrClient's auth: privateKey) - * @param {Number} pollingIntervalMs (optional, default: 1000) ask server if member is in - * @param {Number} retryTimeoutMs (optional, default: 60000) give up - * @return {Promise} resolves when member is in the data union (or fails with HTTP error) - */ -export async function hasJoined(memberAddress, options = {}) { - const { - pollingIntervalMs = 1000, - retryTimeoutMs = 60000, - } = options - const address = parseAddress(this, memberAddress) - const duSidechain = await getSidechainContractReadOnly(this, options) - - // memberData[0] is enum ActiveStatus {None, Active, Inactive}, and zero means member has never joined - await until(async () => (await duSidechain.memberData(address))[0] !== 0, retryTimeoutMs, pollingIntervalMs) -} - -// TODO: this needs more thought: probably something like getEvents from sidechain? Heavy on RPC? -export async function getMembers(options) { - const duSidechain = await getSidechainContractReadOnly(this, options) - throw new Error(`Not implemented for side-chain data union (at ${duSidechain.address})`) - // event MemberJoined(address indexed); - // event MemberParted(address indexed); -} - -export async function getDataUnionStats(options) { - const duSidechain = await getSidechainContractReadOnly(this, options) - const [ - totalEarnings, - totalEarningsWithdrawn, - activeMemberCount, - inactiveMemberCount, - lifetimeMemberEarnings, - joinPartAgentCount, - ] = await duSidechain.getStats() - const totalWithdrawable = totalEarnings.sub(totalEarningsWithdrawn) - return { - activeMemberCount, - inactiveMemberCount, - joinPartAgentCount, - totalEarnings, - totalWithdrawable, - lifetimeMemberEarnings, - } -} - -/** - * Get stats of a single data union member - * @param {EthereumAddress} dataUnion to query - * @param {EthereumAddress} memberAddress (optional) if not supplied, get the stats of currently logged in StreamrClient (if auth: privateKey) - */ -export async function getMemberStats(memberAddress, options) { - const address = parseAddress(this, memberAddress) - // TODO: use duSidechain.getMemberStats(address) once it's implemented, to ensure atomic read - // (so that memberData is from same block as getEarnings, otherwise withdrawable will be foobar) - const duSidechain = await getSidechainContractReadOnly(this, options) - const mdata = await duSidechain.memberData(address) - const total = await duSidechain.getEarnings(address).catch(() => 0) - const withdrawnEarnings = mdata[3].toString() - const withdrawable = total ? total.sub(withdrawnEarnings) : 0 - return { - status: ['unknown', 'active', 'inactive', 'blocked'][mdata[0]], - earningsBeforeLastJoin: mdata[1].toString(), - lmeAtJoin: mdata[2].toString(), - totalEarnings: total.toString(), - withdrawableEarnings: withdrawable.toString(), - } -} - -/** - * Get the amount of tokens the member would get from a successful withdraw - * @param dataUnion to query - * @param memberAddress whose balance is returned - * @return {Promise} - */ -export async function getMemberBalance(memberAddress, options) { - const address = parseAddress(this, memberAddress) - const duSidechain = await getSidechainContractReadOnly(this, options) - return duSidechain.getWithdrawableEarnings(address) -} - -/** - * Get token balance for given address - * @param {EthereumAddress} address - * @param options such as tokenAddress. If not given, then first check if - * dataUnion was given in StreamrClient constructor, then check if tokenAddress - * was given in StreamrClient constructor. - * @returns {Promise} token balance in "wei" (10^-18 parts) - */ -export async function getTokenBalance(address, options) { - const a = parseAddress(this, address) - const tokenAddressMainnet = options.tokenAddress || ( - await getMainnetContractReadOnly(this, options).then((c) => c.token()).catch(() => null) || this.options.tokenAddress - ) - if (!tokenAddressMainnet) { throw new Error('tokenAddress option not found') } - const provider = this.ethereum.getMainnetProvider() - const token = new Contract(tokenAddressMainnet, [{ - name: 'balanceOf', - inputs: [{ type: 'address' }], - outputs: [{ type: 'uint256' }], - constant: true, - payable: false, - stateMutability: 'view', - type: 'function' - }], provider) - return token.balanceOf(a) -} - -/** - * Figure out if given mainnet address is old DataUnion (v 1.0) or current 2.0 - * NOTE: Current version of streamr-client-javascript can only handle current version! - * @param {EthereumAddress} contractAddress - * @returns {number} 1 for old, 2 for current, zero for "not a data union" - */ -export async function getDataUnionVersion(contractAddress) { - const a = getAddress(contractAddress) // throws if bad address - const provider = this.ethereum.getMainnetProvider() - const du = new Contract(a, [{ - name: 'version', - inputs: [], - outputs: [{ type: 'uint256' }], - stateMutability: 'view', - type: 'function' - }], provider) - try { - const version = await du.version() - return +version - } catch (e) { - return 0 - } -} - -// ////////////////////////////////////////////////////////////////// -// member: WITHDRAW EARNINGS -// ////////////////////////////////////////////////////////////////// - -/** - * Withdraw all your earnings - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {Promise} get receipt once withdraw is complete (tokens are seen in mainnet) - */ -export async function withdraw(options = {}) { - const tr = await untilWithdrawIsComplete( - this, - this.getWithdrawTx.bind(this), - this.getTokenBalance.bind(this, null), // null means this StreamrClient's auth credentials - { ...this.options, ...options } - ) - return tr -} - -/** - * Get the tx promise for withdrawing all your earnings - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {Promise} await on call .wait to actually send the tx - */ -export async function getWithdrawTx(options) { - const signer = await this.ethereum.getSidechainSigner() - const address = await signer.getAddress() - const duSidechain = await getSidechainContract(this, options) - - const withdrawable = await duSidechain.getWithdrawableEarnings(address) - if (withdrawable.eq(0)) { - throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) - } - - if (this.options.minimumWithdrawTokenWei && withdrawable.lt(this.options.minimumWithdrawTokenWei)) { - throw new Error(`${address} has only ${withdrawable} to withdraw in ` - + `(sidechain) data union ${duSidechain.address} (min: ${this.options.minimumWithdrawTokenWei})`) - } - return duSidechain.withdrawAll(address, true) // sendToMainnet=true -} - -/** - * Withdraw earnings and "donate" them to the given address - * @param {EthereumAddress} recipientAddress the address to receive the tokens - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {Promise} get receipt once withdraw is complete (tokens are seen in mainnet) - */ -export async function withdrawTo(recipientAddress, options = {}) { - const to = getAddress(recipientAddress) // throws if bad address - const tr = await untilWithdrawIsComplete( - this, - this.getWithdrawTxTo.bind(this, to), - this.getTokenBalance.bind(this, to), - { ...this.options, ...options } - ) - return tr -} - -/** - * Withdraw earnings and "donate" them to the given address - * @param {EthereumAddress} recipientAddress the address to receive the tokens - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {Promise} await on call .wait to actually send the tx - */ -export async function getWithdrawTxTo(recipientAddress, options) { - const signer = await this.ethereum.getSidechainSigner() - const address = await signer.getAddress() - const duSidechain = await getSidechainContract(this, options) - const withdrawable = await duSidechain.getWithdrawableEarnings(address) - if (withdrawable.eq(0)) { - throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) - } - return duSidechain.withdrawAllTo(recipientAddress, true) // sendToMainnet=true -} - -/** - * Member can sign off to "donate" all earnings to another address such that someone else - * can submit the transaction (and pay for the gas) - * This signature is only valid until next withdrawal takes place (using this signature or otherwise). - * Note that while it's a "blank cheque" for withdrawing all earnings at the moment it's used, it's - * invalidated by the first withdraw after signing it. In other words, any signature can be invalidated - * by making a "normal" withdraw e.g. `await streamrClient.withdraw()` - * Admin can execute the withdraw using this signature: ``` - * await adminStreamrClient.withdrawToSigned(memberAddress, recipientAddress, signature) - * ``` - * @param {EthereumAddress} recipientAddress the address authorized to receive the tokens - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {string} signature authorizing withdrawing all earnings to given recipientAddress - */ -export async function signWithdrawTo(recipientAddress, options) { - return this.signWithdrawAmountTo(recipientAddress, BigNumber.from(0), options) -} - -/** - * Member can sign off to "donate" specific amount of earnings to another address such that someone else - * can submit the transaction (and pay for the gas) - * This signature is only valid until next withdrawal takes place (using this signature or otherwise). - * @param {EthereumAddress} recipientAddress the address authorized to receive the tokens - * @param {BigNumber|number|string} amountTokenWei that the signature is for (can't be used for less or for more) - * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) - * @returns {string} signature authorizing withdrawing all earnings to given recipientAddress - */ -export async function signWithdrawAmountTo(recipientAddress, amountTokenWei, options) { - const to = getAddress(recipientAddress) // throws if bad address - const signer = this.ethereum.getSigner() // it shouldn't matter if it's mainnet or sidechain signer since key should be the same - const address = await signer.getAddress() - const duSidechain = await getSidechainContractReadOnly(this, options) - const memberData = await duSidechain.memberData(address) - if (memberData[0] === '0') { throw new Error(`${address} is not a member in Data Union (sidechain address ${duSidechain.address})`) } - const withdrawn = memberData[3] - const message = to + hexZeroPad(amountTokenWei, 32).slice(2) + duSidechain.address.slice(2) + hexZeroPad(withdrawn, 32).slice(2) - const signature = await signer.signMessage(arrayify(message)) - return signature -} diff --git a/src/rest/DataUnionEndpoints.ts b/src/rest/DataUnionEndpoints.ts new file mode 100644 index 000000000..697a2347c --- /dev/null +++ b/src/rest/DataUnionEndpoints.ts @@ -0,0 +1,1142 @@ +/** + * Streamr Data Union related functions + * + * Table of Contents: + * ABIs + * helper utils + * admin: DEPLOY AND SETUP DATA UNION Functions for deploying the contract and adding secrets for smooth joining + * admin: MANAGE DATA UNION Kick and add members + * member: JOIN & QUERY DATA UNION Publicly available info about dataunions and their members (with earnings and proofs) + * member: WITHDRAW EARNINGS Withdrawing functions, there's many: normal, agent, donate + */ + +import { getAddress, isAddress } from '@ethersproject/address' +import { BigNumber } from '@ethersproject/bignumber' +import { arrayify, hexZeroPad } from '@ethersproject/bytes' +import { Contract } from '@ethersproject/contracts' +import { keccak256 } from '@ethersproject/keccak256' +import { verifyMessage } from '@ethersproject/wallet' +import debug from 'debug' +import StreamrClient from '../StreamrClient' +import { Todo } from '../types' + +import { until, getEndpointUrl } from '../utils' + +import authFetch from './authFetch' + +export interface DataUnionOptions { + wallet?: Todo, + provider?: Todo, + confirmations?: Todo, + gasPrice?: Todo, + dataUnion?: Todo, + tokenAddress?: Todo, + minimumWithdrawTokenWei?: BigNumber|number|string, + sidechainTokenAddress?: string, + factoryMainnetAddress?: string, + sidechainAmbAddress?: string, + payForSignatureTransport?: boolean +} + +const log = debug('StreamrClient::DataUnionEndpoints') +// const log = console.log // useful for debugging sometimes + +// /////////////////////////////////////////////////////////////////////// +// ABIs: contract functions we want to call within the client +// /////////////////////////////////////////////////////////////////////// + +const dataUnionMainnetABI = [{ + name: 'sendTokensToBridge', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'token', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'owner', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'setAdminFee', + inputs: [{ type: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'adminFeeFraction', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}] + +const dataUnionSidechainABI = [{ + name: 'addMembers', + inputs: [{ type: 'address[]', internalType: 'address payable[]', }], + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'partMembers', + inputs: [{ type: 'address[]' }], + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'withdrawAll', + inputs: [{ type: 'address' }, { type: 'bool' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'withdrawAllTo', + inputs: [{ type: 'address' }, { type: 'bool' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'withdrawAllToSigned', + inputs: [{ type: 'address' }, { type: 'address' }, { type: 'bool' }, { type: 'bytes' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + // enum ActiveStatus {None, Active, Inactive, Blocked} + // struct MemberInfo { + // ActiveStatus status; + // uint256 earnings_before_last_join; + // uint256 lme_at_join; + // uint256 withdrawnEarnings; + // } + name: 'memberData', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint8' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + inputs: [], + name: 'getStats', + outputs: [{ type: 'uint256[6]' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'getEarnings', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'getWithdrawableEarnings', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'lifetimeMemberEarnings', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'totalWithdrawable', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'totalEarnings', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'activeMemberCount', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + // this event is emitted by withdrawing process, + // see https://github.com/poanetwork/tokenbridge-contracts/blob/master/contracts/upgradeable_contracts/arbitrary_message/HomeAMB.sol + name: 'UserRequestForSignature', + inputs: [ + { indexed: true, name: 'messageId', type: 'bytes32' }, + { indexed: false, name: 'encodedData', type: 'bytes' } + ], + anonymous: false, + type: 'event' +}] + +// Only the part of ABI that is needed by deployment (and address resolution) +const factoryMainnetABI = [{ + type: 'constructor', + inputs: [{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'uint256' }], + stateMutability: 'nonpayable' +}, { + name: 'sidechainAddress', + inputs: [{ type: 'address' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'mainnetAddress', + inputs: [{ type: 'address' }, { type: 'string' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'deployNewDataUnion', + inputs: [{ type: 'address' }, { type: 'uint256' }, { type: 'address[]' }, { type: 'string' }], + outputs: [{ type: 'address' }], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'amb', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'data_union_sidechain_factory', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}] + +const mainnetAmbABI = [{ + name: 'executeSignatures', + inputs: [{ type: 'bytes' }, { type: 'bytes' }], // data, signatures + outputs: [], + stateMutability: 'nonpayable', + type: 'function' +}, { + name: 'messageCallStatus', + inputs: [{ type: 'bytes32' }], // messageId + outputs: [{ type: 'bool' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'failedMessageSender', + inputs: [{ type: 'bytes32' }], // messageId + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'relayedMessages', + inputs: [{ type: 'bytes32' }], // messageId, was called "_txhash" though?! + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'validatorContract', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' +}] + +const sidechainAmbABI = [{ + name: 'signature', + inputs: [{ type: 'bytes32' }, { type: 'uint256' }], // messageHash, index + outputs: [{ type: 'bytes' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'message', + inputs: [{ type: 'bytes32' }], // messageHash + outputs: [{ type: 'bytes' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'requiredSignatures', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}, { + name: 'numMessagesSigned', + inputs: [{ type: 'bytes32' }], // messageHash (TODO: double check) + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' +}] + +// ////////////////////////////////////////////////////////////////// +// Contract utils +// ////////////////////////////////////////////////////////////////// + +/** @typedef {String} EthereumAddress */ + +function throwIfBadAddress(address: string, variableDescription: Todo) { + try { + return getAddress(address) + } catch (e) { + throw new Error(`${variableDescription || 'Error'}: Bad Ethereum address ${address}. Original error: ${e.stack}.`) + } +} + +/** + * Parse address, or use this client's auth address if input not given + * @param {StreamrClient} this + * @param {EthereumAddress} inputAddress from user (NOT case sensitive) + * @returns {EthereumAddress} with checksum case + */ +function parseAddress(client: StreamrClient, inputAddress: string|null|undefined) { + if (inputAddress && isAddress(inputAddress)) { + return getAddress(inputAddress) + } + return client.getAddress() +} + +// Find the Asyncronous Message-passing Bridge sidechain ("home") contract +let cachedSidechainAmb: Todo +async function getSidechainAmb(client: StreamrClient, options: DataUnionOptions) { + if (!cachedSidechainAmb) { + const getAmbPromise = async () => { + const mainnetProvider = client.ethereum.getMainnetProvider() + const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress + const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, mainnetProvider) + const sidechainProvider = client.ethereum.getSidechainProvider() + const factorySidechainAddress = await factoryMainnet.data_union_sidechain_factory() + const factorySidechain = new Contract(factorySidechainAddress, [{ + name: 'amb', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function' + // @ts-expect-error + }], sidechainProvider) + const sidechainAmbAddress = await factorySidechain.amb() + // @ts-expect-error + return new Contract(sidechainAmbAddress, sidechainAmbABI, sidechainProvider) + } + cachedSidechainAmb = getAmbPromise() + cachedSidechainAmb = await cachedSidechainAmb // eslint-disable-line require-atomic-updates + } + return cachedSidechainAmb +} + +async function getMainnetAmb(client: StreamrClient, options: DataUnionOptions) { + const mainnetProvider = client.ethereum.getMainnetProvider() + const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress + const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, mainnetProvider) + const mainnetAmbAddress = await factoryMainnet.amb() + return new Contract(mainnetAmbAddress, mainnetAmbABI, mainnetProvider) +} + +async function requiredSignaturesHaveBeenCollected(client: StreamrClient, messageHash: Todo, options: DataUnionOptions = {}) { + const sidechainAmb = await getSidechainAmb(client, options) + const requiredSignatureCount = await sidechainAmb.requiredSignatures() + + // Bit 255 is set to mark completion, double check though + const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) + const collectedSignatureCount = sigCountStruct.mask(255) + const markedComplete = sigCountStruct.shr(255).gt(0) + + log(`${collectedSignatureCount.toString()} out of ${requiredSignatureCount.toString()} collected`) + if (markedComplete) { log('All signatures collected') } + return markedComplete +} + +// move signatures from sidechain to mainnet +async function transportSignatures(client: StreamrClient, messageHash: Todo, options: DataUnionOptions) { + const sidechainAmb = await getSidechainAmb(client, options) + const message = await sidechainAmb.message(messageHash) + const messageId = '0x' + message.substr(2, 64) + const sigCountStruct = await sidechainAmb.numMessagesSigned(messageHash) + const collectedSignatureCount = sigCountStruct.mask(255).toNumber() + + log(`${collectedSignatureCount} signatures reported, getting them from the sidechain AMB...`) + const signatures = await Promise.all(Array(collectedSignatureCount).fill(0).map(async (_, i) => sidechainAmb.signature(messageHash, i))) + + const [vArray, rArray, sArray]: Todo = [[], [], []] + signatures.forEach((signature: string, i) => { + log(` Signature ${i}: ${signature} (len=${signature.length}=${signature.length / 2 - 1} bytes)`) + rArray.push(signature.substr(2, 64)) + sArray.push(signature.substr(66, 64)) + vArray.push(signature.substr(130, 2)) + }) + const packedSignatures = BigNumber.from(signatures.length).toHexString() + vArray.join('') + rArray.join('') + sArray.join('') + log(`All signatures packed into one: ${packedSignatures}`) + + // Gas estimation also checks that the transaction would succeed, and provides a helpful error message in case it would fail + const mainnetAmb = await getMainnetAmb(client, options) + log(`Estimating gas using mainnet AMB @ ${mainnetAmb.address}, message=${message}`) + let gasLimit + try { + // magic number suggested by https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/utils/constants.js + // @ts-expect-error + gasLimit = await mainnetAmb.estimateGas.executeSignatures(message, packedSignatures) + 200000 + log(`Calculated gas limit: ${gasLimit.toString()}`) + } catch (e) { + // Failure modes from https://github.com/poanetwork/tokenbridge/blob/master/oracle/src/events/processAMBCollectedSignatures/estimateGas.js + log('Gas estimation failed: Check if the message was already processed') + const alreadyProcessed = await mainnetAmb.relayedMessages(messageId) + if (alreadyProcessed) { + log(`WARNING: Tried to transport signatures but they have already been transported (Message ${messageId} has already been processed)`) + log('This could happen if payForSignatureTransport=true, but bridge operator also pays for signatures, and got there before your client') + return null + } + + log('Gas estimation failed: Check if number of signatures is enough') + const mainnetProvider = client.ethereum.getMainnetProvider() + const validatorContractAddress = await mainnetAmb.validatorContract() + const validatorContract = new Contract(validatorContractAddress, [{ + name: 'isValidator', + inputs: [{ type: 'address' }], + outputs: [{ type: 'bool' }], + stateMutability: 'view', + type: 'function' + }, { + name: 'requiredSignatures', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }], mainnetProvider) + const requiredSignatures = await validatorContract.requiredSignatures() + if (requiredSignatures.gt(signatures.length)) { + throw new Error('The number of required signatures does not match between sidechain(' + + signatures.length + ' and mainnet( ' + requiredSignatures.toString()) + } + + log('Gas estimation failed: Check if all the signatures were made by validators') + log(` Recover signer addresses from signatures [${signatures.join(', ')}]`) + const signers = signatures.map((signature) => verifyMessage(arrayify(message), signature)) + log(` Check that signers are validators [[${signers.join(', ')}]]`) + const isValidatorArray = await Promise.all(signers.map((address) => [address, validatorContract.isValidator(address)])) + const nonValidatorSigners = isValidatorArray.filter(([, isValidator]) => !isValidator) + if (nonValidatorSigners.length > 0) { + throw new Error(`Following signers are not listed as validators in mainnet validator contract at ${validatorContractAddress}:\n - ` + + nonValidatorSigners.map(([address]) => address).join('\n - ')) + } + + throw new Error(`Gas estimation failed: Unknown error while processing message ${message} with ${e.stack}`) + } + + const signer = client.ethereum.getSigner() + // @ts-expect-error + log(`Sending message from signer=${await signer.getAddress()}`) + // @ts-expect-error + const txAMB = await mainnetAmb.connect(signer).executeSignatures(message, packedSignatures) + const trAMB = await txAMB.wait() + return trAMB +} + +// template for withdraw functions +// client could be replaced with AMB (mainnet and sidechain) +async function untilWithdrawIsComplete(client: StreamrClient, getWithdrawTxFunc: (options: DataUnionOptions) => Todo, getBalanceFunc: (options: DataUnionOptions) => Todo, options: DataUnionOptions = {}) { + const { + pollingIntervalMs = 1000, + retryTimeoutMs = 60000, + }: Todo = options + const balanceBefore = await getBalanceFunc(options) + const tx = await getWithdrawTxFunc(options) + const tr = await tx.wait() + + if (options.payForSignatureTransport) { + log(`Got receipt, filtering UserRequestForSignature from ${tr.events.length} events...`) + // event UserRequestForSignature(bytes32 indexed messageId, bytes encodedData); + const sigEventArgsArray = tr.events.filter((e: Todo) => e.event === 'UserRequestForSignature').map((e: Todo) => e.args) + if (sigEventArgsArray.length < 1) { + throw new Error("No UserRequestForSignature events emitted from withdraw transaction, can't transport withdraw to mainnet") + } + /* eslint-disable no-await-in-loop */ + // eslint-disable-next-line no-restricted-syntax + for (const eventArgs of sigEventArgsArray) { + const messageId = eventArgs[0] + const messageHash = keccak256(eventArgs[1]) + + log(`Waiting until sidechain AMB has collected required signatures for hash=${messageHash}...`) + await until(async () => requiredSignaturesHaveBeenCollected(client, messageHash, options), pollingIntervalMs, retryTimeoutMs) + + log(`Checking mainnet AMB hasn't already processed messageId=${messageId}`) + const mainnetAmb = await getMainnetAmb(client, options) + const alreadySent = await mainnetAmb.messageCallStatus(messageId) + const failAddress = await mainnetAmb.failedMessageSender(messageId) + if (alreadySent || failAddress !== '0x0000000000000000000000000000000000000000') { // zero address means no failed messages + log(`WARNING: Mainnet bridge has already processed withdraw messageId=${messageId}`) + log([ + 'This could happen if payForSignatureTransport=true, but bridge operator also pays for', + 'signatures, and got there before your client', + ].join(' ')) + continue + } + + log(`Transporting signatures for hash=${messageHash}`) + await transportSignatures(client, messageHash, options) + } + /* eslint-enable no-await-in-loop */ + } + + log(`Waiting for balance ${balanceBefore.toString()} to change`) + await until(async () => !(await getBalanceFunc(options)).eq(balanceBefore), retryTimeoutMs, pollingIntervalMs) + + return tr +} + +// TODO: calculate addresses in JS instead of asking over RPC, see data-union-solidity/contracts/CloneLib.sol +// key the cache with name only, since PROBABLY one StreamrClient will ever use only one private key +const mainnetAddressCache: Todo = {} // mapping: "name" -> mainnet address +/** @returns {Promise} Mainnet address for Data Union */ +async function getDataUnionMainnetAddress(client: StreamrClient, dataUnionName: string, deployerAddress: string, options: DataUnionOptions = {}) { + if (!mainnetAddressCache[dataUnionName]) { + const provider = client.ethereum.getMainnetProvider() + const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress + const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, provider) + const addressPromise = factoryMainnet.mainnetAddress(deployerAddress, dataUnionName) + mainnetAddressCache[dataUnionName] = addressPromise + mainnetAddressCache[dataUnionName] = await addressPromise // eslint-disable-line require-atomic-updates + } + return mainnetAddressCache[dataUnionName] +} + +// TODO: calculate addresses in JS +const sidechainAddressCache: Todo = {} // mapping: mainnet address -> sidechain address +/** @returns {Promise} Sidechain address for Data Union */ +async function getDataUnionSidechainAddress(client: StreamrClient, duMainnetAddress: string, options: DataUnionOptions = {}) { + if (!sidechainAddressCache[duMainnetAddress]) { + const provider = client.ethereum.getMainnetProvider() + const factoryMainnetAddress = options.factoryMainnetAddress || client.options.factoryMainnetAddress + const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, provider) + const addressPromise = factoryMainnet.sidechainAddress(duMainnetAddress) + sidechainAddressCache[duMainnetAddress] = addressPromise + sidechainAddressCache[duMainnetAddress] = await addressPromise // eslint-disable-line require-atomic-updates + } + return sidechainAddressCache[duMainnetAddress] +} + +function getMainnetContractReadOnly(client: StreamrClient, options: DataUnionOptions = {}) { + // @ts-expect-error + let dataUnion = options.dataUnion || options.dataUnionAddress || client.options.dataUnion + if (isAddress(dataUnion)) { + const provider = client.ethereum.getMainnetProvider() + dataUnion = new Contract(dataUnion, dataUnionMainnetABI, provider) + } + + if (!(dataUnion instanceof Contract)) { + throw new Error(`Option dataUnion=${dataUnion} was not a good Ethereum address or Contract`) + } + return dataUnion +} + +function getMainnetContract(client: StreamrClient, options: DataUnionOptions = {}) { + const du = getMainnetContractReadOnly(client, options) + const signer = client.ethereum.getSigner() + // @ts-expect-error + return du.connect(signer) +} + +async function getSidechainContract(client: StreamrClient, options: DataUnionOptions = {}) { + const signer = await client.ethereum.getSidechainSigner() + const duMainnet = getMainnetContractReadOnly(client, options) + const duSidechainAddress = await getDataUnionSidechainAddress(client, duMainnet.address, options) + // @ts-expect-error + const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, signer) + return duSidechain +} + +async function getSidechainContractReadOnly(client: StreamrClient, options: DataUnionOptions = {}) { + const provider = await client.ethereum.getSidechainProvider() + const duMainnet = getMainnetContractReadOnly(client, options) + const duSidechainAddress = await getDataUnionSidechainAddress(client, duMainnet.address, options) + // @ts-expect-error + const duSidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, provider) + return duSidechain +} + +export class DataUnionEndpoints { + + client: StreamrClient + + constructor(client: StreamrClient) { + this.client = client + } + + // ////////////////////////////////////////////////////////////////// + // admin: DEPLOY AND SETUP DATA UNION + // ////////////////////////////////////////////////////////////////// + + async calculateDataUnionMainnetAddress(dataUnionName: string, deployerAddress: string, options: DataUnionOptions) { + const address = getAddress(deployerAddress) // throws if bad address + return getDataUnionMainnetAddress(this.client, dataUnionName, address, options) + } + + async calculateDataUnionSidechainAddress(duMainnetAddress: string, options: DataUnionOptions) { + const address = getAddress(duMainnetAddress) // throws if bad address + return getDataUnionSidechainAddress(this.client, address, options) + } + + /** + * TODO: update this comment + * @typedef {object} EthereumOptions all optional, hence "options" + * @property {Wallet | string} wallet or private key, default is currently logged in StreamrClient (if auth: privateKey) + * @property {string} key private key, alias for String wallet + * @property {string} privateKey, alias for String wallet + * @property {providers.Provider} provider to use in case wallet was a String, or omitted + * @property {number} confirmations, default is 1 + * @property {BigNumber} gasPrice in wei (part of ethers overrides), default is whatever the network recommends (ethers.js default) + * @see https://docs.ethers.io/ethers.js/html/api-contract.html#overrides + */ + /** + * @typedef {object} AdditionalDeployOptions for deployDataUnion + * @property {EthereumAddress} owner new data union owner, defaults to StreamrClient authenticated user + * @property {Array} joinPartAgents defaults to just the owner + * @property {number} adminFee fraction (number between 0...1 where 1 means 100%) + * @property {EthereumAddress} factoryMainnetAddress defaults to StreamrClient options + * @property {string} dataUnionName unique (to the DataUnionFactory) identifier of the new data union, must not exist yet + */ + /** + * @typedef {EthereumOptions & AdditionalDeployOptions} DeployOptions + */ + // TODO: gasPrice to overrides (not needed for browser, but would be useful in node.js) + + /** + * Create a new DataUnionMainnet contract to mainnet with DataUnionFactoryMainnet + * This triggers DataUnionSidechain contract creation in sidechain, over the bridge (AMB) + * @param {DeployOptions} options such as adminFee (default: 0) + * @return {Promise} that resolves when the new DU is deployed over the bridge to side-chain + */ + async deployDataUnion(options: DataUnionOptions = {}) { + const { + owner, + joinPartAgents, + dataUnionName, + adminFee = 0, + sidechainPollingIntervalMs = 1000, + sidechainRetryTimeoutMs = 600000, + }: Todo = options + + let duName = dataUnionName + if (!duName) { + duName = `DataUnion-${Date.now()}` // TODO: use uuid + log(`dataUnionName generated: ${duName}`) + } + + if (adminFee < 0 || adminFee > 1) { throw new Error('options.adminFeeFraction must be a number between 0...1, got: ' + adminFee) } + const adminFeeBN = BigNumber.from((adminFee * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish + + const mainnetProvider = this.client.ethereum.getMainnetProvider() + const mainnetWallet = this.client.ethereum.getSigner() + const sidechainProvider = this.client.ethereum.getSidechainProvider() + + // parseAddress defaults to authenticated user (also if "owner" is not an address) + const ownerAddress = parseAddress(this.client, owner) + + let agentAddressList + if (Array.isArray(joinPartAgents)) { + // getAddress throws if there's an invalid address in the array + agentAddressList = joinPartAgents.map(getAddress) + } else { + // streamrNode needs to be joinPartAgent so that EE join with secret works (and join approvals from Marketplace UI) + agentAddressList = [ownerAddress] + if (this.client.options.streamrNodeAddress) { + agentAddressList.push(getAddress(this.client.options.streamrNodeAddress)) + } + } + + // @ts-expect-error + const duMainnetAddress = await getDataUnionMainnetAddress(this.client, duName, ownerAddress, options) + const duSidechainAddress = await getDataUnionSidechainAddress(this.client, duMainnetAddress, options) + + if (await mainnetProvider.getCode(duMainnetAddress) !== '0x') { + throw new Error(`Mainnet data union "${duName}" contract ${duMainnetAddress} already exists!`) + } + + const factoryMainnetAddress = throwIfBadAddress( + (options.factoryMainnetAddress || this.client.options.factoryMainnetAddress)!, + 'StreamrClient.options.factoryMainnetAddress' + ) + if (await mainnetProvider.getCode(factoryMainnetAddress) === '0x') { + throw new Error(`Data union factory contract not found at ${factoryMainnetAddress}, check StreamrClient.options.factoryMainnetAddress!`) + } + + // function deployNewDataUnion(address owner, uint256 adminFeeFraction, address[] agents, string duName) + // @ts-expect-error + const factoryMainnet = new Contract(factoryMainnetAddress!, factoryMainnetABI, mainnetWallet) + const tx = await factoryMainnet.deployNewDataUnion( + ownerAddress, + adminFeeBN, + agentAddressList, + duName, + ) + const tr = await tx.wait() + + log(`Data Union "${duName}" (mainnet: ${duMainnetAddress}, sidechain: ${duSidechainAddress}) deployed to mainnet, waiting for side-chain...`) + await until( + // @ts-expect-error + async () => await sidechainProvider.getCode(duSidechainAddress) !== '0x', + sidechainRetryTimeoutMs, + sidechainPollingIntervalMs + ) + + // @ts-expect-error + const dataUnion = new Contract(duMainnetAddress, dataUnionMainnetABI, mainnetWallet) + // @ts-expect-error + dataUnion.deployTxReceipt = tr + // @ts-expect-error + dataUnion.sidechain = new Contract(duSidechainAddress, dataUnionSidechainABI, sidechainProvider) + return dataUnion + } + + async getDataUnionContract(options: DataUnionOptions = {}) { + const ret = getMainnetContract(this.client, options) + // @ts-expect-error + ret.sidechain = await getSidechainContract(this.client, options) + return ret + } + + /** + * Add a new data union secret + * @param {EthereumAddress} dataUnionMainnetAddress + * @param {String} name describes the secret + * @returns {String} the server-generated secret + */ + async createSecret(dataUnionMainnetAddress: string, name: string = 'Untitled Data Union Secret') { + const duAddress = getAddress(dataUnionMainnetAddress) // throws if bad address + const url = getEndpointUrl(this.client.options.restUrl, 'dataunions', duAddress, 'secrets') + const res = await authFetch( + url, + this.client.session, + { + method: 'POST', + body: JSON.stringify({ + name + }), + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + return res.secret + } + + // ////////////////////////////////////////////////////////////////// + // admin: MANAGE DATA UNION + // ////////////////////////////////////////////////////////////////// + + /** + * Kick given members from data union + * @param {List} memberAddressList to kick + * @returns {Promise} partMembers sidechain transaction + */ + async kick(memberAddressList: string[], options: DataUnionOptions = {}) { + const members = memberAddressList.map(getAddress) // throws if there are bad addresses + const duSidechain = await getSidechainContract(this.client, options) + const tx = await duSidechain.partMembers(members) + // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) + return tx.wait(options.confirmations || 1) + } + + /** + * Add given Ethereum addresses as data union members + * @param {List} memberAddressList to add + * @returns {Promise} addMembers sidechain transaction + */ + async addMembers(memberAddressList: string[], options: DataUnionOptions = {}) { + const members = memberAddressList.map(getAddress) // throws if there are bad addresses + const duSidechain = await getSidechainContract(this.client, options) + const tx = await duSidechain.addMembers(members) + // TODO: wrap promise for better error reporting in case tx fails (parse reason, throw proper error) + return tx.wait(options.confirmations || 1) + } + + /** + * Admin: withdraw earnings (pay gas) on behalf of a member + * TODO: add test + * @param {EthereumAddress} memberAddress the other member who gets their tokens out of the Data Union + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {Promise} get receipt once withdraw transaction is confirmed + */ + async withdrawMember(memberAddress: string, options: DataUnionOptions) { + const address = getAddress(memberAddress) // throws if bad address + const tr = await untilWithdrawIsComplete( + this.client, + (opts) => this.getWithdrawMemberTx(address, opts), + (opts) => this.getTokenBalance(address, opts), + { ...this.client.options, ...options } + ) + return tr + } + + /** + * Admin: get the tx promise for withdrawing all earnings on behalf of a member + * @param {EthereumAddress} memberAddress the other member who gets their tokens out of the Data Union + * @param {EthereumAddress} dataUnion to withdraw my earnings from + * @param {EthereumOptions} options + * @returns {Promise} await on call .wait to actually send the tx + */ + async getWithdrawMemberTx(memberAddress: string, options: DataUnionOptions) { + const a = getAddress(memberAddress) // throws if bad address + const duSidechain = await getSidechainContract(this.client, options) + return duSidechain.withdrawAll(a, true) // sendToMainnet=true + } + + /** + * Admin: Withdraw a member's earnings to another address, signed by the member + * @param {EthereumAddress} dataUnion to withdraw my earnings from + * @param {EthereumAddress} memberAddress the member whose earnings are sent out + * @param {EthereumAddress} recipientAddress the address to receive the tokens in mainnet + * @param {string} signature from member, produced using signWithdrawTo + * @param {EthereumOptions} options + * @returns {Promise} get receipt once withdraw transaction is confirmed + */ + async withdrawToSigned(memberAddress: string, recipientAddress: string, signature: string, options: DataUnionOptions) { + const from = getAddress(memberAddress) // throws if bad address + const to = getAddress(recipientAddress) + const tr = await untilWithdrawIsComplete( + this.client, + (opts) => this.getWithdrawToSignedTx(from, to, signature, opts), + (opts) => this.getTokenBalance(to, opts), + { ...this.client.options, ...options } + ) + return tr + } + + /** + * Admin: Withdraw a member's earnings to another address, signed by the member + * @param {EthereumAddress} dataUnion to withdraw my earnings from + * @param {EthereumAddress} memberAddress the member whose earnings are sent out + * @param {EthereumAddress} recipientAddress the address to receive the tokens in mainnet + * @param {string} signature from member, produced using signWithdrawTo + * @param {EthereumOptions} options + * @returns {Promise} await on call .wait to actually send the tx + */ + async getWithdrawToSignedTx(memberAddress: string, recipientAddress: string, signature: string, options: DataUnionOptions) { + const duSidechain = await getSidechainContract(this.client, options) + return duSidechain.withdrawAllToSigned(memberAddress, recipientAddress, true, signature) // sendToMainnet=true + } + + /** + * Admin: set admin fee for the data union + * @param {number} newFeeFraction between 0.0 and 1.0 + * @param {EthereumOptions} options + */ + async setAdminFee(newFeeFraction: number, options: DataUnionOptions) { + if (newFeeFraction < 0 || newFeeFraction > 1) { + throw new Error('newFeeFraction argument must be a number between 0...1, got: ' + newFeeFraction) + } + const adminFeeBN = BigNumber.from((newFeeFraction * 1e18).toFixed()) // last 2...3 decimals are going to be gibberish + const duMainnet = getMainnetContract(this.client, options) + const tx = await duMainnet.setAdminFee(adminFeeBN) + return tx.wait() + } + + /** + * Get data union admin fee fraction that admin gets from each revenue event + * @returns {number} between 0.0 and 1.0 + */ + async getAdminFee(options: DataUnionOptions) { + const duMainnet = getMainnetContractReadOnly(this.client, options) + const adminFeeBN = await duMainnet.adminFeeFraction() + return +adminFeeBN.toString() / 1e18 + } + + async getAdminAddress(options: DataUnionOptions) { + const duMainnet = getMainnetContractReadOnly(this.client, options) + return duMainnet.owner() + } + + // ////////////////////////////////////////////////////////////////// + // member: JOIN & QUERY DATA UNION + // ////////////////////////////////////////////////////////////////// + + /** + * Send a joinRequest, or get into data union instantly with a data union secret + * @param {JoinOptions} options + * + * @typedef {object} JoinOptions + * @property {String} dataUnion Ethereum mainnet address of the data union. If not given, use one given when creating StreamrClient + * @property {String} member Ethereum mainnet address of the joining member. If not given, use StreamrClient authentication key + * @property {String} secret if given, and correct, join the data union immediately + */ + async joinDataUnion(options: DataUnionOptions = {}) { + const { + member, + secret, + }: Todo = options + const dataUnion = getMainnetContractReadOnly(this.client, options) + + const body = { + memberAddress: parseAddress(this.client, member) + } + // @ts-expect-error + if (secret) { body.secret = secret } + + const url = getEndpointUrl(this.client.options.restUrl, 'dataunions', dataUnion.address, 'joinRequests') + return authFetch( + url, + this.client.session, + { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + } + + /** + * Await this function when you want to make sure a member is accepted in the data union + * @param {EthereumAddress} memberAddress (optional, default is StreamrClient's auth: privateKey) + * @param {Number} pollingIntervalMs (optional, default: 1000) ask server if member is in + * @param {Number} retryTimeoutMs (optional, default: 60000) give up + * @return {Promise} resolves when member is in the data union (or fails with HTTP error) + */ + async hasJoined(memberAddress: string, options: DataUnionOptions = {}) { + const { + pollingIntervalMs = 1000, + retryTimeoutMs = 60000, + }: Todo = options + const address = parseAddress(this.client, memberAddress) + const duSidechain = await getSidechainContractReadOnly(this.client, options) + + // memberData[0] is enum ActiveStatus {None, Active, Inactive}, and zero means member has never joined + await until(async () => (await duSidechain.memberData(address))[0] !== 0, retryTimeoutMs, pollingIntervalMs) + } + + // TODO: this needs more thought: probably something like getEvents from sidechain? Heavy on RPC? + async getMembers(options: DataUnionOptions) { + const duSidechain = await getSidechainContractReadOnly(this.client, options) + throw new Error(`Not implemented for side-chain data union (at ${duSidechain.address})`) + // event MemberJoined(address indexed); + // event MemberParted(address indexed); + } + + async getDataUnionStats(options: DataUnionOptions) { + const duSidechain = await getSidechainContractReadOnly(this.client, options) + const [ + totalEarnings, + totalEarningsWithdrawn, + activeMemberCount, + inactiveMemberCount, + lifetimeMemberEarnings, + joinPartAgentCount, + ] = await duSidechain.getStats() + const totalWithdrawable = totalEarnings.sub(totalEarningsWithdrawn) + return { + activeMemberCount, + inactiveMemberCount, + joinPartAgentCount, + totalEarnings, + totalWithdrawable, + lifetimeMemberEarnings, + } + } + + /** + * Get stats of a single data union member + * @param {EthereumAddress} dataUnion to query + * @param {EthereumAddress} memberAddress (optional) if not supplied, get the stats of currently logged in StreamrClient (if auth: privateKey) + */ + async getMemberStats(memberAddress: string, options: DataUnionOptions) { + const address = parseAddress(this.client, memberAddress) + // TODO: use duSidechain.getMemberStats(address) once it's implemented, to ensure atomic read + // (so that memberData is from same block as getEarnings, otherwise withdrawable will be foobar) + const duSidechain = await getSidechainContractReadOnly(this.client, options) + const mdata = await duSidechain.memberData(address) + const total = await duSidechain.getEarnings(address).catch(() => 0) + const withdrawnEarnings = mdata[3].toString() + const withdrawable = total ? total.sub(withdrawnEarnings) : 0 + return { + status: ['unknown', 'active', 'inactive', 'blocked'][mdata[0]], + earningsBeforeLastJoin: mdata[1].toString(), + lmeAtJoin: mdata[2].toString(), + totalEarnings: total.toString(), + withdrawableEarnings: withdrawable.toString(), + } + } + + /** + * Get the amount of tokens the member would get from a successful withdraw + * @param dataUnion to query + * @param memberAddress whose balance is returned + * @return {Promise} + */ + async getMemberBalance(memberAddress: string, options: DataUnionOptions) { + const address = parseAddress(this.client, memberAddress) + const duSidechain = await getSidechainContractReadOnly(this.client, options) + return duSidechain.getWithdrawableEarnings(address) + } + + /** + * Get token balance for given address + * @param {EthereumAddress} address + * @param options such as tokenAddress. If not given, then first check if + * dataUnion was given in StreamrClient constructor, then check if tokenAddress + * was given in StreamrClient constructor. + * @returns {Promise} token balance in "wei" (10^-18 parts) + */ + async getTokenBalance(address: string|null|undefined, options: DataUnionOptions) { + const a = parseAddress(this.client, address) + const tokenAddressMainnet = options.tokenAddress || ( + await getMainnetContractReadOnly(this.client, options).then((c: Todo) => c.token()).catch(() => null) || this.client.options.tokenAddress + ) + if (!tokenAddressMainnet) { throw new Error('tokenAddress option not found') } + const provider = this.client.ethereum.getMainnetProvider() + const token = new Contract(tokenAddressMainnet, [{ + name: 'balanceOf', + inputs: [{ type: 'address' }], + outputs: [{ type: 'uint256' }], + constant: true, + payable: false, + stateMutability: 'view', + type: 'function' + }], provider) + return token.balanceOf(a) + } + + /** + * Figure out if given mainnet address is old DataUnion (v 1.0) or current 2.0 + * NOTE: Current version of streamr-client-javascript can only handle current version! + * @param {EthereumAddress} contractAddress + * @returns {number} 1 for old, 2 for current, zero for "not a data union" + */ + async getDataUnionVersion(contractAddress: string) { + const a = getAddress(contractAddress) // throws if bad address + const provider = this.client.ethereum.getMainnetProvider() + const du = new Contract(a, [{ + name: 'version', + inputs: [], + outputs: [{ type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }], provider) + try { + const version = await du.version() + return +version + } catch (e) { + return 0 + } + } + + // ////////////////////////////////////////////////////////////////// + // member: WITHDRAW EARNINGS + // ////////////////////////////////////////////////////////////////// + + /** + * Withdraw all your earnings + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {Promise} get receipt once withdraw is complete (tokens are seen in mainnet) + */ + async withdraw(options: DataUnionOptions = {}) { + const tr = await untilWithdrawIsComplete( + this.client, + (opts) => this.getWithdrawTx(opts), + (opts) => this.getTokenBalance(null, opts), // null means this StreamrClient's auth credentials + { ...this.client.options, ...options } + ) + return tr + } + + /** + * Get the tx promise for withdrawing all your earnings + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {Promise} await on call .wait to actually send the tx + */ + async getWithdrawTx(options: DataUnionOptions) { + const signer = await this.client.ethereum.getSidechainSigner() + // @ts-expect-error + const address = await signer.getAddress() + const duSidechain = await getSidechainContract(this.client, options) + + const withdrawable = await duSidechain.getWithdrawableEarnings(address) + if (withdrawable.eq(0)) { + throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) + } + + if (this.client.options.minimumWithdrawTokenWei && withdrawable.lt(this.client.options.minimumWithdrawTokenWei)) { + throw new Error(`${address} has only ${withdrawable} to withdraw in ` + + `(sidechain) data union ${duSidechain.address} (min: ${this.client.options.minimumWithdrawTokenWei})`) + } + return duSidechain.withdrawAll(address, true) // sendToMainnet=true + } + + /** + * Withdraw earnings and "donate" them to the given address + * @param {EthereumAddress} recipientAddress the address to receive the tokens + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {Promise} get receipt once withdraw is complete (tokens are seen in mainnet) + */ + async withdrawTo(recipientAddress: string, options: DataUnionOptions = {}) { + const to = getAddress(recipientAddress) // throws if bad address + const tr = await untilWithdrawIsComplete( + this.client, + (opts) => this.getWithdrawTxTo(to, opts), + (opts) => this.getTokenBalance(to, opts), + { ...this.client.options, ...options } + ) + return tr + } + + /** + * Withdraw earnings and "donate" them to the given address + * @param {EthereumAddress} recipientAddress the address to receive the tokens + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {Promise} await on call .wait to actually send the tx + */ + async getWithdrawTxTo(recipientAddress: string, options: DataUnionOptions) { + const signer = await this.client.ethereum.getSidechainSigner() + // @ts-expect-error + const address = await signer.getAddress() + const duSidechain = await getSidechainContract(this.client, options) + const withdrawable = await duSidechain.getWithdrawableEarnings(address) + if (withdrawable.eq(0)) { + throw new Error(`${address} has nothing to withdraw in (sidechain) data union ${duSidechain.address}`) + } + return duSidechain.withdrawAllTo(recipientAddress, true) // sendToMainnet=true + } + + /** + * Member can sign off to "donate" all earnings to another address such that someone else + * can submit the transaction (and pay for the gas) + * This signature is only valid until next withdrawal takes place (using this signature or otherwise). + * Note that while it's a "blank cheque" for withdrawing all earnings at the moment it's used, it's + * invalidated by the first withdraw after signing it. In other words, any signature can be invalidated + * by making a "normal" withdraw e.g. `await streamrClient.withdraw()` + * Admin can execute the withdraw using this signature: ``` + * await adminStreamrClient.withdrawToSigned(memberAddress, recipientAddress, signature) + * ``` + * @param {EthereumAddress} recipientAddress the address authorized to receive the tokens + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {string} signature authorizing withdrawing all earnings to given recipientAddress + */ + async signWithdrawTo(recipientAddress: string, options: DataUnionOptions) { + return this.signWithdrawAmountTo(recipientAddress, BigNumber.from(0), options) + } + + /** + * Member can sign off to "donate" specific amount of earnings to another address such that someone else + * can submit the transaction (and pay for the gas) + * This signature is only valid until next withdrawal takes place (using this signature or otherwise). + * @param {EthereumAddress} recipientAddress the address authorized to receive the tokens + * @param {BigNumber|number|string} amountTokenWei that the signature is for (can't be used for less or for more) + * @param {EthereumOptions} options (including e.g. `dataUnion` Contract object or address) + * @returns {string} signature authorizing withdrawing all earnings to given recipientAddress + */ + async signWithdrawAmountTo(recipientAddress: string, amountTokenWei: BigNumber|number|string, options: DataUnionOptions) { + const to = getAddress(recipientAddress) // throws if bad address + const signer = this.client.ethereum.getSigner() // it shouldn't matter if it's mainnet or sidechain signer since key should be the same + // @ts-expect-error + const address = await signer.getAddress() + const duSidechain = await getSidechainContractReadOnly(this.client, options) + const memberData = await duSidechain.memberData(address) + if (memberData[0] === '0') { throw new Error(`${address} is not a member in Data Union (sidechain address ${duSidechain.address})`) } + const withdrawn = memberData[3] + // @ts-expect-error + const message = to + hexZeroPad(amountTokenWei, 32).slice(2) + duSidechain.address.slice(2) + hexZeroPad(withdrawn, 32).slice(2) + // @ts-expect-error + const signature = await signer.signMessage(arrayify(message)) + return signature + } +} + diff --git a/src/rest/LoginEndpoints.js b/src/rest/LoginEndpoints.js deleted file mode 100644 index 2ab9f967e..000000000 --- a/src/rest/LoginEndpoints.js +++ /dev/null @@ -1,100 +0,0 @@ -import { getEndpointUrl } from '../utils' - -import authFetch, { AuthFetchError } from './authFetch' - -async function getSessionToken(url, props) { - return authFetch( - url, - undefined, - { - method: 'POST', - body: JSON.stringify(props), - headers: { - 'Content-Type': 'application/json', - }, - }, - ) -} - -export async function getChallenge(address) { - this.debug('getChallenge %o', { - address, - }) - const url = getEndpointUrl(this.options.restUrl, 'login', 'challenge', address) - return authFetch( - url, - undefined, - { - method: 'POST', - }, - ) -} - -export async function sendChallengeResponse(challenge, signature, address) { - this.debug('sendChallengeResponse %o', { - challenge, - signature, - address, - }) - const url = getEndpointUrl(this.options.restUrl, 'login', 'response') - const props = { - challenge, - signature, - address, - } - return getSessionToken(url, props) -} - -export async function loginWithChallengeResponse(signingFunction, address) { - this.debug('loginWithChallengeResponse %o', { - address, - }) - const challenge = await this.getChallenge(address) - const signature = await signingFunction(challenge.challenge) - return this.sendChallengeResponse(challenge, signature, address) -} - -export async function loginWithApiKey(apiKey) { - this.debug('loginWithApiKey %o', { - apiKey, - }) - const url = getEndpointUrl(this.options.restUrl, 'login', 'apikey') - const props = { - apiKey, - } - return getSessionToken(url, props) -} - -export async function loginWithUsernamePassword(username, password) { - this.debug('loginWithUsernamePassword %o', { - username, - }) - const url = getEndpointUrl(this.options.restUrl, 'login', 'password') - const props = { - username, - password, - } - try { - return await getSessionToken(url, props) - } catch (err) { - if (err && err.response && err.response.status === 404) { - // this 404s if running against new backend with username/password support removed - // wrap with appropriate error message - const message = 'username/password auth is no longer supported. Please create an ethereum identity.' - throw new AuthFetchError(message, err.response, err.body) - } - throw err - } -} - -export async function getUserInfo() { - this.debug('getUserInfo') - return authFetch(`${this.options.restUrl}/users/me`, this.session) -} - -export async function logoutEndpoint() { - this.debug('logoutEndpoint') - return authFetch(`${this.options.restUrl}/logout`, this.session, { - method: 'POST', - }) -} diff --git a/src/rest/LoginEndpoints.ts b/src/rest/LoginEndpoints.ts new file mode 100644 index 000000000..af281830f --- /dev/null +++ b/src/rest/LoginEndpoints.ts @@ -0,0 +1,110 @@ +import StreamrClient from '../StreamrClient' +import { getEndpointUrl } from '../utils' + +import authFetch, { AuthFetchError } from './authFetch' + +async function getSessionToken(url: string, props: any) { + return authFetch( + url, + undefined, + { + method: 'POST', + body: JSON.stringify(props), + headers: { + 'Content-Type': 'application/json', + }, + }, + ) +} + +export class LoginEndpoints { + + client: StreamrClient + + constructor(client: StreamrClient) { + this.client = client + } + + async getChallenge(address: string) { + this.client.debug('getChallenge %o', { + address, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'login', 'challenge', address) + return authFetch( + url, + undefined, + { + method: 'POST', + }, + ) + } + + async sendChallengeResponse(challenge: string, signature: string, address: string) { + this.client.debug('sendChallengeResponse %o', { + challenge, + signature, + address, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'login', 'response') + const props = { + challenge, + signature, + address, + } + return getSessionToken(url, props) + } + + async loginWithChallengeResponse(signingFunction: (challenge: string) => Promise, address: string) { + this.client.debug('loginWithChallengeResponse %o', { + address, + }) + const challenge = await this.getChallenge(address) + const signature = await signingFunction(challenge.challenge) + return this.sendChallengeResponse(challenge, signature, address) + } + + async loginWithApiKey(apiKey: string) { + this.client.debug('loginWithApiKey %o', { + apiKey, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'login', 'apikey') + const props = { + apiKey, + } + return getSessionToken(url, props) + } + + async loginWithUsernamePassword(username: string, password: string) { + this.client.debug('loginWithUsernamePassword %o', { + username, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'login', 'password') + const props = { + username, + password, + } + try { + return await getSessionToken(url, props) + } catch (err) { + if (err && err.response && err.response.status === 404) { + // this 404s if running against new backend with username/password support removed + // wrap with appropriate error message + const message = 'username/password auth is no longer supported. Please create an ethereum identity.' + throw new AuthFetchError(message, err.response, err.body) + } + throw err + } + } + + async getUserInfo() { + this.client.debug('getUserInfo') + return authFetch(`${this.client.options.restUrl}/users/me`, this.client.session) + } + + async logoutEndpoint() { + this.client.debug('logoutEndpoint') + return authFetch(`${this.client.options.restUrl}/logout`, this.client.session, { + method: 'POST', + }) + } +} diff --git a/src/rest/StreamEndpoints.js b/src/rest/StreamEndpoints.js deleted file mode 100644 index 923b8e2e3..000000000 --- a/src/rest/StreamEndpoints.js +++ /dev/null @@ -1,237 +0,0 @@ -import { Agent as HttpAgent } from 'http' -import { Agent as HttpsAgent } from 'https' - -import qs from 'qs' -import debugFactory from 'debug' - -import { getEndpointUrl } from '../utils' -import { validateOptions } from '../stream/utils' -import Stream from '../stream' -import StreamPart from '../stream/StreamPart' -import { isKeyExchangeStream } from '../stream/KeyExchange' - -import authFetch from './authFetch' - -const debug = debugFactory('StreamrClient') - -const agentSettings = { - keepAlive: true, - keepAliveMsecs: 5000, -} - -const agentByProtocol = { - http: new HttpAgent(agentSettings), - https: new HttpsAgent(agentSettings), -} - -function getKeepAliveAgentForUrl(url) { - if (url.startsWith('https')) { - return agentByProtocol.https - } - - if (url.startsWith('http')) { - return agentByProtocol.http - } - - throw new Error(`Unknown protocol in URL: ${url}`) -} - -// These function are mixed in to StreamrClient.prototype. -// In the below functions, 'this' is intended to be the StreamrClient -export async function getStream(streamId) { - this.debug('getStream %o', { - streamId, - }) - - if (isKeyExchangeStream(streamId)) { - return new Stream(this, { - id: streamId, - partitions: 1, - }) - } - - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId) - try { - const json = await authFetch(url, this.session) - return new Stream(this, json) - } catch (e) { - if (e.response && e.response.status === 404) { - return undefined - } - throw e - } -} - -export async function listStreams(query = {}) { - this.debug('listStreams %o', { - query, - }) - const url = getEndpointUrl(this.options.restUrl, 'streams') + '?' + qs.stringify(query) - const json = await authFetch(url, this.session) - return json ? json.map((stream) => new Stream(this, stream)) : [] -} - -export async function getStreamByName(name) { - this.debug('getStreamByName %o', { - name, - }) - const json = await this.listStreams({ - name, - public: false, - }) - return json[0] ? new Stream(this, json[0]) : undefined -} - -export async function createStream(props) { - this.debug('createStream %o', { - props, - }) - - const json = await authFetch( - getEndpointUrl(this.options.restUrl, 'streams'), - this.session, - { - method: 'POST', - body: JSON.stringify(props), - }, - ) - return json ? new Stream(this, json) : undefined -} - -export async function getOrCreateStream(props) { - this.debug('getOrCreateStream %o', { - props, - }) - let json - - // Try looking up the stream by id or name, whichever is defined - if (props.id) { - json = await this.getStream(props.id) - } else if (props.name) { - json = await this.getStreamByName(props.name) - } - - // If not found, try creating the stream - if (!json) { - json = await this.createStream(props) - debug('Created stream: %s (%s)', props.name, json.id) - } - - // If still nothing, throw - if (!json) { - throw new Error(`Unable to find or create stream: ${props.name || props.id}`) - } else { - return new Stream(this, json) - } -} - -export async function getStreamPublishers(streamId) { - this.debug('getStreamPublishers %o', { - streamId, - }) - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId, 'publishers') - const json = await authFetch(url, this.session) - return json.addresses.map((a) => a.toLowerCase()) -} - -export async function isStreamPublisher(streamId, ethAddress) { - this.debug('isStreamPublisher %o', { - streamId, - ethAddress, - }) - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId, 'publisher', ethAddress) - try { - await authFetch(url, this.session) - return true - } catch (e) { - this.debug(e) - if (e.response && e.response.status === 404) { - return false - } - throw e - } -} - -export async function getStreamSubscribers(streamId) { - this.debug('getStreamSubscribers %o', { - streamId, - }) - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId, 'subscribers') - const json = await authFetch(url, this.session) - return json.addresses.map((a) => a.toLowerCase()) -} - -export async function isStreamSubscriber(streamId, ethAddress) { - this.debug('isStreamSubscriber %o', { - streamId, - ethAddress, - }) - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId, 'subscriber', ethAddress) - try { - await authFetch(url, this.session) - return true - } catch (e) { - if (e.response && e.response.status === 404) { - return false - } - throw e - } -} - -export async function getStreamValidationInfo(streamId) { - this.debug('getStreamValidationInfo %o', { - streamId, - }) - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId, 'validation') - const json = await authFetch(url, this.session) - return json -} - -export async function getStreamLast(streamObjectOrId) { - const { streamId, streamPartition = 0, count = 1 } = validateOptions(streamObjectOrId) - this.debug('getStreamLast %o', { - streamId, - streamPartition, - count, - }) - const query = { - count, - } - - const url = getEndpointUrl(this.options.restUrl, 'streams', streamId, 'data', 'partitions', streamPartition, 'last') + `?${qs.stringify(query)}` - const json = await authFetch(url, this.session) - return json -} - -export async function getStreamPartsByStorageNode(address) { - const json = await authFetch(getEndpointUrl(this.options.restUrl, 'storageNodes', address, 'streams'), this.session) - let result = [] - json.forEach((stream) => { - result = result.concat(StreamPart.fromStream(stream)) - }) - return result -} - -export async function publishHttp(streamObjectOrId, data, requestOptions = {}, keepAlive = true) { - let streamId - if (streamObjectOrId instanceof Stream) { - streamId = streamObjectOrId.id - } else { - streamId = streamObjectOrId - } - this.debug('publishHttp %o', { - streamId, data, - }) - - // Send data to the stream - return authFetch( - getEndpointUrl(this.options.restUrl, 'streams', streamId, 'data'), - this.session, - { - ...requestOptions, - method: 'POST', - body: JSON.stringify(data), - agent: keepAlive ? getKeepAliveAgentForUrl(this.options.restUrl) : undefined, - }, - ) -} diff --git a/src/rest/StreamEndpoints.ts b/src/rest/StreamEndpoints.ts new file mode 100644 index 000000000..95a8071f8 --- /dev/null +++ b/src/rest/StreamEndpoints.ts @@ -0,0 +1,261 @@ +import { Agent as HttpAgent } from 'http' +import { Agent as HttpsAgent } from 'https' + +import qs from 'qs' +import debugFactory from 'debug' + +import { getEndpointUrl } from '../utils' +import { validateOptions } from '../stream/utils' +import Stream, { StreamOperation, StreamProperties } from '../stream' +import StreamPart from '../stream/StreamPart' +import { isKeyExchangeStream } from '../stream/KeyExchange' + +import authFetch from './authFetch' +import { Todo } from '../types' +import StreamrClient from '../StreamrClient' + +const debug = debugFactory('StreamrClient') + +export interface StreamListQuery { + name?: string + uiChannel?: boolean + noConfig?: boolean + search?: string + sortBy?: string + order?: 'asc'|'desc' + max?: number + offset?: number + grantedAccess?: boolean + publicAccess?: boolean + operation?: StreamOperation +} + +const agentSettings = { + keepAlive: true, + keepAliveMsecs: 5000, +} + +const agentByProtocol = { + http: new HttpAgent(agentSettings), + https: new HttpsAgent(agentSettings), +} + +function getKeepAliveAgentForUrl(url: string) { + if (url.startsWith('https')) { + return agentByProtocol.https + } + + if (url.startsWith('http')) { + return agentByProtocol.http + } + + throw new Error(`Unknown protocol in URL: ${url}`) +} + +export class StreamEndpoints { + + client: StreamrClient + + constructor(client: StreamrClient) { + this.client = client + } + + async getStream(streamId: string) { + this.client.debug('getStream %o', { + streamId, + }) + + if (isKeyExchangeStream(streamId)) { + return new Stream(this.client, { + id: streamId, + partitions: 1, + }) + } + + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId) + try { + const json = await authFetch(url, this.client.session) + return new Stream(this.client, json) + } catch (e) { + if (e.response && e.response.status === 404) { + return undefined + } + throw e + } + } + + async listStreams(query: StreamListQuery = {}) { + this.client.debug('listStreams %o', { + query, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'streams') + '?' + qs.stringify(query) + const json = await authFetch(url, this.client.session) + return json ? json.map((stream: any) => new Stream(this.client, stream)) : [] + } + + async getStreamByName(name: string) { + this.client.debug('getStreamByName %o', { + name, + }) + const json = await this.listStreams({ + name, + // @ts-expect-error + public: false, + }) + return json[0] ? new Stream(this.client, json[0]) : undefined + } + + async createStream(props: StreamProperties) { + this.client.debug('createStream %o', { + props, + }) + + const json = await authFetch( + getEndpointUrl(this.client.options.restUrl, 'streams'), + this.client.session, + { + method: 'POST', + body: JSON.stringify(props), + }, + ) + return json ? new Stream(this.client, json) : undefined + } + + async getOrCreateStream(props: { id?: string, name?: string }) { + this.client.debug('getOrCreateStream %o', { + props, + }) + let json: any + + // Try looking up the stream by id or name, whichever is defined + if (props.id) { + json = await this.getStream(props.id) + } else if (props.name) { + json = await this.getStreamByName(props.name) + } + + // If not found, try creating the stream + if (!json) { + json = await this.createStream(props) + debug('Created stream: %s (%s)', props.name, json.id) + } + + // If still nothing, throw + if (!json) { + throw new Error(`Unable to find or create stream: ${props.name || props.id}`) + } else { + return new Stream(this.client, json) + } + } + + async getStreamPublishers(streamId: string) { + this.client.debug('getStreamPublishers %o', { + streamId, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'publishers') + const json = await authFetch(url, this.client.session) + return json.addresses.map((a: string) => a.toLowerCase()) + } + + async isStreamPublisher(streamId: string, ethAddress: string) { + this.client.debug('isStreamPublisher %o', { + streamId, + ethAddress, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'publisher', ethAddress) + try { + await authFetch(url, this.client.session) + return true + } catch (e) { + this.client.debug(e) + if (e.response && e.response.status === 404) { + return false + } + throw e + } + } + + async getStreamSubscribers(streamId: string) { + this.client.debug('getStreamSubscribers %o', { + streamId, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'subscribers') + const json = await authFetch(url, this.client.session) + return json.addresses.map((a: string) => a.toLowerCase()) + } + + async isStreamSubscriber(streamId: string, ethAddress: string) { + this.client.debug('isStreamSubscriber %o', { + streamId, + ethAddress, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'subscriber', ethAddress) + try { + await authFetch(url, this.client.session) + return true + } catch (e) { + if (e.response && e.response.status === 404) { + return false + } + throw e + } + } + + async getStreamValidationInfo(streamId: string) { + this.client.debug('getStreamValidationInfo %o', { + streamId, + }) + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'validation') + const json = await authFetch(url, this.client.session) + return json + } + + async getStreamLast(streamObjectOrId: Stream|string) { + const { streamId, streamPartition = 0, count = 1 } = validateOptions(streamObjectOrId) + this.client.debug('getStreamLast %o', { + streamId, + streamPartition, + count, + }) + const query = { + count, + } + + const url = getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'data', 'partitions', streamPartition, 'last') + `?${qs.stringify(query)}` + const json = await authFetch(url, this.client.session) + return json + } + + async getStreamPartsByStorageNode(address: string) { + const json = await authFetch(getEndpointUrl(this.client.options.restUrl, 'storageNodes', address, 'streams'), this.client.session) + let result: StreamPart[] = [] + json.forEach((stream: { id: string, partitions: number }) => { + result = result.concat(StreamPart.fromStream(stream)) + }) + return result + } + + async publishHttp(streamObjectOrId: Stream|string, data: Todo, requestOptions: Todo = {}, keepAlive: boolean = true) { + let streamId + if (streamObjectOrId instanceof Stream) { + streamId = streamObjectOrId.id + } else { + streamId = streamObjectOrId + } + this.client.debug('publishHttp %o', { + streamId, data, + }) + + // Send data to the stream + return authFetch( + getEndpointUrl(this.client.options.restUrl, 'streams', streamId, 'data'), + this.client.session, + { + ...requestOptions, + method: 'POST', + body: JSON.stringify(data), + agent: keepAlive ? getKeepAliveAgentForUrl(this.client.options.restUrl!) : undefined, + }, + ) + } +} diff --git a/src/stream/StreamPart.js b/src/stream/StreamPart.ts similarity index 62% rename from src/stream/StreamPart.js rename to src/stream/StreamPart.ts index e3a3eab4d..9371b7ae6 100644 --- a/src/stream/StreamPart.js +++ b/src/stream/StreamPart.ts @@ -1,11 +1,15 @@ export default class StreamPart { - constructor(streamId, streamPartition) { + + _streamId: string + _streamPartition: number + + constructor(streamId: string, streamPartition: number) { this._streamId = streamId this._streamPartition = streamPartition } - static fromStream({ id, partitions }) { - const result = [] + static fromStream({ id, partitions }: { id: string, partitions: number }) { + const result: StreamPart[] = [] for (let i = 0; i < partitions; i++) { result.push(new StreamPart(id, i)) } diff --git a/src/stream/index.js b/src/stream/index.ts similarity index 78% rename from src/stream/index.js rename to src/stream/index.ts index cd1f1d658..7f9c4ac9f 100644 --- a/src/stream/index.js +++ b/src/stream/index.ts @@ -2,9 +2,28 @@ import { getEndpointUrl } from '../utils' import authFetch from '../rest/authFetch' import StorageNode from './StorageNode' +import StreamrClient from '../StreamrClient' +import { Todo } from '../types' + +export enum StreamOperation { + STREAM_GET = 'stream_get', + STREAM_EDIT = 'stream_edit', + STREAM_DELETE = 'stream_delete', + STREAM_PUBLISH = 'stream_publish', + STREAM_SUBSCRIBE = 'stream_subscribe', + STREAM_SHARE = 'stream_share' +} + +export type StreamProperties = Todo export default class Stream { - constructor(client, props) { + + // TODO add field definitions for all fields + // @ts-expect-error + id: string + _client: StreamrClient + + constructor(client: StreamrClient, props: StreamProperties) { this._client = client Object.assign(this, props) } @@ -25,6 +44,7 @@ export default class Stream { const result = {} Object.keys(this).forEach((key) => { if (!key.startsWith('_')) { + // @ts-expect-error result[key] = this[key] } }) @@ -55,13 +75,13 @@ export default class Stream { ) } - async hasPermission(operation, userId) { + async hasPermission(operation: StreamOperation, userId: string|undefined) { // eth addresses may be in checksumcase, but userId from server has no case const userIdCaseInsensitive = typeof userId === 'string' ? userId.toLowerCase() : undefined // if not string then undefined const permissions = await this.getPermissions() - return permissions.find((p) => { + return permissions.find((p: any) => { if (p.operation !== operation) { return false } if (userIdCaseInsensitive === undefined) { @@ -71,8 +91,8 @@ export default class Stream { }) } - async grantPermission(operation, userId) { - const permissionObject = { + async grantPermission(operation: StreamOperation, userId: string|undefined) { + const permissionObject: any = { operation, } @@ -94,7 +114,7 @@ export default class Stream { ) } - async revokePermission(permissionId) { + async revokePermission(permissionId: number) { return authFetch( getEndpointUrl(this._client.options.restUrl, 'streams', this.id, 'permissions', permissionId), this._client.session, @@ -111,7 +131,7 @@ export default class Stream { ) } - async addToStorageNode(address) { + async addToStorageNode(address: string) { return authFetch( getEndpointUrl(this._client.options.restUrl, 'streams', this.id, 'storageNodes'), this._client.session, @@ -124,7 +144,7 @@ export default class Stream { ) } - async removeFromStorageNode(address) { + async removeFromStorageNode(address: string) { return authFetch( getEndpointUrl(this._client.options.restUrl, 'streams', this.id, 'storageNodes', address), this._client.session, @@ -139,10 +159,10 @@ export default class Stream { getEndpointUrl(this._client.options.restUrl, 'streams', this.id, 'storageNodes'), this._client.session, ) - return json.map((item) => new StorageNode(item.storageNodeAddress)) + return json.map((item: any) => new StorageNode(item.storageNodeAddress)) } - async publish(...theArgs) { + async publish(...theArgs: Todo) { return this._client.publish(this.id, ...theArgs) } } diff --git a/src/subscribe/index.js b/src/subscribe/index.js index a41decbe4..456ec49ef 100644 --- a/src/subscribe/index.js +++ b/src/subscribe/index.js @@ -10,7 +10,7 @@ import Validator from './Validator' import messageStream from './messageStream' import resendStream from './resendStream' -class Subscription extends Emitter { +export class Subscription extends Emitter { constructor(client, opts, onFinally = () => {}) { super() this.client = client diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 000000000..a90812a10 --- /dev/null +++ b/src/types.ts @@ -0,0 +1 @@ +export type Todo = any diff --git a/test/flakey/EnvStressTest.test.js b/test/flakey/EnvStressTest.test.js index f8e5d54f8..d1da5f4d9 100644 --- a/test/flakey/EnvStressTest.test.js +++ b/test/flakey/EnvStressTest.test.js @@ -1,5 +1,5 @@ import { pTimeout } from '../../src/utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { fakePrivateKey, uid } from '../utils' import config from '../integration/config' diff --git a/test/integration/DataUnionEndpoints/DataUnionEndpoints.test.js b/test/integration/DataUnionEndpoints/DataUnionEndpoints.test.js index 1a6f7e504..0a5ab0a21 100644 --- a/test/integration/DataUnionEndpoints/DataUnionEndpoints.test.js +++ b/test/integration/DataUnionEndpoints/DataUnionEndpoints.test.js @@ -5,7 +5,7 @@ import debug from 'debug' import { getEndpointUrl } from '../../../src/utils' import authFetch from '../../../src/rest/authFetch' -import StreamrClient from '../../../src' +import StreamrClient from '../../../src/StreamrClient' import * as Token from '../../../contracts/TestToken.json' import config from '../config' diff --git a/test/integration/DataUnionEndpoints/adminWithdrawMember.test.js b/test/integration/DataUnionEndpoints/adminWithdrawMember.test.js index bc29b2967..f220d6f04 100644 --- a/test/integration/DataUnionEndpoints/adminWithdrawMember.test.js +++ b/test/integration/DataUnionEndpoints/adminWithdrawMember.test.js @@ -3,7 +3,7 @@ import { formatEther, parseEther } from 'ethers/lib/utils' import debug from 'debug' import { getEndpointUrl, until } from '../../../src/utils' -import StreamrClient from '../../../src' +import StreamrClient from '../../../src/StreamrClient' import * as Token from '../../../contracts/TestToken.json' import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' import config from '../config' diff --git a/test/integration/DataUnionEndpoints/adminWithdrawSigned.test.js b/test/integration/DataUnionEndpoints/adminWithdrawSigned.test.js index 9d018e4de..dcb58630d 100644 --- a/test/integration/DataUnionEndpoints/adminWithdrawSigned.test.js +++ b/test/integration/DataUnionEndpoints/adminWithdrawSigned.test.js @@ -3,7 +3,7 @@ import { formatEther, parseEther } from 'ethers/lib/utils' import debug from 'debug' import { getEndpointUrl, until } from '../../../src/utils' -import StreamrClient from '../../../src' +import StreamrClient from '../../../src/StreamrClient' import * as Token from '../../../contracts/TestToken.json' import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' import config from '../config' diff --git a/test/integration/DataUnionEndpoints/calculate.test.js b/test/integration/DataUnionEndpoints/calculate.test.js index 0fcc5e96d..e7d92ca4e 100644 --- a/test/integration/DataUnionEndpoints/calculate.test.js +++ b/test/integration/DataUnionEndpoints/calculate.test.js @@ -1,7 +1,7 @@ import { providers, Wallet } from 'ethers' import debug from 'debug' -import StreamrClient from '../../../src' +import StreamrClient from '../../../src/StreamrClient' import config from '../config' const log = debug('StreamrClient::DataUnionEndpoints::integration-test-calculate') diff --git a/test/integration/DataUnionEndpoints/withdraw.test.js b/test/integration/DataUnionEndpoints/withdraw.test.js index cf7b28624..08d4a0e8d 100644 --- a/test/integration/DataUnionEndpoints/withdraw.test.js +++ b/test/integration/DataUnionEndpoints/withdraw.test.js @@ -3,7 +3,7 @@ import { formatEther, parseEther } from 'ethers/lib/utils' import debug from 'debug' import { getEndpointUrl, until } from '../../../src/utils' -import StreamrClient from '../../../src' +import StreamrClient from '../../../src/StreamrClient' import * as Token from '../../../contracts/TestToken.json' import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' import config from '../config' diff --git a/test/integration/DataUnionEndpoints/withdrawTo.test.js b/test/integration/DataUnionEndpoints/withdrawTo.test.js index bfabe7fed..b7624c9e0 100644 --- a/test/integration/DataUnionEndpoints/withdrawTo.test.js +++ b/test/integration/DataUnionEndpoints/withdrawTo.test.js @@ -3,7 +3,7 @@ import { formatEther, parseEther } from 'ethers/lib/utils' import debug from 'debug' import { getEndpointUrl, until } from '../../../src/utils' -import StreamrClient from '../../../src' +import StreamrClient from '../../../src/StreamrClient' import * as Token from '../../../contracts/TestToken.json' import * as DataUnionSidechain from '../../../contracts/DataUnionSidechain.json' import config from '../config' diff --git a/test/integration/Encryption.test.js b/test/integration/Encryption.test.js index bd0d08341..3ac930bd1 100644 --- a/test/integration/Encryption.test.js +++ b/test/integration/Encryption.test.js @@ -3,7 +3,7 @@ import { MessageLayer } from 'streamr-client-protocol' import { fakePrivateKey, uid, Msg, getPublishTestMessages } from '../utils' import { Defer } from '../../src/utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { GroupKey } from '../../src/stream/Encryption' import Connection from '../../src/Connection' diff --git a/test/integration/GapFill.test.js b/test/integration/GapFill.test.js index c6aee66d4..af2b7e7ec 100644 --- a/test/integration/GapFill.test.js +++ b/test/integration/GapFill.test.js @@ -1,7 +1,7 @@ import { wait } from 'streamr-test-utils' import { uid, fakePrivateKey, describeRepeats, getPublishTestMessages } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import Connection from '../../src/Connection' import config from './config' diff --git a/test/integration/LoginEndpoints.test.js b/test/integration/LoginEndpoints.test.js index 7ceb8213a..a544a483b 100644 --- a/test/integration/LoginEndpoints.test.js +++ b/test/integration/LoginEndpoints.test.js @@ -2,7 +2,7 @@ import assert from 'assert' import { ethers } from 'ethers' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import config from './config' @@ -27,7 +27,7 @@ describe('LoginEndpoints', () => { describe('Challenge generation', () => { it('should retrieve a challenge', async () => { - const challenge = await client.getChallenge('some-address') + const challenge = await client.loginEndpoints.getChallenge('some-address') assert(challenge) assert(challenge.id) assert(challenge.challenge) @@ -38,7 +38,7 @@ describe('LoginEndpoints', () => { describe('Challenge response', () => { it('should fail to get a session token', async () => { await expect(async () => { - await client.sendChallengeResponse({ + await client.loginEndpoints.sendChallengeResponse({ id: 'some-id', challenge: 'some-challenge', }, 'some-sig', 'some-address') @@ -47,10 +47,10 @@ describe('LoginEndpoints', () => { it('should get a session token', async () => { const wallet = ethers.Wallet.createRandom() - const challenge = await client.getChallenge(wallet.address) + const challenge = await client.loginEndpoints.getChallenge(wallet.address) assert(challenge.challenge) const signature = await wallet.signMessage(challenge.challenge) - const sessionToken = await client.sendChallengeResponse(challenge, signature, wallet.address) + const sessionToken = await client.loginEndpoints.sendChallengeResponse(challenge, signature, wallet.address) assert(sessionToken) assert(sessionToken.token) assert(sessionToken.expires) @@ -58,7 +58,7 @@ describe('LoginEndpoints', () => { it('should get a session token with combined function', async () => { const wallet = ethers.Wallet.createRandom() - const sessionToken = await client.loginWithChallengeResponse((d) => wallet.signMessage(d), wallet.address) + const sessionToken = await client.loginEndpoints.loginWithChallengeResponse((d) => wallet.signMessage(d), wallet.address) assert(sessionToken) assert(sessionToken.token) assert(sessionToken.expires) @@ -73,7 +73,7 @@ describe('LoginEndpoints', () => { }) it('should get a session token', async () => { - const sessionToken = await client.loginWithApiKey('tester1-api-key') + const sessionToken = await client.loginEndpoints.loginWithApiKey('tester1-api-key') assert(sessionToken) assert(sessionToken.token) assert(sessionToken.expires) @@ -83,14 +83,14 @@ describe('LoginEndpoints', () => { describe('Username/password login', () => { it('should fail', async () => { await expect(async () => { - await client.loginWithUsernamePassword('username', 'password') + await client.loginEndpoints.loginWithUsernamePassword('username', 'password') }).rejects.toThrow('no longer supported') }) }) describe('UserInfo', () => { it('should get user info', async () => { - const userInfo = await client.getUserInfo() + const userInfo = await client.loginEndpoints.getUserInfo() assert(userInfo.name) assert(userInfo.username) }) @@ -100,7 +100,7 @@ describe('LoginEndpoints', () => { it('should not be able to use the same session token after logout', async () => { await client.getUserInfo() // first fetches the session token, then requests the endpoint const sessionToken1 = client.session.options.sessionToken - await client.logoutEndpoint() // invalidates the session token in engine-and-editor + await client.loginEndpoints.logoutEndpoint() // invalidates the session token in engine-and-editor await client.getUserInfo() // requests the endpoint with sessionToken1, receives 401, fetches a new session token const sessionToken2 = client.session.options.sessionToken assert.notDeepStrictEqual(sessionToken1, sessionToken2) diff --git a/test/integration/MultipleClients.test.js b/test/integration/MultipleClients.test.js index 62436a04a..56bce9cad 100644 --- a/test/integration/MultipleClients.test.js +++ b/test/integration/MultipleClients.test.js @@ -1,7 +1,7 @@ import { wait } from 'streamr-test-utils' import { describeRepeats, uid, fakePrivateKey, getWaitForStorage, getPublishTestMessages, addAfterFn } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { counterId } from '../../src/utils' import Connection from '../../src/Connection' diff --git a/test/integration/ResendReconnect.test.js b/test/integration/ResendReconnect.test.js index 22031a99c..dc650c20e 100644 --- a/test/integration/ResendReconnect.test.js +++ b/test/integration/ResendReconnect.test.js @@ -1,7 +1,7 @@ import { wait, waitForCondition } from 'streamr-test-utils' import { uid, fakePrivateKey, getPublishTestMessages } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { Defer } from '../../src/utils' import config from './config' diff --git a/test/integration/Resends.test.js b/test/integration/Resends.test.js index 6c9914d5b..611dbcf25 100644 --- a/test/integration/Resends.test.js +++ b/test/integration/Resends.test.js @@ -1,7 +1,7 @@ import { wait, waitForCondition, waitForEvent } from 'streamr-test-utils' import { uid, describeRepeats, fakePrivateKey, getPublishTestMessages } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { Defer, pTimeout } from '../../src/utils' import Connection from '../../src/Connection' diff --git a/test/integration/Sequencing.test.js b/test/integration/Sequencing.test.js index 4c5800d16..b75720d34 100644 --- a/test/integration/Sequencing.test.js +++ b/test/integration/Sequencing.test.js @@ -1,7 +1,7 @@ import { wait, waitForCondition, waitForEvent } from 'streamr-test-utils' import { uid, fakePrivateKey, getWaitForStorage } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import Connection from '../../src/Connection' import config from './config' diff --git a/test/integration/Session.test.js b/test/integration/Session.test.js index d20264505..90c9b0bf7 100644 --- a/test/integration/Session.test.js +++ b/test/integration/Session.test.js @@ -1,4 +1,4 @@ -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { fakePrivateKey } from '../utils' import config from './config' diff --git a/test/integration/StreamConnectionState.test.js b/test/integration/StreamConnectionState.test.js index 48c324a65..4c137da74 100644 --- a/test/integration/StreamConnectionState.test.js +++ b/test/integration/StreamConnectionState.test.js @@ -2,7 +2,7 @@ import { wait } from 'streamr-test-utils' import { ControlLayer } from 'streamr-client-protocol' import { uid, fakePrivateKey, describeRepeats, getPublishTestMessages } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { Defer, pLimitFn } from '../../src/utils' import Connection from '../../src/Connection' diff --git a/test/integration/StreamEndpoints.test.js b/test/integration/StreamEndpoints.test.js index e1be6c464..7f50b741f 100644 --- a/test/integration/StreamEndpoints.test.js +++ b/test/integration/StreamEndpoints.test.js @@ -1,7 +1,7 @@ import { ethers } from 'ethers' import { wait } from 'streamr-test-utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { uid } from '../utils' import config from './config' @@ -230,7 +230,7 @@ function TestStreamEndpoints(getName) { }) }) - describe.only('Storage node assignment', () => { + describe('Storage node assignment', () => { it('add', async () => { const storageNodeAddress = ethers.Wallet.createRandom().address const stream = await client.createStream() diff --git a/test/integration/StreamrClient.test.js b/test/integration/StreamrClient.test.js index edd1b81b4..407977ad7 100644 --- a/test/integration/StreamrClient.test.js +++ b/test/integration/StreamrClient.test.js @@ -6,7 +6,7 @@ import { ControlLayer, MessageLayer } from 'streamr-client-protocol' import { wait, waitForEvent } from 'streamr-test-utils' import { describeRepeats, uid, fakePrivateKey, getWaitForStorage, getPublishTestMessages, Msg } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { Defer, pLimitFn } from '../../src/utils' import Connection from '../../src/Connection' diff --git a/test/integration/Subscriber.test.js b/test/integration/Subscriber.test.js index fdc7ac1ea..379390e51 100644 --- a/test/integration/Subscriber.test.js +++ b/test/integration/Subscriber.test.js @@ -2,7 +2,7 @@ import { ControlLayer } from 'streamr-client-protocol' import { wait } from 'streamr-test-utils' import { uid, fakePrivateKey, describeRepeats, getPublishTestMessages, collect } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { Defer } from '../../src/utils' import Connection from '../../src/Connection' diff --git a/test/integration/SubscriberResends.test.js b/test/integration/SubscriberResends.test.js index acda54340..806ba8058 100644 --- a/test/integration/SubscriberResends.test.js +++ b/test/integration/SubscriberResends.test.js @@ -2,7 +2,7 @@ import { ControlLayer } from 'streamr-client-protocol' import { wait } from 'streamr-test-utils' import { Msg, uid, collect, describeRepeats, fakePrivateKey, getWaitForStorage, getPublishTestMessages } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import Connection from '../../src/Connection' import { Defer } from '../../src/utils' diff --git a/test/integration/Subscription.test.js b/test/integration/Subscription.test.js index 7f1ee0312..b15dfadca 100644 --- a/test/integration/Subscription.test.js +++ b/test/integration/Subscription.test.js @@ -1,7 +1,7 @@ import { wait, waitForEvent } from 'streamr-test-utils' import { uid, fakePrivateKey } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import config from './config' diff --git a/test/integration/Validation.test.js b/test/integration/Validation.test.js index 6196ee235..54e072497 100644 --- a/test/integration/Validation.test.js +++ b/test/integration/Validation.test.js @@ -1,7 +1,7 @@ import { wait } from 'streamr-test-utils' import { uid, fakePrivateKey, describeRepeats, getPublishTestMessages } from '../utils' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import Connection from '../../src/Connection' import config from './config' diff --git a/test/integration/authFetch.test.js b/test/integration/authFetch.test.js index 1c8749049..bfef4d431 100644 --- a/test/integration/authFetch.test.js +++ b/test/integration/authFetch.test.js @@ -2,7 +2,7 @@ jest.mock('node-fetch') import fetch from 'node-fetch' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { fakePrivateKey } from '../utils' import config from './config' diff --git a/test/legacy/HistoricalSubscription.test.js b/test/legacy/HistoricalSubscription.test.js index 939d5c364..582dc880d 100644 --- a/test/legacy/HistoricalSubscription.test.js +++ b/test/legacy/HistoricalSubscription.test.js @@ -1,4 +1,3 @@ -/ import sinon from 'sinon' import { ControlLayer, MessageLayer, Errors } from 'streamr-client-protocol' import { wait } from 'streamr-test-utils' diff --git a/test/unit/Session.test.js b/test/unit/Session.test.js index 9610f5797..08046b45d 100644 --- a/test/unit/Session.test.js +++ b/test/unit/Session.test.js @@ -1,6 +1,6 @@ import sinon from 'sinon' -import StreamrClient from '../../src' +import StreamrClient from '../../src/StreamrClient' import { Defer } from '../../src/utils' import Session from '../../src/Session' import config from '../integration/config' @@ -23,7 +23,9 @@ describe('Session', () => { sessionToken: 'session-token', }, }) - clientSessionToken.logoutEndpoint = sinon.stub().resolves() + clientSessionToken.loginEndpoints = { + logoutEndpoint: sinon.stub().resolves() + } session = new Session(clientSessionToken) session.options.unauthenticated = false @@ -160,7 +162,7 @@ describe('Session', () => { it('should call the logout endpoint', async () => { await session.getSessionToken() await session.logout() - expect(clientSessionToken.logoutEndpoint.calledOnce).toBeTruthy() + expect(clientSessionToken.loginEndpoints.logoutEndpoint.calledOnce).toBeTruthy() }) it('should call the logout endpoint again', async () => { @@ -169,7 +171,7 @@ describe('Session', () => { await session.logout() await session.getSessionToken() await session.logout() - expect(clientSessionToken.logoutEndpoint.calledTwice).toBeTruthy() + expect(clientSessionToken.loginEndpoints.logoutEndpoint.calledTwice).toBeTruthy() }) it('should throw if already logging out', async () => { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..277e2659b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "allowJs": true, + "declaration": true, + "declarationDir": "dist/types", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 6cfa15224..52b22578e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,7 +25,7 @@ module.exports = (env, argv) => { const commonConfig = { mode: isProduction ? 'production' : 'development', - entry: path.join(__dirname, 'src', 'index.js'), + entry: path.join(__dirname, 'src', 'StreamrClient.ts'), devtool: 'source-map', output: { path: path.join(__dirname, 'dist'), @@ -41,7 +41,7 @@ module.exports = (env, argv) => { module: { rules: [ { - test: /(\.jsx|\.js)$/, + test: /(\.jsx|\.js|\.ts)$/, exclude: /(node_modules|bower_components)/, use: { loader: 'babel-loader', @@ -53,7 +53,7 @@ module.exports = (env, argv) => { } }, { - test: /(\.jsx|\.js)$/, + test: /(\.jsx|\.js|\.ts)$/, loader: 'eslint-loader', exclude: /(node_modules|streamr-client-protocol|dist)/, // excluding streamr-client-protocol makes build work when 'npm link'ed }, @@ -61,7 +61,7 @@ module.exports = (env, argv) => { }, resolve: { modules: [path.resolve('./node_modules'), path.resolve('./src')], - extensions: ['.json', '.js'], + extensions: ['.json', '.js', '.ts'], }, plugins: [ gitRevisionPlugin, @@ -87,7 +87,7 @@ module.exports = (env, argv) => { serverConfig.module.rules = [ { - test: /(\.jsx|\.js)$/, + test: /(\.jsx|\.js|\.ts)$/, exclude: /(node_modules|bower_components)/, use: { loader: 'babel-loader', @@ -99,7 +99,7 @@ module.exports = (env, argv) => { } }, { - test: /(\.jsx|\.js)$/, + test: /(\.jsx|\.js|\.ts)$/, loader: 'eslint-loader', exclude: /(node_modules|streamr-client-protocol|dist)/, // excluding streamr-client-protocol makes build work when 'npm link'ed },