@polkadot/rpc-core
Version:
A JavaScript wrapper for the Polkadot JsonRPC interface
411 lines (410 loc) • 17.5 kB
JavaScript
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}`);
}
}
}