From 93ac67db845c1490d8d28157ad56f3f07c0df7d8 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 21:39:42 +0200 Subject: [PATCH 01/50] add rpc-provider --- packages/rpc-provider/README.md | 68 ++ packages/rpc-provider/package.json | 43 ++ packages/rpc-provider/src/bundle.ts | 7 + .../src/coder/decodeResponse.spec.ts | 70 ++ .../rpc-provider/src/coder/encodeJson.spec.ts | 20 + .../src/coder/encodeObject.spec.ts | 25 + packages/rpc-provider/src/coder/error.spec.ts | 111 +++ packages/rpc-provider/src/coder/error.ts | 66 ++ packages/rpc-provider/src/coder/index.ts | 88 +++ packages/rpc-provider/src/defaults.ts | 10 + packages/rpc-provider/src/http/index.spec.ts | 62 ++ packages/rpc-provider/src/http/index.ts | 202 ++++++ packages/rpc-provider/src/http/send.spec.ts | 61 ++ packages/rpc-provider/src/http/types.ts | 11 + packages/rpc-provider/src/index.ts | 6 + packages/rpc-provider/src/lru.spec.ts | 57 ++ packages/rpc-provider/src/lru.ts | 134 ++++ packages/rpc-provider/src/mock/index.ts | 259 +++++++ packages/rpc-provider/src/mock/mockHttp.ts | 35 + packages/rpc-provider/src/mock/mockWs.ts | 92 +++ packages/rpc-provider/src/mock/on.spec.ts | 43 ++ packages/rpc-provider/src/mock/send.spec.ts | 38 ++ .../rpc-provider/src/mock/subscribe.spec.ts | 81 +++ packages/rpc-provider/src/mock/types.ts | 36 + .../rpc-provider/src/mock/unsubscribe.spec.ts | 57 ++ packages/rpc-provider/src/mod.ts | 4 + packages/rpc-provider/src/packageDetect.ts | 12 + .../src/substrate-connect/Health.ts | 325 +++++++++ .../src/substrate-connect/index.spec.ts | 638 ++++++++++++++++++ .../src/substrate-connect/index.ts | 415 ++++++++++++ .../src/substrate-connect/types.ts | 16 + packages/rpc-provider/src/types.ts | 99 +++ packages/rpc-provider/src/ws/connect.spec.ts | 167 +++++ packages/rpc-provider/src/ws/errors.ts | 41 ++ packages/rpc-provider/src/ws/index.spec.ts | 92 +++ packages/rpc-provider/src/ws/index.ts | 626 +++++++++++++++++ packages/rpc-provider/src/ws/send.spec.ts | 126 ++++ packages/rpc-provider/src/ws/state.spec.ts | 20 + .../rpc-provider/src/ws/subscribe.spec.ts | 68 ++ .../rpc-provider/src/ws/unsubscribe.spec.ts | 100 +++ packages/rpc-provider/tsconfig.build.json | 17 + packages/rpc-provider/tsconfig.spec.json | 18 + 42 files changed, 4466 insertions(+) create mode 100644 packages/rpc-provider/README.md create mode 100644 packages/rpc-provider/package.json create mode 100644 packages/rpc-provider/src/bundle.ts create mode 100644 packages/rpc-provider/src/coder/decodeResponse.spec.ts create mode 100644 packages/rpc-provider/src/coder/encodeJson.spec.ts create mode 100644 packages/rpc-provider/src/coder/encodeObject.spec.ts create mode 100644 packages/rpc-provider/src/coder/error.spec.ts create mode 100644 packages/rpc-provider/src/coder/error.ts create mode 100644 packages/rpc-provider/src/coder/index.ts create mode 100644 packages/rpc-provider/src/defaults.ts create mode 100644 packages/rpc-provider/src/http/index.spec.ts create mode 100644 packages/rpc-provider/src/http/index.ts create mode 100644 packages/rpc-provider/src/http/send.spec.ts create mode 100644 packages/rpc-provider/src/http/types.ts create mode 100644 packages/rpc-provider/src/index.ts create mode 100644 packages/rpc-provider/src/lru.spec.ts create mode 100644 packages/rpc-provider/src/lru.ts create mode 100644 packages/rpc-provider/src/mock/index.ts create mode 100644 packages/rpc-provider/src/mock/mockHttp.ts create mode 100644 packages/rpc-provider/src/mock/mockWs.ts create mode 100644 packages/rpc-provider/src/mock/on.spec.ts create mode 100644 packages/rpc-provider/src/mock/send.spec.ts create mode 100644 packages/rpc-provider/src/mock/subscribe.spec.ts create mode 100644 packages/rpc-provider/src/mock/types.ts create mode 100644 packages/rpc-provider/src/mock/unsubscribe.spec.ts create mode 100644 packages/rpc-provider/src/mod.ts create mode 100644 packages/rpc-provider/src/packageDetect.ts create mode 100644 packages/rpc-provider/src/substrate-connect/Health.ts create mode 100644 packages/rpc-provider/src/substrate-connect/index.spec.ts create mode 100644 packages/rpc-provider/src/substrate-connect/index.ts create mode 100644 packages/rpc-provider/src/substrate-connect/types.ts create mode 100644 packages/rpc-provider/src/types.ts create mode 100644 packages/rpc-provider/src/ws/connect.spec.ts create mode 100644 packages/rpc-provider/src/ws/errors.ts create mode 100644 packages/rpc-provider/src/ws/index.spec.ts create mode 100644 packages/rpc-provider/src/ws/index.ts create mode 100644 packages/rpc-provider/src/ws/send.spec.ts create mode 100644 packages/rpc-provider/src/ws/state.spec.ts create mode 100644 packages/rpc-provider/src/ws/subscribe.spec.ts create mode 100644 packages/rpc-provider/src/ws/unsubscribe.spec.ts create mode 100644 packages/rpc-provider/tsconfig.build.json create mode 100644 packages/rpc-provider/tsconfig.spec.json diff --git a/packages/rpc-provider/README.md b/packages/rpc-provider/README.md new file mode 100644 index 00000000..e114fb2d --- /dev/null +++ b/packages/rpc-provider/README.md @@ -0,0 +1,68 @@ +# @polkadot/rpc-provider + +Generic transport providers to handle the transport of method calls to and from Polkadot clients from applications interacting with it. It provides an interface to making RPC calls and is generally, unless you are operating at a low-level and taking care of encoding and decoding of parameters/results, it won't be directly used, rather only passed to a higher-level interface. + +## Provider Selection + +There are three flavours of the providers provided, one allowing for using HTTP as a transport mechanism, the other using WebSockets, and the third one uses substrate light-client through @substrate/connect. It is generally recommended to use the [[WsProvider]] since in addition to standard calls, it allows for subscriptions where all changes to state can be pushed from the node to the client. + +All providers are usable (as is the API), in both browser-based and Node.js environments. Polyfills for unsupported functionality are automatically applied based on feature-detection. + +## Usage + +Installation - + +``` +yarn add @polkadot/rpc-provider +``` + +WebSocket Initialization - + +```javascript +import { WsProvider } from '@polkadot/rpc-provider'; + +// this is the actual default endpoint +const provider = new WsProvider('ws://127.0.0.1:9944'); +const version = await provider.send('client_version', []); + +console.log('client version', version); +``` + +HTTP Initialization - + +```javascript +import { HttpProvider } from '@polkadot/rpc-provider'; + +// this is the actual default endpoint +const provider = new HttpProvider('http://127.0.0.1:9933'); +const version = await provider.send('chain_getBlockHash', []); + +console.log('latest block Hash', hash); +``` + +@substrate/connect Initialization - + +Instantiating a Provider for the Polkadot Relay Chain: +```javascript +import { ScProvider } from '@polkadot/rpc-provider'; +import * as Sc from '@substrate/connect'; + +const provider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); + +await provider.connect(); + +const version = await provider.send('chain_getBlockHash', []); +``` + +Instantiating a Provider for a Polkadot parachain: +```javascript +import { ScProvider } from '@polkadot/rpc-provider'; +import * as Sc from '@substrate/connect'; + +const polkadotProvider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); +const parachainProvider = new ScProvider(Sc, parachainSpec, polkadotProvider); + +await parachainProvider.connect(); + +const version = await parachainProvider.send('chain_getBlockHash', []); +``` diff --git a/packages/rpc-provider/package.json b/packages/rpc-provider/package.json new file mode 100644 index 00000000..e8d5f46f --- /dev/null +++ b/packages/rpc-provider/package.json @@ -0,0 +1,43 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/polkadot-js/api/issues", + "description": "Transport providers for the API", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/polkadot-js/api/tree/master/packages/rpc-provider#readme", + "license": "Apache-2.0", + "name": "@polkadot/rpc-provider", + "repository": { + "directory": "packages/rpc-provider", + "type": "git", + "url": "https://github.com/polkadot-js/api.git" + }, + "sideEffects": [ + "./packageDetect.js", + "./packageDetect.cjs" + ], + "type": "module", + "version": "11.2.1", + "main": "index.js", + "dependencies": { + "@polkadot/keyring": "^12.6.2", + "@polkadot/types": "11.2.1", + "@polkadot/types-support": "11.2.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "@polkadot/x-fetch": "^12.6.2", + "@polkadot/x-global": "^12.6.2", + "@polkadot/x-ws": "^12.6.2", + "eventemitter3": "^5.0.1", + "mock-socket": "^9.3.1", + "nock": "^13.5.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@substrate/connect": "0.8.10" + }, + "optionalDependencies": { + "@substrate/connect": "0.8.10" + } +} diff --git a/packages/rpc-provider/src/bundle.ts b/packages/rpc-provider/src/bundle.ts new file mode 100644 index 00000000..06b0acaf --- /dev/null +++ b/packages/rpc-provider/src/bundle.ts @@ -0,0 +1,7 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { HttpProvider } from './http/index.js'; +export { packageInfo } from './packageInfo.js'; +export { ScProvider } from './substrate-connect/index.js'; +export { WsProvider } from './ws/index.js'; diff --git a/packages/rpc-provider/src/coder/decodeResponse.spec.ts b/packages/rpc-provider/src/coder/decodeResponse.spec.ts new file mode 100644 index 00000000..293fd641 --- /dev/null +++ b/packages/rpc-provider/src/coder/decodeResponse.spec.ts @@ -0,0 +1,70 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { JsonRpcResponse } from '../types.js'; + +import { RpcCoder } from './index.js'; + +describe('decodeResponse', (): void => { + let coder: RpcCoder; + + beforeEach((): void => { + coder = new RpcCoder(); + }); + + it('expects a non-empty input object', (): void => { + expect( + () => coder.decodeResponse(undefined as unknown as JsonRpcResponse) + ).toThrow(/Invalid jsonrpc/); + }); + + it('expects a valid jsonrpc field', (): void => { + expect( + () => coder.decodeResponse({} as JsonRpcResponse) + ).toThrow(/Invalid jsonrpc/); + }); + + it('expects a valid id field', (): void => { + expect( + () => coder.decodeResponse({ jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/Invalid id/); + }); + + it('expects a valid result field', (): void => { + expect( + () => coder.decodeResponse({ id: 1, jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/No result/); + }); + + it('throws any error found', (): void => { + expect( + () => coder.decodeResponse({ error: { code: 123, message: 'test error' }, id: 1, jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/123: test error/); + }); + + it('throws any error found, with data', (): void => { + expect( + () => coder.decodeResponse({ error: { code: 123, data: 'Error("Some random error description")', message: 'test error' }, id: 1, jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/123: test error: Some random error description/); + }); + + it('allows for number subscription ids', (): void => { + expect( + coder.decodeResponse({ id: 1, jsonrpc: '2.0', method: 'test', params: { result: 'test result', subscription: 1 } } as JsonRpcResponse) + ).toEqual('test result'); + }); + + it('allows for string subscription ids', (): void => { + expect( + coder.decodeResponse({ id: 1, jsonrpc: '2.0', method: 'test', params: { result: 'test result', subscription: 'abc' } } as JsonRpcResponse) + ).toEqual('test result'); + }); + + it('returns the result', (): void => { + expect( + coder.decodeResponse({ id: 1, jsonrpc: '2.0', result: 'some result' } as JsonRpcResponse) + ).toEqual('some result'); + }); +}); diff --git a/packages/rpc-provider/src/coder/encodeJson.spec.ts b/packages/rpc-provider/src/coder/encodeJson.spec.ts new file mode 100644 index 00000000..5e166e8b --- /dev/null +++ b/packages/rpc-provider/src/coder/encodeJson.spec.ts @@ -0,0 +1,20 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { RpcCoder } from './index.js'; + +describe('encodeJson', (): void => { + let coder: RpcCoder; + + beforeEach((): void => { + coder = new RpcCoder(); + }); + + it('encodes a valid JsonRPC JSON string', (): void => { + expect( + coder.encodeJson('method', ['params']) + ).toEqual([1, '{"id":1,"jsonrpc":"2.0","method":"method","params":["params"]}']); + }); +}); diff --git a/packages/rpc-provider/src/coder/encodeObject.spec.ts b/packages/rpc-provider/src/coder/encodeObject.spec.ts new file mode 100644 index 00000000..841a3257 --- /dev/null +++ b/packages/rpc-provider/src/coder/encodeObject.spec.ts @@ -0,0 +1,25 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { RpcCoder } from './index.js'; + +describe('encodeObject', (): void => { + let coder: RpcCoder; + + beforeEach((): void => { + coder = new RpcCoder(); + }); + + it('encodes a valid JsonRPC object', (): void => { + expect( + coder.encodeObject('method', ['a', 'b']) + ).toEqual([1, { + id: 1, + jsonrpc: '2.0', + method: 'method', + params: ['a', 'b'] + }]); + }); +}); diff --git a/packages/rpc-provider/src/coder/error.spec.ts b/packages/rpc-provider/src/coder/error.spec.ts new file mode 100644 index 00000000..89ac16c2 --- /dev/null +++ b/packages/rpc-provider/src/coder/error.spec.ts @@ -0,0 +1,111 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { isError } from '@polkadot/util'; + +import RpcError from './error.js'; + +describe('RpcError', (): void => { + describe('constructor', (): void => { + it('constructs an Error that is still an Error', (): void => { + expect( + isError( + new RpcError() + ) + ).toEqual(true); + }); + }); + + describe('static', (): void => { + it('exposes the .CODES as a static', (): void => { + expect( + Object.keys(RpcError.CODES) + ).not.toEqual(0); + }); + }); + + describe('constructor properties', (): void => { + it('sets the .message property', (): void => { + expect( + new RpcError('test message').message + ).toEqual('test message'); + }); + + it("sets the .message to '' when not set", (): void => { + expect( + new RpcError().message + ).toEqual(''); + }); + + it('sets the .code property', (): void => { + expect( + new RpcError('test message', 1234).code + ).toEqual(1234); + }); + + it('sets the .code to UKNOWN when not set', (): void => { + expect( + new RpcError('test message').code + ).toEqual(RpcError.CODES.UNKNOWN); + }); + + it('sets the .data property', (): void => { + const data = 'here'; + + expect( + new RpcError('test message', 1234, data).data + ).toEqual(data); + }); + + it('sets the .data property to generic value', (): void => { + const data = { custom: 'value' } as const; + + expect( + new RpcError('test message', 1234, data).data + ).toEqual(data); + }); + }); + + describe('stack traces', (): void => { + // eslint-disable-next-line @typescript-eslint/ban-types + let captureStackTrace: (targetObject: Record, constructorOpt?: Function | undefined) => void; + + beforeEach((): void => { + captureStackTrace = Error.captureStackTrace; + + Error.captureStackTrace = function (error): void { + Object.defineProperty(error, 'stack', { + configurable: true, + get: function getStack (): string { + const value = 'some stack returned'; + + Object.defineProperty(this, 'stack', { value }); + + return value; + } + }); + }; + }); + + afterEach((): void => { + Error.captureStackTrace = captureStackTrace; + }); + + it('captures via captureStackTrace when available', (): void => { + expect( + new RpcError().stack + ).toEqual('some stack returned'); + }); + + it('captures via stack when captureStackTrace not available', (): void => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + Error.captureStackTrace = null as any; + + expect( + new RpcError().stack.length + ).not.toEqual(0); + }); + }); +}); diff --git a/packages/rpc-provider/src/coder/error.ts b/packages/rpc-provider/src/coder/error.ts new file mode 100644 index 00000000..908d9ead --- /dev/null +++ b/packages/rpc-provider/src/coder/error.ts @@ -0,0 +1,66 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RpcErrorInterface } from '../types.js'; + +import { isFunction } from '@polkadot/util'; + +const UNKNOWN = -99999; + +function extend> (that: RpcError, name: K, value: RpcError[K]): void { + Object.defineProperty(that, name, { + configurable: true, + enumerable: false, + value + }); +} + +/** + * @name RpcError + * @summary Extension to the basic JS Error. + * @description + * The built-in JavaScript Error class is extended by adding a code to allow for Error categorization. In addition to the normal `stack`, `message`, the numeric `code` and `data` (any types) parameters are available on the object. + * @example + *
+ * + * ```javascript + * const { RpcError } from '@polkadot/util'); + * + * throw new RpcError('some message', RpcError.CODES.METHOD_NOT_FOUND); // => error.code = -32601 + * ``` + */ +export default class RpcError extends Error implements RpcErrorInterface { + public code!: number; + + public data?: T; + + public override message!: string; + + public override name!: string; + + public override stack!: string; + + public constructor (message = '', code: number = UNKNOWN, data?: T) { + super(); + + extend(this, 'message', String(message)); + extend(this, 'name', this.constructor.name); + extend(this, 'data', data); + extend(this, 'code', code); + + if (isFunction(Error.captureStackTrace)) { + Error.captureStackTrace(this, this.constructor); + } else { + const { stack } = new Error(message); + + stack && extend(this, 'stack', stack); + } + } + + public static CODES = { + ASSERT: -90009, + INVALID_JSONRPC: -99998, + METHOD_NOT_FOUND: -32601, // Rust client + UNKNOWN + }; +} diff --git a/packages/rpc-provider/src/coder/index.ts b/packages/rpc-provider/src/coder/index.ts new file mode 100644 index 00000000..120e1a17 --- /dev/null +++ b/packages/rpc-provider/src/coder/index.ts @@ -0,0 +1,88 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { JsonRpcRequest, JsonRpcResponse, JsonRpcResponseBaseError } from '../types.js'; + +import { isNumber, isString, isUndefined, stringify } from '@polkadot/util'; + +import RpcError from './error.js'; + +function formatErrorData (data?: string | number): string { + if (isUndefined(data)) { + return ''; + } + + const formatted = `: ${isString(data) + ? data.replace(/Error\("/g, '').replace(/\("/g, '(').replace(/"\)/g, ')').replace(/\(/g, ', ').replace(/\)/g, '') + : stringify(data)}`; + + // We need some sort of cut-off here since these can be very large and + // very nested, pick a number and trim the result display to it + return formatted.length <= 256 + ? formatted + : `${formatted.substring(0, 255)}…`; +} + +function checkError (error?: JsonRpcResponseBaseError): void { + if (error) { + const { code, data, message } = error; + + throw new RpcError(`${code}: ${message}${formatErrorData(data)}`, code, data); + } +} + +/** @internal */ +export class RpcCoder { + #id = 0; + + public decodeResponse (response?: JsonRpcResponse): T { + if (!response || response.jsonrpc !== '2.0') { + throw new Error('Invalid jsonrpc field in decoded object'); + } + + const isSubscription = !isUndefined(response.params) && !isUndefined(response.method); + + if ( + !isNumber(response.id) && + ( + !isSubscription || ( + !isNumber(response.params.subscription) && + !isString(response.params.subscription) + ) + ) + ) { + throw new Error('Invalid id field in decoded object'); + } + + checkError(response.error); + + if (response.result === undefined && !isSubscription) { + throw new Error('No result found in jsonrpc response'); + } + + if (isSubscription) { + checkError(response.params.error); + + return response.params.result; + } + + return response.result; + } + + public encodeJson (method: string, params: unknown[]): [number, string] { + const [id, data] = this.encodeObject(method, params); + + return [id, stringify(data)]; + } + + public encodeObject (method: string, params: unknown[]): [number, JsonRpcRequest] { + const id = ++this.#id; + + return [id, { + id, + jsonrpc: '2.0', + method, + params + }]; + } +} diff --git a/packages/rpc-provider/src/defaults.ts b/packages/rpc-provider/src/defaults.ts new file mode 100644 index 00000000..55d19f21 --- /dev/null +++ b/packages/rpc-provider/src/defaults.ts @@ -0,0 +1,10 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const HTTP_URL = 'http://127.0.0.1:9933'; +const WS_URL = 'ws://127.0.0.1:9944'; + +export default { + HTTP_URL, + WS_URL +}; diff --git a/packages/rpc-provider/src/http/index.spec.ts b/packages/rpc-provider/src/http/index.spec.ts new file mode 100644 index 00000000..1bb8a629 --- /dev/null +++ b/packages/rpc-provider/src/http/index.spec.ts @@ -0,0 +1,62 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TEST_HTTP_URL } from '../mock/mockHttp.js'; +import { HttpProvider } from './index.js'; + +describe('Http', (): void => { + let http: HttpProvider; + + beforeEach((): void => { + http = new HttpProvider(TEST_HTTP_URL); + }); + + it('requires an http:// prefixed endpoint', (): void => { + expect( + () => new HttpProvider('ws://') + ).toThrow(/with 'http/); + }); + + it('allows https:// endpoints', (): void => { + expect( + () => new HttpProvider('https://') + ).not.toThrow(); + }); + + it('allows custom headers', (): void => { + expect( + () => new HttpProvider('https://', { foo: 'bar' }) + ).not.toThrow(); + }); + + it('allow clone', (): void => { + const clone = http.clone(); + /* eslint-disable */ + expect((clone as any)['#endpoint']).toEqual((http as any)['#endpoint']); + expect((clone as any)['#headers']).toEqual((http as any)['#headers']); + /* eslint-enable */ + }); + + it('always returns isConnected true', (): void => { + expect(http.isConnected).toEqual(true); + }); + + it('does not (yet) support subscribe', async (): Promise => { + await http.subscribe('', '', [], (cb): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect(cb).toEqual(expect.anything()); + }).catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/does not have subscriptions/); + }); + }); + + it('does not (yet) support unsubscribe', async (): Promise => { + await http.unsubscribe('', '', 0).catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/does not have subscriptions/); + }); + }); +}); diff --git a/packages/rpc-provider/src/http/index.ts b/packages/rpc-provider/src/http/index.ts new file mode 100644 index 00000000..794702b6 --- /dev/null +++ b/packages/rpc-provider/src/http/index.ts @@ -0,0 +1,202 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; + +import { logger, noop, stringify } from '@polkadot/util'; +import { fetch } from '@polkadot/x-fetch'; + +import { RpcCoder } from '../coder/index.js'; +import defaults from '../defaults.js'; +import { LRUCache } from '../lru.js'; + +const ERROR_SUBSCRIBE = 'HTTP Provider does not have subscriptions, use WebSockets instead'; + +const l = logger('api-http'); + +/** + * # @polkadot/rpc-provider + * + * @name HttpProvider + * + * @description The HTTP Provider allows sending requests using HTTP to a HTTP RPC server TCP port. It does not support subscriptions so you won't be able to listen to events such as new blocks or balance changes. It is usually preferable using the [[WsProvider]]. + * + * @example + *
+ * + * ```javascript + * import Api from '@polkadot/api/promise'; + * import { HttpProvider } from '@polkadot/rpc-provider'; + * + * const provider = new HttpProvider('http://127.0.0.1:9933'); + * const api = new Api(provider); + * ``` + * + * @see [[WsProvider]] + */ +export class HttpProvider implements ProviderInterface { + readonly #callCache = new LRUCache(); + readonly #coder: RpcCoder; + readonly #endpoint: string; + readonly #headers: Record; + readonly #stats: ProviderStats; + + /** + * @param {string} endpoint The endpoint url starting with http:// + */ + constructor (endpoint: string = defaults.HTTP_URL, headers: Record = {}) { + if (!/^(https|http):\/\//.test(endpoint)) { + throw new Error(`Endpoint should start with 'http://' or 'https://', received '${endpoint}'`); + } + + this.#coder = new RpcCoder(); + this.#endpoint = endpoint; + this.#headers = headers; + this.#stats = { + active: { requests: 0, subscriptions: 0 }, + total: { bytesRecv: 0, bytesSent: 0, cached: 0, errors: 0, requests: 0, subscriptions: 0, timeout: 0 } + }; + } + + /** + * @summary `true` when this provider supports subscriptions + */ + public get hasSubscriptions (): boolean { + return !!false; + } + + /** + * @description Returns a clone of the object + */ + public clone (): HttpProvider { + return new HttpProvider(this.#endpoint, this.#headers); + } + + /** + * @description Manually connect from the connection + */ + public async connect (): Promise { + // noop + } + + /** + * @description Manually disconnect from the connection + */ + public async disconnect (): Promise { + // noop + } + + /** + * @description Returns the connection stats + */ + public get stats (): ProviderStats { + return this.#stats; + } + + /** + * @summary `true` when this provider supports clone() + */ + public get isClonable (): boolean { + return !!true; + } + + /** + * @summary Whether the node is connected or not. + * @return {boolean} true if connected + */ + public get isConnected (): boolean { + return !!true; + } + + /** + * @summary Events are not supported with the HttpProvider, see [[WsProvider]]. + * @description HTTP Provider does not have 'on' emitters. WebSockets should be used instead. + */ + public on (_type: ProviderInterfaceEmitted, _sub: ProviderInterfaceEmitCb): () => void { + l.error('HTTP Provider does not have \'on\' emitters, use WebSockets instead'); + + return noop; + } + + /** + * @summary Send HTTP POST Request with Body to configured HTTP Endpoint. + */ + public async send (method: string, params: unknown[], isCacheable?: boolean): Promise { + this.#stats.total.requests++; + + const [, body] = this.#coder.encodeJson(method, params); + const cacheKey = isCacheable ? `${method}::${stringify(params)}` : ''; + let resultPromise: Promise | null = isCacheable + ? this.#callCache.get(cacheKey) + : null; + + if (!resultPromise) { + resultPromise = this.#send(body); + + if (isCacheable) { + this.#callCache.set(cacheKey, resultPromise); + } + } else { + this.#stats.total.cached++; + } + + return resultPromise; + } + + async #send (body: string): Promise { + this.#stats.active.requests++; + this.#stats.total.bytesSent += body.length; + + try { + const response = await fetch(this.#endpoint, { + body, + headers: { + Accept: 'application/json', + 'Content-Length': `${body.length}`, + 'Content-Type': 'application/json', + ...this.#headers + }, + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`[${response.status}]: ${response.statusText}`); + } + + const result = await response.text(); + + this.#stats.total.bytesRecv += result.length; + + const decoded = this.#coder.decodeResponse(JSON.parse(result) as JsonRpcResponse); + + this.#stats.active.requests--; + + return decoded; + } catch (e) { + this.#stats.active.requests--; + this.#stats.total.errors++; + + throw e; + } + } + + /** + * @summary Subscriptions are not supported with the HttpProvider, see [[WsProvider]]. + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async subscribe (_types: string, _method: string, _params: unknown[], _cb: ProviderInterfaceCallback): Promise { + l.error(ERROR_SUBSCRIBE); + + throw new Error(ERROR_SUBSCRIBE); + } + + /** + * @summary Subscriptions are not supported with the HttpProvider, see [[WsProvider]]. + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async unsubscribe (_type: string, _method: string, _id: number): Promise { + l.error(ERROR_SUBSCRIBE); + + throw new Error(ERROR_SUBSCRIBE); + } +} diff --git a/packages/rpc-provider/src/http/send.spec.ts b/packages/rpc-provider/src/http/send.spec.ts new file mode 100644 index 00000000..0906a74d --- /dev/null +++ b/packages/rpc-provider/src/http/send.spec.ts @@ -0,0 +1,61 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Mock } from '../mock/types.js'; + +import { mockHttp, TEST_HTTP_URL } from '../mock/mockHttp.js'; +import { HttpProvider } from './index.js'; + +// Does not work with Node 18 (native fetch) +// See https://github.com/nock/nock/issues/2397 +// eslint-disable-next-line jest/no-disabled-tests +describe.skip('send', (): void => { + let http: HttpProvider; + let mock: Mock; + + beforeEach((): void => { + http = new HttpProvider(TEST_HTTP_URL); + }); + + afterEach(async () => { + if (mock) { + await mock.done(); + } + }); + + it('passes the body through correctly', (): Promise => { + mock = mockHttp([{ + method: 'test_body', + reply: { + result: 'ok' + } + }]); + + return http + .send('test_body', ['param']) + .then((): void => { + expect(mock.body['test_body']).toEqual({ + id: 1, + jsonrpc: '2.0', + method: 'test_body', + params: ['param'] + }); + }); + }); + + it('throws error when !response.ok', async (): Promise => { + mock = mockHttp([{ + code: 500, + method: 'test_error' + }]); + + return http + .send('test_error', []) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/\[500\]/); + }); + }); +}); diff --git a/packages/rpc-provider/src/http/types.ts b/packages/rpc-provider/src/http/types.ts new file mode 100644 index 00000000..2d6d0b94 --- /dev/null +++ b/packages/rpc-provider/src/http/types.ts @@ -0,0 +1,11 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Logger } from '@polkadot/util/types'; +import type { RpcCoder } from '../coder/index.js'; + +export interface HttpState { + coder: RpcCoder; + endpoint: string; + l: Logger; +} diff --git a/packages/rpc-provider/src/index.ts b/packages/rpc-provider/src/index.ts new file mode 100644 index 00000000..e88d86ba --- /dev/null +++ b/packages/rpc-provider/src/index.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import './packageDetect.js'; + +export * from './bundle.js'; diff --git a/packages/rpc-provider/src/lru.spec.ts b/packages/rpc-provider/src/lru.spec.ts new file mode 100644 index 00000000..0079eba6 --- /dev/null +++ b/packages/rpc-provider/src/lru.spec.ts @@ -0,0 +1,57 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { LRUCache } from './lru.js'; + +describe('LRUCache', (): void => { + it('allows getting of items below capacity', (): void => { + const keys = ['1', '2', '3', '4']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.keys().join(', ')).toEqual(keys.reverse().join(', ')); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + keys.forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); + }); + + it('drops items when at capacity', (): void => { + const keys = ['1', '2', '3', '4', '5', '6']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.keys().join(', ')).toEqual(keys.slice(2).reverse().join(', ')); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + keys.slice(2).forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); + }); + + it('adjusts the order as they are used', (): void => { + const keys = ['1', '2', '3', '4', '5']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.entries()).toEqual([['5', '555'], ['4', '444'], ['3', '333'], ['2', '222']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + lru.get('3'); + + expect(lru.entries()).toEqual([['3', '333'], ['5', '555'], ['4', '444'], ['2', '222']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + lru.set('4', '4433'); + + expect(lru.entries()).toEqual([['4', '4433'], ['3', '333'], ['5', '555'], ['2', '222']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + lru.set('6', '666'); + + expect(lru.entries()).toEqual([['6', '666'], ['4', '4433'], ['3', '333'], ['5', '555']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + }); +}); diff --git a/packages/rpc-provider/src/lru.ts b/packages/rpc-provider/src/lru.ts new file mode 100644 index 00000000..b9afa882 --- /dev/null +++ b/packages/rpc-provider/src/lru.ts @@ -0,0 +1,134 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Assuming all 1.5MB responses, we apply a default allowing for 192MB +// cache space (depending on the historic queries this would vary, metadata +// for Kusama/Polkadot/Substrate falls between 600-750K, 2x for estimate) +export const DEFAULT_CAPACITY = 128; + +class LRUNode { + readonly key: string; + + public next: LRUNode; + public prev: LRUNode; + + constructor (key: string) { + this.key = key; + this.next = this.prev = this; + } +} + +// https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU +export class LRUCache { + readonly capacity: number; + + readonly #data = new Map(); + readonly #refs = new Map(); + + #length = 0; + #head: LRUNode; + #tail: LRUNode; + + constructor (capacity = DEFAULT_CAPACITY) { + this.capacity = capacity; + this.#head = this.#tail = new LRUNode(''); + } + + get length (): number { + return this.#length; + } + + get lengthData (): number { + return this.#data.size; + } + + get lengthRefs (): number { + return this.#refs.size; + } + + entries (): [string, unknown][] { + const keys = this.keys(); + const count = keys.length; + const entries = new Array<[string, unknown]>(count); + + for (let i = 0; i < count; i++) { + const key = keys[i]; + + entries[i] = [key, this.#data.get(key)]; + } + + return entries; + } + + keys (): string[] { + const keys: string[] = []; + + if (this.#length) { + let curr = this.#head; + + while (curr !== this.#tail) { + keys.push(curr.key); + curr = curr.next; + } + + keys.push(curr.key); + } + + return keys; + } + + get (key: string): T | null { + const data = this.#data.get(key); + + if (data) { + this.#toHead(key); + + return data as T; + } + + return null; + } + + set (key: string, value: T): void { + if (this.#data.has(key)) { + this.#toHead(key); + } else { + const node = new LRUNode(key); + + this.#refs.set(node.key, node); + + if (this.length === 0) { + this.#head = this.#tail = node; + } else { + this.#head.prev = node; + node.next = this.#head; + this.#head = node; + } + + if (this.#length === this.capacity) { + this.#data.delete(this.#tail.key); + this.#refs.delete(this.#tail.key); + + this.#tail = this.#tail.prev; + this.#tail.next = this.#head; + } else { + this.#length += 1; + } + } + + this.#data.set(key, value); + } + + #toHead (key: string): void { + const ref = this.#refs.get(key); + + if (ref && ref !== this.#head) { + ref.prev.next = ref.next; + ref.next.prev = ref.prev; + ref.next = this.#head; + + this.#head.prev = ref; + this.#head = ref; + } + } +} diff --git a/packages/rpc-provider/src/mock/index.ts b/packages/rpc-provider/src/mock/index.ts new file mode 100644 index 00000000..b688710f --- /dev/null +++ b/packages/rpc-provider/src/mock/index.ts @@ -0,0 +1,259 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable camelcase */ + +import type { Header } from '@polkadot/types/interfaces'; +import type { Codec, Registry } from '@polkadot/types/types'; +import type { ProviderInterface, ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '../types.js'; +import type { MockStateDb, MockStateSubscriptionCallback, MockStateSubscriptions } from './types.js'; + +import { EventEmitter } from 'eventemitter3'; + +import { createTestKeyring } from '@polkadot/keyring/testing'; +import { decorateStorage, Metadata } from '@polkadot/types'; +import jsonrpc from '@polkadot/types/interfaces/jsonrpc'; +import rpcHeader from '@polkadot/types-support/json/Header.004.json' assert { type: 'json' }; +import rpcSignedBlock from '@polkadot/types-support/json/SignedBlock.004.immortal.json' assert { type: 'json' }; +import rpcMetadata from '@polkadot/types-support/metadata/static-substrate'; +import { BN, bnToU8a, logger, u8aToHex } from '@polkadot/util'; +import { randomAsU8a } from '@polkadot/util-crypto'; + +const INTERVAL = 1000; +const SUBSCRIPTIONS: string[] = Array.prototype.concat.apply( + [], + Object.values(jsonrpc).map((section): string[] => + Object + .values(section) + .filter(({ isSubscription }) => isSubscription) + .map(({ jsonrpc }) => jsonrpc) + .concat('chain_subscribeNewHead') + ) +) as string[]; + +const keyring = createTestKeyring({ type: 'ed25519' }); +const l = logger('api-mock'); + +/** + * A mock provider mainly used for testing. + * @return {ProviderInterface} The mock provider + * @internal + */ +export class MockProvider implements ProviderInterface { + private db: MockStateDb = {}; + + private emitter = new EventEmitter(); + + private intervalId?: ReturnType | null; + + public isUpdating = true; + + private registry: Registry; + + private prevNumber = new BN(-1); + + private requests: Record unknown> = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + chain_getBlock: () => this.registry.createType('SignedBlock', rpcSignedBlock.result).toJSON(), + chain_getBlockHash: () => '0x1234000000000000000000000000000000000000000000000000000000000000', + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + chain_getFinalizedHead: () => this.registry.createType('Header', rpcHeader.result).hash, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + chain_getHeader: () => this.registry.createType('Header', rpcHeader.result).toJSON(), + rpc_methods: () => this.registry.createType('RpcMethods').toJSON(), + state_getKeys: () => [], + state_getKeysPaged: () => [], + state_getMetadata: () => rpcMetadata, + state_getRuntimeVersion: () => this.registry.createType('RuntimeVersion').toHex(), + state_getStorage: (storage: MockStateDb, [key]: string[]) => u8aToHex(storage[key]), + system_chain: () => 'mockChain', + system_health: () => ({}), + system_name: () => 'mockClient', + system_properties: () => ({ ss58Format: 42 }), + system_upgradedToTripleRefCount: () => this.registry.createType('bool', true), + system_version: () => '9.8.7', + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, sort-keys + dev_echo: (_, params: any) => params + }; + + public subscriptions: MockStateSubscriptions = SUBSCRIPTIONS.reduce((subs, name): MockStateSubscriptions => { + subs[name] = { + callbacks: {}, + lastValue: null + }; + + return subs; + }, ({} as MockStateSubscriptions)); + + private subscriptionId = 0; + + private subscriptionMap: Record = {}; + + constructor (registry: Registry) { + this.registry = registry; + + this.init(); + } + + public get hasSubscriptions (): boolean { + return !!true; + } + + public clone (): MockProvider { + throw new Error('Unimplemented'); + } + + public async connect (): Promise { + // noop + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async disconnect (): Promise { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + public get isClonable (): boolean { + return !!false; + } + + public get isConnected (): boolean { + return !!true; + } + + public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { + this.emitter.on(type, sub); + + return (): void => { + this.emitter.removeListener(type, sub); + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async send (method: string, params: unknown[]): Promise { + l.debug(() => ['send', method, params]); + + if (!this.requests[method]) { + throw new Error(`provider.send: Invalid method '${method}'`); + } + + return this.requests[method](this.db, params) as T; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async subscribe (_type: string, method: string, ...params: unknown[]): Promise { + l.debug(() => ['subscribe', method, params]); + + if (!this.subscriptions[method]) { + throw new Error(`provider.subscribe: Invalid method '${method}'`); + } + + const callback = params.pop() as MockStateSubscriptionCallback; + const id = ++this.subscriptionId; + + this.subscriptions[method].callbacks[id] = callback; + this.subscriptionMap[id] = method; + + if (this.subscriptions[method].lastValue !== null) { + callback(null, this.subscriptions[method].lastValue); + } + + return id; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async unsubscribe (_type: string, _method: string, id: number): Promise { + const sub = this.subscriptionMap[id]; + + l.debug(() => ['unsubscribe', id, sub]); + + if (!sub) { + throw new Error(`Unable to find subscription for ${id}`); + } + + delete this.subscriptionMap[id]; + delete this.subscriptions[sub].callbacks[id]; + + return true; + } + + private init (): void { + const emitEvents: ProviderInterfaceEmitted[] = ['connected', 'disconnected']; + let emitIndex = 0; + let newHead = this.makeBlockHeader(); + let counter = -1; + + const metadata = new Metadata(this.registry, rpcMetadata); + + this.registry.setMetadata(metadata); + + const query = decorateStorage(this.registry, metadata.asLatest, metadata.version); + + // Do something every 1 seconds + this.intervalId = setInterval((): void => { + if (!this.isUpdating) { + return; + } + + // create a new header (next block) + newHead = this.makeBlockHeader(); + + // increment the balances and nonce for each account + keyring.getPairs().forEach(({ publicKey }, index): void => { + this.setStateBn(query['system']['account'](publicKey), newHead.number.toBn().addn(index)); + }); + + // set the timestamp for the current block + this.setStateBn(query['timestamp']['now'](), Math.floor(Date.now() / 1000)); + this.updateSubs('chain_subscribeNewHead', newHead); + + // We emit connected/disconnected at intervals + if (++counter % 2 === 1) { + if (++emitIndex === emitEvents.length) { + emitIndex = 0; + } + + this.emitter.emit(emitEvents[emitIndex]); + } + }, INTERVAL); + } + + private makeBlockHeader (): Header { + const blockNumber = this.prevNumber.addn(1); + const header = this.registry.createType('Header', { + digest: { + logs: [] + }, + extrinsicsRoot: randomAsU8a(), + number: blockNumber, + parentHash: blockNumber.isZero() + ? new Uint8Array(32) + : bnToU8a(this.prevNumber, { bitLength: 256, isLe: false }), + stateRoot: bnToU8a(blockNumber, { bitLength: 256, isLe: false }) + }); + + this.prevNumber = blockNumber; + + return header as unknown as Header; + } + + private setStateBn (key: Uint8Array, value: BN | number): void { + this.db[u8aToHex(key)] = bnToU8a(value, { bitLength: 64, isLe: true }); + } + + private updateSubs (method: string, value: Codec): void { + this.subscriptions[method].lastValue = value; + + Object + .values(this.subscriptions[method].callbacks) + .forEach((cb): void => { + try { + cb(null, value.toJSON()); + } catch (error) { + l.error(`Error on '${method}' subscription`, error); + } + }); + } +} diff --git a/packages/rpc-provider/src/mock/mockHttp.ts b/packages/rpc-provider/src/mock/mockHttp.ts new file mode 100644 index 00000000..3335790f --- /dev/null +++ b/packages/rpc-provider/src/mock/mockHttp.ts @@ -0,0 +1,35 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Mock } from './types.js'; + +import nock from 'nock'; + +interface Request { + code?: number; + method: string; + reply?: Record; +} + +interface HttpMock extends Mock { + post: (uri: string) => { + reply: (code: number, handler: (uri: string, body: { id: string }) => unknown) => HttpMock + } +} + +export const TEST_HTTP_URL = 'http://localhost:9944'; + +export function mockHttp (requests: Request[]): Mock { + nock.cleanAll(); + + return requests.reduce((scope: HttpMock, request: Request) => + scope + .post('/') + .reply(request.code || 200, (_uri: string, body: { id: string }) => { + scope.body = scope.body || {}; + scope.body[request.method] = body; + + return Object.assign({ id: body.id, jsonrpc: '2.0' }, request.reply || {}) as unknown; + }), + nock(TEST_HTTP_URL) as unknown as HttpMock); +} diff --git a/packages/rpc-provider/src/mock/mockWs.ts b/packages/rpc-provider/src/mock/mockWs.ts new file mode 100644 index 00000000..a5c51793 --- /dev/null +++ b/packages/rpc-provider/src/mock/mockWs.ts @@ -0,0 +1,92 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Server, WebSocket } from 'mock-socket'; + +import { stringify } from '@polkadot/util'; + +interface Scope { + body: Record>; + requests: number; + server: Server; + done: any; +} + +interface ErrorDef { + id: number; + error: { + code: number; + message: string; + }; +} + +interface ReplyDef { + id: number; + reply: { + result: unknown; + }; +} + +interface RpcBase { + id: number; + jsonrpc: '2.0'; +} + +type RpcError = RpcBase & ErrorDef; +type RpcReply = RpcBase & { result: unknown }; + +export type Request = { method: string } & (ErrorDef | ReplyDef); + +global.WebSocket = WebSocket as typeof global.WebSocket; + +export const TEST_WS_URL = 'ws://localhost:9955'; + +// should be JSONRPC def return +function createError ({ error: { code, message }, id }: ErrorDef): RpcError { + return { + error: { + code, + message + }, + id, + jsonrpc: '2.0' + }; +} + +// should be JSONRPC def return +function createReply ({ id, reply: { result } }: ReplyDef): RpcReply { + return { + id, + jsonrpc: '2.0', + result + }; +} + +// scope definition returned +export function mockWs (requests: Request[], wsUrl: string = TEST_WS_URL): Scope { + const server = new Server(wsUrl); + + let requestCount = 0; + const scope: Scope = { + body: {}, + done: () => new Promise((resolve) => server.stop(resolve)), + requests: 0, + server + }; + + server.on('connection', (socket): void => { + socket.on('message', (body): void => { + const request = requests[requestCount]; + const response = (request as ErrorDef).error + ? createError(request as ErrorDef) + : createReply(request as ReplyDef); + + scope.body[request.method] = body as unknown as Record; + requestCount++; + + socket.send(stringify(response)); + }); + }); + + return scope; +} diff --git a/packages/rpc-provider/src/mock/on.spec.ts b/packages/rpc-provider/src/mock/on.spec.ts new file mode 100644 index 00000000..79f9bc43 --- /dev/null +++ b/packages/rpc-provider/src/mock/on.spec.ts @@ -0,0 +1,43 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { ProviderInterfaceEmitted } from '../types.js'; + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('on', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + + beforeEach((): void => { + mock = new MockProvider(registry); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + // eslint-disable-next-line jest/expect-expect + it('emits both connected and disconnected events', async (): Promise => { + const events: Record = { connected: false, disconnected: false }; + + await new Promise((resolve) => { + const handler = (type: ProviderInterfaceEmitted): void => { + mock.on(type, (): void => { + events[type] = true; + + if (Object.values(events).filter((value): boolean => value).length === 2) { + resolve(true); + } + }); + }; + + handler('connected'); + handler('disconnected'); + }); + }); +}); diff --git a/packages/rpc-provider/src/mock/send.spec.ts b/packages/rpc-provider/src/mock/send.spec.ts new file mode 100644 index 00000000..164d93cb --- /dev/null +++ b/packages/rpc-provider/src/mock/send.spec.ts @@ -0,0 +1,38 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('send', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + + beforeEach((): void => { + mock = new MockProvider(registry); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + it('fails on non-supported methods', (): Promise => { + return mock + .send('something_invalid', []) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Invalid method/); + }); + }); + + it('returns values for mocked requests', (): Promise => { + return mock + .send('system_name', []) + .then((result): void => { + expect(result).toBe('mockClient'); + }); + }); +}); diff --git a/packages/rpc-provider/src/mock/subscribe.spec.ts b/packages/rpc-provider/src/mock/subscribe.spec.ts new file mode 100644 index 00000000..50bfce2b --- /dev/null +++ b/packages/rpc-provider/src/mock/subscribe.spec.ts @@ -0,0 +1,81 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('subscribe', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + + beforeEach((): void => { + mock = new MockProvider(registry); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + it('fails on unknown methods', async (): Promise => { + await mock + .subscribe('test', 'test_notFound') + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Invalid method 'test_notFound'/); + }); + }); + + it('returns a subscription id', async (): Promise => { + await mock + .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) + .then((id): void => { + expect(id).toEqual(1); + }); + }); + + it('calls back with the last known value', async (): Promise => { + mock.isUpdating = false; + mock.subscriptions.chain_subscribeNewHead.lastValue = 'testValue'; + + await new Promise((resolve) => { + mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, value: string): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect(value).toEqual('testValue'); + resolve(true); + }).catch(console.error); + }); + }); + + // eslint-disable-next-line jest/expect-expect + it('calls back with new headers', async (): Promise => { + await new Promise((resolve) => { + mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { + if (header.number === 4) { + resolve(true); + } + }).catch(console.error); + }); + }); + + // eslint-disable-next-line jest/expect-expect + it('handles errors within callbacks gracefully', async (): Promise => { + let hasThrown = false; + + await new Promise((resolve) => { + mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { + if (!hasThrown) { + hasThrown = true; + + throw new Error('testing'); + } + + if (header.number === 3) { + resolve(true); + } + }).catch(console.error); + }); + }); +}); diff --git a/packages/rpc-provider/src/mock/types.ts b/packages/rpc-provider/src/mock/types.ts new file mode 100644 index 00000000..0a7fbc3a --- /dev/null +++ b/packages/rpc-provider/src/mock/types.ts @@ -0,0 +1,36 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Server } from 'mock-socket'; + +export type Global = typeof globalThis & { + WebSocket: typeof WebSocket; + fetch: any; +} + +export interface Mock { + body: Record>; + requests: number; + server: Server; + done: () => Promise; +} + +export type MockStateSubscriptionCallback = (error: Error | null, value: any) => void; + +export interface MockStateSubscription { + callbacks: Record; + lastValue: any; +} + +export interface MockStateSubscriptions { + // known + chain_subscribeNewHead: MockStateSubscription; + state_subscribeStorage: MockStateSubscription; + + // others + [key: string]: MockStateSubscription; +} + +export type MockStateDb = Record; + +export type MockStateRequests = Record string>; diff --git a/packages/rpc-provider/src/mock/unsubscribe.spec.ts b/packages/rpc-provider/src/mock/unsubscribe.spec.ts new file mode 100644 index 00000000..35a9cf2a --- /dev/null +++ b/packages/rpc-provider/src/mock/unsubscribe.spec.ts @@ -0,0 +1,57 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('unsubscribe', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + let id: number; + + beforeEach((): Promise => { + mock = new MockProvider(registry); + + return mock + .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) + .then((_id): void => { + id = _id; + }); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + it('fails on unknown ids', async (): Promise => { + await mock + .unsubscribe('chain_newHead', 'chain_subscribeNewHead', 5) + .catch((error): boolean => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Unable to find/); + + return false; + }); + }); + + // eslint-disable-next-line jest/expect-expect + it('unsubscribes successfully', async (): Promise => { + await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id); + }); + + it('fails on double unsubscribe', async (): Promise => { + await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) + .then((): Promise => + mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) + ) + .catch((error): boolean => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Unable to find/); + + return false; + }); + }); +}); diff --git a/packages/rpc-provider/src/mod.ts b/packages/rpc-provider/src/mod.ts new file mode 100644 index 00000000..aa7b729d --- /dev/null +++ b/packages/rpc-provider/src/mod.ts @@ -0,0 +1,4 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export * from './index.js'; diff --git a/packages/rpc-provider/src/packageDetect.ts b/packages/rpc-provider/src/packageDetect.ts new file mode 100644 index 00000000..5c2b7116 --- /dev/null +++ b/packages/rpc-provider/src/packageDetect.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @polkadot/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { packageInfo as typesInfo } from '@polkadot/types/packageInfo'; +import { detectPackage } from '@polkadot/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, [typesInfo]); diff --git a/packages/rpc-provider/src/substrate-connect/Health.ts b/packages/rpc-provider/src/substrate-connect/Health.ts new file mode 100644 index 00000000..b2c7fd0c --- /dev/null +++ b/packages/rpc-provider/src/substrate-connect/Health.ts @@ -0,0 +1,325 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { HealthChecker, SmoldotHealth } from './types.js'; + +import { stringify } from '@polkadot/util'; + +interface JSONRequest { + id: string; + jsonrpc: '2.0', + method: string; + params: unknown[]; +} + +/* + * Creates a new health checker. + * + * The role of the health checker is to report to the user the health of a smoldot chain. + * + * In order to use it, start by creating a health checker, and call `setSendJsonRpc` to set the + * way to send a JSON-RPC request to a chain. The health checker is disabled by default. Use + * `start()` in order to start the health checks. The `start()` function must be passed a callback called + * when an update to the health of the node is available. + * + * In order to send a JSON-RPC request to the chain, you **must** use the `sendJsonRpc` function + * of the health checker. The health checker rewrites the `id` of the requests it receives. + * + * When the chain send a JSON-RPC response, it must be passed to `responsePassThrough()`. This + * function intercepts the responses destined to the requests that have been emitted by the health + * checker and returns `null`. If the response doesn't concern the health checker, the response is + * simply returned by the function. + * + * # How it works + * + * The health checker periodically calls the `system_health` JSON-RPC call in order to determine + * the health of the chain. + * + * In addition to this, as long as the health check reports that `isSyncing` is `true`, the + * health checker also maintains a subscription to new best blocks using `chain_subscribeNewHeads`. + * Whenever a new block is notified, a health check is performed immediately in order to determine + * whether `isSyncing` has changed to `false`. + * + * Thanks to this subscription, the latency of the report of the switch from `isSyncing: true` to + * `isSyncing: false` is very low. + * + */ +export function healthChecker (): HealthChecker { + // `null` if health checker is not started. + let checker: null | InnerChecker = null; + let sendJsonRpc: null | ((request: string) => void) = null; + + return { + responsePassThrough: (jsonRpcResponse) => { + if (checker === null) { + return jsonRpcResponse; + } + + return checker.responsePassThrough(jsonRpcResponse); + }, + sendJsonRpc: (request) => { + if (!sendJsonRpc) { + throw new Error('setSendJsonRpc must be called before sending requests'); + } + + if (checker === null) { + sendJsonRpc(request); + } else { + checker.sendJsonRpc(request); + } + }, + setSendJsonRpc: (cb) => { + sendJsonRpc = cb; + }, + start: (healthCallback) => { + if (checker !== null) { + throw new Error("Can't start the health checker multiple times in parallel"); + } else if (!sendJsonRpc) { + throw new Error('setSendJsonRpc must be called before starting the health checks'); + } + + checker = new InnerChecker(healthCallback, sendJsonRpc); + checker.update(true); + }, + stop: () => { + if (checker === null) { + return; + } // Already stopped. + + checker.destroy(); + checker = null; + } + }; +} + +class InnerChecker { + #healthCallback: (health: SmoldotHealth) => void; + #currentHealthCheckId: string | null = null; + #currentHealthTimeout: ReturnType | null = null; + #currentSubunsubRequestId: string | null = null; + #currentSubscriptionId: string | null = null; + #requestToSmoldot: (request: JSONRequest) => void; + #isSyncing = false; + #nextRequestId = 0; + + constructor (healthCallback: (health: SmoldotHealth) => void, requestToSmoldot: (request: string) => void) { + this.#healthCallback = healthCallback; + this.#requestToSmoldot = (request: JSONRequest) => requestToSmoldot(stringify(request)); + } + + sendJsonRpc = (request: string): void => { + // Replace the `id` in the request to prefix the request ID with `extern:`. + let parsedRequest: JSONRequest; + + try { + parsedRequest = JSON.parse(request) as JSONRequest; + } catch { + return; + } + + if (parsedRequest.id) { + const newId = 'extern:' + stringify(parsedRequest.id); + + parsedRequest.id = newId; + } + + this.#requestToSmoldot(parsedRequest); + }; + + responsePassThrough = (jsonRpcResponse: string): string | null => { + let parsedResponse: {id: string, result?: SmoldotHealth, params?: { subscription: string }}; + + try { + parsedResponse = JSON.parse(jsonRpcResponse) as { id: string, result?: SmoldotHealth }; + } catch { + return jsonRpcResponse; + } + + // Check whether response is a response to `system_health`. + if (parsedResponse.id && this.#currentHealthCheckId === parsedResponse.id) { + this.#currentHealthCheckId = null; + + // Check whether query was successful. It is possible for queries to fail for + // various reasons, such as the client being overloaded. + if (!parsedResponse.result) { + this.update(false); + + return null; + } + + this.#healthCallback(parsedResponse.result); + this.#isSyncing = parsedResponse.result.isSyncing; + this.update(false); + + return null; + } + + // Check whether response is a response to the subscription or unsubscription. + if ( + parsedResponse.id && + this.#currentSubunsubRequestId === parsedResponse.id + ) { + this.#currentSubunsubRequestId = null; + + // Check whether query was successful. It is possible for queries to fail for + // various reasons, such as the client being overloaded. + if (!parsedResponse.result) { + this.update(false); + + return null; + } + + if (this.#currentSubscriptionId) { + this.#currentSubscriptionId = null; + } else { + this.#currentSubscriptionId = parsedResponse.result as unknown as string; + } + + this.update(false); + + return null; + } + + // Check whether response is a notification to a subscription. + if ( + parsedResponse.params && + this.#currentSubscriptionId && + parsedResponse.params.subscription === this.#currentSubscriptionId + ) { + // Note that after a successful subscription, a notification containing + // the current best block is always returned. Considering that a + // subscription is performed in response to a health check, calling + // `startHealthCheck()` here will lead to a second health check. + // It might seem redundant to perform two health checks in a quick + // succession, but doing so doesn't lead to any problem, and it is + // actually possible for the health to have changed in between as the + // current best block might have been updated during the subscription + // request. + this.update(true); + + return null; + } + + // Response doesn't concern us. + if (parsedResponse.id) { + const id: string = parsedResponse.id; + + // Need to remove the `extern:` prefix. + if (!id.startsWith('extern:')) { + throw new Error('State inconsistency in health checker'); + } + + const newId = JSON.parse(id.slice('extern:'.length)) as string; + + parsedResponse.id = newId; + } + + return stringify(parsedResponse); + }; + + update = (startNow: boolean): void => { + // If `startNow`, clear `#currentHealthTimeout` so that it is set below. + if (startNow && this.#currentHealthTimeout) { + clearTimeout(this.#currentHealthTimeout); + this.#currentHealthTimeout = null; + } + + if (!this.#currentHealthTimeout) { + const startHealthRequest = () => { + this.#currentHealthTimeout = null; + + // No matter what, don't start a health request if there is already one in progress. + // This is sane to do because receiving a response to a health request calls `update()`. + if (this.#currentHealthCheckId) { + return; + } + + // Actual request starting. + this.#currentHealthCheckId = `health-checker:${this.#nextRequestId}`; + this.#nextRequestId += 1; + + this.#requestToSmoldot({ + id: this.#currentHealthCheckId, + jsonrpc: '2.0', + method: 'system_health', + params: [] + }); + }; + + if (startNow) { + startHealthRequest(); + } else { + this.#currentHealthTimeout = setTimeout(startHealthRequest, 1000); + } + } + + if ( + this.#isSyncing && + !this.#currentSubscriptionId && + !this.#currentSubunsubRequestId + ) { + this.startSubscription(); + } + + if ( + !this.#isSyncing && + this.#currentSubscriptionId && + !this.#currentSubunsubRequestId + ) { + this.endSubscription(); + } + }; + + startSubscription = (): void => { + if (this.#currentSubunsubRequestId || this.#currentSubscriptionId) { + throw new Error('Internal error in health checker'); + } + + this.#currentSubunsubRequestId = `health-checker:${this.#nextRequestId}`; + this.#nextRequestId += 1; + + this.#requestToSmoldot({ + id: this.#currentSubunsubRequestId, + jsonrpc: '2.0', + method: 'chain_subscribeNewHeads', + params: [] + }); + }; + + endSubscription = (): void => { + if (this.#currentSubunsubRequestId || !this.#currentSubscriptionId) { + throw new Error('Internal error in health checker'); + } + + this.#currentSubunsubRequestId = `health-checker:${this.#nextRequestId}`; + this.#nextRequestId += 1; + + this.#requestToSmoldot({ + id: this.#currentSubunsubRequestId, + jsonrpc: '2.0', + method: 'chain_unsubscribeNewHeads', + params: [this.#currentSubscriptionId] + }); + }; + + destroy = (): void => { + if (this.#currentHealthTimeout) { + clearTimeout(this.#currentHealthTimeout); + this.#currentHealthTimeout = null; + } + }; +} + +export class HealthCheckError extends Error { + readonly #cause: unknown; + + getCause (): unknown { + return this.#cause; + } + + constructor (response: unknown, message = 'Got error response asking for system health') { + super(message); + + this.#cause = response; + } +} diff --git a/packages/rpc-provider/src/substrate-connect/index.spec.ts b/packages/rpc-provider/src/substrate-connect/index.spec.ts new file mode 100644 index 00000000..d0dd077b --- /dev/null +++ b/packages/rpc-provider/src/substrate-connect/index.spec.ts @@ -0,0 +1,638 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type * as Sc from '@substrate/connect'; +import type { HealthChecker, SmoldotHealth } from './types.js'; + +import { noop, stringify } from '@polkadot/util'; + +import { ScProvider } from './index.js'; + +interface MockChain extends Sc.Chain { + _spec: () => string; + _recevedRequests: () => string[]; + _isTerminated: () => boolean; + _triggerCallback: (response: string | object) => void; + _setTerminateInterceptor: (fn: () => void) => void; + _setSendJsonRpcInterceptor: (fn: (rpc: string) => void) => void; + _getLatestRequest: () => string; +} + +interface MockedHealthChecker extends HealthChecker { + _isActive: () => boolean; + _triggerHealthUpdate: (update: SmoldotHealth) => void; +} + +type MockSc = typeof Sc & { + latestChain: () => MockChain; +}; + +enum WellKnownChain { + polkadot = 'polkadot', + ksmcc3 = 'ksmcc3', + rococo_v2_2 = 'rococo_v2_2', + westend2 = 'westend2' +} + +const wait = (ms: number) => + new Promise((resolve) => + setTimeout(resolve, ms) + ); + +function healthCheckerMock (): MockedHealthChecker { + let cb: (health: SmoldotHealth) => void = () => undefined; + let sendJsonRpc: (request: string) => void = () => undefined; + let isActive = false; + + return { + _isActive: () => isActive, + _triggerHealthUpdate: (update: SmoldotHealth) => { + cb(update); + }, + responsePassThrough: (response) => response, + sendJsonRpc: (...args) => sendJsonRpc(...args), + setSendJsonRpc: (cb) => { + sendJsonRpc = cb; + }, + start: (x) => { + isActive = true; + cb = x; + }, + stop: () => { + isActive = false; + } + }; +} + +function healthCheckerFactory () { + const _healthCheckers: MockedHealthChecker[] = []; + + return { + _healthCheckers, + _latestHealthChecker: () => _healthCheckers.slice(-1)[0], + healthChecker: () => { + const result = healthCheckerMock(); + + _healthCheckers.push(result); + + return result; + } + }; +} + +function getFakeChain (spec: string, callback: Sc.JsonRpcCallback): MockChain { + const _receivedRequests: string[] = []; + let _isTerminated = false; + + let terminateInterceptor = Function.prototype; + let sendJsonRpcInterceptor = Function.prototype; + + return { + _getLatestRequest: () => _receivedRequests[_receivedRequests.length - 1], + _isTerminated: () => _isTerminated, + _recevedRequests: () => _receivedRequests, + _setSendJsonRpcInterceptor: (fn) => { + sendJsonRpcInterceptor = fn; + }, + _setTerminateInterceptor: (fn) => { + terminateInterceptor = fn; + }, + _spec: () => spec, + _triggerCallback: (response) => { + callback( + typeof response === 'string' + ? response + : stringify(response) + ); + }, + addChain: (chainSpec, jsonRpcCallback) => + Promise.resolve(getFakeChain(chainSpec, jsonRpcCallback ?? noop)), + remove: () => { + terminateInterceptor(); + _isTerminated = true; + }, + sendJsonRpc: (rpc) => { + sendJsonRpcInterceptor(rpc); + _receivedRequests.push(rpc); + } + }; +} + +function getFakeClient () { + const chains: MockChain[] = []; + let addChainInterceptor: Promise = Promise.resolve(); + let addWellKnownChainInterceptor: Promise = Promise.resolve(); + + return { + _chains: () => chains, + _setAddChainInterceptor: (interceptor: Promise) => { + addChainInterceptor = interceptor; + }, + _setAddWellKnownChainInterceptor: (interceptor: Promise) => { + addWellKnownChainInterceptor = interceptor; + }, + addChain: (chainSpec: string, cb: Sc.JsonRpcCallback): Promise => + addChainInterceptor.then(() => { + const result = getFakeChain(chainSpec, cb); + + chains.push(result); + + return result; + }), + addWellKnownChain: ( + wellKnownChain: string, + cb: Sc.JsonRpcCallback + ): Promise => + addWellKnownChainInterceptor.then(() => { + const result = getFakeChain(wellKnownChain, cb); + + chains.push(result); + + return result; + }) + }; +} + +function connectorFactory (): MockSc { + const clients: ReturnType[] = []; + const latestClient = () => clients[clients.length - 1]; + + return { + WellKnownChain, + _clients: () => clients, + createScClient: () => { + const result = getFakeClient(); + + clients.push(result); + + return result; + }, + latestChain: () => + latestClient()._chains()[latestClient()._chains().length - 1], + latestClient + } as unknown as MockSc; +} + +function setChainSyncyingStatus (isSyncing: boolean): void { + getCurrentHealthChecker()._triggerHealthUpdate({ + isSyncing, + peers: 1, + shouldHavePeers: true + }); +} + +let mockSc: MockSc; +let mockedHealthChecker: ReturnType; +const getCurrentHealthChecker = () => mockedHealthChecker._latestHealthChecker(); + +describe('ScProvider', () => { + beforeAll(() => { + mockSc = connectorFactory(); + mockedHealthChecker = healthCheckerFactory(); + }); + + describe('on', () => { + it('emits `connected` as soon as the chain is not syncing', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + const onConnected = jest.fn(); + + provider.on('connected', onConnected); + + expect(onConnected).not.toHaveBeenCalled(); + setChainSyncyingStatus(false); + expect(onConnected).toHaveBeenCalled(); + }); + + it('stops receiving notifications after unsubscribing', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + const onConnected = jest.fn(); + + provider.on('connected', onConnected)(); + expect(onConnected).not.toHaveBeenCalled(); + + setChainSyncyingStatus(false); + expect(onConnected).not.toHaveBeenCalled(); + }); + + it('synchronously emits connected if the Provider is already `connected`', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + setChainSyncyingStatus(false); + + const onConnected = jest.fn(); + + provider.on('connected', onConnected); + expect(onConnected).toHaveBeenCalled(); + }); + + it('emits `disconnected` once the chain goes back to syncing', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + setChainSyncyingStatus(false); + + const onConnected = jest.fn(); + const onDisconnected = jest.fn(); + + provider.on('connected', onConnected); + provider.on('disconnected', onDisconnected); + + expect(onConnected).toHaveBeenCalled(); + expect(onDisconnected).not.toHaveBeenCalled(); + + onConnected.mockReset(); + setChainSyncyingStatus(true); + + expect(onConnected).not.toHaveBeenCalled(); + expect(onDisconnected).toHaveBeenCalled(); + }); + }); + + describe('hasSubscriptions', () => { + it('supports subscriptions', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + expect(provider.hasSubscriptions).toBe(true); + }); + }); + + describe('clone', () => { + it('can not be clonned', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + expect(() => provider.clone()).toThrow(); + }); + }); + + describe('connect', () => { + it('does not create a new chain when trying to re-connect while the current chain is syncing', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + expect(chain).toBe(mockSc.latestChain()); + }); + + it('throws when trying to connect on an already connected Provider', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + setChainSyncyingStatus(false); + + await expect( + provider.connect(undefined, mockedHealthChecker.healthChecker) + ).rejects.toThrow(/Already connected/); + }); + }); + + describe('disconnect', () => { + it('removes the chain and cleans up', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + await provider.disconnect(); + + expect(chain._isTerminated()).toBe(true); + }); + + // eslint-disable-next-line jest/expect-expect + it('does not throw when disconnecting on an already disconnected Provider', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + await provider.disconnect(); + await provider.disconnect(); + }); + }); + + describe('send', () => { + it('throws when trying to send a request while the Provider is not connected', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + await expect(provider.send('', [])).rejects.toThrow(); + }); + + it('receives responses to its requests', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + setChainSyncyingStatus(false); + + const responsePromise = provider.send('getData', ['foo']); + + await wait(0); + expect(chain._getLatestRequest()).toEqual( + '{"id":1,"jsonrpc":"2.0","method":"getData","params":["foo"]}' + ); + + const result = { foo: 'foo' }; + + chain._triggerCallback({ + id: 1, + jsonrpc: '2.0', + result + }); + + const response = await responsePromise; + + expect(response).toEqual(result); + }); + + it("rejects when the response can't be deserialized", async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + setChainSyncyingStatus(false); + + setTimeout(() => { + chain._triggerCallback({ + id: 1, + jsonrpc: '2.0' + }); + }, 0); + + await expect(provider.send('getData', ['foo'])).rejects.toThrow(); + }); + + it('rejects when the smoldot chain has crashed', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + setChainSyncyingStatus(false); + await wait(0); + + chain._setSendJsonRpcInterceptor(() => { + throw new Error('boom!'); + }); + + await expect( + provider.send('getData', ['foo']) + ).rejects.toThrow(/Disconnected/); + expect(provider.isConnected).toBe(false); + }); + }); + + describe('subscribe', () => { + it('subscribes and recives messages until it unsubscribes', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + setChainSyncyingStatus(false); + + const unsubscribeToken = 'unsubscribeToken'; + + setTimeout(() => { + chain._triggerCallback({ + id: 1, + jsonrpc: '2.0', + result: unsubscribeToken + }); + }, 0); + + const cb = jest.fn(); + const token = await provider.subscribe( + 'foo', + 'chain_subscribeNewHeads', + ['baz'], + cb + ); + + expect(token).toBe(unsubscribeToken); + expect(cb).not.toHaveBeenCalled(); + + chain._triggerCallback({ + jsonrpc: '2.0', + method: 'foo', + params: { + result: 1, + subscription: token + } + }); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(null, 1); + + chain._triggerCallback({ + jsonrpc: '2.0', + method: 'foo', + params: { + result: 2, + subscription: token + } + }); + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith(null, 2); + + provider + .unsubscribe('foo', 'chain_unsubscribeNewHeads', unsubscribeToken) + .catch(console.error); + + chain._triggerCallback({ + jsonrpc: '2.0', + method: 'foo', + params: { + result: 3, + subscription: token + } + }); + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith(null, 2); + }); + + it('ignores subscription messages that were received before the subscription token', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + setChainSyncyingStatus(false); + + const unsubscribeToken = 'unsubscribeToken'; + + chain._triggerCallback({ + jsonrpc: '2.0', + method: 'foo', + params: { + result: 1, + subscription: unsubscribeToken + } + }); + setTimeout(() => { + chain._triggerCallback({ + id: 1, + jsonrpc: '2.0', + result: unsubscribeToken + }); + }, 0); + + const cb = jest.fn(); + const token = await provider.subscribe( + 'foo', + 'chain_subscribeNewHeads', + ['baz'], + cb + ); + + expect(token).toBe(unsubscribeToken); + expect(cb).not.toHaveBeenCalled(); + }); + + it('emits the error when the message has an error', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + setChainSyncyingStatus(false); + await wait(0); + + const unsubscribeToken = 'unsubscribeToken'; + + setTimeout(() => { + chain._triggerCallback({ + id: 1, + jsonrpc: '2.0', + result: unsubscribeToken + }); + }, 0); + + const cb = jest.fn(); + const token = await provider.subscribe( + 'foo', + 'chain_subscribeNewHeads', + ['baz'], + cb + ); + + chain._triggerCallback({ + jsonrpc: '2.0', + method: 'foo', + params: { + error: 'boom', + subscription: unsubscribeToken + } + }); + + expect(token).toBe(unsubscribeToken); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenLastCalledWith(expect.any(Error), undefined); + }); + + it('errors when subscribing to an unsupported method', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + setChainSyncyingStatus(false); + + await wait(0); + await expect( + provider.subscribe('foo', 'bar', ['baz'], () => undefined) + ).rejects.toThrow(/Unsupported subscribe method: bar/); + }); + }); + + describe('unsubscribe', () => { + it('rejects when trying to unsubscribe from un unexisting subscription', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + + setChainSyncyingStatus(false); + + await expect( + provider.unsubscribe('', '', '') + ).rejects.toThrow(/Unable to find active subscription/); + }); + }); + + it('cleans up the stale subscriptions once it reconnects', async () => { + const provider = new ScProvider(mockSc, ''); + + await provider.connect(undefined, mockedHealthChecker.healthChecker); + const chain = mockSc.latestChain(); + + // setting the syncing status of the chain to fals so that the Provider + // gets `connected` + setChainSyncyingStatus(false); + + // while connected we create a subscription + const unsubscribeToken = 'unsubscribeToken'; + + setTimeout(() => { + chain._triggerCallback({ + id: 1, + jsonrpc: '2.0', + result: unsubscribeToken + }); + }, 0); + + const cb = jest.fn(); + const token = await provider.subscribe( + 'foo', + 'chain_subscribeNewHeads', + ['baz'], + cb + ); + + // setting the syncing status of the chain to fals so that the Provider + // gets `disconnected` + setChainSyncyingStatus(true); + + // let's wait some time in order to ensure that the stale unsubscription + // messages are not sent until the chain syncing status changes back to false + await wait(200); + + // before we let the healthChecker know that the chain is no longer syncing, + // let's make sure that the chain has received the correct request, and + // most importantly that it has not received a request for unsubscribing + // from the stale subscription, since that request should happen once the + // chain is no longer syncing + expect(chain._recevedRequests()).toEqual([ + '{"id":1,"jsonrpc":"2.0","method":"chain_subscribeNewHeads","params":["baz"]}' + ]); + + // lets change the sync status back to false + setChainSyncyingStatus(false); + + // let's wait one tick to ensure that the microtasks got processed + await wait(0); + + // let's make sure that we have now sent the request for killing the + // stale subscription + expect(chain._recevedRequests()).toEqual([ + '{"id":1,"jsonrpc":"2.0","method":"chain_subscribeNewHeads","params":["baz"]}', + `{"id":2,"jsonrpc":"2.0","method":"chain_unsubscribeNewHeads","params":["${token}"]}`, + '{"id":3,"jsonrpc":"2.0","method":"chain_subscribeNewHeads","params":["baz"]}' + ]); + }); +}); diff --git a/packages/rpc-provider/src/substrate-connect/index.ts b/packages/rpc-provider/src/substrate-connect/index.ts new file mode 100644 index 00000000..eaa7f17a --- /dev/null +++ b/packages/rpc-provider/src/substrate-connect/index.ts @@ -0,0 +1,415 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type * as ScType from '@substrate/connect'; +import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '../types.js'; + +import { EventEmitter } from 'eventemitter3'; + +import { isError, isFunction, isObject, logger, noop, objectSpread } from '@polkadot/util'; + +import { RpcCoder } from '../coder/index.js'; +import { healthChecker } from './Health.js'; + +type ResponseCallback = (response: string | Error) => void; + +// We define the interface with items we use - this means that we don't really +// need to be passed a full `import * as Sc from '@ubstrate/connect'`, but can +// also make do with a { WellKnownChain, createScClient } interface +interface SubstrateConnect { + WellKnownChain: typeof ScType['WellKnownChain']; + createScClient: typeof ScType['createScClient']; +} + +const l = logger('api-substrate-connect'); + +// These methods have been taken from: +// https://github.com/paritytech/smoldot/blob/17425040ddda47d539556eeaf62b88c4240d1d42/src/json_rpc/methods.rs#L338-L462 +// It's important to take into account that smoldot is adding support to the new +// json-rpc-interface https://paritytech.github.io/json-rpc-interface-spec/ +// However, at the moment this list only includes methods that belong to the "old" API +const subscriptionUnsubscriptionMethods = new Map([ + ['author_submitAndWatchExtrinsic', 'author_unwatchExtrinsic'], + ['chain_subscribeAllHeads', 'chain_unsubscribeAllHeads'], + ['chain_subscribeFinalizedHeads', 'chain_unsubscribeFinalizedHeads'], + ['chain_subscribeFinalisedHeads', 'chain_subscribeFinalisedHeads'], + ['chain_subscribeNewHeads', 'chain_unsubscribeNewHeads'], + ['chain_subscribeNewHead', 'chain_unsubscribeNewHead'], + ['chain_subscribeRuntimeVersion', 'chain_unsubscribeRuntimeVersion'], + ['subscribe_newHead', 'unsubscribe_newHead'], + ['state_subscribeRuntimeVersion', 'state_unsubscribeRuntimeVersion'], + ['state_subscribeStorage', 'state_unsubscribeStorage'] +]); + +const scClients = new WeakMap(); + +interface ActiveSubs { + type: string, + method: string, + params: any[], + callback: ProviderInterfaceCallback +} + +export class ScProvider implements ProviderInterface { + readonly #Sc: SubstrateConnect; + readonly #coder: RpcCoder = new RpcCoder(); + readonly #spec: string | ScType.WellKnownChain; + readonly #sharedSandbox?: ScProvider | undefined; + readonly #subscriptions = new Map(); + readonly #resubscribeMethods = new Map(); + readonly #requests = new Map(); + readonly #wellKnownChains: Set; + readonly #eventemitter: EventEmitter = new EventEmitter(); + + #chain: Promise | null = null; + #isChainReady = false; + + public constructor (Sc: SubstrateConnect, spec: string | ScType.WellKnownChain, sharedSandbox?: ScProvider) { + if (!isObject(Sc) || !isObject(Sc.WellKnownChain) || !isFunction(Sc.createScClient)) { + throw new Error('Expected an @substrate/connect interface as first parameter to ScProvider'); + } + + this.#Sc = Sc; + this.#spec = spec; + this.#sharedSandbox = sharedSandbox; + this.#wellKnownChains = new Set(Object.values(Sc.WellKnownChain)); + } + + public get hasSubscriptions (): boolean { + // Indicates that subscriptions are supported + return !!true; + } + + public get isClonable (): boolean { + return !!false; + } + + public get isConnected (): boolean { + return !!this.#chain && this.#isChainReady; + } + + public clone (): ProviderInterface { + throw new Error('clone() is not supported.'); + } + + // Config details can be found in @substrate/connect repo following the link: + // https://github.com/paritytech/substrate-connect/blob/main/packages/connect/src/connector/index.ts + async connect (config?: ScType.Config, checkerFactory = healthChecker): Promise { + if (this.isConnected) { + throw new Error('Already connected!'); + } + + // it could happen that after emitting `disconnected` due to the fact that + // smoldot is syncing, the consumer tries to reconnect after a certain amount + // of time... In which case we want to make sure that we don't create a new + // chain. + if (this.#chain) { + await this.#chain; + + return; + } + + if (this.#sharedSandbox && !this.#sharedSandbox.isConnected) { + await this.#sharedSandbox.connect(); + } + + const client = this.#sharedSandbox + ? scClients.get(this.#sharedSandbox) + : this.#Sc.createScClient(config); + + if (!client) { + throw new Error('Unknown ScProvider!'); + } + + scClients.set(this, client); + + const hc = checkerFactory(); + + const onResponse = (res: string): void => { + const hcRes = hc.responsePassThrough(res); + + if (!hcRes) { + return; + } + + const response = JSON.parse(hcRes) as JsonRpcResponse; + let decodedResponse: string | Error; + + try { + decodedResponse = this.#coder.decodeResponse(response); + } catch (e) { + decodedResponse = e as Error; + } + + // It's not a subscription message, but rather a standar RPC response + if (response.params?.subscription === undefined || !response.method) { + return this.#requests.get(response.id)?.(decodedResponse); + } + + // We are dealing with a subscription message + const subscriptionId = `${response.method}::${response.params.subscription}`; + + const callback = this.#subscriptions.get(subscriptionId)?.[0]; + + callback?.(decodedResponse); + }; + + const addChain = this.#sharedSandbox + ? (async (...args) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const source = this.#sharedSandbox!; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (await source.#chain)!.addChain(...args); + }) as ScType.AddChain + : this.#wellKnownChains.has(this.#spec as ScType.WellKnownChain) + ? client.addWellKnownChain + : client.addChain; + + this.#chain = addChain(this.#spec as ScType.WellKnownChain, onResponse).then((chain) => { + hc.setSendJsonRpc(chain.sendJsonRpc); + + this.#isChainReady = false; + + const cleanup = () => { + // If there are any callbacks left, we have to reject/error them. + // Otherwise, that would cause a memory leak. + const disconnectionError = new Error('Disconnected'); + + this.#requests.forEach((cb) => cb(disconnectionError)); + this.#subscriptions.forEach(([cb]) => cb(disconnectionError)); + this.#subscriptions.clear(); + }; + + const staleSubscriptions: { + unsubscribeMethod: string + id: number | string + }[] = []; + + const killStaleSubscriptions = () => { + if (staleSubscriptions.length === 0) { + return; + } + + const stale = staleSubscriptions.pop(); + + if (!stale) { + throw new Error('Unable to get stale subscription'); + } + + const { id, unsubscribeMethod } = stale; + + Promise + .race([ + this.send(unsubscribeMethod, [id]).catch(noop), + new Promise((resolve) => setTimeout(resolve, 500)) + ]) + .then(killStaleSubscriptions) + .catch(noop); + }; + + hc.start((health) => { + const isReady = + !health.isSyncing && (health.peers > 0 || !health.shouldHavePeers); + + // if it's the same as before, then nothing has changed and we are done + if (this.#isChainReady === isReady) { + return; + } + + this.#isChainReady = isReady; + + if (!isReady) { + // If we've reached this point, that means that the chain used to be "ready" + // and now we are about to emit `disconnected`. + // + // This will cause the PolkadotJs API think that the connection is + // actually dead. In reality the smoldot chain is not dead, of course. + // However, we have to cleanup all the existing callbacks because when + // the smoldot chain stops syncing, then we will emit `connected` and + // the PolkadotJs API will try to re-create the previous + // subscriptions and requests. Although, now is not a good moment + // to be sending unsubscription messages to the smoldot chain, we + // should wait until is no longer syncing to send the unsubscription + // messages from the stale subscriptions of the previous connection. + // + // That's why -before we perform the cleanup of `this.#subscriptions`- + // we keep the necessary information that we will need later on to + // kill the stale subscriptions. + [...this.#subscriptions.values()].forEach((s) => { + staleSubscriptions.push(s[1]); + }); + cleanup(); + + this.#eventemitter.emit('disconnected'); + } else { + killStaleSubscriptions(); + + this.#eventemitter.emit('connected'); + + if (this.#resubscribeMethods.size) { + this.#resubscribe(); + } + } + }); + + return objectSpread({}, chain, { + remove: () => { + hc.stop(); + chain.remove(); + cleanup(); + }, + sendJsonRpc: hc.sendJsonRpc.bind(hc) + }); + }); + + try { + await this.#chain; + } catch (e) { + this.#chain = null; + this.#eventemitter.emit('error', e); + throw e; + } + } + + #resubscribe = (): void => { + const promises: any[] = []; + + this.#resubscribeMethods.forEach((subDetails: ActiveSubs): void => { + // only re-create subscriptions which are not in author (only area where + // transactions are created, i.e. submissions such as 'author_submitAndWatchExtrinsic' + // are not included (and will not be re-broadcast) + if (subDetails.type.startsWith('author_')) { + return; + } + + try { + const promise = new Promise((resolve) => { + this.subscribe(subDetails.type, subDetails.method, subDetails.params, subDetails.callback).catch((error) => console.log(error)); + resolve(); + }); + + promises.push(promise); + } catch (error) { + l.error(error); + } + }); + + Promise.all(promises).catch((err) => l.log(err)); + }; + + async disconnect (): Promise { + if (!this.#chain) { + return; + } + + const chain = await this.#chain; + + this.#chain = null; + this.#isChainReady = false; + + try { + chain.remove(); + } catch (_) {} + + this.#eventemitter.emit('disconnected'); + } + + public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { + // It's possible. Although, quite unlikely, that by the time that polkadot + // subscribes to the `connected` event, the Provider is already connected. + // In that case, we must emit to let the consumer know that we are connected. + if (type === 'connected' && this.isConnected) { + sub(); + } + + this.#eventemitter.on(type, sub); + + return (): void => { + this.#eventemitter.removeListener(type, sub); + }; + } + + public async send (method: string, params: unknown[]): Promise { + if (!this.isConnected || !this.#chain) { + throw new Error('Provider is not connected'); + } + + const chain = await this.#chain; + const [id, json] = this.#coder.encodeJson(method, params); + + const result = new Promise((resolve, reject): void => { + this.#requests.set(id, (response) => { + (isError(response) ? reject : resolve)(response as unknown as T); + }); + + try { + chain.sendJsonRpc(json); + } catch (e) { + this.#chain = null; + + try { + chain.remove(); + } catch (_) {} + + this.#eventemitter.emit('error', e); + } + }); + + try { + return await result; + } finally { + // let's ensure that once the Promise is resolved/rejected, then we remove + // remove its entry from the internal #requests + this.#requests.delete(id); + } + } + + public async subscribe (type: string, method: string, params: any[], callback: ProviderInterfaceCallback): Promise { + if (!subscriptionUnsubscriptionMethods.has(method)) { + throw new Error(`Unsupported subscribe method: ${method}`); + } + + const id = await this.send(method, params); + const subscriptionId = `${type}::${id}`; + + const cb = (response: Error | string) => { + if (response instanceof Error) { + callback(response, undefined); + } else { + callback(null, response); + } + }; + + const unsubscribeMethod = subscriptionUnsubscriptionMethods.get(method); + + if (!unsubscribeMethod) { + throw new Error('Invalid unsubscribe method found'); + } + + this.#resubscribeMethods.set(subscriptionId, { callback, method, params, type }); + + this.#subscriptions.set(subscriptionId, [cb, { id, unsubscribeMethod }]); + + return id; + } + + public unsubscribe (type: string, method: string, id: number | string): Promise { + if (!this.isConnected) { + throw new Error('Provider is not connected'); + } + + const subscriptionId = `${type}::${id}`; + + if (!this.#subscriptions.has(subscriptionId)) { + return Promise.reject( + new Error(`Unable to find active subscription=${subscriptionId}`) + ); + } + + this.#resubscribeMethods.delete(subscriptionId); + this.#subscriptions.delete(subscriptionId); + + return this.send(method, [id]); + } +} diff --git a/packages/rpc-provider/src/substrate-connect/types.ts b/packages/rpc-provider/src/substrate-connect/types.ts new file mode 100644 index 00000000..2de85f2d --- /dev/null +++ b/packages/rpc-provider/src/substrate-connect/types.ts @@ -0,0 +1,16 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export interface SmoldotHealth { + isSyncing: boolean + peers: number + shouldHavePeers: boolean +} + +export interface HealthChecker { + setSendJsonRpc(sendRequest: (request: string) => void): void + start(healthCallback: (health: SmoldotHealth) => void): void + stop(): void + sendJsonRpc(request: string): void + responsePassThrough(response: string): string | null +} diff --git a/packages/rpc-provider/src/types.ts b/packages/rpc-provider/src/types.ts new file mode 100644 index 00000000..ad030ec4 --- /dev/null +++ b/packages/rpc-provider/src/types.ts @@ -0,0 +1,99 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export interface JsonRpcObject { + id: number; + jsonrpc: '2.0'; +} + +export interface JsonRpcRequest extends JsonRpcObject { + method: string; + params: unknown[]; +} + +export interface JsonRpcResponseBaseError { + code: number; + data?: number | string; + message: string; +} + +export interface RpcErrorInterface { + code: number; + data?: T; + message: string; + stack: string; +} + +interface JsonRpcResponseSingle { + error?: JsonRpcResponseBaseError; + result: T; +} + +interface JsonRpcResponseSubscription { + method?: string; + params: { + error?: JsonRpcResponseBaseError; + result: T; + subscription: number | string; + }; +} + +export type JsonRpcResponseBase = JsonRpcResponseSingle & JsonRpcResponseSubscription; + +export type JsonRpcResponse = JsonRpcObject & JsonRpcResponseBase; + +export type ProviderInterfaceCallback = (error: Error | null, result: any) => void; + +export type ProviderInterfaceEmitted = 'connected' | 'disconnected' | 'error'; + +export type ProviderInterfaceEmitCb = (value?: any) => any; + +export interface ProviderInterface { + /** true if the provider supports subscriptions (not available for HTTP) */ + readonly hasSubscriptions: boolean; + /** true if the clone() functionality is available on the provider */ + readonly isClonable: boolean; + /** true if the provider is currently connected (ws/sc has connection logic) */ + readonly isConnected: boolean; + /** (optional) stats for the provider with connections/bytes */ + readonly stats?: ProviderStats; + + clone (): ProviderInterface; + connect (): Promise; + disconnect (): Promise; + on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void; + send (method: string, params: unknown[], isCacheable?: boolean): Promise; + subscribe (type: string, method: string, params: unknown[], cb: ProviderInterfaceCallback): Promise; + unsubscribe (type: string, method: string, id: number | string): Promise; +} + +/** Stats for a specific endpoint */ +export interface EndpointStats { + /** The total number of bytes sent */ + bytesRecv: number; + /** The total number of bytes received */ + bytesSent: number; + /** The number of cached/in-progress requests made */ + cached: number; + /** The number of errors found */ + errors: number; + /** The number of requests */ + requests: number; + /** The number of subscriptions */ + subscriptions: number; + /** The number of request timeouts */ + timeout: number; +} + +/** Overall stats for the provider */ +export interface ProviderStats { + /** Details for the active/open requests */ + active: { + /** Number of active requests */ + requests: number; + /** Number of active subscriptions */ + subscriptions: number; + }; + /** The total requests that have been made */ + total: EndpointStats; +} diff --git a/packages/rpc-provider/src/ws/connect.spec.ts b/packages/rpc-provider/src/ws/connect.spec.ts new file mode 100644 index 00000000..50a857f2 --- /dev/null +++ b/packages/rpc-provider/src/ws/connect.spec.ts @@ -0,0 +1,167 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Mock } from '../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +const TEST_WS_URL = 'ws://localhost-connect.spec.ts:9988'; + +function sleep (ms = 100): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('onConnect', (): void => { + let mocks: Mock[]; + let provider: WsProvider | null; + + beforeEach((): void => { + mocks = [mockWs([], TEST_WS_URL)]; + }); + + afterEach(async () => { + if (provider) { + await provider.disconnect(); + await sleep(); + + provider = null; + } + + await Promise.all(mocks.map((m) => m.done())); + await sleep(); + }); + + it('Does not connect when autoConnect is false', async () => { + provider = new WsProvider(TEST_WS_URL, 0); + + await sleep(); + + expect(provider.isConnected).toBe(false); + + await provider.connect(); + await sleep(); + + expect(provider.isConnected).toBe(true); + + await provider.disconnect(); + await sleep(); + + expect(provider.isConnected).toBe(false); + }); + + it('Does connect when autoConnect is true', async () => { + provider = new WsProvider(TEST_WS_URL, 1); + + await sleep(); + + expect(provider.isConnected).toBe(true); + }); + + it('Creates a new WebSocket instance by calling the connect() method', async () => { + provider = new WsProvider(TEST_WS_URL, false); + + expect(provider.isConnected).toBe(false); + expect(mocks[0].server.clients().length).toBe(0); + + await provider.connect(); + await sleep(); + + expect(provider.isConnected).toBe(true); + expect(mocks[0].server.clients()).toHaveLength(1); + }); + + it('Connects to first endpoint when an array is given', async () => { + provider = new WsProvider([TEST_WS_URL], 1); + + await sleep(); + + expect(provider.isConnected).toBe(true); + expect(mocks[0].server.clients()).toHaveLength(1); + }); + + it('Does not allow connect() on already-connected', async () => { + provider = new WsProvider([TEST_WS_URL], 1); + + await sleep(); + + expect(provider.isConnected).toBe(true); + + await expect( + provider.connect() + ).rejects.toThrow(/already connected/); + }); + + it('Connects to the second endpoint when the first is unreachable', async () => { + const endpoints: string[] = ['ws://localhost-unreachable-connect.spec.ts:9956', TEST_WS_URL]; + + provider = new WsProvider(endpoints, 1); + + await sleep(); + + expect(mocks[0].server.clients()).toHaveLength(1); + expect(provider.isConnected).toBe(true); + }); + + it('Connects to the second endpoint when the first is dropped', async () => { + const endpoints: string[] = [TEST_WS_URL, 'ws://localhost-connect.spec.ts:9957']; + + mocks.push(mockWs([], endpoints[1])); + + provider = new WsProvider(endpoints, 1); + + await sleep(); + + // Check that first server is connected + expect(mocks[0].server.clients()).toHaveLength(1); + expect(mocks[1].server.clients()).toHaveLength(0); + + // Close connection from first server + mocks[0].server.clients()[0].close(); + + await sleep(); + + // Check that second server is connected + expect(mocks[1].server.clients()).toHaveLength(1); + expect(provider.isConnected).toBe(true); + }); + + it('Round-robin of endpoints on WsProvider', async () => { + const endpoints: string[] = [ + TEST_WS_URL, + 'ws://localhost-connect.spec.ts:9956', + 'ws://localhost-connect.spec.ts:9957', + 'ws://invalid-connect.spec.ts:9956', + 'ws://localhost-connect.spec.ts:9958' + ]; + + mocks.push(mockWs([], endpoints[1])); + mocks.push(mockWs([], endpoints[2])); + mocks.push(mockWs([], endpoints[4])); + + const mockNext = [ + mocks[1], + mocks[2], + mocks[3], + mocks[0] + ]; + + provider = new WsProvider(endpoints, 1); + + for (let round = 0; round < 2; round++) { + for (let mock = 0; mock < mocks.length; mock++) { + await sleep(); + + // Wwe are connected, the current mock has the connection and the next doesn't + expect(provider.isConnected).toBe(true); + expect(mocks[mock].server.clients()).toHaveLength(1); + expect(mockNext[mock].server.clients()).toHaveLength(0); + + // Close connection from first server + mocks[mock].server.clients()[0].close(); + } + } + }); +}); diff --git a/packages/rpc-provider/src/ws/errors.ts b/packages/rpc-provider/src/ws/errors.ts new file mode 100644 index 00000000..ad5ff6cd --- /dev/null +++ b/packages/rpc-provider/src/ws/errors.ts @@ -0,0 +1,41 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// from https://stackoverflow.com/questions/19304157/getting-the-reason-why-websockets-closed-with-close-code-1006 + +const known: Record = { + 1000: 'Normal Closure', + 1001: 'Going Away', + 1002: 'Protocol Error', + 1003: 'Unsupported Data', + 1004: '(For future)', + 1005: 'No Status Received', + 1006: 'Abnormal Closure', + 1007: 'Invalid frame payload data', + 1008: 'Policy Violation', + 1009: 'Message too big', + 1010: 'Missing Extension', + 1011: 'Internal Error', + 1012: 'Service Restart', + 1013: 'Try Again Later', + 1014: 'Bad Gateway', + 1015: 'TLS Handshake' +}; + +export function getWSErrorString (code: number): string { + if (code >= 0 && code <= 999) { + return '(Unused)'; + } else if (code >= 1016) { + if (code <= 1999) { + return '(For WebSocket standard)'; + } else if (code <= 2999) { + return '(For WebSocket extensions)'; + } else if (code <= 3999) { + return '(For libraries and frameworks)'; + } else if (code <= 4999) { + return '(For applications)'; + } + } + + return known[code] || '(Unknown)'; +} diff --git a/packages/rpc-provider/src/ws/index.spec.ts b/packages/rpc-provider/src/ws/index.spec.ts new file mode 100644 index 00000000..6b5e3905 --- /dev/null +++ b/packages/rpc-provider/src/ws/index.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Mock } from '../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +const TEST_WS_URL = 'ws://localhost-index.spec.ts:9977'; + +let provider: WsProvider | null; +let mock: Mock; + +function createWs (requests: Request[], autoConnect = 1000, headers?: Record, timeout?: number): WsProvider { + mock = mockWs(requests, TEST_WS_URL); + provider = new WsProvider(TEST_WS_URL, autoConnect, headers, timeout); + + return provider; +} + +describe('Ws', (): void => { + afterEach(async () => { + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('returns the connected state', (): void => { + expect( + createWs([]).isConnected + ).toEqual(false); + }); + + // eslint-disable-next-line jest/expect-expect + it('allows you to initialize the provider with custom headers', () => { + createWs([], 100, { foo: 'bar' }); + }); + + // eslint-disable-next-line jest/expect-expect + it('allows you to set custom timeout value for handlers', () => { + const CUSTOM_TIMEOUT_S = 90; + const CUSTOM_TIMEOUT_MS = CUSTOM_TIMEOUT_S * 1000; + + createWs([], 100, { foo: 'bar' }, CUSTOM_TIMEOUT_MS); + }); +}); + +describe('Endpoint Parsing', (): void => { + // eslint-disable-next-line jest/expect-expect + it('Succeeds when WsProvider endpoint is a valid string', () => { + /* eslint-disable no-new */ + new WsProvider(TEST_WS_URL, 0); + }); + + it('Throws when WsProvider endpoint is an invalid string', () => { + expect( + () => new WsProvider('http://127.0.0.1:9955', 0) + ).toThrow(/^Endpoint should start with /); + }); + + // eslint-disable-next-line jest/expect-expect + it('Succeeds when WsProvider endpoint is a valid array', () => { + const endpoints: string[] = ['ws://127.0.0.1:9955', 'wss://testnet.io:9944', 'ws://mychain.com:9933']; + + /* eslint-disable no-new */ + new WsProvider(endpoints, 0); + }); + + it('Throws when WsProvider endpoint is an empty array', () => { + const endpoints: string[] = []; + + expect( + () => new WsProvider(endpoints, 0) + ).toThrow('WsProvider requires at least one Endpoint'); + }); + + it('Throws when WsProvider endpoint is an invalid array', () => { + const endpoints: string[] = ['ws://127.0.0.1:9955', 'http://bad.co:9944', 'ws://mychain.com:9933']; + + expect( + () => new WsProvider(endpoints, 0) + ).toThrow(/^Endpoint should start with /); + }); +}); diff --git a/packages/rpc-provider/src/ws/index.ts b/packages/rpc-provider/src/ws/index.ts new file mode 100644 index 00000000..1cf65fee --- /dev/null +++ b/packages/rpc-provider/src/ws/index.ts @@ -0,0 +1,626 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Class } from '@polkadot/util/types'; +import type { EndpointStats, JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; + +import { EventEmitter } from 'eventemitter3'; + +import { isChildClass, isNull, isUndefined, logger, noop, objectSpread, stringify } from '@polkadot/util'; +import { xglobal } from '@polkadot/x-global'; +import { WebSocket } from '@polkadot/x-ws'; + +import { RpcCoder } from '../coder/index.js'; +import defaults from '../defaults.js'; +import { DEFAULT_CAPACITY, LRUCache } from '../lru.js'; +import { getWSErrorString } from './errors.js'; + +interface SubscriptionHandler { + callback: ProviderInterfaceCallback; + type: string; +} + +interface WsStateAwaiting { + callback: ProviderInterfaceCallback; + method: string; + params: unknown[]; + start: number; + subscription?: SubscriptionHandler | undefined; +} + +interface WsStateSubscription extends SubscriptionHandler { + method: string; + params: unknown[]; +} + +const ALIASES: Record = { + chain_finalisedHead: 'chain_finalizedHead', + chain_subscribeFinalisedHeads: 'chain_subscribeFinalizedHeads', + chain_unsubscribeFinalisedHeads: 'chain_unsubscribeFinalizedHeads' +}; + +const RETRY_DELAY = 2_500; + +const DEFAULT_TIMEOUT_MS = 60 * 1000; +const TIMEOUT_INTERVAL = 5_000; + +const l = logger('api-ws'); + +/** @internal Clears a Record<*> of all keys, optionally with all callback on clear */ +function eraseRecord (record: Record, cb?: (item: T) => void): void { + Object.keys(record).forEach((key): void => { + if (cb) { + cb(record[key]); + } + + delete record[key]; + }); +} + +/** @internal Creates a default/empty stats object */ +function defaultEndpointStats (): EndpointStats { + return { bytesRecv: 0, bytesSent: 0, cached: 0, errors: 0, requests: 0, subscriptions: 0, timeout: 0 }; +} + +/** + * # @polkadot/rpc-provider/ws + * + * @name WsProvider + * + * @description The WebSocket Provider allows sending requests using WebSocket to a WebSocket RPC server TCP port. Unlike the [[HttpProvider]], it does support subscriptions and allows listening to events such as new blocks or balance changes. + * + * @example + *
+ * + * ```javascript + * import Api from '@polkadot/api/promise'; + * import { WsProvider } from '@polkadot/rpc-provider/ws'; + * + * const provider = new WsProvider('ws://127.0.0.1:9944'); + * const api = new Api(provider); + * ``` + * + * @see [[HttpProvider]] + */ +export class WsProvider implements ProviderInterface { + readonly #callCache: LRUCache; + readonly #coder: RpcCoder; + readonly #endpoints: string[]; + readonly #headers: Record; + readonly #eventemitter: EventEmitter; + readonly #handlers: Record = {}; + readonly #isReadyPromise: Promise; + readonly #stats: ProviderStats; + readonly #waitingForId: Record> = {}; + + #autoConnectMs: number; + #endpointIndex: number; + #endpointStats: EndpointStats; + #isConnected = false; + #subscriptions: Record = {}; + #timeoutId?: ReturnType | null = null; + #websocket: WebSocket | null; + #timeout: number; + + /** + * @param {string | string[]} endpoint The endpoint url. Usually `ws://ip:9944` or `wss://ip:9944`, may provide an array of endpoint strings. + * @param {number | false} autoConnectMs Whether to connect automatically or not (default). Provided value is used as a delay between retries. + * @param {Record} headers The headers provided to the underlying WebSocket + * @param {number} [timeout] Custom timeout value used per request . Defaults to `DEFAULT_TIMEOUT_MS` + */ + constructor (endpoint: string | string[] = defaults.WS_URL, autoConnectMs: number | false = RETRY_DELAY, headers: Record = {}, timeout?: number, cacheCapacity?: number) { + const endpoints = Array.isArray(endpoint) + ? endpoint + : [endpoint]; + + if (endpoints.length === 0) { + throw new Error('WsProvider requires at least one Endpoint'); + } + + endpoints.forEach((endpoint) => { + if (!/^(wss|ws):\/\//.test(endpoint)) { + throw new Error(`Endpoint should start with 'ws://', received '${endpoint}'`); + } + }); + this.#callCache = new LRUCache(cacheCapacity || DEFAULT_CAPACITY); + this.#eventemitter = new EventEmitter(); + this.#autoConnectMs = autoConnectMs || 0; + this.#coder = new RpcCoder(); + this.#endpointIndex = -1; + this.#endpoints = endpoints; + this.#headers = headers; + this.#websocket = null; + this.#stats = { + active: { requests: 0, subscriptions: 0 }, + total: defaultEndpointStats() + }; + this.#endpointStats = defaultEndpointStats(); + this.#timeout = timeout || DEFAULT_TIMEOUT_MS; + + if (autoConnectMs && autoConnectMs > 0) { + this.connectWithRetry().catch(noop); + } + + this.#isReadyPromise = new Promise((resolve): void => { + this.#eventemitter.once('connected', (): void => { + resolve(this); + }); + }); + } + + /** + * @summary `true` when this provider supports subscriptions + */ + public get hasSubscriptions (): boolean { + return !!true; + } + + /** + * @summary `true` when this provider supports clone() + */ + public get isClonable (): boolean { + return !!true; + } + + /** + * @summary Whether the node is connected or not. + * @return {boolean} true if connected + */ + public get isConnected (): boolean { + return this.#isConnected; + } + + /** + * @description Promise that resolves the first time we are connected and loaded + */ + public get isReady (): Promise { + return this.#isReadyPromise; + } + + public get endpoint (): string { + return this.#endpoints[this.#endpointIndex]; + } + + /** + * @description Returns a clone of the object + */ + public clone (): WsProvider { + return new WsProvider(this.#endpoints); + } + + protected selectEndpointIndex (endpoints: string[]): number { + return (this.#endpointIndex + 1) % endpoints.length; + } + + /** + * @summary Manually connect + * @description The [[WsProvider]] connects automatically by default, however if you decided otherwise, you may + * connect manually using this method. + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async connect (): Promise { + if (this.#websocket) { + throw new Error('WebSocket is already connected'); + } + + try { + this.#endpointIndex = this.selectEndpointIndex(this.#endpoints); + + // the as here is Deno-specific - not available on the globalThis + this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) + ? new WebSocket(this.endpoint) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - WS may be an instance of ws, which supports options + : new WebSocket(this.endpoint, undefined, { + headers: this.#headers + }); + + if (this.#websocket) { + this.#websocket.onclose = this.#onSocketClose; + this.#websocket.onerror = this.#onSocketError; + this.#websocket.onmessage = this.#onSocketMessage; + this.#websocket.onopen = this.#onSocketOpen; + } + + // timeout any handlers that have not had a response + this.#timeoutId = setInterval(() => this.#timeoutHandlers(), TIMEOUT_INTERVAL); + } catch (error) { + l.error(error); + + this.#emit('error', error); + + throw error; + } + } + + /** + * @description Connect, never throwing an error, but rather forcing a retry + */ + public async connectWithRetry (): Promise { + if (this.#autoConnectMs > 0) { + try { + await this.connect(); + } catch { + setTimeout((): void => { + this.connectWithRetry().catch(noop); + }, this.#autoConnectMs); + } + } + } + + /** + * @description Manually disconnect from the connection, clearing auto-connect logic + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async disconnect (): Promise { + // switch off autoConnect, we are in manual mode now + this.#autoConnectMs = 0; + + try { + if (this.#websocket) { + // 1000 - Normal closure; the connection successfully completed + this.#websocket.close(1000); + } + } catch (error) { + l.error(error); + + this.#emit('error', error); + + throw error; + } + } + + /** + * @description Returns the connection stats + */ + public get stats (): ProviderStats { + return { + active: { + requests: Object.keys(this.#handlers).length, + subscriptions: Object.keys(this.#subscriptions).length + }, + total: this.#stats.total + }; + } + + public get endpointStats (): EndpointStats { + return this.#endpointStats; + } + + /** + * @summary Listens on events after having subscribed using the [[subscribe]] function. + * @param {ProviderInterfaceEmitted} type Event + * @param {ProviderInterfaceEmitCb} sub Callback + * @return unsubscribe function + */ + public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { + this.#eventemitter.on(type, sub); + + return (): void => { + this.#eventemitter.removeListener(type, sub); + }; + } + + /** + * @summary Send JSON data using WebSockets to configured HTTP Endpoint or queue. + * @param method The RPC methods to execute + * @param params Encoded parameters as applicable for the method + * @param subscription Subscription details (internally used) + */ + public send (method: string, params: unknown[], isCacheable?: boolean, subscription?: SubscriptionHandler): Promise { + this.#endpointStats.requests++; + this.#stats.total.requests++; + + const [id, body] = this.#coder.encodeJson(method, params); + const cacheKey = isCacheable ? `${method}::${stringify(params)}` : ''; + let resultPromise: Promise | null = isCacheable + ? this.#callCache.get(cacheKey) + : null; + + if (!resultPromise) { + resultPromise = this.#send(id, body, method, params, subscription); + + if (isCacheable) { + this.#callCache.set(cacheKey, resultPromise); + } + } else { + this.#endpointStats.cached++; + this.#stats.total.cached++; + } + + return resultPromise; + } + + async #send (id: number, body: string, method: string, params: unknown[], subscription?: SubscriptionHandler): Promise { + return new Promise((resolve, reject): void => { + try { + if (!this.isConnected || this.#websocket === null) { + throw new Error('WebSocket is not connected'); + } + + const callback = (error?: Error | null, result?: T): void => { + error + ? reject(error) + : resolve(result as T); + }; + + l.debug(() => ['calling', method, body]); + + this.#handlers[id] = { + callback, + method, + params, + start: Date.now(), + subscription + }; + + const bytesSent = body.length; + + this.#endpointStats.bytesSent += bytesSent; + this.#stats.total.bytesSent += bytesSent; + + this.#websocket.send(body); + } catch (error) { + this.#endpointStats.errors++; + this.#stats.total.errors++; + + reject(error); + } + }); + } + + /** + * @name subscribe + * @summary Allows subscribing to a specific event. + * + * @example + *
+ * + * ```javascript + * const provider = new WsProvider('ws://127.0.0.1:9944'); + * const rpc = new Rpc(provider); + * + * rpc.state.subscribeStorage([[storage.system.account,
]], (_, values) => { + * console.log(values) + * }).then((subscriptionId) => { + * console.log('balance changes subscription id: ', subscriptionId) + * }) + * ``` + */ + public subscribe (type: string, method: string, params: unknown[], callback: ProviderInterfaceCallback): Promise { + this.#endpointStats.subscriptions++; + this.#stats.total.subscriptions++; + + // subscriptions are not cached, LRU applies to .at() only + return this.send(method, params, false, { callback, type }); + } + + /** + * @summary Allows unsubscribing to subscriptions made with [[subscribe]]. + */ + public async unsubscribe (type: string, method: string, id: number | string): Promise { + const subscription = `${type}::${id}`; + + // FIXME This now could happen with re-subscriptions. The issue is that with a re-sub + // the assigned id now does not match what the API user originally received. It has + // a slight complication in solving - since we cannot rely on the send id, but rather + // need to find the actual subscription id to map it + if (isUndefined(this.#subscriptions[subscription])) { + l.debug(() => `Unable to find active subscription=${subscription}`); + + return false; + } + + delete this.#subscriptions[subscription]; + + try { + return this.isConnected && !isNull(this.#websocket) + ? this.send(method, [id]) + : true; + } catch { + return false; + } + } + + #emit = (type: ProviderInterfaceEmitted, ...args: unknown[]): void => { + this.#eventemitter.emit(type, ...args); + }; + + #onSocketClose = (event: CloseEvent): void => { + const error = new Error(`disconnected from ${this.endpoint}: ${event.code}:: ${event.reason || getWSErrorString(event.code)}`); + + if (this.#autoConnectMs > 0) { + l.error(error.message); + } + + this.#isConnected = false; + + if (this.#websocket) { + this.#websocket.onclose = null; + this.#websocket.onerror = null; + this.#websocket.onmessage = null; + this.#websocket.onopen = null; + this.#websocket = null; + } + + if (this.#timeoutId) { + clearInterval(this.#timeoutId); + this.#timeoutId = null; + } + + // reject all hanging requests + eraseRecord(this.#handlers, (h) => { + try { + h.callback(error, undefined); + } catch (err) { + // does not throw + l.error(err); + } + }); + eraseRecord(this.#waitingForId); + + // Reset stats for active endpoint + this.#endpointStats = defaultEndpointStats(); + + this.#emit('disconnected'); + + if (this.#autoConnectMs > 0) { + setTimeout((): void => { + this.connectWithRetry().catch(noop); + }, this.#autoConnectMs); + } + }; + + #onSocketError = (error: Event): void => { + l.debug(() => ['socket error', error]); + this.#emit('error', error); + }; + + #onSocketMessage = (message: MessageEvent): void => { + l.debug(() => ['received', message.data]); + + const bytesRecv = message.data.length; + + this.#endpointStats.bytesRecv += bytesRecv; + this.#stats.total.bytesRecv += bytesRecv; + + const response = JSON.parse(message.data) as JsonRpcResponse; + + return isUndefined(response.method) + ? this.#onSocketMessageResult(response) + : this.#onSocketMessageSubscribe(response); + }; + + #onSocketMessageResult = (response: JsonRpcResponse): void => { + const handler = this.#handlers[response.id]; + + if (!handler) { + l.debug(() => `Unable to find handler for id=${response.id}`); + + return; + } + + try { + const { method, params, subscription } = handler; + const result = this.#coder.decodeResponse(response); + + // first send the result - in case of subs, we may have an update + // immediately if we have some queued results already + handler.callback(null, result); + + if (subscription) { + const subId = `${subscription.type}::${result}`; + + this.#subscriptions[subId] = objectSpread({}, subscription, { + method, + params + }); + + // if we have a result waiting for this subscription already + if (this.#waitingForId[subId]) { + this.#onSocketMessageSubscribe(this.#waitingForId[subId]); + } + } + } catch (error) { + this.#endpointStats.errors++; + this.#stats.total.errors++; + + handler.callback(error as Error, undefined); + } + + delete this.#handlers[response.id]; + }; + + #onSocketMessageSubscribe = (response: JsonRpcResponse): void => { + if (!response.method) { + throw new Error('No method found in JSONRPC response'); + } + + const method = ALIASES[response.method] || response.method; + const subId = `${method}::${response.params.subscription}`; + const handler = this.#subscriptions[subId]; + + if (!handler) { + // store the JSON, we could have out-of-order subid coming in + this.#waitingForId[subId] = response; + + l.debug(() => `Unable to find handler for subscription=${subId}`); + + return; + } + + // housekeeping + delete this.#waitingForId[subId]; + + try { + const result = this.#coder.decodeResponse(response); + + handler.callback(null, result); + } catch (error) { + this.#endpointStats.errors++; + this.#stats.total.errors++; + + handler.callback(error as Error, undefined); + } + }; + + #onSocketOpen = (): boolean => { + if (this.#websocket === null) { + throw new Error('WebSocket cannot be null in onOpen'); + } + + l.debug(() => ['connected to', this.endpoint]); + + this.#isConnected = true; + + this.#resubscribe(); + + this.#emit('connected'); + + return true; + }; + + #resubscribe = (): void => { + const subscriptions = this.#subscriptions; + + this.#subscriptions = {}; + + Promise.all(Object.keys(subscriptions).map(async (id): Promise => { + const { callback, method, params, type } = subscriptions[id]; + + // only re-create subscriptions which are not in author (only area where + // transactions are created, i.e. submissions such as 'author_submitAndWatchExtrinsic' + // are not included (and will not be re-broadcast) + if (type.startsWith('author_')) { + return; + } + + try { + await this.subscribe(type, method, params, callback); + } catch (error) { + l.error(error); + } + })).catch(l.error); + }; + + #timeoutHandlers = (): void => { + const now = Date.now(); + const ids = Object.keys(this.#handlers); + + for (let i = 0, count = ids.length; i < count; i++) { + const handler = this.#handlers[ids[i]]; + + if ((now - handler.start) > this.#timeout) { + try { + handler.callback(new Error(`No response received from RPC endpoint in ${this.#timeout / 1000}s`), undefined); + } catch { + // ignore + } + + this.#endpointStats.timeout++; + this.#stats.total.timeout++; + delete this.#handlers[ids[i]]; + } + } + }; +} diff --git a/packages/rpc-provider/src/ws/send.spec.ts b/packages/rpc-provider/src/ws/send.spec.ts new file mode 100644 index 00000000..7de7b395 --- /dev/null +++ b/packages/rpc-provider/src/ws/send.spec.ts @@ -0,0 +1,126 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Global, Mock } from '../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +declare const global: Global; + +const TEST_WS_URL = 'ws://localhost-send.spec.ts:9965'; + +let provider: WsProvider | null; +let mock: Mock; + +function createMock (requests: Request[]): void { + mock = mockWs(requests, TEST_WS_URL); +} + +function createWs (autoConnect = 1000): Promise { + provider = new WsProvider(TEST_WS_URL, autoConnect); + + return provider.isReady; +} + +describe('send', (): void => { + let globalWs: typeof WebSocket; + + beforeEach((): void => { + globalWs = global.WebSocket; + }); + + afterEach(async () => { + global.WebSocket = globalWs; + + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('handles internal errors', (): Promise => { + createMock([{ + id: 1, + method: 'test_body', + reply: { + result: 'ok' + } + }]); + + return createWs().then((ws) => + ws + .send('test_encoding', [{ error: 'send error' }]) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toEqual('send error'); + }) + ); + }); + + it('passes the body through correctly', (): Promise => { + createMock([{ + id: 1, + method: 'test_body', + reply: { + result: 'ok' + } + }]); + + return createWs().then((ws) => + ws + .send('test_body', ['param']) + .then((): void => { + expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (mock.body as any).test_body + ).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}'); + }) + ); + }); + + it('throws error when !response.ok', (): Promise => { + createMock([{ + error: { + code: 666, + message: 'error' + }, + id: 1, + method: 'something' + }]); + + return createWs().then((ws) => + ws + .send('test_error', []) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/666: error/); + }) + ); + }); + + it('adds subscriptions', (): Promise => { + createMock([{ + id: 1, + method: 'test_sub', + reply: { + result: 1 + } + }]); + + return createWs().then((ws) => + ws + .send('test_sub', []) + .then((id): void => { + expect(id).toEqual(1); + }) + ); + }); +}); diff --git a/packages/rpc-provider/src/ws/state.spec.ts b/packages/rpc-provider/src/ws/state.spec.ts new file mode 100644 index 00000000..f7c4d6e4 --- /dev/null +++ b/packages/rpc-provider/src/ws/state.spec.ts @@ -0,0 +1,20 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { WsProvider } from './index.js'; + +describe('state', (): void => { + it('requires an ws:// prefixed endpoint', (): void => { + expect( + () => new WsProvider('http://', 0) + ).toThrow(/with 'ws/); + }); + + it('allows wss:// endpoints', (): void => { + expect( + () => new WsProvider('wss://', 0) + ).not.toThrow(); + }); +}); diff --git a/packages/rpc-provider/src/ws/subscribe.spec.ts b/packages/rpc-provider/src/ws/subscribe.spec.ts new file mode 100644 index 00000000..c1694526 --- /dev/null +++ b/packages/rpc-provider/src/ws/subscribe.spec.ts @@ -0,0 +1,68 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Global, Mock } from './../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +declare const global: Global; + +const TEST_WS_URL = 'ws://localhost-subscribe.test.ts:9933'; + +let provider: WsProvider | null; +let mock: Mock; + +function createMock (requests: Request[]): void { + mock = mockWs(requests, TEST_WS_URL); +} + +function createWs (autoConnect = 1000): Promise { + provider = new WsProvider(TEST_WS_URL, autoConnect); + + return provider.isReady; +} + +describe('subscribe', (): void => { + let globalWs: typeof WebSocket; + + beforeEach((): void => { + globalWs = global.WebSocket; + }); + + afterEach(async () => { + global.WebSocket = globalWs; + + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('adds subscriptions', (): Promise => { + createMock([{ + id: 1, + method: 'test_sub', + reply: { + result: 1 + } + }]); + + return createWs().then((ws) => + ws + .subscribe('type', 'test_sub', [], (cb): void => { + expect(cb).toEqual(expect.anything()); + }) + .then((id): void => { + expect(id).toEqual(1); + }) + ); + }); +}); diff --git a/packages/rpc-provider/src/ws/unsubscribe.spec.ts b/packages/rpc-provider/src/ws/unsubscribe.spec.ts new file mode 100644 index 00000000..9693c5d6 --- /dev/null +++ b/packages/rpc-provider/src/ws/unsubscribe.spec.ts @@ -0,0 +1,100 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Global, Mock } from './../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +declare const global: Global; + +const TEST_WS_URL = 'ws://localhost-unsubscribe.test.ts:9933'; + +let provider: WsProvider | null; +let mock: Mock; + +function createMock (requests: Request[]): void { + mock = mockWs(requests, TEST_WS_URL); +} + +function createWs (autoConnect = 1000): Promise { + provider = new WsProvider(TEST_WS_URL, autoConnect); + + return provider.isReady; +} + +describe('subscribe', (): void => { + let globalWs: typeof WebSocket; + + beforeEach((): void => { + globalWs = global.WebSocket; + }); + + afterEach(async () => { + global.WebSocket = globalWs; + + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('removes subscriptions', async (): Promise => { + createMock([ + { + id: 1, + method: 'subscribe_test', + reply: { + result: 1 + } + }, + { + id: 2, + method: 'unsubscribe_test', + reply: { + result: true + } + } + ]); + + await createWs().then((ws) => + ws + .subscribe('test', 'subscribe_test', [], (cb): void => { + expect(cb).toEqual(expect.anything()); + }) + .then((id): Promise => { + return ws.unsubscribe('test', 'subscribe_test', id); + }) + ); + }); + + it('fails when sub not found', (): Promise => { + createMock([{ + id: 1, + method: 'subscribe_test', + reply: { + result: 1 + } + }]); + + return createWs().then((ws) => + ws + .subscribe('test', 'subscribe_test', [], (cb): void => { + expect(cb).toEqual(expect.anything()); + }) + .then((): Promise => { + return ws.unsubscribe('test', 'subscribe_test', 111); + }) + .then((result): void => { + expect(result).toBe(false); + }) + ); + }); +}); diff --git a/packages/rpc-provider/tsconfig.build.json b/packages/rpc-provider/tsconfig.build.json new file mode 100644 index 00000000..e4b9f972 --- /dev/null +++ b/packages/rpc-provider/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "resolveJsonModule": true + }, + "exclude": [ + "**/*.spec.ts", + "**/mod.ts" + ], + "references": [ + { "path": "../types/tsconfig.build.json" }, + { "path": "../types-support/tsconfig.build.json" } + ] +} diff --git a/packages/rpc-provider/tsconfig.spec.json b/packages/rpc-provider/tsconfig.spec.json new file mode 100644 index 00000000..b13f18eb --- /dev/null +++ b/packages/rpc-provider/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "emitDeclarationOnly": false, + "resolveJsonModule": true, + "noEmit": true + }, + "include": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../rpc-provider/tsconfig.build.json" }, + { "path": "../types/tsconfig.build.json" } + ] +} From 557f20e74092f6340f257ababe6477a8074de719 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 21:41:37 +0200 Subject: [PATCH 02/50] remove unnecessary modules from rpc-provider --- packages/rpc-provider/src/http/index.spec.ts | 62 -- packages/rpc-provider/src/http/index.ts | 202 ------ packages/rpc-provider/src/http/send.spec.ts | 61 -- packages/rpc-provider/src/http/types.ts | 11 - .../src/substrate-connect/Health.ts | 325 --------- .../src/substrate-connect/index.spec.ts | 638 ------------------ .../src/substrate-connect/index.ts | 415 ------------ .../src/substrate-connect/types.ts | 16 - 8 files changed, 1730 deletions(-) delete mode 100644 packages/rpc-provider/src/http/index.spec.ts delete mode 100644 packages/rpc-provider/src/http/index.ts delete mode 100644 packages/rpc-provider/src/http/send.spec.ts delete mode 100644 packages/rpc-provider/src/http/types.ts delete mode 100644 packages/rpc-provider/src/substrate-connect/Health.ts delete mode 100644 packages/rpc-provider/src/substrate-connect/index.spec.ts delete mode 100644 packages/rpc-provider/src/substrate-connect/index.ts delete mode 100644 packages/rpc-provider/src/substrate-connect/types.ts diff --git a/packages/rpc-provider/src/http/index.spec.ts b/packages/rpc-provider/src/http/index.spec.ts deleted file mode 100644 index 1bb8a629..00000000 --- a/packages/rpc-provider/src/http/index.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TEST_HTTP_URL } from '../mock/mockHttp.js'; -import { HttpProvider } from './index.js'; - -describe('Http', (): void => { - let http: HttpProvider; - - beforeEach((): void => { - http = new HttpProvider(TEST_HTTP_URL); - }); - - it('requires an http:// prefixed endpoint', (): void => { - expect( - () => new HttpProvider('ws://') - ).toThrow(/with 'http/); - }); - - it('allows https:// endpoints', (): void => { - expect( - () => new HttpProvider('https://') - ).not.toThrow(); - }); - - it('allows custom headers', (): void => { - expect( - () => new HttpProvider('https://', { foo: 'bar' }) - ).not.toThrow(); - }); - - it('allow clone', (): void => { - const clone = http.clone(); - /* eslint-disable */ - expect((clone as any)['#endpoint']).toEqual((http as any)['#endpoint']); - expect((clone as any)['#headers']).toEqual((http as any)['#headers']); - /* eslint-enable */ - }); - - it('always returns isConnected true', (): void => { - expect(http.isConnected).toEqual(true); - }); - - it('does not (yet) support subscribe', async (): Promise => { - await http.subscribe('', '', [], (cb): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect(cb).toEqual(expect.anything()); - }).catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/does not have subscriptions/); - }); - }); - - it('does not (yet) support unsubscribe', async (): Promise => { - await http.unsubscribe('', '', 0).catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/does not have subscriptions/); - }); - }); -}); diff --git a/packages/rpc-provider/src/http/index.ts b/packages/rpc-provider/src/http/index.ts deleted file mode 100644 index 794702b6..00000000 --- a/packages/rpc-provider/src/http/index.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; - -import { logger, noop, stringify } from '@polkadot/util'; -import { fetch } from '@polkadot/x-fetch'; - -import { RpcCoder } from '../coder/index.js'; -import defaults from '../defaults.js'; -import { LRUCache } from '../lru.js'; - -const ERROR_SUBSCRIBE = 'HTTP Provider does not have subscriptions, use WebSockets instead'; - -const l = logger('api-http'); - -/** - * # @polkadot/rpc-provider - * - * @name HttpProvider - * - * @description The HTTP Provider allows sending requests using HTTP to a HTTP RPC server TCP port. It does not support subscriptions so you won't be able to listen to events such as new blocks or balance changes. It is usually preferable using the [[WsProvider]]. - * - * @example - *
- * - * ```javascript - * import Api from '@polkadot/api/promise'; - * import { HttpProvider } from '@polkadot/rpc-provider'; - * - * const provider = new HttpProvider('http://127.0.0.1:9933'); - * const api = new Api(provider); - * ``` - * - * @see [[WsProvider]] - */ -export class HttpProvider implements ProviderInterface { - readonly #callCache = new LRUCache(); - readonly #coder: RpcCoder; - readonly #endpoint: string; - readonly #headers: Record; - readonly #stats: ProviderStats; - - /** - * @param {string} endpoint The endpoint url starting with http:// - */ - constructor (endpoint: string = defaults.HTTP_URL, headers: Record = {}) { - if (!/^(https|http):\/\//.test(endpoint)) { - throw new Error(`Endpoint should start with 'http://' or 'https://', received '${endpoint}'`); - } - - this.#coder = new RpcCoder(); - this.#endpoint = endpoint; - this.#headers = headers; - this.#stats = { - active: { requests: 0, subscriptions: 0 }, - total: { bytesRecv: 0, bytesSent: 0, cached: 0, errors: 0, requests: 0, subscriptions: 0, timeout: 0 } - }; - } - - /** - * @summary `true` when this provider supports subscriptions - */ - public get hasSubscriptions (): boolean { - return !!false; - } - - /** - * @description Returns a clone of the object - */ - public clone (): HttpProvider { - return new HttpProvider(this.#endpoint, this.#headers); - } - - /** - * @description Manually connect from the connection - */ - public async connect (): Promise { - // noop - } - - /** - * @description Manually disconnect from the connection - */ - public async disconnect (): Promise { - // noop - } - - /** - * @description Returns the connection stats - */ - public get stats (): ProviderStats { - return this.#stats; - } - - /** - * @summary `true` when this provider supports clone() - */ - public get isClonable (): boolean { - return !!true; - } - - /** - * @summary Whether the node is connected or not. - * @return {boolean} true if connected - */ - public get isConnected (): boolean { - return !!true; - } - - /** - * @summary Events are not supported with the HttpProvider, see [[WsProvider]]. - * @description HTTP Provider does not have 'on' emitters. WebSockets should be used instead. - */ - public on (_type: ProviderInterfaceEmitted, _sub: ProviderInterfaceEmitCb): () => void { - l.error('HTTP Provider does not have \'on\' emitters, use WebSockets instead'); - - return noop; - } - - /** - * @summary Send HTTP POST Request with Body to configured HTTP Endpoint. - */ - public async send (method: string, params: unknown[], isCacheable?: boolean): Promise { - this.#stats.total.requests++; - - const [, body] = this.#coder.encodeJson(method, params); - const cacheKey = isCacheable ? `${method}::${stringify(params)}` : ''; - let resultPromise: Promise | null = isCacheable - ? this.#callCache.get(cacheKey) - : null; - - if (!resultPromise) { - resultPromise = this.#send(body); - - if (isCacheable) { - this.#callCache.set(cacheKey, resultPromise); - } - } else { - this.#stats.total.cached++; - } - - return resultPromise; - } - - async #send (body: string): Promise { - this.#stats.active.requests++; - this.#stats.total.bytesSent += body.length; - - try { - const response = await fetch(this.#endpoint, { - body, - headers: { - Accept: 'application/json', - 'Content-Length': `${body.length}`, - 'Content-Type': 'application/json', - ...this.#headers - }, - method: 'POST' - }); - - if (!response.ok) { - throw new Error(`[${response.status}]: ${response.statusText}`); - } - - const result = await response.text(); - - this.#stats.total.bytesRecv += result.length; - - const decoded = this.#coder.decodeResponse(JSON.parse(result) as JsonRpcResponse); - - this.#stats.active.requests--; - - return decoded; - } catch (e) { - this.#stats.active.requests--; - this.#stats.total.errors++; - - throw e; - } - } - - /** - * @summary Subscriptions are not supported with the HttpProvider, see [[WsProvider]]. - */ - // eslint-disable-next-line @typescript-eslint/require-await - public async subscribe (_types: string, _method: string, _params: unknown[], _cb: ProviderInterfaceCallback): Promise { - l.error(ERROR_SUBSCRIBE); - - throw new Error(ERROR_SUBSCRIBE); - } - - /** - * @summary Subscriptions are not supported with the HttpProvider, see [[WsProvider]]. - */ - // eslint-disable-next-line @typescript-eslint/require-await - public async unsubscribe (_type: string, _method: string, _id: number): Promise { - l.error(ERROR_SUBSCRIBE); - - throw new Error(ERROR_SUBSCRIBE); - } -} diff --git a/packages/rpc-provider/src/http/send.spec.ts b/packages/rpc-provider/src/http/send.spec.ts deleted file mode 100644 index 0906a74d..00000000 --- a/packages/rpc-provider/src/http/send.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Mock } from '../mock/types.js'; - -import { mockHttp, TEST_HTTP_URL } from '../mock/mockHttp.js'; -import { HttpProvider } from './index.js'; - -// Does not work with Node 18 (native fetch) -// See https://github.com/nock/nock/issues/2397 -// eslint-disable-next-line jest/no-disabled-tests -describe.skip('send', (): void => { - let http: HttpProvider; - let mock: Mock; - - beforeEach((): void => { - http = new HttpProvider(TEST_HTTP_URL); - }); - - afterEach(async () => { - if (mock) { - await mock.done(); - } - }); - - it('passes the body through correctly', (): Promise => { - mock = mockHttp([{ - method: 'test_body', - reply: { - result: 'ok' - } - }]); - - return http - .send('test_body', ['param']) - .then((): void => { - expect(mock.body['test_body']).toEqual({ - id: 1, - jsonrpc: '2.0', - method: 'test_body', - params: ['param'] - }); - }); - }); - - it('throws error when !response.ok', async (): Promise => { - mock = mockHttp([{ - code: 500, - method: 'test_error' - }]); - - return http - .send('test_error', []) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/\[500\]/); - }); - }); -}); diff --git a/packages/rpc-provider/src/http/types.ts b/packages/rpc-provider/src/http/types.ts deleted file mode 100644 index 2d6d0b94..00000000 --- a/packages/rpc-provider/src/http/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { Logger } from '@polkadot/util/types'; -import type { RpcCoder } from '../coder/index.js'; - -export interface HttpState { - coder: RpcCoder; - endpoint: string; - l: Logger; -} diff --git a/packages/rpc-provider/src/substrate-connect/Health.ts b/packages/rpc-provider/src/substrate-connect/Health.ts deleted file mode 100644 index b2c7fd0c..00000000 --- a/packages/rpc-provider/src/substrate-connect/Health.ts +++ /dev/null @@ -1,325 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { HealthChecker, SmoldotHealth } from './types.js'; - -import { stringify } from '@polkadot/util'; - -interface JSONRequest { - id: string; - jsonrpc: '2.0', - method: string; - params: unknown[]; -} - -/* - * Creates a new health checker. - * - * The role of the health checker is to report to the user the health of a smoldot chain. - * - * In order to use it, start by creating a health checker, and call `setSendJsonRpc` to set the - * way to send a JSON-RPC request to a chain. The health checker is disabled by default. Use - * `start()` in order to start the health checks. The `start()` function must be passed a callback called - * when an update to the health of the node is available. - * - * In order to send a JSON-RPC request to the chain, you **must** use the `sendJsonRpc` function - * of the health checker. The health checker rewrites the `id` of the requests it receives. - * - * When the chain send a JSON-RPC response, it must be passed to `responsePassThrough()`. This - * function intercepts the responses destined to the requests that have been emitted by the health - * checker and returns `null`. If the response doesn't concern the health checker, the response is - * simply returned by the function. - * - * # How it works - * - * The health checker periodically calls the `system_health` JSON-RPC call in order to determine - * the health of the chain. - * - * In addition to this, as long as the health check reports that `isSyncing` is `true`, the - * health checker also maintains a subscription to new best blocks using `chain_subscribeNewHeads`. - * Whenever a new block is notified, a health check is performed immediately in order to determine - * whether `isSyncing` has changed to `false`. - * - * Thanks to this subscription, the latency of the report of the switch from `isSyncing: true` to - * `isSyncing: false` is very low. - * - */ -export function healthChecker (): HealthChecker { - // `null` if health checker is not started. - let checker: null | InnerChecker = null; - let sendJsonRpc: null | ((request: string) => void) = null; - - return { - responsePassThrough: (jsonRpcResponse) => { - if (checker === null) { - return jsonRpcResponse; - } - - return checker.responsePassThrough(jsonRpcResponse); - }, - sendJsonRpc: (request) => { - if (!sendJsonRpc) { - throw new Error('setSendJsonRpc must be called before sending requests'); - } - - if (checker === null) { - sendJsonRpc(request); - } else { - checker.sendJsonRpc(request); - } - }, - setSendJsonRpc: (cb) => { - sendJsonRpc = cb; - }, - start: (healthCallback) => { - if (checker !== null) { - throw new Error("Can't start the health checker multiple times in parallel"); - } else if (!sendJsonRpc) { - throw new Error('setSendJsonRpc must be called before starting the health checks'); - } - - checker = new InnerChecker(healthCallback, sendJsonRpc); - checker.update(true); - }, - stop: () => { - if (checker === null) { - return; - } // Already stopped. - - checker.destroy(); - checker = null; - } - }; -} - -class InnerChecker { - #healthCallback: (health: SmoldotHealth) => void; - #currentHealthCheckId: string | null = null; - #currentHealthTimeout: ReturnType | null = null; - #currentSubunsubRequestId: string | null = null; - #currentSubscriptionId: string | null = null; - #requestToSmoldot: (request: JSONRequest) => void; - #isSyncing = false; - #nextRequestId = 0; - - constructor (healthCallback: (health: SmoldotHealth) => void, requestToSmoldot: (request: string) => void) { - this.#healthCallback = healthCallback; - this.#requestToSmoldot = (request: JSONRequest) => requestToSmoldot(stringify(request)); - } - - sendJsonRpc = (request: string): void => { - // Replace the `id` in the request to prefix the request ID with `extern:`. - let parsedRequest: JSONRequest; - - try { - parsedRequest = JSON.parse(request) as JSONRequest; - } catch { - return; - } - - if (parsedRequest.id) { - const newId = 'extern:' + stringify(parsedRequest.id); - - parsedRequest.id = newId; - } - - this.#requestToSmoldot(parsedRequest); - }; - - responsePassThrough = (jsonRpcResponse: string): string | null => { - let parsedResponse: {id: string, result?: SmoldotHealth, params?: { subscription: string }}; - - try { - parsedResponse = JSON.parse(jsonRpcResponse) as { id: string, result?: SmoldotHealth }; - } catch { - return jsonRpcResponse; - } - - // Check whether response is a response to `system_health`. - if (parsedResponse.id && this.#currentHealthCheckId === parsedResponse.id) { - this.#currentHealthCheckId = null; - - // Check whether query was successful. It is possible for queries to fail for - // various reasons, such as the client being overloaded. - if (!parsedResponse.result) { - this.update(false); - - return null; - } - - this.#healthCallback(parsedResponse.result); - this.#isSyncing = parsedResponse.result.isSyncing; - this.update(false); - - return null; - } - - // Check whether response is a response to the subscription or unsubscription. - if ( - parsedResponse.id && - this.#currentSubunsubRequestId === parsedResponse.id - ) { - this.#currentSubunsubRequestId = null; - - // Check whether query was successful. It is possible for queries to fail for - // various reasons, such as the client being overloaded. - if (!parsedResponse.result) { - this.update(false); - - return null; - } - - if (this.#currentSubscriptionId) { - this.#currentSubscriptionId = null; - } else { - this.#currentSubscriptionId = parsedResponse.result as unknown as string; - } - - this.update(false); - - return null; - } - - // Check whether response is a notification to a subscription. - if ( - parsedResponse.params && - this.#currentSubscriptionId && - parsedResponse.params.subscription === this.#currentSubscriptionId - ) { - // Note that after a successful subscription, a notification containing - // the current best block is always returned. Considering that a - // subscription is performed in response to a health check, calling - // `startHealthCheck()` here will lead to a second health check. - // It might seem redundant to perform two health checks in a quick - // succession, but doing so doesn't lead to any problem, and it is - // actually possible for the health to have changed in between as the - // current best block might have been updated during the subscription - // request. - this.update(true); - - return null; - } - - // Response doesn't concern us. - if (parsedResponse.id) { - const id: string = parsedResponse.id; - - // Need to remove the `extern:` prefix. - if (!id.startsWith('extern:')) { - throw new Error('State inconsistency in health checker'); - } - - const newId = JSON.parse(id.slice('extern:'.length)) as string; - - parsedResponse.id = newId; - } - - return stringify(parsedResponse); - }; - - update = (startNow: boolean): void => { - // If `startNow`, clear `#currentHealthTimeout` so that it is set below. - if (startNow && this.#currentHealthTimeout) { - clearTimeout(this.#currentHealthTimeout); - this.#currentHealthTimeout = null; - } - - if (!this.#currentHealthTimeout) { - const startHealthRequest = () => { - this.#currentHealthTimeout = null; - - // No matter what, don't start a health request if there is already one in progress. - // This is sane to do because receiving a response to a health request calls `update()`. - if (this.#currentHealthCheckId) { - return; - } - - // Actual request starting. - this.#currentHealthCheckId = `health-checker:${this.#nextRequestId}`; - this.#nextRequestId += 1; - - this.#requestToSmoldot({ - id: this.#currentHealthCheckId, - jsonrpc: '2.0', - method: 'system_health', - params: [] - }); - }; - - if (startNow) { - startHealthRequest(); - } else { - this.#currentHealthTimeout = setTimeout(startHealthRequest, 1000); - } - } - - if ( - this.#isSyncing && - !this.#currentSubscriptionId && - !this.#currentSubunsubRequestId - ) { - this.startSubscription(); - } - - if ( - !this.#isSyncing && - this.#currentSubscriptionId && - !this.#currentSubunsubRequestId - ) { - this.endSubscription(); - } - }; - - startSubscription = (): void => { - if (this.#currentSubunsubRequestId || this.#currentSubscriptionId) { - throw new Error('Internal error in health checker'); - } - - this.#currentSubunsubRequestId = `health-checker:${this.#nextRequestId}`; - this.#nextRequestId += 1; - - this.#requestToSmoldot({ - id: this.#currentSubunsubRequestId, - jsonrpc: '2.0', - method: 'chain_subscribeNewHeads', - params: [] - }); - }; - - endSubscription = (): void => { - if (this.#currentSubunsubRequestId || !this.#currentSubscriptionId) { - throw new Error('Internal error in health checker'); - } - - this.#currentSubunsubRequestId = `health-checker:${this.#nextRequestId}`; - this.#nextRequestId += 1; - - this.#requestToSmoldot({ - id: this.#currentSubunsubRequestId, - jsonrpc: '2.0', - method: 'chain_unsubscribeNewHeads', - params: [this.#currentSubscriptionId] - }); - }; - - destroy = (): void => { - if (this.#currentHealthTimeout) { - clearTimeout(this.#currentHealthTimeout); - this.#currentHealthTimeout = null; - } - }; -} - -export class HealthCheckError extends Error { - readonly #cause: unknown; - - getCause (): unknown { - return this.#cause; - } - - constructor (response: unknown, message = 'Got error response asking for system health') { - super(message); - - this.#cause = response; - } -} diff --git a/packages/rpc-provider/src/substrate-connect/index.spec.ts b/packages/rpc-provider/src/substrate-connect/index.spec.ts deleted file mode 100644 index d0dd077b..00000000 --- a/packages/rpc-provider/src/substrate-connect/index.spec.ts +++ /dev/null @@ -1,638 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type * as Sc from '@substrate/connect'; -import type { HealthChecker, SmoldotHealth } from './types.js'; - -import { noop, stringify } from '@polkadot/util'; - -import { ScProvider } from './index.js'; - -interface MockChain extends Sc.Chain { - _spec: () => string; - _recevedRequests: () => string[]; - _isTerminated: () => boolean; - _triggerCallback: (response: string | object) => void; - _setTerminateInterceptor: (fn: () => void) => void; - _setSendJsonRpcInterceptor: (fn: (rpc: string) => void) => void; - _getLatestRequest: () => string; -} - -interface MockedHealthChecker extends HealthChecker { - _isActive: () => boolean; - _triggerHealthUpdate: (update: SmoldotHealth) => void; -} - -type MockSc = typeof Sc & { - latestChain: () => MockChain; -}; - -enum WellKnownChain { - polkadot = 'polkadot', - ksmcc3 = 'ksmcc3', - rococo_v2_2 = 'rococo_v2_2', - westend2 = 'westend2' -} - -const wait = (ms: number) => - new Promise((resolve) => - setTimeout(resolve, ms) - ); - -function healthCheckerMock (): MockedHealthChecker { - let cb: (health: SmoldotHealth) => void = () => undefined; - let sendJsonRpc: (request: string) => void = () => undefined; - let isActive = false; - - return { - _isActive: () => isActive, - _triggerHealthUpdate: (update: SmoldotHealth) => { - cb(update); - }, - responsePassThrough: (response) => response, - sendJsonRpc: (...args) => sendJsonRpc(...args), - setSendJsonRpc: (cb) => { - sendJsonRpc = cb; - }, - start: (x) => { - isActive = true; - cb = x; - }, - stop: () => { - isActive = false; - } - }; -} - -function healthCheckerFactory () { - const _healthCheckers: MockedHealthChecker[] = []; - - return { - _healthCheckers, - _latestHealthChecker: () => _healthCheckers.slice(-1)[0], - healthChecker: () => { - const result = healthCheckerMock(); - - _healthCheckers.push(result); - - return result; - } - }; -} - -function getFakeChain (spec: string, callback: Sc.JsonRpcCallback): MockChain { - const _receivedRequests: string[] = []; - let _isTerminated = false; - - let terminateInterceptor = Function.prototype; - let sendJsonRpcInterceptor = Function.prototype; - - return { - _getLatestRequest: () => _receivedRequests[_receivedRequests.length - 1], - _isTerminated: () => _isTerminated, - _recevedRequests: () => _receivedRequests, - _setSendJsonRpcInterceptor: (fn) => { - sendJsonRpcInterceptor = fn; - }, - _setTerminateInterceptor: (fn) => { - terminateInterceptor = fn; - }, - _spec: () => spec, - _triggerCallback: (response) => { - callback( - typeof response === 'string' - ? response - : stringify(response) - ); - }, - addChain: (chainSpec, jsonRpcCallback) => - Promise.resolve(getFakeChain(chainSpec, jsonRpcCallback ?? noop)), - remove: () => { - terminateInterceptor(); - _isTerminated = true; - }, - sendJsonRpc: (rpc) => { - sendJsonRpcInterceptor(rpc); - _receivedRequests.push(rpc); - } - }; -} - -function getFakeClient () { - const chains: MockChain[] = []; - let addChainInterceptor: Promise = Promise.resolve(); - let addWellKnownChainInterceptor: Promise = Promise.resolve(); - - return { - _chains: () => chains, - _setAddChainInterceptor: (interceptor: Promise) => { - addChainInterceptor = interceptor; - }, - _setAddWellKnownChainInterceptor: (interceptor: Promise) => { - addWellKnownChainInterceptor = interceptor; - }, - addChain: (chainSpec: string, cb: Sc.JsonRpcCallback): Promise => - addChainInterceptor.then(() => { - const result = getFakeChain(chainSpec, cb); - - chains.push(result); - - return result; - }), - addWellKnownChain: ( - wellKnownChain: string, - cb: Sc.JsonRpcCallback - ): Promise => - addWellKnownChainInterceptor.then(() => { - const result = getFakeChain(wellKnownChain, cb); - - chains.push(result); - - return result; - }) - }; -} - -function connectorFactory (): MockSc { - const clients: ReturnType[] = []; - const latestClient = () => clients[clients.length - 1]; - - return { - WellKnownChain, - _clients: () => clients, - createScClient: () => { - const result = getFakeClient(); - - clients.push(result); - - return result; - }, - latestChain: () => - latestClient()._chains()[latestClient()._chains().length - 1], - latestClient - } as unknown as MockSc; -} - -function setChainSyncyingStatus (isSyncing: boolean): void { - getCurrentHealthChecker()._triggerHealthUpdate({ - isSyncing, - peers: 1, - shouldHavePeers: true - }); -} - -let mockSc: MockSc; -let mockedHealthChecker: ReturnType; -const getCurrentHealthChecker = () => mockedHealthChecker._latestHealthChecker(); - -describe('ScProvider', () => { - beforeAll(() => { - mockSc = connectorFactory(); - mockedHealthChecker = healthCheckerFactory(); - }); - - describe('on', () => { - it('emits `connected` as soon as the chain is not syncing', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - const onConnected = jest.fn(); - - provider.on('connected', onConnected); - - expect(onConnected).not.toHaveBeenCalled(); - setChainSyncyingStatus(false); - expect(onConnected).toHaveBeenCalled(); - }); - - it('stops receiving notifications after unsubscribing', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - const onConnected = jest.fn(); - - provider.on('connected', onConnected)(); - expect(onConnected).not.toHaveBeenCalled(); - - setChainSyncyingStatus(false); - expect(onConnected).not.toHaveBeenCalled(); - }); - - it('synchronously emits connected if the Provider is already `connected`', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - setChainSyncyingStatus(false); - - const onConnected = jest.fn(); - - provider.on('connected', onConnected); - expect(onConnected).toHaveBeenCalled(); - }); - - it('emits `disconnected` once the chain goes back to syncing', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - setChainSyncyingStatus(false); - - const onConnected = jest.fn(); - const onDisconnected = jest.fn(); - - provider.on('connected', onConnected); - provider.on('disconnected', onDisconnected); - - expect(onConnected).toHaveBeenCalled(); - expect(onDisconnected).not.toHaveBeenCalled(); - - onConnected.mockReset(); - setChainSyncyingStatus(true); - - expect(onConnected).not.toHaveBeenCalled(); - expect(onDisconnected).toHaveBeenCalled(); - }); - }); - - describe('hasSubscriptions', () => { - it('supports subscriptions', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - expect(provider.hasSubscriptions).toBe(true); - }); - }); - - describe('clone', () => { - it('can not be clonned', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - expect(() => provider.clone()).toThrow(); - }); - }); - - describe('connect', () => { - it('does not create a new chain when trying to re-connect while the current chain is syncing', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - expect(chain).toBe(mockSc.latestChain()); - }); - - it('throws when trying to connect on an already connected Provider', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - setChainSyncyingStatus(false); - - await expect( - provider.connect(undefined, mockedHealthChecker.healthChecker) - ).rejects.toThrow(/Already connected/); - }); - }); - - describe('disconnect', () => { - it('removes the chain and cleans up', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - await provider.disconnect(); - - expect(chain._isTerminated()).toBe(true); - }); - - // eslint-disable-next-line jest/expect-expect - it('does not throw when disconnecting on an already disconnected Provider', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - await provider.disconnect(); - await provider.disconnect(); - }); - }); - - describe('send', () => { - it('throws when trying to send a request while the Provider is not connected', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - await expect(provider.send('', [])).rejects.toThrow(); - }); - - it('receives responses to its requests', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - setChainSyncyingStatus(false); - - const responsePromise = provider.send('getData', ['foo']); - - await wait(0); - expect(chain._getLatestRequest()).toEqual( - '{"id":1,"jsonrpc":"2.0","method":"getData","params":["foo"]}' - ); - - const result = { foo: 'foo' }; - - chain._triggerCallback({ - id: 1, - jsonrpc: '2.0', - result - }); - - const response = await responsePromise; - - expect(response).toEqual(result); - }); - - it("rejects when the response can't be deserialized", async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - setChainSyncyingStatus(false); - - setTimeout(() => { - chain._triggerCallback({ - id: 1, - jsonrpc: '2.0' - }); - }, 0); - - await expect(provider.send('getData', ['foo'])).rejects.toThrow(); - }); - - it('rejects when the smoldot chain has crashed', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - setChainSyncyingStatus(false); - await wait(0); - - chain._setSendJsonRpcInterceptor(() => { - throw new Error('boom!'); - }); - - await expect( - provider.send('getData', ['foo']) - ).rejects.toThrow(/Disconnected/); - expect(provider.isConnected).toBe(false); - }); - }); - - describe('subscribe', () => { - it('subscribes and recives messages until it unsubscribes', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - setChainSyncyingStatus(false); - - const unsubscribeToken = 'unsubscribeToken'; - - setTimeout(() => { - chain._triggerCallback({ - id: 1, - jsonrpc: '2.0', - result: unsubscribeToken - }); - }, 0); - - const cb = jest.fn(); - const token = await provider.subscribe( - 'foo', - 'chain_subscribeNewHeads', - ['baz'], - cb - ); - - expect(token).toBe(unsubscribeToken); - expect(cb).not.toHaveBeenCalled(); - - chain._triggerCallback({ - jsonrpc: '2.0', - method: 'foo', - params: { - result: 1, - subscription: token - } - }); - expect(cb).toHaveBeenCalledTimes(1); - expect(cb).toHaveBeenLastCalledWith(null, 1); - - chain._triggerCallback({ - jsonrpc: '2.0', - method: 'foo', - params: { - result: 2, - subscription: token - } - }); - expect(cb).toHaveBeenCalledTimes(2); - expect(cb).toHaveBeenLastCalledWith(null, 2); - - provider - .unsubscribe('foo', 'chain_unsubscribeNewHeads', unsubscribeToken) - .catch(console.error); - - chain._triggerCallback({ - jsonrpc: '2.0', - method: 'foo', - params: { - result: 3, - subscription: token - } - }); - expect(cb).toHaveBeenCalledTimes(2); - expect(cb).toHaveBeenLastCalledWith(null, 2); - }); - - it('ignores subscription messages that were received before the subscription token', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - setChainSyncyingStatus(false); - - const unsubscribeToken = 'unsubscribeToken'; - - chain._triggerCallback({ - jsonrpc: '2.0', - method: 'foo', - params: { - result: 1, - subscription: unsubscribeToken - } - }); - setTimeout(() => { - chain._triggerCallback({ - id: 1, - jsonrpc: '2.0', - result: unsubscribeToken - }); - }, 0); - - const cb = jest.fn(); - const token = await provider.subscribe( - 'foo', - 'chain_subscribeNewHeads', - ['baz'], - cb - ); - - expect(token).toBe(unsubscribeToken); - expect(cb).not.toHaveBeenCalled(); - }); - - it('emits the error when the message has an error', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - setChainSyncyingStatus(false); - await wait(0); - - const unsubscribeToken = 'unsubscribeToken'; - - setTimeout(() => { - chain._triggerCallback({ - id: 1, - jsonrpc: '2.0', - result: unsubscribeToken - }); - }, 0); - - const cb = jest.fn(); - const token = await provider.subscribe( - 'foo', - 'chain_subscribeNewHeads', - ['baz'], - cb - ); - - chain._triggerCallback({ - jsonrpc: '2.0', - method: 'foo', - params: { - error: 'boom', - subscription: unsubscribeToken - } - }); - - expect(token).toBe(unsubscribeToken); - expect(cb).toHaveBeenCalledTimes(1); - expect(cb).toHaveBeenLastCalledWith(expect.any(Error), undefined); - }); - - it('errors when subscribing to an unsupported method', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - setChainSyncyingStatus(false); - - await wait(0); - await expect( - provider.subscribe('foo', 'bar', ['baz'], () => undefined) - ).rejects.toThrow(/Unsupported subscribe method: bar/); - }); - }); - - describe('unsubscribe', () => { - it('rejects when trying to unsubscribe from un unexisting subscription', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - - setChainSyncyingStatus(false); - - await expect( - provider.unsubscribe('', '', '') - ).rejects.toThrow(/Unable to find active subscription/); - }); - }); - - it('cleans up the stale subscriptions once it reconnects', async () => { - const provider = new ScProvider(mockSc, ''); - - await provider.connect(undefined, mockedHealthChecker.healthChecker); - const chain = mockSc.latestChain(); - - // setting the syncing status of the chain to fals so that the Provider - // gets `connected` - setChainSyncyingStatus(false); - - // while connected we create a subscription - const unsubscribeToken = 'unsubscribeToken'; - - setTimeout(() => { - chain._triggerCallback({ - id: 1, - jsonrpc: '2.0', - result: unsubscribeToken - }); - }, 0); - - const cb = jest.fn(); - const token = await provider.subscribe( - 'foo', - 'chain_subscribeNewHeads', - ['baz'], - cb - ); - - // setting the syncing status of the chain to fals so that the Provider - // gets `disconnected` - setChainSyncyingStatus(true); - - // let's wait some time in order to ensure that the stale unsubscription - // messages are not sent until the chain syncing status changes back to false - await wait(200); - - // before we let the healthChecker know that the chain is no longer syncing, - // let's make sure that the chain has received the correct request, and - // most importantly that it has not received a request for unsubscribing - // from the stale subscription, since that request should happen once the - // chain is no longer syncing - expect(chain._recevedRequests()).toEqual([ - '{"id":1,"jsonrpc":"2.0","method":"chain_subscribeNewHeads","params":["baz"]}' - ]); - - // lets change the sync status back to false - setChainSyncyingStatus(false); - - // let's wait one tick to ensure that the microtasks got processed - await wait(0); - - // let's make sure that we have now sent the request for killing the - // stale subscription - expect(chain._recevedRequests()).toEqual([ - '{"id":1,"jsonrpc":"2.0","method":"chain_subscribeNewHeads","params":["baz"]}', - `{"id":2,"jsonrpc":"2.0","method":"chain_unsubscribeNewHeads","params":["${token}"]}`, - '{"id":3,"jsonrpc":"2.0","method":"chain_subscribeNewHeads","params":["baz"]}' - ]); - }); -}); diff --git a/packages/rpc-provider/src/substrate-connect/index.ts b/packages/rpc-provider/src/substrate-connect/index.ts deleted file mode 100644 index eaa7f17a..00000000 --- a/packages/rpc-provider/src/substrate-connect/index.ts +++ /dev/null @@ -1,415 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type * as ScType from '@substrate/connect'; -import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '../types.js'; - -import { EventEmitter } from 'eventemitter3'; - -import { isError, isFunction, isObject, logger, noop, objectSpread } from '@polkadot/util'; - -import { RpcCoder } from '../coder/index.js'; -import { healthChecker } from './Health.js'; - -type ResponseCallback = (response: string | Error) => void; - -// We define the interface with items we use - this means that we don't really -// need to be passed a full `import * as Sc from '@ubstrate/connect'`, but can -// also make do with a { WellKnownChain, createScClient } interface -interface SubstrateConnect { - WellKnownChain: typeof ScType['WellKnownChain']; - createScClient: typeof ScType['createScClient']; -} - -const l = logger('api-substrate-connect'); - -// These methods have been taken from: -// https://github.com/paritytech/smoldot/blob/17425040ddda47d539556eeaf62b88c4240d1d42/src/json_rpc/methods.rs#L338-L462 -// It's important to take into account that smoldot is adding support to the new -// json-rpc-interface https://paritytech.github.io/json-rpc-interface-spec/ -// However, at the moment this list only includes methods that belong to the "old" API -const subscriptionUnsubscriptionMethods = new Map([ - ['author_submitAndWatchExtrinsic', 'author_unwatchExtrinsic'], - ['chain_subscribeAllHeads', 'chain_unsubscribeAllHeads'], - ['chain_subscribeFinalizedHeads', 'chain_unsubscribeFinalizedHeads'], - ['chain_subscribeFinalisedHeads', 'chain_subscribeFinalisedHeads'], - ['chain_subscribeNewHeads', 'chain_unsubscribeNewHeads'], - ['chain_subscribeNewHead', 'chain_unsubscribeNewHead'], - ['chain_subscribeRuntimeVersion', 'chain_unsubscribeRuntimeVersion'], - ['subscribe_newHead', 'unsubscribe_newHead'], - ['state_subscribeRuntimeVersion', 'state_unsubscribeRuntimeVersion'], - ['state_subscribeStorage', 'state_unsubscribeStorage'] -]); - -const scClients = new WeakMap(); - -interface ActiveSubs { - type: string, - method: string, - params: any[], - callback: ProviderInterfaceCallback -} - -export class ScProvider implements ProviderInterface { - readonly #Sc: SubstrateConnect; - readonly #coder: RpcCoder = new RpcCoder(); - readonly #spec: string | ScType.WellKnownChain; - readonly #sharedSandbox?: ScProvider | undefined; - readonly #subscriptions = new Map(); - readonly #resubscribeMethods = new Map(); - readonly #requests = new Map(); - readonly #wellKnownChains: Set; - readonly #eventemitter: EventEmitter = new EventEmitter(); - - #chain: Promise | null = null; - #isChainReady = false; - - public constructor (Sc: SubstrateConnect, spec: string | ScType.WellKnownChain, sharedSandbox?: ScProvider) { - if (!isObject(Sc) || !isObject(Sc.WellKnownChain) || !isFunction(Sc.createScClient)) { - throw new Error('Expected an @substrate/connect interface as first parameter to ScProvider'); - } - - this.#Sc = Sc; - this.#spec = spec; - this.#sharedSandbox = sharedSandbox; - this.#wellKnownChains = new Set(Object.values(Sc.WellKnownChain)); - } - - public get hasSubscriptions (): boolean { - // Indicates that subscriptions are supported - return !!true; - } - - public get isClonable (): boolean { - return !!false; - } - - public get isConnected (): boolean { - return !!this.#chain && this.#isChainReady; - } - - public clone (): ProviderInterface { - throw new Error('clone() is not supported.'); - } - - // Config details can be found in @substrate/connect repo following the link: - // https://github.com/paritytech/substrate-connect/blob/main/packages/connect/src/connector/index.ts - async connect (config?: ScType.Config, checkerFactory = healthChecker): Promise { - if (this.isConnected) { - throw new Error('Already connected!'); - } - - // it could happen that after emitting `disconnected` due to the fact that - // smoldot is syncing, the consumer tries to reconnect after a certain amount - // of time... In which case we want to make sure that we don't create a new - // chain. - if (this.#chain) { - await this.#chain; - - return; - } - - if (this.#sharedSandbox && !this.#sharedSandbox.isConnected) { - await this.#sharedSandbox.connect(); - } - - const client = this.#sharedSandbox - ? scClients.get(this.#sharedSandbox) - : this.#Sc.createScClient(config); - - if (!client) { - throw new Error('Unknown ScProvider!'); - } - - scClients.set(this, client); - - const hc = checkerFactory(); - - const onResponse = (res: string): void => { - const hcRes = hc.responsePassThrough(res); - - if (!hcRes) { - return; - } - - const response = JSON.parse(hcRes) as JsonRpcResponse; - let decodedResponse: string | Error; - - try { - decodedResponse = this.#coder.decodeResponse(response); - } catch (e) { - decodedResponse = e as Error; - } - - // It's not a subscription message, but rather a standar RPC response - if (response.params?.subscription === undefined || !response.method) { - return this.#requests.get(response.id)?.(decodedResponse); - } - - // We are dealing with a subscription message - const subscriptionId = `${response.method}::${response.params.subscription}`; - - const callback = this.#subscriptions.get(subscriptionId)?.[0]; - - callback?.(decodedResponse); - }; - - const addChain = this.#sharedSandbox - ? (async (...args) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const source = this.#sharedSandbox!; - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return (await source.#chain)!.addChain(...args); - }) as ScType.AddChain - : this.#wellKnownChains.has(this.#spec as ScType.WellKnownChain) - ? client.addWellKnownChain - : client.addChain; - - this.#chain = addChain(this.#spec as ScType.WellKnownChain, onResponse).then((chain) => { - hc.setSendJsonRpc(chain.sendJsonRpc); - - this.#isChainReady = false; - - const cleanup = () => { - // If there are any callbacks left, we have to reject/error them. - // Otherwise, that would cause a memory leak. - const disconnectionError = new Error('Disconnected'); - - this.#requests.forEach((cb) => cb(disconnectionError)); - this.#subscriptions.forEach(([cb]) => cb(disconnectionError)); - this.#subscriptions.clear(); - }; - - const staleSubscriptions: { - unsubscribeMethod: string - id: number | string - }[] = []; - - const killStaleSubscriptions = () => { - if (staleSubscriptions.length === 0) { - return; - } - - const stale = staleSubscriptions.pop(); - - if (!stale) { - throw new Error('Unable to get stale subscription'); - } - - const { id, unsubscribeMethod } = stale; - - Promise - .race([ - this.send(unsubscribeMethod, [id]).catch(noop), - new Promise((resolve) => setTimeout(resolve, 500)) - ]) - .then(killStaleSubscriptions) - .catch(noop); - }; - - hc.start((health) => { - const isReady = - !health.isSyncing && (health.peers > 0 || !health.shouldHavePeers); - - // if it's the same as before, then nothing has changed and we are done - if (this.#isChainReady === isReady) { - return; - } - - this.#isChainReady = isReady; - - if (!isReady) { - // If we've reached this point, that means that the chain used to be "ready" - // and now we are about to emit `disconnected`. - // - // This will cause the PolkadotJs API think that the connection is - // actually dead. In reality the smoldot chain is not dead, of course. - // However, we have to cleanup all the existing callbacks because when - // the smoldot chain stops syncing, then we will emit `connected` and - // the PolkadotJs API will try to re-create the previous - // subscriptions and requests. Although, now is not a good moment - // to be sending unsubscription messages to the smoldot chain, we - // should wait until is no longer syncing to send the unsubscription - // messages from the stale subscriptions of the previous connection. - // - // That's why -before we perform the cleanup of `this.#subscriptions`- - // we keep the necessary information that we will need later on to - // kill the stale subscriptions. - [...this.#subscriptions.values()].forEach((s) => { - staleSubscriptions.push(s[1]); - }); - cleanup(); - - this.#eventemitter.emit('disconnected'); - } else { - killStaleSubscriptions(); - - this.#eventemitter.emit('connected'); - - if (this.#resubscribeMethods.size) { - this.#resubscribe(); - } - } - }); - - return objectSpread({}, chain, { - remove: () => { - hc.stop(); - chain.remove(); - cleanup(); - }, - sendJsonRpc: hc.sendJsonRpc.bind(hc) - }); - }); - - try { - await this.#chain; - } catch (e) { - this.#chain = null; - this.#eventemitter.emit('error', e); - throw e; - } - } - - #resubscribe = (): void => { - const promises: any[] = []; - - this.#resubscribeMethods.forEach((subDetails: ActiveSubs): void => { - // only re-create subscriptions which are not in author (only area where - // transactions are created, i.e. submissions such as 'author_submitAndWatchExtrinsic' - // are not included (and will not be re-broadcast) - if (subDetails.type.startsWith('author_')) { - return; - } - - try { - const promise = new Promise((resolve) => { - this.subscribe(subDetails.type, subDetails.method, subDetails.params, subDetails.callback).catch((error) => console.log(error)); - resolve(); - }); - - promises.push(promise); - } catch (error) { - l.error(error); - } - }); - - Promise.all(promises).catch((err) => l.log(err)); - }; - - async disconnect (): Promise { - if (!this.#chain) { - return; - } - - const chain = await this.#chain; - - this.#chain = null; - this.#isChainReady = false; - - try { - chain.remove(); - } catch (_) {} - - this.#eventemitter.emit('disconnected'); - } - - public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { - // It's possible. Although, quite unlikely, that by the time that polkadot - // subscribes to the `connected` event, the Provider is already connected. - // In that case, we must emit to let the consumer know that we are connected. - if (type === 'connected' && this.isConnected) { - sub(); - } - - this.#eventemitter.on(type, sub); - - return (): void => { - this.#eventemitter.removeListener(type, sub); - }; - } - - public async send (method: string, params: unknown[]): Promise { - if (!this.isConnected || !this.#chain) { - throw new Error('Provider is not connected'); - } - - const chain = await this.#chain; - const [id, json] = this.#coder.encodeJson(method, params); - - const result = new Promise((resolve, reject): void => { - this.#requests.set(id, (response) => { - (isError(response) ? reject : resolve)(response as unknown as T); - }); - - try { - chain.sendJsonRpc(json); - } catch (e) { - this.#chain = null; - - try { - chain.remove(); - } catch (_) {} - - this.#eventemitter.emit('error', e); - } - }); - - try { - return await result; - } finally { - // let's ensure that once the Promise is resolved/rejected, then we remove - // remove its entry from the internal #requests - this.#requests.delete(id); - } - } - - public async subscribe (type: string, method: string, params: any[], callback: ProviderInterfaceCallback): Promise { - if (!subscriptionUnsubscriptionMethods.has(method)) { - throw new Error(`Unsupported subscribe method: ${method}`); - } - - const id = await this.send(method, params); - const subscriptionId = `${type}::${id}`; - - const cb = (response: Error | string) => { - if (response instanceof Error) { - callback(response, undefined); - } else { - callback(null, response); - } - }; - - const unsubscribeMethod = subscriptionUnsubscriptionMethods.get(method); - - if (!unsubscribeMethod) { - throw new Error('Invalid unsubscribe method found'); - } - - this.#resubscribeMethods.set(subscriptionId, { callback, method, params, type }); - - this.#subscriptions.set(subscriptionId, [cb, { id, unsubscribeMethod }]); - - return id; - } - - public unsubscribe (type: string, method: string, id: number | string): Promise { - if (!this.isConnected) { - throw new Error('Provider is not connected'); - } - - const subscriptionId = `${type}::${id}`; - - if (!this.#subscriptions.has(subscriptionId)) { - return Promise.reject( - new Error(`Unable to find active subscription=${subscriptionId}`) - ); - } - - this.#resubscribeMethods.delete(subscriptionId); - this.#subscriptions.delete(subscriptionId); - - return this.send(method, [id]); - } -} diff --git a/packages/rpc-provider/src/substrate-connect/types.ts b/packages/rpc-provider/src/substrate-connect/types.ts deleted file mode 100644 index 2de85f2d..00000000 --- a/packages/rpc-provider/src/substrate-connect/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -export interface SmoldotHealth { - isSyncing: boolean - peers: number - shouldHavePeers: boolean -} - -export interface HealthChecker { - setSendJsonRpc(sendRequest: (request: string) => void): void - start(healthCallback: (health: SmoldotHealth) => void): void - stop(): void - sendJsonRpc(request: string): void - responsePassThrough(response: string): string | null -} From 6d79e12f8b6c76d53c30aaf880a1ca766f7d916f Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 21:42:14 +0200 Subject: [PATCH 03/50] yarn lock --- yarn.lock | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/yarn.lock b/yarn.lock index f64e63f7..e04aa9d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2208,6 +2208,29 @@ __metadata: languageName: node linkType: hard +"@polkadot/rpc-provider@workspace:packages/rpc-provider": + version: 0.0.0-use.local + resolution: "@polkadot/rpc-provider@workspace:packages/rpc-provider" + dependencies: + "@polkadot/keyring": "npm:^12.6.2" + "@polkadot/types": "npm:11.2.1" + "@polkadot/types-support": "npm:11.2.1" + "@polkadot/util": "npm:^12.6.2" + "@polkadot/util-crypto": "npm:^12.6.2" + "@polkadot/x-fetch": "npm:^12.6.2" + "@polkadot/x-global": "npm:^12.6.2" + "@polkadot/x-ws": "npm:^12.6.2" + "@substrate/connect": "npm:0.8.10" + eventemitter3: "npm:^5.0.1" + mock-socket: "npm:^9.3.1" + nock: "npm:^13.5.0" + tslib: "npm:^2.6.2" + dependenciesMeta: + "@substrate/connect": + optional: true + languageName: unknown + linkType: soft + "@polkadot/typegen@npm:11.2.1": version: 11.2.1 resolution: "@polkadot/typegen@npm:11.2.1" From 1815e0cdc46ab8ae6d97f47151b03ed60713eb9f Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 22:30:46 +0200 Subject: [PATCH 04/50] remove rpc provider again --- packages/rpc-provider/README.md | 68 -- packages/rpc-provider/package.json | 43 -- packages/rpc-provider/src/bundle.ts | 7 - .../src/coder/decodeResponse.spec.ts | 70 -- .../rpc-provider/src/coder/encodeJson.spec.ts | 20 - .../src/coder/encodeObject.spec.ts | 25 - packages/rpc-provider/src/coder/error.spec.ts | 111 ---- packages/rpc-provider/src/coder/error.ts | 66 -- packages/rpc-provider/src/coder/index.ts | 88 --- packages/rpc-provider/src/defaults.ts | 10 - packages/rpc-provider/src/index.ts | 6 - packages/rpc-provider/src/lru.spec.ts | 57 -- packages/rpc-provider/src/lru.ts | 134 ---- packages/rpc-provider/src/mock/index.ts | 259 -------- packages/rpc-provider/src/mock/mockHttp.ts | 35 - packages/rpc-provider/src/mock/mockWs.ts | 92 --- packages/rpc-provider/src/mock/on.spec.ts | 43 -- packages/rpc-provider/src/mock/send.spec.ts | 38 -- .../rpc-provider/src/mock/subscribe.spec.ts | 81 --- packages/rpc-provider/src/mock/types.ts | 36 - .../rpc-provider/src/mock/unsubscribe.spec.ts | 57 -- packages/rpc-provider/src/mod.ts | 4 - packages/rpc-provider/src/packageDetect.ts | 12 - packages/rpc-provider/src/types.ts | 99 --- packages/rpc-provider/src/ws/connect.spec.ts | 167 ----- packages/rpc-provider/src/ws/errors.ts | 41 -- packages/rpc-provider/src/ws/index.spec.ts | 92 --- packages/rpc-provider/src/ws/index.ts | 626 ------------------ packages/rpc-provider/src/ws/send.spec.ts | 126 ---- packages/rpc-provider/src/ws/state.spec.ts | 20 - .../rpc-provider/src/ws/subscribe.spec.ts | 68 -- .../rpc-provider/src/ws/unsubscribe.spec.ts | 100 --- packages/rpc-provider/tsconfig.build.json | 17 - packages/rpc-provider/tsconfig.spec.json | 18 - 34 files changed, 2736 deletions(-) delete mode 100644 packages/rpc-provider/README.md delete mode 100644 packages/rpc-provider/package.json delete mode 100644 packages/rpc-provider/src/bundle.ts delete mode 100644 packages/rpc-provider/src/coder/decodeResponse.spec.ts delete mode 100644 packages/rpc-provider/src/coder/encodeJson.spec.ts delete mode 100644 packages/rpc-provider/src/coder/encodeObject.spec.ts delete mode 100644 packages/rpc-provider/src/coder/error.spec.ts delete mode 100644 packages/rpc-provider/src/coder/error.ts delete mode 100644 packages/rpc-provider/src/coder/index.ts delete mode 100644 packages/rpc-provider/src/defaults.ts delete mode 100644 packages/rpc-provider/src/index.ts delete mode 100644 packages/rpc-provider/src/lru.spec.ts delete mode 100644 packages/rpc-provider/src/lru.ts delete mode 100644 packages/rpc-provider/src/mock/index.ts delete mode 100644 packages/rpc-provider/src/mock/mockHttp.ts delete mode 100644 packages/rpc-provider/src/mock/mockWs.ts delete mode 100644 packages/rpc-provider/src/mock/on.spec.ts delete mode 100644 packages/rpc-provider/src/mock/send.spec.ts delete mode 100644 packages/rpc-provider/src/mock/subscribe.spec.ts delete mode 100644 packages/rpc-provider/src/mock/types.ts delete mode 100644 packages/rpc-provider/src/mock/unsubscribe.spec.ts delete mode 100644 packages/rpc-provider/src/mod.ts delete mode 100644 packages/rpc-provider/src/packageDetect.ts delete mode 100644 packages/rpc-provider/src/types.ts delete mode 100644 packages/rpc-provider/src/ws/connect.spec.ts delete mode 100644 packages/rpc-provider/src/ws/errors.ts delete mode 100644 packages/rpc-provider/src/ws/index.spec.ts delete mode 100644 packages/rpc-provider/src/ws/index.ts delete mode 100644 packages/rpc-provider/src/ws/send.spec.ts delete mode 100644 packages/rpc-provider/src/ws/state.spec.ts delete mode 100644 packages/rpc-provider/src/ws/subscribe.spec.ts delete mode 100644 packages/rpc-provider/src/ws/unsubscribe.spec.ts delete mode 100644 packages/rpc-provider/tsconfig.build.json delete mode 100644 packages/rpc-provider/tsconfig.spec.json diff --git a/packages/rpc-provider/README.md b/packages/rpc-provider/README.md deleted file mode 100644 index e114fb2d..00000000 --- a/packages/rpc-provider/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# @polkadot/rpc-provider - -Generic transport providers to handle the transport of method calls to and from Polkadot clients from applications interacting with it. It provides an interface to making RPC calls and is generally, unless you are operating at a low-level and taking care of encoding and decoding of parameters/results, it won't be directly used, rather only passed to a higher-level interface. - -## Provider Selection - -There are three flavours of the providers provided, one allowing for using HTTP as a transport mechanism, the other using WebSockets, and the third one uses substrate light-client through @substrate/connect. It is generally recommended to use the [[WsProvider]] since in addition to standard calls, it allows for subscriptions where all changes to state can be pushed from the node to the client. - -All providers are usable (as is the API), in both browser-based and Node.js environments. Polyfills for unsupported functionality are automatically applied based on feature-detection. - -## Usage - -Installation - - -``` -yarn add @polkadot/rpc-provider -``` - -WebSocket Initialization - - -```javascript -import { WsProvider } from '@polkadot/rpc-provider'; - -// this is the actual default endpoint -const provider = new WsProvider('ws://127.0.0.1:9944'); -const version = await provider.send('client_version', []); - -console.log('client version', version); -``` - -HTTP Initialization - - -```javascript -import { HttpProvider } from '@polkadot/rpc-provider'; - -// this is the actual default endpoint -const provider = new HttpProvider('http://127.0.0.1:9933'); -const version = await provider.send('chain_getBlockHash', []); - -console.log('latest block Hash', hash); -``` - -@substrate/connect Initialization - - -Instantiating a Provider for the Polkadot Relay Chain: -```javascript -import { ScProvider } from '@polkadot/rpc-provider'; -import * as Sc from '@substrate/connect'; - -const provider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); - -await provider.connect(); - -const version = await provider.send('chain_getBlockHash', []); -``` - -Instantiating a Provider for a Polkadot parachain: -```javascript -import { ScProvider } from '@polkadot/rpc-provider'; -import * as Sc from '@substrate/connect'; - -const polkadotProvider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); -const parachainProvider = new ScProvider(Sc, parachainSpec, polkadotProvider); - -await parachainProvider.connect(); - -const version = await parachainProvider.send('chain_getBlockHash', []); -``` diff --git a/packages/rpc-provider/package.json b/packages/rpc-provider/package.json deleted file mode 100644 index e8d5f46f..00000000 --- a/packages/rpc-provider/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "author": "Jaco Greeff ", - "bugs": "https://github.com/polkadot-js/api/issues", - "description": "Transport providers for the API", - "engines": { - "node": ">=18" - }, - "homepage": "https://github.com/polkadot-js/api/tree/master/packages/rpc-provider#readme", - "license": "Apache-2.0", - "name": "@polkadot/rpc-provider", - "repository": { - "directory": "packages/rpc-provider", - "type": "git", - "url": "https://github.com/polkadot-js/api.git" - }, - "sideEffects": [ - "./packageDetect.js", - "./packageDetect.cjs" - ], - "type": "module", - "version": "11.2.1", - "main": "index.js", - "dependencies": { - "@polkadot/keyring": "^12.6.2", - "@polkadot/types": "11.2.1", - "@polkadot/types-support": "11.2.1", - "@polkadot/util": "^12.6.2", - "@polkadot/util-crypto": "^12.6.2", - "@polkadot/x-fetch": "^12.6.2", - "@polkadot/x-global": "^12.6.2", - "@polkadot/x-ws": "^12.6.2", - "eventemitter3": "^5.0.1", - "mock-socket": "^9.3.1", - "nock": "^13.5.0", - "tslib": "^2.6.2" - }, - "devDependencies": { - "@substrate/connect": "0.8.10" - }, - "optionalDependencies": { - "@substrate/connect": "0.8.10" - } -} diff --git a/packages/rpc-provider/src/bundle.ts b/packages/rpc-provider/src/bundle.ts deleted file mode 100644 index 06b0acaf..00000000 --- a/packages/rpc-provider/src/bundle.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -export { HttpProvider } from './http/index.js'; -export { packageInfo } from './packageInfo.js'; -export { ScProvider } from './substrate-connect/index.js'; -export { WsProvider } from './ws/index.js'; diff --git a/packages/rpc-provider/src/coder/decodeResponse.spec.ts b/packages/rpc-provider/src/coder/decodeResponse.spec.ts deleted file mode 100644 index 293fd641..00000000 --- a/packages/rpc-provider/src/coder/decodeResponse.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { JsonRpcResponse } from '../types.js'; - -import { RpcCoder } from './index.js'; - -describe('decodeResponse', (): void => { - let coder: RpcCoder; - - beforeEach((): void => { - coder = new RpcCoder(); - }); - - it('expects a non-empty input object', (): void => { - expect( - () => coder.decodeResponse(undefined as unknown as JsonRpcResponse) - ).toThrow(/Invalid jsonrpc/); - }); - - it('expects a valid jsonrpc field', (): void => { - expect( - () => coder.decodeResponse({} as JsonRpcResponse) - ).toThrow(/Invalid jsonrpc/); - }); - - it('expects a valid id field', (): void => { - expect( - () => coder.decodeResponse({ jsonrpc: '2.0' } as JsonRpcResponse) - ).toThrow(/Invalid id/); - }); - - it('expects a valid result field', (): void => { - expect( - () => coder.decodeResponse({ id: 1, jsonrpc: '2.0' } as JsonRpcResponse) - ).toThrow(/No result/); - }); - - it('throws any error found', (): void => { - expect( - () => coder.decodeResponse({ error: { code: 123, message: 'test error' }, id: 1, jsonrpc: '2.0' } as JsonRpcResponse) - ).toThrow(/123: test error/); - }); - - it('throws any error found, with data', (): void => { - expect( - () => coder.decodeResponse({ error: { code: 123, data: 'Error("Some random error description")', message: 'test error' }, id: 1, jsonrpc: '2.0' } as JsonRpcResponse) - ).toThrow(/123: test error: Some random error description/); - }); - - it('allows for number subscription ids', (): void => { - expect( - coder.decodeResponse({ id: 1, jsonrpc: '2.0', method: 'test', params: { result: 'test result', subscription: 1 } } as JsonRpcResponse) - ).toEqual('test result'); - }); - - it('allows for string subscription ids', (): void => { - expect( - coder.decodeResponse({ id: 1, jsonrpc: '2.0', method: 'test', params: { result: 'test result', subscription: 'abc' } } as JsonRpcResponse) - ).toEqual('test result'); - }); - - it('returns the result', (): void => { - expect( - coder.decodeResponse({ id: 1, jsonrpc: '2.0', result: 'some result' } as JsonRpcResponse) - ).toEqual('some result'); - }); -}); diff --git a/packages/rpc-provider/src/coder/encodeJson.spec.ts b/packages/rpc-provider/src/coder/encodeJson.spec.ts deleted file mode 100644 index 5e166e8b..00000000 --- a/packages/rpc-provider/src/coder/encodeJson.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { RpcCoder } from './index.js'; - -describe('encodeJson', (): void => { - let coder: RpcCoder; - - beforeEach((): void => { - coder = new RpcCoder(); - }); - - it('encodes a valid JsonRPC JSON string', (): void => { - expect( - coder.encodeJson('method', ['params']) - ).toEqual([1, '{"id":1,"jsonrpc":"2.0","method":"method","params":["params"]}']); - }); -}); diff --git a/packages/rpc-provider/src/coder/encodeObject.spec.ts b/packages/rpc-provider/src/coder/encodeObject.spec.ts deleted file mode 100644 index 841a3257..00000000 --- a/packages/rpc-provider/src/coder/encodeObject.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { RpcCoder } from './index.js'; - -describe('encodeObject', (): void => { - let coder: RpcCoder; - - beforeEach((): void => { - coder = new RpcCoder(); - }); - - it('encodes a valid JsonRPC object', (): void => { - expect( - coder.encodeObject('method', ['a', 'b']) - ).toEqual([1, { - id: 1, - jsonrpc: '2.0', - method: 'method', - params: ['a', 'b'] - }]); - }); -}); diff --git a/packages/rpc-provider/src/coder/error.spec.ts b/packages/rpc-provider/src/coder/error.spec.ts deleted file mode 100644 index 89ac16c2..00000000 --- a/packages/rpc-provider/src/coder/error.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { isError } from '@polkadot/util'; - -import RpcError from './error.js'; - -describe('RpcError', (): void => { - describe('constructor', (): void => { - it('constructs an Error that is still an Error', (): void => { - expect( - isError( - new RpcError() - ) - ).toEqual(true); - }); - }); - - describe('static', (): void => { - it('exposes the .CODES as a static', (): void => { - expect( - Object.keys(RpcError.CODES) - ).not.toEqual(0); - }); - }); - - describe('constructor properties', (): void => { - it('sets the .message property', (): void => { - expect( - new RpcError('test message').message - ).toEqual('test message'); - }); - - it("sets the .message to '' when not set", (): void => { - expect( - new RpcError().message - ).toEqual(''); - }); - - it('sets the .code property', (): void => { - expect( - new RpcError('test message', 1234).code - ).toEqual(1234); - }); - - it('sets the .code to UKNOWN when not set', (): void => { - expect( - new RpcError('test message').code - ).toEqual(RpcError.CODES.UNKNOWN); - }); - - it('sets the .data property', (): void => { - const data = 'here'; - - expect( - new RpcError('test message', 1234, data).data - ).toEqual(data); - }); - - it('sets the .data property to generic value', (): void => { - const data = { custom: 'value' } as const; - - expect( - new RpcError('test message', 1234, data).data - ).toEqual(data); - }); - }); - - describe('stack traces', (): void => { - // eslint-disable-next-line @typescript-eslint/ban-types - let captureStackTrace: (targetObject: Record, constructorOpt?: Function | undefined) => void; - - beforeEach((): void => { - captureStackTrace = Error.captureStackTrace; - - Error.captureStackTrace = function (error): void { - Object.defineProperty(error, 'stack', { - configurable: true, - get: function getStack (): string { - const value = 'some stack returned'; - - Object.defineProperty(this, 'stack', { value }); - - return value; - } - }); - }; - }); - - afterEach((): void => { - Error.captureStackTrace = captureStackTrace; - }); - - it('captures via captureStackTrace when available', (): void => { - expect( - new RpcError().stack - ).toEqual('some stack returned'); - }); - - it('captures via stack when captureStackTrace not available', (): void => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - Error.captureStackTrace = null as any; - - expect( - new RpcError().stack.length - ).not.toEqual(0); - }); - }); -}); diff --git a/packages/rpc-provider/src/coder/error.ts b/packages/rpc-provider/src/coder/error.ts deleted file mode 100644 index 908d9ead..00000000 --- a/packages/rpc-provider/src/coder/error.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { RpcErrorInterface } from '../types.js'; - -import { isFunction } from '@polkadot/util'; - -const UNKNOWN = -99999; - -function extend> (that: RpcError, name: K, value: RpcError[K]): void { - Object.defineProperty(that, name, { - configurable: true, - enumerable: false, - value - }); -} - -/** - * @name RpcError - * @summary Extension to the basic JS Error. - * @description - * The built-in JavaScript Error class is extended by adding a code to allow for Error categorization. In addition to the normal `stack`, `message`, the numeric `code` and `data` (any types) parameters are available on the object. - * @example - *
- * - * ```javascript - * const { RpcError } from '@polkadot/util'); - * - * throw new RpcError('some message', RpcError.CODES.METHOD_NOT_FOUND); // => error.code = -32601 - * ``` - */ -export default class RpcError extends Error implements RpcErrorInterface { - public code!: number; - - public data?: T; - - public override message!: string; - - public override name!: string; - - public override stack!: string; - - public constructor (message = '', code: number = UNKNOWN, data?: T) { - super(); - - extend(this, 'message', String(message)); - extend(this, 'name', this.constructor.name); - extend(this, 'data', data); - extend(this, 'code', code); - - if (isFunction(Error.captureStackTrace)) { - Error.captureStackTrace(this, this.constructor); - } else { - const { stack } = new Error(message); - - stack && extend(this, 'stack', stack); - } - } - - public static CODES = { - ASSERT: -90009, - INVALID_JSONRPC: -99998, - METHOD_NOT_FOUND: -32601, // Rust client - UNKNOWN - }; -} diff --git a/packages/rpc-provider/src/coder/index.ts b/packages/rpc-provider/src/coder/index.ts deleted file mode 100644 index 120e1a17..00000000 --- a/packages/rpc-provider/src/coder/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { JsonRpcRequest, JsonRpcResponse, JsonRpcResponseBaseError } from '../types.js'; - -import { isNumber, isString, isUndefined, stringify } from '@polkadot/util'; - -import RpcError from './error.js'; - -function formatErrorData (data?: string | number): string { - if (isUndefined(data)) { - return ''; - } - - const formatted = `: ${isString(data) - ? data.replace(/Error\("/g, '').replace(/\("/g, '(').replace(/"\)/g, ')').replace(/\(/g, ', ').replace(/\)/g, '') - : stringify(data)}`; - - // We need some sort of cut-off here since these can be very large and - // very nested, pick a number and trim the result display to it - return formatted.length <= 256 - ? formatted - : `${formatted.substring(0, 255)}…`; -} - -function checkError (error?: JsonRpcResponseBaseError): void { - if (error) { - const { code, data, message } = error; - - throw new RpcError(`${code}: ${message}${formatErrorData(data)}`, code, data); - } -} - -/** @internal */ -export class RpcCoder { - #id = 0; - - public decodeResponse (response?: JsonRpcResponse): T { - if (!response || response.jsonrpc !== '2.0') { - throw new Error('Invalid jsonrpc field in decoded object'); - } - - const isSubscription = !isUndefined(response.params) && !isUndefined(response.method); - - if ( - !isNumber(response.id) && - ( - !isSubscription || ( - !isNumber(response.params.subscription) && - !isString(response.params.subscription) - ) - ) - ) { - throw new Error('Invalid id field in decoded object'); - } - - checkError(response.error); - - if (response.result === undefined && !isSubscription) { - throw new Error('No result found in jsonrpc response'); - } - - if (isSubscription) { - checkError(response.params.error); - - return response.params.result; - } - - return response.result; - } - - public encodeJson (method: string, params: unknown[]): [number, string] { - const [id, data] = this.encodeObject(method, params); - - return [id, stringify(data)]; - } - - public encodeObject (method: string, params: unknown[]): [number, JsonRpcRequest] { - const id = ++this.#id; - - return [id, { - id, - jsonrpc: '2.0', - method, - params - }]; - } -} diff --git a/packages/rpc-provider/src/defaults.ts b/packages/rpc-provider/src/defaults.ts deleted file mode 100644 index 55d19f21..00000000 --- a/packages/rpc-provider/src/defaults.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -const HTTP_URL = 'http://127.0.0.1:9933'; -const WS_URL = 'ws://127.0.0.1:9944'; - -export default { - HTTP_URL, - WS_URL -}; diff --git a/packages/rpc-provider/src/index.ts b/packages/rpc-provider/src/index.ts deleted file mode 100644 index e88d86ba..00000000 --- a/packages/rpc-provider/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import './packageDetect.js'; - -export * from './bundle.js'; diff --git a/packages/rpc-provider/src/lru.spec.ts b/packages/rpc-provider/src/lru.spec.ts deleted file mode 100644 index 0079eba6..00000000 --- a/packages/rpc-provider/src/lru.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { LRUCache } from './lru.js'; - -describe('LRUCache', (): void => { - it('allows getting of items below capacity', (): void => { - const keys = ['1', '2', '3', '4']; - const lru = new LRUCache(4); - - keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); - - expect(lru.keys().join(', ')).toEqual(keys.reverse().join(', ')); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - keys.forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); - }); - - it('drops items when at capacity', (): void => { - const keys = ['1', '2', '3', '4', '5', '6']; - const lru = new LRUCache(4); - - keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); - - expect(lru.keys().join(', ')).toEqual(keys.slice(2).reverse().join(', ')); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - keys.slice(2).forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); - }); - - it('adjusts the order as they are used', (): void => { - const keys = ['1', '2', '3', '4', '5']; - const lru = new LRUCache(4); - - keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); - - expect(lru.entries()).toEqual([['5', '555'], ['4', '444'], ['3', '333'], ['2', '222']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - lru.get('3'); - - expect(lru.entries()).toEqual([['3', '333'], ['5', '555'], ['4', '444'], ['2', '222']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - lru.set('4', '4433'); - - expect(lru.entries()).toEqual([['4', '4433'], ['3', '333'], ['5', '555'], ['2', '222']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - lru.set('6', '666'); - - expect(lru.entries()).toEqual([['6', '666'], ['4', '4433'], ['3', '333'], ['5', '555']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - }); -}); diff --git a/packages/rpc-provider/src/lru.ts b/packages/rpc-provider/src/lru.ts deleted file mode 100644 index b9afa882..00000000 --- a/packages/rpc-provider/src/lru.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -// Assuming all 1.5MB responses, we apply a default allowing for 192MB -// cache space (depending on the historic queries this would vary, metadata -// for Kusama/Polkadot/Substrate falls between 600-750K, 2x for estimate) -export const DEFAULT_CAPACITY = 128; - -class LRUNode { - readonly key: string; - - public next: LRUNode; - public prev: LRUNode; - - constructor (key: string) { - this.key = key; - this.next = this.prev = this; - } -} - -// https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU -export class LRUCache { - readonly capacity: number; - - readonly #data = new Map(); - readonly #refs = new Map(); - - #length = 0; - #head: LRUNode; - #tail: LRUNode; - - constructor (capacity = DEFAULT_CAPACITY) { - this.capacity = capacity; - this.#head = this.#tail = new LRUNode(''); - } - - get length (): number { - return this.#length; - } - - get lengthData (): number { - return this.#data.size; - } - - get lengthRefs (): number { - return this.#refs.size; - } - - entries (): [string, unknown][] { - const keys = this.keys(); - const count = keys.length; - const entries = new Array<[string, unknown]>(count); - - for (let i = 0; i < count; i++) { - const key = keys[i]; - - entries[i] = [key, this.#data.get(key)]; - } - - return entries; - } - - keys (): string[] { - const keys: string[] = []; - - if (this.#length) { - let curr = this.#head; - - while (curr !== this.#tail) { - keys.push(curr.key); - curr = curr.next; - } - - keys.push(curr.key); - } - - return keys; - } - - get (key: string): T | null { - const data = this.#data.get(key); - - if (data) { - this.#toHead(key); - - return data as T; - } - - return null; - } - - set (key: string, value: T): void { - if (this.#data.has(key)) { - this.#toHead(key); - } else { - const node = new LRUNode(key); - - this.#refs.set(node.key, node); - - if (this.length === 0) { - this.#head = this.#tail = node; - } else { - this.#head.prev = node; - node.next = this.#head; - this.#head = node; - } - - if (this.#length === this.capacity) { - this.#data.delete(this.#tail.key); - this.#refs.delete(this.#tail.key); - - this.#tail = this.#tail.prev; - this.#tail.next = this.#head; - } else { - this.#length += 1; - } - } - - this.#data.set(key, value); - } - - #toHead (key: string): void { - const ref = this.#refs.get(key); - - if (ref && ref !== this.#head) { - ref.prev.next = ref.next; - ref.next.prev = ref.prev; - ref.next = this.#head; - - this.#head.prev = ref; - this.#head = ref; - } - } -} diff --git a/packages/rpc-provider/src/mock/index.ts b/packages/rpc-provider/src/mock/index.ts deleted file mode 100644 index b688710f..00000000 --- a/packages/rpc-provider/src/mock/index.ts +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/* eslint-disable camelcase */ - -import type { Header } from '@polkadot/types/interfaces'; -import type { Codec, Registry } from '@polkadot/types/types'; -import type { ProviderInterface, ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '../types.js'; -import type { MockStateDb, MockStateSubscriptionCallback, MockStateSubscriptions } from './types.js'; - -import { EventEmitter } from 'eventemitter3'; - -import { createTestKeyring } from '@polkadot/keyring/testing'; -import { decorateStorage, Metadata } from '@polkadot/types'; -import jsonrpc from '@polkadot/types/interfaces/jsonrpc'; -import rpcHeader from '@polkadot/types-support/json/Header.004.json' assert { type: 'json' }; -import rpcSignedBlock from '@polkadot/types-support/json/SignedBlock.004.immortal.json' assert { type: 'json' }; -import rpcMetadata from '@polkadot/types-support/metadata/static-substrate'; -import { BN, bnToU8a, logger, u8aToHex } from '@polkadot/util'; -import { randomAsU8a } from '@polkadot/util-crypto'; - -const INTERVAL = 1000; -const SUBSCRIPTIONS: string[] = Array.prototype.concat.apply( - [], - Object.values(jsonrpc).map((section): string[] => - Object - .values(section) - .filter(({ isSubscription }) => isSubscription) - .map(({ jsonrpc }) => jsonrpc) - .concat('chain_subscribeNewHead') - ) -) as string[]; - -const keyring = createTestKeyring({ type: 'ed25519' }); -const l = logger('api-mock'); - -/** - * A mock provider mainly used for testing. - * @return {ProviderInterface} The mock provider - * @internal - */ -export class MockProvider implements ProviderInterface { - private db: MockStateDb = {}; - - private emitter = new EventEmitter(); - - private intervalId?: ReturnType | null; - - public isUpdating = true; - - private registry: Registry; - - private prevNumber = new BN(-1); - - private requests: Record unknown> = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - chain_getBlock: () => this.registry.createType('SignedBlock', rpcSignedBlock.result).toJSON(), - chain_getBlockHash: () => '0x1234000000000000000000000000000000000000000000000000000000000000', - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - chain_getFinalizedHead: () => this.registry.createType('Header', rpcHeader.result).hash, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - chain_getHeader: () => this.registry.createType('Header', rpcHeader.result).toJSON(), - rpc_methods: () => this.registry.createType('RpcMethods').toJSON(), - state_getKeys: () => [], - state_getKeysPaged: () => [], - state_getMetadata: () => rpcMetadata, - state_getRuntimeVersion: () => this.registry.createType('RuntimeVersion').toHex(), - state_getStorage: (storage: MockStateDb, [key]: string[]) => u8aToHex(storage[key]), - system_chain: () => 'mockChain', - system_health: () => ({}), - system_name: () => 'mockClient', - system_properties: () => ({ ss58Format: 42 }), - system_upgradedToTripleRefCount: () => this.registry.createType('bool', true), - system_version: () => '9.8.7', - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, sort-keys - dev_echo: (_, params: any) => params - }; - - public subscriptions: MockStateSubscriptions = SUBSCRIPTIONS.reduce((subs, name): MockStateSubscriptions => { - subs[name] = { - callbacks: {}, - lastValue: null - }; - - return subs; - }, ({} as MockStateSubscriptions)); - - private subscriptionId = 0; - - private subscriptionMap: Record = {}; - - constructor (registry: Registry) { - this.registry = registry; - - this.init(); - } - - public get hasSubscriptions (): boolean { - return !!true; - } - - public clone (): MockProvider { - throw new Error('Unimplemented'); - } - - public async connect (): Promise { - // noop - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async disconnect (): Promise { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - public get isClonable (): boolean { - return !!false; - } - - public get isConnected (): boolean { - return !!true; - } - - public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { - this.emitter.on(type, sub); - - return (): void => { - this.emitter.removeListener(type, sub); - }; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async send (method: string, params: unknown[]): Promise { - l.debug(() => ['send', method, params]); - - if (!this.requests[method]) { - throw new Error(`provider.send: Invalid method '${method}'`); - } - - return this.requests[method](this.db, params) as T; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async subscribe (_type: string, method: string, ...params: unknown[]): Promise { - l.debug(() => ['subscribe', method, params]); - - if (!this.subscriptions[method]) { - throw new Error(`provider.subscribe: Invalid method '${method}'`); - } - - const callback = params.pop() as MockStateSubscriptionCallback; - const id = ++this.subscriptionId; - - this.subscriptions[method].callbacks[id] = callback; - this.subscriptionMap[id] = method; - - if (this.subscriptions[method].lastValue !== null) { - callback(null, this.subscriptions[method].lastValue); - } - - return id; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async unsubscribe (_type: string, _method: string, id: number): Promise { - const sub = this.subscriptionMap[id]; - - l.debug(() => ['unsubscribe', id, sub]); - - if (!sub) { - throw new Error(`Unable to find subscription for ${id}`); - } - - delete this.subscriptionMap[id]; - delete this.subscriptions[sub].callbacks[id]; - - return true; - } - - private init (): void { - const emitEvents: ProviderInterfaceEmitted[] = ['connected', 'disconnected']; - let emitIndex = 0; - let newHead = this.makeBlockHeader(); - let counter = -1; - - const metadata = new Metadata(this.registry, rpcMetadata); - - this.registry.setMetadata(metadata); - - const query = decorateStorage(this.registry, metadata.asLatest, metadata.version); - - // Do something every 1 seconds - this.intervalId = setInterval((): void => { - if (!this.isUpdating) { - return; - } - - // create a new header (next block) - newHead = this.makeBlockHeader(); - - // increment the balances and nonce for each account - keyring.getPairs().forEach(({ publicKey }, index): void => { - this.setStateBn(query['system']['account'](publicKey), newHead.number.toBn().addn(index)); - }); - - // set the timestamp for the current block - this.setStateBn(query['timestamp']['now'](), Math.floor(Date.now() / 1000)); - this.updateSubs('chain_subscribeNewHead', newHead); - - // We emit connected/disconnected at intervals - if (++counter % 2 === 1) { - if (++emitIndex === emitEvents.length) { - emitIndex = 0; - } - - this.emitter.emit(emitEvents[emitIndex]); - } - }, INTERVAL); - } - - private makeBlockHeader (): Header { - const blockNumber = this.prevNumber.addn(1); - const header = this.registry.createType('Header', { - digest: { - logs: [] - }, - extrinsicsRoot: randomAsU8a(), - number: blockNumber, - parentHash: blockNumber.isZero() - ? new Uint8Array(32) - : bnToU8a(this.prevNumber, { bitLength: 256, isLe: false }), - stateRoot: bnToU8a(blockNumber, { bitLength: 256, isLe: false }) - }); - - this.prevNumber = blockNumber; - - return header as unknown as Header; - } - - private setStateBn (key: Uint8Array, value: BN | number): void { - this.db[u8aToHex(key)] = bnToU8a(value, { bitLength: 64, isLe: true }); - } - - private updateSubs (method: string, value: Codec): void { - this.subscriptions[method].lastValue = value; - - Object - .values(this.subscriptions[method].callbacks) - .forEach((cb): void => { - try { - cb(null, value.toJSON()); - } catch (error) { - l.error(`Error on '${method}' subscription`, error); - } - }); - } -} diff --git a/packages/rpc-provider/src/mock/mockHttp.ts b/packages/rpc-provider/src/mock/mockHttp.ts deleted file mode 100644 index 3335790f..00000000 --- a/packages/rpc-provider/src/mock/mockHttp.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { Mock } from './types.js'; - -import nock from 'nock'; - -interface Request { - code?: number; - method: string; - reply?: Record; -} - -interface HttpMock extends Mock { - post: (uri: string) => { - reply: (code: number, handler: (uri: string, body: { id: string }) => unknown) => HttpMock - } -} - -export const TEST_HTTP_URL = 'http://localhost:9944'; - -export function mockHttp (requests: Request[]): Mock { - nock.cleanAll(); - - return requests.reduce((scope: HttpMock, request: Request) => - scope - .post('/') - .reply(request.code || 200, (_uri: string, body: { id: string }) => { - scope.body = scope.body || {}; - scope.body[request.method] = body; - - return Object.assign({ id: body.id, jsonrpc: '2.0' }, request.reply || {}) as unknown; - }), - nock(TEST_HTTP_URL) as unknown as HttpMock); -} diff --git a/packages/rpc-provider/src/mock/mockWs.ts b/packages/rpc-provider/src/mock/mockWs.ts deleted file mode 100644 index a5c51793..00000000 --- a/packages/rpc-provider/src/mock/mockWs.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import { Server, WebSocket } from 'mock-socket'; - -import { stringify } from '@polkadot/util'; - -interface Scope { - body: Record>; - requests: number; - server: Server; - done: any; -} - -interface ErrorDef { - id: number; - error: { - code: number; - message: string; - }; -} - -interface ReplyDef { - id: number; - reply: { - result: unknown; - }; -} - -interface RpcBase { - id: number; - jsonrpc: '2.0'; -} - -type RpcError = RpcBase & ErrorDef; -type RpcReply = RpcBase & { result: unknown }; - -export type Request = { method: string } & (ErrorDef | ReplyDef); - -global.WebSocket = WebSocket as typeof global.WebSocket; - -export const TEST_WS_URL = 'ws://localhost:9955'; - -// should be JSONRPC def return -function createError ({ error: { code, message }, id }: ErrorDef): RpcError { - return { - error: { - code, - message - }, - id, - jsonrpc: '2.0' - }; -} - -// should be JSONRPC def return -function createReply ({ id, reply: { result } }: ReplyDef): RpcReply { - return { - id, - jsonrpc: '2.0', - result - }; -} - -// scope definition returned -export function mockWs (requests: Request[], wsUrl: string = TEST_WS_URL): Scope { - const server = new Server(wsUrl); - - let requestCount = 0; - const scope: Scope = { - body: {}, - done: () => new Promise((resolve) => server.stop(resolve)), - requests: 0, - server - }; - - server.on('connection', (socket): void => { - socket.on('message', (body): void => { - const request = requests[requestCount]; - const response = (request as ErrorDef).error - ? createError(request as ErrorDef) - : createReply(request as ReplyDef); - - scope.body[request.method] = body as unknown as Record; - requestCount++; - - socket.send(stringify(response)); - }); - }); - - return scope; -} diff --git a/packages/rpc-provider/src/mock/on.spec.ts b/packages/rpc-provider/src/mock/on.spec.ts deleted file mode 100644 index 79f9bc43..00000000 --- a/packages/rpc-provider/src/mock/on.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { ProviderInterfaceEmitted } from '../types.js'; - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('on', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - - beforeEach((): void => { - mock = new MockProvider(registry); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - // eslint-disable-next-line jest/expect-expect - it('emits both connected and disconnected events', async (): Promise => { - const events: Record = { connected: false, disconnected: false }; - - await new Promise((resolve) => { - const handler = (type: ProviderInterfaceEmitted): void => { - mock.on(type, (): void => { - events[type] = true; - - if (Object.values(events).filter((value): boolean => value).length === 2) { - resolve(true); - } - }); - }; - - handler('connected'); - handler('disconnected'); - }); - }); -}); diff --git a/packages/rpc-provider/src/mock/send.spec.ts b/packages/rpc-provider/src/mock/send.spec.ts deleted file mode 100644 index 164d93cb..00000000 --- a/packages/rpc-provider/src/mock/send.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('send', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - - beforeEach((): void => { - mock = new MockProvider(registry); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - it('fails on non-supported methods', (): Promise => { - return mock - .send('something_invalid', []) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Invalid method/); - }); - }); - - it('returns values for mocked requests', (): Promise => { - return mock - .send('system_name', []) - .then((result): void => { - expect(result).toBe('mockClient'); - }); - }); -}); diff --git a/packages/rpc-provider/src/mock/subscribe.spec.ts b/packages/rpc-provider/src/mock/subscribe.spec.ts deleted file mode 100644 index 50bfce2b..00000000 --- a/packages/rpc-provider/src/mock/subscribe.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('subscribe', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - - beforeEach((): void => { - mock = new MockProvider(registry); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - it('fails on unknown methods', async (): Promise => { - await mock - .subscribe('test', 'test_notFound') - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Invalid method 'test_notFound'/); - }); - }); - - it('returns a subscription id', async (): Promise => { - await mock - .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) - .then((id): void => { - expect(id).toEqual(1); - }); - }); - - it('calls back with the last known value', async (): Promise => { - mock.isUpdating = false; - mock.subscriptions.chain_subscribeNewHead.lastValue = 'testValue'; - - await new Promise((resolve) => { - mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, value: string): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect(value).toEqual('testValue'); - resolve(true); - }).catch(console.error); - }); - }); - - // eslint-disable-next-line jest/expect-expect - it('calls back with new headers', async (): Promise => { - await new Promise((resolve) => { - mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { - if (header.number === 4) { - resolve(true); - } - }).catch(console.error); - }); - }); - - // eslint-disable-next-line jest/expect-expect - it('handles errors within callbacks gracefully', async (): Promise => { - let hasThrown = false; - - await new Promise((resolve) => { - mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { - if (!hasThrown) { - hasThrown = true; - - throw new Error('testing'); - } - - if (header.number === 3) { - resolve(true); - } - }).catch(console.error); - }); - }); -}); diff --git a/packages/rpc-provider/src/mock/types.ts b/packages/rpc-provider/src/mock/types.ts deleted file mode 100644 index 0a7fbc3a..00000000 --- a/packages/rpc-provider/src/mock/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { Server } from 'mock-socket'; - -export type Global = typeof globalThis & { - WebSocket: typeof WebSocket; - fetch: any; -} - -export interface Mock { - body: Record>; - requests: number; - server: Server; - done: () => Promise; -} - -export type MockStateSubscriptionCallback = (error: Error | null, value: any) => void; - -export interface MockStateSubscription { - callbacks: Record; - lastValue: any; -} - -export interface MockStateSubscriptions { - // known - chain_subscribeNewHead: MockStateSubscription; - state_subscribeStorage: MockStateSubscription; - - // others - [key: string]: MockStateSubscription; -} - -export type MockStateDb = Record; - -export type MockStateRequests = Record string>; diff --git a/packages/rpc-provider/src/mock/unsubscribe.spec.ts b/packages/rpc-provider/src/mock/unsubscribe.spec.ts deleted file mode 100644 index 35a9cf2a..00000000 --- a/packages/rpc-provider/src/mock/unsubscribe.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('unsubscribe', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - let id: number; - - beforeEach((): Promise => { - mock = new MockProvider(registry); - - return mock - .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) - .then((_id): void => { - id = _id; - }); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - it('fails on unknown ids', async (): Promise => { - await mock - .unsubscribe('chain_newHead', 'chain_subscribeNewHead', 5) - .catch((error): boolean => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Unable to find/); - - return false; - }); - }); - - // eslint-disable-next-line jest/expect-expect - it('unsubscribes successfully', async (): Promise => { - await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id); - }); - - it('fails on double unsubscribe', async (): Promise => { - await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) - .then((): Promise => - mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) - ) - .catch((error): boolean => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Unable to find/); - - return false; - }); - }); -}); diff --git a/packages/rpc-provider/src/mod.ts b/packages/rpc-provider/src/mod.ts deleted file mode 100644 index aa7b729d..00000000 --- a/packages/rpc-provider/src/mod.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -export * from './index.js'; diff --git a/packages/rpc-provider/src/packageDetect.ts b/packages/rpc-provider/src/packageDetect.ts deleted file mode 100644 index 5c2b7116..00000000 --- a/packages/rpc-provider/src/packageDetect.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -// Do not edit, auto-generated by @polkadot/dev -// (packageInfo imports will be kept as-is, user-editable) - -import { packageInfo as typesInfo } from '@polkadot/types/packageInfo'; -import { detectPackage } from '@polkadot/util'; - -import { packageInfo } from './packageInfo.js'; - -detectPackage(packageInfo, null, [typesInfo]); diff --git a/packages/rpc-provider/src/types.ts b/packages/rpc-provider/src/types.ts deleted file mode 100644 index ad030ec4..00000000 --- a/packages/rpc-provider/src/types.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -export interface JsonRpcObject { - id: number; - jsonrpc: '2.0'; -} - -export interface JsonRpcRequest extends JsonRpcObject { - method: string; - params: unknown[]; -} - -export interface JsonRpcResponseBaseError { - code: number; - data?: number | string; - message: string; -} - -export interface RpcErrorInterface { - code: number; - data?: T; - message: string; - stack: string; -} - -interface JsonRpcResponseSingle { - error?: JsonRpcResponseBaseError; - result: T; -} - -interface JsonRpcResponseSubscription { - method?: string; - params: { - error?: JsonRpcResponseBaseError; - result: T; - subscription: number | string; - }; -} - -export type JsonRpcResponseBase = JsonRpcResponseSingle & JsonRpcResponseSubscription; - -export type JsonRpcResponse = JsonRpcObject & JsonRpcResponseBase; - -export type ProviderInterfaceCallback = (error: Error | null, result: any) => void; - -export type ProviderInterfaceEmitted = 'connected' | 'disconnected' | 'error'; - -export type ProviderInterfaceEmitCb = (value?: any) => any; - -export interface ProviderInterface { - /** true if the provider supports subscriptions (not available for HTTP) */ - readonly hasSubscriptions: boolean; - /** true if the clone() functionality is available on the provider */ - readonly isClonable: boolean; - /** true if the provider is currently connected (ws/sc has connection logic) */ - readonly isConnected: boolean; - /** (optional) stats for the provider with connections/bytes */ - readonly stats?: ProviderStats; - - clone (): ProviderInterface; - connect (): Promise; - disconnect (): Promise; - on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void; - send (method: string, params: unknown[], isCacheable?: boolean): Promise; - subscribe (type: string, method: string, params: unknown[], cb: ProviderInterfaceCallback): Promise; - unsubscribe (type: string, method: string, id: number | string): Promise; -} - -/** Stats for a specific endpoint */ -export interface EndpointStats { - /** The total number of bytes sent */ - bytesRecv: number; - /** The total number of bytes received */ - bytesSent: number; - /** The number of cached/in-progress requests made */ - cached: number; - /** The number of errors found */ - errors: number; - /** The number of requests */ - requests: number; - /** The number of subscriptions */ - subscriptions: number; - /** The number of request timeouts */ - timeout: number; -} - -/** Overall stats for the provider */ -export interface ProviderStats { - /** Details for the active/open requests */ - active: { - /** Number of active requests */ - requests: number; - /** Number of active subscriptions */ - subscriptions: number; - }; - /** The total requests that have been made */ - total: EndpointStats; -} diff --git a/packages/rpc-provider/src/ws/connect.spec.ts b/packages/rpc-provider/src/ws/connect.spec.ts deleted file mode 100644 index 50a857f2..00000000 --- a/packages/rpc-provider/src/ws/connect.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Mock } from '../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -const TEST_WS_URL = 'ws://localhost-connect.spec.ts:9988'; - -function sleep (ms = 100): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe('onConnect', (): void => { - let mocks: Mock[]; - let provider: WsProvider | null; - - beforeEach((): void => { - mocks = [mockWs([], TEST_WS_URL)]; - }); - - afterEach(async () => { - if (provider) { - await provider.disconnect(); - await sleep(); - - provider = null; - } - - await Promise.all(mocks.map((m) => m.done())); - await sleep(); - }); - - it('Does not connect when autoConnect is false', async () => { - provider = new WsProvider(TEST_WS_URL, 0); - - await sleep(); - - expect(provider.isConnected).toBe(false); - - await provider.connect(); - await sleep(); - - expect(provider.isConnected).toBe(true); - - await provider.disconnect(); - await sleep(); - - expect(provider.isConnected).toBe(false); - }); - - it('Does connect when autoConnect is true', async () => { - provider = new WsProvider(TEST_WS_URL, 1); - - await sleep(); - - expect(provider.isConnected).toBe(true); - }); - - it('Creates a new WebSocket instance by calling the connect() method', async () => { - provider = new WsProvider(TEST_WS_URL, false); - - expect(provider.isConnected).toBe(false); - expect(mocks[0].server.clients().length).toBe(0); - - await provider.connect(); - await sleep(); - - expect(provider.isConnected).toBe(true); - expect(mocks[0].server.clients()).toHaveLength(1); - }); - - it('Connects to first endpoint when an array is given', async () => { - provider = new WsProvider([TEST_WS_URL], 1); - - await sleep(); - - expect(provider.isConnected).toBe(true); - expect(mocks[0].server.clients()).toHaveLength(1); - }); - - it('Does not allow connect() on already-connected', async () => { - provider = new WsProvider([TEST_WS_URL], 1); - - await sleep(); - - expect(provider.isConnected).toBe(true); - - await expect( - provider.connect() - ).rejects.toThrow(/already connected/); - }); - - it('Connects to the second endpoint when the first is unreachable', async () => { - const endpoints: string[] = ['ws://localhost-unreachable-connect.spec.ts:9956', TEST_WS_URL]; - - provider = new WsProvider(endpoints, 1); - - await sleep(); - - expect(mocks[0].server.clients()).toHaveLength(1); - expect(provider.isConnected).toBe(true); - }); - - it('Connects to the second endpoint when the first is dropped', async () => { - const endpoints: string[] = [TEST_WS_URL, 'ws://localhost-connect.spec.ts:9957']; - - mocks.push(mockWs([], endpoints[1])); - - provider = new WsProvider(endpoints, 1); - - await sleep(); - - // Check that first server is connected - expect(mocks[0].server.clients()).toHaveLength(1); - expect(mocks[1].server.clients()).toHaveLength(0); - - // Close connection from first server - mocks[0].server.clients()[0].close(); - - await sleep(); - - // Check that second server is connected - expect(mocks[1].server.clients()).toHaveLength(1); - expect(provider.isConnected).toBe(true); - }); - - it('Round-robin of endpoints on WsProvider', async () => { - const endpoints: string[] = [ - TEST_WS_URL, - 'ws://localhost-connect.spec.ts:9956', - 'ws://localhost-connect.spec.ts:9957', - 'ws://invalid-connect.spec.ts:9956', - 'ws://localhost-connect.spec.ts:9958' - ]; - - mocks.push(mockWs([], endpoints[1])); - mocks.push(mockWs([], endpoints[2])); - mocks.push(mockWs([], endpoints[4])); - - const mockNext = [ - mocks[1], - mocks[2], - mocks[3], - mocks[0] - ]; - - provider = new WsProvider(endpoints, 1); - - for (let round = 0; round < 2; round++) { - for (let mock = 0; mock < mocks.length; mock++) { - await sleep(); - - // Wwe are connected, the current mock has the connection and the next doesn't - expect(provider.isConnected).toBe(true); - expect(mocks[mock].server.clients()).toHaveLength(1); - expect(mockNext[mock].server.clients()).toHaveLength(0); - - // Close connection from first server - mocks[mock].server.clients()[0].close(); - } - } - }); -}); diff --git a/packages/rpc-provider/src/ws/errors.ts b/packages/rpc-provider/src/ws/errors.ts deleted file mode 100644 index ad5ff6cd..00000000 --- a/packages/rpc-provider/src/ws/errors.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -// from https://stackoverflow.com/questions/19304157/getting-the-reason-why-websockets-closed-with-close-code-1006 - -const known: Record = { - 1000: 'Normal Closure', - 1001: 'Going Away', - 1002: 'Protocol Error', - 1003: 'Unsupported Data', - 1004: '(For future)', - 1005: 'No Status Received', - 1006: 'Abnormal Closure', - 1007: 'Invalid frame payload data', - 1008: 'Policy Violation', - 1009: 'Message too big', - 1010: 'Missing Extension', - 1011: 'Internal Error', - 1012: 'Service Restart', - 1013: 'Try Again Later', - 1014: 'Bad Gateway', - 1015: 'TLS Handshake' -}; - -export function getWSErrorString (code: number): string { - if (code >= 0 && code <= 999) { - return '(Unused)'; - } else if (code >= 1016) { - if (code <= 1999) { - return '(For WebSocket standard)'; - } else if (code <= 2999) { - return '(For WebSocket extensions)'; - } else if (code <= 3999) { - return '(For libraries and frameworks)'; - } else if (code <= 4999) { - return '(For applications)'; - } - } - - return known[code] || '(Unknown)'; -} diff --git a/packages/rpc-provider/src/ws/index.spec.ts b/packages/rpc-provider/src/ws/index.spec.ts deleted file mode 100644 index 6b5e3905..00000000 --- a/packages/rpc-provider/src/ws/index.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Mock } from '../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -const TEST_WS_URL = 'ws://localhost-index.spec.ts:9977'; - -let provider: WsProvider | null; -let mock: Mock; - -function createWs (requests: Request[], autoConnect = 1000, headers?: Record, timeout?: number): WsProvider { - mock = mockWs(requests, TEST_WS_URL); - provider = new WsProvider(TEST_WS_URL, autoConnect, headers, timeout); - - return provider; -} - -describe('Ws', (): void => { - afterEach(async () => { - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('returns the connected state', (): void => { - expect( - createWs([]).isConnected - ).toEqual(false); - }); - - // eslint-disable-next-line jest/expect-expect - it('allows you to initialize the provider with custom headers', () => { - createWs([], 100, { foo: 'bar' }); - }); - - // eslint-disable-next-line jest/expect-expect - it('allows you to set custom timeout value for handlers', () => { - const CUSTOM_TIMEOUT_S = 90; - const CUSTOM_TIMEOUT_MS = CUSTOM_TIMEOUT_S * 1000; - - createWs([], 100, { foo: 'bar' }, CUSTOM_TIMEOUT_MS); - }); -}); - -describe('Endpoint Parsing', (): void => { - // eslint-disable-next-line jest/expect-expect - it('Succeeds when WsProvider endpoint is a valid string', () => { - /* eslint-disable no-new */ - new WsProvider(TEST_WS_URL, 0); - }); - - it('Throws when WsProvider endpoint is an invalid string', () => { - expect( - () => new WsProvider('http://127.0.0.1:9955', 0) - ).toThrow(/^Endpoint should start with /); - }); - - // eslint-disable-next-line jest/expect-expect - it('Succeeds when WsProvider endpoint is a valid array', () => { - const endpoints: string[] = ['ws://127.0.0.1:9955', 'wss://testnet.io:9944', 'ws://mychain.com:9933']; - - /* eslint-disable no-new */ - new WsProvider(endpoints, 0); - }); - - it('Throws when WsProvider endpoint is an empty array', () => { - const endpoints: string[] = []; - - expect( - () => new WsProvider(endpoints, 0) - ).toThrow('WsProvider requires at least one Endpoint'); - }); - - it('Throws when WsProvider endpoint is an invalid array', () => { - const endpoints: string[] = ['ws://127.0.0.1:9955', 'http://bad.co:9944', 'ws://mychain.com:9933']; - - expect( - () => new WsProvider(endpoints, 0) - ).toThrow(/^Endpoint should start with /); - }); -}); diff --git a/packages/rpc-provider/src/ws/index.ts b/packages/rpc-provider/src/ws/index.ts deleted file mode 100644 index 1cf65fee..00000000 --- a/packages/rpc-provider/src/ws/index.ts +++ /dev/null @@ -1,626 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { Class } from '@polkadot/util/types'; -import type { EndpointStats, JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; - -import { EventEmitter } from 'eventemitter3'; - -import { isChildClass, isNull, isUndefined, logger, noop, objectSpread, stringify } from '@polkadot/util'; -import { xglobal } from '@polkadot/x-global'; -import { WebSocket } from '@polkadot/x-ws'; - -import { RpcCoder } from '../coder/index.js'; -import defaults from '../defaults.js'; -import { DEFAULT_CAPACITY, LRUCache } from '../lru.js'; -import { getWSErrorString } from './errors.js'; - -interface SubscriptionHandler { - callback: ProviderInterfaceCallback; - type: string; -} - -interface WsStateAwaiting { - callback: ProviderInterfaceCallback; - method: string; - params: unknown[]; - start: number; - subscription?: SubscriptionHandler | undefined; -} - -interface WsStateSubscription extends SubscriptionHandler { - method: string; - params: unknown[]; -} - -const ALIASES: Record = { - chain_finalisedHead: 'chain_finalizedHead', - chain_subscribeFinalisedHeads: 'chain_subscribeFinalizedHeads', - chain_unsubscribeFinalisedHeads: 'chain_unsubscribeFinalizedHeads' -}; - -const RETRY_DELAY = 2_500; - -const DEFAULT_TIMEOUT_MS = 60 * 1000; -const TIMEOUT_INTERVAL = 5_000; - -const l = logger('api-ws'); - -/** @internal Clears a Record<*> of all keys, optionally with all callback on clear */ -function eraseRecord (record: Record, cb?: (item: T) => void): void { - Object.keys(record).forEach((key): void => { - if (cb) { - cb(record[key]); - } - - delete record[key]; - }); -} - -/** @internal Creates a default/empty stats object */ -function defaultEndpointStats (): EndpointStats { - return { bytesRecv: 0, bytesSent: 0, cached: 0, errors: 0, requests: 0, subscriptions: 0, timeout: 0 }; -} - -/** - * # @polkadot/rpc-provider/ws - * - * @name WsProvider - * - * @description The WebSocket Provider allows sending requests using WebSocket to a WebSocket RPC server TCP port. Unlike the [[HttpProvider]], it does support subscriptions and allows listening to events such as new blocks or balance changes. - * - * @example - *
- * - * ```javascript - * import Api from '@polkadot/api/promise'; - * import { WsProvider } from '@polkadot/rpc-provider/ws'; - * - * const provider = new WsProvider('ws://127.0.0.1:9944'); - * const api = new Api(provider); - * ``` - * - * @see [[HttpProvider]] - */ -export class WsProvider implements ProviderInterface { - readonly #callCache: LRUCache; - readonly #coder: RpcCoder; - readonly #endpoints: string[]; - readonly #headers: Record; - readonly #eventemitter: EventEmitter; - readonly #handlers: Record = {}; - readonly #isReadyPromise: Promise; - readonly #stats: ProviderStats; - readonly #waitingForId: Record> = {}; - - #autoConnectMs: number; - #endpointIndex: number; - #endpointStats: EndpointStats; - #isConnected = false; - #subscriptions: Record = {}; - #timeoutId?: ReturnType | null = null; - #websocket: WebSocket | null; - #timeout: number; - - /** - * @param {string | string[]} endpoint The endpoint url. Usually `ws://ip:9944` or `wss://ip:9944`, may provide an array of endpoint strings. - * @param {number | false} autoConnectMs Whether to connect automatically or not (default). Provided value is used as a delay between retries. - * @param {Record} headers The headers provided to the underlying WebSocket - * @param {number} [timeout] Custom timeout value used per request . Defaults to `DEFAULT_TIMEOUT_MS` - */ - constructor (endpoint: string | string[] = defaults.WS_URL, autoConnectMs: number | false = RETRY_DELAY, headers: Record = {}, timeout?: number, cacheCapacity?: number) { - const endpoints = Array.isArray(endpoint) - ? endpoint - : [endpoint]; - - if (endpoints.length === 0) { - throw new Error('WsProvider requires at least one Endpoint'); - } - - endpoints.forEach((endpoint) => { - if (!/^(wss|ws):\/\//.test(endpoint)) { - throw new Error(`Endpoint should start with 'ws://', received '${endpoint}'`); - } - }); - this.#callCache = new LRUCache(cacheCapacity || DEFAULT_CAPACITY); - this.#eventemitter = new EventEmitter(); - this.#autoConnectMs = autoConnectMs || 0; - this.#coder = new RpcCoder(); - this.#endpointIndex = -1; - this.#endpoints = endpoints; - this.#headers = headers; - this.#websocket = null; - this.#stats = { - active: { requests: 0, subscriptions: 0 }, - total: defaultEndpointStats() - }; - this.#endpointStats = defaultEndpointStats(); - this.#timeout = timeout || DEFAULT_TIMEOUT_MS; - - if (autoConnectMs && autoConnectMs > 0) { - this.connectWithRetry().catch(noop); - } - - this.#isReadyPromise = new Promise((resolve): void => { - this.#eventemitter.once('connected', (): void => { - resolve(this); - }); - }); - } - - /** - * @summary `true` when this provider supports subscriptions - */ - public get hasSubscriptions (): boolean { - return !!true; - } - - /** - * @summary `true` when this provider supports clone() - */ - public get isClonable (): boolean { - return !!true; - } - - /** - * @summary Whether the node is connected or not. - * @return {boolean} true if connected - */ - public get isConnected (): boolean { - return this.#isConnected; - } - - /** - * @description Promise that resolves the first time we are connected and loaded - */ - public get isReady (): Promise { - return this.#isReadyPromise; - } - - public get endpoint (): string { - return this.#endpoints[this.#endpointIndex]; - } - - /** - * @description Returns a clone of the object - */ - public clone (): WsProvider { - return new WsProvider(this.#endpoints); - } - - protected selectEndpointIndex (endpoints: string[]): number { - return (this.#endpointIndex + 1) % endpoints.length; - } - - /** - * @summary Manually connect - * @description The [[WsProvider]] connects automatically by default, however if you decided otherwise, you may - * connect manually using this method. - */ - // eslint-disable-next-line @typescript-eslint/require-await - public async connect (): Promise { - if (this.#websocket) { - throw new Error('WebSocket is already connected'); - } - - try { - this.#endpointIndex = this.selectEndpointIndex(this.#endpoints); - - // the as here is Deno-specific - not available on the globalThis - this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) - ? new WebSocket(this.endpoint) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - WS may be an instance of ws, which supports options - : new WebSocket(this.endpoint, undefined, { - headers: this.#headers - }); - - if (this.#websocket) { - this.#websocket.onclose = this.#onSocketClose; - this.#websocket.onerror = this.#onSocketError; - this.#websocket.onmessage = this.#onSocketMessage; - this.#websocket.onopen = this.#onSocketOpen; - } - - // timeout any handlers that have not had a response - this.#timeoutId = setInterval(() => this.#timeoutHandlers(), TIMEOUT_INTERVAL); - } catch (error) { - l.error(error); - - this.#emit('error', error); - - throw error; - } - } - - /** - * @description Connect, never throwing an error, but rather forcing a retry - */ - public async connectWithRetry (): Promise { - if (this.#autoConnectMs > 0) { - try { - await this.connect(); - } catch { - setTimeout((): void => { - this.connectWithRetry().catch(noop); - }, this.#autoConnectMs); - } - } - } - - /** - * @description Manually disconnect from the connection, clearing auto-connect logic - */ - // eslint-disable-next-line @typescript-eslint/require-await - public async disconnect (): Promise { - // switch off autoConnect, we are in manual mode now - this.#autoConnectMs = 0; - - try { - if (this.#websocket) { - // 1000 - Normal closure; the connection successfully completed - this.#websocket.close(1000); - } - } catch (error) { - l.error(error); - - this.#emit('error', error); - - throw error; - } - } - - /** - * @description Returns the connection stats - */ - public get stats (): ProviderStats { - return { - active: { - requests: Object.keys(this.#handlers).length, - subscriptions: Object.keys(this.#subscriptions).length - }, - total: this.#stats.total - }; - } - - public get endpointStats (): EndpointStats { - return this.#endpointStats; - } - - /** - * @summary Listens on events after having subscribed using the [[subscribe]] function. - * @param {ProviderInterfaceEmitted} type Event - * @param {ProviderInterfaceEmitCb} sub Callback - * @return unsubscribe function - */ - public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { - this.#eventemitter.on(type, sub); - - return (): void => { - this.#eventemitter.removeListener(type, sub); - }; - } - - /** - * @summary Send JSON data using WebSockets to configured HTTP Endpoint or queue. - * @param method The RPC methods to execute - * @param params Encoded parameters as applicable for the method - * @param subscription Subscription details (internally used) - */ - public send (method: string, params: unknown[], isCacheable?: boolean, subscription?: SubscriptionHandler): Promise { - this.#endpointStats.requests++; - this.#stats.total.requests++; - - const [id, body] = this.#coder.encodeJson(method, params); - const cacheKey = isCacheable ? `${method}::${stringify(params)}` : ''; - let resultPromise: Promise | null = isCacheable - ? this.#callCache.get(cacheKey) - : null; - - if (!resultPromise) { - resultPromise = this.#send(id, body, method, params, subscription); - - if (isCacheable) { - this.#callCache.set(cacheKey, resultPromise); - } - } else { - this.#endpointStats.cached++; - this.#stats.total.cached++; - } - - return resultPromise; - } - - async #send (id: number, body: string, method: string, params: unknown[], subscription?: SubscriptionHandler): Promise { - return new Promise((resolve, reject): void => { - try { - if (!this.isConnected || this.#websocket === null) { - throw new Error('WebSocket is not connected'); - } - - const callback = (error?: Error | null, result?: T): void => { - error - ? reject(error) - : resolve(result as T); - }; - - l.debug(() => ['calling', method, body]); - - this.#handlers[id] = { - callback, - method, - params, - start: Date.now(), - subscription - }; - - const bytesSent = body.length; - - this.#endpointStats.bytesSent += bytesSent; - this.#stats.total.bytesSent += bytesSent; - - this.#websocket.send(body); - } catch (error) { - this.#endpointStats.errors++; - this.#stats.total.errors++; - - reject(error); - } - }); - } - - /** - * @name subscribe - * @summary Allows subscribing to a specific event. - * - * @example - *
- * - * ```javascript - * const provider = new WsProvider('ws://127.0.0.1:9944'); - * const rpc = new Rpc(provider); - * - * rpc.state.subscribeStorage([[storage.system.account,
]], (_, values) => { - * console.log(values) - * }).then((subscriptionId) => { - * console.log('balance changes subscription id: ', subscriptionId) - * }) - * ``` - */ - public subscribe (type: string, method: string, params: unknown[], callback: ProviderInterfaceCallback): Promise { - this.#endpointStats.subscriptions++; - this.#stats.total.subscriptions++; - - // subscriptions are not cached, LRU applies to .at() only - return this.send(method, params, false, { callback, type }); - } - - /** - * @summary Allows unsubscribing to subscriptions made with [[subscribe]]. - */ - public async unsubscribe (type: string, method: string, id: number | string): Promise { - const subscription = `${type}::${id}`; - - // FIXME This now could happen with re-subscriptions. The issue is that with a re-sub - // the assigned id now does not match what the API user originally received. It has - // a slight complication in solving - since we cannot rely on the send id, but rather - // need to find the actual subscription id to map it - if (isUndefined(this.#subscriptions[subscription])) { - l.debug(() => `Unable to find active subscription=${subscription}`); - - return false; - } - - delete this.#subscriptions[subscription]; - - try { - return this.isConnected && !isNull(this.#websocket) - ? this.send(method, [id]) - : true; - } catch { - return false; - } - } - - #emit = (type: ProviderInterfaceEmitted, ...args: unknown[]): void => { - this.#eventemitter.emit(type, ...args); - }; - - #onSocketClose = (event: CloseEvent): void => { - const error = new Error(`disconnected from ${this.endpoint}: ${event.code}:: ${event.reason || getWSErrorString(event.code)}`); - - if (this.#autoConnectMs > 0) { - l.error(error.message); - } - - this.#isConnected = false; - - if (this.#websocket) { - this.#websocket.onclose = null; - this.#websocket.onerror = null; - this.#websocket.onmessage = null; - this.#websocket.onopen = null; - this.#websocket = null; - } - - if (this.#timeoutId) { - clearInterval(this.#timeoutId); - this.#timeoutId = null; - } - - // reject all hanging requests - eraseRecord(this.#handlers, (h) => { - try { - h.callback(error, undefined); - } catch (err) { - // does not throw - l.error(err); - } - }); - eraseRecord(this.#waitingForId); - - // Reset stats for active endpoint - this.#endpointStats = defaultEndpointStats(); - - this.#emit('disconnected'); - - if (this.#autoConnectMs > 0) { - setTimeout((): void => { - this.connectWithRetry().catch(noop); - }, this.#autoConnectMs); - } - }; - - #onSocketError = (error: Event): void => { - l.debug(() => ['socket error', error]); - this.#emit('error', error); - }; - - #onSocketMessage = (message: MessageEvent): void => { - l.debug(() => ['received', message.data]); - - const bytesRecv = message.data.length; - - this.#endpointStats.bytesRecv += bytesRecv; - this.#stats.total.bytesRecv += bytesRecv; - - const response = JSON.parse(message.data) as JsonRpcResponse; - - return isUndefined(response.method) - ? this.#onSocketMessageResult(response) - : this.#onSocketMessageSubscribe(response); - }; - - #onSocketMessageResult = (response: JsonRpcResponse): void => { - const handler = this.#handlers[response.id]; - - if (!handler) { - l.debug(() => `Unable to find handler for id=${response.id}`); - - return; - } - - try { - const { method, params, subscription } = handler; - const result = this.#coder.decodeResponse(response); - - // first send the result - in case of subs, we may have an update - // immediately if we have some queued results already - handler.callback(null, result); - - if (subscription) { - const subId = `${subscription.type}::${result}`; - - this.#subscriptions[subId] = objectSpread({}, subscription, { - method, - params - }); - - // if we have a result waiting for this subscription already - if (this.#waitingForId[subId]) { - this.#onSocketMessageSubscribe(this.#waitingForId[subId]); - } - } - } catch (error) { - this.#endpointStats.errors++; - this.#stats.total.errors++; - - handler.callback(error as Error, undefined); - } - - delete this.#handlers[response.id]; - }; - - #onSocketMessageSubscribe = (response: JsonRpcResponse): void => { - if (!response.method) { - throw new Error('No method found in JSONRPC response'); - } - - const method = ALIASES[response.method] || response.method; - const subId = `${method}::${response.params.subscription}`; - const handler = this.#subscriptions[subId]; - - if (!handler) { - // store the JSON, we could have out-of-order subid coming in - this.#waitingForId[subId] = response; - - l.debug(() => `Unable to find handler for subscription=${subId}`); - - return; - } - - // housekeeping - delete this.#waitingForId[subId]; - - try { - const result = this.#coder.decodeResponse(response); - - handler.callback(null, result); - } catch (error) { - this.#endpointStats.errors++; - this.#stats.total.errors++; - - handler.callback(error as Error, undefined); - } - }; - - #onSocketOpen = (): boolean => { - if (this.#websocket === null) { - throw new Error('WebSocket cannot be null in onOpen'); - } - - l.debug(() => ['connected to', this.endpoint]); - - this.#isConnected = true; - - this.#resubscribe(); - - this.#emit('connected'); - - return true; - }; - - #resubscribe = (): void => { - const subscriptions = this.#subscriptions; - - this.#subscriptions = {}; - - Promise.all(Object.keys(subscriptions).map(async (id): Promise => { - const { callback, method, params, type } = subscriptions[id]; - - // only re-create subscriptions which are not in author (only area where - // transactions are created, i.e. submissions such as 'author_submitAndWatchExtrinsic' - // are not included (and will not be re-broadcast) - if (type.startsWith('author_')) { - return; - } - - try { - await this.subscribe(type, method, params, callback); - } catch (error) { - l.error(error); - } - })).catch(l.error); - }; - - #timeoutHandlers = (): void => { - const now = Date.now(); - const ids = Object.keys(this.#handlers); - - for (let i = 0, count = ids.length; i < count; i++) { - const handler = this.#handlers[ids[i]]; - - if ((now - handler.start) > this.#timeout) { - try { - handler.callback(new Error(`No response received from RPC endpoint in ${this.#timeout / 1000}s`), undefined); - } catch { - // ignore - } - - this.#endpointStats.timeout++; - this.#stats.total.timeout++; - delete this.#handlers[ids[i]]; - } - } - }; -} diff --git a/packages/rpc-provider/src/ws/send.spec.ts b/packages/rpc-provider/src/ws/send.spec.ts deleted file mode 100644 index 7de7b395..00000000 --- a/packages/rpc-provider/src/ws/send.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Global, Mock } from '../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -declare const global: Global; - -const TEST_WS_URL = 'ws://localhost-send.spec.ts:9965'; - -let provider: WsProvider | null; -let mock: Mock; - -function createMock (requests: Request[]): void { - mock = mockWs(requests, TEST_WS_URL); -} - -function createWs (autoConnect = 1000): Promise { - provider = new WsProvider(TEST_WS_URL, autoConnect); - - return provider.isReady; -} - -describe('send', (): void => { - let globalWs: typeof WebSocket; - - beforeEach((): void => { - globalWs = global.WebSocket; - }); - - afterEach(async () => { - global.WebSocket = globalWs; - - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('handles internal errors', (): Promise => { - createMock([{ - id: 1, - method: 'test_body', - reply: { - result: 'ok' - } - }]); - - return createWs().then((ws) => - ws - .send('test_encoding', [{ error: 'send error' }]) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toEqual('send error'); - }) - ); - }); - - it('passes the body through correctly', (): Promise => { - createMock([{ - id: 1, - method: 'test_body', - reply: { - result: 'ok' - } - }]); - - return createWs().then((ws) => - ws - .send('test_body', ['param']) - .then((): void => { - expect( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (mock.body as any).test_body - ).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}'); - }) - ); - }); - - it('throws error when !response.ok', (): Promise => { - createMock([{ - error: { - code: 666, - message: 'error' - }, - id: 1, - method: 'something' - }]); - - return createWs().then((ws) => - ws - .send('test_error', []) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/666: error/); - }) - ); - }); - - it('adds subscriptions', (): Promise => { - createMock([{ - id: 1, - method: 'test_sub', - reply: { - result: 1 - } - }]); - - return createWs().then((ws) => - ws - .send('test_sub', []) - .then((id): void => { - expect(id).toEqual(1); - }) - ); - }); -}); diff --git a/packages/rpc-provider/src/ws/state.spec.ts b/packages/rpc-provider/src/ws/state.spec.ts deleted file mode 100644 index f7c4d6e4..00000000 --- a/packages/rpc-provider/src/ws/state.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { WsProvider } from './index.js'; - -describe('state', (): void => { - it('requires an ws:// prefixed endpoint', (): void => { - expect( - () => new WsProvider('http://', 0) - ).toThrow(/with 'ws/); - }); - - it('allows wss:// endpoints', (): void => { - expect( - () => new WsProvider('wss://', 0) - ).not.toThrow(); - }); -}); diff --git a/packages/rpc-provider/src/ws/subscribe.spec.ts b/packages/rpc-provider/src/ws/subscribe.spec.ts deleted file mode 100644 index c1694526..00000000 --- a/packages/rpc-provider/src/ws/subscribe.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Global, Mock } from './../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -declare const global: Global; - -const TEST_WS_URL = 'ws://localhost-subscribe.test.ts:9933'; - -let provider: WsProvider | null; -let mock: Mock; - -function createMock (requests: Request[]): void { - mock = mockWs(requests, TEST_WS_URL); -} - -function createWs (autoConnect = 1000): Promise { - provider = new WsProvider(TEST_WS_URL, autoConnect); - - return provider.isReady; -} - -describe('subscribe', (): void => { - let globalWs: typeof WebSocket; - - beforeEach((): void => { - globalWs = global.WebSocket; - }); - - afterEach(async () => { - global.WebSocket = globalWs; - - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('adds subscriptions', (): Promise => { - createMock([{ - id: 1, - method: 'test_sub', - reply: { - result: 1 - } - }]); - - return createWs().then((ws) => - ws - .subscribe('type', 'test_sub', [], (cb): void => { - expect(cb).toEqual(expect.anything()); - }) - .then((id): void => { - expect(id).toEqual(1); - }) - ); - }); -}); diff --git a/packages/rpc-provider/src/ws/unsubscribe.spec.ts b/packages/rpc-provider/src/ws/unsubscribe.spec.ts deleted file mode 100644 index 9693c5d6..00000000 --- a/packages/rpc-provider/src/ws/unsubscribe.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Global, Mock } from './../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -declare const global: Global; - -const TEST_WS_URL = 'ws://localhost-unsubscribe.test.ts:9933'; - -let provider: WsProvider | null; -let mock: Mock; - -function createMock (requests: Request[]): void { - mock = mockWs(requests, TEST_WS_URL); -} - -function createWs (autoConnect = 1000): Promise { - provider = new WsProvider(TEST_WS_URL, autoConnect); - - return provider.isReady; -} - -describe('subscribe', (): void => { - let globalWs: typeof WebSocket; - - beforeEach((): void => { - globalWs = global.WebSocket; - }); - - afterEach(async () => { - global.WebSocket = globalWs; - - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('removes subscriptions', async (): Promise => { - createMock([ - { - id: 1, - method: 'subscribe_test', - reply: { - result: 1 - } - }, - { - id: 2, - method: 'unsubscribe_test', - reply: { - result: true - } - } - ]); - - await createWs().then((ws) => - ws - .subscribe('test', 'subscribe_test', [], (cb): void => { - expect(cb).toEqual(expect.anything()); - }) - .then((id): Promise => { - return ws.unsubscribe('test', 'subscribe_test', id); - }) - ); - }); - - it('fails when sub not found', (): Promise => { - createMock([{ - id: 1, - method: 'subscribe_test', - reply: { - result: 1 - } - }]); - - return createWs().then((ws) => - ws - .subscribe('test', 'subscribe_test', [], (cb): void => { - expect(cb).toEqual(expect.anything()); - }) - .then((): Promise => { - return ws.unsubscribe('test', 'subscribe_test', 111); - }) - .then((result): void => { - expect(result).toBe(false); - }) - ); - }); -}); diff --git a/packages/rpc-provider/tsconfig.build.json b/packages/rpc-provider/tsconfig.build.json deleted file mode 100644 index e4b9f972..00000000 --- a/packages/rpc-provider/tsconfig.build.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": "..", - "outDir": "./build", - "rootDir": "./src", - "resolveJsonModule": true - }, - "exclude": [ - "**/*.spec.ts", - "**/mod.ts" - ], - "references": [ - { "path": "../types/tsconfig.build.json" }, - { "path": "../types-support/tsconfig.build.json" } - ] -} diff --git a/packages/rpc-provider/tsconfig.spec.json b/packages/rpc-provider/tsconfig.spec.json deleted file mode 100644 index b13f18eb..00000000 --- a/packages/rpc-provider/tsconfig.spec.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": "..", - "outDir": "./build", - "rootDir": "./src", - "emitDeclarationOnly": false, - "resolveJsonModule": true, - "noEmit": true - }, - "include": [ - "**/*.spec.ts" - ], - "references": [ - { "path": "../rpc-provider/tsconfig.build.json" }, - { "path": "../types/tsconfig.build.json" } - ] -} From b87764ce13fc613447b94d8473e3690adb761d6c Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 22:33:06 +0200 Subject: [PATCH 05/50] Functional WIP of working RPC provider --- .../worker-api/src/websocketWorker.spec.ts | 43 ++++ packages/worker-api/src/websocketWorker.ts | 209 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 packages/worker-api/src/websocketWorker.spec.ts create mode 100644 packages/worker-api/src/websocketWorker.ts diff --git a/packages/worker-api/src/websocketWorker.spec.ts b/packages/worker-api/src/websocketWorker.spec.ts new file mode 100644 index 00000000..89e1e642 --- /dev/null +++ b/packages/worker-api/src/websocketWorker.spec.ts @@ -0,0 +1,43 @@ +import { cryptoWaitReady } from '@polkadot/util-crypto'; +import {paseoNetwork} from './testUtils/networks.js'; +import { Worker } from './websocketWorker.js'; + +describe('worker', () => { + const network = paseoNetwork(); + let worker: Worker; + beforeAll(async () => { + jest.setTimeout(90000); + await cryptoWaitReady(); + + worker = new Worker(network.worker); + }); + + // skip it, as this requires a worker (and hence a node) to be running + // To my knowledge jest does not have an option to run skipped tests specifically, does it? + // Todo: add proper CI to test this too. + describe('needs worker and node running', () => { + describe('getWorkerPubKey', () => { + it('should return value', async () => { + const result = await worker.getShieldingKey(); + // console.log('Shielding Key', result); + expect(result).toBeDefined(); + }); + }); + + describe('getShardVault', () => { + it('should return value', async () => { + const result = await worker.getShardVault(); + console.log('ShardVault', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('getFingerprint', () => { + it('should return value', async () => { + const result = await worker.getFingerprint(); + console.log('Fingerprint', result.toString()); + expect(result).toBeDefined(); + }); + }); + }); +}); diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts new file mode 100644 index 00000000..fc9bd971 --- /dev/null +++ b/packages/worker-api/src/websocketWorker.ts @@ -0,0 +1,209 @@ +import type {Vec} from '@polkadot/types'; +import {TypeRegistry} from '@polkadot/types'; +import type {RegistryTypes} from '@polkadot/types/types'; +import {Keyring} from '@polkadot/keyring' +import {compactAddLength, hexToU8a} from '@polkadot/util'; + + +import {options as encointerOptions} from '@encointer/node-api'; + +import type {EnclaveFingerprint, RpcReturnValue, Vault} from '@encointer/types'; + +import { type WorkerOptions} from './interface.js'; +import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; +import type {u8} from "@polkadot/types-codec"; +import BN from "bn.js"; +import {WsProvider} from "@polkadot/api"; + + +// const parseGetterResponse = (self: IWorker, responseType: string, data: string) => { +// if (data === 'Could not decode request') { +// throw new Error(`Worker error: ${data}`); +// } +// +// // console.debug(`Getter response: ${data}`); +// const json = JSON.parse(data); +// +// const value = hexToU8a(json["result"]); +// const returnValue = self.createType('RpcReturnValue', value); +// console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); +// +// if (returnValue.status.isError) { +// const errorMsg = self.createType('String', returnValue.value); +// throw new Error(`RPC Error: ${errorMsg}`); +// } +// +// let parsedData: any; +// try { +// switch (responseType) { +// case 'raw': +// parsedData = unwrapWorkerResponse(self, returnValue.value); +// break; +// case 'BalanceEntry': +// parsedData = unwrapWorkerResponse(self, returnValue.value); +// parsedData = parseBalance(self, parsedData); +// break; +// case 'I64F64': +// parsedData = unwrapWorkerResponse(self, returnValue.value); +// parsedData = parseI64F64(self.createType('i128', parsedData)); +// break; +// case 'CryptoKey': +// const jsonStr = self.createType('String', returnValue.value); +// // Todo: For some reason there are 2 non-utf characters, where I don't know where +// // they come from currently. +// console.debug(`Got shielding key: ${jsonStr.toJSON().substring(2)}`); +// parsedData = parseWebCryptoRSA(jsonStr.toJSON().substring(2)); +// break +// case 'Vault': +// parsedData = self.createType(responseType, returnValue.value); +// break +// case 'EnclaveFingerprint': +// parsedData = self.createType(responseType, returnValue.value); +// break +// case 'TrustedOperationResult': +// console.debug(`Got TrustedOperationResult`) +// parsedData = self.createType('Hash', returnValue.value); +// break +// default: +// parsedData = unwrapWorkerResponse(self, returnValue.value); +// console.debug(`unwrapped data ${parsedData}`); +// parsedData = self.createType(responseType, parsedData); +// break; +// } +// } catch (err) { +// throw new Error(`Can't parse into ${responseType}:\n${err}`); +// } +// return parsedData; +// } + +export class Worker { + + readonly #registry: TypeRegistry; + + #keyring?: Keyring; + + #shieldingKey?: CryptoKey; + + #ws: WsProvider; + + rsCount: number; + + rqStack: string[]; + + constructor(url: string, options: WorkerOptions = {} as WorkerOptions) { + this.#keyring = (options.keyring || undefined); + this.#registry = new TypeRegistry(); + this.rsCount = 0; + this.rqStack = [] as string[] + this.#ws = new WsProvider(url); + + + if (options.types != undefined) { + this.#registry.register(encointerOptions({types: options.types}).types as RegistryTypes); + } else { + this.#registry.register(encointerOptions().types as RegistryTypes); + } + } + + public async isReady(): Promise { + return this.#ws.isReady + } + + public async encrypt(data: Uint8Array): Promise> { + const dataBE = new BN(data); + const dataArrayBE = new Uint8Array(dataBE.toArray()); + + const cypherTextBuffer = await encryptWithPublicKey(dataArrayBE, this.shieldingKey() as CryptoKey); + + const outputData = new Uint8Array(cypherTextBuffer); + const be = new BN(outputData) + const beArray = new Uint8Array(be.toArray()); + + // console.debug(`${JSON.stringify({encrypted_array: beArray})}`) + + return this.createType('Vec', compactAddLength(beArray)) + } + + public registry(): TypeRegistry { + return this.#registry + } + + public createType(apiType: string, obj?: any): any { + return this.#registry.createType(apiType as never, obj) + } + + public keyring(): Keyring | undefined { + return this.#keyring; + } + + public setKeyring(keyring: Keyring): void { + this.#keyring = keyring; + } + + public shieldingKey(): CryptoKey | undefined { + return this.#shieldingKey; + } + + public setShieldingKey(shieldingKey: CryptoKey): void { + this.#shieldingKey = shieldingKey; + } + + public async getShieldingKey(): Promise { + const res = await this.send( + 'author_getShieldingKey',[] + ); + + const jsonStr = this.createType('String', res.value); + // Todo: For some reason there are 2 non-utf characters, where I don't know where + // they come from currently. + // console.debug(`Got shielding key: ${jsonStr.toJSON().substring(2)}`); + const key = await parseWebCryptoRSA(jsonStr.toJSON().substring(2)); + + // @ts-ignore + this.setShieldingKey(key); + // @ts-ignore + return key; + } + + public async getShardVault(): Promise { + const res = await this.send( + 'author_getShardVault',[] + ); + + console.debug(`Got vault key: ${JSON.stringify(res)}`); + return this.createType('Vault', res.value); + } + + public async getFingerprint(): Promise { + const res = await this.send( + 'author_getFingerprint',[] + ); + + console.debug(`Got fingerprint key: ${res}`); + + return this.createType('EnclaveFingerprint', res.value); + } + + + public async send(method: string, params: unknown[]): Promise { + await this.isReady(); + const result = await this.#ws.send( + method, params + ); + + if (result === 'Could not decode request') { + throw new Error(`Worker error: ${result}`); + } + + const value = hexToU8a(result); + const returnValue = this.createType('RpcReturnValue', value); + console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); + + if (returnValue.status.isError) { + const errorMsg = this.createType('String', returnValue.value); + throw new Error(`RPC Error: ${errorMsg}`); + } + + return returnValue; + } +} From 4cfceec2b0d0e03bc21b870954d93ba90ff6c8bd Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 22:39:40 +0200 Subject: [PATCH 06/50] simplify the websocket worker some more --- packages/worker-api/src/websocketWorker.ts | 80 ---------------------- 1 file changed, 80 deletions(-) diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index fc9bd971..7d5ce61e 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -1,7 +1,6 @@ import type {Vec} from '@polkadot/types'; import {TypeRegistry} from '@polkadot/types'; import type {RegistryTypes} from '@polkadot/types/types'; -import {Keyring} from '@polkadot/keyring' import {compactAddLength, hexToU8a} from '@polkadot/util'; @@ -15,89 +14,18 @@ import type {u8} from "@polkadot/types-codec"; import BN from "bn.js"; import {WsProvider} from "@polkadot/api"; - -// const parseGetterResponse = (self: IWorker, responseType: string, data: string) => { -// if (data === 'Could not decode request') { -// throw new Error(`Worker error: ${data}`); -// } -// -// // console.debug(`Getter response: ${data}`); -// const json = JSON.parse(data); -// -// const value = hexToU8a(json["result"]); -// const returnValue = self.createType('RpcReturnValue', value); -// console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); -// -// if (returnValue.status.isError) { -// const errorMsg = self.createType('String', returnValue.value); -// throw new Error(`RPC Error: ${errorMsg}`); -// } -// -// let parsedData: any; -// try { -// switch (responseType) { -// case 'raw': -// parsedData = unwrapWorkerResponse(self, returnValue.value); -// break; -// case 'BalanceEntry': -// parsedData = unwrapWorkerResponse(self, returnValue.value); -// parsedData = parseBalance(self, parsedData); -// break; -// case 'I64F64': -// parsedData = unwrapWorkerResponse(self, returnValue.value); -// parsedData = parseI64F64(self.createType('i128', parsedData)); -// break; -// case 'CryptoKey': -// const jsonStr = self.createType('String', returnValue.value); -// // Todo: For some reason there are 2 non-utf characters, where I don't know where -// // they come from currently. -// console.debug(`Got shielding key: ${jsonStr.toJSON().substring(2)}`); -// parsedData = parseWebCryptoRSA(jsonStr.toJSON().substring(2)); -// break -// case 'Vault': -// parsedData = self.createType(responseType, returnValue.value); -// break -// case 'EnclaveFingerprint': -// parsedData = self.createType(responseType, returnValue.value); -// break -// case 'TrustedOperationResult': -// console.debug(`Got TrustedOperationResult`) -// parsedData = self.createType('Hash', returnValue.value); -// break -// default: -// parsedData = unwrapWorkerResponse(self, returnValue.value); -// console.debug(`unwrapped data ${parsedData}`); -// parsedData = self.createType(responseType, parsedData); -// break; -// } -// } catch (err) { -// throw new Error(`Can't parse into ${responseType}:\n${err}`); -// } -// return parsedData; -// } - export class Worker { readonly #registry: TypeRegistry; - #keyring?: Keyring; - #shieldingKey?: CryptoKey; #ws: WsProvider; - rsCount: number; - - rqStack: string[]; - constructor(url: string, options: WorkerOptions = {} as WorkerOptions) { - this.#keyring = (options.keyring || undefined); this.#registry = new TypeRegistry(); - this.rsCount = 0; - this.rqStack = [] as string[] this.#ws = new WsProvider(url); - if (options.types != undefined) { this.#registry.register(encointerOptions({types: options.types}).types as RegistryTypes); } else { @@ -132,14 +60,6 @@ export class Worker { return this.#registry.createType(apiType as never, obj) } - public keyring(): Keyring | undefined { - return this.#keyring; - } - - public setKeyring(keyring: Keyring): void { - this.#keyring = keyring; - } - public shieldingKey(): CryptoKey | undefined { return this.#shieldingKey; } From 4753f34ac50e0304f85223f0147ecee442890a7b Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 22:47:00 +0200 Subject: [PATCH 07/50] Revert "remove rpc provider again" This reverts commit 1815e0cdc46ab8ae6d97f47151b03ed60713eb9f. --- packages/rpc-provider/README.md | 68 ++ packages/rpc-provider/package.json | 43 ++ packages/rpc-provider/src/bundle.ts | 7 + .../src/coder/decodeResponse.spec.ts | 70 ++ .../rpc-provider/src/coder/encodeJson.spec.ts | 20 + .../src/coder/encodeObject.spec.ts | 25 + packages/rpc-provider/src/coder/error.spec.ts | 111 ++++ packages/rpc-provider/src/coder/error.ts | 66 ++ packages/rpc-provider/src/coder/index.ts | 88 +++ packages/rpc-provider/src/defaults.ts | 10 + packages/rpc-provider/src/index.ts | 6 + packages/rpc-provider/src/lru.spec.ts | 57 ++ packages/rpc-provider/src/lru.ts | 134 ++++ packages/rpc-provider/src/mock/index.ts | 259 ++++++++ packages/rpc-provider/src/mock/mockHttp.ts | 35 + packages/rpc-provider/src/mock/mockWs.ts | 92 +++ packages/rpc-provider/src/mock/on.spec.ts | 43 ++ packages/rpc-provider/src/mock/send.spec.ts | 38 ++ .../rpc-provider/src/mock/subscribe.spec.ts | 81 +++ packages/rpc-provider/src/mock/types.ts | 36 + .../rpc-provider/src/mock/unsubscribe.spec.ts | 57 ++ packages/rpc-provider/src/mod.ts | 4 + packages/rpc-provider/src/packageDetect.ts | 12 + packages/rpc-provider/src/types.ts | 99 +++ packages/rpc-provider/src/ws/connect.spec.ts | 167 +++++ packages/rpc-provider/src/ws/errors.ts | 41 ++ packages/rpc-provider/src/ws/index.spec.ts | 92 +++ packages/rpc-provider/src/ws/index.ts | 626 ++++++++++++++++++ packages/rpc-provider/src/ws/send.spec.ts | 126 ++++ packages/rpc-provider/src/ws/state.spec.ts | 20 + .../rpc-provider/src/ws/subscribe.spec.ts | 68 ++ .../rpc-provider/src/ws/unsubscribe.spec.ts | 100 +++ packages/rpc-provider/tsconfig.build.json | 17 + packages/rpc-provider/tsconfig.spec.json | 18 + 34 files changed, 2736 insertions(+) create mode 100644 packages/rpc-provider/README.md create mode 100644 packages/rpc-provider/package.json create mode 100644 packages/rpc-provider/src/bundle.ts create mode 100644 packages/rpc-provider/src/coder/decodeResponse.spec.ts create mode 100644 packages/rpc-provider/src/coder/encodeJson.spec.ts create mode 100644 packages/rpc-provider/src/coder/encodeObject.spec.ts create mode 100644 packages/rpc-provider/src/coder/error.spec.ts create mode 100644 packages/rpc-provider/src/coder/error.ts create mode 100644 packages/rpc-provider/src/coder/index.ts create mode 100644 packages/rpc-provider/src/defaults.ts create mode 100644 packages/rpc-provider/src/index.ts create mode 100644 packages/rpc-provider/src/lru.spec.ts create mode 100644 packages/rpc-provider/src/lru.ts create mode 100644 packages/rpc-provider/src/mock/index.ts create mode 100644 packages/rpc-provider/src/mock/mockHttp.ts create mode 100644 packages/rpc-provider/src/mock/mockWs.ts create mode 100644 packages/rpc-provider/src/mock/on.spec.ts create mode 100644 packages/rpc-provider/src/mock/send.spec.ts create mode 100644 packages/rpc-provider/src/mock/subscribe.spec.ts create mode 100644 packages/rpc-provider/src/mock/types.ts create mode 100644 packages/rpc-provider/src/mock/unsubscribe.spec.ts create mode 100644 packages/rpc-provider/src/mod.ts create mode 100644 packages/rpc-provider/src/packageDetect.ts create mode 100644 packages/rpc-provider/src/types.ts create mode 100644 packages/rpc-provider/src/ws/connect.spec.ts create mode 100644 packages/rpc-provider/src/ws/errors.ts create mode 100644 packages/rpc-provider/src/ws/index.spec.ts create mode 100644 packages/rpc-provider/src/ws/index.ts create mode 100644 packages/rpc-provider/src/ws/send.spec.ts create mode 100644 packages/rpc-provider/src/ws/state.spec.ts create mode 100644 packages/rpc-provider/src/ws/subscribe.spec.ts create mode 100644 packages/rpc-provider/src/ws/unsubscribe.spec.ts create mode 100644 packages/rpc-provider/tsconfig.build.json create mode 100644 packages/rpc-provider/tsconfig.spec.json diff --git a/packages/rpc-provider/README.md b/packages/rpc-provider/README.md new file mode 100644 index 00000000..e114fb2d --- /dev/null +++ b/packages/rpc-provider/README.md @@ -0,0 +1,68 @@ +# @polkadot/rpc-provider + +Generic transport providers to handle the transport of method calls to and from Polkadot clients from applications interacting with it. It provides an interface to making RPC calls and is generally, unless you are operating at a low-level and taking care of encoding and decoding of parameters/results, it won't be directly used, rather only passed to a higher-level interface. + +## Provider Selection + +There are three flavours of the providers provided, one allowing for using HTTP as a transport mechanism, the other using WebSockets, and the third one uses substrate light-client through @substrate/connect. It is generally recommended to use the [[WsProvider]] since in addition to standard calls, it allows for subscriptions where all changes to state can be pushed from the node to the client. + +All providers are usable (as is the API), in both browser-based and Node.js environments. Polyfills for unsupported functionality are automatically applied based on feature-detection. + +## Usage + +Installation - + +``` +yarn add @polkadot/rpc-provider +``` + +WebSocket Initialization - + +```javascript +import { WsProvider } from '@polkadot/rpc-provider'; + +// this is the actual default endpoint +const provider = new WsProvider('ws://127.0.0.1:9944'); +const version = await provider.send('client_version', []); + +console.log('client version', version); +``` + +HTTP Initialization - + +```javascript +import { HttpProvider } from '@polkadot/rpc-provider'; + +// this is the actual default endpoint +const provider = new HttpProvider('http://127.0.0.1:9933'); +const version = await provider.send('chain_getBlockHash', []); + +console.log('latest block Hash', hash); +``` + +@substrate/connect Initialization - + +Instantiating a Provider for the Polkadot Relay Chain: +```javascript +import { ScProvider } from '@polkadot/rpc-provider'; +import * as Sc from '@substrate/connect'; + +const provider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); + +await provider.connect(); + +const version = await provider.send('chain_getBlockHash', []); +``` + +Instantiating a Provider for a Polkadot parachain: +```javascript +import { ScProvider } from '@polkadot/rpc-provider'; +import * as Sc from '@substrate/connect'; + +const polkadotProvider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); +const parachainProvider = new ScProvider(Sc, parachainSpec, polkadotProvider); + +await parachainProvider.connect(); + +const version = await parachainProvider.send('chain_getBlockHash', []); +``` diff --git a/packages/rpc-provider/package.json b/packages/rpc-provider/package.json new file mode 100644 index 00000000..e8d5f46f --- /dev/null +++ b/packages/rpc-provider/package.json @@ -0,0 +1,43 @@ +{ + "author": "Jaco Greeff ", + "bugs": "https://github.com/polkadot-js/api/issues", + "description": "Transport providers for the API", + "engines": { + "node": ">=18" + }, + "homepage": "https://github.com/polkadot-js/api/tree/master/packages/rpc-provider#readme", + "license": "Apache-2.0", + "name": "@polkadot/rpc-provider", + "repository": { + "directory": "packages/rpc-provider", + "type": "git", + "url": "https://github.com/polkadot-js/api.git" + }, + "sideEffects": [ + "./packageDetect.js", + "./packageDetect.cjs" + ], + "type": "module", + "version": "11.2.1", + "main": "index.js", + "dependencies": { + "@polkadot/keyring": "^12.6.2", + "@polkadot/types": "11.2.1", + "@polkadot/types-support": "11.2.1", + "@polkadot/util": "^12.6.2", + "@polkadot/util-crypto": "^12.6.2", + "@polkadot/x-fetch": "^12.6.2", + "@polkadot/x-global": "^12.6.2", + "@polkadot/x-ws": "^12.6.2", + "eventemitter3": "^5.0.1", + "mock-socket": "^9.3.1", + "nock": "^13.5.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@substrate/connect": "0.8.10" + }, + "optionalDependencies": { + "@substrate/connect": "0.8.10" + } +} diff --git a/packages/rpc-provider/src/bundle.ts b/packages/rpc-provider/src/bundle.ts new file mode 100644 index 00000000..06b0acaf --- /dev/null +++ b/packages/rpc-provider/src/bundle.ts @@ -0,0 +1,7 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { HttpProvider } from './http/index.js'; +export { packageInfo } from './packageInfo.js'; +export { ScProvider } from './substrate-connect/index.js'; +export { WsProvider } from './ws/index.js'; diff --git a/packages/rpc-provider/src/coder/decodeResponse.spec.ts b/packages/rpc-provider/src/coder/decodeResponse.spec.ts new file mode 100644 index 00000000..293fd641 --- /dev/null +++ b/packages/rpc-provider/src/coder/decodeResponse.spec.ts @@ -0,0 +1,70 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { JsonRpcResponse } from '../types.js'; + +import { RpcCoder } from './index.js'; + +describe('decodeResponse', (): void => { + let coder: RpcCoder; + + beforeEach((): void => { + coder = new RpcCoder(); + }); + + it('expects a non-empty input object', (): void => { + expect( + () => coder.decodeResponse(undefined as unknown as JsonRpcResponse) + ).toThrow(/Invalid jsonrpc/); + }); + + it('expects a valid jsonrpc field', (): void => { + expect( + () => coder.decodeResponse({} as JsonRpcResponse) + ).toThrow(/Invalid jsonrpc/); + }); + + it('expects a valid id field', (): void => { + expect( + () => coder.decodeResponse({ jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/Invalid id/); + }); + + it('expects a valid result field', (): void => { + expect( + () => coder.decodeResponse({ id: 1, jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/No result/); + }); + + it('throws any error found', (): void => { + expect( + () => coder.decodeResponse({ error: { code: 123, message: 'test error' }, id: 1, jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/123: test error/); + }); + + it('throws any error found, with data', (): void => { + expect( + () => coder.decodeResponse({ error: { code: 123, data: 'Error("Some random error description")', message: 'test error' }, id: 1, jsonrpc: '2.0' } as JsonRpcResponse) + ).toThrow(/123: test error: Some random error description/); + }); + + it('allows for number subscription ids', (): void => { + expect( + coder.decodeResponse({ id: 1, jsonrpc: '2.0', method: 'test', params: { result: 'test result', subscription: 1 } } as JsonRpcResponse) + ).toEqual('test result'); + }); + + it('allows for string subscription ids', (): void => { + expect( + coder.decodeResponse({ id: 1, jsonrpc: '2.0', method: 'test', params: { result: 'test result', subscription: 'abc' } } as JsonRpcResponse) + ).toEqual('test result'); + }); + + it('returns the result', (): void => { + expect( + coder.decodeResponse({ id: 1, jsonrpc: '2.0', result: 'some result' } as JsonRpcResponse) + ).toEqual('some result'); + }); +}); diff --git a/packages/rpc-provider/src/coder/encodeJson.spec.ts b/packages/rpc-provider/src/coder/encodeJson.spec.ts new file mode 100644 index 00000000..5e166e8b --- /dev/null +++ b/packages/rpc-provider/src/coder/encodeJson.spec.ts @@ -0,0 +1,20 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { RpcCoder } from './index.js'; + +describe('encodeJson', (): void => { + let coder: RpcCoder; + + beforeEach((): void => { + coder = new RpcCoder(); + }); + + it('encodes a valid JsonRPC JSON string', (): void => { + expect( + coder.encodeJson('method', ['params']) + ).toEqual([1, '{"id":1,"jsonrpc":"2.0","method":"method","params":["params"]}']); + }); +}); diff --git a/packages/rpc-provider/src/coder/encodeObject.spec.ts b/packages/rpc-provider/src/coder/encodeObject.spec.ts new file mode 100644 index 00000000..841a3257 --- /dev/null +++ b/packages/rpc-provider/src/coder/encodeObject.spec.ts @@ -0,0 +1,25 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { RpcCoder } from './index.js'; + +describe('encodeObject', (): void => { + let coder: RpcCoder; + + beforeEach((): void => { + coder = new RpcCoder(); + }); + + it('encodes a valid JsonRPC object', (): void => { + expect( + coder.encodeObject('method', ['a', 'b']) + ).toEqual([1, { + id: 1, + jsonrpc: '2.0', + method: 'method', + params: ['a', 'b'] + }]); + }); +}); diff --git a/packages/rpc-provider/src/coder/error.spec.ts b/packages/rpc-provider/src/coder/error.spec.ts new file mode 100644 index 00000000..89ac16c2 --- /dev/null +++ b/packages/rpc-provider/src/coder/error.spec.ts @@ -0,0 +1,111 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { isError } from '@polkadot/util'; + +import RpcError from './error.js'; + +describe('RpcError', (): void => { + describe('constructor', (): void => { + it('constructs an Error that is still an Error', (): void => { + expect( + isError( + new RpcError() + ) + ).toEqual(true); + }); + }); + + describe('static', (): void => { + it('exposes the .CODES as a static', (): void => { + expect( + Object.keys(RpcError.CODES) + ).not.toEqual(0); + }); + }); + + describe('constructor properties', (): void => { + it('sets the .message property', (): void => { + expect( + new RpcError('test message').message + ).toEqual('test message'); + }); + + it("sets the .message to '' when not set", (): void => { + expect( + new RpcError().message + ).toEqual(''); + }); + + it('sets the .code property', (): void => { + expect( + new RpcError('test message', 1234).code + ).toEqual(1234); + }); + + it('sets the .code to UKNOWN when not set', (): void => { + expect( + new RpcError('test message').code + ).toEqual(RpcError.CODES.UNKNOWN); + }); + + it('sets the .data property', (): void => { + const data = 'here'; + + expect( + new RpcError('test message', 1234, data).data + ).toEqual(data); + }); + + it('sets the .data property to generic value', (): void => { + const data = { custom: 'value' } as const; + + expect( + new RpcError('test message', 1234, data).data + ).toEqual(data); + }); + }); + + describe('stack traces', (): void => { + // eslint-disable-next-line @typescript-eslint/ban-types + let captureStackTrace: (targetObject: Record, constructorOpt?: Function | undefined) => void; + + beforeEach((): void => { + captureStackTrace = Error.captureStackTrace; + + Error.captureStackTrace = function (error): void { + Object.defineProperty(error, 'stack', { + configurable: true, + get: function getStack (): string { + const value = 'some stack returned'; + + Object.defineProperty(this, 'stack', { value }); + + return value; + } + }); + }; + }); + + afterEach((): void => { + Error.captureStackTrace = captureStackTrace; + }); + + it('captures via captureStackTrace when available', (): void => { + expect( + new RpcError().stack + ).toEqual('some stack returned'); + }); + + it('captures via stack when captureStackTrace not available', (): void => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + Error.captureStackTrace = null as any; + + expect( + new RpcError().stack.length + ).not.toEqual(0); + }); + }); +}); diff --git a/packages/rpc-provider/src/coder/error.ts b/packages/rpc-provider/src/coder/error.ts new file mode 100644 index 00000000..908d9ead --- /dev/null +++ b/packages/rpc-provider/src/coder/error.ts @@ -0,0 +1,66 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RpcErrorInterface } from '../types.js'; + +import { isFunction } from '@polkadot/util'; + +const UNKNOWN = -99999; + +function extend> (that: RpcError, name: K, value: RpcError[K]): void { + Object.defineProperty(that, name, { + configurable: true, + enumerable: false, + value + }); +} + +/** + * @name RpcError + * @summary Extension to the basic JS Error. + * @description + * The built-in JavaScript Error class is extended by adding a code to allow for Error categorization. In addition to the normal `stack`, `message`, the numeric `code` and `data` (any types) parameters are available on the object. + * @example + *
+ * + * ```javascript + * const { RpcError } from '@polkadot/util'); + * + * throw new RpcError('some message', RpcError.CODES.METHOD_NOT_FOUND); // => error.code = -32601 + * ``` + */ +export default class RpcError extends Error implements RpcErrorInterface { + public code!: number; + + public data?: T; + + public override message!: string; + + public override name!: string; + + public override stack!: string; + + public constructor (message = '', code: number = UNKNOWN, data?: T) { + super(); + + extend(this, 'message', String(message)); + extend(this, 'name', this.constructor.name); + extend(this, 'data', data); + extend(this, 'code', code); + + if (isFunction(Error.captureStackTrace)) { + Error.captureStackTrace(this, this.constructor); + } else { + const { stack } = new Error(message); + + stack && extend(this, 'stack', stack); + } + } + + public static CODES = { + ASSERT: -90009, + INVALID_JSONRPC: -99998, + METHOD_NOT_FOUND: -32601, // Rust client + UNKNOWN + }; +} diff --git a/packages/rpc-provider/src/coder/index.ts b/packages/rpc-provider/src/coder/index.ts new file mode 100644 index 00000000..120e1a17 --- /dev/null +++ b/packages/rpc-provider/src/coder/index.ts @@ -0,0 +1,88 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { JsonRpcRequest, JsonRpcResponse, JsonRpcResponseBaseError } from '../types.js'; + +import { isNumber, isString, isUndefined, stringify } from '@polkadot/util'; + +import RpcError from './error.js'; + +function formatErrorData (data?: string | number): string { + if (isUndefined(data)) { + return ''; + } + + const formatted = `: ${isString(data) + ? data.replace(/Error\("/g, '').replace(/\("/g, '(').replace(/"\)/g, ')').replace(/\(/g, ', ').replace(/\)/g, '') + : stringify(data)}`; + + // We need some sort of cut-off here since these can be very large and + // very nested, pick a number and trim the result display to it + return formatted.length <= 256 + ? formatted + : `${formatted.substring(0, 255)}…`; +} + +function checkError (error?: JsonRpcResponseBaseError): void { + if (error) { + const { code, data, message } = error; + + throw new RpcError(`${code}: ${message}${formatErrorData(data)}`, code, data); + } +} + +/** @internal */ +export class RpcCoder { + #id = 0; + + public decodeResponse (response?: JsonRpcResponse): T { + if (!response || response.jsonrpc !== '2.0') { + throw new Error('Invalid jsonrpc field in decoded object'); + } + + const isSubscription = !isUndefined(response.params) && !isUndefined(response.method); + + if ( + !isNumber(response.id) && + ( + !isSubscription || ( + !isNumber(response.params.subscription) && + !isString(response.params.subscription) + ) + ) + ) { + throw new Error('Invalid id field in decoded object'); + } + + checkError(response.error); + + if (response.result === undefined && !isSubscription) { + throw new Error('No result found in jsonrpc response'); + } + + if (isSubscription) { + checkError(response.params.error); + + return response.params.result; + } + + return response.result; + } + + public encodeJson (method: string, params: unknown[]): [number, string] { + const [id, data] = this.encodeObject(method, params); + + return [id, stringify(data)]; + } + + public encodeObject (method: string, params: unknown[]): [number, JsonRpcRequest] { + const id = ++this.#id; + + return [id, { + id, + jsonrpc: '2.0', + method, + params + }]; + } +} diff --git a/packages/rpc-provider/src/defaults.ts b/packages/rpc-provider/src/defaults.ts new file mode 100644 index 00000000..55d19f21 --- /dev/null +++ b/packages/rpc-provider/src/defaults.ts @@ -0,0 +1,10 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +const HTTP_URL = 'http://127.0.0.1:9933'; +const WS_URL = 'ws://127.0.0.1:9944'; + +export default { + HTTP_URL, + WS_URL +}; diff --git a/packages/rpc-provider/src/index.ts b/packages/rpc-provider/src/index.ts new file mode 100644 index 00000000..e88d86ba --- /dev/null +++ b/packages/rpc-provider/src/index.ts @@ -0,0 +1,6 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import './packageDetect.js'; + +export * from './bundle.js'; diff --git a/packages/rpc-provider/src/lru.spec.ts b/packages/rpc-provider/src/lru.spec.ts new file mode 100644 index 00000000..0079eba6 --- /dev/null +++ b/packages/rpc-provider/src/lru.spec.ts @@ -0,0 +1,57 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { LRUCache } from './lru.js'; + +describe('LRUCache', (): void => { + it('allows getting of items below capacity', (): void => { + const keys = ['1', '2', '3', '4']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.keys().join(', ')).toEqual(keys.reverse().join(', ')); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + keys.forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); + }); + + it('drops items when at capacity', (): void => { + const keys = ['1', '2', '3', '4', '5', '6']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.keys().join(', ')).toEqual(keys.slice(2).reverse().join(', ')); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + keys.slice(2).forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); + }); + + it('adjusts the order as they are used', (): void => { + const keys = ['1', '2', '3', '4', '5']; + const lru = new LRUCache(4); + + keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); + + expect(lru.entries()).toEqual([['5', '555'], ['4', '444'], ['3', '333'], ['2', '222']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + lru.get('3'); + + expect(lru.entries()).toEqual([['3', '333'], ['5', '555'], ['4', '444'], ['2', '222']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + lru.set('4', '4433'); + + expect(lru.entries()).toEqual([['4', '4433'], ['3', '333'], ['5', '555'], ['2', '222']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + + lru.set('6', '666'); + + expect(lru.entries()).toEqual([['6', '666'], ['4', '4433'], ['3', '333'], ['5', '555']]); + expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); + }); +}); diff --git a/packages/rpc-provider/src/lru.ts b/packages/rpc-provider/src/lru.ts new file mode 100644 index 00000000..b9afa882 --- /dev/null +++ b/packages/rpc-provider/src/lru.ts @@ -0,0 +1,134 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Assuming all 1.5MB responses, we apply a default allowing for 192MB +// cache space (depending on the historic queries this would vary, metadata +// for Kusama/Polkadot/Substrate falls between 600-750K, 2x for estimate) +export const DEFAULT_CAPACITY = 128; + +class LRUNode { + readonly key: string; + + public next: LRUNode; + public prev: LRUNode; + + constructor (key: string) { + this.key = key; + this.next = this.prev = this; + } +} + +// https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU +export class LRUCache { + readonly capacity: number; + + readonly #data = new Map(); + readonly #refs = new Map(); + + #length = 0; + #head: LRUNode; + #tail: LRUNode; + + constructor (capacity = DEFAULT_CAPACITY) { + this.capacity = capacity; + this.#head = this.#tail = new LRUNode(''); + } + + get length (): number { + return this.#length; + } + + get lengthData (): number { + return this.#data.size; + } + + get lengthRefs (): number { + return this.#refs.size; + } + + entries (): [string, unknown][] { + const keys = this.keys(); + const count = keys.length; + const entries = new Array<[string, unknown]>(count); + + for (let i = 0; i < count; i++) { + const key = keys[i]; + + entries[i] = [key, this.#data.get(key)]; + } + + return entries; + } + + keys (): string[] { + const keys: string[] = []; + + if (this.#length) { + let curr = this.#head; + + while (curr !== this.#tail) { + keys.push(curr.key); + curr = curr.next; + } + + keys.push(curr.key); + } + + return keys; + } + + get (key: string): T | null { + const data = this.#data.get(key); + + if (data) { + this.#toHead(key); + + return data as T; + } + + return null; + } + + set (key: string, value: T): void { + if (this.#data.has(key)) { + this.#toHead(key); + } else { + const node = new LRUNode(key); + + this.#refs.set(node.key, node); + + if (this.length === 0) { + this.#head = this.#tail = node; + } else { + this.#head.prev = node; + node.next = this.#head; + this.#head = node; + } + + if (this.#length === this.capacity) { + this.#data.delete(this.#tail.key); + this.#refs.delete(this.#tail.key); + + this.#tail = this.#tail.prev; + this.#tail.next = this.#head; + } else { + this.#length += 1; + } + } + + this.#data.set(key, value); + } + + #toHead (key: string): void { + const ref = this.#refs.get(key); + + if (ref && ref !== this.#head) { + ref.prev.next = ref.next; + ref.next.prev = ref.prev; + ref.next = this.#head; + + this.#head.prev = ref; + this.#head = ref; + } + } +} diff --git a/packages/rpc-provider/src/mock/index.ts b/packages/rpc-provider/src/mock/index.ts new file mode 100644 index 00000000..b688710f --- /dev/null +++ b/packages/rpc-provider/src/mock/index.ts @@ -0,0 +1,259 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable camelcase */ + +import type { Header } from '@polkadot/types/interfaces'; +import type { Codec, Registry } from '@polkadot/types/types'; +import type { ProviderInterface, ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '../types.js'; +import type { MockStateDb, MockStateSubscriptionCallback, MockStateSubscriptions } from './types.js'; + +import { EventEmitter } from 'eventemitter3'; + +import { createTestKeyring } from '@polkadot/keyring/testing'; +import { decorateStorage, Metadata } from '@polkadot/types'; +import jsonrpc from '@polkadot/types/interfaces/jsonrpc'; +import rpcHeader from '@polkadot/types-support/json/Header.004.json' assert { type: 'json' }; +import rpcSignedBlock from '@polkadot/types-support/json/SignedBlock.004.immortal.json' assert { type: 'json' }; +import rpcMetadata from '@polkadot/types-support/metadata/static-substrate'; +import { BN, bnToU8a, logger, u8aToHex } from '@polkadot/util'; +import { randomAsU8a } from '@polkadot/util-crypto'; + +const INTERVAL = 1000; +const SUBSCRIPTIONS: string[] = Array.prototype.concat.apply( + [], + Object.values(jsonrpc).map((section): string[] => + Object + .values(section) + .filter(({ isSubscription }) => isSubscription) + .map(({ jsonrpc }) => jsonrpc) + .concat('chain_subscribeNewHead') + ) +) as string[]; + +const keyring = createTestKeyring({ type: 'ed25519' }); +const l = logger('api-mock'); + +/** + * A mock provider mainly used for testing. + * @return {ProviderInterface} The mock provider + * @internal + */ +export class MockProvider implements ProviderInterface { + private db: MockStateDb = {}; + + private emitter = new EventEmitter(); + + private intervalId?: ReturnType | null; + + public isUpdating = true; + + private registry: Registry; + + private prevNumber = new BN(-1); + + private requests: Record unknown> = { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + chain_getBlock: () => this.registry.createType('SignedBlock', rpcSignedBlock.result).toJSON(), + chain_getBlockHash: () => '0x1234000000000000000000000000000000000000000000000000000000000000', + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + chain_getFinalizedHead: () => this.registry.createType('Header', rpcHeader.result).hash, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + chain_getHeader: () => this.registry.createType('Header', rpcHeader.result).toJSON(), + rpc_methods: () => this.registry.createType('RpcMethods').toJSON(), + state_getKeys: () => [], + state_getKeysPaged: () => [], + state_getMetadata: () => rpcMetadata, + state_getRuntimeVersion: () => this.registry.createType('RuntimeVersion').toHex(), + state_getStorage: (storage: MockStateDb, [key]: string[]) => u8aToHex(storage[key]), + system_chain: () => 'mockChain', + system_health: () => ({}), + system_name: () => 'mockClient', + system_properties: () => ({ ss58Format: 42 }), + system_upgradedToTripleRefCount: () => this.registry.createType('bool', true), + system_version: () => '9.8.7', + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, sort-keys + dev_echo: (_, params: any) => params + }; + + public subscriptions: MockStateSubscriptions = SUBSCRIPTIONS.reduce((subs, name): MockStateSubscriptions => { + subs[name] = { + callbacks: {}, + lastValue: null + }; + + return subs; + }, ({} as MockStateSubscriptions)); + + private subscriptionId = 0; + + private subscriptionMap: Record = {}; + + constructor (registry: Registry) { + this.registry = registry; + + this.init(); + } + + public get hasSubscriptions (): boolean { + return !!true; + } + + public clone (): MockProvider { + throw new Error('Unimplemented'); + } + + public async connect (): Promise { + // noop + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async disconnect (): Promise { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + public get isClonable (): boolean { + return !!false; + } + + public get isConnected (): boolean { + return !!true; + } + + public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { + this.emitter.on(type, sub); + + return (): void => { + this.emitter.removeListener(type, sub); + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async send (method: string, params: unknown[]): Promise { + l.debug(() => ['send', method, params]); + + if (!this.requests[method]) { + throw new Error(`provider.send: Invalid method '${method}'`); + } + + return this.requests[method](this.db, params) as T; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async subscribe (_type: string, method: string, ...params: unknown[]): Promise { + l.debug(() => ['subscribe', method, params]); + + if (!this.subscriptions[method]) { + throw new Error(`provider.subscribe: Invalid method '${method}'`); + } + + const callback = params.pop() as MockStateSubscriptionCallback; + const id = ++this.subscriptionId; + + this.subscriptions[method].callbacks[id] = callback; + this.subscriptionMap[id] = method; + + if (this.subscriptions[method].lastValue !== null) { + callback(null, this.subscriptions[method].lastValue); + } + + return id; + } + + // eslint-disable-next-line @typescript-eslint/require-await + public async unsubscribe (_type: string, _method: string, id: number): Promise { + const sub = this.subscriptionMap[id]; + + l.debug(() => ['unsubscribe', id, sub]); + + if (!sub) { + throw new Error(`Unable to find subscription for ${id}`); + } + + delete this.subscriptionMap[id]; + delete this.subscriptions[sub].callbacks[id]; + + return true; + } + + private init (): void { + const emitEvents: ProviderInterfaceEmitted[] = ['connected', 'disconnected']; + let emitIndex = 0; + let newHead = this.makeBlockHeader(); + let counter = -1; + + const metadata = new Metadata(this.registry, rpcMetadata); + + this.registry.setMetadata(metadata); + + const query = decorateStorage(this.registry, metadata.asLatest, metadata.version); + + // Do something every 1 seconds + this.intervalId = setInterval((): void => { + if (!this.isUpdating) { + return; + } + + // create a new header (next block) + newHead = this.makeBlockHeader(); + + // increment the balances and nonce for each account + keyring.getPairs().forEach(({ publicKey }, index): void => { + this.setStateBn(query['system']['account'](publicKey), newHead.number.toBn().addn(index)); + }); + + // set the timestamp for the current block + this.setStateBn(query['timestamp']['now'](), Math.floor(Date.now() / 1000)); + this.updateSubs('chain_subscribeNewHead', newHead); + + // We emit connected/disconnected at intervals + if (++counter % 2 === 1) { + if (++emitIndex === emitEvents.length) { + emitIndex = 0; + } + + this.emitter.emit(emitEvents[emitIndex]); + } + }, INTERVAL); + } + + private makeBlockHeader (): Header { + const blockNumber = this.prevNumber.addn(1); + const header = this.registry.createType('Header', { + digest: { + logs: [] + }, + extrinsicsRoot: randomAsU8a(), + number: blockNumber, + parentHash: blockNumber.isZero() + ? new Uint8Array(32) + : bnToU8a(this.prevNumber, { bitLength: 256, isLe: false }), + stateRoot: bnToU8a(blockNumber, { bitLength: 256, isLe: false }) + }); + + this.prevNumber = blockNumber; + + return header as unknown as Header; + } + + private setStateBn (key: Uint8Array, value: BN | number): void { + this.db[u8aToHex(key)] = bnToU8a(value, { bitLength: 64, isLe: true }); + } + + private updateSubs (method: string, value: Codec): void { + this.subscriptions[method].lastValue = value; + + Object + .values(this.subscriptions[method].callbacks) + .forEach((cb): void => { + try { + cb(null, value.toJSON()); + } catch (error) { + l.error(`Error on '${method}' subscription`, error); + } + }); + } +} diff --git a/packages/rpc-provider/src/mock/mockHttp.ts b/packages/rpc-provider/src/mock/mockHttp.ts new file mode 100644 index 00000000..3335790f --- /dev/null +++ b/packages/rpc-provider/src/mock/mockHttp.ts @@ -0,0 +1,35 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Mock } from './types.js'; + +import nock from 'nock'; + +interface Request { + code?: number; + method: string; + reply?: Record; +} + +interface HttpMock extends Mock { + post: (uri: string) => { + reply: (code: number, handler: (uri: string, body: { id: string }) => unknown) => HttpMock + } +} + +export const TEST_HTTP_URL = 'http://localhost:9944'; + +export function mockHttp (requests: Request[]): Mock { + nock.cleanAll(); + + return requests.reduce((scope: HttpMock, request: Request) => + scope + .post('/') + .reply(request.code || 200, (_uri: string, body: { id: string }) => { + scope.body = scope.body || {}; + scope.body[request.method] = body; + + return Object.assign({ id: body.id, jsonrpc: '2.0' }, request.reply || {}) as unknown; + }), + nock(TEST_HTTP_URL) as unknown as HttpMock); +} diff --git a/packages/rpc-provider/src/mock/mockWs.ts b/packages/rpc-provider/src/mock/mockWs.ts new file mode 100644 index 00000000..a5c51793 --- /dev/null +++ b/packages/rpc-provider/src/mock/mockWs.ts @@ -0,0 +1,92 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import { Server, WebSocket } from 'mock-socket'; + +import { stringify } from '@polkadot/util'; + +interface Scope { + body: Record>; + requests: number; + server: Server; + done: any; +} + +interface ErrorDef { + id: number; + error: { + code: number; + message: string; + }; +} + +interface ReplyDef { + id: number; + reply: { + result: unknown; + }; +} + +interface RpcBase { + id: number; + jsonrpc: '2.0'; +} + +type RpcError = RpcBase & ErrorDef; +type RpcReply = RpcBase & { result: unknown }; + +export type Request = { method: string } & (ErrorDef | ReplyDef); + +global.WebSocket = WebSocket as typeof global.WebSocket; + +export const TEST_WS_URL = 'ws://localhost:9955'; + +// should be JSONRPC def return +function createError ({ error: { code, message }, id }: ErrorDef): RpcError { + return { + error: { + code, + message + }, + id, + jsonrpc: '2.0' + }; +} + +// should be JSONRPC def return +function createReply ({ id, reply: { result } }: ReplyDef): RpcReply { + return { + id, + jsonrpc: '2.0', + result + }; +} + +// scope definition returned +export function mockWs (requests: Request[], wsUrl: string = TEST_WS_URL): Scope { + const server = new Server(wsUrl); + + let requestCount = 0; + const scope: Scope = { + body: {}, + done: () => new Promise((resolve) => server.stop(resolve)), + requests: 0, + server + }; + + server.on('connection', (socket): void => { + socket.on('message', (body): void => { + const request = requests[requestCount]; + const response = (request as ErrorDef).error + ? createError(request as ErrorDef) + : createReply(request as ReplyDef); + + scope.body[request.method] = body as unknown as Record; + requestCount++; + + socket.send(stringify(response)); + }); + }); + + return scope; +} diff --git a/packages/rpc-provider/src/mock/on.spec.ts b/packages/rpc-provider/src/mock/on.spec.ts new file mode 100644 index 00000000..79f9bc43 --- /dev/null +++ b/packages/rpc-provider/src/mock/on.spec.ts @@ -0,0 +1,43 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { ProviderInterfaceEmitted } from '../types.js'; + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('on', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + + beforeEach((): void => { + mock = new MockProvider(registry); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + // eslint-disable-next-line jest/expect-expect + it('emits both connected and disconnected events', async (): Promise => { + const events: Record = { connected: false, disconnected: false }; + + await new Promise((resolve) => { + const handler = (type: ProviderInterfaceEmitted): void => { + mock.on(type, (): void => { + events[type] = true; + + if (Object.values(events).filter((value): boolean => value).length === 2) { + resolve(true); + } + }); + }; + + handler('connected'); + handler('disconnected'); + }); + }); +}); diff --git a/packages/rpc-provider/src/mock/send.spec.ts b/packages/rpc-provider/src/mock/send.spec.ts new file mode 100644 index 00000000..164d93cb --- /dev/null +++ b/packages/rpc-provider/src/mock/send.spec.ts @@ -0,0 +1,38 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('send', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + + beforeEach((): void => { + mock = new MockProvider(registry); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + it('fails on non-supported methods', (): Promise => { + return mock + .send('something_invalid', []) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Invalid method/); + }); + }); + + it('returns values for mocked requests', (): Promise => { + return mock + .send('system_name', []) + .then((result): void => { + expect(result).toBe('mockClient'); + }); + }); +}); diff --git a/packages/rpc-provider/src/mock/subscribe.spec.ts b/packages/rpc-provider/src/mock/subscribe.spec.ts new file mode 100644 index 00000000..50bfce2b --- /dev/null +++ b/packages/rpc-provider/src/mock/subscribe.spec.ts @@ -0,0 +1,81 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('subscribe', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + + beforeEach((): void => { + mock = new MockProvider(registry); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + it('fails on unknown methods', async (): Promise => { + await mock + .subscribe('test', 'test_notFound') + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Invalid method 'test_notFound'/); + }); + }); + + it('returns a subscription id', async (): Promise => { + await mock + .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) + .then((id): void => { + expect(id).toEqual(1); + }); + }); + + it('calls back with the last known value', async (): Promise => { + mock.isUpdating = false; + mock.subscriptions.chain_subscribeNewHead.lastValue = 'testValue'; + + await new Promise((resolve) => { + mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, value: string): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect(value).toEqual('testValue'); + resolve(true); + }).catch(console.error); + }); + }); + + // eslint-disable-next-line jest/expect-expect + it('calls back with new headers', async (): Promise => { + await new Promise((resolve) => { + mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { + if (header.number === 4) { + resolve(true); + } + }).catch(console.error); + }); + }); + + // eslint-disable-next-line jest/expect-expect + it('handles errors within callbacks gracefully', async (): Promise => { + let hasThrown = false; + + await new Promise((resolve) => { + mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { + if (!hasThrown) { + hasThrown = true; + + throw new Error('testing'); + } + + if (header.number === 3) { + resolve(true); + } + }).catch(console.error); + }); + }); +}); diff --git a/packages/rpc-provider/src/mock/types.ts b/packages/rpc-provider/src/mock/types.ts new file mode 100644 index 00000000..0a7fbc3a --- /dev/null +++ b/packages/rpc-provider/src/mock/types.ts @@ -0,0 +1,36 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Server } from 'mock-socket'; + +export type Global = typeof globalThis & { + WebSocket: typeof WebSocket; + fetch: any; +} + +export interface Mock { + body: Record>; + requests: number; + server: Server; + done: () => Promise; +} + +export type MockStateSubscriptionCallback = (error: Error | null, value: any) => void; + +export interface MockStateSubscription { + callbacks: Record; + lastValue: any; +} + +export interface MockStateSubscriptions { + // known + chain_subscribeNewHead: MockStateSubscription; + state_subscribeStorage: MockStateSubscription; + + // others + [key: string]: MockStateSubscription; +} + +export type MockStateDb = Record; + +export type MockStateRequests = Record string>; diff --git a/packages/rpc-provider/src/mock/unsubscribe.spec.ts b/packages/rpc-provider/src/mock/unsubscribe.spec.ts new file mode 100644 index 00000000..35a9cf2a --- /dev/null +++ b/packages/rpc-provider/src/mock/unsubscribe.spec.ts @@ -0,0 +1,57 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { TypeRegistry } from '@polkadot/types/create'; + +import { MockProvider } from './index.js'; + +describe('unsubscribe', (): void => { + const registry = new TypeRegistry(); + let mock: MockProvider; + let id: number; + + beforeEach((): Promise => { + mock = new MockProvider(registry); + + return mock + .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) + .then((_id): void => { + id = _id; + }); + }); + + afterEach(async () => { + await mock.disconnect(); + }); + + it('fails on unknown ids', async (): Promise => { + await mock + .unsubscribe('chain_newHead', 'chain_subscribeNewHead', 5) + .catch((error): boolean => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Unable to find/); + + return false; + }); + }); + + // eslint-disable-next-line jest/expect-expect + it('unsubscribes successfully', async (): Promise => { + await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id); + }); + + it('fails on double unsubscribe', async (): Promise => { + await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) + .then((): Promise => + mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) + ) + .catch((error): boolean => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/Unable to find/); + + return false; + }); + }); +}); diff --git a/packages/rpc-provider/src/mod.ts b/packages/rpc-provider/src/mod.ts new file mode 100644 index 00000000..aa7b729d --- /dev/null +++ b/packages/rpc-provider/src/mod.ts @@ -0,0 +1,4 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export * from './index.js'; diff --git a/packages/rpc-provider/src/packageDetect.ts b/packages/rpc-provider/src/packageDetect.ts new file mode 100644 index 00000000..5c2b7116 --- /dev/null +++ b/packages/rpc-provider/src/packageDetect.ts @@ -0,0 +1,12 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Do not edit, auto-generated by @polkadot/dev +// (packageInfo imports will be kept as-is, user-editable) + +import { packageInfo as typesInfo } from '@polkadot/types/packageInfo'; +import { detectPackage } from '@polkadot/util'; + +import { packageInfo } from './packageInfo.js'; + +detectPackage(packageInfo, null, [typesInfo]); diff --git a/packages/rpc-provider/src/types.ts b/packages/rpc-provider/src/types.ts new file mode 100644 index 00000000..ad030ec4 --- /dev/null +++ b/packages/rpc-provider/src/types.ts @@ -0,0 +1,99 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export interface JsonRpcObject { + id: number; + jsonrpc: '2.0'; +} + +export interface JsonRpcRequest extends JsonRpcObject { + method: string; + params: unknown[]; +} + +export interface JsonRpcResponseBaseError { + code: number; + data?: number | string; + message: string; +} + +export interface RpcErrorInterface { + code: number; + data?: T; + message: string; + stack: string; +} + +interface JsonRpcResponseSingle { + error?: JsonRpcResponseBaseError; + result: T; +} + +interface JsonRpcResponseSubscription { + method?: string; + params: { + error?: JsonRpcResponseBaseError; + result: T; + subscription: number | string; + }; +} + +export type JsonRpcResponseBase = JsonRpcResponseSingle & JsonRpcResponseSubscription; + +export type JsonRpcResponse = JsonRpcObject & JsonRpcResponseBase; + +export type ProviderInterfaceCallback = (error: Error | null, result: any) => void; + +export type ProviderInterfaceEmitted = 'connected' | 'disconnected' | 'error'; + +export type ProviderInterfaceEmitCb = (value?: any) => any; + +export interface ProviderInterface { + /** true if the provider supports subscriptions (not available for HTTP) */ + readonly hasSubscriptions: boolean; + /** true if the clone() functionality is available on the provider */ + readonly isClonable: boolean; + /** true if the provider is currently connected (ws/sc has connection logic) */ + readonly isConnected: boolean; + /** (optional) stats for the provider with connections/bytes */ + readonly stats?: ProviderStats; + + clone (): ProviderInterface; + connect (): Promise; + disconnect (): Promise; + on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void; + send (method: string, params: unknown[], isCacheable?: boolean): Promise; + subscribe (type: string, method: string, params: unknown[], cb: ProviderInterfaceCallback): Promise; + unsubscribe (type: string, method: string, id: number | string): Promise; +} + +/** Stats for a specific endpoint */ +export interface EndpointStats { + /** The total number of bytes sent */ + bytesRecv: number; + /** The total number of bytes received */ + bytesSent: number; + /** The number of cached/in-progress requests made */ + cached: number; + /** The number of errors found */ + errors: number; + /** The number of requests */ + requests: number; + /** The number of subscriptions */ + subscriptions: number; + /** The number of request timeouts */ + timeout: number; +} + +/** Overall stats for the provider */ +export interface ProviderStats { + /** Details for the active/open requests */ + active: { + /** Number of active requests */ + requests: number; + /** Number of active subscriptions */ + subscriptions: number; + }; + /** The total requests that have been made */ + total: EndpointStats; +} diff --git a/packages/rpc-provider/src/ws/connect.spec.ts b/packages/rpc-provider/src/ws/connect.spec.ts new file mode 100644 index 00000000..50a857f2 --- /dev/null +++ b/packages/rpc-provider/src/ws/connect.spec.ts @@ -0,0 +1,167 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Mock } from '../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +const TEST_WS_URL = 'ws://localhost-connect.spec.ts:9988'; + +function sleep (ms = 100): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe('onConnect', (): void => { + let mocks: Mock[]; + let provider: WsProvider | null; + + beforeEach((): void => { + mocks = [mockWs([], TEST_WS_URL)]; + }); + + afterEach(async () => { + if (provider) { + await provider.disconnect(); + await sleep(); + + provider = null; + } + + await Promise.all(mocks.map((m) => m.done())); + await sleep(); + }); + + it('Does not connect when autoConnect is false', async () => { + provider = new WsProvider(TEST_WS_URL, 0); + + await sleep(); + + expect(provider.isConnected).toBe(false); + + await provider.connect(); + await sleep(); + + expect(provider.isConnected).toBe(true); + + await provider.disconnect(); + await sleep(); + + expect(provider.isConnected).toBe(false); + }); + + it('Does connect when autoConnect is true', async () => { + provider = new WsProvider(TEST_WS_URL, 1); + + await sleep(); + + expect(provider.isConnected).toBe(true); + }); + + it('Creates a new WebSocket instance by calling the connect() method', async () => { + provider = new WsProvider(TEST_WS_URL, false); + + expect(provider.isConnected).toBe(false); + expect(mocks[0].server.clients().length).toBe(0); + + await provider.connect(); + await sleep(); + + expect(provider.isConnected).toBe(true); + expect(mocks[0].server.clients()).toHaveLength(1); + }); + + it('Connects to first endpoint when an array is given', async () => { + provider = new WsProvider([TEST_WS_URL], 1); + + await sleep(); + + expect(provider.isConnected).toBe(true); + expect(mocks[0].server.clients()).toHaveLength(1); + }); + + it('Does not allow connect() on already-connected', async () => { + provider = new WsProvider([TEST_WS_URL], 1); + + await sleep(); + + expect(provider.isConnected).toBe(true); + + await expect( + provider.connect() + ).rejects.toThrow(/already connected/); + }); + + it('Connects to the second endpoint when the first is unreachable', async () => { + const endpoints: string[] = ['ws://localhost-unreachable-connect.spec.ts:9956', TEST_WS_URL]; + + provider = new WsProvider(endpoints, 1); + + await sleep(); + + expect(mocks[0].server.clients()).toHaveLength(1); + expect(provider.isConnected).toBe(true); + }); + + it('Connects to the second endpoint when the first is dropped', async () => { + const endpoints: string[] = [TEST_WS_URL, 'ws://localhost-connect.spec.ts:9957']; + + mocks.push(mockWs([], endpoints[1])); + + provider = new WsProvider(endpoints, 1); + + await sleep(); + + // Check that first server is connected + expect(mocks[0].server.clients()).toHaveLength(1); + expect(mocks[1].server.clients()).toHaveLength(0); + + // Close connection from first server + mocks[0].server.clients()[0].close(); + + await sleep(); + + // Check that second server is connected + expect(mocks[1].server.clients()).toHaveLength(1); + expect(provider.isConnected).toBe(true); + }); + + it('Round-robin of endpoints on WsProvider', async () => { + const endpoints: string[] = [ + TEST_WS_URL, + 'ws://localhost-connect.spec.ts:9956', + 'ws://localhost-connect.spec.ts:9957', + 'ws://invalid-connect.spec.ts:9956', + 'ws://localhost-connect.spec.ts:9958' + ]; + + mocks.push(mockWs([], endpoints[1])); + mocks.push(mockWs([], endpoints[2])); + mocks.push(mockWs([], endpoints[4])); + + const mockNext = [ + mocks[1], + mocks[2], + mocks[3], + mocks[0] + ]; + + provider = new WsProvider(endpoints, 1); + + for (let round = 0; round < 2; round++) { + for (let mock = 0; mock < mocks.length; mock++) { + await sleep(); + + // Wwe are connected, the current mock has the connection and the next doesn't + expect(provider.isConnected).toBe(true); + expect(mocks[mock].server.clients()).toHaveLength(1); + expect(mockNext[mock].server.clients()).toHaveLength(0); + + // Close connection from first server + mocks[mock].server.clients()[0].close(); + } + } + }); +}); diff --git a/packages/rpc-provider/src/ws/errors.ts b/packages/rpc-provider/src/ws/errors.ts new file mode 100644 index 00000000..ad5ff6cd --- /dev/null +++ b/packages/rpc-provider/src/ws/errors.ts @@ -0,0 +1,41 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// from https://stackoverflow.com/questions/19304157/getting-the-reason-why-websockets-closed-with-close-code-1006 + +const known: Record = { + 1000: 'Normal Closure', + 1001: 'Going Away', + 1002: 'Protocol Error', + 1003: 'Unsupported Data', + 1004: '(For future)', + 1005: 'No Status Received', + 1006: 'Abnormal Closure', + 1007: 'Invalid frame payload data', + 1008: 'Policy Violation', + 1009: 'Message too big', + 1010: 'Missing Extension', + 1011: 'Internal Error', + 1012: 'Service Restart', + 1013: 'Try Again Later', + 1014: 'Bad Gateway', + 1015: 'TLS Handshake' +}; + +export function getWSErrorString (code: number): string { + if (code >= 0 && code <= 999) { + return '(Unused)'; + } else if (code >= 1016) { + if (code <= 1999) { + return '(For WebSocket standard)'; + } else if (code <= 2999) { + return '(For WebSocket extensions)'; + } else if (code <= 3999) { + return '(For libraries and frameworks)'; + } else if (code <= 4999) { + return '(For applications)'; + } + } + + return known[code] || '(Unknown)'; +} diff --git a/packages/rpc-provider/src/ws/index.spec.ts b/packages/rpc-provider/src/ws/index.spec.ts new file mode 100644 index 00000000..6b5e3905 --- /dev/null +++ b/packages/rpc-provider/src/ws/index.spec.ts @@ -0,0 +1,92 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Mock } from '../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +const TEST_WS_URL = 'ws://localhost-index.spec.ts:9977'; + +let provider: WsProvider | null; +let mock: Mock; + +function createWs (requests: Request[], autoConnect = 1000, headers?: Record, timeout?: number): WsProvider { + mock = mockWs(requests, TEST_WS_URL); + provider = new WsProvider(TEST_WS_URL, autoConnect, headers, timeout); + + return provider; +} + +describe('Ws', (): void => { + afterEach(async () => { + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('returns the connected state', (): void => { + expect( + createWs([]).isConnected + ).toEqual(false); + }); + + // eslint-disable-next-line jest/expect-expect + it('allows you to initialize the provider with custom headers', () => { + createWs([], 100, { foo: 'bar' }); + }); + + // eslint-disable-next-line jest/expect-expect + it('allows you to set custom timeout value for handlers', () => { + const CUSTOM_TIMEOUT_S = 90; + const CUSTOM_TIMEOUT_MS = CUSTOM_TIMEOUT_S * 1000; + + createWs([], 100, { foo: 'bar' }, CUSTOM_TIMEOUT_MS); + }); +}); + +describe('Endpoint Parsing', (): void => { + // eslint-disable-next-line jest/expect-expect + it('Succeeds when WsProvider endpoint is a valid string', () => { + /* eslint-disable no-new */ + new WsProvider(TEST_WS_URL, 0); + }); + + it('Throws when WsProvider endpoint is an invalid string', () => { + expect( + () => new WsProvider('http://127.0.0.1:9955', 0) + ).toThrow(/^Endpoint should start with /); + }); + + // eslint-disable-next-line jest/expect-expect + it('Succeeds when WsProvider endpoint is a valid array', () => { + const endpoints: string[] = ['ws://127.0.0.1:9955', 'wss://testnet.io:9944', 'ws://mychain.com:9933']; + + /* eslint-disable no-new */ + new WsProvider(endpoints, 0); + }); + + it('Throws when WsProvider endpoint is an empty array', () => { + const endpoints: string[] = []; + + expect( + () => new WsProvider(endpoints, 0) + ).toThrow('WsProvider requires at least one Endpoint'); + }); + + it('Throws when WsProvider endpoint is an invalid array', () => { + const endpoints: string[] = ['ws://127.0.0.1:9955', 'http://bad.co:9944', 'ws://mychain.com:9933']; + + expect( + () => new WsProvider(endpoints, 0) + ).toThrow(/^Endpoint should start with /); + }); +}); diff --git a/packages/rpc-provider/src/ws/index.ts b/packages/rpc-provider/src/ws/index.ts new file mode 100644 index 00000000..1cf65fee --- /dev/null +++ b/packages/rpc-provider/src/ws/index.ts @@ -0,0 +1,626 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { Class } from '@polkadot/util/types'; +import type { EndpointStats, JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; + +import { EventEmitter } from 'eventemitter3'; + +import { isChildClass, isNull, isUndefined, logger, noop, objectSpread, stringify } from '@polkadot/util'; +import { xglobal } from '@polkadot/x-global'; +import { WebSocket } from '@polkadot/x-ws'; + +import { RpcCoder } from '../coder/index.js'; +import defaults from '../defaults.js'; +import { DEFAULT_CAPACITY, LRUCache } from '../lru.js'; +import { getWSErrorString } from './errors.js'; + +interface SubscriptionHandler { + callback: ProviderInterfaceCallback; + type: string; +} + +interface WsStateAwaiting { + callback: ProviderInterfaceCallback; + method: string; + params: unknown[]; + start: number; + subscription?: SubscriptionHandler | undefined; +} + +interface WsStateSubscription extends SubscriptionHandler { + method: string; + params: unknown[]; +} + +const ALIASES: Record = { + chain_finalisedHead: 'chain_finalizedHead', + chain_subscribeFinalisedHeads: 'chain_subscribeFinalizedHeads', + chain_unsubscribeFinalisedHeads: 'chain_unsubscribeFinalizedHeads' +}; + +const RETRY_DELAY = 2_500; + +const DEFAULT_TIMEOUT_MS = 60 * 1000; +const TIMEOUT_INTERVAL = 5_000; + +const l = logger('api-ws'); + +/** @internal Clears a Record<*> of all keys, optionally with all callback on clear */ +function eraseRecord (record: Record, cb?: (item: T) => void): void { + Object.keys(record).forEach((key): void => { + if (cb) { + cb(record[key]); + } + + delete record[key]; + }); +} + +/** @internal Creates a default/empty stats object */ +function defaultEndpointStats (): EndpointStats { + return { bytesRecv: 0, bytesSent: 0, cached: 0, errors: 0, requests: 0, subscriptions: 0, timeout: 0 }; +} + +/** + * # @polkadot/rpc-provider/ws + * + * @name WsProvider + * + * @description The WebSocket Provider allows sending requests using WebSocket to a WebSocket RPC server TCP port. Unlike the [[HttpProvider]], it does support subscriptions and allows listening to events such as new blocks or balance changes. + * + * @example + *
+ * + * ```javascript + * import Api from '@polkadot/api/promise'; + * import { WsProvider } from '@polkadot/rpc-provider/ws'; + * + * const provider = new WsProvider('ws://127.0.0.1:9944'); + * const api = new Api(provider); + * ``` + * + * @see [[HttpProvider]] + */ +export class WsProvider implements ProviderInterface { + readonly #callCache: LRUCache; + readonly #coder: RpcCoder; + readonly #endpoints: string[]; + readonly #headers: Record; + readonly #eventemitter: EventEmitter; + readonly #handlers: Record = {}; + readonly #isReadyPromise: Promise; + readonly #stats: ProviderStats; + readonly #waitingForId: Record> = {}; + + #autoConnectMs: number; + #endpointIndex: number; + #endpointStats: EndpointStats; + #isConnected = false; + #subscriptions: Record = {}; + #timeoutId?: ReturnType | null = null; + #websocket: WebSocket | null; + #timeout: number; + + /** + * @param {string | string[]} endpoint The endpoint url. Usually `ws://ip:9944` or `wss://ip:9944`, may provide an array of endpoint strings. + * @param {number | false} autoConnectMs Whether to connect automatically or not (default). Provided value is used as a delay between retries. + * @param {Record} headers The headers provided to the underlying WebSocket + * @param {number} [timeout] Custom timeout value used per request . Defaults to `DEFAULT_TIMEOUT_MS` + */ + constructor (endpoint: string | string[] = defaults.WS_URL, autoConnectMs: number | false = RETRY_DELAY, headers: Record = {}, timeout?: number, cacheCapacity?: number) { + const endpoints = Array.isArray(endpoint) + ? endpoint + : [endpoint]; + + if (endpoints.length === 0) { + throw new Error('WsProvider requires at least one Endpoint'); + } + + endpoints.forEach((endpoint) => { + if (!/^(wss|ws):\/\//.test(endpoint)) { + throw new Error(`Endpoint should start with 'ws://', received '${endpoint}'`); + } + }); + this.#callCache = new LRUCache(cacheCapacity || DEFAULT_CAPACITY); + this.#eventemitter = new EventEmitter(); + this.#autoConnectMs = autoConnectMs || 0; + this.#coder = new RpcCoder(); + this.#endpointIndex = -1; + this.#endpoints = endpoints; + this.#headers = headers; + this.#websocket = null; + this.#stats = { + active: { requests: 0, subscriptions: 0 }, + total: defaultEndpointStats() + }; + this.#endpointStats = defaultEndpointStats(); + this.#timeout = timeout || DEFAULT_TIMEOUT_MS; + + if (autoConnectMs && autoConnectMs > 0) { + this.connectWithRetry().catch(noop); + } + + this.#isReadyPromise = new Promise((resolve): void => { + this.#eventemitter.once('connected', (): void => { + resolve(this); + }); + }); + } + + /** + * @summary `true` when this provider supports subscriptions + */ + public get hasSubscriptions (): boolean { + return !!true; + } + + /** + * @summary `true` when this provider supports clone() + */ + public get isClonable (): boolean { + return !!true; + } + + /** + * @summary Whether the node is connected or not. + * @return {boolean} true if connected + */ + public get isConnected (): boolean { + return this.#isConnected; + } + + /** + * @description Promise that resolves the first time we are connected and loaded + */ + public get isReady (): Promise { + return this.#isReadyPromise; + } + + public get endpoint (): string { + return this.#endpoints[this.#endpointIndex]; + } + + /** + * @description Returns a clone of the object + */ + public clone (): WsProvider { + return new WsProvider(this.#endpoints); + } + + protected selectEndpointIndex (endpoints: string[]): number { + return (this.#endpointIndex + 1) % endpoints.length; + } + + /** + * @summary Manually connect + * @description The [[WsProvider]] connects automatically by default, however if you decided otherwise, you may + * connect manually using this method. + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async connect (): Promise { + if (this.#websocket) { + throw new Error('WebSocket is already connected'); + } + + try { + this.#endpointIndex = this.selectEndpointIndex(this.#endpoints); + + // the as here is Deno-specific - not available on the globalThis + this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) + ? new WebSocket(this.endpoint) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - WS may be an instance of ws, which supports options + : new WebSocket(this.endpoint, undefined, { + headers: this.#headers + }); + + if (this.#websocket) { + this.#websocket.onclose = this.#onSocketClose; + this.#websocket.onerror = this.#onSocketError; + this.#websocket.onmessage = this.#onSocketMessage; + this.#websocket.onopen = this.#onSocketOpen; + } + + // timeout any handlers that have not had a response + this.#timeoutId = setInterval(() => this.#timeoutHandlers(), TIMEOUT_INTERVAL); + } catch (error) { + l.error(error); + + this.#emit('error', error); + + throw error; + } + } + + /** + * @description Connect, never throwing an error, but rather forcing a retry + */ + public async connectWithRetry (): Promise { + if (this.#autoConnectMs > 0) { + try { + await this.connect(); + } catch { + setTimeout((): void => { + this.connectWithRetry().catch(noop); + }, this.#autoConnectMs); + } + } + } + + /** + * @description Manually disconnect from the connection, clearing auto-connect logic + */ + // eslint-disable-next-line @typescript-eslint/require-await + public async disconnect (): Promise { + // switch off autoConnect, we are in manual mode now + this.#autoConnectMs = 0; + + try { + if (this.#websocket) { + // 1000 - Normal closure; the connection successfully completed + this.#websocket.close(1000); + } + } catch (error) { + l.error(error); + + this.#emit('error', error); + + throw error; + } + } + + /** + * @description Returns the connection stats + */ + public get stats (): ProviderStats { + return { + active: { + requests: Object.keys(this.#handlers).length, + subscriptions: Object.keys(this.#subscriptions).length + }, + total: this.#stats.total + }; + } + + public get endpointStats (): EndpointStats { + return this.#endpointStats; + } + + /** + * @summary Listens on events after having subscribed using the [[subscribe]] function. + * @param {ProviderInterfaceEmitted} type Event + * @param {ProviderInterfaceEmitCb} sub Callback + * @return unsubscribe function + */ + public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { + this.#eventemitter.on(type, sub); + + return (): void => { + this.#eventemitter.removeListener(type, sub); + }; + } + + /** + * @summary Send JSON data using WebSockets to configured HTTP Endpoint or queue. + * @param method The RPC methods to execute + * @param params Encoded parameters as applicable for the method + * @param subscription Subscription details (internally used) + */ + public send (method: string, params: unknown[], isCacheable?: boolean, subscription?: SubscriptionHandler): Promise { + this.#endpointStats.requests++; + this.#stats.total.requests++; + + const [id, body] = this.#coder.encodeJson(method, params); + const cacheKey = isCacheable ? `${method}::${stringify(params)}` : ''; + let resultPromise: Promise | null = isCacheable + ? this.#callCache.get(cacheKey) + : null; + + if (!resultPromise) { + resultPromise = this.#send(id, body, method, params, subscription); + + if (isCacheable) { + this.#callCache.set(cacheKey, resultPromise); + } + } else { + this.#endpointStats.cached++; + this.#stats.total.cached++; + } + + return resultPromise; + } + + async #send (id: number, body: string, method: string, params: unknown[], subscription?: SubscriptionHandler): Promise { + return new Promise((resolve, reject): void => { + try { + if (!this.isConnected || this.#websocket === null) { + throw new Error('WebSocket is not connected'); + } + + const callback = (error?: Error | null, result?: T): void => { + error + ? reject(error) + : resolve(result as T); + }; + + l.debug(() => ['calling', method, body]); + + this.#handlers[id] = { + callback, + method, + params, + start: Date.now(), + subscription + }; + + const bytesSent = body.length; + + this.#endpointStats.bytesSent += bytesSent; + this.#stats.total.bytesSent += bytesSent; + + this.#websocket.send(body); + } catch (error) { + this.#endpointStats.errors++; + this.#stats.total.errors++; + + reject(error); + } + }); + } + + /** + * @name subscribe + * @summary Allows subscribing to a specific event. + * + * @example + *
+ * + * ```javascript + * const provider = new WsProvider('ws://127.0.0.1:9944'); + * const rpc = new Rpc(provider); + * + * rpc.state.subscribeStorage([[storage.system.account,
]], (_, values) => { + * console.log(values) + * }).then((subscriptionId) => { + * console.log('balance changes subscription id: ', subscriptionId) + * }) + * ``` + */ + public subscribe (type: string, method: string, params: unknown[], callback: ProviderInterfaceCallback): Promise { + this.#endpointStats.subscriptions++; + this.#stats.total.subscriptions++; + + // subscriptions are not cached, LRU applies to .at() only + return this.send(method, params, false, { callback, type }); + } + + /** + * @summary Allows unsubscribing to subscriptions made with [[subscribe]]. + */ + public async unsubscribe (type: string, method: string, id: number | string): Promise { + const subscription = `${type}::${id}`; + + // FIXME This now could happen with re-subscriptions. The issue is that with a re-sub + // the assigned id now does not match what the API user originally received. It has + // a slight complication in solving - since we cannot rely on the send id, but rather + // need to find the actual subscription id to map it + if (isUndefined(this.#subscriptions[subscription])) { + l.debug(() => `Unable to find active subscription=${subscription}`); + + return false; + } + + delete this.#subscriptions[subscription]; + + try { + return this.isConnected && !isNull(this.#websocket) + ? this.send(method, [id]) + : true; + } catch { + return false; + } + } + + #emit = (type: ProviderInterfaceEmitted, ...args: unknown[]): void => { + this.#eventemitter.emit(type, ...args); + }; + + #onSocketClose = (event: CloseEvent): void => { + const error = new Error(`disconnected from ${this.endpoint}: ${event.code}:: ${event.reason || getWSErrorString(event.code)}`); + + if (this.#autoConnectMs > 0) { + l.error(error.message); + } + + this.#isConnected = false; + + if (this.#websocket) { + this.#websocket.onclose = null; + this.#websocket.onerror = null; + this.#websocket.onmessage = null; + this.#websocket.onopen = null; + this.#websocket = null; + } + + if (this.#timeoutId) { + clearInterval(this.#timeoutId); + this.#timeoutId = null; + } + + // reject all hanging requests + eraseRecord(this.#handlers, (h) => { + try { + h.callback(error, undefined); + } catch (err) { + // does not throw + l.error(err); + } + }); + eraseRecord(this.#waitingForId); + + // Reset stats for active endpoint + this.#endpointStats = defaultEndpointStats(); + + this.#emit('disconnected'); + + if (this.#autoConnectMs > 0) { + setTimeout((): void => { + this.connectWithRetry().catch(noop); + }, this.#autoConnectMs); + } + }; + + #onSocketError = (error: Event): void => { + l.debug(() => ['socket error', error]); + this.#emit('error', error); + }; + + #onSocketMessage = (message: MessageEvent): void => { + l.debug(() => ['received', message.data]); + + const bytesRecv = message.data.length; + + this.#endpointStats.bytesRecv += bytesRecv; + this.#stats.total.bytesRecv += bytesRecv; + + const response = JSON.parse(message.data) as JsonRpcResponse; + + return isUndefined(response.method) + ? this.#onSocketMessageResult(response) + : this.#onSocketMessageSubscribe(response); + }; + + #onSocketMessageResult = (response: JsonRpcResponse): void => { + const handler = this.#handlers[response.id]; + + if (!handler) { + l.debug(() => `Unable to find handler for id=${response.id}`); + + return; + } + + try { + const { method, params, subscription } = handler; + const result = this.#coder.decodeResponse(response); + + // first send the result - in case of subs, we may have an update + // immediately if we have some queued results already + handler.callback(null, result); + + if (subscription) { + const subId = `${subscription.type}::${result}`; + + this.#subscriptions[subId] = objectSpread({}, subscription, { + method, + params + }); + + // if we have a result waiting for this subscription already + if (this.#waitingForId[subId]) { + this.#onSocketMessageSubscribe(this.#waitingForId[subId]); + } + } + } catch (error) { + this.#endpointStats.errors++; + this.#stats.total.errors++; + + handler.callback(error as Error, undefined); + } + + delete this.#handlers[response.id]; + }; + + #onSocketMessageSubscribe = (response: JsonRpcResponse): void => { + if (!response.method) { + throw new Error('No method found in JSONRPC response'); + } + + const method = ALIASES[response.method] || response.method; + const subId = `${method}::${response.params.subscription}`; + const handler = this.#subscriptions[subId]; + + if (!handler) { + // store the JSON, we could have out-of-order subid coming in + this.#waitingForId[subId] = response; + + l.debug(() => `Unable to find handler for subscription=${subId}`); + + return; + } + + // housekeeping + delete this.#waitingForId[subId]; + + try { + const result = this.#coder.decodeResponse(response); + + handler.callback(null, result); + } catch (error) { + this.#endpointStats.errors++; + this.#stats.total.errors++; + + handler.callback(error as Error, undefined); + } + }; + + #onSocketOpen = (): boolean => { + if (this.#websocket === null) { + throw new Error('WebSocket cannot be null in onOpen'); + } + + l.debug(() => ['connected to', this.endpoint]); + + this.#isConnected = true; + + this.#resubscribe(); + + this.#emit('connected'); + + return true; + }; + + #resubscribe = (): void => { + const subscriptions = this.#subscriptions; + + this.#subscriptions = {}; + + Promise.all(Object.keys(subscriptions).map(async (id): Promise => { + const { callback, method, params, type } = subscriptions[id]; + + // only re-create subscriptions which are not in author (only area where + // transactions are created, i.e. submissions such as 'author_submitAndWatchExtrinsic' + // are not included (and will not be re-broadcast) + if (type.startsWith('author_')) { + return; + } + + try { + await this.subscribe(type, method, params, callback); + } catch (error) { + l.error(error); + } + })).catch(l.error); + }; + + #timeoutHandlers = (): void => { + const now = Date.now(); + const ids = Object.keys(this.#handlers); + + for (let i = 0, count = ids.length; i < count; i++) { + const handler = this.#handlers[ids[i]]; + + if ((now - handler.start) > this.#timeout) { + try { + handler.callback(new Error(`No response received from RPC endpoint in ${this.#timeout / 1000}s`), undefined); + } catch { + // ignore + } + + this.#endpointStats.timeout++; + this.#stats.total.timeout++; + delete this.#handlers[ids[i]]; + } + } + }; +} diff --git a/packages/rpc-provider/src/ws/send.spec.ts b/packages/rpc-provider/src/ws/send.spec.ts new file mode 100644 index 00000000..7de7b395 --- /dev/null +++ b/packages/rpc-provider/src/ws/send.spec.ts @@ -0,0 +1,126 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Global, Mock } from '../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +declare const global: Global; + +const TEST_WS_URL = 'ws://localhost-send.spec.ts:9965'; + +let provider: WsProvider | null; +let mock: Mock; + +function createMock (requests: Request[]): void { + mock = mockWs(requests, TEST_WS_URL); +} + +function createWs (autoConnect = 1000): Promise { + provider = new WsProvider(TEST_WS_URL, autoConnect); + + return provider.isReady; +} + +describe('send', (): void => { + let globalWs: typeof WebSocket; + + beforeEach((): void => { + globalWs = global.WebSocket; + }); + + afterEach(async () => { + global.WebSocket = globalWs; + + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('handles internal errors', (): Promise => { + createMock([{ + id: 1, + method: 'test_body', + reply: { + result: 'ok' + } + }]); + + return createWs().then((ws) => + ws + .send('test_encoding', [{ error: 'send error' }]) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toEqual('send error'); + }) + ); + }); + + it('passes the body through correctly', (): Promise => { + createMock([{ + id: 1, + method: 'test_body', + reply: { + result: 'ok' + } + }]); + + return createWs().then((ws) => + ws + .send('test_body', ['param']) + .then((): void => { + expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (mock.body as any).test_body + ).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}'); + }) + ); + }); + + it('throws error when !response.ok', (): Promise => { + createMock([{ + error: { + code: 666, + message: 'error' + }, + id: 1, + method: 'something' + }]); + + return createWs().then((ws) => + ws + .send('test_error', []) + .catch((error): void => { + // eslint-disable-next-line jest/no-conditional-expect + expect((error as Error).message).toMatch(/666: error/); + }) + ); + }); + + it('adds subscriptions', (): Promise => { + createMock([{ + id: 1, + method: 'test_sub', + reply: { + result: 1 + } + }]); + + return createWs().then((ws) => + ws + .send('test_sub', []) + .then((id): void => { + expect(id).toEqual(1); + }) + ); + }); +}); diff --git a/packages/rpc-provider/src/ws/state.spec.ts b/packages/rpc-provider/src/ws/state.spec.ts new file mode 100644 index 00000000..f7c4d6e4 --- /dev/null +++ b/packages/rpc-provider/src/ws/state.spec.ts @@ -0,0 +1,20 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { WsProvider } from './index.js'; + +describe('state', (): void => { + it('requires an ws:// prefixed endpoint', (): void => { + expect( + () => new WsProvider('http://', 0) + ).toThrow(/with 'ws/); + }); + + it('allows wss:// endpoints', (): void => { + expect( + () => new WsProvider('wss://', 0) + ).not.toThrow(); + }); +}); diff --git a/packages/rpc-provider/src/ws/subscribe.spec.ts b/packages/rpc-provider/src/ws/subscribe.spec.ts new file mode 100644 index 00000000..c1694526 --- /dev/null +++ b/packages/rpc-provider/src/ws/subscribe.spec.ts @@ -0,0 +1,68 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Global, Mock } from './../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +declare const global: Global; + +const TEST_WS_URL = 'ws://localhost-subscribe.test.ts:9933'; + +let provider: WsProvider | null; +let mock: Mock; + +function createMock (requests: Request[]): void { + mock = mockWs(requests, TEST_WS_URL); +} + +function createWs (autoConnect = 1000): Promise { + provider = new WsProvider(TEST_WS_URL, autoConnect); + + return provider.isReady; +} + +describe('subscribe', (): void => { + let globalWs: typeof WebSocket; + + beforeEach((): void => { + globalWs = global.WebSocket; + }); + + afterEach(async () => { + global.WebSocket = globalWs; + + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('adds subscriptions', (): Promise => { + createMock([{ + id: 1, + method: 'test_sub', + reply: { + result: 1 + } + }]); + + return createWs().then((ws) => + ws + .subscribe('type', 'test_sub', [], (cb): void => { + expect(cb).toEqual(expect.anything()); + }) + .then((id): void => { + expect(id).toEqual(1); + }) + ); + }); +}); diff --git a/packages/rpc-provider/src/ws/unsubscribe.spec.ts b/packages/rpc-provider/src/ws/unsubscribe.spec.ts new file mode 100644 index 00000000..9693c5d6 --- /dev/null +++ b/packages/rpc-provider/src/ws/unsubscribe.spec.ts @@ -0,0 +1,100 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Request } from '../mock/mockWs.js'; +import type { Global, Mock } from './../mock/types.js'; + +import { mockWs } from '../mock/mockWs.js'; +import { WsProvider } from './index.js'; + +declare const global: Global; + +const TEST_WS_URL = 'ws://localhost-unsubscribe.test.ts:9933'; + +let provider: WsProvider | null; +let mock: Mock; + +function createMock (requests: Request[]): void { + mock = mockWs(requests, TEST_WS_URL); +} + +function createWs (autoConnect = 1000): Promise { + provider = new WsProvider(TEST_WS_URL, autoConnect); + + return provider.isReady; +} + +describe('subscribe', (): void => { + let globalWs: typeof WebSocket; + + beforeEach((): void => { + globalWs = global.WebSocket; + }); + + afterEach(async () => { + global.WebSocket = globalWs; + + if (mock) { + await mock.done(); + } + + if (provider) { + await provider.disconnect(); + provider = null; + } + }); + + it('removes subscriptions', async (): Promise => { + createMock([ + { + id: 1, + method: 'subscribe_test', + reply: { + result: 1 + } + }, + { + id: 2, + method: 'unsubscribe_test', + reply: { + result: true + } + } + ]); + + await createWs().then((ws) => + ws + .subscribe('test', 'subscribe_test', [], (cb): void => { + expect(cb).toEqual(expect.anything()); + }) + .then((id): Promise => { + return ws.unsubscribe('test', 'subscribe_test', id); + }) + ); + }); + + it('fails when sub not found', (): Promise => { + createMock([{ + id: 1, + method: 'subscribe_test', + reply: { + result: 1 + } + }]); + + return createWs().then((ws) => + ws + .subscribe('test', 'subscribe_test', [], (cb): void => { + expect(cb).toEqual(expect.anything()); + }) + .then((): Promise => { + return ws.unsubscribe('test', 'subscribe_test', 111); + }) + .then((result): void => { + expect(result).toBe(false); + }) + ); + }); +}); diff --git a/packages/rpc-provider/tsconfig.build.json b/packages/rpc-provider/tsconfig.build.json new file mode 100644 index 00000000..e4b9f972 --- /dev/null +++ b/packages/rpc-provider/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "resolveJsonModule": true + }, + "exclude": [ + "**/*.spec.ts", + "**/mod.ts" + ], + "references": [ + { "path": "../types/tsconfig.build.json" }, + { "path": "../types-support/tsconfig.build.json" } + ] +} diff --git a/packages/rpc-provider/tsconfig.spec.json b/packages/rpc-provider/tsconfig.spec.json new file mode 100644 index 00000000..b13f18eb --- /dev/null +++ b/packages/rpc-provider/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": "..", + "outDir": "./build", + "rootDir": "./src", + "emitDeclarationOnly": false, + "resolveJsonModule": true, + "noEmit": true + }, + "include": [ + "**/*.spec.ts" + ], + "references": [ + { "path": "../rpc-provider/tsconfig.build.json" }, + { "path": "../types/tsconfig.build.json" } + ] +} From c91ee622b45253cbfc928b479e1218e2d97c5d17 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Thu, 24 Oct 2024 23:53:43 +0200 Subject: [PATCH 08/50] submitAndWatch WIP --- packages/worker-api/package.json | 1 + packages/worker-api/src/interface.ts | 6 ++ packages/worker-api/src/requests.ts | 16 ++-- .../src/websocketIntegriteeWorker.spec.ts | 63 ++++++++++++++++ .../src/websocketIntegriteeWorker.ts | 73 +++++++++++++++++++ packages/worker-api/src/websocketWorker.ts | 72 +++++++++++++++++- yarn.lock | 3 +- 7 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 packages/worker-api/src/websocketIntegriteeWorker.spec.ts create mode 100644 packages/worker-api/src/websocketIntegriteeWorker.ts diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index ae0f7f59..e4ff47e9 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -28,6 +28,7 @@ "@peculiar/webcrypto": "^1.4.6", "@polkadot/api": "^11.2.1", "@polkadot/keyring": "^12.6.2", + "@polkadot/rpc-provider": "^11.2.1", "@polkadot/types": "^11.2.1", "@polkadot/util": "^12.6.2", "@polkadot/util-crypto": "^12.6.2", diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index 38edfbfa..1a3512cb 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -22,6 +22,12 @@ export interface IWorker extends WebSocketAsPromised { registry: () => TypeRegistry } +export interface IWorkerBase { + createType: (apiType: string, obj?: any) => any; + encrypt: (data: Uint8Array) => Promise> + registry: () => TypeRegistry +} + export interface ISubmittableGetter { worker: W; diff --git a/packages/worker-api/src/requests.ts b/packages/worker-api/src/requests.ts index d6a8cc3b..348575c9 100644 --- a/packages/worker-api/src/requests.ts +++ b/packages/worker-api/src/requests.ts @@ -1,6 +1,6 @@ import { createJsonRpcRequest, - type IWorker, type PublicGetterParams, type TrustedGetterParams, + type IWorkerBase, type PublicGetterParams, type TrustedGetterParams, type TrustedSignerOptions } from "./interface.js"; import type { @@ -17,7 +17,7 @@ import type {u32} from "@polkadot/types"; import bs58 from "bs58"; import type {AddressOrPair} from "@polkadot/api-base/types/submittable"; -export const createIntegriteeGetterPublic = (self: IWorker, request: string, publicGetterParams: PublicGetterParams) => { +export const createIntegriteeGetterPublic = (self: IWorkerBase, request: string, publicGetterParams: PublicGetterParams) => { const g = self.createType('IntegriteeGetter', { public: { [request]: publicGetterParams , @@ -26,18 +26,18 @@ export const createIntegriteeGetterPublic = (self: IWorker, request: string, pub return g; } -export const createSignedGetter = async (self: IWorker, request: string, account: AddressOrPair, trustedGetterParams: TrustedGetterParams, options: TrustedSignerOptions) => { +export const createSignedGetter = async (self: IWorkerBase, request: string, account: AddressOrPair, trustedGetterParams: TrustedGetterParams, options: TrustedSignerOptions) => { const trustedGetter = createTrustedGetter(self, request, trustedGetterParams); return await signTrustedGetter(self, account, trustedGetter, options); } -export const createTrustedGetter = (self: IWorker, request: string, params: TrustedGetterParams) => { +export const createTrustedGetter = (self: IWorkerBase, request: string, params: TrustedGetterParams) => { return self.createType('IntegriteeTrustedGetter', { [request]: params }); } -export async function signTrustedGetter(self: IWorker, account: AddressOrPair, getter: IntegriteeTrustedGetter, options?: TrustedSignerOptions): Promise { +export async function signTrustedGetter(self: IWorkerBase, account: AddressOrPair, getter: IntegriteeTrustedGetter, options?: TrustedSignerOptions): Promise { const signature = await signPayload(account, getter.toU8a(), options?.signer); const g = self.createType('IntegriteeGetter', { trusted: { @@ -50,7 +50,7 @@ export async function signTrustedGetter(self: IWorker, account: AddressOrPair, g return g; } -export const createGetterRpc = (self: IWorker, getter: IntegriteeGetter, shard: ShardIdentifier) => { +export const createGetterRpc = (self: IWorkerBase, getter: IntegriteeGetter, shard: ShardIdentifier) => { const r = self.createType( 'Request', { shard: shard, @@ -67,7 +67,7 @@ export type TrustedCallArgs = (BalanceTransferArgs | BalanceUnshieldArgs | Guess export type TrustedCallVariant = [string, string] export const createTrustedCall = ( - self: IWorker, + self: IWorkerBase, trustedCall: TrustedCallVariant, params: TrustedCallArgs ): IntegriteeTrustedCall => { @@ -79,7 +79,7 @@ export const createTrustedCall = ( } export const signTrustedCall = async ( - self: IWorker, + self: IWorkerBase, call: IntegriteeTrustedCall, account: AddressOrPair, shard: ShardIdentifier, diff --git a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts new file mode 100644 index 00000000..0cf9974d --- /dev/null +++ b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts @@ -0,0 +1,63 @@ +import { Keyring } from '@polkadot/api'; +import { cryptoWaitReady } from '@polkadot/util-crypto'; +import {paseoNetwork} from './testUtils/networks.js'; +import { IntegriteeWorker } from './websocketIntegriteeWorker.js'; +import {type KeyringPair} from "@polkadot/keyring/types"; + +describe('worker', () => { + const network = paseoNetwork(); + let keyring: Keyring; + let worker: IntegriteeWorker; + let alice: KeyringPair; + let charlie: KeyringPair; + beforeAll(async () => { + jest.setTimeout(90000); + await cryptoWaitReady(); + keyring = new Keyring({type: 'sr25519'}); + alice = keyring.addFromUri('//Alice', {name: 'Alice default'}); + charlie = keyring.addFromUri('//Charlie', {name: 'Bob default'}); + + worker = new IntegriteeWorker(network.worker, { + keyring: keyring, + }); + }); + + + // skip it, as this requires a worker (and hence a node) to be running + // To my knowledge jest does not have an option to run skipped tests specifically, does it? + // Todo: add proper CI to test this too. + describe('needs worker and node running', () => { + describe('getWorkerPubKey', () => { + it('should return value', async () => { + const result = await worker.getShieldingKey(); + // console.log('Shielding Key', result); + expect(result).toBeDefined(); + }); + }); + + describe('getShardVault', () => { + it('should return value', async () => { + const result = await worker.getShardVault(); + console.log('ShardVault', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('balance unshield should work', () => { + it('should return value', async () => { + const shard = network.chosenCid; + + const result = await worker.balanceUnshieldFunds( + alice, + shard, + network.mrenclave, + alice.address, + charlie.address, + 1100000000000, + ); + console.log('balance unshield result', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + }); +}); diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts new file mode 100644 index 00000000..22dcd84e --- /dev/null +++ b/packages/worker-api/src/websocketIntegriteeWorker.ts @@ -0,0 +1,73 @@ +import type {Hash} from '@polkadot/types/interfaces/runtime'; +import type { + ShardIdentifier, + IntegriteeTrustedCallSigned, +} from '@encointer/types'; +import { + type TrustedSignerOptions, +} from './interface.js'; +import {Worker} from "./websocketWorker.js"; +import { + createTrustedCall, + signTrustedCall, +} from "./requests.js"; +import bs58 from "bs58"; +import type {AddressOrPair} from "@polkadot/api-base/types/submittable"; + +export class IntegriteeWorker extends Worker { + + public async balanceUnshieldFunds( + account: AddressOrPair, + shard: string, + mrenclave: string, + fromIncognitoAddress: string, + toPublicAddress: string, + amount: number, + signerOptions?: TrustedSignerOptions, + ): Promise { + // const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions, requestOptions) + const nonce = signerOptions?.nonce ?? this.createType('u32', 0); + + const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); + const params = this.createType('BalanceUnshieldArgs', [fromIncognitoAddress, toPublicAddress, amount, shardT]) + const call = createTrustedCall(this, ['balance_unshield', 'BalanceUnshieldArgs'], params); + const signed = await signTrustedCall(this, call, account, shardT, mrenclave, nonce, signerOptions); + return this.sendTrustedCall(signed, shardT); + } + + async sendTrustedCall(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier): Promise { + if (this.shieldingKey() == undefined) { + console.debug(`[sentTrustedCall] Setting the shielding pubKey of the worker.`) + await this.getShieldingKey(); + } + + return this.submitAndWatch(call, shard, true); + } + + async submitAndWatch(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier, direct: boolean): Promise { + let top; + if (direct) { + top = this.createType('IntegriteeTrustedOperation', { + direct_call: call + }) + } else { + top = this.createType('IntegriteeTrustedOperation', { + indirect_call: call + }) + } + + console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); + + const cyphertext = await this.encrypt(top.toU8a()); + + const r = this.createType( + 'Request', { shard, cyphertext: cyphertext } + ); + + const hash = await this.subscribe('author_submitAndWatchExtrinsic', [r]) + + console.debug(`[sendTrustedCall] sent result: ${JSON.stringify(r)}`); + + return hash; + } +} diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index 7d5ce61e..ff427284 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -6,13 +6,20 @@ import {compactAddLength, hexToU8a} from '@polkadot/util'; import {options as encointerOptions} from '@encointer/node-api'; -import type {EnclaveFingerprint, RpcReturnValue, Vault} from '@encointer/types'; - -import { type WorkerOptions} from './interface.js'; +import type { + EnclaveFingerprint, + RpcReturnValue, + TrustedOperationStatus, + Vault +} from '@encointer/types'; + +import {type WorkerOptions} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; import type {u8} from "@polkadot/types-codec"; import BN from "bn.js"; import {WsProvider} from "@polkadot/api"; +import type {Hash} from "@polkadot/types/interfaces/runtime"; +import {Keyring} from "@polkadot/keyring"; export class Worker { @@ -20,11 +27,15 @@ export class Worker { #shieldingKey?: CryptoKey; + #keyring?: Keyring; + #ws: WsProvider; constructor(url: string, options: WorkerOptions = {} as WorkerOptions) { this.#registry = new TypeRegistry(); this.#ws = new WsProvider(url); + this.#keyring = (options.keyring || undefined); + if (options.types != undefined) { this.#registry.register(encointerOptions({types: options.types}).types as RegistryTypes); @@ -52,6 +63,16 @@ export class Worker { return this.createType('Vec', compactAddLength(beArray)) } + + public keyring(): Keyring | undefined { + return this.#keyring; + } + + public setKeyring(keyring: Keyring): void { + this.#keyring = keyring; + } + + public registry(): TypeRegistry { return this.#registry } @@ -126,4 +147,49 @@ export class Worker { return returnValue; } + + public async subscribe(method: string, params: unknown[]): Promise { + await this.isReady(); + + return new Promise((async resolve => { + // @ts-ignore + const onStatusChange = (error, result: string) => { + console.debug(`DirectRequestStatus: error ${JSON.stringify(error)}`) + console.debug(`DirectRequestStatus: ${JSON.stringify(result)}`) + + const value = hexToU8a(result); + const returnValue = this.createType('RpcReturnValue', value); + + if (returnValue.isError) { + const errorMsg = this.createType('String', returnValue.value); + throw new Error(`DirectRequestStatus is Error ${errorMsg}`); + } + if (returnValue.isOk) { + const hash = this.createType('Hash', returnValue.value); + resolve(hash) + } + + if (returnValue.isTrustedOperationStatus) { + const status = returnValue.asTrustedOperationStatus; + const hash = this.createType('Hash', returnValue.value); + if (connection_can_be_closed(status)) { + resolve(hash) + } + } + } + + try { + const res = await this.#ws.subscribe('type', + method, params, onStatusChange + ); + console.debug(`{result: ${res}`); + } catch (err) { + console.error(`{error: ${err}}`); + } + })) + } +} + +function connection_can_be_closed(status: TrustedOperationStatus): boolean { + return !(status.isSubmitted || status.isFuture || status.isReady || status.isBroadCast || status.isInvalid) } diff --git a/yarn.lock b/yarn.lock index e04aa9d2..ae6919c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -804,6 +804,7 @@ __metadata: "@peculiar/webcrypto": "npm:^1.4.6" "@polkadot/api": "npm:^11.2.1" "@polkadot/keyring": "npm:^12.6.2" + "@polkadot/rpc-provider": "npm:^11.2.1" "@polkadot/types": "npm:^11.2.1" "@polkadot/util": "npm:^12.6.2" "@polkadot/util-crypto": "npm:^12.6.2" @@ -2208,7 +2209,7 @@ __metadata: languageName: node linkType: hard -"@polkadot/rpc-provider@workspace:packages/rpc-provider": +"@polkadot/rpc-provider@npm:^11.2.1, @polkadot/rpc-provider@workspace:packages/rpc-provider": version: 0.0.0-use.local resolution: "@polkadot/rpc-provider@workspace:packages/rpc-provider" dependencies: From 31a2565cf0e0d957a5fa10b8afe1e81692f4b7bf Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 08:54:27 +0200 Subject: [PATCH 09/50] [websocketIntegriteeWorker] close ws after tests --- packages/worker-api/src/websocketIntegriteeWorker.spec.ts | 6 +++++- packages/worker-api/src/websocketWorker.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts index 0cf9974d..d8aa5678 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts @@ -22,6 +22,10 @@ describe('worker', () => { }); }); + afterAll(async () => { + await worker.closeWs() + }); + // skip it, as this requires a worker (and hence a node) to be running // To my knowledge jest does not have an option to run skipped tests specifically, does it? @@ -57,7 +61,7 @@ describe('worker', () => { ); console.log('balance unshield result', result.toHuman()); expect(result).toBeDefined(); - }); + }, 20000); }); }); }); diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index ff427284..e482ccc9 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -48,6 +48,10 @@ export class Worker { return this.#ws.isReady } + public async closeWs(): Promise { + return this.#ws.disconnect() + } + public async encrypt(data: Uint8Array): Promise> { const dataBE = new BN(data); const dataArrayBE = new Uint8Array(dataBE.toArray()); From 2531f29e6b4c48021e093b9f6f13b010de7c3ea1 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 08:55:01 +0200 Subject: [PATCH 10/50] [websocketIntegriteeWorker] extract resultToRpcReturnValue --- .../src/websocketIntegriteeWorker.ts | 2 +- packages/worker-api/src/websocketWorker.ts | 45 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts index 22dcd84e..738d28c0 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.ts @@ -68,6 +68,6 @@ export class IntegriteeWorker extends Worker { console.debug(`[sendTrustedCall] sent result: ${JSON.stringify(r)}`); - return hash; + return hash.hash; } } diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index e482ccc9..448dfa0a 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -18,7 +18,6 @@ import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; import type {u8} from "@polkadot/types-codec"; import BN from "bn.js"; import {WsProvider} from "@polkadot/api"; -import type {Hash} from "@polkadot/types/interfaces/runtime"; import {Keyring} from "@polkadot/keyring"; export class Worker { @@ -136,23 +135,10 @@ export class Worker { method, params ); - if (result === 'Could not decode request') { - throw new Error(`Worker error: ${result}`); - } - - const value = hexToU8a(result); - const returnValue = this.createType('RpcReturnValue', value); - console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); - - if (returnValue.status.isError) { - const errorMsg = this.createType('String', returnValue.value); - throw new Error(`RPC Error: ${errorMsg}`); - } - - return returnValue; + return this.resultToRpcReturnValue(result); } - public async subscribe(method: string, params: unknown[]): Promise { + public async subscribe(method: string, params: unknown[]): Promise { await this.isReady(); return new Promise((async resolve => { @@ -170,28 +156,51 @@ export class Worker { } if (returnValue.isOk) { const hash = this.createType('Hash', returnValue.value); - resolve(hash) + resolve({hash: hash}) } if (returnValue.isTrustedOperationStatus) { const status = returnValue.asTrustedOperationStatus; const hash = this.createType('Hash', returnValue.value); if (connection_can_be_closed(status)) { - resolve(hash) + resolve({hash: hash}) } } + + throw( new Error(`Hello: ${JSON.stringify(returnValue)}`)); } try { const res = await this.#ws.subscribe('type', method, params, onStatusChange ); + let returnValue = this.resultToRpcReturnValue(res as string); console.debug(`{result: ${res}`); + let topHash = this.createType('Hash', returnValue.value) + + resolve({hash: topHash}) } catch (err) { console.error(`{error: ${err}}`); } })) } + + resultToRpcReturnValue(result: string): RpcReturnValue { + if (result === 'Could not decode request') { + throw new Error(`Worker error: ${result}`); + } + + const value = hexToU8a(result); + const returnValue = this.createType('RpcReturnValue', value); + console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); + + if (returnValue.status.isError) { + const errorMsg = this.createType('String', returnValue.value); + throw new Error(`RPC Error: ${errorMsg}`); + } + + return returnValue; + } } function connection_can_be_closed(status: TrustedOperationStatus): boolean { From 7fc408b9a1d6d23d68954fe884eddd01961a1457 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 09:12:19 +0200 Subject: [PATCH 11/50] [websocketIntegriteeWorker] trustedCall fire&forget works --- packages/worker-api/src/websocketIntegriteeWorker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts index 738d28c0..97835b84 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.ts @@ -64,7 +64,7 @@ export class IntegriteeWorker extends Worker { 'Request', { shard, cyphertext: cyphertext } ); - const hash = await this.subscribe('author_submitAndWatchExtrinsic', [r]) + const hash = await this.send('author_submitAndWatchExtrinsic', [r.toHex()]) console.debug(`[sendTrustedCall] sent result: ${JSON.stringify(r)}`); From fff816ab8ff9fbe15c9d506b8c10db6436b1c508 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 09:17:58 +0200 Subject: [PATCH 12/50] [websocketIntegriteeWorker] submitAndCallback architecture seems to work, but we don't get an update --- packages/worker-api/src/websocketIntegriteeWorker.ts | 2 +- packages/worker-api/src/websocketWorker.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts index 97835b84..4a0acb2e 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.ts @@ -64,7 +64,7 @@ export class IntegriteeWorker extends Worker { 'Request', { shard, cyphertext: cyphertext } ); - const hash = await this.send('author_submitAndWatchExtrinsic', [r.toHex()]) + const hash = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) console.debug(`[sendTrustedCall] sent result: ${JSON.stringify(r)}`); diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index 448dfa0a..fdf6e8e1 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -174,13 +174,14 @@ export class Worker { const res = await this.#ws.subscribe('type', method, params, onStatusChange ); + console.debug(`{res: ${res}`); let returnValue = this.resultToRpcReturnValue(res as string); console.debug(`{result: ${res}`); let topHash = this.createType('Hash', returnValue.value) - - resolve({hash: topHash}) + console.debug(`{topHash: ${topHash}`); } catch (err) { - console.error(`{error: ${err}}`); + console.error(err); + throw(err) } })) } @@ -196,7 +197,7 @@ export class Worker { if (returnValue.status.isError) { const errorMsg = this.createType('String', returnValue.value); - throw new Error(`RPC Error: ${errorMsg}`); + throw new Error(`RPC: ${errorMsg}`); } return returnValue; From da24cab3e6306a0f8784ae93ca7b1df55a2235d3 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 09:25:51 +0200 Subject: [PATCH 13/50] [websocketIntegriteeWorker] improve logging --- packages/worker-api/src/websocketWorker.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index fdf6e8e1..e2a6c63d 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -144,8 +144,9 @@ export class Worker { return new Promise((async resolve => { // @ts-ignore const onStatusChange = (error, result: string) => { - console.debug(`DirectRequestStatus: error ${JSON.stringify(error)}`) - console.debug(`DirectRequestStatus: ${JSON.stringify(result)}`) + resolve({hash: "mz hash"}) + console.log(`DirectRequestStatus: error ${JSON.stringify(error)}`) + console.log(`DirectRequestStatus: ${JSON.stringify(result)}`) const value = hexToU8a(result); const returnValue = this.createType('RpcReturnValue', value); @@ -171,14 +172,13 @@ export class Worker { } try { - const res = await this.#ws.subscribe('type', + const res = await this.#ws.subscribe('Hash', method, params, onStatusChange ); - console.debug(`{res: ${res}`); let returnValue = this.resultToRpcReturnValue(res as string); - console.debug(`{result: ${res}`); + console.debug(`Subscription RpcReturnValue ${JSON.stringify(returnValue)}`); let topHash = this.createType('Hash', returnValue.value) - console.debug(`{topHash: ${topHash}`); + console.debug(`topHash: ${topHash}`); } catch (err) { console.error(err); throw(err) From 1c801e51223f659b1f4ab3336dbb5d3e7754aa14 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 11:42:13 +0200 Subject: [PATCH 14/50] implement getters with the new api --- packages/worker-api/src/interface.ts | 8 ++- .../src/websocketIntegriteeWorker.spec.ts | 28 ++++++++ .../src/websocketIntegriteeWorker.ts | 71 +++++++++++++++++-- packages/worker-api/src/websocketWorker.ts | 27 ++++++- 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index 1a3512cb..33aa3ce4 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -4,7 +4,6 @@ import type {u8} from "@polkadot/types-codec"; import type {TypeRegistry, u32, Vec} from "@polkadot/types"; import type {RegistryTypes, Signer} from "@polkadot/types/types"; import type {AddressOrPair} from "@polkadot/api-base/types/submittable"; -import {Worker} from "./worker.js"; import type { GuessTheNumberPublicGetter, GuessTheNumberTrustedGetter, @@ -22,13 +21,18 @@ export interface IWorker extends WebSocketAsPromised { registry: () => TypeRegistry } +export interface GenericGetter { + + toHex(): string +} + export interface IWorkerBase { createType: (apiType: string, obj?: any) => any; encrypt: (data: Uint8Array) => Promise> registry: () => TypeRegistry } -export interface ISubmittableGetter { +export interface ISubmittableGetter { worker: W; diff --git a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts index d8aa5678..9feb4bee 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts @@ -47,6 +47,34 @@ describe('worker', () => { }); }); + describe('getNonce', () => { + it('should return value', async () => { + const result = await worker.getNonce(alice, network.mrenclave); + console.log(`Nonce: ${JSON.stringify(result)}`); + expect(result).toBeDefined(); + }); + }); + + + describe('getAccountInfo', () => { + it('should return value', async () => { + const result = await worker.getAccountInfo(alice, network.mrenclave); + console.log(`getAccountInfo: ${JSON.stringify(result)}`); + expect(result).toBeDefined(); + }); + }); + + describe('accountInfoGetter', () => { + it('should return value', async () => { + const getter = await worker.accountInfoGetter(charlie, network.mrenclave); + console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); + const result = await getter.send(); + console.log(`getAccountInfo: ${JSON.stringify(result)}`); + expect(result).toBeDefined(); + }); + }); + + describe('balance unshield should work', () => { it('should return value', async () => { const shard = network.chosenCid; diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts index 4a0acb2e..3d94f8dd 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.ts @@ -1,21 +1,47 @@ import type {Hash} from '@polkadot/types/interfaces/runtime'; import type { ShardIdentifier, - IntegriteeTrustedCallSigned, + IntegriteeTrustedCallSigned, IntegriteeGetter, } from '@encointer/types'; import { + type ISubmittableGetter, type JsonRpcRequest, + type TrustedGetterArgs, type TrustedGetterParams, type TrustedSignerOptions, } from './interface.js'; import {Worker} from "./websocketWorker.js"; import { + createSignedGetter, createTrustedCall, signTrustedCall, } from "./requests.js"; import bs58 from "bs58"; import type {AddressOrPair} from "@polkadot/api-base/types/submittable"; +import type {u32} from "@polkadot/types-codec"; +import type {AccountInfo} from "@polkadot/types/interfaces/system"; +import {asString} from "@encointer/util"; export class IntegriteeWorker extends Worker { + public async getNonce(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions): Promise { + const info = await this.getAccountInfo(accountOrPubKey, shard, singerOptions); + return info.nonce; + } + + public async getAccountInfo(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions): Promise { + const getter = await this.accountInfoGetter(accountOrPubKey, shard, singerOptions); + return getter.send(); + } + + public async accountInfoGetter(accountOrPubKey: AddressOrPair, shard: string, signerOptions?: TrustedSignerOptions): Promise> { + const trustedGetterArgs = { + shard: shard, + account: accountOrPubKey, + signer: signerOptions?.signer, + } + return await submittableTrustedGetter(this, 'account_info', accountOrPubKey, trustedGetterArgs, asString(accountOrPubKey), 'AccountInfo'); + } + + public async balanceUnshieldFunds( account: AddressOrPair, shard: string, @@ -25,8 +51,7 @@ export class IntegriteeWorker extends Worker { amount: number, signerOptions?: TrustedSignerOptions, ): Promise { - // const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions, requestOptions) - const nonce = signerOptions?.nonce ?? this.createType('u32', 0); + const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); const params = this.createType('BalanceUnshieldArgs', [fromIncognitoAddress, toPublicAddress, amount, shardT]) @@ -64,10 +89,44 @@ export class IntegriteeWorker extends Worker { 'Request', { shard, cyphertext: cyphertext } ); - const hash = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) + const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) + + // const returnValue = await this.send('author_submitExtrinsic', [r.toHex()]) + + console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); - console.debug(`[sendTrustedCall] sent result: ${JSON.stringify(r)}`); + return this.createType('Hash', returnValue.value); + } +} + +async function submittableTrustedGetter (self: W, request: string, account: AddressOrPair, args: TrustedGetterArgs, trustedGetterParams: TrustedGetterParams, returnType: string): Promise> { + const {shard} = args; + const shardT = self.createType('ShardIdentifier', bs58.decode(shard)); + const signedGetter = await createSignedGetter(self, request, account, trustedGetterParams, { signer: args?.signer }) + return new SubmittableGetter(self, shardT, signedGetter, returnType); +} + + +export class SubmittableGetter implements ISubmittableGetter { + worker: W; + shard: ShardIdentifier; + getter: IntegriteeGetter; + returnType: string; + + constructor(worker: W, shard: ShardIdentifier, getter: IntegriteeGetter, returnType: string) { + this.worker = worker; + this.shard = shard; + this.getter = getter; + this.returnType = returnType; + } + + // todo: deprecated + // @ts-ignore + into_rpc(): JsonRpcRequest { + // return createGetterRpc(this.worker, this.getter, this.shard); + } - return hash.hash; + async send(): Promise { + return this.worker.sendGetter(this.getter, this.shard, this.returnType); } } diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index e2a6c63d..64d227f1 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -8,14 +8,14 @@ import {options as encointerOptions} from '@encointer/node-api'; import type { EnclaveFingerprint, - RpcReturnValue, + RpcReturnValue, ShardIdentifier, TrustedOperationStatus, Vault } from '@encointer/types'; -import {type WorkerOptions} from './interface.js'; +import {type GenericGetter, type WorkerOptions} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; -import type {u8} from "@polkadot/types-codec"; +import type {Bytes, u8} from "@polkadot/types-codec"; import BN from "bn.js"; import {WsProvider} from "@polkadot/api"; import {Keyring} from "@polkadot/keyring"; @@ -128,6 +128,18 @@ export class Worker { return this.createType('EnclaveFingerprint', res.value); } + public async sendGetter(getter: Getter, shard: ShardIdentifier, returnType: string): Promise { + const r = this.createType( + 'Request', { + shard: shard, + cyphertext: getter.toHex() + } + ); + const response = await this.send('state_executeGetter', [r.toHex()]) + const value = unwrapWorkerResponse(this, response.value) + return this.createType(returnType, value); + } + public async send(method: string, params: unknown[]): Promise { await this.isReady(); @@ -207,3 +219,12 @@ export class Worker { function connection_can_be_closed(status: TrustedOperationStatus): boolean { return !(status.isSubmitted || status.isFuture || status.isReady || status.isBroadCast || status.isInvalid) } + +/** + * Defaults to return `[]`, which is fine as `createType(api.registry, , [])` + * instantiates the with its default value. + */ +function unwrapWorkerResponse (self: Worker, data: Bytes) { + const dataTyped = self.createType('Option', data) + return dataTyped.unwrapOrDefault(); +} From 03fa309e3eb4d7a4ee99c9e4709c4a03e21e82bb Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 12:01:34 +0200 Subject: [PATCH 15/50] fix tests by resolving shard vs mrenclave ambiguity --- packages/worker-api/src/testUtils/networks.ts | 4 ++-- .../worker-api/src/websocketIntegriteeWorker.spec.ts | 8 ++++---- packages/worker-api/src/websocketWorker.spec.ts | 12 +++++++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/worker-api/src/testUtils/networks.ts b/packages/worker-api/src/testUtils/networks.ts index 42d4b43d..b29f6c90 100644 --- a/packages/worker-api/src/testUtils/networks.ts +++ b/packages/worker-api/src/testUtils/networks.ts @@ -52,8 +52,8 @@ export const paseoNetwork = () => { // reverse proxy to the worker worker: 'wss://scv1.paseo.api.incognitee.io:443', genesisHash: '', - mrenclave: '5wePd1LYa5M49ghwgZXs55cepKbJKhj5xfzQGfPeMS7c', - // abused as shard vault + mrenclave: '5BUCG8UXdgjWDDFQUd5kuRwnubDnMFhYEdbgxDZTnrBx', + shard: '5wePd1LYa5M49ghwgZXs55cepKbJKhj5xfzQGfPeMS7c', chosenCid: '5wePd1LYa5M49ghwgZXs55cepKbJKhj5xfzQGfPeMS7c', customTypes: {}, palletOverrides: {} diff --git a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts index 9feb4bee..d9c6aa94 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts @@ -49,7 +49,7 @@ describe('worker', () => { describe('getNonce', () => { it('should return value', async () => { - const result = await worker.getNonce(alice, network.mrenclave); + const result = await worker.getNonce(alice, network.shard); console.log(`Nonce: ${JSON.stringify(result)}`); expect(result).toBeDefined(); }); @@ -58,7 +58,7 @@ describe('worker', () => { describe('getAccountInfo', () => { it('should return value', async () => { - const result = await worker.getAccountInfo(alice, network.mrenclave); + const result = await worker.getAccountInfo(alice, network.shard); console.log(`getAccountInfo: ${JSON.stringify(result)}`); expect(result).toBeDefined(); }); @@ -66,7 +66,7 @@ describe('worker', () => { describe('accountInfoGetter', () => { it('should return value', async () => { - const getter = await worker.accountInfoGetter(charlie, network.mrenclave); + const getter = await worker.accountInfoGetter(charlie, network.shard); console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); const result = await getter.send(); console.log(`getAccountInfo: ${JSON.stringify(result)}`); @@ -77,7 +77,7 @@ describe('worker', () => { describe('balance unshield should work', () => { it('should return value', async () => { - const shard = network.chosenCid; + const shard = network.shard; const result = await worker.balanceUnshieldFunds( alice, diff --git a/packages/worker-api/src/websocketWorker.spec.ts b/packages/worker-api/src/websocketWorker.spec.ts index 89e1e642..25ab6346 100644 --- a/packages/worker-api/src/websocketWorker.spec.ts +++ b/packages/worker-api/src/websocketWorker.spec.ts @@ -1,6 +1,7 @@ import { cryptoWaitReady } from '@polkadot/util-crypto'; import {paseoNetwork} from './testUtils/networks.js'; import { Worker } from './websocketWorker.js'; +import bs58 from "bs58"; describe('worker', () => { const network = paseoNetwork(); @@ -12,6 +13,10 @@ describe('worker', () => { worker = new Worker(network.worker); }); + afterAll(async () => { + await worker.closeWs() + }); + // skip it, as this requires a worker (and hence a node) to be running // To my knowledge jest does not have an option to run skipped tests specifically, does it? // Todo: add proper CI to test this too. @@ -34,9 +39,10 @@ describe('worker', () => { describe('getFingerprint', () => { it('should return value', async () => { - const result = await worker.getFingerprint(); - console.log('Fingerprint', result.toString()); - expect(result).toBeDefined(); + const mrenclave = await worker.getFingerprint(); + + console.log('Fingerprint', bs58.encode(mrenclave.toU8a())); + expect(mrenclave).toBeDefined(); }); }); }); From 8d9f02b39828ffb9f45ce7ac801076d9f70ba718 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:06:43 +0200 Subject: [PATCH 16/50] properly reject upon priority error --- packages/worker-api/src/websocketWorker.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index 64d227f1..98719b40 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -153,9 +153,8 @@ export class Worker { public async subscribe(method: string, params: unknown[]): Promise { await this.isReady(); - return new Promise((async resolve => { - // @ts-ignore - const onStatusChange = (error, result: string) => { + return new Promise( async (resolve, reject) => { + const onStatusChange = (error: Error | null, result: string) => { resolve({hash: "mz hash"}) console.log(`DirectRequestStatus: error ${JSON.stringify(error)}`) console.log(`DirectRequestStatus: ${JSON.stringify(result)}`) @@ -193,9 +192,9 @@ export class Worker { console.debug(`topHash: ${topHash}`); } catch (err) { console.error(err); - throw(err) + reject(err); } - })) + }) } resultToRpcReturnValue(result: string): RpcReturnValue { From 8e064c4759ed2ef84aa7ea2916c4e9fdfe36b091 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:12:34 +0200 Subject: [PATCH 17/50] fix tests in old integriteeWorker --- .../worker-api/src/integriteeWorker.spec.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/worker-api/src/integriteeWorker.spec.ts b/packages/worker-api/src/integriteeWorker.spec.ts index 0c3e3ff4..5eb72316 100644 --- a/packages/worker-api/src/integriteeWorker.spec.ts +++ b/packages/worker-api/src/integriteeWorker.spec.ts @@ -1,6 +1,6 @@ import { Keyring } from '@polkadot/api'; import { cryptoWaitReady } from '@polkadot/util-crypto'; -import {localDockerNetwork} from './testUtils/networks.js'; +import {paseoNetwork} from './testUtils/networks.js'; import { IntegriteeWorker } from './integriteeWorker.js'; import WS from 'websocket'; import {type KeyringPair} from "@polkadot/keyring/types"; @@ -8,7 +8,7 @@ import {type KeyringPair} from "@polkadot/keyring/types"; const {w3cwebsocket: WebSocket} = WS; describe('worker', () => { - const network = localDockerNetwork(); + const network = paseoNetwork(); let keyring: Keyring; let worker: IntegriteeWorker; let alice: KeyringPair; @@ -40,7 +40,7 @@ describe('worker', () => { // skip it, as this requires a worker (and hence a node) to be running // To my knowledge jest does not have an option to run skipped tests specifically, does it? // Todo: add proper CI to test this too. - describe.skip('needs worker and node running', () => { + describe('needs worker and node running', () => { describe('getWorkerPubKey', () => { it('should return value', async () => { const result = await worker.getShieldingKey(); @@ -59,7 +59,7 @@ describe('worker', () => { describe('getNonce', () => { it('should return value', async () => { - const result = await worker.getNonce(alice, network.mrenclave); + const result = await worker.getNonce(alice, network.shard); console.log('Nonce', result); expect(result).toBeDefined(); }); @@ -68,7 +68,7 @@ describe('worker', () => { describe('getAccountInfo', () => { it('should return value', async () => { - const result = await worker.getAccountInfo(alice, network.mrenclave); + const result = await worker.getAccountInfo(alice, network.shard); console.log('getAccountInfo', result); expect(result).toBeDefined(); }); @@ -76,7 +76,7 @@ describe('worker', () => { describe('accountInfoGetter', () => { it('should return value', async () => { - const getter = await worker.accountInfoGetter(charlie, network.mrenclave); + const getter = await worker.accountInfoGetter(charlie, network.shard); console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); const result = await getter.send(); console.log('getAccountInfo:', result); @@ -86,7 +86,7 @@ describe('worker', () => { describe('parentchainsInfoGetter', () => { it('should return value', async () => { - const getter = worker.parentchainsInfoGetter(network.mrenclave); + const getter = worker.parentchainsInfoGetter(network.shard); console.log(`parentchainsInfoGetter: ${JSON.stringify(getter)}`); const result = await getter.send(); console.log('parentchainsInfoGetter:', result); @@ -96,7 +96,7 @@ describe('worker', () => { describe('guessTheNumberInfoGetter', () => { it('should return value', async () => { - const getter = worker.guessTheNumberInfoGetter(network.mrenclave); + const getter = worker.guessTheNumberInfoGetter(network.shard); console.log(`GuessTheNumberInfo: ${JSON.stringify(getter)}`); const result = await getter.send(); console.log('GuessTheNumberInfo:', result); @@ -106,7 +106,7 @@ describe('worker', () => { describe('guessTheNumberAttemptsGetter', () => { it('should return value', async () => { - const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.mrenclave); + const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.shard); console.log(`Attempts: ${JSON.stringify(getter)}`); const result = await getter.send(); console.log('Attempts:', result); @@ -116,7 +116,7 @@ describe('worker', () => { describe('balance transfer should work', () => { it('should return value', async () => { - const shard = network.chosenCid; + const shard = network.shard; const result = await worker.trustedBalanceTransfer( alice, shard, @@ -132,7 +132,7 @@ describe('worker', () => { describe('balance unshield should work', () => { it('should return value', async () => { - const shard = network.chosenCid; + const shard = network.shard; const result = await worker.balanceUnshieldFunds( alice, @@ -149,7 +149,7 @@ describe('worker', () => { describe('guess the number should work', () => { it('should return value', async () => { - const shard = network.chosenCid; + const shard = network.shard; const result = await worker.guessTheNumber( alice, From 9416f1ae6668ad30b4d8ab020d3eef04933e2506 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:19:42 +0200 Subject: [PATCH 18/50] update localDocker network with shard --- packages/worker-api/src/testUtils/networks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/worker-api/src/testUtils/networks.ts b/packages/worker-api/src/testUtils/networks.ts index b29f6c90..efe7fe45 100644 --- a/packages/worker-api/src/testUtils/networks.ts +++ b/packages/worker-api/src/testUtils/networks.ts @@ -40,6 +40,7 @@ export const localDockerNetwork = () => { worker: 'wss://127.0.0.1:2000', genesisHash: '0x388c446a804e24e77ae89f5bb099edb60cacc2ac7c898ce175bdaa08629c1439', mrenclave: 'GopQCtWihHQ8Xw1sZP6DTWMDQuaijSBE2NM8GU2U4Erc', + shard: 'GopQCtWihHQ8Xw1sZP6DTWMDQuaijSBE2NM8GU2U4Erc', chosenCid: 'GopQCtWihHQ8Xw1sZP6DTWMDQuaijSBE2NM8GU2U4Erc', customTypes: {}, palletOverrides: {} From ed48e7a327ae55f9c814c323d214621da9127cc6 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:38:13 +0200 Subject: [PATCH 19/50] move rpc-provider to worker --- packages/rpc-provider/package.json | 43 ------------------- packages/rpc-provider/src/bundle.ts | 7 --- packages/rpc-provider/src/packageDetect.ts | 12 ------ .../src}/rpc-provider/README.md | 0 .../worker-api/src/rpc-provider/src/bundle.ts | 4 ++ .../src/coder/decodeResponse.spec.ts | 0 .../rpc-provider/src/coder/encodeJson.spec.ts | 0 .../src/coder/encodeObject.spec.ts | 0 .../src}/rpc-provider/src/coder/error.spec.ts | 0 .../src}/rpc-provider/src/coder/error.ts | 0 .../src}/rpc-provider/src/coder/index.ts | 0 .../src}/rpc-provider/src/defaults.ts | 0 .../src}/rpc-provider/src/index.ts | 2 - .../src}/rpc-provider/src/lru.spec.ts | 0 .../src}/rpc-provider/src/lru.ts | 0 .../src}/rpc-provider/src/mock/index.ts | 0 .../src}/rpc-provider/src/mock/mockHttp.ts | 0 .../src}/rpc-provider/src/mock/mockWs.ts | 0 .../src}/rpc-provider/src/mock/on.spec.ts | 0 .../src}/rpc-provider/src/mock/send.spec.ts | 0 .../rpc-provider/src/mock/subscribe.spec.ts | 0 .../src}/rpc-provider/src/mock/types.ts | 0 .../rpc-provider/src/mock/unsubscribe.spec.ts | 0 .../src}/rpc-provider/src/mod.ts | 0 .../src}/rpc-provider/src/types.ts | 0 .../src}/rpc-provider/src/ws/connect.spec.ts | 0 .../src}/rpc-provider/src/ws/errors.ts | 0 .../src}/rpc-provider/src/ws/index.spec.ts | 0 .../src}/rpc-provider/src/ws/index.ts | 0 .../src}/rpc-provider/src/ws/send.spec.ts | 0 .../src}/rpc-provider/src/ws/state.spec.ts | 0 .../rpc-provider/src/ws/subscribe.spec.ts | 0 .../rpc-provider/src/ws/unsubscribe.spec.ts | 0 .../src}/rpc-provider/tsconfig.build.json | 0 .../src}/rpc-provider/tsconfig.spec.json | 0 35 files changed, 4 insertions(+), 64 deletions(-) delete mode 100644 packages/rpc-provider/package.json delete mode 100644 packages/rpc-provider/src/bundle.ts delete mode 100644 packages/rpc-provider/src/packageDetect.ts rename packages/{ => worker-api/src}/rpc-provider/README.md (100%) create mode 100644 packages/worker-api/src/rpc-provider/src/bundle.ts rename packages/{ => worker-api/src}/rpc-provider/src/coder/decodeResponse.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/coder/encodeJson.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/coder/encodeObject.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/coder/error.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/coder/error.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/coder/index.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/defaults.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/index.ts (82%) rename packages/{ => worker-api/src}/rpc-provider/src/lru.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/lru.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/index.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/mockHttp.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/mockWs.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/on.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/send.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/subscribe.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/types.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mock/unsubscribe.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/mod.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/types.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/connect.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/errors.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/index.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/index.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/send.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/state.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/subscribe.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/src/ws/unsubscribe.spec.ts (100%) rename packages/{ => worker-api/src}/rpc-provider/tsconfig.build.json (100%) rename packages/{ => worker-api/src}/rpc-provider/tsconfig.spec.json (100%) diff --git a/packages/rpc-provider/package.json b/packages/rpc-provider/package.json deleted file mode 100644 index e8d5f46f..00000000 --- a/packages/rpc-provider/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "author": "Jaco Greeff ", - "bugs": "https://github.com/polkadot-js/api/issues", - "description": "Transport providers for the API", - "engines": { - "node": ">=18" - }, - "homepage": "https://github.com/polkadot-js/api/tree/master/packages/rpc-provider#readme", - "license": "Apache-2.0", - "name": "@polkadot/rpc-provider", - "repository": { - "directory": "packages/rpc-provider", - "type": "git", - "url": "https://github.com/polkadot-js/api.git" - }, - "sideEffects": [ - "./packageDetect.js", - "./packageDetect.cjs" - ], - "type": "module", - "version": "11.2.1", - "main": "index.js", - "dependencies": { - "@polkadot/keyring": "^12.6.2", - "@polkadot/types": "11.2.1", - "@polkadot/types-support": "11.2.1", - "@polkadot/util": "^12.6.2", - "@polkadot/util-crypto": "^12.6.2", - "@polkadot/x-fetch": "^12.6.2", - "@polkadot/x-global": "^12.6.2", - "@polkadot/x-ws": "^12.6.2", - "eventemitter3": "^5.0.1", - "mock-socket": "^9.3.1", - "nock": "^13.5.0", - "tslib": "^2.6.2" - }, - "devDependencies": { - "@substrate/connect": "0.8.10" - }, - "optionalDependencies": { - "@substrate/connect": "0.8.10" - } -} diff --git a/packages/rpc-provider/src/bundle.ts b/packages/rpc-provider/src/bundle.ts deleted file mode 100644 index 06b0acaf..00000000 --- a/packages/rpc-provider/src/bundle.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -export { HttpProvider } from './http/index.js'; -export { packageInfo } from './packageInfo.js'; -export { ScProvider } from './substrate-connect/index.js'; -export { WsProvider } from './ws/index.js'; diff --git a/packages/rpc-provider/src/packageDetect.ts b/packages/rpc-provider/src/packageDetect.ts deleted file mode 100644 index 5c2b7116..00000000 --- a/packages/rpc-provider/src/packageDetect.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -// Do not edit, auto-generated by @polkadot/dev -// (packageInfo imports will be kept as-is, user-editable) - -import { packageInfo as typesInfo } from '@polkadot/types/packageInfo'; -import { detectPackage } from '@polkadot/util'; - -import { packageInfo } from './packageInfo.js'; - -detectPackage(packageInfo, null, [typesInfo]); diff --git a/packages/rpc-provider/README.md b/packages/worker-api/src/rpc-provider/README.md similarity index 100% rename from packages/rpc-provider/README.md rename to packages/worker-api/src/rpc-provider/README.md diff --git a/packages/worker-api/src/rpc-provider/src/bundle.ts b/packages/worker-api/src/rpc-provider/src/bundle.ts new file mode 100644 index 00000000..0a4d3a2b --- /dev/null +++ b/packages/worker-api/src/rpc-provider/src/bundle.ts @@ -0,0 +1,4 @@ +// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +export { WsProvider } from './ws/index.js'; diff --git a/packages/rpc-provider/src/coder/decodeResponse.spec.ts b/packages/worker-api/src/rpc-provider/src/coder/decodeResponse.spec.ts similarity index 100% rename from packages/rpc-provider/src/coder/decodeResponse.spec.ts rename to packages/worker-api/src/rpc-provider/src/coder/decodeResponse.spec.ts diff --git a/packages/rpc-provider/src/coder/encodeJson.spec.ts b/packages/worker-api/src/rpc-provider/src/coder/encodeJson.spec.ts similarity index 100% rename from packages/rpc-provider/src/coder/encodeJson.spec.ts rename to packages/worker-api/src/rpc-provider/src/coder/encodeJson.spec.ts diff --git a/packages/rpc-provider/src/coder/encodeObject.spec.ts b/packages/worker-api/src/rpc-provider/src/coder/encodeObject.spec.ts similarity index 100% rename from packages/rpc-provider/src/coder/encodeObject.spec.ts rename to packages/worker-api/src/rpc-provider/src/coder/encodeObject.spec.ts diff --git a/packages/rpc-provider/src/coder/error.spec.ts b/packages/worker-api/src/rpc-provider/src/coder/error.spec.ts similarity index 100% rename from packages/rpc-provider/src/coder/error.spec.ts rename to packages/worker-api/src/rpc-provider/src/coder/error.spec.ts diff --git a/packages/rpc-provider/src/coder/error.ts b/packages/worker-api/src/rpc-provider/src/coder/error.ts similarity index 100% rename from packages/rpc-provider/src/coder/error.ts rename to packages/worker-api/src/rpc-provider/src/coder/error.ts diff --git a/packages/rpc-provider/src/coder/index.ts b/packages/worker-api/src/rpc-provider/src/coder/index.ts similarity index 100% rename from packages/rpc-provider/src/coder/index.ts rename to packages/worker-api/src/rpc-provider/src/coder/index.ts diff --git a/packages/rpc-provider/src/defaults.ts b/packages/worker-api/src/rpc-provider/src/defaults.ts similarity index 100% rename from packages/rpc-provider/src/defaults.ts rename to packages/worker-api/src/rpc-provider/src/defaults.ts diff --git a/packages/rpc-provider/src/index.ts b/packages/worker-api/src/rpc-provider/src/index.ts similarity index 82% rename from packages/rpc-provider/src/index.ts rename to packages/worker-api/src/rpc-provider/src/index.ts index e88d86ba..3fb2827c 100644 --- a/packages/rpc-provider/src/index.ts +++ b/packages/worker-api/src/rpc-provider/src/index.ts @@ -1,6 +1,4 @@ // Copyright 2017-2024 @polkadot/rpc-provider authors & contributors // SPDX-License-Identifier: Apache-2.0 -import './packageDetect.js'; - export * from './bundle.js'; diff --git a/packages/rpc-provider/src/lru.spec.ts b/packages/worker-api/src/rpc-provider/src/lru.spec.ts similarity index 100% rename from packages/rpc-provider/src/lru.spec.ts rename to packages/worker-api/src/rpc-provider/src/lru.spec.ts diff --git a/packages/rpc-provider/src/lru.ts b/packages/worker-api/src/rpc-provider/src/lru.ts similarity index 100% rename from packages/rpc-provider/src/lru.ts rename to packages/worker-api/src/rpc-provider/src/lru.ts diff --git a/packages/rpc-provider/src/mock/index.ts b/packages/worker-api/src/rpc-provider/src/mock/index.ts similarity index 100% rename from packages/rpc-provider/src/mock/index.ts rename to packages/worker-api/src/rpc-provider/src/mock/index.ts diff --git a/packages/rpc-provider/src/mock/mockHttp.ts b/packages/worker-api/src/rpc-provider/src/mock/mockHttp.ts similarity index 100% rename from packages/rpc-provider/src/mock/mockHttp.ts rename to packages/worker-api/src/rpc-provider/src/mock/mockHttp.ts diff --git a/packages/rpc-provider/src/mock/mockWs.ts b/packages/worker-api/src/rpc-provider/src/mock/mockWs.ts similarity index 100% rename from packages/rpc-provider/src/mock/mockWs.ts rename to packages/worker-api/src/rpc-provider/src/mock/mockWs.ts diff --git a/packages/rpc-provider/src/mock/on.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/on.spec.ts similarity index 100% rename from packages/rpc-provider/src/mock/on.spec.ts rename to packages/worker-api/src/rpc-provider/src/mock/on.spec.ts diff --git a/packages/rpc-provider/src/mock/send.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/send.spec.ts similarity index 100% rename from packages/rpc-provider/src/mock/send.spec.ts rename to packages/worker-api/src/rpc-provider/src/mock/send.spec.ts diff --git a/packages/rpc-provider/src/mock/subscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/subscribe.spec.ts similarity index 100% rename from packages/rpc-provider/src/mock/subscribe.spec.ts rename to packages/worker-api/src/rpc-provider/src/mock/subscribe.spec.ts diff --git a/packages/rpc-provider/src/mock/types.ts b/packages/worker-api/src/rpc-provider/src/mock/types.ts similarity index 100% rename from packages/rpc-provider/src/mock/types.ts rename to packages/worker-api/src/rpc-provider/src/mock/types.ts diff --git a/packages/rpc-provider/src/mock/unsubscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/unsubscribe.spec.ts similarity index 100% rename from packages/rpc-provider/src/mock/unsubscribe.spec.ts rename to packages/worker-api/src/rpc-provider/src/mock/unsubscribe.spec.ts diff --git a/packages/rpc-provider/src/mod.ts b/packages/worker-api/src/rpc-provider/src/mod.ts similarity index 100% rename from packages/rpc-provider/src/mod.ts rename to packages/worker-api/src/rpc-provider/src/mod.ts diff --git a/packages/rpc-provider/src/types.ts b/packages/worker-api/src/rpc-provider/src/types.ts similarity index 100% rename from packages/rpc-provider/src/types.ts rename to packages/worker-api/src/rpc-provider/src/types.ts diff --git a/packages/rpc-provider/src/ws/connect.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/connect.spec.ts similarity index 100% rename from packages/rpc-provider/src/ws/connect.spec.ts rename to packages/worker-api/src/rpc-provider/src/ws/connect.spec.ts diff --git a/packages/rpc-provider/src/ws/errors.ts b/packages/worker-api/src/rpc-provider/src/ws/errors.ts similarity index 100% rename from packages/rpc-provider/src/ws/errors.ts rename to packages/worker-api/src/rpc-provider/src/ws/errors.ts diff --git a/packages/rpc-provider/src/ws/index.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/index.spec.ts similarity index 100% rename from packages/rpc-provider/src/ws/index.spec.ts rename to packages/worker-api/src/rpc-provider/src/ws/index.spec.ts diff --git a/packages/rpc-provider/src/ws/index.ts b/packages/worker-api/src/rpc-provider/src/ws/index.ts similarity index 100% rename from packages/rpc-provider/src/ws/index.ts rename to packages/worker-api/src/rpc-provider/src/ws/index.ts diff --git a/packages/rpc-provider/src/ws/send.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/send.spec.ts similarity index 100% rename from packages/rpc-provider/src/ws/send.spec.ts rename to packages/worker-api/src/rpc-provider/src/ws/send.spec.ts diff --git a/packages/rpc-provider/src/ws/state.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/state.spec.ts similarity index 100% rename from packages/rpc-provider/src/ws/state.spec.ts rename to packages/worker-api/src/rpc-provider/src/ws/state.spec.ts diff --git a/packages/rpc-provider/src/ws/subscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/subscribe.spec.ts similarity index 100% rename from packages/rpc-provider/src/ws/subscribe.spec.ts rename to packages/worker-api/src/rpc-provider/src/ws/subscribe.spec.ts diff --git a/packages/rpc-provider/src/ws/unsubscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/unsubscribe.spec.ts similarity index 100% rename from packages/rpc-provider/src/ws/unsubscribe.spec.ts rename to packages/worker-api/src/rpc-provider/src/ws/unsubscribe.spec.ts diff --git a/packages/rpc-provider/tsconfig.build.json b/packages/worker-api/src/rpc-provider/tsconfig.build.json similarity index 100% rename from packages/rpc-provider/tsconfig.build.json rename to packages/worker-api/src/rpc-provider/tsconfig.build.json diff --git a/packages/rpc-provider/tsconfig.spec.json b/packages/worker-api/src/rpc-provider/tsconfig.spec.json similarity index 100% rename from packages/rpc-provider/tsconfig.spec.json rename to packages/worker-api/src/rpc-provider/tsconfig.spec.json From 5329bfc6e41dec3256bd6939e6105ebcf6ee0e64 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:38:48 +0200 Subject: [PATCH 20/50] integrate local rpc-provider --- packages/worker-api/package.json | 11 +++++++++-- packages/worker-api/src/websocketWorker.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index e4ff47e9..63ca642f 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -28,7 +28,6 @@ "@peculiar/webcrypto": "^1.4.6", "@polkadot/api": "^11.2.1", "@polkadot/keyring": "^12.6.2", - "@polkadot/rpc-provider": "^11.2.1", "@polkadot/types": "^11.2.1", "@polkadot/util": "^12.6.2", "@polkadot/util-crypto": "^12.6.2", @@ -36,7 +35,15 @@ "bs58": "^4.0.1", "promised-map": "^1.0.0", "websocket": "^1.0.34", - "websocket-as-promised": "^2.0.1" + "websocket-as-promised": "^2.0.1", + "@polkadot/types-support": "11.2.1", + "@polkadot/x-fetch": "^12.6.2", + "@polkadot/x-global": "^12.6.2", + "@polkadot/x-ws": "^12.6.2", + "eventemitter3": "^5.0.1", + "mock-socket": "^9.3.1", + "nock": "^13.5.0", + "tslib": "^2.6.2" }, "devDependencies": { "@types/bs58": "^4.0.4" diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index 98719b40..ad22ed96 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -17,7 +17,7 @@ import {type GenericGetter, type WorkerOptions} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; import type {Bytes, u8} from "@polkadot/types-codec"; import BN from "bn.js"; -import {WsProvider} from "@polkadot/api"; +import {WsProvider} from "./rpc-provider/src/index.js"; import {Keyring} from "@polkadot/keyring"; export class Worker { From 641b64f0cbfe541cef5d181974c1c53741c6194d Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:39:22 +0200 Subject: [PATCH 21/50] close websockets after test in old worker implementation --- packages/worker-api/src/integriteeWorker.spec.ts | 3 +++ packages/worker-api/src/worker.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/worker-api/src/integriteeWorker.spec.ts b/packages/worker-api/src/integriteeWorker.spec.ts index 5eb72316..0ff1ac20 100644 --- a/packages/worker-api/src/integriteeWorker.spec.ts +++ b/packages/worker-api/src/integriteeWorker.spec.ts @@ -36,6 +36,9 @@ describe('worker', () => { }); }); + afterAll(async () => { + await worker.closeWs() + }); // skip it, as this requires a worker (and hence a node) to be running // To my knowledge jest does not have an option to run skipped tests specifically, does it? diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 1ca34450..88aabae9 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -132,6 +132,10 @@ export class Worker extends WebSocketAsPromised implements IWorker { return this.createType('Vec', compactAddLength(beArray)) } + public async closeWs(): Promise { + await this.close() + } + public registry(): TypeRegistry { return this.#registry } From 154d0caa2bcaf8233dff770622450ae85b22b821 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 13:46:30 +0200 Subject: [PATCH 22/50] able to run test with local rpc-provider --- .../src/rpc-provider/src/ws/index.ts | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/worker-api/src/rpc-provider/src/ws/index.ts b/packages/worker-api/src/rpc-provider/src/ws/index.ts index 1cf65fee..d26ab3e9 100644 --- a/packages/worker-api/src/rpc-provider/src/ws/index.ts +++ b/packages/worker-api/src/rpc-provider/src/ws/index.ts @@ -1,20 +1,24 @@ // Copyright 2017-2024 @polkadot/rpc-provider authors & contributors // SPDX-License-Identifier: Apache-2.0 -import type { Class } from '@polkadot/util/types'; +// import type { Class } from '@polkadot/util/types'; import type { EndpointStats, JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; import { EventEmitter } from 'eventemitter3'; -import { isChildClass, isNull, isUndefined, logger, noop, objectSpread, stringify } from '@polkadot/util'; -import { xglobal } from '@polkadot/x-global'; -import { WebSocket } from '@polkadot/x-ws'; +import { isNull, isUndefined, logger, noop, objectSpread, stringify } from '@polkadot/util'; +// import { xglobal } from '@polkadot/x-global'; +// import { WebSocket } from '@polkadot/x-ws'; import { RpcCoder } from '../coder/index.js'; import defaults from '../defaults.js'; import { DEFAULT_CAPACITY, LRUCache } from '../lru.js'; import { getWSErrorString } from './errors.js'; +import WS from 'websocket'; + +const {w3cwebsocket: WebSocket} = WS; + interface SubscriptionHandler { callback: ProviderInterfaceCallback; type: string; @@ -86,6 +90,7 @@ export class WsProvider implements ProviderInterface { readonly #callCache: LRUCache; readonly #coder: RpcCoder; readonly #endpoints: string[]; + // @ts-ignore - readonly #headers: Record; readonly #eventemitter: EventEmitter; readonly #handlers: Record = {}; @@ -206,14 +211,24 @@ export class WsProvider implements ProviderInterface { try { this.#endpointIndex = this.selectEndpointIndex(this.#endpoints); - // the as here is Deno-specific - not available on the globalThis - this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) - ? new WebSocket(this.endpoint) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - WS may be an instance of ws, which supports options - : new WebSocket(this.endpoint, undefined, { - headers: this.#headers - }); + // // the as here is Deno-specific - not available on the globalThis + // this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) + // ? new WebSocket(this.endpoint) + // // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // // @ts-ignore - WS may be an instance of ws, which supports options + // : new WebSocket(this.endpoint, undefined, { + // headers: this.#headers + // }); + + // @ts-ignore + this.#websocket = new WebSocket( + this.endpoint, + undefined, + undefined, + undefined, + // Allow the worker's self-signed certificate + { rejectUnauthorized: false } + ); if (this.#websocket) { this.#websocket.onclose = this.#onSocketClose; From 62151334c18021770b0a221807e3a7355291d967 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 17:34:33 +0200 Subject: [PATCH 23/50] [rpc-provider] add debug logs --- .../src/rpc-provider/src/ws/index.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/worker-api/src/rpc-provider/src/ws/index.ts b/packages/worker-api/src/rpc-provider/src/ws/index.ts index d26ab3e9..dd88724d 100644 --- a/packages/worker-api/src/rpc-provider/src/ws/index.ts +++ b/packages/worker-api/src/rpc-provider/src/ws/index.ts @@ -360,6 +360,8 @@ export class WsProvider implements ProviderInterface { }; l.debug(() => ['calling', method, body]); + console.log(`['calling', ${method}, ${body}]`); + console.log(`setting handler for id=${id}`); this.#handlers[id] = { callback, @@ -501,6 +503,8 @@ export class WsProvider implements ProviderInterface { const response = JSON.parse(message.data) as JsonRpcResponse; + console.log(`Json Response: ${JSON.stringify(response)}`); + return isUndefined(response.method) ? this.#onSocketMessageResult(response) : this.#onSocketMessageSubscribe(response); @@ -509,8 +513,13 @@ export class WsProvider implements ProviderInterface { #onSocketMessageResult = (response: JsonRpcResponse): void => { const handler = this.#handlers[response.id]; + console.log(`Json Result: ${JSON.stringify(response)}`); + console.log(`handler: ${JSON.stringify(this.#handlers)}`); + + if (!handler) { l.debug(() => `Unable to find handler for id=${response.id}`); + console.log(`Unable to find handler for id=${response.id}`); return; } @@ -525,12 +534,16 @@ export class WsProvider implements ProviderInterface { if (subscription) { const subId = `${subscription.type}::${result}`; + console.log(`subId: ${subId}`); + console.log(`it is as subscription: ${JSON.stringify(subscription)}}`); this.#subscriptions[subId] = objectSpread({}, subscription, { method, params }); + console.log(`subscriptions: ${JSON.stringify(this.#subscriptions)}`); + // if we have a result waiting for this subscription already if (this.#waitingForId[subId]) { this.#onSocketMessageSubscribe(this.#waitingForId[subId]); @@ -560,6 +573,7 @@ export class WsProvider implements ProviderInterface { this.#waitingForId[subId] = response; l.debug(() => `Unable to find handler for subscription=${subId}`); + console.log(`Unable to find handler for subscription=${subId}`); return; } @@ -568,10 +582,16 @@ export class WsProvider implements ProviderInterface { delete this.#waitingForId[subId]; try { + console.log(`Decoding Response=${subId}`); + const result = this.#coder.decodeResponse(response); + console.log(`Decoded Response=${subId}`); + handler.callback(null, result); } catch (error) { + console.log(`Failed to decode response=${(error as Error).message}`); + this.#endpointStats.errors++; this.#stats.total.errors++; From af73e1d9a4956a26981dd2673253519a40065e42 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 17:35:29 +0200 Subject: [PATCH 24/50] successfully implement `author_submitAndWatchExtrinsic` --- packages/worker-api/src/websocketWorker.ts | 33 ++++++++++------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts index ad22ed96..196c4873 100644 --- a/packages/worker-api/src/websocketWorker.ts +++ b/packages/worker-api/src/websocketWorker.ts @@ -155,41 +155,38 @@ export class Worker { return new Promise( async (resolve, reject) => { const onStatusChange = (error: Error | null, result: string) => { - resolve({hash: "mz hash"}) console.log(`DirectRequestStatus: error ${JSON.stringify(error)}`) console.log(`DirectRequestStatus: ${JSON.stringify(result)}`) const value = hexToU8a(result); - const returnValue = this.createType('RpcReturnValue', value); + const directRequestStatus = this.createType('DirectRequestStatus', value); - if (returnValue.isError) { - const errorMsg = this.createType('String', returnValue.value); + if (directRequestStatus.isError) { + const errorMsg = this.createType('String', directRequestStatus.value); throw new Error(`DirectRequestStatus is Error ${errorMsg}`); } - if (returnValue.isOk) { - const hash = this.createType('Hash', returnValue.value); - resolve({hash: hash}) + if (directRequestStatus.isOk) { + // const hash = this.createType('Hash', directRequestStatus.value); + resolve({}) } - if (returnValue.isTrustedOperationStatus) { - const status = returnValue.asTrustedOperationStatus; - const hash = this.createType('Hash', returnValue.value); + if (directRequestStatus.isTrustedOperationStatus) { + console.log(`TrustedOperationStatus: ${directRequestStatus}`) + const status = directRequestStatus.asTrustedOperationStatus; if (connection_can_be_closed(status)) { - resolve({hash: hash}) + resolve({}) } } - - throw( new Error(`Hello: ${JSON.stringify(returnValue)}`)); } try { - const res = await this.#ws.subscribe('Hash', + const res = await this.#ws.subscribe(method, method, params, onStatusChange ); - let returnValue = this.resultToRpcReturnValue(res as string); - console.debug(`Subscription RpcReturnValue ${JSON.stringify(returnValue)}`); - let topHash = this.createType('Hash', returnValue.value) - console.debug(`topHash: ${topHash}`); + // let returnValue = this.resultToRpcReturnValue(res as string); + // console.debug(`Subscription RpcReturnValue ${JSON.stringify(returnValue)}`); + let topHash = this.createType('Hash', res); + console.debug(`resHash: ${topHash}`); } catch (err) { console.error(err); reject(err); From 034167ade22e8d4d19a3e203d70d77e3605b0d38 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 18:53:11 +0200 Subject: [PATCH 25/50] merge the websocketWorker back into the worker --- .../worker-api/src/websocketWorker.spec.ts | 49 ---- packages/worker-api/src/worker.spec.ts | 37 +-- packages/worker-api/src/worker.ts | 263 +++++++++++------- 3 files changed, 167 insertions(+), 182 deletions(-) delete mode 100644 packages/worker-api/src/websocketWorker.spec.ts diff --git a/packages/worker-api/src/websocketWorker.spec.ts b/packages/worker-api/src/websocketWorker.spec.ts deleted file mode 100644 index 25ab6346..00000000 --- a/packages/worker-api/src/websocketWorker.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { cryptoWaitReady } from '@polkadot/util-crypto'; -import {paseoNetwork} from './testUtils/networks.js'; -import { Worker } from './websocketWorker.js'; -import bs58 from "bs58"; - -describe('worker', () => { - const network = paseoNetwork(); - let worker: Worker; - beforeAll(async () => { - jest.setTimeout(90000); - await cryptoWaitReady(); - - worker = new Worker(network.worker); - }); - - afterAll(async () => { - await worker.closeWs() - }); - - // skip it, as this requires a worker (and hence a node) to be running - // To my knowledge jest does not have an option to run skipped tests specifically, does it? - // Todo: add proper CI to test this too. - describe('needs worker and node running', () => { - describe('getWorkerPubKey', () => { - it('should return value', async () => { - const result = await worker.getShieldingKey(); - // console.log('Shielding Key', result); - expect(result).toBeDefined(); - }); - }); - - describe('getShardVault', () => { - it('should return value', async () => { - const result = await worker.getShardVault(); - console.log('ShardVault', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('getFingerprint', () => { - it('should return value', async () => { - const mrenclave = await worker.getFingerprint(); - - console.log('Fingerprint', bs58.encode(mrenclave.toU8a())); - expect(mrenclave).toBeDefined(); - }); - }); - }); -}); diff --git a/packages/worker-api/src/worker.spec.ts b/packages/worker-api/src/worker.spec.ts index 8f20a4ab..e3d9b811 100644 --- a/packages/worker-api/src/worker.spec.ts +++ b/packages/worker-api/src/worker.spec.ts @@ -1,35 +1,19 @@ -import { Keyring } from '@polkadot/api'; import { cryptoWaitReady } from '@polkadot/util-crypto'; import {localDockerNetwork} from './testUtils/networks.js'; -import { Worker } from './worker.js'; -import WS from 'websocket'; +import { Worker } from './websocketWorker.js'; +import bs58 from "bs58"; -const {w3cwebsocket: WebSocket} = WS; - -describe.skip('worker', () => { +describe('worker', () => { const network = localDockerNetwork(); - let keyring: Keyring; let worker: Worker; beforeAll(async () => { jest.setTimeout(90000); await cryptoWaitReady(); - keyring = new Keyring({type: 'sr25519'}); + worker = new Worker(network.worker); + }); - worker = new Worker(network.worker, { - keyring: keyring, - types: network.customTypes, - // @ts-ignore - createWebSocket: (url) => new WebSocket( - url, - undefined, - undefined, - undefined, - // Allow the worker's self-signed certificate, needed in non-reverse proxy setups - // where we talk to the worker directly. - { rejectUnauthorized: false } - ), - api: null, - }); + afterAll(async () => { + await worker.closeWs() }); // skip it, as this requires a worker (and hence a node) to be running @@ -54,9 +38,10 @@ describe.skip('worker', () => { describe('getFingerprint', () => { it('should return value', async () => { - const result = await worker.getFingerprint(); - console.log('Fingerprint', result.toString()); - expect(result).toBeDefined(); + const mrenclave = await worker.getFingerprint(); + + console.log('Fingerprint', bs58.encode(mrenclave.toU8a())); + expect(mrenclave).toBeDefined(); }); }); }); diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 88aabae9..e3313d10 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -1,114 +1,39 @@ import type {Vec} from '@polkadot/types'; import {TypeRegistry} from '@polkadot/types'; import type {RegistryTypes} from '@polkadot/types/types'; -import {Keyring} from '@polkadot/keyring' import {compactAddLength, hexToU8a} from '@polkadot/util'; -import WebSocketAsPromised from 'websocket-as-promised'; import {options as encointerOptions} from '@encointer/node-api'; -import {parseI64F64} from '@encointer/util'; -import type {EnclaveFingerprint, Vault} from '@encointer/types'; +import type { + EnclaveFingerprint, + RpcReturnValue, ShardIdentifier, + TrustedOperationStatus, + Vault +} from '@encointer/types'; -import {type RequestOptions, type IWorker, Request, type WorkerOptions} from './interface.js'; -import {parseBalance} from './parsers.js'; -import {callGetter} from './sendRequest.js'; +import {type GenericGetter, type WorkerOptions} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; -import type {u8} from "@polkadot/types-codec"; +import type {Bytes, u8} from "@polkadot/types-codec"; import BN from "bn.js"; +import {WsProvider} from "./rpc-provider/src/index.js"; +import {Keyring} from "@polkadot/keyring"; -const unwrapWorkerResponse = (self: IWorker, data: string) => { - /// Defaults to return `[]`, which is fine as `createType(api.registry, , [])` - /// instantiates the with its default value. - const dataTyped = self.createType('Option', data) - return dataTyped.unwrapOrDefault(); -} - -const parseGetterResponse = (self: IWorker, responseType: string, data: string) => { - if (data === 'Could not decode request') { - throw new Error(`Worker error: ${data}`); - } - - // console.debug(`Getter response: ${data}`); - const json = JSON.parse(data); - - const value = hexToU8a(json["result"]); - const returnValue = self.createType('RpcReturnValue', value); - console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); - - if (returnValue.status.isError) { - const errorMsg = self.createType('String', returnValue.value); - throw new Error(`RPC Error: ${errorMsg}`); - } - - let parsedData: any; - try { - switch (responseType) { - case 'raw': - parsedData = unwrapWorkerResponse(self, returnValue.value); - break; - case 'BalanceEntry': - parsedData = unwrapWorkerResponse(self, returnValue.value); - parsedData = parseBalance(self, parsedData); - break; - case 'I64F64': - parsedData = unwrapWorkerResponse(self, returnValue.value); - parsedData = parseI64F64(self.createType('i128', parsedData)); - break; - case 'CryptoKey': - const jsonStr = self.createType('String', returnValue.value); - // Todo: For some reason there are 2 non-utf characters, where I don't know where - // they come from currently. - console.debug(`Got shielding key: ${jsonStr.toJSON().substring(2)}`); - parsedData = parseWebCryptoRSA(jsonStr.toJSON().substring(2)); - break - case 'Vault': - parsedData = self.createType(responseType, returnValue.value); - break - case 'EnclaveFingerprint': - parsedData = self.createType(responseType, returnValue.value); - break - case 'TrustedOperationResult': - console.debug(`Got TrustedOperationResult`) - parsedData = self.createType('Hash', returnValue.value); - break - default: - parsedData = unwrapWorkerResponse(self, returnValue.value); - console.debug(`unwrapped data ${parsedData}`); - parsedData = self.createType(responseType, parsedData); - break; - } - } catch (err) { - throw new Error(`Can't parse into ${responseType}:\n${err}`); - } - return parsedData; -} - -export class Worker extends WebSocketAsPromised implements IWorker { +export class Worker { readonly #registry: TypeRegistry; - #keyring?: Keyring; - - #shieldingKey?: CryptoKey + #shieldingKey?: CryptoKey; - rsCount: number; + #keyring?: Keyring; - rqStack: string[]; + #ws: WsProvider; constructor(url: string, options: WorkerOptions = {} as WorkerOptions) { - super(url, { - createWebSocket: (options.createWebSocket || undefined), - packMessage: (data: any) => JSON.stringify(data), - unpackMessage: (data: any) => parseGetterResponse(this, this.rqStack.shift() || '', data), - attachRequestId: (data: any): any => data, - extractRequestId: () => this.rsCount = ++this.rsCount - }); - this.#keyring = (options.keyring || undefined); this.#registry = new TypeRegistry(); - this.rsCount = 0; - this.rqStack = [] as string[] + this.#ws = new WsProvider(url); + this.#keyring = (options.keyring || undefined); if (options.types != undefined) { this.#registry.register(encointerOptions({types: options.types}).types as RegistryTypes); @@ -117,6 +42,14 @@ export class Worker extends WebSocketAsPromised implements IWorker { } } + public async isReady(): Promise { + return this.#ws.isReady + } + + public async closeWs(): Promise { + return this.#ws.disconnect() + } + public async encrypt(data: Uint8Array): Promise> { const dataBE = new BN(data); const dataArrayBE = new Uint8Array(dataBE.toArray()); @@ -132,10 +65,16 @@ export class Worker extends WebSocketAsPromised implements IWorker { return this.createType('Vec', compactAddLength(beArray)) } - public async closeWs(): Promise { - await this.close() + + public keyring(): Keyring | undefined { + return this.#keyring; } + public setKeyring(keyring: Keyring): void { + this.#keyring = keyring; + } + + public registry(): TypeRegistry { return this.#registry } @@ -144,14 +83,6 @@ export class Worker extends WebSocketAsPromised implements IWorker { return this.#registry.createType(apiType as never, obj) } - public keyring(): Keyring | undefined { - return this.#keyring; - } - - public setKeyring(keyring: Keyring): void { - this.#keyring = keyring; - } - public shieldingKey(): CryptoKey | undefined { return this.#shieldingKey; } @@ -160,17 +91,135 @@ export class Worker extends WebSocketAsPromised implements IWorker { this.#shieldingKey = shieldingKey; } - public async getShieldingKey(options: RequestOptions = {} as RequestOptions): Promise { - const key = await callGetter(this, [Request.Worker, 'author_getShieldingKey', 'CryptoKey'], {}, options) + public async getShieldingKey(): Promise { + const res = await this.send( + 'author_getShieldingKey',[] + ); + + const jsonStr = this.createType('String', res.value); + // Todo: For some reason there are 2 non-utf characters, where I don't know where + // they come from currently. + // console.debug(`Got shielding key: ${jsonStr.toJSON().substring(2)}`); + const key = await parseWebCryptoRSA(jsonStr.toJSON().substring(2)); + + // @ts-ignore this.setShieldingKey(key); + // @ts-ignore return key; } - public async getShardVault(options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.Worker, 'author_getShardVault', 'Vault'], {}, options) + public async getShardVault(): Promise { + const res = await this.send( + 'author_getShardVault',[] + ); + + console.debug(`Got vault key: ${JSON.stringify(res)}`); + return this.createType('Vault', res.value); + } + + public async getFingerprint(): Promise { + const res = await this.send( + 'author_getFingerprint',[] + ); + + console.debug(`Got fingerprint: ${res}`); + + return this.createType('EnclaveFingerprint', res.value); + } + + public async sendGetter(getter: Getter, shard: ShardIdentifier, returnType: string): Promise { + const r = this.createType( + 'Request', { + shard: shard, + cyphertext: getter.toHex() + } + ); + const response = await this.send('state_executeGetter', [r.toHex()]) + const value = unwrapWorkerResponse(this, response.value) + return this.createType(returnType, value); + } + + + public async send(method: string, params: unknown[]): Promise { + await this.isReady(); + const result = await this.#ws.send( + method, params + ); + + return this.resultToRpcReturnValue(result); } - public async getFingerprint(options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.Worker, 'author_getFingerprint', 'EnclaveFingerprint'], {}, options) + public async subscribe(method: string, params: unknown[]): Promise { + await this.isReady(); + + return new Promise( async (resolve, reject) => { + const onStatusChange = (error: Error | null, result: string) => { + console.log(`DirectRequestStatus: error ${JSON.stringify(error)}`) + console.log(`DirectRequestStatus: ${JSON.stringify(result)}`) + + const value = hexToU8a(result); + const directRequestStatus = this.createType('DirectRequestStatus', value); + + if (directRequestStatus.isError) { + const errorMsg = this.createType('String', directRequestStatus.value); + throw new Error(`DirectRequestStatus is Error ${errorMsg}`); + } + if (directRequestStatus.isOk) { + // const hash = this.createType('Hash', directRequestStatus.value); + resolve({}) + } + + if (directRequestStatus.isTrustedOperationStatus) { + console.log(`TrustedOperationStatus: ${directRequestStatus}`) + const status = directRequestStatus.asTrustedOperationStatus; + if (connection_can_be_closed(status)) { + resolve({}) + } + } + } + + try { + const res = await this.#ws.subscribe(method, + method, params, onStatusChange + ); + // let returnValue = this.resultToRpcReturnValue(res as string); + // console.debug(`Subscription RpcReturnValue ${JSON.stringify(returnValue)}`); + let topHash = this.createType('Hash', res); + console.debug(`resHash: ${topHash}`); + } catch (err) { + console.error(err); + reject(err); + } + }) } + + resultToRpcReturnValue(result: string): RpcReturnValue { + if (result === 'Could not decode request') { + throw new Error(`Worker error: ${result}`); + } + + const value = hexToU8a(result); + const returnValue = this.createType('RpcReturnValue', value); + console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); + + if (returnValue.status.isError) { + const errorMsg = this.createType('String', returnValue.value); + throw new Error(`RPC: ${errorMsg}`); + } + + return returnValue; + } +} + +function connection_can_be_closed(status: TrustedOperationStatus): boolean { + return !(status.isSubmitted || status.isFuture || status.isReady || status.isBroadCast || status.isInvalid) +} + +/** + * Defaults to return `[]`, which is fine as `createType(api.registry, , [])` + * instantiates the with its default value. + */ +function unwrapWorkerResponse (self: Worker, data: Bytes) { + const dataTyped = self.createType('Option', data) + return dataTyped.unwrapOrDefault(); } From ca5c0924221b71f37623dc593189d7bf56f9457a Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 18:54:00 +0200 Subject: [PATCH 26/50] fix tests by resolving shard vs mrenclave ambiguity --- .../src/websocketIntegriteeWorker.ts | 2 +- packages/worker-api/src/websocketWorker.ts | 226 ------------------ packages/worker-api/src/worker.spec.ts | 2 +- 3 files changed, 2 insertions(+), 228 deletions(-) delete mode 100644 packages/worker-api/src/websocketWorker.ts diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts index 3d94f8dd..6cc0fe56 100644 --- a/packages/worker-api/src/websocketIntegriteeWorker.ts +++ b/packages/worker-api/src/websocketIntegriteeWorker.ts @@ -8,7 +8,7 @@ import { type TrustedGetterArgs, type TrustedGetterParams, type TrustedSignerOptions, } from './interface.js'; -import {Worker} from "./websocketWorker.js"; +import {Worker} from "./worker.js"; import { createSignedGetter, createTrustedCall, diff --git a/packages/worker-api/src/websocketWorker.ts b/packages/worker-api/src/websocketWorker.ts deleted file mode 100644 index 196c4873..00000000 --- a/packages/worker-api/src/websocketWorker.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type {Vec} from '@polkadot/types'; -import {TypeRegistry} from '@polkadot/types'; -import type {RegistryTypes} from '@polkadot/types/types'; -import {compactAddLength, hexToU8a} from '@polkadot/util'; - - -import {options as encointerOptions} from '@encointer/node-api'; - -import type { - EnclaveFingerprint, - RpcReturnValue, ShardIdentifier, - TrustedOperationStatus, - Vault -} from '@encointer/types'; - -import {type GenericGetter, type WorkerOptions} from './interface.js'; -import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; -import type {Bytes, u8} from "@polkadot/types-codec"; -import BN from "bn.js"; -import {WsProvider} from "./rpc-provider/src/index.js"; -import {Keyring} from "@polkadot/keyring"; - -export class Worker { - - readonly #registry: TypeRegistry; - - #shieldingKey?: CryptoKey; - - #keyring?: Keyring; - - #ws: WsProvider; - - constructor(url: string, options: WorkerOptions = {} as WorkerOptions) { - this.#registry = new TypeRegistry(); - this.#ws = new WsProvider(url); - this.#keyring = (options.keyring || undefined); - - - if (options.types != undefined) { - this.#registry.register(encointerOptions({types: options.types}).types as RegistryTypes); - } else { - this.#registry.register(encointerOptions().types as RegistryTypes); - } - } - - public async isReady(): Promise { - return this.#ws.isReady - } - - public async closeWs(): Promise { - return this.#ws.disconnect() - } - - public async encrypt(data: Uint8Array): Promise> { - const dataBE = new BN(data); - const dataArrayBE = new Uint8Array(dataBE.toArray()); - - const cypherTextBuffer = await encryptWithPublicKey(dataArrayBE, this.shieldingKey() as CryptoKey); - - const outputData = new Uint8Array(cypherTextBuffer); - const be = new BN(outputData) - const beArray = new Uint8Array(be.toArray()); - - // console.debug(`${JSON.stringify({encrypted_array: beArray})}`) - - return this.createType('Vec', compactAddLength(beArray)) - } - - - public keyring(): Keyring | undefined { - return this.#keyring; - } - - public setKeyring(keyring: Keyring): void { - this.#keyring = keyring; - } - - - public registry(): TypeRegistry { - return this.#registry - } - - public createType(apiType: string, obj?: any): any { - return this.#registry.createType(apiType as never, obj) - } - - public shieldingKey(): CryptoKey | undefined { - return this.#shieldingKey; - } - - public setShieldingKey(shieldingKey: CryptoKey): void { - this.#shieldingKey = shieldingKey; - } - - public async getShieldingKey(): Promise { - const res = await this.send( - 'author_getShieldingKey',[] - ); - - const jsonStr = this.createType('String', res.value); - // Todo: For some reason there are 2 non-utf characters, where I don't know where - // they come from currently. - // console.debug(`Got shielding key: ${jsonStr.toJSON().substring(2)}`); - const key = await parseWebCryptoRSA(jsonStr.toJSON().substring(2)); - - // @ts-ignore - this.setShieldingKey(key); - // @ts-ignore - return key; - } - - public async getShardVault(): Promise { - const res = await this.send( - 'author_getShardVault',[] - ); - - console.debug(`Got vault key: ${JSON.stringify(res)}`); - return this.createType('Vault', res.value); - } - - public async getFingerprint(): Promise { - const res = await this.send( - 'author_getFingerprint',[] - ); - - console.debug(`Got fingerprint key: ${res}`); - - return this.createType('EnclaveFingerprint', res.value); - } - - public async sendGetter(getter: Getter, shard: ShardIdentifier, returnType: string): Promise { - const r = this.createType( - 'Request', { - shard: shard, - cyphertext: getter.toHex() - } - ); - const response = await this.send('state_executeGetter', [r.toHex()]) - const value = unwrapWorkerResponse(this, response.value) - return this.createType(returnType, value); - } - - - public async send(method: string, params: unknown[]): Promise { - await this.isReady(); - const result = await this.#ws.send( - method, params - ); - - return this.resultToRpcReturnValue(result); - } - - public async subscribe(method: string, params: unknown[]): Promise { - await this.isReady(); - - return new Promise( async (resolve, reject) => { - const onStatusChange = (error: Error | null, result: string) => { - console.log(`DirectRequestStatus: error ${JSON.stringify(error)}`) - console.log(`DirectRequestStatus: ${JSON.stringify(result)}`) - - const value = hexToU8a(result); - const directRequestStatus = this.createType('DirectRequestStatus', value); - - if (directRequestStatus.isError) { - const errorMsg = this.createType('String', directRequestStatus.value); - throw new Error(`DirectRequestStatus is Error ${errorMsg}`); - } - if (directRequestStatus.isOk) { - // const hash = this.createType('Hash', directRequestStatus.value); - resolve({}) - } - - if (directRequestStatus.isTrustedOperationStatus) { - console.log(`TrustedOperationStatus: ${directRequestStatus}`) - const status = directRequestStatus.asTrustedOperationStatus; - if (connection_can_be_closed(status)) { - resolve({}) - } - } - } - - try { - const res = await this.#ws.subscribe(method, - method, params, onStatusChange - ); - // let returnValue = this.resultToRpcReturnValue(res as string); - // console.debug(`Subscription RpcReturnValue ${JSON.stringify(returnValue)}`); - let topHash = this.createType('Hash', res); - console.debug(`resHash: ${topHash}`); - } catch (err) { - console.error(err); - reject(err); - } - }) - } - - resultToRpcReturnValue(result: string): RpcReturnValue { - if (result === 'Could not decode request') { - throw new Error(`Worker error: ${result}`); - } - - const value = hexToU8a(result); - const returnValue = this.createType('RpcReturnValue', value); - console.debug(`RpcReturnValue ${JSON.stringify(returnValue)}`); - - if (returnValue.status.isError) { - const errorMsg = this.createType('String', returnValue.value); - throw new Error(`RPC: ${errorMsg}`); - } - - return returnValue; - } -} - -function connection_can_be_closed(status: TrustedOperationStatus): boolean { - return !(status.isSubmitted || status.isFuture || status.isReady || status.isBroadCast || status.isInvalid) -} - -/** - * Defaults to return `[]`, which is fine as `createType(api.registry, , [])` - * instantiates the with its default value. - */ -function unwrapWorkerResponse (self: Worker, data: Bytes) { - const dataTyped = self.createType('Option', data) - return dataTyped.unwrapOrDefault(); -} diff --git a/packages/worker-api/src/worker.spec.ts b/packages/worker-api/src/worker.spec.ts index e3d9b811..1e2cbe24 100644 --- a/packages/worker-api/src/worker.spec.ts +++ b/packages/worker-api/src/worker.spec.ts @@ -1,6 +1,6 @@ import { cryptoWaitReady } from '@polkadot/util-crypto'; import {localDockerNetwork} from './testUtils/networks.js'; -import { Worker } from './websocketWorker.js'; +import { Worker } from './worker.js'; import bs58 from "bs58"; describe('worker', () => { From a2c661cfb1b764dfde2b91b90e333d7a2987ef3c Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 19:22:47 +0200 Subject: [PATCH 27/50] merge the integritee websocket worker back into the original one --- .../worker-api/src/integriteeWorker.spec.ts | 102 +++++++------- packages/worker-api/src/integriteeWorker.ts | 96 +++++++------ packages/worker-api/src/interface.ts | 2 - .../src/websocketIntegriteeWorker.spec.ts | 95 ------------- .../src/websocketIntegriteeWorker.ts | 132 ------------------ 5 files changed, 108 insertions(+), 319 deletions(-) delete mode 100644 packages/worker-api/src/websocketIntegriteeWorker.spec.ts delete mode 100644 packages/worker-api/src/websocketIntegriteeWorker.ts diff --git a/packages/worker-api/src/integriteeWorker.spec.ts b/packages/worker-api/src/integriteeWorker.spec.ts index 0ff1ac20..64aadd4a 100644 --- a/packages/worker-api/src/integriteeWorker.spec.ts +++ b/packages/worker-api/src/integriteeWorker.spec.ts @@ -1,14 +1,15 @@ import { Keyring } from '@polkadot/api'; import { cryptoWaitReady } from '@polkadot/util-crypto'; -import {paseoNetwork} from './testUtils/networks.js'; +import {localDockerNetwork} from './testUtils/networks.js'; import { IntegriteeWorker } from './integriteeWorker.js'; -import WS from 'websocket'; import {type KeyringPair} from "@polkadot/keyring/types"; -const {w3cwebsocket: WebSocket} = WS; +// import WS from 'websocket'; + +// const {w3cwebsocket: WebSocket} = WS; describe('worker', () => { - const network = paseoNetwork(); + const network = localDockerNetwork(); let keyring: Keyring; let worker: IntegriteeWorker; let alice: KeyringPair; @@ -22,17 +23,16 @@ describe('worker', () => { worker = new IntegriteeWorker(network.worker, { keyring: keyring, - types: network.customTypes, // @ts-ignore - createWebSocket: (url) => new WebSocket( - url, - undefined, - undefined, - undefined, - // Allow the worker's self-signed certificate - { rejectUnauthorized: false } - ), - api: null, + // createWebSocket: (url) => new WebSocket( + // url, + // undefined, + // undefined, + // undefined, + // // Allow the worker's self-signed certificate + // { rejectUnauthorized: false } + // ), + // api: null, }); }); @@ -63,7 +63,7 @@ describe('worker', () => { describe('getNonce', () => { it('should return value', async () => { const result = await worker.getNonce(alice, network.shard); - console.log('Nonce', result); + console.log('Nonce', result.toHuman); expect(result).toBeDefined(); }); }); @@ -72,7 +72,7 @@ describe('worker', () => { describe('getAccountInfo', () => { it('should return value', async () => { const result = await worker.getAccountInfo(alice, network.shard); - console.log('getAccountInfo', result); + console.log('getAccountInfo', result.toHuman()); expect(result).toBeDefined(); }); }); @@ -82,7 +82,7 @@ describe('worker', () => { const getter = await worker.accountInfoGetter(charlie, network.shard); console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); const result = await getter.send(); - console.log('getAccountInfo:', result); + console.log('getAccountInfo:', result.toHuman()); expect(result).toBeDefined(); }); }); @@ -92,7 +92,7 @@ describe('worker', () => { const getter = worker.parentchainsInfoGetter(network.shard); console.log(`parentchainsInfoGetter: ${JSON.stringify(getter)}`); const result = await getter.send(); - console.log('parentchainsInfoGetter:', result); + console.log('parentchainsInfoGetter:', result.toHuman()); expect(result).toBeDefined(); }); }); @@ -102,7 +102,7 @@ describe('worker', () => { const getter = worker.guessTheNumberInfoGetter(network.shard); console.log(`GuessTheNumberInfo: ${JSON.stringify(getter)}`); const result = await getter.send(); - console.log('GuessTheNumberInfo:', result); + console.log('GuessTheNumberInfo:', result.toHuman()); expect(result).toBeDefined(); }); }); @@ -112,7 +112,7 @@ describe('worker', () => { const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.shard); console.log(`Attempts: ${JSON.stringify(getter)}`); const result = await getter.send(); - console.log('Attempts:', result); + console.log('Attempts:', result.toHuman()); expect(result).toBeDefined(); }); }); @@ -133,36 +133,36 @@ describe('worker', () => { }); }); - describe('balance unshield should work', () => { - it('should return value', async () => { - const shard = network.shard; - - const result = await worker.balanceUnshieldFunds( - alice, - shard, - network.mrenclave, - alice.address, - charlie.address, - 1100000000000, - ); - console.log('balance unshield result', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('guess the number should work', () => { - it('should return value', async () => { - const shard = network.shard; - - const result = await worker.guessTheNumber( - alice, - shard, - network.mrenclave, - 1, - ); - console.log('guess the number result', result.toHuman()); - expect(result).toBeDefined(); - }); - }); + // describe('balance unshield should work', () => { + // it('should return value', async () => { + // const shard = network.shard; + // + // const result = await worker.balanceUnshieldFunds( + // alice, + // shard, + // network.mrenclave, + // alice.address, + // charlie.address, + // 1100000000000, + // ); + // console.log('balance unshield result', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('guess the number should work', () => { + // it('should return value', async () => { + // const shard = network.shard; + // + // const result = await worker.guessTheNumber( + // alice, + // shard, + // network.mrenclave, + // 1, + // ); + // console.log('guess the number result', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); }); }); diff --git a/packages/worker-api/src/integriteeWorker.ts b/packages/worker-api/src/integriteeWorker.ts index 37765424..79ab4019 100644 --- a/packages/worker-api/src/integriteeWorker.ts +++ b/packages/worker-api/src/integriteeWorker.ts @@ -7,19 +7,15 @@ import type { GuessTheNumberTrustedCall, GuessTheNumberPublicGetter, GuessTheNumberTrustedGetter, AttemptsArg, } from '@encointer/types'; import { - type RequestOptions, type ISubmittableGetter, - type JsonRpcRequest, type TrustedGetterArgs, type TrustedSignerOptions, type PublicGetterArgs, - type IWorker, type PublicGetterParams, type TrustedGetterParams, } from './interface.js'; import {Worker} from "./worker.js"; -import {sendTrustedCall, sendWorkerRequest} from './sendRequest.js'; import { - createGetterRpc, createIntegriteeGetterPublic, + createIntegriteeGetterPublic, createSignedGetter, createTrustedCall, signTrustedCall, type TrustedCallArgs, type TrustedCallVariant, @@ -32,14 +28,14 @@ import {asString} from "@encointer/util"; export class IntegriteeWorker extends Worker { - public async getNonce(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions, requestOptions?: RequestOptions): Promise { - const info = await this.getAccountInfo(accountOrPubKey, shard, singerOptions, requestOptions); + public async getNonce(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions): Promise { + const info = await this.getAccountInfo(accountOrPubKey, shard, singerOptions); return info.nonce; } - public async getAccountInfo(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions, requestOptions?: RequestOptions): Promise { + public async getAccountInfo(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions): Promise { const getter = await this.accountInfoGetter(accountOrPubKey, shard, singerOptions); - return getter.send(requestOptions); + return getter.send(); } public async accountInfoGetter(accountOrPubKey: AddressOrPair, shard: string, signerOptions?: TrustedSignerOptions): Promise> { @@ -85,14 +81,13 @@ export class IntegriteeWorker extends Worker { to: String, amount: number, signerOptions?: TrustedSignerOptions, - requestOptions?: RequestOptions, ): Promise { - const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions, requestOptions) + const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); const params = this.createType('BalanceTransferArgs', [from, to, amount]) const call = createTrustedCall(this, ['balance_transfer', 'BalanceTransferArgs'], params); const signed = await signTrustedCall(this, call, account, shardT, mrenclave, nonce, signerOptions); - return this.sendTrustedCall(signed, shardT, requestOptions); + return this.sendTrustedCall(signed, shardT); } public async balanceUnshieldFunds( @@ -103,15 +98,14 @@ export class IntegriteeWorker extends Worker { toPublicAddress: string, amount: number, signerOptions?: TrustedSignerOptions, - requestOptions?: RequestOptions, ): Promise { - const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions, requestOptions) + const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); const params = this.createType('BalanceUnshieldArgs', [fromIncognitoAddress, toPublicAddress, amount, shardT]) const call = createTrustedCall(this, ['balance_unshield', 'BalanceUnshieldArgs'], params); const signed = await signTrustedCall(this, call, account, shardT, mrenclave, nonce, signerOptions); - return this.sendTrustedCall(signed, shardT, requestOptions); + return this.sendTrustedCall(signed, shardT); } public async guessTheNumber( @@ -120,9 +114,8 @@ export class IntegriteeWorker extends Worker { mrenclave: string, guess: number, signerOptions?: TrustedSignerOptions, - requestOptions?: RequestOptions, ): Promise { - const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions, requestOptions) + const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); const params = this.createType('GuessArgs', [asString(account), guess]) @@ -131,16 +124,45 @@ export class IntegriteeWorker extends Worker { const signed = await signTrustedCall(this, call, account, shardT, mrenclave, nonce, signerOptions); console.debug(`GuessTheNumber ${JSON.stringify(signed)}`); - return this.sendTrustedCall(signed, shardT, requestOptions); + return this.sendTrustedCall(signed, shardT); } - async sendTrustedCall(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier, requestOptions?: RequestOptions): Promise { + async sendTrustedCall(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier): Promise { if (this.shieldingKey() == undefined) { console.debug(`[sentTrustedCall] Setting the shielding pubKey of the worker.`) - await this.getShieldingKey(requestOptions); + await this.getShieldingKey(); } - return sendTrustedCall(this, call, shard, true, 'TrustedOperationResult', requestOptions); + return this.submitAndWatch(call, shard, true); + } + + async submitAndWatch(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier, direct: boolean): Promise { + let top; + if (direct) { + top = this.createType('IntegriteeTrustedOperation', { + direct_call: call + }) + } else { + top = this.createType('IntegriteeTrustedOperation', { + indirect_call: call + }) + } + + console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); + + const cyphertext = await this.encrypt(top.toU8a()); + + const r = this.createType( + 'Request', { shard, cyphertext: cyphertext } + ); + + const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) + + // const returnValue = await this.send('author_submitExtrinsic', [r.toHex()]) + + console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); + + return this.createType('Hash', returnValue.value); } } @@ -157,44 +179,40 @@ export class SubmittableGetter implements ISubmittableGe this.returnType = returnType; } - into_rpc(): JsonRpcRequest { - return createGetterRpc(this.worker, this.getter, this.shard); - } - - send(requestOptions?: RequestOptions): Promise { - const rpc = this.into_rpc(); - return sendWorkerRequest(this.worker, rpc, this.returnType, requestOptions); + async send(): Promise { + return this.worker.sendGetter(this.getter, this.shard, this.returnType); } } -export const submittableTrustedGetter = async (self: W, request: string, account: AddressOrPair, args: TrustedGetterArgs, trustedGetterParams: TrustedGetterParams, returnType: string)=> { +async function submittableTrustedGetter(self: W, request: string, account: AddressOrPair, args: TrustedGetterArgs, trustedGetterParams: TrustedGetterParams, returnType: string): Promise> { const {shard} = args; const shardT = self.createType('ShardIdentifier', bs58.decode(shard)); const signedGetter = await createSignedGetter(self, request, account, trustedGetterParams, { signer: args?.signer }) return new SubmittableGetter(self, shardT, signedGetter, returnType); } -export const submittablePublicGetter = (self: W, request: string, args: PublicGetterArgs, publicGetterParams: PublicGetterParams, returnType: string)=> { + +function submittablePublicGetter(self: W, request: string, args: PublicGetterArgs, publicGetterParams: PublicGetterParams, returnType: string): SubmittableGetter { const {shard} = args; const shardT = self.createType('ShardIdentifier', bs58.decode(shard)); const signedGetter = createIntegriteeGetterPublic(self, request, publicGetterParams) return new SubmittableGetter(self, shardT, signedGetter, returnType); } -export const guessTheNumberPublicGetter = ( - self: IWorker, +function guessTheNumberPublicGetter( + self: Worker, getterVariant: string, -): GuessTheNumberPublicGetter => { +): GuessTheNumberPublicGetter { return self.createType('GuessTheNumberPublicGetter', { [getterVariant]: null }); } -export const guessTheNumberTrustedGetter = ( - self: IWorker, +function guessTheNumberTrustedGetter( + self: Worker, getterVariant: string, params: GuessTheNumberTrustedGetterParams -): GuessTheNumberTrustedGetter => { +): GuessTheNumberTrustedGetter { return self.createType('GuessTheNumberTrustedGetter', { [getterVariant]: params }); @@ -203,11 +221,11 @@ export const guessTheNumberTrustedGetter = ( export type GuessTheNumberTrustedGetterParams = AttemptsArg | null; -export const guessTheNumberCall = ( - self: IWorker, +function guessTheNumberCall( + self: Worker, callVariant: TrustedCallVariant, params: TrustedCallArgs -): GuessTheNumberTrustedCall => { +): GuessTheNumberTrustedCall { const [variant, argType] = callVariant; return self.createType('GuessTheNumberTrustedCall', { diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index 33aa3ce4..0a8d048a 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -42,8 +42,6 @@ export interface ISubmittableGetter { returnType: string, - into_rpc(): JsonRpcRequest; - send(): Promise; } diff --git a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts b/packages/worker-api/src/websocketIntegriteeWorker.spec.ts deleted file mode 100644 index d9c6aa94..00000000 --- a/packages/worker-api/src/websocketIntegriteeWorker.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Keyring } from '@polkadot/api'; -import { cryptoWaitReady } from '@polkadot/util-crypto'; -import {paseoNetwork} from './testUtils/networks.js'; -import { IntegriteeWorker } from './websocketIntegriteeWorker.js'; -import {type KeyringPair} from "@polkadot/keyring/types"; - -describe('worker', () => { - const network = paseoNetwork(); - let keyring: Keyring; - let worker: IntegriteeWorker; - let alice: KeyringPair; - let charlie: KeyringPair; - beforeAll(async () => { - jest.setTimeout(90000); - await cryptoWaitReady(); - keyring = new Keyring({type: 'sr25519'}); - alice = keyring.addFromUri('//Alice', {name: 'Alice default'}); - charlie = keyring.addFromUri('//Charlie', {name: 'Bob default'}); - - worker = new IntegriteeWorker(network.worker, { - keyring: keyring, - }); - }); - - afterAll(async () => { - await worker.closeWs() - }); - - - // skip it, as this requires a worker (and hence a node) to be running - // To my knowledge jest does not have an option to run skipped tests specifically, does it? - // Todo: add proper CI to test this too. - describe('needs worker and node running', () => { - describe('getWorkerPubKey', () => { - it('should return value', async () => { - const result = await worker.getShieldingKey(); - // console.log('Shielding Key', result); - expect(result).toBeDefined(); - }); - }); - - describe('getShardVault', () => { - it('should return value', async () => { - const result = await worker.getShardVault(); - console.log('ShardVault', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('getNonce', () => { - it('should return value', async () => { - const result = await worker.getNonce(alice, network.shard); - console.log(`Nonce: ${JSON.stringify(result)}`); - expect(result).toBeDefined(); - }); - }); - - - describe('getAccountInfo', () => { - it('should return value', async () => { - const result = await worker.getAccountInfo(alice, network.shard); - console.log(`getAccountInfo: ${JSON.stringify(result)}`); - expect(result).toBeDefined(); - }); - }); - - describe('accountInfoGetter', () => { - it('should return value', async () => { - const getter = await worker.accountInfoGetter(charlie, network.shard); - console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); - const result = await getter.send(); - console.log(`getAccountInfo: ${JSON.stringify(result)}`); - expect(result).toBeDefined(); - }); - }); - - - describe('balance unshield should work', () => { - it('should return value', async () => { - const shard = network.shard; - - const result = await worker.balanceUnshieldFunds( - alice, - shard, - network.mrenclave, - alice.address, - charlie.address, - 1100000000000, - ); - console.log('balance unshield result', result.toHuman()); - expect(result).toBeDefined(); - }, 20000); - }); - }); -}); diff --git a/packages/worker-api/src/websocketIntegriteeWorker.ts b/packages/worker-api/src/websocketIntegriteeWorker.ts deleted file mode 100644 index 6cc0fe56..00000000 --- a/packages/worker-api/src/websocketIntegriteeWorker.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type {Hash} from '@polkadot/types/interfaces/runtime'; -import type { - ShardIdentifier, - IntegriteeTrustedCallSigned, IntegriteeGetter, -} from '@encointer/types'; -import { - type ISubmittableGetter, type JsonRpcRequest, - type TrustedGetterArgs, type TrustedGetterParams, - type TrustedSignerOptions, -} from './interface.js'; -import {Worker} from "./worker.js"; -import { - createSignedGetter, - createTrustedCall, - signTrustedCall, -} from "./requests.js"; -import bs58 from "bs58"; -import type {AddressOrPair} from "@polkadot/api-base/types/submittable"; -import type {u32} from "@polkadot/types-codec"; -import type {AccountInfo} from "@polkadot/types/interfaces/system"; -import {asString} from "@encointer/util"; - -export class IntegriteeWorker extends Worker { - - public async getNonce(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions): Promise { - const info = await this.getAccountInfo(accountOrPubKey, shard, singerOptions); - return info.nonce; - } - - public async getAccountInfo(accountOrPubKey: AddressOrPair, shard: string, singerOptions?: TrustedSignerOptions): Promise { - const getter = await this.accountInfoGetter(accountOrPubKey, shard, singerOptions); - return getter.send(); - } - - public async accountInfoGetter(accountOrPubKey: AddressOrPair, shard: string, signerOptions?: TrustedSignerOptions): Promise> { - const trustedGetterArgs = { - shard: shard, - account: accountOrPubKey, - signer: signerOptions?.signer, - } - return await submittableTrustedGetter(this, 'account_info', accountOrPubKey, trustedGetterArgs, asString(accountOrPubKey), 'AccountInfo'); - } - - - public async balanceUnshieldFunds( - account: AddressOrPair, - shard: string, - mrenclave: string, - fromIncognitoAddress: string, - toPublicAddress: string, - amount: number, - signerOptions?: TrustedSignerOptions, - ): Promise { - const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) - - const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); - const params = this.createType('BalanceUnshieldArgs', [fromIncognitoAddress, toPublicAddress, amount, shardT]) - const call = createTrustedCall(this, ['balance_unshield', 'BalanceUnshieldArgs'], params); - const signed = await signTrustedCall(this, call, account, shardT, mrenclave, nonce, signerOptions); - return this.sendTrustedCall(signed, shardT); - } - - async sendTrustedCall(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier): Promise { - if (this.shieldingKey() == undefined) { - console.debug(`[sentTrustedCall] Setting the shielding pubKey of the worker.`) - await this.getShieldingKey(); - } - - return this.submitAndWatch(call, shard, true); - } - - async submitAndWatch(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier, direct: boolean): Promise { - let top; - if (direct) { - top = this.createType('IntegriteeTrustedOperation', { - direct_call: call - }) - } else { - top = this.createType('IntegriteeTrustedOperation', { - indirect_call: call - }) - } - - console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); - - const cyphertext = await this.encrypt(top.toU8a()); - - const r = this.createType( - 'Request', { shard, cyphertext: cyphertext } - ); - - const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) - - // const returnValue = await this.send('author_submitExtrinsic', [r.toHex()]) - - console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); - - return this.createType('Hash', returnValue.value); - } -} - -async function submittableTrustedGetter (self: W, request: string, account: AddressOrPair, args: TrustedGetterArgs, trustedGetterParams: TrustedGetterParams, returnType: string): Promise> { - const {shard} = args; - const shardT = self.createType('ShardIdentifier', bs58.decode(shard)); - const signedGetter = await createSignedGetter(self, request, account, trustedGetterParams, { signer: args?.signer }) - return new SubmittableGetter(self, shardT, signedGetter, returnType); -} - - -export class SubmittableGetter implements ISubmittableGetter { - worker: W; - shard: ShardIdentifier; - getter: IntegriteeGetter; - returnType: string; - - constructor(worker: W, shard: ShardIdentifier, getter: IntegriteeGetter, returnType: string) { - this.worker = worker; - this.shard = shard; - this.getter = getter; - this.returnType = returnType; - } - - // todo: deprecated - // @ts-ignore - into_rpc(): JsonRpcRequest { - // return createGetterRpc(this.worker, this.getter, this.shard); - } - - async send(): Promise { - return this.worker.sendGetter(this.getter, this.shard, this.returnType); - } -} From 6da2a66e8cab0ec81935142e2f7b7a92e9c3e4b1 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 19:25:32 +0200 Subject: [PATCH 28/50] remove obsolete sendTrustedCall method --- packages/worker-api/src/sendRequest.ts | 32 -------------------------- 1 file changed, 32 deletions(-) diff --git a/packages/worker-api/src/sendRequest.ts b/packages/worker-api/src/sendRequest.ts index 49da9051..2dd119f4 100644 --- a/packages/worker-api/src/sendRequest.ts +++ b/packages/worker-api/src/sendRequest.ts @@ -6,7 +6,6 @@ import { createJsonRpcRequest } from './interface.js'; import { Request } from './interface.js'; -import type {ShardIdentifier, IntegriteeTrustedCallSigned} from "@encointer/types"; export const sendWorkerRequest = async (self: IWorker, clientRequest: any, parserType: string, options?: RequestOptions): Promise =>{ if( !self.isOpened ) { @@ -37,34 +36,3 @@ export const callGetter = async (self: IWorker, workerMethod: WorkerMethod, _ return result as Promise } -export const sendTrustedCall = async (self: IWorker, call: IntegriteeTrustedCallSigned, shard: ShardIdentifier, direct: boolean, parser: string, options: RequestOptions = {} as RequestOptions): Promise => { - let result: Promise; - let parserType: string = options.debug ? 'raw': parser; - - let top; - if (direct) { - top = self.createType('IntegriteeTrustedOperation', { - direct_call: call - }) - } else { - top = self.createType('IntegriteeTrustedOperation', { - indirect_call: call - }) - } - - console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); - - const cyphertext = await self.encrypt(top.toU8a()); - - const r = self.createType( - 'Request', { shard, cyphertext: cyphertext } - ); - - const rpc = createJsonRpcRequest('author_submitExtrinsic', [r.toHex()], 1); - result = sendWorkerRequest(self, rpc, parserType, options) - - console.debug(`[sendTrustedCall] sent request: ${JSON.stringify(rpc)}`); - - return result as Promise -} - From cdf69f0b899a5d728799bf1868eb5354872a3670 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 19:30:47 +0200 Subject: [PATCH 29/50] remove legacy code and encointer code --- .../worker-api/src/encointerWorker.spec.ts | 132 ------------------ packages/worker-api/src/encointerWorker.ts | 107 -------------- packages/worker-api/src/index.ts | 1 - packages/worker-api/src/interface.ts | 27 ---- packages/worker-api/src/parsers.ts | 22 --- packages/worker-api/src/requests.ts | 13 -- packages/worker-api/src/sendRequest.ts | 38 ----- 7 files changed, 340 deletions(-) delete mode 100644 packages/worker-api/src/encointerWorker.spec.ts delete mode 100644 packages/worker-api/src/encointerWorker.ts delete mode 100644 packages/worker-api/src/parsers.ts delete mode 100644 packages/worker-api/src/sendRequest.ts diff --git a/packages/worker-api/src/encointerWorker.spec.ts b/packages/worker-api/src/encointerWorker.spec.ts deleted file mode 100644 index d9751912..00000000 --- a/packages/worker-api/src/encointerWorker.spec.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Keyring } from '@polkadot/api'; -import { cryptoWaitReady } from '@polkadot/util-crypto'; -import { localDockerNetwork } from './testUtils/networks.js'; -import { EncointerWorker } from './encointerWorker.js'; -import WS from 'websocket'; - -const {w3cwebsocket: WebSocket} = WS; - -describe('worker', () => { - const network = localDockerNetwork(); - let keyring: Keyring; - let worker: EncointerWorker; - // let alice: KeyringPair; - // let charlie: KeyringPair; - beforeAll(async () => { - jest.setTimeout(90000); - await cryptoWaitReady(); - keyring = new Keyring({type: 'sr25519'}); - // alice = keyring.addFromUri('//Alice', {name: 'Alice default'}); - // charlie = keyring.addFromUri('//Charlie', {name: 'Bob default'}); - - worker = new EncointerWorker(network.worker, { - keyring: keyring, - types: network.customTypes, - // @ts-ignore - createWebSocket: (url) => new WebSocket( - url, - undefined, - undefined, - undefined, - // Allow the worker's self-signed certificate - { rejectUnauthorized: false } - ), - api: null, - }); - }); - - // skip it, as this requires a worker (and hence a node) to be running - // To my knowledge jest does not have an option to run skipped tests specifically, does it? - // Todo: add proper CI to test this too. - describe.skip('needs worker and node running', () => { - // Tests specific for the encointer protocol - describe('encointer-worker', () => { - describe('getTotalIssuance', () => { - it('should return value', async () => { - const result = await worker.getTotalIssuance(network.chosenCid); - // console.debug('getTotalIssuance', result); - expect(result).toBeDefined(); - }); - }); - - describe('getParticipantCount', () => { - it('should return default value', async () => { - const result = await worker.getParticipantCount(network.chosenCid); - expect(result).toBe(0); - }); - }); - - describe('getMeetupCount', () => { - it('should return default value', async () => { - const result = await worker.getMeetupCount(network.chosenCid); - expect(result).toBe(0); - }); - }); - - describe('getCeremonyReward', () => { - it('should return default value', async () => { - const result = await worker.getCeremonyReward(network.chosenCid); - expect(result).toBe(1); - }); - }); - - describe('getLocationTolerance', () => { - it('should return default value', async () => { - const result = await worker.getLocationTolerance(network.chosenCid); - expect(result).toBe(1000); - }); - }); - - describe('getTimeTolerance', () => { - it('should return default value', async () => { - const result = await worker.getTimeTolerance(network.chosenCid); - expect(result.toNumber()).toBe(600000); - }); - }); - - describe('getSchedulerState', () => { - it('should return value', async () => { - const result = await worker.getSchedulerState(network.chosenCid); - // console.debug('schedulerStateResult', result); - expect(result).toBeDefined(); - }); - }); - - describe('getRegistration', () => { - it('should return default value', async () => { - await cryptoWaitReady(); - const bob = keyring.addFromUri('//Bob', {name: 'Bob default'}); - const result = await worker.getParticipantIndex(bob, network.chosenCid); - expect(result.toNumber()).toBe(0); - }); - }); - - describe('getMeetupIndex', () => { - it('should return default value', async () => { - await cryptoWaitReady(); - const bob = keyring.addFromUri('//Bob', {name: 'Bob default'}); - const result = await worker.getMeetupIndex(bob, network.chosenCid); - expect(result.toNumber()).toBe(0); - }); - }); - - describe('getAttestations', () => { - it('should be empty', async () => { - await cryptoWaitReady(); - const bob = keyring.addFromUri('//Bob', {name: 'Bob default'}); - const result = await worker.getAttestations(bob, network.chosenCid); - expect(result.toJSON()).toStrictEqual([]); - }); - }); - - describe('getMeetupRegistry method', () => { - it('should be empty', async () => { - await cryptoWaitReady(); - const bob = keyring.addFromUri('//Bob', {name: 'Bob default'}); - const result = await worker.getMeetupRegistry(bob, network.chosenCid); - expect(result.toJSON()).toStrictEqual([]); - }); - }); - }); - }); -}); diff --git a/packages/worker-api/src/encointerWorker.ts b/packages/worker-api/src/encointerWorker.ts deleted file mode 100644 index cdf0b47d..00000000 --- a/packages/worker-api/src/encointerWorker.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type {u32, u64, Vec} from '@polkadot/types'; -import {communityIdentifierFromString} from '@encointer/util'; - -import type { - CommunityIdentifier, - MeetupIndexType, - ParticipantIndexType, - SchedulerState, - Attestation, -} from '@encointer/types'; - -import {type RequestOptions, Request} from './interface.js'; -import {callGetter} from './sendRequest.js'; -import {PubKeyPinPair, toAccount} from "@encointer/util/common"; -import type {KeyringPair} from "@polkadot/keyring/types"; -import {Worker} from "./worker.js"; -import type {AccountId, Balance, Moment} from "@polkadot/types/interfaces/runtime"; - -// Todo: This code is a WIP and will not work as is: https://github.com/encointer/encointer-js/issues/91 - -export class EncointerWorker extends Worker { - - public cidFromStr(cidStr: String): CommunityIdentifier { - return communityIdentifierFromString(this.registry(), cidStr); - } - - public async getNonce(accountOrPubKey: KeyringPair | PubKeyPinPair, cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.TrustedGetter, 'nonce', 'u32'], { - shard: cid, - account: toAccount(accountOrPubKey, this.keyring()) - }, options) - } - - public async getTotalIssuance(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.PublicGetter, 'total_issuance', 'Balance'], {cid}, options) - } - - public async getParticipantCount(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return (await callGetter(this, [Request.PublicGetter, 'participant_count', 'u64'], {cid}, options)).toNumber() - } - - public async getMeetupCount(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return (await callGetter(this, [Request.PublicGetter, 'meetup_count', 'u64'], {cid}, options)).toNumber() - } - - public async getCeremonyReward(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.PublicGetter, 'ceremony_reward', 'I64F64'], {cid}, options) - } - - public async getLocationTolerance(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return (await callGetter(this, [Request.PublicGetter, 'location_tolerance', 'u32'], {cid}, options)).toNumber() - } - - public async getTimeTolerance(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.PublicGetter, 'time_tolerance', 'Moment'], {cid}, options) - } - - public async getSchedulerState(cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.PublicGetter, 'scheduler_state', 'SchedulerState'], {cid}, options) - } - - public async getBalance(accountOrPubKey: KeyringPair | PubKeyPinPair, cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.TrustedGetter, 'free_balance', 'Balance'], { - shard: cid, - account: toAccount(accountOrPubKey,this.keyring()) - }, options) - } - - public async getParticipantIndex(accountOrPubKey: KeyringPair | PubKeyPinPair, cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.TrustedGetter, 'participant_index', 'ParticipantIndexType'], { - cid, - account: toAccount(accountOrPubKey,this.keyring()) - }, options) - } - - public async getMeetupIndex(accountOrPubKey: KeyringPair | PubKeyPinPair, cid: string, options: RequestOptions = {} as RequestOptions): Promise { - return await callGetter(this, [Request.TrustedGetter, 'meetup_index', 'MeetupIndexType'], { - cid, - account: toAccount(accountOrPubKey,this.keyring()) - }, options) - } - - public async getAttestations(accountOrPubKey: KeyringPair | PubKeyPinPair, cid: string, options: RequestOptions = {} as RequestOptions): Promise> { - return await callGetter>(this, [Request.TrustedGetter, 'attestations', 'Vec'], { - cid, - account: toAccount(accountOrPubKey,this.keyring()) - }, options) - } - - public async getMeetupRegistry(accountOrPubKey: KeyringPair | PubKeyPinPair, cid: string, options: RequestOptions = {} as RequestOptions): Promise> { - return await callGetter>(this, [Request.TrustedGetter, 'meetup_registry', 'Vec'], { - cid, - account: toAccount(accountOrPubKey, this.keyring()) - }, options) - } - - // Todo: `sendTrustedCall` must be generic over the trusted call or we have to duplicate code for encointer. - // async sendTrustedCall(call: EncointerTrustedCallSigned, shard: ShardIdentifier, options: CallOptions = {} as CallOptions): Promise { - // if (this.shieldingKey() == undefined) { - // const key = await this.getShieldingKey(options); - // console.log(`Setting the shielding pubKey of the worker.`) - // this.setShieldingKey(key); - // } - // - // return sendTrustedCall(this, call, shard, true, 'TrustedOperationResult', options); - // } -} diff --git a/packages/worker-api/src/index.ts b/packages/worker-api/src/index.ts index 90f94eda..56d16b90 100644 --- a/packages/worker-api/src/index.ts +++ b/packages/worker-api/src/index.ts @@ -1,5 +1,4 @@ export { Worker } from './worker.js'; -export { EncointerWorker } from './encointerWorker.js'; export { IntegriteeWorker } from './integriteeWorker.js'; export * from './interface.js'; diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index 0a8d048a..04ddc01e 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -22,7 +22,6 @@ export interface IWorker extends WebSocketAsPromised { } export interface GenericGetter { - toHex(): string } @@ -45,22 +44,6 @@ export interface ISubmittableGetter { send(): Promise; } -export interface JsonRpcRequest { - jsonrpc: string; - method: string; - params?: any; - id: number | string; -} - -export function createJsonRpcRequest(method: string, params: any, id: number | string): JsonRpcRequest { - return { - jsonrpc: '2.0', - method: method, - params: params, - id: id - }; -} - export interface WorkerOptions { keyring?: Keyring; types?: RegistryTypes; @@ -94,17 +77,7 @@ export interface PublicGetterArgs { export type PublicGetterParams = GuessTheNumberPublicGetter | null -export type RequestArgs = PublicGetterArgs | TrustedGetterArgs | { } - export interface RequestOptions { timeout?: number; debug?: boolean; } - -export enum Request { - TrustedGetter, - PublicGetter, - Worker -} - -export type WorkerMethod = [ Request, string, string ] diff --git a/packages/worker-api/src/parsers.ts b/packages/worker-api/src/parsers.ts deleted file mode 100644 index 80738d18..00000000 --- a/packages/worker-api/src/parsers.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {parseI64F64} from '@encointer/util'; -import {u8aToBn} from '@polkadot/util'; - -import type {IWorker} from './interface.js'; -import type {BalanceEntry} from "@encointer/types"; - -export function parseBalance(self: IWorker, data: any): BalanceEntry { - const balanceEntry = self.createType('BalanceEntry', data); - // Todo: apply demurrage - return self.createType('BalanceEntry', - { - principal: parseI64F64(balanceEntry.principal), - last_update: balanceEntry.last_update - } - ); -} - -export function parseBalanceType(data: any): number { - return parseI64F64(u8aToBn(data)); -} - - diff --git a/packages/worker-api/src/requests.ts b/packages/worker-api/src/requests.ts index 348575c9..cf32b1da 100644 --- a/packages/worker-api/src/requests.ts +++ b/packages/worker-api/src/requests.ts @@ -1,5 +1,4 @@ import { - createJsonRpcRequest, type IWorkerBase, type PublicGetterParams, type TrustedGetterParams, type TrustedSignerOptions } from "./interface.js"; @@ -50,18 +49,6 @@ export async function signTrustedGetter(self: IWorkerBase, account: AddressOrPai return g; } -export const createGetterRpc = (self: IWorkerBase, getter: IntegriteeGetter, shard: ShardIdentifier) => { - const r = self.createType( - 'Request', { - shard: shard, - cyphertext: getter.toHex() - } - ); - - return createJsonRpcRequest('state_executeGetter', [r.toHex()], 1); -} - - export type TrustedCallArgs = (BalanceTransferArgs | BalanceUnshieldArgs | GuessTheNumberTrustedCall); export type TrustedCallVariant = [string, string] diff --git a/packages/worker-api/src/sendRequest.ts b/packages/worker-api/src/sendRequest.ts deleted file mode 100644 index 2dd119f4..00000000 --- a/packages/worker-api/src/sendRequest.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - type IWorker, - type RequestArgs, - type RequestOptions, - type WorkerMethod, - createJsonRpcRequest -} from './interface.js'; -import { Request } from './interface.js'; - -export const sendWorkerRequest = async (self: IWorker, clientRequest: any, parserType: string, options?: RequestOptions): Promise =>{ - if( !self.isOpened ) { - await self.open(); - } - - const requestId = self.rqStack.push(parserType) + self.rsCount; - const timeout = options && options.timeout ? options.timeout : undefined; - return self.sendRequest( - clientRequest, { - timeout: timeout, - requestId - } - ) -} - -export const callGetter = async (self: IWorker, workerMethod: WorkerMethod, _args: RequestArgs, requestOptions?: RequestOptions): Promise => { - const [getterType, method, parser] = workerMethod; - let result: Promise; - let parserType: string = requestOptions?.debug ? 'raw': parser; - switch (getterType) { - case Request.Worker: - result = sendWorkerRequest(self, createJsonRpcRequest(method, [], 1), parserType, requestOptions) - break; - default: - throw "Invalid request variant, public and trusted have been removed for the integritee worker" - } - return result as Promise -} - From e5b6d5388e31b88f3ea2e90edf88aa2b15ba8dd8 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 19:34:41 +0200 Subject: [PATCH 30/50] delete tests in rpc-provider that don't run --- .../src/rpc-provider/src/lru.spec.ts | 57 ------ .../src/rpc-provider/src/mock/on.spec.ts | 43 ----- .../src/rpc-provider/src/mock/send.spec.ts | 38 ---- .../rpc-provider/src/mock/subscribe.spec.ts | 81 --------- .../rpc-provider/src/mock/unsubscribe.spec.ts | 57 ------ .../src/rpc-provider/src/ws/connect.spec.ts | 167 ------------------ .../src/rpc-provider/src/ws/index.spec.ts | 92 ---------- .../src/rpc-provider/src/ws/send.spec.ts | 126 ------------- .../src/rpc-provider/src/ws/state.spec.ts | 20 --- .../src/rpc-provider/src/ws/subscribe.spec.ts | 68 ------- .../rpc-provider/src/ws/unsubscribe.spec.ts | 100 ----------- 11 files changed, 849 deletions(-) delete mode 100644 packages/worker-api/src/rpc-provider/src/lru.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/on.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/send.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/subscribe.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/unsubscribe.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/ws/connect.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/ws/index.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/ws/send.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/ws/state.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/ws/subscribe.spec.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/ws/unsubscribe.spec.ts diff --git a/packages/worker-api/src/rpc-provider/src/lru.spec.ts b/packages/worker-api/src/rpc-provider/src/lru.spec.ts deleted file mode 100644 index 0079eba6..00000000 --- a/packages/worker-api/src/rpc-provider/src/lru.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { LRUCache } from './lru.js'; - -describe('LRUCache', (): void => { - it('allows getting of items below capacity', (): void => { - const keys = ['1', '2', '3', '4']; - const lru = new LRUCache(4); - - keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); - - expect(lru.keys().join(', ')).toEqual(keys.reverse().join(', ')); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - keys.forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); - }); - - it('drops items when at capacity', (): void => { - const keys = ['1', '2', '3', '4', '5', '6']; - const lru = new LRUCache(4); - - keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); - - expect(lru.keys().join(', ')).toEqual(keys.slice(2).reverse().join(', ')); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - keys.slice(2).forEach((k) => expect(lru.get(k)).toEqual(`${k}${k}${k}`)); - }); - - it('adjusts the order as they are used', (): void => { - const keys = ['1', '2', '3', '4', '5']; - const lru = new LRUCache(4); - - keys.forEach((k) => lru.set(k, `${k}${k}${k}`)); - - expect(lru.entries()).toEqual([['5', '555'], ['4', '444'], ['3', '333'], ['2', '222']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - lru.get('3'); - - expect(lru.entries()).toEqual([['3', '333'], ['5', '555'], ['4', '444'], ['2', '222']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - lru.set('4', '4433'); - - expect(lru.entries()).toEqual([['4', '4433'], ['3', '333'], ['5', '555'], ['2', '222']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - - lru.set('6', '666'); - - expect(lru.entries()).toEqual([['6', '666'], ['4', '4433'], ['3', '333'], ['5', '555']]); - expect(lru.length === lru.lengthData && lru.length === lru.lengthRefs).toBe(true); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/mock/on.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/on.spec.ts deleted file mode 100644 index 79f9bc43..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/on.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { ProviderInterfaceEmitted } from '../types.js'; - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('on', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - - beforeEach((): void => { - mock = new MockProvider(registry); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - // eslint-disable-next-line jest/expect-expect - it('emits both connected and disconnected events', async (): Promise => { - const events: Record = { connected: false, disconnected: false }; - - await new Promise((resolve) => { - const handler = (type: ProviderInterfaceEmitted): void => { - mock.on(type, (): void => { - events[type] = true; - - if (Object.values(events).filter((value): boolean => value).length === 2) { - resolve(true); - } - }); - }; - - handler('connected'); - handler('disconnected'); - }); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/mock/send.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/send.spec.ts deleted file mode 100644 index 164d93cb..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/send.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('send', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - - beforeEach((): void => { - mock = new MockProvider(registry); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - it('fails on non-supported methods', (): Promise => { - return mock - .send('something_invalid', []) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Invalid method/); - }); - }); - - it('returns values for mocked requests', (): Promise => { - return mock - .send('system_name', []) - .then((result): void => { - expect(result).toBe('mockClient'); - }); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/mock/subscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/subscribe.spec.ts deleted file mode 100644 index 50bfce2b..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/subscribe.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('subscribe', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - - beforeEach((): void => { - mock = new MockProvider(registry); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - it('fails on unknown methods', async (): Promise => { - await mock - .subscribe('test', 'test_notFound') - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Invalid method 'test_notFound'/); - }); - }); - - it('returns a subscription id', async (): Promise => { - await mock - .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) - .then((id): void => { - expect(id).toEqual(1); - }); - }); - - it('calls back with the last known value', async (): Promise => { - mock.isUpdating = false; - mock.subscriptions.chain_subscribeNewHead.lastValue = 'testValue'; - - await new Promise((resolve) => { - mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, value: string): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect(value).toEqual('testValue'); - resolve(true); - }).catch(console.error); - }); - }); - - // eslint-disable-next-line jest/expect-expect - it('calls back with new headers', async (): Promise => { - await new Promise((resolve) => { - mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { - if (header.number === 4) { - resolve(true); - } - }).catch(console.error); - }); - }); - - // eslint-disable-next-line jest/expect-expect - it('handles errors within callbacks gracefully', async (): Promise => { - let hasThrown = false; - - await new Promise((resolve) => { - mock.subscribe('chain_newHead', 'chain_subscribeNewHead', (_: any, header: { number: number }): void => { - if (!hasThrown) { - hasThrown = true; - - throw new Error('testing'); - } - - if (header.number === 3) { - resolve(true); - } - }).catch(console.error); - }); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/mock/unsubscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/mock/unsubscribe.spec.ts deleted file mode 100644 index 35a9cf2a..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/unsubscribe.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { TypeRegistry } from '@polkadot/types/create'; - -import { MockProvider } from './index.js'; - -describe('unsubscribe', (): void => { - const registry = new TypeRegistry(); - let mock: MockProvider; - let id: number; - - beforeEach((): Promise => { - mock = new MockProvider(registry); - - return mock - .subscribe('chain_newHead', 'chain_subscribeNewHead', (): void => undefined) - .then((_id): void => { - id = _id; - }); - }); - - afterEach(async () => { - await mock.disconnect(); - }); - - it('fails on unknown ids', async (): Promise => { - await mock - .unsubscribe('chain_newHead', 'chain_subscribeNewHead', 5) - .catch((error): boolean => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Unable to find/); - - return false; - }); - }); - - // eslint-disable-next-line jest/expect-expect - it('unsubscribes successfully', async (): Promise => { - await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id); - }); - - it('fails on double unsubscribe', async (): Promise => { - await mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) - .then((): Promise => - mock.unsubscribe('chain_newHead', 'chain_subscribeNewHead', id) - ) - .catch((error): boolean => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/Unable to find/); - - return false; - }); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/ws/connect.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/connect.spec.ts deleted file mode 100644 index 50a857f2..00000000 --- a/packages/worker-api/src/rpc-provider/src/ws/connect.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Mock } from '../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -const TEST_WS_URL = 'ws://localhost-connect.spec.ts:9988'; - -function sleep (ms = 100): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -describe('onConnect', (): void => { - let mocks: Mock[]; - let provider: WsProvider | null; - - beforeEach((): void => { - mocks = [mockWs([], TEST_WS_URL)]; - }); - - afterEach(async () => { - if (provider) { - await provider.disconnect(); - await sleep(); - - provider = null; - } - - await Promise.all(mocks.map((m) => m.done())); - await sleep(); - }); - - it('Does not connect when autoConnect is false', async () => { - provider = new WsProvider(TEST_WS_URL, 0); - - await sleep(); - - expect(provider.isConnected).toBe(false); - - await provider.connect(); - await sleep(); - - expect(provider.isConnected).toBe(true); - - await provider.disconnect(); - await sleep(); - - expect(provider.isConnected).toBe(false); - }); - - it('Does connect when autoConnect is true', async () => { - provider = new WsProvider(TEST_WS_URL, 1); - - await sleep(); - - expect(provider.isConnected).toBe(true); - }); - - it('Creates a new WebSocket instance by calling the connect() method', async () => { - provider = new WsProvider(TEST_WS_URL, false); - - expect(provider.isConnected).toBe(false); - expect(mocks[0].server.clients().length).toBe(0); - - await provider.connect(); - await sleep(); - - expect(provider.isConnected).toBe(true); - expect(mocks[0].server.clients()).toHaveLength(1); - }); - - it('Connects to first endpoint when an array is given', async () => { - provider = new WsProvider([TEST_WS_URL], 1); - - await sleep(); - - expect(provider.isConnected).toBe(true); - expect(mocks[0].server.clients()).toHaveLength(1); - }); - - it('Does not allow connect() on already-connected', async () => { - provider = new WsProvider([TEST_WS_URL], 1); - - await sleep(); - - expect(provider.isConnected).toBe(true); - - await expect( - provider.connect() - ).rejects.toThrow(/already connected/); - }); - - it('Connects to the second endpoint when the first is unreachable', async () => { - const endpoints: string[] = ['ws://localhost-unreachable-connect.spec.ts:9956', TEST_WS_URL]; - - provider = new WsProvider(endpoints, 1); - - await sleep(); - - expect(mocks[0].server.clients()).toHaveLength(1); - expect(provider.isConnected).toBe(true); - }); - - it('Connects to the second endpoint when the first is dropped', async () => { - const endpoints: string[] = [TEST_WS_URL, 'ws://localhost-connect.spec.ts:9957']; - - mocks.push(mockWs([], endpoints[1])); - - provider = new WsProvider(endpoints, 1); - - await sleep(); - - // Check that first server is connected - expect(mocks[0].server.clients()).toHaveLength(1); - expect(mocks[1].server.clients()).toHaveLength(0); - - // Close connection from first server - mocks[0].server.clients()[0].close(); - - await sleep(); - - // Check that second server is connected - expect(mocks[1].server.clients()).toHaveLength(1); - expect(provider.isConnected).toBe(true); - }); - - it('Round-robin of endpoints on WsProvider', async () => { - const endpoints: string[] = [ - TEST_WS_URL, - 'ws://localhost-connect.spec.ts:9956', - 'ws://localhost-connect.spec.ts:9957', - 'ws://invalid-connect.spec.ts:9956', - 'ws://localhost-connect.spec.ts:9958' - ]; - - mocks.push(mockWs([], endpoints[1])); - mocks.push(mockWs([], endpoints[2])); - mocks.push(mockWs([], endpoints[4])); - - const mockNext = [ - mocks[1], - mocks[2], - mocks[3], - mocks[0] - ]; - - provider = new WsProvider(endpoints, 1); - - for (let round = 0; round < 2; round++) { - for (let mock = 0; mock < mocks.length; mock++) { - await sleep(); - - // Wwe are connected, the current mock has the connection and the next doesn't - expect(provider.isConnected).toBe(true); - expect(mocks[mock].server.clients()).toHaveLength(1); - expect(mockNext[mock].server.clients()).toHaveLength(0); - - // Close connection from first server - mocks[mock].server.clients()[0].close(); - } - } - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/ws/index.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/index.spec.ts deleted file mode 100644 index 6b5e3905..00000000 --- a/packages/worker-api/src/rpc-provider/src/ws/index.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Mock } from '../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -const TEST_WS_URL = 'ws://localhost-index.spec.ts:9977'; - -let provider: WsProvider | null; -let mock: Mock; - -function createWs (requests: Request[], autoConnect = 1000, headers?: Record, timeout?: number): WsProvider { - mock = mockWs(requests, TEST_WS_URL); - provider = new WsProvider(TEST_WS_URL, autoConnect, headers, timeout); - - return provider; -} - -describe('Ws', (): void => { - afterEach(async () => { - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('returns the connected state', (): void => { - expect( - createWs([]).isConnected - ).toEqual(false); - }); - - // eslint-disable-next-line jest/expect-expect - it('allows you to initialize the provider with custom headers', () => { - createWs([], 100, { foo: 'bar' }); - }); - - // eslint-disable-next-line jest/expect-expect - it('allows you to set custom timeout value for handlers', () => { - const CUSTOM_TIMEOUT_S = 90; - const CUSTOM_TIMEOUT_MS = CUSTOM_TIMEOUT_S * 1000; - - createWs([], 100, { foo: 'bar' }, CUSTOM_TIMEOUT_MS); - }); -}); - -describe('Endpoint Parsing', (): void => { - // eslint-disable-next-line jest/expect-expect - it('Succeeds when WsProvider endpoint is a valid string', () => { - /* eslint-disable no-new */ - new WsProvider(TEST_WS_URL, 0); - }); - - it('Throws when WsProvider endpoint is an invalid string', () => { - expect( - () => new WsProvider('http://127.0.0.1:9955', 0) - ).toThrow(/^Endpoint should start with /); - }); - - // eslint-disable-next-line jest/expect-expect - it('Succeeds when WsProvider endpoint is a valid array', () => { - const endpoints: string[] = ['ws://127.0.0.1:9955', 'wss://testnet.io:9944', 'ws://mychain.com:9933']; - - /* eslint-disable no-new */ - new WsProvider(endpoints, 0); - }); - - it('Throws when WsProvider endpoint is an empty array', () => { - const endpoints: string[] = []; - - expect( - () => new WsProvider(endpoints, 0) - ).toThrow('WsProvider requires at least one Endpoint'); - }); - - it('Throws when WsProvider endpoint is an invalid array', () => { - const endpoints: string[] = ['ws://127.0.0.1:9955', 'http://bad.co:9944', 'ws://mychain.com:9933']; - - expect( - () => new WsProvider(endpoints, 0) - ).toThrow(/^Endpoint should start with /); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/ws/send.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/send.spec.ts deleted file mode 100644 index 7de7b395..00000000 --- a/packages/worker-api/src/rpc-provider/src/ws/send.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Global, Mock } from '../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -declare const global: Global; - -const TEST_WS_URL = 'ws://localhost-send.spec.ts:9965'; - -let provider: WsProvider | null; -let mock: Mock; - -function createMock (requests: Request[]): void { - mock = mockWs(requests, TEST_WS_URL); -} - -function createWs (autoConnect = 1000): Promise { - provider = new WsProvider(TEST_WS_URL, autoConnect); - - return provider.isReady; -} - -describe('send', (): void => { - let globalWs: typeof WebSocket; - - beforeEach((): void => { - globalWs = global.WebSocket; - }); - - afterEach(async () => { - global.WebSocket = globalWs; - - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('handles internal errors', (): Promise => { - createMock([{ - id: 1, - method: 'test_body', - reply: { - result: 'ok' - } - }]); - - return createWs().then((ws) => - ws - .send('test_encoding', [{ error: 'send error' }]) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toEqual('send error'); - }) - ); - }); - - it('passes the body through correctly', (): Promise => { - createMock([{ - id: 1, - method: 'test_body', - reply: { - result: 'ok' - } - }]); - - return createWs().then((ws) => - ws - .send('test_body', ['param']) - .then((): void => { - expect( - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (mock.body as any).test_body - ).toEqual('{"id":1,"jsonrpc":"2.0","method":"test_body","params":["param"]}'); - }) - ); - }); - - it('throws error when !response.ok', (): Promise => { - createMock([{ - error: { - code: 666, - message: 'error' - }, - id: 1, - method: 'something' - }]); - - return createWs().then((ws) => - ws - .send('test_error', []) - .catch((error): void => { - // eslint-disable-next-line jest/no-conditional-expect - expect((error as Error).message).toMatch(/666: error/); - }) - ); - }); - - it('adds subscriptions', (): Promise => { - createMock([{ - id: 1, - method: 'test_sub', - reply: { - result: 1 - } - }]); - - return createWs().then((ws) => - ws - .send('test_sub', []) - .then((id): void => { - expect(id).toEqual(1); - }) - ); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/ws/state.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/state.spec.ts deleted file mode 100644 index f7c4d6e4..00000000 --- a/packages/worker-api/src/rpc-provider/src/ws/state.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import { WsProvider } from './index.js'; - -describe('state', (): void => { - it('requires an ws:// prefixed endpoint', (): void => { - expect( - () => new WsProvider('http://', 0) - ).toThrow(/with 'ws/); - }); - - it('allows wss:// endpoints', (): void => { - expect( - () => new WsProvider('wss://', 0) - ).not.toThrow(); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/ws/subscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/subscribe.spec.ts deleted file mode 100644 index c1694526..00000000 --- a/packages/worker-api/src/rpc-provider/src/ws/subscribe.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Global, Mock } from './../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -declare const global: Global; - -const TEST_WS_URL = 'ws://localhost-subscribe.test.ts:9933'; - -let provider: WsProvider | null; -let mock: Mock; - -function createMock (requests: Request[]): void { - mock = mockWs(requests, TEST_WS_URL); -} - -function createWs (autoConnect = 1000): Promise { - provider = new WsProvider(TEST_WS_URL, autoConnect); - - return provider.isReady; -} - -describe('subscribe', (): void => { - let globalWs: typeof WebSocket; - - beforeEach((): void => { - globalWs = global.WebSocket; - }); - - afterEach(async () => { - global.WebSocket = globalWs; - - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('adds subscriptions', (): Promise => { - createMock([{ - id: 1, - method: 'test_sub', - reply: { - result: 1 - } - }]); - - return createWs().then((ws) => - ws - .subscribe('type', 'test_sub', [], (cb): void => { - expect(cb).toEqual(expect.anything()); - }) - .then((id): void => { - expect(id).toEqual(1); - }) - ); - }); -}); diff --git a/packages/worker-api/src/rpc-provider/src/ws/unsubscribe.spec.ts b/packages/worker-api/src/rpc-provider/src/ws/unsubscribe.spec.ts deleted file mode 100644 index 9693c5d6..00000000 --- a/packages/worker-api/src/rpc-provider/src/ws/unsubscribe.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/// - -import type { Request } from '../mock/mockWs.js'; -import type { Global, Mock } from './../mock/types.js'; - -import { mockWs } from '../mock/mockWs.js'; -import { WsProvider } from './index.js'; - -declare const global: Global; - -const TEST_WS_URL = 'ws://localhost-unsubscribe.test.ts:9933'; - -let provider: WsProvider | null; -let mock: Mock; - -function createMock (requests: Request[]): void { - mock = mockWs(requests, TEST_WS_URL); -} - -function createWs (autoConnect = 1000): Promise { - provider = new WsProvider(TEST_WS_URL, autoConnect); - - return provider.isReady; -} - -describe('subscribe', (): void => { - let globalWs: typeof WebSocket; - - beforeEach((): void => { - globalWs = global.WebSocket; - }); - - afterEach(async () => { - global.WebSocket = globalWs; - - if (mock) { - await mock.done(); - } - - if (provider) { - await provider.disconnect(); - provider = null; - } - }); - - it('removes subscriptions', async (): Promise => { - createMock([ - { - id: 1, - method: 'subscribe_test', - reply: { - result: 1 - } - }, - { - id: 2, - method: 'unsubscribe_test', - reply: { - result: true - } - } - ]); - - await createWs().then((ws) => - ws - .subscribe('test', 'subscribe_test', [], (cb): void => { - expect(cb).toEqual(expect.anything()); - }) - .then((id): Promise => { - return ws.unsubscribe('test', 'subscribe_test', id); - }) - ); - }); - - it('fails when sub not found', (): Promise => { - createMock([{ - id: 1, - method: 'subscribe_test', - reply: { - result: 1 - } - }]); - - return createWs().then((ws) => - ws - .subscribe('test', 'subscribe_test', [], (cb): void => { - expect(cb).toEqual(expect.anything()); - }) - .then((): Promise => { - return ws.unsubscribe('test', 'subscribe_test', 111); - }) - .then((result): void => { - expect(result).toBe(false); - }) - ); - }); -}); From a22bfe2eb4dccad13e13ea0254f548d49591c20d Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:00:10 +0200 Subject: [PATCH 31/50] allow passing the websocket implementation into the rpc-provider. --- .../worker-api/src/integriteeWorker.spec.ts | 21 ++++---- .../src/rpc-provider/src/ws/index.ts | 52 +++++++++---------- packages/worker-api/src/worker.spec.ts | 19 ++++++- packages/worker-api/src/worker.ts | 6 ++- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/packages/worker-api/src/integriteeWorker.spec.ts b/packages/worker-api/src/integriteeWorker.spec.ts index 64aadd4a..421b9826 100644 --- a/packages/worker-api/src/integriteeWorker.spec.ts +++ b/packages/worker-api/src/integriteeWorker.spec.ts @@ -4,9 +4,9 @@ import {localDockerNetwork} from './testUtils/networks.js'; import { IntegriteeWorker } from './integriteeWorker.js'; import {type KeyringPair} from "@polkadot/keyring/types"; -// import WS from 'websocket'; +import WS from 'websocket'; -// const {w3cwebsocket: WebSocket} = WS; +const {w3cwebsocket: WebSocket} = WS; describe('worker', () => { const network = localDockerNetwork(); @@ -24,15 +24,14 @@ describe('worker', () => { worker = new IntegriteeWorker(network.worker, { keyring: keyring, // @ts-ignore - // createWebSocket: (url) => new WebSocket( - // url, - // undefined, - // undefined, - // undefined, - // // Allow the worker's self-signed certificate - // { rejectUnauthorized: false } - // ), - // api: null, + createWebSocket: (url) => new WebSocket( + url, + undefined, + undefined, + undefined, + // Allow the worker's self-signed certificate + { rejectUnauthorized: false } + ), }); }); diff --git a/packages/worker-api/src/rpc-provider/src/ws/index.ts b/packages/worker-api/src/rpc-provider/src/ws/index.ts index dd88724d..288cce1f 100644 --- a/packages/worker-api/src/rpc-provider/src/ws/index.ts +++ b/packages/worker-api/src/rpc-provider/src/ws/index.ts @@ -1,24 +1,20 @@ // Copyright 2017-2024 @polkadot/rpc-provider authors & contributors // SPDX-License-Identifier: Apache-2.0 -// import type { Class } from '@polkadot/util/types'; +import type { Class } from '@polkadot/util/types'; import type { EndpointStats, JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback, ProviderInterfaceEmitCb, ProviderInterfaceEmitted, ProviderStats } from '../types.js'; import { EventEmitter } from 'eventemitter3'; -import { isNull, isUndefined, logger, noop, objectSpread, stringify } from '@polkadot/util'; -// import { xglobal } from '@polkadot/x-global'; -// import { WebSocket } from '@polkadot/x-ws'; +import {isChildClass, isNull, isUndefined, logger, noop, objectSpread, stringify} from '@polkadot/util'; +import { xglobal } from '@polkadot/x-global'; +import { WebSocket } from '@polkadot/x-ws'; import { RpcCoder } from '../coder/index.js'; import defaults from '../defaults.js'; import { DEFAULT_CAPACITY, LRUCache } from '../lru.js'; import { getWSErrorString } from './errors.js'; -import WS from 'websocket'; - -const {w3cwebsocket: WebSocket} = WS; - interface SubscriptionHandler { callback: ProviderInterfaceCallback; type: string; @@ -98,6 +94,11 @@ export class WsProvider implements ProviderInterface { readonly #stats: ProviderStats; readonly #waitingForId: Record> = {}; + // @encointer's only customization so that we can pass in + // node's websocket implementation in our integration tests + // to accept the worker's self-signed certificate. + readonly #createWebsocket?: (url: string) => WebSocket; + #autoConnectMs: number; #endpointIndex: number; #endpointStats: EndpointStats; @@ -113,7 +114,7 @@ export class WsProvider implements ProviderInterface { * @param {Record} headers The headers provided to the underlying WebSocket * @param {number} [timeout] Custom timeout value used per request . Defaults to `DEFAULT_TIMEOUT_MS` */ - constructor (endpoint: string | string[] = defaults.WS_URL, autoConnectMs: number | false = RETRY_DELAY, headers: Record = {}, timeout?: number, cacheCapacity?: number) { + constructor (endpoint: string | string[] = defaults.WS_URL, autoConnectMs: number | false = RETRY_DELAY, headers: Record = {}, timeout?: number, cacheCapacity?: number, createWebsocket?: (url: string) => WebSocket) { const endpoints = Array.isArray(endpoint) ? endpoint : [endpoint]; @@ -141,6 +142,7 @@ export class WsProvider implements ProviderInterface { }; this.#endpointStats = defaultEndpointStats(); this.#timeout = timeout || DEFAULT_TIMEOUT_MS; + this.#createWebsocket = createWebsocket; if (autoConnectMs && autoConnectMs > 0) { this.connectWithRetry().catch(noop); @@ -211,24 +213,20 @@ export class WsProvider implements ProviderInterface { try { this.#endpointIndex = this.selectEndpointIndex(this.#endpoints); - // // the as here is Deno-specific - not available on the globalThis - // this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) - // ? new WebSocket(this.endpoint) - // // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // // @ts-ignore - WS may be an instance of ws, which supports options - // : new WebSocket(this.endpoint, undefined, { - // headers: this.#headers - // }); - - // @ts-ignore - this.#websocket = new WebSocket( - this.endpoint, - undefined, - undefined, - undefined, - // Allow the worker's self-signed certificate - { rejectUnauthorized: false } - ); + + if (this.#createWebsocket !== undefined) { + this.#websocket = this.#createWebsocket(this.endpoint); + } else { + // the as here is Deno-specific - not available on the globalThis + this.#websocket = typeof xglobal.WebSocket !== 'undefined' && isChildClass(xglobal.WebSocket as unknown as Class, WebSocket) + ? new WebSocket(this.endpoint) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - WS may be an instance of ws, which supports options + : new WebSocket(this.endpoint, undefined, { + headers: this.#headers + }); + + } if (this.#websocket) { this.#websocket.onclose = this.#onSocketClose; diff --git a/packages/worker-api/src/worker.spec.ts b/packages/worker-api/src/worker.spec.ts index 1e2cbe24..31cb5aa1 100644 --- a/packages/worker-api/src/worker.spec.ts +++ b/packages/worker-api/src/worker.spec.ts @@ -3,13 +3,30 @@ import {localDockerNetwork} from './testUtils/networks.js'; import { Worker } from './worker.js'; import bs58 from "bs58"; +import WS from 'websocket'; + +const {w3cwebsocket: WebSocket} = WS; + describe('worker', () => { const network = localDockerNetwork(); let worker: Worker; beforeAll(async () => { jest.setTimeout(90000); await cryptoWaitReady(); - worker = new Worker(network.worker); + worker = new Worker(network.worker, + { + // @ts-ignore + createWebSocket: (url) => new WebSocket( + url, + undefined, + undefined, + undefined, + // Allow the worker's self-signed certificate, needed in non-reverse proxy setups + // where we talk to the worker directly. + { rejectUnauthorized: false } + ), + } + ); }); afterAll(async () => { diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index e3313d10..8474f53c 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -32,9 +32,13 @@ export class Worker { constructor(url: string, options: WorkerOptions = {} as WorkerOptions) { this.#registry = new TypeRegistry(); - this.#ws = new WsProvider(url); this.#keyring = (options.keyring || undefined); + // We want to pass the custom node's websocket implementation into the provider + // in our integration tests, so that we can accept the workers self-signed + // certificate. + this.#ws = new WsProvider(url, 100, undefined, undefined, undefined, options.createWebSocket); + if (options.types != undefined) { this.#registry.register(encointerOptions({types: options.types}).types as RegistryTypes); } else { From 9231c933a76d252ad47ce6bf476eda56ac5dfd3e Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:05:37 +0200 Subject: [PATCH 32/50] remove old IWorker interface --- .../chnl-npm-1.2.0-0147cf365c-78044132c0.zip | Bin 13014 -> 0 bytes ...tract-npm-1.18.0-ac2faa8a98-98b2dd3778.zip | Bin 562481 -> 0 bytes ...roller-npm-1.0.0-3e6f67a83d-80e1a43d37.zip | Bin 6386 -> 0 bytes ...inally-npm-3.1.2-18b6014744-e0b6e94d32.zip | Bin 12659 -> 0 bytes ...omised-npm-2.0.1-289ab937b7-68dfd25be9.zip | Bin 13462 -> 0 bytes packages/worker-api/package.json | 11 +- packages/worker-api/src/interface.ts | 11 -- packages/worker-api/src/worker.ts | 4 +- yarn.lock | 100 ++---------------- 9 files changed, 18 insertions(+), 108 deletions(-) delete mode 100644 .yarn/cache/chnl-npm-1.2.0-0147cf365c-78044132c0.zip delete mode 100644 .yarn/cache/es-abstract-npm-1.18.0-ac2faa8a98-98b2dd3778.zip delete mode 100644 .yarn/cache/promise-controller-npm-1.0.0-3e6f67a83d-80e1a43d37.zip delete mode 100644 .yarn/cache/promise.prototype.finally-npm-3.1.2-18b6014744-e0b6e94d32.zip delete mode 100644 .yarn/cache/websocket-as-promised-npm-2.0.1-289ab937b7-68dfd25be9.zip diff --git a/.yarn/cache/chnl-npm-1.2.0-0147cf365c-78044132c0.zip b/.yarn/cache/chnl-npm-1.2.0-0147cf365c-78044132c0.zip deleted file mode 100644 index ba3aab3f7f7b56e4c9b256a33bd04ade7e12a925..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13014 zcma)j1yo!~)AryA?ivyx5ZpDm1()FNI=H(#!QI{6-Q8V-TOb5?4g6&H&9}R3vgf}r z%$;*?KT}m*)m8m;@lg~E92M~6;x3(i@yE^o{=hxGo0;qCX_%Vp+M4KDQ-A#JqsY%6 z)iE?P`H$x@e)+sE(AwsIgFpc<{*Y&2_nRX-3;+O53IGuOevpo#mYJEJ36+kKHI>Od=A9Q`yN z^(ZsiUrl}FPvbItYb(`GN`I%dG=sOwqfwuvpt|2!8t+X5_IVV=>e?tTJ7p>mu0F8x z(^5IqZG1&s%ew7^Iwmu(XRQ%9I+x>#9HGd z`ZrViQpViQKEaBFzP_%G#!r18s97k+aU5nKj^X{$g_~x(Bg?scbmvIwZw&^I(OtQR zK(kj2hny#wjR-i%%m;Wzd=x*nDMudX%nq>o)p$>oE9dNuqQYFX$F}aDXDbbVl`#Lp z=peF3h);gxAw6K-kENAAygB84=bug+)W&t|1{jUZp-`#WVN6KEe~|$dac1dTq~e7I zDgQ`4c%$plL}__A>;V;lgqDsa1-_KS>)j!35F@`;21@{fw8li5$J=22MV8%)y^Y@c z2tcvlcRLQto`ehv+kBr37l<%P8LX4q4y@0y2$DcthBx2jx@~Jc2 z_iMPp1|*z=h27roS0r;ob}f0dI-YcsV7;b>k-mj(7E^?PwdaResvyzQYW$4KhM$!( z?!l%dUIA)5A0OkQ+_Fu0_G!|_p!-^9P~#rNM`3n9u5wLm?+tv|27%wr8_^a}^;tZ< zDpFFun-bIHX#Tb~FadGvvEYwM?+!zFxdX^4`;WNq(A?>w<3YH%$6?NexnRD2!CV^; z&Bo&^0I$ma(pYpbJv;L~3m{97s6xO^d`tBzB;;PTp1;BG*Gh;TfMCyEW#ou!T3?U#4VonID}vV^BD-l()T&S-vhOj%ARn zhya6NSciQF;USDqgxrAWOGv^)fD$Rl4L5>ru&iS3qXNpzye5Igw+CmfxGSse2}V#_ zjl)!+aZPO{if7P|@9j|#&i|FvQxVT@UnsIjx3xk9ze6JBF4db8&{B%jE&7^QqW{XN z%4-V=Es#fI#EXo-7UV|kzV)9Un$$Qa7aH(hveYzDfOjtaM=WF>Nc=;^oso_0pm8ZQRH zekoY+t1ruw)0=%&#u6Y_yfy1$6TwlOJ_;W)po&0Xl~lTUkyQ^y`0FuUiyU zZQwLl^#DnjtC`|KaIKTHO@|H58zBN>p+#_GX#x~@hxgeHjx$C=pvsvb-IWi#UM3Db zGgNEYSM4c&vqYZD;CR~U*bF{Q^SPFq96l(QKtv}ZCH1?=z5kjfI@8rC?phTy8ixblVK zh?LVdw5x9rdg$Z4q1~^;)P6%N)_?P2gX^2Wml)AK;{C2TG?7nW^aU=T+=|vq!v3K} zsxY2|QIzOBhC9>%7w-&e#Nw>k3KZ|PIuA$|U{69zGzDbXdadG`ub9a+A=iF)@L-~_ z=71v)sN&4k+k9rO4Ms1DX`E95a^(RdCNvU|KFw`GfVZ+%zT7#X7fdw6_TFUnNS(tT zsrUVjeQFM-i_x@Q;|1XqRG;g_$}06z|3vCKcTbn$uyCn*K~JftxcO~tKaN^s z3emMJ1e!_}cmPMVq9COH2YpJ0R0Xj->weV(43B<-zBqVU?2FIyqk5dDGf4`-VB0&R zeL65jtE^ocy95XdV`>JTqsUq)yD}+Y&lFI_&Y)cnBAGkMVx2N5U4+$jmVzWIH=~0g zs?&S{TrQF3OiA`Y)AySN34(uh!S{YkhxT3$8msEb*b_{sMHcxTo-Vfdt0{>kYf4V~ znd*epI@P{YiW|;Nh|V!0SG6-A`00KLqc+*_xPJ9Cz&Ujc8otb$0xv&yw;k(8L*=i0 zJ$9=dmA=}!8QN=B>O(P*Bw0A2MPi_P+m(6LW>b_CV5*zNEjNap*alt4NjUFbFE#RF znP{0Y#t#sr*2Ai#bEw;Z88RwGs*?x4hF=k~goGSspZn6^A*62H)E;t+?Ydew=K$G@ zpOgy5s;ZSJJSs2)cHx>K!l2m}Ppo)pAJ{ z$P?@(PFCS=7^kEH`?V9DZ>Yp1T)C1kPSn>A7J0_0Z(Ab>s+(AxUnR0!3kM(&w9I{U zIMv41#If6rfPZWstm2k13_w{>`!1`8YF;n!RiUgTyH~)uO`Nw*7})4Ht;DhM4VM-h ze?xS^nHEB|PryM8CDk`w7j6Mso>5ZU2z~}0Y^94?5jH=W3wniVHHJTL`aXE&6I1=% zzCdRhuH+<760f>np(xfXa8v6__}VY0?DY*&;+RvTL89uB5 zREPNDE&&en8M#+l=)moc(3o7co+U(O+M{Zrp61UPXnh;yBM3Et4>;*E1C9`@-#;b0 zBS`Ia&&hN?q6@jv8n@0xWFT6C`@N{)4~TD15Q~oG!SPW>+~wg@M-e7s#KA@MDSqR# zitTYM=ijGMFTq@^9lp8O*GK}@hL6owOn9D3#^+e<*m%f}@==4*v4Mf#*xHR7*xPHe z@SufoIIrdgks-Vj#Cf^M#or}BU2*f#j174oYMeh>4Z>B3Q@2=!pLBFR>DEaG`M3^8 zZpBlTd5T(N^WIbmrM|TOAY;mHx0B04>Rw>hi83AoT?GMSuM0Phodp{^o%=fxFVWqw z9`CwD&<&Sx9P$k#T%M+I@zCQkGh|IgB{1MrlBE-Z#vbIs-aqhDbv&0(%J8xaqIFkj zQ#^u|!&hl<=8neUJy6U8n8#2JA{Vv$<+R_|_~^eZzpbrwo|Wv3R=K8~kN^M<9suz4 z`_C=JANztIV}4fbM%FD#D&~t!sBU9rYaSW3D14*po9`84-*J1IQqG!D`&LvUn;OTE zhGkoM7CmyVP!s4C3D?H4#KI$t-CU=@gc;=J5wD@Dn&b86gBB$MZ2W1m($zh235$o5 zppn~O+mnDP$jPnB0ItZ*ry>Aa~p){Mux4(eK0k_)N+*6>zzM(cYv1lZNmVMEXTgV%@82Qmoby(;= zwDv+R4b~=6yW!I3G2!JU=zvCdm}+^PDdG-gj_Q0nlnC^prW|?#sysO?mZS=Ko(5t! zwioIAqpCGD_tM}F{X-mG|E6cros3~sHtpWXIw=I5t^=YHIeHKTW(jt3Rb}s6kTUx4 zD!_(nfU#XSl^fm{C(3$@HXfE^IFWcIQ*C^UDW@znHuG5_a8V0+olV{b(9NUBJTpT0 z@J^T}$<-s=iiM!iaq?Oc?D3GIi{Zsc1VK%FihCtM z5gR78DP~RcxfLI#YOh4XUIUH+&tL8WYg>c2p)T!F;Qb~g2z3A~M|qC#c-g4_0jgm| zx#}Ty<8E9^p%aU+v-H{i9aqCytoemB)yWF}mk3sdu{S)+kG=S4xP0Gv;+Y6zXLV)a zR0Cno+eu60`_0W2pqSzpxuwaeG1i)Km~8r-8S2VV&k67XU$6G>nQnfyUpcFt&(CG5 zJYv@KZC5;O^PEOu(Oz;a(=yLzlRj^Mbe@=EpT2Q`XFm$?7PG!L`J(lXAQo@p>q#3T zS0mqGYY&3Gw5Pa)lJ9Y%X}jK_hcQoRINlWzMlM@)Tv246?n_2nv5GlwiXl9uDa2~z z)ftU&dQ)otT)?DYvPORQj6|xLpL8`oV=0a@N3XnE;FTn;IAMCUUK`V)S?oo5*~-hx zNxkcZLIK8NEdCnG$B!}lr;u%w=jF3Q%=E!XZP69v$luqPm#lGfG@ z0YXf#Pd_1E8?FlTz9tG-%gjL{J*uZf4-RO54dQbO18>1Rxv?#lzEdvcJBWP9_g&yn zD=1c!KsMXXU$L5Sv!oX;Ei-tT@nq603Z93Yl@U+rVo>*t6GeQadn)(BR-_iqes_*z z(s91f@WTCoQ1b?n?8DM1zdb#?D~m+psOfh-QZyPP%nand4BWYc;1U-7<0=5s76*!l z1T?b1ds)4{DY?Tp_SK^3InHm8hcYF$H>?r5Iipo%nN5bHpotBOu@3EKHtUCyfF3ux z(4))m3J7mtxU{O-pt_A4J>8H=(V7bazP5aQkMlX z`y2x|`^zoCNAK5Hu`t!Z=QLF50iFpe16D<^fe=|M{jR10IYhCzMP!D%Cvvk;-tSkL z6~&olzkRJGqk42%tS1XvpnFu_Hx>`JRB!pVht-pRWA!UC;cEyXb?aelu|ry*+m2fWx-A@ytdnEW?_WgLSaX#Fr1T>NA~E#Wraa9whD zt)`@0To7BToK~ogf=QiAf)yE?LW#?hfVAEqQ%T6xXZemCHZNE78GospSLDO1GiLc% zgiJ1BGi6N@VMSb0U1V@LlL`;(GFSg9u*21`+F{L{<%B%8RW-J6&_BeyOng$xjUnE9 zo89nsGj-@ZsTQVnzhNgqU!QItBYQl_zPXC;e7A=tn8a1ul>^5>;SF!c{V=0ZX{R%; zm6eMYc|jO2m78f}$-tg~nAx=-!z?a^#X6bFN=qQT0+5ZVrsFgfdwypuBdu?VsG+>K zZE0O-7%F3`gzmwEG}cWUM@Mz5jft)A?k?ua3VvrYSBPpi@>r$nd$3T^%7rEMdu!NI z$h&TSg;X~(ouDs63`o0RsQ0NOjIIk&?jV2W%%7AMK{Qd;1}FeP01p7X z`)&Tyv(q!Pq0}=4+Suq>J#khe3Tl#zG^nmeW%Y&#;$&OR2(%UW@5C4Ust@4sX-36$ zEcW~DG6KG|joBqrl=JCmHTSBtL( z+!1udMNxS>QYHyjll%am!Q(QvU%@mYqVrN~24KH_Hy)dhF-)fLAs(dfJ@Xa;Q%84W z+XQR$%aLvlFPnCr+xVS#@*zA7xdOc~qj=Rsm^HD2j5F0<{%P_}RLtHNZo6l^bgMI~ z5nGU$Xd9m@ljNGFii~w@J?Z^dHYQX3zYKGvP_+us2n&_3`p^3twkU7EG98{}&DO=# zS-$B(<>rOR>!pSz$SWHOmRTj?6`;Kae>rHr#Z2TU<8O%Lr5IR6$tfR0D^>w!OY^EE z%rd*w`V@@ZHpshWGWCXSCICMEL4_c!(*hCEsaH+K4X0$G17?b{OGvz1_*rT4)|x3sBQ=ga)1B;ZcmZ&`nOR6 zG}F~{c#00w$O*Gf8rTo3XJ~GGyyOXf@!LV9+;(ZMRQF`@w#A~p}+IY!iuDUV!Ob5i&QPVX)^<>u*&oj0$xyK z_@RZ&eW|f!XA5F-DEa+7E z4BzTkoE(Ns9t4o*Cv=I3Xy+h21X!AX2gYLFtPp%@Aly0Y`L5mI622V0QPID;g;tRN zy?|zy*ygeTb0vLo6ycwSUo*aV!TV(Nkf#!z{I`a;($mthp|rNuw$`x%TG#;1&7NkV zMo!#(kp{NqPT?3muYcdU3Uzs5&q+~g|08OV$y5!E(kWr1@W<=6uvo5423&c~uRWEd zryQpdk$TIRq85w#RCSg3K!)hq>2e`y3`~qY5|Ob{0v2z!NRi3cRz%5Qh2S8EFKpM< zJ1}8NqHbSi`yG*3HPQ{|N};%bPDBgK+ z7nGf=%~ox2-Abb6F|s_S6ukP`yo)3}{b=a*?2mYAE=21eJGX_3-X?I$<9S3yz|wOK zFsuhZKq87AanTIYaROzq*GJ#1gNja&Vf%6=B-M?PRN)qI5!YZtuI$j)Y15Qsy>Cg| z#wv+{7^Q64P#WNWAm>+3HmuQnyGr^9hL*oQF=*wnN8omy1ifMp<6~B*d(*mc5OFIo zeO)UUBAY<4RCk&n>G3FXbJOa?n<)oi-92;$OR!-1a5g-4a;0_M^ zDOxizpp-#V zRa{P$%LJ-~g|jR<(Bfq&7$N8>VP_?2T`8!na7uw*O!&$5mVmcr29eD4vLU&VIlkaF z5`(A4TU@X6)|*zwtlyJrkI@)R9oLzcT5jJ_?|5j2*DzmCxvoLv#EqoeZ1=h|^J!5c zIGE|D-OMA4JdQ>UOv2_Slf$2lCP~)bYuGeGAP}`QY?pLc!e~}3^O7z1gomy!%8ImlwJX%i3_dggoA+#p zRMb0Ps6ca+jY0n9jy`>^SY%~v)1#c} zMj0oizA#cg!t2s9U$kTNdR$UFUe)G@^rFZL_yHy{?Z+?CzD329lzx2#N0yRr%9MYV z9xaq--2T;Lkn9ZrH=kJ#sW7ElJ^{~=4nf0yatM92Ze?g{=6wtXn9~VuM%%6@(6>Sy zS8oxkXVq!kj=B1s@XRRoB=?uW&mJ^rFXl97v`i3XWKN0AKv)n!1X@LJ(;Gz6DZDN0 z7H5#9FyY=_s}-?>{29;S8f1)5eaBk0+SYMlIPtbpS>;1`4>j$`!Ed zz!S!XUPpR3m|d=bn&v3bqXELd8JC6Qfs8gch7EX0%s!?>878+8QxgF7yJ(%z6u@Ao zw1*^B6Sp6A=U2I^w2mchZufEjnU&BLp>K7cX7csZ3G}ba0Shf1V=V){r%5z7bA3ug zOH4n)A2Eo)FeKpAY^jSgmtG{wwr; z&}$kr;YS}J+qTn4p?aZTi&IZGUZv!8L=?TqMJ$?a?X(q^XaPy|mR{(>njTD!wgR!| z_m0U&coAQB)o&#O_#p+~KSwJpI{PKM>GP6BHM+VCf1)(z%Tv_qAF9fn$H%yZcDIrR zxtgs9zJ@i_8Unaro7D@(xy`kQVAFow2%K0W8#)xRmsV^Z}|c!u+1zN_7!q7z#&OG-$~@Y5X?QeU1T0 zQV!^}NGbEG4#n7s3Hu2B0dvzKf?FRP#on?>xxy*hI(aT- zv0a?{XCbD%$wS7*kxd_5sGmPgTD@j_V7dY_X!9Zn0Xf+1Gb)I_ z;5@Ktw6S;?Du<1kX6}pK8mgOLCw=C;423mEIt^?sFYK$!?^l{59-3d;nHeE%J#N## zSRd(0hg%g>4KNpDFo}H`ISn?k`id z^amb(@efp{x*@VdW{Wh4&1dAb#~raqG8R^%5w*ZcZ%@=0;k6i9+$2Dvi3}mn>@S!+ z4=th``@OVF>fnjV3Am$F9A4GapD4K~+DdZ<*E(R@?AnNPu4Q>B(6rh{z_uD3W0n=|m=t_)V=3o=nSHQce;}X_gJ4%axS#iZ_HS(7e~=Ya?OG zU?hWza!TnoOG)N=r$*_boXG)JoQ)9ZF&C4#^zH1!%ZgBhf+@bn3ZOq_B8o*Q0NK9ky;ZeO;mJ?`)zpOfOXsz5wD`aNrk^~H z9;yS>yQc!<^vUfB|6|n8lMG!d8|#<|9#Jq_MBk%3a=+ACygk=AGP&dI7fz@MRRi-O z`cXs)l$1G-%M6T^S{X+0p2K(BBgdJy9jt!KS*K!tORqVT547af%HSoZt8z&k*btZO z4PkeQH%3Jixk*3nq4y}j72H7kFpA-{mVk`d!3W_est_d&ua7E=8$K9Mzh3NQtqx?P zj?^L%M#jeFsc(_Aokv3qHP9tmvo&e4Fw)c$w=vDchIF*c(%cjA=I{+%FBvv+o0b}^ zmP;|Pm`n>v4+_p&DSh;!sF8@K-no!;fNJ66+8y&s)|^;FUEqlxMCi{XFDm4%JEirl zyE2i`j&(EcCMtiEAc8Y>j!(bn7P%ITDXv?Y$Ovr7i6-1T!`S{N8mv>uMY3~)0mF_W zljM2=_s`TX(;`s^_GIVdCp-Uo7x3ro{F5>JVeOpA4v}seM86}~PYnm?*()-I>HFd* zAl+Ribf(@w>Bx%x%KfIK+G^?L8L#)?bZ1{4qwXif_t?91LS`d9Cz~LdK9{_jOJbjR zYvDBMIS|P;CN@J|VSvn83*Hn;afCf0_K;9-2Ms+ht$~I(Z>+NH!LM^kvI-&QRk2Mm z<#_~=&XN0ZiD##J(7lk8#o)yOSIT0Uy|$<_3#whKbFBPS!(y50L-o1h>{%pI>fV>pL(5sVok#^l6@(0GII@VKeL3v0Ta3^C z0QyqDl?QAA@(oH0ccL0`igyO(2bXhJ?S_2cM0kuMe-Vw6ySJ(fDk;d?yM5`!w8G5O z#;fI@SP2i*gipj8JWpkJ-Ra;@Z)3|Wu%B2>R)f6v1}m#Or89W@IC7 z+ZG1jzV&U;Rce&)F^1!#n)SW<2v*!Tn16=MW}rP*_>&>QpNvZV9|rvei}S;}rlD=n zo!qegSI(bId-SpBRWTC z=}=?gtywgtEylk3kQd2;-4liJ##TMpln>HscH;Wwn>Df5CF~J#xa(6eJcosk;NynT ztlw*rUv2S97}2Fy6o*%7W$H3o+~HNjHLn`O4GqpMHd7VE?oVFe56=muh5x}6UL|*`EJ9q$26(;<1PMg(S&J45lu54UEp)OMN$T`CzD#TV zPEl+QM$EE=hQvA#vYAyCtXJ{*8w9!>rIjhm=lJk&L7G?WVmaHmG2}-0$M54Ph;*|U zI&+!?Mry^G#%VLS$mKaZ@iY@%)JhMXa;abliwk{q!lEycwY5dXDSN{t*w8k^jAFnO znOIXm;|@&X+*=FLCDSV-$Dy2uKF3pI_B@-_2qb!Q*C&Pho?cJwuD>`q|8pVyyW)Qj z;e2>1aWV<2K?;(A;juFc;So3?3Xx&TQaK^=!7;gyb5qm;k#i&9WFPCcH$+MR#@*+kVyjcgFQ;&~B}Ca_E$#*z-!(=!H! z5>yd7x}c2vY6c%}N{pva2La^*`+W!TlOlgy2RuLCp7j&|e&~;m;-A+6&##Hf|N815 z9mYSe10I#9+kbrZAAJb_>o@*Wh@Wfd^N!=6AlaTjBJMXJKWl(L`;Y%it{+_#Kd%EG z1lV6e{@GLUPqChNRs6)r_T>95#!sn?tba=MQ_1INRDVN5`JF`n%)kG}e9q|qg$aC8 z`rn58q2*70|G9k6N$S6_Hl8N-*I2(;?EWp}bIS2AU{mbh0slSTeiD$+1%A#({e@fj z>i-A#iPm@y`J58@3)10;E=$KY@M>c>ezZKT7GJ z*8$JWQ^E0Xpr3{HKb`q`Y5fx=+Y{!g$^UmKKi&DqGXGPqpAP-Ji2MuD`sobzcZmOV z>0jYKuc-dQZ6*Fa?%(UH=aN1zhW?QQ>p$t@P7x)udtt|yT7nqo|gOnAM8Jp-$zl1r{eV}|2N2L} AF#rGn diff --git a/.yarn/cache/es-abstract-npm-1.18.0-ac2faa8a98-98b2dd3778.zip b/.yarn/cache/es-abstract-npm-1.18.0-ac2faa8a98-98b2dd3778.zip deleted file mode 100644 index 3b2441ed8c3a2931c0e0b224ed4e9bd0e76df222..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 562481 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 E0n7qp1ONa4 diff --git a/.yarn/cache/promise.prototype.finally-npm-3.1.2-18b6014744-e0b6e94d32.zip b/.yarn/cache/promise.prototype.finally-npm-3.1.2-18b6014744-e0b6e94d32.zip deleted file mode 100644 index 8c9fdcd49be399c25e1a891a86c4c5ca87ce1734..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12659 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 diff --git a/.yarn/cache/websocket-as-promised-npm-2.0.1-289ab937b7-68dfd25be9.zip b/.yarn/cache/websocket-as-promised-npm-2.0.1-289ab937b7-68dfd25be9.zip deleted file mode 100644 index f448c1158126d0e3e5c96c655c3b12e063a0985f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13462 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 AApigX diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index 63ca642f..3ccbcb2d 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -29,21 +29,20 @@ "@polkadot/api": "^11.2.1", "@polkadot/keyring": "^12.6.2", "@polkadot/types": "^11.2.1", + "@polkadot/types-support": "11.2.1", "@polkadot/util": "^12.6.2", "@polkadot/util-crypto": "^12.6.2", "@polkadot/wasm-crypto": "^7.3.2", - "bs58": "^4.0.1", - "promised-map": "^1.0.0", - "websocket": "^1.0.34", - "websocket-as-promised": "^2.0.1", - "@polkadot/types-support": "11.2.1", "@polkadot/x-fetch": "^12.6.2", "@polkadot/x-global": "^12.6.2", "@polkadot/x-ws": "^12.6.2", + "bs58": "^4.0.1", "eventemitter3": "^5.0.1", "mock-socket": "^9.3.1", "nock": "^13.5.0", - "tslib": "^2.6.2" + "promised-map": "^1.0.0", + "tslib": "^2.6.2", + "websocket": "^1.0.34" }, "devDependencies": { "@types/bs58": "^4.0.4" diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index 04ddc01e..ea0e5868 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -1,4 +1,3 @@ -import WebSocketAsPromised from 'websocket-as-promised'; import {Keyring} from "@polkadot/keyring"; import type {u8} from "@polkadot/types-codec"; import type {TypeRegistry, u32, Vec} from "@polkadot/types"; @@ -11,16 +10,6 @@ import type { ShardIdentifier } from "@encointer/types"; -export interface IWorker extends WebSocketAsPromised { - rsCount: number; - rqStack: string[]; - keyring: () => Keyring | undefined; - createType: (apiType: string, obj?: any) => any; - open: () => Promise; - encrypt: (data: Uint8Array) => Promise> - registry: () => TypeRegistry -} - export interface GenericGetter { toHex(): string } diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 8474f53c..bc060bbc 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -13,14 +13,14 @@ import type { Vault } from '@encointer/types'; -import {type GenericGetter, type WorkerOptions} from './interface.js'; +import {type GenericGetter, type IWorkerBase, type WorkerOptions} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; import type {Bytes, u8} from "@polkadot/types-codec"; import BN from "bn.js"; import {WsProvider} from "./rpc-provider/src/index.js"; import {Keyring} from "@polkadot/keyring"; -export class Worker { +export class Worker implements IWorkerBase { readonly #registry: TypeRegistry; diff --git a/yarn.lock b/yarn.lock index ae6919c3..0925d8e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -804,16 +804,22 @@ __metadata: "@peculiar/webcrypto": "npm:^1.4.6" "@polkadot/api": "npm:^11.2.1" "@polkadot/keyring": "npm:^12.6.2" - "@polkadot/rpc-provider": "npm:^11.2.1" "@polkadot/types": "npm:^11.2.1" + "@polkadot/types-support": "npm:11.2.1" "@polkadot/util": "npm:^12.6.2" "@polkadot/util-crypto": "npm:^12.6.2" "@polkadot/wasm-crypto": "npm:^7.3.2" + "@polkadot/x-fetch": "npm:^12.6.2" + "@polkadot/x-global": "npm:^12.6.2" + "@polkadot/x-ws": "npm:^12.6.2" "@types/bs58": "npm:^4.0.4" bs58: "npm:^4.0.1" + eventemitter3: "npm:^5.0.1" + mock-socket: "npm:^9.3.1" + nock: "npm:^13.5.0" promised-map: "npm:^1.0.0" + tslib: "npm:^2.6.2" websocket: "npm:^1.0.34" - websocket-as-promised: "npm:^2.0.1" peerDependencies: "@polkadot/x-randomvalues": ^12.3.2 languageName: unknown @@ -2209,29 +2215,6 @@ __metadata: languageName: node linkType: hard -"@polkadot/rpc-provider@npm:^11.2.1, @polkadot/rpc-provider@workspace:packages/rpc-provider": - version: 0.0.0-use.local - resolution: "@polkadot/rpc-provider@workspace:packages/rpc-provider" - dependencies: - "@polkadot/keyring": "npm:^12.6.2" - "@polkadot/types": "npm:11.2.1" - "@polkadot/types-support": "npm:11.2.1" - "@polkadot/util": "npm:^12.6.2" - "@polkadot/util-crypto": "npm:^12.6.2" - "@polkadot/x-fetch": "npm:^12.6.2" - "@polkadot/x-global": "npm:^12.6.2" - "@polkadot/x-ws": "npm:^12.6.2" - "@substrate/connect": "npm:0.8.10" - eventemitter3: "npm:^5.0.1" - mock-socket: "npm:^9.3.1" - nock: "npm:^13.5.0" - tslib: "npm:^2.6.2" - dependenciesMeta: - "@substrate/connect": - optional: true - languageName: unknown - linkType: soft - "@polkadot/typegen@npm:11.2.1": version: 11.2.1 resolution: "@polkadot/typegen@npm:11.2.1" @@ -4861,13 +4844,6 @@ __metadata: languageName: node linkType: hard -"chnl@npm:^1.2.0": - version: 1.2.0 - resolution: "chnl@npm:1.2.0" - checksum: 10/78044132c0a002b40f4e388407d57d4e767b223bcc1f7dcb108ecb188a8999c0b6a366e0871070c0e661648f8aca9dc1bd42a8f39c893c272c9935bb92ad82c9 - languageName: node - linkType: hard - "chokidar@npm:^3.4.0": version: 3.5.2 resolution: "chokidar@npm:3.5.2" @@ -6246,30 +6222,6 @@ __metadata: languageName: node linkType: hard -"es-abstract@npm:^1.17.0-next.0": - version: 1.18.0 - resolution: "es-abstract@npm:1.18.0" - dependencies: - call-bind: "npm:^1.0.2" - es-to-primitive: "npm:^1.2.1" - function-bind: "npm:^1.1.1" - get-intrinsic: "npm:^1.1.1" - has: "npm:^1.0.3" - has-symbols: "npm:^1.0.2" - is-callable: "npm:^1.2.3" - is-negative-zero: "npm:^2.0.1" - is-regex: "npm:^1.1.2" - is-string: "npm:^1.0.5" - object-inspect: "npm:^1.9.0" - object-keys: "npm:^1.1.1" - object.assign: "npm:^4.1.2" - string.prototype.trimend: "npm:^1.0.4" - string.prototype.trimstart: "npm:^1.0.4" - unbox-primitive: "npm:^1.0.0" - checksum: 10/98b2dd3778d0bc36b86302603681f26432aad85d2019834a09d5221ca350600eb4b1e95915d442644cfcc422d5996a806c13ca30435655150f4a4e081b5960cb - languageName: node - linkType: hard - "es-abstract@npm:^1.18.0-next.1, es-abstract@npm:^1.18.0-next.2, es-abstract@npm:^1.19.0, es-abstract@npm:^1.19.1": version: 1.19.1 resolution: "es-abstract@npm:1.19.1" @@ -8842,7 +8794,7 @@ __metadata: languageName: node linkType: hard -"is-callable@npm:^1.1.4, is-callable@npm:^1.2.3, is-callable@npm:^1.2.4": +"is-callable@npm:^1.1.4, is-callable@npm:^1.2.4": version: 1.2.4 resolution: "is-callable@npm:1.2.4" checksum: 10/4e3d8c08208475e74a4108a9dc44dbcb74978782e38a1d1b55388342a4824685765d95917622efa2ca1483f7c4dbec631dd979cbb3ebd239f57a75c83a46d99f @@ -9127,7 +9079,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.2, is-regex@npm:^1.1.4": +"is-regex@npm:^1.1.4": version: 1.1.4 resolution: "is-regex@npm:1.1.4" dependencies: @@ -12733,13 +12685,6 @@ __metadata: languageName: node linkType: hard -"promise-controller@npm:^1.0.0": - version: 1.0.0 - resolution: "promise-controller@npm:1.0.0" - checksum: 10/80e1a43d377ab15e4bfd61a9826fb6feec50fde69f583544bf67fd2dad191b5190c39509d13180dd3adfd9449e651e0fb727cd827a2bf0ffc6bd3c0874dc619f - languageName: node - linkType: hard - "promise-inflight@npm:^1.0.1": version: 1.0.1 resolution: "promise-inflight@npm:1.0.1" @@ -12757,17 +12702,6 @@ __metadata: languageName: node linkType: hard -"promise.prototype.finally@npm:^3.1.2": - version: 3.1.2 - resolution: "promise.prototype.finally@npm:3.1.2" - dependencies: - define-properties: "npm:^1.1.3" - es-abstract: "npm:^1.17.0-next.0" - function-bind: "npm:^1.1.1" - checksum: 10/e0b6e94d32dd11ee3a3fdcac1aa9a2cc0d1af9391fc34fe3bd169cb365b35851e85f7e4ba47b20538974d08cd855c86f68aa214c7b12d31ca07c084b428781fd - languageName: node - linkType: hard - "promised-map@npm:^1.0.0": version: 1.0.0 resolution: "promised-map@npm:1.0.0" @@ -15117,7 +15051,7 @@ __metadata: languageName: node linkType: hard -"unbox-primitive@npm:^1.0.0, unbox-primitive@npm:^1.0.1": +"unbox-primitive@npm:^1.0.1": version: 1.0.1 resolution: "unbox-primitive@npm:1.0.1" dependencies: @@ -15680,18 +15614,6 @@ __metadata: languageName: node linkType: hard -"websocket-as-promised@npm:^2.0.1": - version: 2.0.1 - resolution: "websocket-as-promised@npm:2.0.1" - dependencies: - chnl: "npm:^1.2.0" - promise-controller: "npm:^1.0.0" - promise.prototype.finally: "npm:^3.1.2" - promised-map: "npm:^1.0.0" - checksum: 10/68dfd25be95e648e18ac1cb996562f0fb1738850e7b1ec3bc3d641833b8dc30dfc1e02f63c5c245a6b64d09ad1bbe5326a3652e8ba38d00833f1e521bbeba0a7 - languageName: node - linkType: hard - "websocket-driver@npm:>=0.5.1, websocket-driver@npm:^0.7.4": version: 0.7.4 resolution: "websocket-driver@npm:0.7.4" From aa0d200cd69c3955d47ed2fce8a13d28d2112cfd Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:08:49 +0200 Subject: [PATCH 33/50] remove unnecessary stuff from rpc-provider --- packages/worker-api/package.json | 3 - .../src/rpc-provider/src/mock/index.ts | 259 ------------------ .../src/rpc-provider/src/mock/mockHttp.ts | 35 --- .../src/rpc-provider/src/mock/mockWs.ts | 92 ------- .../src/rpc-provider/src/mock/types.ts | 36 --- .../src/rpc-provider/tsconfig.build.json | 17 -- .../src/rpc-provider/tsconfig.spec.json | 18 -- yarn.lock | 3 - 8 files changed, 463 deletions(-) delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/index.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/mockHttp.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/mockWs.ts delete mode 100644 packages/worker-api/src/rpc-provider/src/mock/types.ts delete mode 100644 packages/worker-api/src/rpc-provider/tsconfig.build.json delete mode 100644 packages/worker-api/src/rpc-provider/tsconfig.spec.json diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index 3ccbcb2d..87b081e6 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -33,13 +33,10 @@ "@polkadot/util": "^12.6.2", "@polkadot/util-crypto": "^12.6.2", "@polkadot/wasm-crypto": "^7.3.2", - "@polkadot/x-fetch": "^12.6.2", "@polkadot/x-global": "^12.6.2", "@polkadot/x-ws": "^12.6.2", "bs58": "^4.0.1", "eventemitter3": "^5.0.1", - "mock-socket": "^9.3.1", - "nock": "^13.5.0", "promised-map": "^1.0.0", "tslib": "^2.6.2", "websocket": "^1.0.34" diff --git a/packages/worker-api/src/rpc-provider/src/mock/index.ts b/packages/worker-api/src/rpc-provider/src/mock/index.ts deleted file mode 100644 index b688710f..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/index.ts +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -/* eslint-disable camelcase */ - -import type { Header } from '@polkadot/types/interfaces'; -import type { Codec, Registry } from '@polkadot/types/types'; -import type { ProviderInterface, ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '../types.js'; -import type { MockStateDb, MockStateSubscriptionCallback, MockStateSubscriptions } from './types.js'; - -import { EventEmitter } from 'eventemitter3'; - -import { createTestKeyring } from '@polkadot/keyring/testing'; -import { decorateStorage, Metadata } from '@polkadot/types'; -import jsonrpc from '@polkadot/types/interfaces/jsonrpc'; -import rpcHeader from '@polkadot/types-support/json/Header.004.json' assert { type: 'json' }; -import rpcSignedBlock from '@polkadot/types-support/json/SignedBlock.004.immortal.json' assert { type: 'json' }; -import rpcMetadata from '@polkadot/types-support/metadata/static-substrate'; -import { BN, bnToU8a, logger, u8aToHex } from '@polkadot/util'; -import { randomAsU8a } from '@polkadot/util-crypto'; - -const INTERVAL = 1000; -const SUBSCRIPTIONS: string[] = Array.prototype.concat.apply( - [], - Object.values(jsonrpc).map((section): string[] => - Object - .values(section) - .filter(({ isSubscription }) => isSubscription) - .map(({ jsonrpc }) => jsonrpc) - .concat('chain_subscribeNewHead') - ) -) as string[]; - -const keyring = createTestKeyring({ type: 'ed25519' }); -const l = logger('api-mock'); - -/** - * A mock provider mainly used for testing. - * @return {ProviderInterface} The mock provider - * @internal - */ -export class MockProvider implements ProviderInterface { - private db: MockStateDb = {}; - - private emitter = new EventEmitter(); - - private intervalId?: ReturnType | null; - - public isUpdating = true; - - private registry: Registry; - - private prevNumber = new BN(-1); - - private requests: Record unknown> = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - chain_getBlock: () => this.registry.createType('SignedBlock', rpcSignedBlock.result).toJSON(), - chain_getBlockHash: () => '0x1234000000000000000000000000000000000000000000000000000000000000', - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - chain_getFinalizedHead: () => this.registry.createType('Header', rpcHeader.result).hash, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - chain_getHeader: () => this.registry.createType('Header', rpcHeader.result).toJSON(), - rpc_methods: () => this.registry.createType('RpcMethods').toJSON(), - state_getKeys: () => [], - state_getKeysPaged: () => [], - state_getMetadata: () => rpcMetadata, - state_getRuntimeVersion: () => this.registry.createType('RuntimeVersion').toHex(), - state_getStorage: (storage: MockStateDb, [key]: string[]) => u8aToHex(storage[key]), - system_chain: () => 'mockChain', - system_health: () => ({}), - system_name: () => 'mockClient', - system_properties: () => ({ ss58Format: 42 }), - system_upgradedToTripleRefCount: () => this.registry.createType('bool', true), - system_version: () => '9.8.7', - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, sort-keys - dev_echo: (_, params: any) => params - }; - - public subscriptions: MockStateSubscriptions = SUBSCRIPTIONS.reduce((subs, name): MockStateSubscriptions => { - subs[name] = { - callbacks: {}, - lastValue: null - }; - - return subs; - }, ({} as MockStateSubscriptions)); - - private subscriptionId = 0; - - private subscriptionMap: Record = {}; - - constructor (registry: Registry) { - this.registry = registry; - - this.init(); - } - - public get hasSubscriptions (): boolean { - return !!true; - } - - public clone (): MockProvider { - throw new Error('Unimplemented'); - } - - public async connect (): Promise { - // noop - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async disconnect (): Promise { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - public get isClonable (): boolean { - return !!false; - } - - public get isConnected (): boolean { - return !!true; - } - - public on (type: ProviderInterfaceEmitted, sub: ProviderInterfaceEmitCb): () => void { - this.emitter.on(type, sub); - - return (): void => { - this.emitter.removeListener(type, sub); - }; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async send (method: string, params: unknown[]): Promise { - l.debug(() => ['send', method, params]); - - if (!this.requests[method]) { - throw new Error(`provider.send: Invalid method '${method}'`); - } - - return this.requests[method](this.db, params) as T; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async subscribe (_type: string, method: string, ...params: unknown[]): Promise { - l.debug(() => ['subscribe', method, params]); - - if (!this.subscriptions[method]) { - throw new Error(`provider.subscribe: Invalid method '${method}'`); - } - - const callback = params.pop() as MockStateSubscriptionCallback; - const id = ++this.subscriptionId; - - this.subscriptions[method].callbacks[id] = callback; - this.subscriptionMap[id] = method; - - if (this.subscriptions[method].lastValue !== null) { - callback(null, this.subscriptions[method].lastValue); - } - - return id; - } - - // eslint-disable-next-line @typescript-eslint/require-await - public async unsubscribe (_type: string, _method: string, id: number): Promise { - const sub = this.subscriptionMap[id]; - - l.debug(() => ['unsubscribe', id, sub]); - - if (!sub) { - throw new Error(`Unable to find subscription for ${id}`); - } - - delete this.subscriptionMap[id]; - delete this.subscriptions[sub].callbacks[id]; - - return true; - } - - private init (): void { - const emitEvents: ProviderInterfaceEmitted[] = ['connected', 'disconnected']; - let emitIndex = 0; - let newHead = this.makeBlockHeader(); - let counter = -1; - - const metadata = new Metadata(this.registry, rpcMetadata); - - this.registry.setMetadata(metadata); - - const query = decorateStorage(this.registry, metadata.asLatest, metadata.version); - - // Do something every 1 seconds - this.intervalId = setInterval((): void => { - if (!this.isUpdating) { - return; - } - - // create a new header (next block) - newHead = this.makeBlockHeader(); - - // increment the balances and nonce for each account - keyring.getPairs().forEach(({ publicKey }, index): void => { - this.setStateBn(query['system']['account'](publicKey), newHead.number.toBn().addn(index)); - }); - - // set the timestamp for the current block - this.setStateBn(query['timestamp']['now'](), Math.floor(Date.now() / 1000)); - this.updateSubs('chain_subscribeNewHead', newHead); - - // We emit connected/disconnected at intervals - if (++counter % 2 === 1) { - if (++emitIndex === emitEvents.length) { - emitIndex = 0; - } - - this.emitter.emit(emitEvents[emitIndex]); - } - }, INTERVAL); - } - - private makeBlockHeader (): Header { - const blockNumber = this.prevNumber.addn(1); - const header = this.registry.createType('Header', { - digest: { - logs: [] - }, - extrinsicsRoot: randomAsU8a(), - number: blockNumber, - parentHash: blockNumber.isZero() - ? new Uint8Array(32) - : bnToU8a(this.prevNumber, { bitLength: 256, isLe: false }), - stateRoot: bnToU8a(blockNumber, { bitLength: 256, isLe: false }) - }); - - this.prevNumber = blockNumber; - - return header as unknown as Header; - } - - private setStateBn (key: Uint8Array, value: BN | number): void { - this.db[u8aToHex(key)] = bnToU8a(value, { bitLength: 64, isLe: true }); - } - - private updateSubs (method: string, value: Codec): void { - this.subscriptions[method].lastValue = value; - - Object - .values(this.subscriptions[method].callbacks) - .forEach((cb): void => { - try { - cb(null, value.toJSON()); - } catch (error) { - l.error(`Error on '${method}' subscription`, error); - } - }); - } -} diff --git a/packages/worker-api/src/rpc-provider/src/mock/mockHttp.ts b/packages/worker-api/src/rpc-provider/src/mock/mockHttp.ts deleted file mode 100644 index 3335790f..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/mockHttp.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { Mock } from './types.js'; - -import nock from 'nock'; - -interface Request { - code?: number; - method: string; - reply?: Record; -} - -interface HttpMock extends Mock { - post: (uri: string) => { - reply: (code: number, handler: (uri: string, body: { id: string }) => unknown) => HttpMock - } -} - -export const TEST_HTTP_URL = 'http://localhost:9944'; - -export function mockHttp (requests: Request[]): Mock { - nock.cleanAll(); - - return requests.reduce((scope: HttpMock, request: Request) => - scope - .post('/') - .reply(request.code || 200, (_uri: string, body: { id: string }) => { - scope.body = scope.body || {}; - scope.body[request.method] = body; - - return Object.assign({ id: body.id, jsonrpc: '2.0' }, request.reply || {}) as unknown; - }), - nock(TEST_HTTP_URL) as unknown as HttpMock); -} diff --git a/packages/worker-api/src/rpc-provider/src/mock/mockWs.ts b/packages/worker-api/src/rpc-provider/src/mock/mockWs.ts deleted file mode 100644 index a5c51793..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/mockWs.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import { Server, WebSocket } from 'mock-socket'; - -import { stringify } from '@polkadot/util'; - -interface Scope { - body: Record>; - requests: number; - server: Server; - done: any; -} - -interface ErrorDef { - id: number; - error: { - code: number; - message: string; - }; -} - -interface ReplyDef { - id: number; - reply: { - result: unknown; - }; -} - -interface RpcBase { - id: number; - jsonrpc: '2.0'; -} - -type RpcError = RpcBase & ErrorDef; -type RpcReply = RpcBase & { result: unknown }; - -export type Request = { method: string } & (ErrorDef | ReplyDef); - -global.WebSocket = WebSocket as typeof global.WebSocket; - -export const TEST_WS_URL = 'ws://localhost:9955'; - -// should be JSONRPC def return -function createError ({ error: { code, message }, id }: ErrorDef): RpcError { - return { - error: { - code, - message - }, - id, - jsonrpc: '2.0' - }; -} - -// should be JSONRPC def return -function createReply ({ id, reply: { result } }: ReplyDef): RpcReply { - return { - id, - jsonrpc: '2.0', - result - }; -} - -// scope definition returned -export function mockWs (requests: Request[], wsUrl: string = TEST_WS_URL): Scope { - const server = new Server(wsUrl); - - let requestCount = 0; - const scope: Scope = { - body: {}, - done: () => new Promise((resolve) => server.stop(resolve)), - requests: 0, - server - }; - - server.on('connection', (socket): void => { - socket.on('message', (body): void => { - const request = requests[requestCount]; - const response = (request as ErrorDef).error - ? createError(request as ErrorDef) - : createReply(request as ReplyDef); - - scope.body[request.method] = body as unknown as Record; - requestCount++; - - socket.send(stringify(response)); - }); - }); - - return scope; -} diff --git a/packages/worker-api/src/rpc-provider/src/mock/types.ts b/packages/worker-api/src/rpc-provider/src/mock/types.ts deleted file mode 100644 index 0a7fbc3a..00000000 --- a/packages/worker-api/src/rpc-provider/src/mock/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2017-2024 @polkadot/rpc-provider authors & contributors -// SPDX-License-Identifier: Apache-2.0 - -import type { Server } from 'mock-socket'; - -export type Global = typeof globalThis & { - WebSocket: typeof WebSocket; - fetch: any; -} - -export interface Mock { - body: Record>; - requests: number; - server: Server; - done: () => Promise; -} - -export type MockStateSubscriptionCallback = (error: Error | null, value: any) => void; - -export interface MockStateSubscription { - callbacks: Record; - lastValue: any; -} - -export interface MockStateSubscriptions { - // known - chain_subscribeNewHead: MockStateSubscription; - state_subscribeStorage: MockStateSubscription; - - // others - [key: string]: MockStateSubscription; -} - -export type MockStateDb = Record; - -export type MockStateRequests = Record string>; diff --git a/packages/worker-api/src/rpc-provider/tsconfig.build.json b/packages/worker-api/src/rpc-provider/tsconfig.build.json deleted file mode 100644 index e4b9f972..00000000 --- a/packages/worker-api/src/rpc-provider/tsconfig.build.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": "..", - "outDir": "./build", - "rootDir": "./src", - "resolveJsonModule": true - }, - "exclude": [ - "**/*.spec.ts", - "**/mod.ts" - ], - "references": [ - { "path": "../types/tsconfig.build.json" }, - { "path": "../types-support/tsconfig.build.json" } - ] -} diff --git a/packages/worker-api/src/rpc-provider/tsconfig.spec.json b/packages/worker-api/src/rpc-provider/tsconfig.spec.json deleted file mode 100644 index b13f18eb..00000000 --- a/packages/worker-api/src/rpc-provider/tsconfig.spec.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "baseUrl": "..", - "outDir": "./build", - "rootDir": "./src", - "emitDeclarationOnly": false, - "resolveJsonModule": true, - "noEmit": true - }, - "include": [ - "**/*.spec.ts" - ], - "references": [ - { "path": "../rpc-provider/tsconfig.build.json" }, - { "path": "../types/tsconfig.build.json" } - ] -} diff --git a/yarn.lock b/yarn.lock index 0925d8e4..413e4f5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -809,14 +809,11 @@ __metadata: "@polkadot/util": "npm:^12.6.2" "@polkadot/util-crypto": "npm:^12.6.2" "@polkadot/wasm-crypto": "npm:^7.3.2" - "@polkadot/x-fetch": "npm:^12.6.2" "@polkadot/x-global": "npm:^12.6.2" "@polkadot/x-ws": "npm:^12.6.2" "@types/bs58": "npm:^4.0.4" bs58: "npm:^4.0.1" eventemitter3: "npm:^5.0.1" - mock-socket: "npm:^9.3.1" - nock: "npm:^13.5.0" promised-map: "npm:^1.0.0" tslib: "npm:^2.6.2" websocket: "npm:^1.0.34" From 6714142887f1df009fb4ff89064b02311b2e7b3c Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:10:02 +0200 Subject: [PATCH 34/50] make websocket a dev-dep --- packages/worker-api/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index 87b081e6..3c1af4b1 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -38,11 +38,11 @@ "bs58": "^4.0.1", "eventemitter3": "^5.0.1", "promised-map": "^1.0.0", - "tslib": "^2.6.2", - "websocket": "^1.0.34" + "tslib": "^2.6.2" }, "devDependencies": { - "@types/bs58": "^4.0.4" + "@types/bs58": "^4.0.4", + "websocket": "^1.0.34" }, "peerDependencies": { "@polkadot/x-randomvalues": "^12.3.2" From 60125483acd859d4bf1e69e8744fb1e1c9e363dd Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:11:55 +0200 Subject: [PATCH 35/50] remove unnecessary promised map dependency --- ...mised-map-npm-1.0.0-22c41839f5-716cc4b1be.zip | Bin 8734 -> 0 bytes packages/worker-api/package.json | 1 - yarn.lock | 8 -------- 3 files changed, 9 deletions(-) delete mode 100644 .yarn/cache/promised-map-npm-1.0.0-22c41839f5-716cc4b1be.zip diff --git a/.yarn/cache/promised-map-npm-1.0.0-22c41839f5-716cc4b1be.zip b/.yarn/cache/promised-map-npm-1.0.0-22c41839f5-716cc4b1be.zip deleted file mode 100644 index fee23f5f64fdf7e7bee2ff081a451b25a4b12ea5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8734 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
  • E2~0jW)yYJ3Wk{5P2Kyp|~kux85p{e~7G_b5K5346ad_uy?6V z>(9xSIUiwtwGZY($^;DR$s0FArAt6!$IvYy);FqFePRW=b3@J>Cu!TOG6_8oy>><8Ja8T*k7RF!snkNz1< zqC+AUdb}dJdt-O0S}m(iDL2ctX1Z%WzxS?$j*(}!_Lw<;RL^LNDVTL_WJgSi@(X^_ z`^gqB$J`xjb)G1_+;$z|OK>;UCkY~PYOd6%*}0Fr6UH*UMY4FfhW){(MT*4js_txZU8YJYftOYji8leFhofA6aI663 zm1_cwCxaumfwEd_r!HQ~H{$iI6evBnzhuWjGCWL`^I1q27RGpX?Rxep%7sJ|dnyG^ z^sB=@JMH2UiLX@1QokHUQg41exun^3>ylISCr;0v!FK+5n9Xtgq1S#*Gg@%EL0h%j zR90cucgXhoM)94+amSHI&JxzZPKDOyz2G!q4+{~NfLBYNTMYsHTFuThVY+)>i@y6^ zr0SD%f?fe4`D~`~XJ#LxJJ5}Db0`HH)6kAZ(^Yp_vewcREpav+wm+9X-zF{P@k$4X z)#7qMQ3*@dP0jhULkSC=W6Qbwj!AU)qKLUc7;h1pDl@YRb24nBEfIIgvqP{vc!wy0 zGSCo|TzatZ3Xp;lz$#>cb(EqD1lQ2g9za_V{P0qogVCjiBn4;CSxlpY)f7|kJHZWj z1dg;IZ@e=Xz{1P5SZnu_h;U=5!jafO$S6*G=iuY0{^$C2eGXpjjr0yB-v+I3<{?t-x=>=4K9& z4gEy7D7ura)g&-(JF5|8%X<_q^wIJr{uQ^g6!#_i*#O{y+jbfDdh2`p$Bt);Sv^u2QgFA0&cu~s7?>&#SKVaO{bEIe* z2D9=^)(H?6g&a`bx{c8c(AF?~5pu&$2`$nY+7l~|KRVJe(wORt@Usm1m*oFe8UJtO z;o!v){<*FOoBpqDTk`z!Q-VuP@t=pD*V!)Gw&bttSHHqJue)8eZAo+3?N1c`caH5> zVCS0ZqHRm&z;1s7_N^-S?O$FP=jFQG1>l2#qWGCen=3&0Dem+KJU znP&bO;AN&4z?XBw-+@g~e#`WHPXE&V-w6M62KPHI9_(EDo3;Aek!+!taWAK7zvG7d z9`|z6_VO%W&d`1bU;icekNMimn3oeq-!V&J)!^Su#McS_s}lSzW&N`OUq0)92UofB zTk!8u+ZOttz%QS%zJn*={ucZP)A)DTym-IG{=q!{6ZYk!*LU6%zr_CgAa;2PFRP*N zfO4>E!5=T-c~tmw=mg^5LVsVt{{($GcztKRK>AzgznAadA*aJ)#c$^0@5TFujD&p7 PivaeihTVb5&wu?7oW Date: Fri, 25 Oct 2024 20:18:30 +0200 Subject: [PATCH 36/50] extract generic top to worker base implementation --- packages/worker-api/src/integriteeWorker.ts | 33 +++---------------- packages/worker-api/src/interface.ts | 19 ++++++----- packages/worker-api/src/testUtils/networks.ts | 6 ++-- packages/worker-api/src/worker.ts | 22 ++++++++++++- 4 files changed, 38 insertions(+), 42 deletions(-) diff --git a/packages/worker-api/src/integriteeWorker.ts b/packages/worker-api/src/integriteeWorker.ts index 79ab4019..b14c42da 100644 --- a/packages/worker-api/src/integriteeWorker.ts +++ b/packages/worker-api/src/integriteeWorker.ts @@ -133,36 +133,11 @@ export class IntegriteeWorker extends Worker { await this.getShieldingKey(); } - return this.submitAndWatch(call, shard, true); - } - - async submitAndWatch(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier, direct: boolean): Promise { - let top; - if (direct) { - top = this.createType('IntegriteeTrustedOperation', { - direct_call: call - }) - } else { - top = this.createType('IntegriteeTrustedOperation', { - indirect_call: call - }) - } - - console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); - - const cyphertext = await this.encrypt(top.toU8a()); - - const r = this.createType( - 'Request', { shard, cyphertext: cyphertext } - ); - - const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) - - // const returnValue = await this.send('author_submitExtrinsic', [r.toHex()]) - - console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); + const top = this.createType('IntegriteeTrustedOperation', { + direct_call: call + }) - return this.createType('Hash', returnValue.value); + return this.submitAndWatchTop(top, shard); } } diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index ea0e5868..fbcaa5f6 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -10,16 +10,22 @@ import type { ShardIdentifier } from "@encointer/types"; -export interface GenericGetter { - toHex(): string -} - export interface IWorkerBase { createType: (apiType: string, obj?: any) => any; encrypt: (data: Uint8Array) => Promise> registry: () => TypeRegistry } +export interface GenericGetter { + toU8a(): Uint8Array, + toHex(): string +} + +export interface GenericTop { + toU8a(): Uint8Array, + toHex(): string +} + export interface ISubmittableGetter { worker: W; @@ -65,8 +71,3 @@ export interface PublicGetterArgs { } export type PublicGetterParams = GuessTheNumberPublicGetter | null - -export interface RequestOptions { - timeout?: number; - debug?: boolean; -} diff --git a/packages/worker-api/src/testUtils/networks.ts b/packages/worker-api/src/testUtils/networks.ts index efe7fe45..786e3269 100644 --- a/packages/worker-api/src/testUtils/networks.ts +++ b/packages/worker-api/src/testUtils/networks.ts @@ -39,9 +39,9 @@ export const localDockerNetwork = () => { chain: 'ws://127.0.0.1:9944', worker: 'wss://127.0.0.1:2000', genesisHash: '0x388c446a804e24e77ae89f5bb099edb60cacc2ac7c898ce175bdaa08629c1439', - mrenclave: 'GopQCtWihHQ8Xw1sZP6DTWMDQuaijSBE2NM8GU2U4Erc', - shard: 'GopQCtWihHQ8Xw1sZP6DTWMDQuaijSBE2NM8GU2U4Erc', - chosenCid: 'GopQCtWihHQ8Xw1sZP6DTWMDQuaijSBE2NM8GU2U4Erc', + mrenclave: 'BrFk2gARyQxD56NLpDKVbZinDmD1Twt1GwMsEyQuWshx', + shard: 'BrFk2gARyQxD56NLpDKVbZinDmD1Twt1GwMsEyQuWshx', + chosenCid: 'BrFk2gARyQxD56NLpDKVbZinDmD1Twt1GwMsEyQuWshx', customTypes: {}, palletOverrides: {} }; diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index bc060bbc..e805d8e3 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -13,12 +13,13 @@ import type { Vault } from '@encointer/types'; -import {type GenericGetter, type IWorkerBase, type WorkerOptions} from './interface.js'; +import {type GenericGetter, type GenericTop, type IWorkerBase, type WorkerOptions} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; import type {Bytes, u8} from "@polkadot/types-codec"; import BN from "bn.js"; import {WsProvider} from "./rpc-provider/src/index.js"; import {Keyring} from "@polkadot/keyring"; +import type {Hash} from "@polkadot/types/interfaces/runtime"; export class Worker implements IWorkerBase { @@ -143,6 +144,25 @@ export class Worker implements IWorkerBase { return this.createType(returnType, value); } + async submitAndWatchTop(top: Top, shard: ShardIdentifier): Promise { + + console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); + + const cyphertext = await this.encrypt(top.toU8a()); + + const r = this.createType( + 'Request', { shard, cyphertext: cyphertext } + ); + + const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) + + // const returnValue = await this.send('author_submitExtrinsic', [r.toHex()]) + + console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); + + return this.createType('Hash', returnValue.value); + } + public async send(method: string, params: unknown[]): Promise { await this.isReady(); From ea83f50d3a7f17b8db19fa2759ce9a507f2a5c9e Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:30:04 +0200 Subject: [PATCH 37/50] simplify callback code --- packages/worker-api/src/worker.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index e805d8e3..812225c0 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -178,11 +178,12 @@ export class Worker implements IWorkerBase { return new Promise( async (resolve, reject) => { const onStatusChange = (error: Error | null, result: string) => { - console.log(`DirectRequestStatus: error ${JSON.stringify(error)}`) - console.log(`DirectRequestStatus: ${JSON.stringify(result)}`) + if (error !== null) { + throw new Error(`Callback Error: ${error.message}`); + } - const value = hexToU8a(result); - const directRequestStatus = this.createType('DirectRequestStatus', value); + console.debug(`DirectRequestStatus: ${JSON.stringify(result)}`) + const directRequestStatus = this.createType('DirectRequestStatus', result); if (directRequestStatus.isError) { const errorMsg = this.createType('String', directRequestStatus.value); @@ -197,7 +198,7 @@ export class Worker implements IWorkerBase { console.log(`TrustedOperationStatus: ${directRequestStatus}`) const status = directRequestStatus.asTrustedOperationStatus; if (connection_can_be_closed(status)) { - resolve({}) + resolve(status) } } } @@ -206,8 +207,6 @@ export class Worker implements IWorkerBase { const res = await this.#ws.subscribe(method, method, params, onStatusChange ); - // let returnValue = this.resultToRpcReturnValue(res as string); - // console.debug(`Subscription RpcReturnValue ${JSON.stringify(returnValue)}`); let topHash = this.createType('Hash', res); console.debug(`resHash: ${topHash}`); } catch (err) { From 4a87aad32878d72445794fa5450a2ac3eb5eaf2d Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:47:25 +0200 Subject: [PATCH 38/50] aggregate callback result in TrustedCallResult --- .../worker-api/src/integriteeWorker.spec.ts | 150 +++++++++--------- packages/worker-api/src/integriteeWorker.ts | 11 +- packages/worker-api/src/interface.ts | 8 +- packages/worker-api/src/worker.ts | 35 ++-- 4 files changed, 110 insertions(+), 94 deletions(-) diff --git a/packages/worker-api/src/integriteeWorker.spec.ts b/packages/worker-api/src/integriteeWorker.spec.ts index 421b9826..37b42e8f 100644 --- a/packages/worker-api/src/integriteeWorker.spec.ts +++ b/packages/worker-api/src/integriteeWorker.spec.ts @@ -43,78 +43,78 @@ describe('worker', () => { // To my knowledge jest does not have an option to run skipped tests specifically, does it? // Todo: add proper CI to test this too. describe('needs worker and node running', () => { - describe('getWorkerPubKey', () => { - it('should return value', async () => { - const result = await worker.getShieldingKey(); - // console.log('Shielding Key', result); - expect(result).toBeDefined(); - }); - }); - - describe('getShardVault', () => { - it('should return value', async () => { - const result = await worker.getShardVault(); - console.log('ShardVault', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('getNonce', () => { - it('should return value', async () => { - const result = await worker.getNonce(alice, network.shard); - console.log('Nonce', result.toHuman); - expect(result).toBeDefined(); - }); - }); - - - describe('getAccountInfo', () => { - it('should return value', async () => { - const result = await worker.getAccountInfo(alice, network.shard); - console.log('getAccountInfo', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('accountInfoGetter', () => { - it('should return value', async () => { - const getter = await worker.accountInfoGetter(charlie, network.shard); - console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); - const result = await getter.send(); - console.log('getAccountInfo:', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('parentchainsInfoGetter', () => { - it('should return value', async () => { - const getter = worker.parentchainsInfoGetter(network.shard); - console.log(`parentchainsInfoGetter: ${JSON.stringify(getter)}`); - const result = await getter.send(); - console.log('parentchainsInfoGetter:', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('guessTheNumberInfoGetter', () => { - it('should return value', async () => { - const getter = worker.guessTheNumberInfoGetter(network.shard); - console.log(`GuessTheNumberInfo: ${JSON.stringify(getter)}`); - const result = await getter.send(); - console.log('GuessTheNumberInfo:', result.toHuman()); - expect(result).toBeDefined(); - }); - }); - - describe('guessTheNumberAttemptsGetter', () => { - it('should return value', async () => { - const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.shard); - console.log(`Attempts: ${JSON.stringify(getter)}`); - const result = await getter.send(); - console.log('Attempts:', result.toHuman()); - expect(result).toBeDefined(); - }); - }); + // describe('getWorkerPubKey', () => { + // it('should return value', async () => { + // const result = await worker.getShieldingKey(); + // // console.log('Shielding Key', result); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('getShardVault', () => { + // it('should return value', async () => { + // const result = await worker.getShardVault(); + // console.log('ShardVault', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('getNonce', () => { + // it('should return value', async () => { + // const result = await worker.getNonce(alice, network.shard); + // console.log('Nonce', result.toHuman); + // expect(result).toBeDefined(); + // }); + // }); + // + // + // describe('getAccountInfo', () => { + // it('should return value', async () => { + // const result = await worker.getAccountInfo(alice, network.shard); + // console.log('getAccountInfo', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('accountInfoGetter', () => { + // it('should return value', async () => { + // const getter = await worker.accountInfoGetter(charlie, network.shard); + // console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); + // const result = await getter.send(); + // console.log('getAccountInfo:', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('parentchainsInfoGetter', () => { + // it('should return value', async () => { + // const getter = worker.parentchainsInfoGetter(network.shard); + // console.log(`parentchainsInfoGetter: ${JSON.stringify(getter)}`); + // const result = await getter.send(); + // console.log('parentchainsInfoGetter:', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('guessTheNumberInfoGetter', () => { + // it('should return value', async () => { + // const getter = worker.guessTheNumberInfoGetter(network.shard); + // console.log(`GuessTheNumberInfo: ${JSON.stringify(getter)}`); + // const result = await getter.send(); + // console.log('GuessTheNumberInfo:', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); + // + // describe('guessTheNumberAttemptsGetter', () => { + // it('should return value', async () => { + // const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.shard); + // console.log(`Attempts: ${JSON.stringify(getter)}`); + // const result = await getter.send(); + // console.log('Attempts:', result.toHuman()); + // expect(result).toBeDefined(); + // }); + // }); describe('balance transfer should work', () => { it('should return value', async () => { @@ -127,7 +127,7 @@ describe('worker', () => { charlie.address, 1100000000000 ); - console.log('balance transfer result', result.toHuman()); + console.log('balance transfer result', JSON.stringify(result)); expect(result).toBeDefined(); }); }); @@ -144,7 +144,7 @@ describe('worker', () => { // charlie.address, // 1100000000000, // ); - // console.log('balance unshield result', result.toHuman()); + // console.log('balance unshield result', JSON.stringify(result)); // expect(result).toBeDefined(); // }); // }); @@ -159,7 +159,7 @@ describe('worker', () => { // network.mrenclave, // 1, // ); - // console.log('guess the number result', result.toHuman()); + // console.log('guess the number result', JSON.stringify(result)); // expect(result).toBeDefined(); // }); // }); diff --git a/packages/worker-api/src/integriteeWorker.ts b/packages/worker-api/src/integriteeWorker.ts index b14c42da..aa5968ba 100644 --- a/packages/worker-api/src/integriteeWorker.ts +++ b/packages/worker-api/src/integriteeWorker.ts @@ -1,4 +1,3 @@ -import type {Hash} from '@polkadot/types/interfaces/runtime'; import type { ShardIdentifier, IntegriteeTrustedCallSigned, @@ -11,7 +10,7 @@ import { type TrustedGetterArgs, type TrustedSignerOptions, type PublicGetterArgs, - type PublicGetterParams, type TrustedGetterParams, + type PublicGetterParams, type TrustedGetterParams, type TrustedCallResult, } from './interface.js'; import {Worker} from "./worker.js"; import { @@ -81,7 +80,7 @@ export class IntegriteeWorker extends Worker { to: String, amount: number, signerOptions?: TrustedSignerOptions, - ): Promise { + ): Promise { const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); const params = this.createType('BalanceTransferArgs', [from, to, amount]) @@ -98,7 +97,7 @@ export class IntegriteeWorker extends Worker { toPublicAddress: string, amount: number, signerOptions?: TrustedSignerOptions, - ): Promise { + ): Promise { const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); @@ -114,7 +113,7 @@ export class IntegriteeWorker extends Worker { mrenclave: string, guess: number, signerOptions?: TrustedSignerOptions, - ): Promise { + ): Promise { const nonce = signerOptions?.nonce ?? await this.getNonce(account, shard, signerOptions) const shardT = this.createType('ShardIdentifier', bs58.decode(shard)); @@ -127,7 +126,7 @@ export class IntegriteeWorker extends Worker { return this.sendTrustedCall(signed, shardT); } - async sendTrustedCall(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier): Promise { + async sendTrustedCall(call: IntegriteeTrustedCallSigned, shard: ShardIdentifier): Promise { if (this.shieldingKey() == undefined) { console.debug(`[sentTrustedCall] Setting the shielding pubKey of the worker.`) await this.getShieldingKey(); diff --git a/packages/worker-api/src/interface.ts b/packages/worker-api/src/interface.ts index fbcaa5f6..e7cb3606 100644 --- a/packages/worker-api/src/interface.ts +++ b/packages/worker-api/src/interface.ts @@ -7,8 +7,9 @@ import type { GuessTheNumberPublicGetter, GuessTheNumberTrustedGetter, IntegriteeGetter, - ShardIdentifier + ShardIdentifier, TrustedOperationStatus } from "@encointer/types"; +import type {Hash} from "@polkadot/types/interfaces/runtime"; export interface IWorkerBase { createType: (apiType: string, obj?: any) => any; @@ -26,6 +27,11 @@ export interface GenericTop { toHex(): string } +export interface TrustedCallResult { + topHash?: Hash, + status?: TrustedOperationStatus, +} + export interface ISubmittableGetter { worker: W; diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 812225c0..e67ec80c 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -13,7 +13,13 @@ import type { Vault } from '@encointer/types'; -import {type GenericGetter, type GenericTop, type IWorkerBase, type WorkerOptions} from './interface.js'; +import { + type GenericGetter, + type GenericTop, + type IWorkerBase, + type TrustedCallResult, + type WorkerOptions +} from './interface.js'; import {encryptWithPublicKey, parseWebCryptoRSA} from "./webCryptoRSA.js"; import type {Bytes, u8} from "@polkadot/types-codec"; import BN from "bn.js"; @@ -144,10 +150,9 @@ export class Worker implements IWorkerBase { return this.createType(returnType, value); } - async submitAndWatchTop(top: Top, shard: ShardIdentifier): Promise { + async submitAndWatchTop(top: Top, shard: ShardIdentifier): Promise { console.debug(`Sending TrustedOperation: ${JSON.stringify(top)}`); - const cyphertext = await this.encrypt(top.toU8a()); const r = this.createType( @@ -156,11 +161,9 @@ export class Worker implements IWorkerBase { const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) - // const returnValue = await this.send('author_submitExtrinsic', [r.toHex()]) - console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); - return this.createType('Hash', returnValue.value); + return returnValue; } @@ -173,9 +176,11 @@ export class Worker implements IWorkerBase { return this.resultToRpcReturnValue(result); } - public async subscribe(method: string, params: unknown[]): Promise { + public async subscribe(method: string, params: unknown[]): Promise { await this.isReady(); + let topHash: Hash; + return new Promise( async (resolve, reject) => { const onStatusChange = (error: Error | null, result: string) => { if (error !== null) { @@ -189,16 +194,22 @@ export class Worker implements IWorkerBase { const errorMsg = this.createType('String', directRequestStatus.value); throw new Error(`DirectRequestStatus is Error ${errorMsg}`); } + if (directRequestStatus.isOk) { - // const hash = this.createType('Hash', directRequestStatus.value); - resolve({}) + resolve({ + topHash: topHash, + status: undefined + }) } if (directRequestStatus.isTrustedOperationStatus) { console.log(`TrustedOperationStatus: ${directRequestStatus}`) const status = directRequestStatus.asTrustedOperationStatus; if (connection_can_be_closed(status)) { - resolve(status) + resolve({ + topHash: topHash, + status: status + }) } } } @@ -207,8 +218,8 @@ export class Worker implements IWorkerBase { const res = await this.#ws.subscribe(method, method, params, onStatusChange ); - let topHash = this.createType('Hash', res); - console.debug(`resHash: ${topHash}`); + topHash = this.createType('Hash', res); + console.debug(`topHash: ${topHash}`); } catch (err) { console.error(err); reject(err); From d2acd0e80d0e36dda3adfe4fbf71ddb121073614 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 20:59:34 +0200 Subject: [PATCH 39/50] improve callback code --- packages/worker-api/src/worker.ts | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index e67ec80c..4b63e26f 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -183,33 +183,25 @@ export class Worker implements IWorkerBase { return new Promise( async (resolve, reject) => { const onStatusChange = (error: Error | null, result: string) => { - if (error !== null) { - throw new Error(`Callback Error: ${error.message}`); + if (error) { + reject(new Error(`Callback Error: ${error.message}`)); + return; } - console.debug(`DirectRequestStatus: ${JSON.stringify(result)}`) + console.debug(`DirectRequestStatus: ${JSON.stringify(result)}`); const directRequestStatus = this.createType('DirectRequestStatus', result); if (directRequestStatus.isError) { const errorMsg = this.createType('String', directRequestStatus.value); - throw new Error(`DirectRequestStatus is Error ${errorMsg}`); - } - - if (directRequestStatus.isOk) { - resolve({ - topHash: topHash, - status: undefined - }) - } - - if (directRequestStatus.isTrustedOperationStatus) { - console.log(`TrustedOperationStatus: ${directRequestStatus}`) + reject(new Error(`DirectRequestStatus is Error: ${errorMsg}`)); + } else if (directRequestStatus.isOk) { + resolve({topHash, status: undefined}); + } else if (directRequestStatus.isTrustedOperationStatus) { + console.log(`TrustedOperationStatus: ${directRequestStatus}`); const status = directRequestStatus.asTrustedOperationStatus; + if (connection_can_be_closed(status)) { - resolve({ - topHash: topHash, - status: status - }) + resolve({topHash, status}); } } } From 7b090168ca3ea1a1f82ca95647f59995483ed388 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:02:36 +0200 Subject: [PATCH 40/50] fix code peculiarities --- packages/worker-api/src/worker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 4b63e26f..2774fb4b 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -159,7 +159,7 @@ export class Worker implements IWorkerBase { 'Request', { shard, cyphertext: cyphertext } ); - const returnValue = await this.subscribe('author_submitAndWatchExtrinsic', [r.toHex()]) + const returnValue = await this.submitAndWatch([r.toHex()]) console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); @@ -176,7 +176,7 @@ export class Worker implements IWorkerBase { return this.resultToRpcReturnValue(result); } - public async subscribe(method: string, params: unknown[]): Promise { + public async submitAndWatch(params: unknown[]): Promise { await this.isReady(); let topHash: Hash; @@ -207,8 +207,8 @@ export class Worker implements IWorkerBase { } try { - const res = await this.#ws.subscribe(method, - method, params, onStatusChange + const res = await this.#ws.subscribe('author_submitAndWatchExtrinsic', + 'author_submitAndWatchExtrinsic', params, onStatusChange ); topHash = this.createType('Hash', res); console.debug(`topHash: ${topHash}`); From 2164072789994318c47d4c62289450a8e4f1a085 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:06:49 +0200 Subject: [PATCH 41/50] add comment about tests and skip them again for the ci --- README.md | 9 + .../worker-api/src/integriteeWorker.spec.ts | 210 +++++++++--------- packages/worker-api/src/worker.spec.ts | 2 +- 3 files changed, 116 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 72a598fc..e9c73a62 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,15 @@ The worker uses the webcrypto api if it is run in the browser. This library is o defined if you access the webpage with `localhost` in firefox. It is not available on `127.0.0.1` or `0.0.0.0` due to browser security policies. +## Testing +Use the below command to only execute a particular test suite. + +**Note:** The worker tests are skipped by default, as they need a running setup. + +```bash +// execute worker tests +yarn test --runTestsByPath packages/worker-api/src/integriteeWorker.spec.ts +``` ```bash yarn add @encointer/node-api @encointer/worker-api diff --git a/packages/worker-api/src/integriteeWorker.spec.ts b/packages/worker-api/src/integriteeWorker.spec.ts index 37b42e8f..1cdfd156 100644 --- a/packages/worker-api/src/integriteeWorker.spec.ts +++ b/packages/worker-api/src/integriteeWorker.spec.ts @@ -42,79 +42,79 @@ describe('worker', () => { // skip it, as this requires a worker (and hence a node) to be running // To my knowledge jest does not have an option to run skipped tests specifically, does it? // Todo: add proper CI to test this too. - describe('needs worker and node running', () => { - // describe('getWorkerPubKey', () => { - // it('should return value', async () => { - // const result = await worker.getShieldingKey(); - // // console.log('Shielding Key', result); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('getShardVault', () => { - // it('should return value', async () => { - // const result = await worker.getShardVault(); - // console.log('ShardVault', result.toHuman()); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('getNonce', () => { - // it('should return value', async () => { - // const result = await worker.getNonce(alice, network.shard); - // console.log('Nonce', result.toHuman); - // expect(result).toBeDefined(); - // }); - // }); - // - // - // describe('getAccountInfo', () => { - // it('should return value', async () => { - // const result = await worker.getAccountInfo(alice, network.shard); - // console.log('getAccountInfo', result.toHuman()); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('accountInfoGetter', () => { - // it('should return value', async () => { - // const getter = await worker.accountInfoGetter(charlie, network.shard); - // console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); - // const result = await getter.send(); - // console.log('getAccountInfo:', result.toHuman()); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('parentchainsInfoGetter', () => { - // it('should return value', async () => { - // const getter = worker.parentchainsInfoGetter(network.shard); - // console.log(`parentchainsInfoGetter: ${JSON.stringify(getter)}`); - // const result = await getter.send(); - // console.log('parentchainsInfoGetter:', result.toHuman()); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('guessTheNumberInfoGetter', () => { - // it('should return value', async () => { - // const getter = worker.guessTheNumberInfoGetter(network.shard); - // console.log(`GuessTheNumberInfo: ${JSON.stringify(getter)}`); - // const result = await getter.send(); - // console.log('GuessTheNumberInfo:', result.toHuman()); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('guessTheNumberAttemptsGetter', () => { - // it('should return value', async () => { - // const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.shard); - // console.log(`Attempts: ${JSON.stringify(getter)}`); - // const result = await getter.send(); - // console.log('Attempts:', result.toHuman()); - // expect(result).toBeDefined(); - // }); - // }); + describe.skip('needs worker and node running', () => { + describe('getWorkerPubKey', () => { + it('should return value', async () => { + const result = await worker.getShieldingKey(); + // console.log('Shielding Key', result); + expect(result).toBeDefined(); + }); + }); + + describe('getShardVault', () => { + it('should return value', async () => { + const result = await worker.getShardVault(); + console.log('ShardVault', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('getNonce', () => { + it('should return value', async () => { + const result = await worker.getNonce(alice, network.shard); + console.log('Nonce', result.toHuman); + expect(result).toBeDefined(); + }); + }); + + + describe('getAccountInfo', () => { + it('should return value', async () => { + const result = await worker.getAccountInfo(alice, network.shard); + console.log('getAccountInfo', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('accountInfoGetter', () => { + it('should return value', async () => { + const getter = await worker.accountInfoGetter(charlie, network.shard); + console.log(`AccountInfoGetter: ${JSON.stringify(getter)}`); + const result = await getter.send(); + console.log('getAccountInfo:', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('parentchainsInfoGetter', () => { + it('should return value', async () => { + const getter = worker.parentchainsInfoGetter(network.shard); + console.log(`parentchainsInfoGetter: ${JSON.stringify(getter)}`); + const result = await getter.send(); + console.log('parentchainsInfoGetter:', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('guessTheNumberInfoGetter', () => { + it('should return value', async () => { + const getter = worker.guessTheNumberInfoGetter(network.shard); + console.log(`GuessTheNumberInfo: ${JSON.stringify(getter)}`); + const result = await getter.send(); + console.log('GuessTheNumberInfo:', result.toHuman()); + expect(result).toBeDefined(); + }); + }); + + describe('guessTheNumberAttemptsGetter', () => { + it('should return value', async () => { + const getter = await worker.guessTheNumberAttemptsTrustedGetter(charlie, network.shard); + console.log(`Attempts: ${JSON.stringify(getter)}`); + const result = await getter.send(); + console.log('Attempts:', result.toHuman()); + expect(result).toBeDefined(); + }); + }); describe('balance transfer should work', () => { it('should return value', async () => { @@ -132,36 +132,38 @@ describe('worker', () => { }); }); - // describe('balance unshield should work', () => { - // it('should return value', async () => { - // const shard = network.shard; - // - // const result = await worker.balanceUnshieldFunds( - // alice, - // shard, - // network.mrenclave, - // alice.address, - // charlie.address, - // 1100000000000, - // ); - // console.log('balance unshield result', JSON.stringify(result)); - // expect(result).toBeDefined(); - // }); - // }); - // - // describe('guess the number should work', () => { - // it('should return value', async () => { - // const shard = network.shard; - // - // const result = await worker.guessTheNumber( - // alice, - // shard, - // network.mrenclave, - // 1, - // ); - // console.log('guess the number result', JSON.stringify(result)); - // expect(result).toBeDefined(); - // }); - // }); + // race condition so skipped + describe.skip('balance unshield should work', () => { + it('should return value', async () => { + const shard = network.shard; + + const result = await worker.balanceUnshieldFunds( + alice, + shard, + network.mrenclave, + alice.address, + charlie.address, + 1100000000000, + ); + console.log('balance unshield result', JSON.stringify(result)); + expect(result).toBeDefined(); + }); + }); + + // race condition, so skipped + describe.skip('guess the number should work', () => { + it('should return value', async () => { + const shard = network.shard; + + const result = await worker.guessTheNumber( + alice, + shard, + network.mrenclave, + 1, + ); + console.log('guess the number result', JSON.stringify(result)); + expect(result).toBeDefined(); + }); + }); }); }); diff --git a/packages/worker-api/src/worker.spec.ts b/packages/worker-api/src/worker.spec.ts index 31cb5aa1..5566e099 100644 --- a/packages/worker-api/src/worker.spec.ts +++ b/packages/worker-api/src/worker.spec.ts @@ -36,7 +36,7 @@ describe('worker', () => { // skip it, as this requires a worker (and hence a node) to be running // To my knowledge jest does not have an option to run skipped tests specifically, does it? // Todo: add proper CI to test this too. - describe('needs worker and node running', () => { + describe.skip('needs worker and node running', () => { describe('getWorkerPubKey', () => { it('should return value', async () => { const result = await worker.getShieldingKey(); From df0ba527b92d635ec62ef2a5b4a72a8663b909e2 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:09:06 +0200 Subject: [PATCH 42/50] remove unnecessary dependency --- packages/worker-api/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index 882b6a45..63525b97 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -29,7 +29,6 @@ "@polkadot/api": "^11.2.1", "@polkadot/keyring": "^12.6.2", "@polkadot/types": "^11.2.1", - "@polkadot/types-support": "11.2.1", "@polkadot/util": "^12.6.2", "@polkadot/util-crypto": "^12.6.2", "@polkadot/wasm-crypto": "^7.3.2", diff --git a/yarn.lock b/yarn.lock index 0724367c..f88e0e9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -805,7 +805,6 @@ __metadata: "@polkadot/api": "npm:^11.2.1" "@polkadot/keyring": "npm:^12.6.2" "@polkadot/types": "npm:^11.2.1" - "@polkadot/types-support": "npm:11.2.1" "@polkadot/util": "npm:^12.6.2" "@polkadot/util-crypto": "npm:^12.6.2" "@polkadot/wasm-crypto": "npm:^7.3.2" From c668eb632f009d7b6d3bda203518322ea69725ac Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:15:27 +0200 Subject: [PATCH 43/50] add comment about websocket in rpc-provider --- .../worker-api/src/rpc-provider/README.md | 69 +------------------ 1 file changed, 3 insertions(+), 66 deletions(-) diff --git a/packages/worker-api/src/rpc-provider/README.md b/packages/worker-api/src/rpc-provider/README.md index e114fb2d..39c347bf 100644 --- a/packages/worker-api/src/rpc-provider/README.md +++ b/packages/worker-api/src/rpc-provider/README.md @@ -1,68 +1,5 @@ -# @polkadot/rpc-provider +The RPC provider is a subset of https://github.com/polkadot-js/api/tree/master/packages/rpc-provider. -Generic transport providers to handle the transport of method calls to and from Polkadot clients from applications interacting with it. It provides an interface to making RPC calls and is generally, unless you are operating at a low-level and taking care of encoding and decoding of parameters/results, it won't be directly used, rather only passed to a higher-level interface. +However, it contains a small but crucial change for us: -## Provider Selection - -There are three flavours of the providers provided, one allowing for using HTTP as a transport mechanism, the other using WebSockets, and the third one uses substrate light-client through @substrate/connect. It is generally recommended to use the [[WsProvider]] since in addition to standard calls, it allows for subscriptions where all changes to state can be pushed from the node to the client. - -All providers are usable (as is the API), in both browser-based and Node.js environments. Polyfills for unsupported functionality are automatically applied based on feature-detection. - -## Usage - -Installation - - -``` -yarn add @polkadot/rpc-provider -``` - -WebSocket Initialization - - -```javascript -import { WsProvider } from '@polkadot/rpc-provider'; - -// this is the actual default endpoint -const provider = new WsProvider('ws://127.0.0.1:9944'); -const version = await provider.send('client_version', []); - -console.log('client version', version); -``` - -HTTP Initialization - - -```javascript -import { HttpProvider } from '@polkadot/rpc-provider'; - -// this is the actual default endpoint -const provider = new HttpProvider('http://127.0.0.1:9933'); -const version = await provider.send('chain_getBlockHash', []); - -console.log('latest block Hash', hash); -``` - -@substrate/connect Initialization - - -Instantiating a Provider for the Polkadot Relay Chain: -```javascript -import { ScProvider } from '@polkadot/rpc-provider'; -import * as Sc from '@substrate/connect'; - -const provider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); - -await provider.connect(); - -const version = await provider.send('chain_getBlockHash', []); -``` - -Instantiating a Provider for a Polkadot parachain: -```javascript -import { ScProvider } from '@polkadot/rpc-provider'; -import * as Sc from '@substrate/connect'; - -const polkadotProvider = new ScProvider(Sc, Sc.WellKnownChain.polkadot); -const parachainProvider = new ScProvider(Sc, parachainSpec, polkadotProvider); - -await parachainProvider.connect(); - -const version = await parachainProvider.send('chain_getBlockHash', []); -``` +The reason it lives here, is only because we want to be able to inject a websocket for integration testing against local setups for the integritee worker, which means the websocket needs accept self-signed certificates. From 41ff9a82cc8f2d2c8923c8b85770d082da31042b Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:17:36 +0200 Subject: [PATCH 44/50] better comment --- packages/worker-api/src/worker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 2774fb4b..db9b58ce 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -41,9 +41,9 @@ export class Worker implements IWorkerBase { this.#registry = new TypeRegistry(); this.#keyring = (options.keyring || undefined); - // We want to pass the custom node's websocket implementation into the provider + // We want to pass arguments to NodeJS' websocket implementation into the provider // in our integration tests, so that we can accept the workers self-signed - // certificate. + // certificate. Hence, we inject the factory function. this.#ws = new WsProvider(url, 100, undefined, undefined, undefined, options.createWebSocket); if (options.types != undefined) { From af1075c44dd6af34fc1323dea4c0b912ae4a2014 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:24:41 +0200 Subject: [PATCH 45/50] better type for submitAndWatch --- packages/worker-api/src/worker.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index db9b58ce..82525e86 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -10,7 +10,7 @@ import type { EnclaveFingerprint, RpcReturnValue, ShardIdentifier, TrustedOperationStatus, - Vault + Vault, Request } from '@encointer/types'; import { @@ -159,7 +159,7 @@ export class Worker implements IWorkerBase { 'Request', { shard, cyphertext: cyphertext } ); - const returnValue = await this.submitAndWatch([r.toHex()]) + const returnValue = await this.submitAndWatch(r) console.debug(`[sendTrustedCall] result: ${JSON.stringify(returnValue)}`); @@ -176,7 +176,7 @@ export class Worker implements IWorkerBase { return this.resultToRpcReturnValue(result); } - public async submitAndWatch(params: unknown[]): Promise { + public async submitAndWatch(request: Request): Promise { await this.isReady(); let topHash: Hash; @@ -208,7 +208,7 @@ export class Worker implements IWorkerBase { try { const res = await this.#ws.subscribe('author_submitAndWatchExtrinsic', - 'author_submitAndWatchExtrinsic', params, onStatusChange + 'author_submitAndWatchExtrinsic', [request.toHex()], onStatusChange ); topHash = this.createType('Hash', res); console.debug(`topHash: ${topHash}`); From 54c70af0ebbca6a0c9451c52017892fa916d57f4 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:34:56 +0200 Subject: [PATCH 46/50] v0.15.3-alpha.0 --- lerna.json | 2 +- packages/node-api/package.json | 4 ++-- packages/types/package.json | 2 +- packages/util/package.json | 2 +- packages/worker-api/package.json | 8 ++++---- yarn.lock | 14 +++++++------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lerna.json b/lerna.json index cc5ff0be..d4f6f524 100644 --- a/lerna.json +++ b/lerna.json @@ -6,5 +6,5 @@ "publishConfig": { "directory": "build" }, - "version": "0.15.2" + "version": "0.15.3-alpha.0" } diff --git a/packages/node-api/package.json b/packages/node-api/package.json index 9e22b709..01135242 100644 --- a/packages/node-api/package.json +++ b/packages/node-api/package.json @@ -18,10 +18,10 @@ }, "sideEffects": false, "type": "module", - "version": "0.15.2", + "version": "0.15.3-alpha.0", "main": "index.js", "dependencies": { - "@encointer/types": "^0.15.2", + "@encointer/types": "^0.15.3-alpha.0", "@polkadot/api": "^11.2.1", "tslib": "^2.6.2" }, diff --git a/packages/types/package.json b/packages/types/package.json index 7ef71931..255c6e2a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -18,7 +18,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.15.2", + "version": "0.15.3-alpha.0", "main": "index.js", "scripts": { "generate:defs": "node --experimental-specifier-resolution=node --loader ts-node/esm ../../node_modules/.bin/polkadot-types-from-defs --package @encointer/types/interfaces --input ./src/interfaces", diff --git a/packages/util/package.json b/packages/util/package.json index 298e6415..43b9e60b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -20,7 +20,7 @@ "sideEffects": false, "type": "module", "types": "./index.d.ts", - "version": "0.15.2", + "version": "0.15.3-alpha.0", "main": "index.js", "dependencies": { "@babel/runtime": "^7.18.9", diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index 63525b97..223cd3a8 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -19,12 +19,12 @@ "sideEffects": false, "type": "module", "types": "./index.d.ts", - "version": "0.15.2", + "version": "0.15.3-alpha.0", "main": "index.js", "dependencies": { - "@encointer/node-api": "^0.15.2", - "@encointer/types": "^0.15.2", - "@encointer/util": "^0.15.2", + "@encointer/node-api": "^0.15.3-alpha.0", + "@encointer/types": "^0.15.3-alpha.0", + "@encointer/util": "^0.15.3-alpha.0", "@peculiar/webcrypto": "^1.4.6", "@polkadot/api": "^11.2.1", "@polkadot/keyring": "^12.6.2", diff --git a/yarn.lock b/yarn.lock index f88e0e9f..2cb3bfaa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -755,18 +755,18 @@ __metadata: languageName: node linkType: hard -"@encointer/node-api@npm:^0.15.2, @encointer/node-api@workspace:packages/node-api": +"@encointer/node-api@npm:^0.15.3-alpha.0, @encointer/node-api@workspace:packages/node-api": version: 0.0.0-use.local resolution: "@encointer/node-api@workspace:packages/node-api" dependencies: - "@encointer/types": "npm:^0.15.2" + "@encointer/types": "npm:^0.15.3-alpha.0" "@polkadot/api": "npm:^11.2.1" "@polkadot/util-crypto": "npm:^12.6.2" tslib: "npm:^2.6.2" languageName: unknown linkType: soft -"@encointer/types@npm:^0.15.2, @encointer/types@workspace:packages/types": +"@encointer/types@npm:^0.15.3-alpha.0, @encointer/types@workspace:packages/types": version: 0.0.0-use.local resolution: "@encointer/types@workspace:packages/types" dependencies: @@ -781,7 +781,7 @@ __metadata: languageName: unknown linkType: soft -"@encointer/util@npm:^0.15.2, @encointer/util@workspace:packages/util": +"@encointer/util@npm:^0.15.3-alpha.0, @encointer/util@workspace:packages/util": version: 0.0.0-use.local resolution: "@encointer/util@workspace:packages/util" dependencies: @@ -798,9 +798,9 @@ __metadata: version: 0.0.0-use.local resolution: "@encointer/worker-api@workspace:packages/worker-api" dependencies: - "@encointer/node-api": "npm:^0.15.2" - "@encointer/types": "npm:^0.15.2" - "@encointer/util": "npm:^0.15.2" + "@encointer/node-api": "npm:^0.15.3-alpha.0" + "@encointer/types": "npm:^0.15.3-alpha.0" + "@encointer/util": "npm:^0.15.3-alpha.0" "@peculiar/webcrypto": "npm:^1.4.6" "@polkadot/api": "npm:^11.2.1" "@polkadot/keyring": "npm:^12.6.2" From 9d26933f5668614f656718d7fc013f97dc9f2d06 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Fri, 25 Oct 2024 21:36:29 +0200 Subject: [PATCH 47/50] v0.16.0-alpha.0 --- lerna.json | 2 +- packages/node-api/package.json | 4 ++-- packages/types/package.json | 2 +- packages/util/package.json | 2 +- packages/worker-api/package.json | 8 ++++---- yarn.lock | 14 +++++++------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lerna.json b/lerna.json index d4f6f524..6744e2b8 100644 --- a/lerna.json +++ b/lerna.json @@ -6,5 +6,5 @@ "publishConfig": { "directory": "build" }, - "version": "0.15.3-alpha.0" + "version": "0.16.0-alpha.0" } diff --git a/packages/node-api/package.json b/packages/node-api/package.json index 01135242..0ced22ee 100644 --- a/packages/node-api/package.json +++ b/packages/node-api/package.json @@ -18,10 +18,10 @@ }, "sideEffects": false, "type": "module", - "version": "0.15.3-alpha.0", + "version": "0.16.0-alpha.0", "main": "index.js", "dependencies": { - "@encointer/types": "^0.15.3-alpha.0", + "@encointer/types": "^0.16.0-alpha.0", "@polkadot/api": "^11.2.1", "tslib": "^2.6.2" }, diff --git a/packages/types/package.json b/packages/types/package.json index 255c6e2a..9c8900c5 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -18,7 +18,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.15.3-alpha.0", + "version": "0.16.0-alpha.0", "main": "index.js", "scripts": { "generate:defs": "node --experimental-specifier-resolution=node --loader ts-node/esm ../../node_modules/.bin/polkadot-types-from-defs --package @encointer/types/interfaces --input ./src/interfaces", diff --git a/packages/util/package.json b/packages/util/package.json index 43b9e60b..8aee88f6 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -20,7 +20,7 @@ "sideEffects": false, "type": "module", "types": "./index.d.ts", - "version": "0.15.3-alpha.0", + "version": "0.16.0-alpha.0", "main": "index.js", "dependencies": { "@babel/runtime": "^7.18.9", diff --git a/packages/worker-api/package.json b/packages/worker-api/package.json index 223cd3a8..ff9d9dae 100644 --- a/packages/worker-api/package.json +++ b/packages/worker-api/package.json @@ -19,12 +19,12 @@ "sideEffects": false, "type": "module", "types": "./index.d.ts", - "version": "0.15.3-alpha.0", + "version": "0.16.0-alpha.0", "main": "index.js", "dependencies": { - "@encointer/node-api": "^0.15.3-alpha.0", - "@encointer/types": "^0.15.3-alpha.0", - "@encointer/util": "^0.15.3-alpha.0", + "@encointer/node-api": "^0.16.0-alpha.0", + "@encointer/types": "^0.16.0-alpha.0", + "@encointer/util": "^0.16.0-alpha.0", "@peculiar/webcrypto": "^1.4.6", "@polkadot/api": "^11.2.1", "@polkadot/keyring": "^12.6.2", diff --git a/yarn.lock b/yarn.lock index 2cb3bfaa..17c0d237 100644 --- a/yarn.lock +++ b/yarn.lock @@ -755,18 +755,18 @@ __metadata: languageName: node linkType: hard -"@encointer/node-api@npm:^0.15.3-alpha.0, @encointer/node-api@workspace:packages/node-api": +"@encointer/node-api@npm:^0.16.0-alpha.0, @encointer/node-api@workspace:packages/node-api": version: 0.0.0-use.local resolution: "@encointer/node-api@workspace:packages/node-api" dependencies: - "@encointer/types": "npm:^0.15.3-alpha.0" + "@encointer/types": "npm:^0.16.0-alpha.0" "@polkadot/api": "npm:^11.2.1" "@polkadot/util-crypto": "npm:^12.6.2" tslib: "npm:^2.6.2" languageName: unknown linkType: soft -"@encointer/types@npm:^0.15.3-alpha.0, @encointer/types@workspace:packages/types": +"@encointer/types@npm:^0.16.0-alpha.0, @encointer/types@workspace:packages/types": version: 0.0.0-use.local resolution: "@encointer/types@workspace:packages/types" dependencies: @@ -781,7 +781,7 @@ __metadata: languageName: unknown linkType: soft -"@encointer/util@npm:^0.15.3-alpha.0, @encointer/util@workspace:packages/util": +"@encointer/util@npm:^0.16.0-alpha.0, @encointer/util@workspace:packages/util": version: 0.0.0-use.local resolution: "@encointer/util@workspace:packages/util" dependencies: @@ -798,9 +798,9 @@ __metadata: version: 0.0.0-use.local resolution: "@encointer/worker-api@workspace:packages/worker-api" dependencies: - "@encointer/node-api": "npm:^0.15.3-alpha.0" - "@encointer/types": "npm:^0.15.3-alpha.0" - "@encointer/util": "npm:^0.15.3-alpha.0" + "@encointer/node-api": "npm:^0.16.0-alpha.0" + "@encointer/types": "npm:^0.16.0-alpha.0" + "@encointer/util": "npm:^0.16.0-alpha.0" "@peculiar/webcrypto": "npm:^1.4.6" "@polkadot/api": "npm:^11.2.1" "@polkadot/keyring": "npm:^12.6.2" From 578726b937bc632cd1da2c399c1cf57d4041499a Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Sat, 26 Oct 2024 20:57:33 +0200 Subject: [PATCH 48/50] resolve promise if status is invalid --- packages/worker-api/src/worker.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 82525e86..5443461a 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -200,6 +200,11 @@ export class Worker implements IWorkerBase { console.log(`TrustedOperationStatus: ${directRequestStatus}`); const status = directRequestStatus.asTrustedOperationStatus; + if (status.isInvalid || status.isUsurped || status.isDropped) { + console.debug(`Trusted operation failed to execute: ${status.toHuman()}`); + resolve({topHash, status}); + } + if (connection_can_be_closed(status)) { resolve({topHash, status}); } From 25e7cc6e45a480999914979580c899b4fc0083f6 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Sat, 26 Oct 2024 20:58:02 +0200 Subject: [PATCH 49/50] Revert "[rpc-provider] add debug logs" This reverts commit 62151334c18021770b0a221807e3a7355291d967. --- .../src/rpc-provider/src/ws/index.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/packages/worker-api/src/rpc-provider/src/ws/index.ts b/packages/worker-api/src/rpc-provider/src/ws/index.ts index 288cce1f..21bf03d1 100644 --- a/packages/worker-api/src/rpc-provider/src/ws/index.ts +++ b/packages/worker-api/src/rpc-provider/src/ws/index.ts @@ -358,8 +358,6 @@ export class WsProvider implements ProviderInterface { }; l.debug(() => ['calling', method, body]); - console.log(`['calling', ${method}, ${body}]`); - console.log(`setting handler for id=${id}`); this.#handlers[id] = { callback, @@ -501,8 +499,6 @@ export class WsProvider implements ProviderInterface { const response = JSON.parse(message.data) as JsonRpcResponse; - console.log(`Json Response: ${JSON.stringify(response)}`); - return isUndefined(response.method) ? this.#onSocketMessageResult(response) : this.#onSocketMessageSubscribe(response); @@ -511,13 +507,8 @@ export class WsProvider implements ProviderInterface { #onSocketMessageResult = (response: JsonRpcResponse): void => { const handler = this.#handlers[response.id]; - console.log(`Json Result: ${JSON.stringify(response)}`); - console.log(`handler: ${JSON.stringify(this.#handlers)}`); - - if (!handler) { l.debug(() => `Unable to find handler for id=${response.id}`); - console.log(`Unable to find handler for id=${response.id}`); return; } @@ -532,16 +523,12 @@ export class WsProvider implements ProviderInterface { if (subscription) { const subId = `${subscription.type}::${result}`; - console.log(`subId: ${subId}`); - console.log(`it is as subscription: ${JSON.stringify(subscription)}}`); this.#subscriptions[subId] = objectSpread({}, subscription, { method, params }); - console.log(`subscriptions: ${JSON.stringify(this.#subscriptions)}`); - // if we have a result waiting for this subscription already if (this.#waitingForId[subId]) { this.#onSocketMessageSubscribe(this.#waitingForId[subId]); @@ -571,7 +558,6 @@ export class WsProvider implements ProviderInterface { this.#waitingForId[subId] = response; l.debug(() => `Unable to find handler for subscription=${subId}`); - console.log(`Unable to find handler for subscription=${subId}`); return; } @@ -580,16 +566,10 @@ export class WsProvider implements ProviderInterface { delete this.#waitingForId[subId]; try { - console.log(`Decoding Response=${subId}`); - const result = this.#coder.decodeResponse(response); - console.log(`Decoded Response=${subId}`); - handler.callback(null, result); } catch (error) { - console.log(`Failed to decode response=${(error as Error).message}`); - this.#endpointStats.errors++; this.#stats.total.errors++; From 11b21477b4bcbcb9e962ee5b194af24fff2d22b9 Mon Sep 17 00:00:00 2001 From: Christian Langenbacher Date: Sat, 26 Oct 2024 21:00:02 +0200 Subject: [PATCH 50/50] downgrade a log --- packages/worker-api/src/worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker-api/src/worker.ts b/packages/worker-api/src/worker.ts index 5443461a..6ef29700 100644 --- a/packages/worker-api/src/worker.ts +++ b/packages/worker-api/src/worker.ts @@ -197,7 +197,7 @@ export class Worker implements IWorkerBase { } else if (directRequestStatus.isOk) { resolve({topHash, status: undefined}); } else if (directRequestStatus.isTrustedOperationStatus) { - console.log(`TrustedOperationStatus: ${directRequestStatus}`); + console.debug(`TrustedOperationStatus: ${directRequestStatus}`); const status = directRequestStatus.asTrustedOperationStatus; if (status.isInvalid || status.isUsurped || status.isDropped) {