UNPKG

@polkadot/rpc-core

Version:

A JavaScript wrapper for the Polkadot JsonRPC interface

411 lines (410 loc) • 17.5 kB
import { Observable, publishReplay, refCount } from 'rxjs'; import { LRUCache } from '@polkadot/rpc-provider'; import { rpcDefinitions } from '@polkadot/types'; import { hexToU8a, isFunction, isNull, isUndefined, lazyMethod, logger, memoize, objectSpread, u8aConcat, u8aToU8a } from '@polkadot/util'; import { drr, refCountDelay } from './util/index.js'; export { packageInfo } from './packageInfo.js'; export * from './util/index.js'; const l = logger('rpc-core'); const EMPTY_META = { fallback: undefined, modifier: { isOptional: true }, type: { asMap: { linked: { isTrue: false } }, isMap: false } }; const RPC_CORE_DEFAULT_CAPACITY = 1024 * 10 * 10; /** @internal */ function logErrorMessage(method, { noErrorLog, params, type }, error) { if (noErrorLog) { return; } l.error(`${method}(${params.map(({ isOptional, name, type }) => `${name}${isOptional ? '?' : ''}: ${type}`).join(', ')}): ${type}:: ${error.message}`); } function isTreatAsHex(key) { // :code is problematic - it does not have the length attached, which is // unlike all other storage entries where it is indeed properly encoded return ['0x3a636f6465'].includes(key.toHex()); } /** * @name Rpc * @summary The API may use a HTTP or WebSockets provider. * @description It allows for querying a Polkadot Client Node. * WebSockets provider is recommended since HTTP provider only supports basic querying. * * ```mermaid * graph LR; * A[Api] --> |WebSockets| B[WsProvider]; * B --> |endpoint| C[ws://127.0.0.1:9944] * ``` * * @example * <BR> * * ```javascript * import Rpc from '@polkadot/rpc-core'; * import { WsProvider } from '@polkadot/rpc-provider/ws'; * * const provider = new WsProvider('ws://127.0.0.1:9944'); * const rpc = new Rpc(provider); * ``` */ export class RpcCore { #instanceId; #isPedantic; #registryDefault; #storageCache; #storageCacheHits = 0; #getBlockRegistry; #getBlockHash; mapping = new Map(); provider; sections = []; /** * @constructor * Default constructor for the core RPC handler * @param {Registry} registry Type Registry * @param {ProviderInterface} options.provider An API provider using any of the supported providers (HTTP, SC or WebSocket) * @param {number} [options.rpcCacheCapacity] Custom size of the rpc LRUCache capacity. Defaults to `RPC_CORE_DEFAULT_CAPACITY` (1024 * 10 * 10) */ constructor(instanceId, registry, { isPedantic = true, provider, rpcCacheCapacity, ttl, userRpc = {} }) { if (!provider || !isFunction(provider.send)) { throw new Error('Expected Provider to API create'); } this.#instanceId = instanceId; this.#isPedantic = isPedantic; this.#registryDefault = registry; this.provider = provider; const sectionNames = Object.keys(rpcDefinitions); // these are the base keys (i.e. part of jsonrpc) this.sections.push(...sectionNames); this.#storageCache = new LRUCache(rpcCacheCapacity || RPC_CORE_DEFAULT_CAPACITY, ttl); // decorate all interfaces, defined and user on this instance this.addUserInterfaces(userRpc); } /** * @description Returns the connected status of a provider */ get isConnected() { return this.provider.isConnected; } /** * @description Manually connect from the attached provider */ connect() { return this.provider.connect(); } /** * @description Manually disconnect from the attached provider */ async disconnect() { return this.provider.disconnect(); } /** * @description Returns the underlying core stats, including those from teh provider */ get stats() { const stats = this.provider.stats; return stats ? { ...stats, core: { cacheHits: this.#storageCacheHits, cacheSize: this.#storageCache.length } } : undefined; } /** * @description Sets a registry swap (typically from Api) */ setRegistrySwap(registrySwap) { this.#getBlockRegistry = memoize(registrySwap, { getInstanceId: () => this.#instanceId }); } /** * @description Sets a function to resolve block hash from block number */ setResolveBlockHash(resolveBlockHash) { this.#getBlockHash = memoize(resolveBlockHash, { getInstanceId: () => this.#instanceId }); } addUserInterfaces(userRpc) { // add any extra user-defined sections this.sections.push(...Object.keys(userRpc).filter((k) => !this.sections.includes(k))); for (let s = 0, scount = this.sections.length; s < scount; s++) { const section = this.sections[s]; const defs = objectSpread({}, rpcDefinitions[section], userRpc[section]); const methods = Object.keys(defs); for (let m = 0, mcount = methods.length; m < mcount; m++) { const method = methods[m]; const def = defs[method]; const jsonrpc = def.endpoint || `${section}_${method}`; if (!this.mapping.has(jsonrpc)) { const isSubscription = !!def.pubsub; if (!this[section]) { this[section] = {}; } this.mapping.set(jsonrpc, objectSpread({}, def, { isSubscription, jsonrpc, method, section })); lazyMethod(this[section], method, () => isSubscription ? this._createMethodSubscribe(section, method, def) : this._createMethodSend(section, method, def)); } } } } _memomize(creator, def) { const memoOpts = { getInstanceId: () => this.#instanceId }; const memoized = memoize(creator(true), memoOpts); memoized.raw = memoize(creator(false), memoOpts); memoized.meta = def; return memoized; } _formatResult(isScale, registry, blockHash, method, def, params, result) { return isScale ? this._formatOutput(registry, blockHash, method, def, params, result) : result; } _createMethodSend(section, method, def) { const rpcName = def.endpoint || `${section}_${method}`; const hashIndex = def.params.findIndex(({ isHistoric }) => isHistoric); let memoized = null; // execute the RPC call, doing a registry swap for historic as applicable const callWithRegistry = async (isScale, values) => { const blockId = hashIndex === -1 ? null : values[hashIndex]; const blockHash = blockId && def.params[hashIndex].type === 'BlockNumber' ? await this.#getBlockHash?.(blockId) : blockId; const { registry } = isScale && blockHash && this.#getBlockRegistry ? await this.#getBlockRegistry(u8aToU8a(blockHash)) : { registry: this.#registryDefault }; const params = this._formatParams(registry, null, def, values); // only cache .at(<blockHash>) queries, e.g. where valid blockHash was supplied const result = await this.provider.send(rpcName, params.map((p) => p.toJSON()), !!blockHash); return this._formatResult(isScale, registry, blockHash, method, def, params, result); }; const creator = (isScale) => (...values) => { const isDelayed = isScale && hashIndex !== -1 && !!values[hashIndex]; return new Observable((observer) => { callWithRegistry(isScale, values) .then((value) => { observer.next(value); observer.complete(); }) .catch((error) => { logErrorMessage(method, def, error); observer.error(error); observer.complete(); }); return () => { // delete old results from cache if (isScale) { memoized?.unmemoize(...values); } else { memoized?.raw.unmemoize(...values); } }; }).pipe( // eslint-disable-next-line deprecation/deprecation publishReplay(1), // create a Replay(1) isDelayed ? refCountDelay() // Unsubscribe after delay // eslint-disable-next-line deprecation/deprecation : refCount()); }; memoized = this._memomize(creator, def); return memoized; } // create a subscriptor, it subscribes once and resolves with the id as subscribe _createSubscriber({ paramsJson, subName, subType, update }, errorHandler) { return new Promise((resolve, reject) => { this.provider .subscribe(subType, subName, paramsJson, update) .then(resolve) .catch((error) => { errorHandler(error); reject(error); }); }); } _createMethodSubscribe(section, method, def) { const [updateType, subMethod, unsubMethod] = def.pubsub; const subName = `${section}_${subMethod}`; const unsubName = `${section}_${unsubMethod}`; const subType = `${section}_${updateType}`; let memoized = null; const creator = (isScale) => (...values) => { return new Observable((observer) => { // Have at least an empty promise, as used in the unsubscribe let subscriptionPromise = Promise.resolve(null); const registry = this.#registryDefault; const errorHandler = (error) => { logErrorMessage(method, def, error); observer.error(error); }; try { const params = this._formatParams(registry, null, def, values); const update = (error, result) => { if (error) { logErrorMessage(method, def, error); return; } try { observer.next(this._formatResult(isScale, registry, null, method, def, params, result)); } catch (error) { observer.error(error); } }; subscriptionPromise = this._createSubscriber({ paramsJson: params.map((p) => p.toJSON()), subName, subType, update }, errorHandler); } catch (error) { errorHandler(error); } // Teardown logic return () => { // Delete from cache, so old results don't hang around if (isScale) { memoized?.unmemoize(...values); } else { memoized?.raw.unmemoize(...values); } // Unsubscribe from provider subscriptionPromise .then((subscriptionId) => isNull(subscriptionId) ? Promise.resolve(false) : this.provider.unsubscribe(subType, unsubName, subscriptionId)) .catch((error) => logErrorMessage(method, def, error)); }; }).pipe(drr()); }; memoized = this._memomize(creator, def); return memoized; } _formatParams(registry, blockHash, def, inputs) { const count = inputs.length; const reqCount = def.params.filter(({ isOptional }) => !isOptional).length; if (count < reqCount || count > def.params.length) { throw new Error(`Expected ${def.params.length} parameters${reqCount === def.params.length ? '' : ` (${def.params.length - reqCount} optional)`}, ${count} found instead`); } const params = new Array(count); for (let i = 0; i < count; i++) { params[i] = registry.createTypeUnsafe(def.params[i].type, [inputs[i]], { blockHash }); } return params; } _formatOutput(registry, blockHash, method, rpc, params, result) { if (rpc.type === 'StorageData') { const key = params[0]; return this._formatStorageData(registry, blockHash, key, result); } else if (rpc.type === 'StorageChangeSet') { const keys = params[0]; return keys ? this._formatStorageSet(registry, result.block, keys, result.changes) : registry.createType('StorageChangeSet', result); } else if (rpc.type === 'Vec<StorageChangeSet>') { const jsonSet = result; const count = jsonSet.length; const mapped = new Array(count); for (let i = 0; i < count; i++) { const { block, changes } = jsonSet[i]; mapped[i] = [ registry.createType('BlockHash', block), this._formatStorageSet(registry, block, params[0], changes) ]; } // we only query at a specific block, not a range - flatten return method === 'queryStorageAt' ? mapped[0][1] : mapped; } return registry.createTypeUnsafe(rpc.type, [result], { blockHash }); } _formatStorageData(registry, blockHash, key, value) { const isEmpty = isNull(value); // we convert to Uint8Array since it maps to the raw encoding, all // data will be correctly encoded (incl. numbers, excl. :code) const input = isEmpty ? null : isTreatAsHex(key) ? value : u8aToU8a(value); return this._newType(registry, blockHash, key, input, isEmpty); } _formatStorageSet(registry, blockHash, keys, changes) { // For StorageChangeSet, the changes has the [key, value] mappings const count = keys.length; const withCache = count !== 1; const values = new Array(count); // multiple return values (via state.storage subscription), decode the // values one at a time, all based on the supplied query types for (let i = 0; i < count; i++) { values[i] = this._formatStorageSetEntry(registry, blockHash, keys[i], changes, withCache, i); } return values; } _formatStorageSetEntry(registry, blockHash, key, changes, withCache, entryIndex) { const hexKey = key.toHex(); const found = changes.find(([key]) => key === hexKey); const isNotFound = isUndefined(found); // if we don't find the value, this is our fallback // - in the case of an array of values, fill the hole from the cache // - if a single result value, don't fill - it is not an update hole // - fallback to an empty option in all cases if (isNotFound && withCache) { const cached = this.#storageCache.get(hexKey); if (cached) { this.#storageCacheHits++; return cached; } } const value = isNotFound ? null : found[1]; const isEmpty = isNull(value); const input = isEmpty || isTreatAsHex(key) ? value : u8aToU8a(value); const codec = this._newType(registry, blockHash, key, input, isEmpty, entryIndex); this._setToCache(hexKey, codec); return codec; } _setToCache(key, value) { this.#storageCache.set(key, value); } _newType(registry, blockHash, key, input, isEmpty, entryIndex = -1) { // single return value (via state.getStorage), decode the value based on the // outputType that we have specified. Fallback to Raw on nothing const type = key.outputType || 'Raw'; const meta = key.meta || EMPTY_META; const entryNum = entryIndex === -1 ? '' : ` entry ${entryIndex}:`; try { return registry.createTypeUnsafe(type, [ isEmpty ? meta.fallback // For old-style Linkage, we add an empty linkage at the end ? type.includes('Linkage<') ? u8aConcat(hexToU8a(meta.fallback.toHex()), new Uint8Array(2)) : hexToU8a(meta.fallback.toHex()) : undefined : meta.modifier.isOptional ? registry.createTypeUnsafe(type, [input], { blockHash, isPedantic: this.#isPedantic }) : input ], { blockHash, isFallback: isEmpty && !!meta.fallback, isOptional: meta.modifier.isOptional, isPedantic: this.#isPedantic && !meta.modifier.isOptional }); } catch (error) { throw new Error(`Unable to decode storage ${key.section || 'unknown'}.${key.method || 'unknown'}:${entryNum}: ${error.message}`); } } }