From 452ce41b7179a4b71c4b81e1d76b1bf79bdfca04 Mon Sep 17 00:00:00 2001 From: Amaury Martiny Date: Wed, 26 Feb 2020 17:18:44 +0100 Subject: [PATCH] Provider Injection (#253) * Add PostMessageProvider * Remove coder * Add provier in state * Add msg handling * Fix lint * Fix little bugs * Make send work * Small tweaks * Make subscriptions work * Add readme * Remove useless file * Roder alphabet * Fix lint * List & start provider * Add unsubscribe * List and Use providers * Update packages/extension/src/background/types.ts Co-Authored-By: Jaco Greeff * Update packages/extension/src/background/handlers/State.ts Co-Authored-By: Jaco Greeff * Update packages/extension/src/background/handlers/State.ts Co-Authored-By: Jaco Greeff * Update packages/extension/src/background/handlers/State.ts Co-Authored-By: Jaco Greeff * Update packages/extension/src/background/handlers/State.ts Co-Authored-By: Jaco Greeff * Update packages/extension-dapp/src/index.ts Co-Authored-By: Jaco Greeff * Update README * Remove circular dep * Remove useless function * Update README Co-authored-by: Jaco Greeff --- packages/extension-dapp/README.md | 10 +- packages/extension-dapp/src/index.ts | 28 +++- packages/extension-inject/src/types.ts | 27 +++ .../src/background/handlers/State.ts | 87 +++++++++- .../extension/src/background/handlers/Tabs.ts | 48 +++++- packages/extension/src/background/types.ts | 26 ++- packages/extension/src/page/Injected.ts | 4 + .../extension/src/page/PostMessageProvider.ts | 157 ++++++++++++++++++ 8 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 packages/extension/src/page/PostMessageProvider.ts diff --git a/packages/extension-dapp/README.md b/packages/extension-dapp/README.md index 654099c2..280bd2d6 100644 --- a/packages/extension-dapp/README.md +++ b/packages/extension-dapp/README.md @@ -13,7 +13,7 @@ A basic extractor that manipulates the `window.injectedWeb3` to retrieve all the ## Usage ```js -import { web3Accounts, web3Enable, web3FromAddress } from '@polkadot/extension-dapp'; +import { web3Accounts, web3Enable, web3FromAddress, web3ListRpcProviders, web3UseRpcProvider } from '@polkadot/extension-dapp'; // returns an array of all the injected sources // (this needs to be called first, before other requests) @@ -35,4 +35,12 @@ api.setSigner(injector.signer); api.tx.balances .transfer('5C5555yEXUcmEJ5kkcCMvdZjUo7NGJiQJMS7vZXEeoMhj3VQ', 123456) .signAndSend('5DTestUPts3kjeXSTMyerHihn1uwMfLj8vU8sqF7qYrFabHE', (status) => { ... }); + +// retrieve all the RPC providers from a particular source. this returns a map +// of string->ProviderMeta, where the string represents an unique key to identify +// a provider, and ProviderMeta shows metadata about this provider +const allProviders = web3ListRpcProviders('polkadot-js'); +// assuming one of the keys in `allProviders` is 'kusama-cc3', we can then use that provider +const { provider } = web3UseRpcProvider('polkadot-js', 'kusama-cc3'); +const head = await provider.send('chain_getHeader', []); // shows latest header ``` diff --git a/packages/extension-dapp/src/index.ts b/packages/extension-dapp/src/index.ts index 3e2e0296..de7823b1 100644 --- a/packages/extension-dapp/src/index.ts +++ b/packages/extension-dapp/src/index.ts @@ -2,7 +2,7 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { Injected, InjectedAccount, InjectedAccountWithMeta, InjectedExtension, InjectedExtensionInfo, InjectedWindow, Unsubcall } from '@polkadot/extension-inject/types'; +import { Injected, InjectedAccount, InjectedAccountWithMeta, InjectedExtension, InjectedExtensionInfo, InjectedProviderWithMeta, InjectedWindow, Unsubcall, ProviderList } from '@polkadot/extension-inject/types'; // just a helper (otherwise we cast all-over, so shorter and more readable) const win = window as Window & InjectedWindow; @@ -176,3 +176,29 @@ export async function web3FromAddress (address: string): Promise { + const { provider } = await web3FromSource(source); + + if (!provider) { + console.warn(`Extension ${source} does not expose any provider`); + + return null; + } + + return provider.listProviders(); +} + +// retrieve all providers exposed by one source +export async function web3UseRpcProvider (source: string, key: string): Promise { + const { provider } = await web3FromSource(source); + + if (!provider) { + throw new Error(`Extension ${source} does not expose any provider`); + } + + const meta = await provider.startProvider(key); + + return { provider, meta }; +} diff --git a/packages/extension-inject/src/types.ts b/packages/extension-inject/src/types.ts index b5906ce7..8495cbd9 100644 --- a/packages/extension-inject/src/types.ts +++ b/packages/extension-inject/src/types.ts @@ -3,6 +3,7 @@ // of the Apache-2.0 license. See the LICENSE file for details. import { Signer as InjectedSigner } from '@polkadot/api/types'; +import { ProviderInterface } from '@polkadot/rpc-provider/types'; // eslint-disable-next-line no-undef type This = typeof globalThis; @@ -34,9 +35,35 @@ export interface InjectedExtensionInfo { version: string; } +// Metadata about a provider +export interface ProviderMeta { + // Network of the provider + network: string; + // Light or full node + node: 'full' | 'light'; + // The extension source + source: string; + // Provider transport: 'WsProvider' etc. + transport: string; +} + +export type ProviderList = Record + +export interface InjectedProvider extends ProviderInterface { + listProviders: () => Promise; + startProvider: (key: string) => Promise; +} + +export interface InjectedProviderWithMeta { + // provider will actually always be a PostMessageProvider, which implements InjectedProvider + provider: InjectedProvider; + meta: ProviderMeta; +} + export interface Injected { accounts: InjectedAccounts; signer: InjectedSigner; + provider?: InjectedProvider; } export interface InjectedWindowProvider { diff --git a/packages/extension/src/background/handlers/State.ts b/packages/extension/src/background/handlers/State.ts index 4dfdf2d2..57b2c098 100644 --- a/packages/extension/src/background/handlers/State.ts +++ b/packages/extension/src/background/handlers/State.ts @@ -2,7 +2,10 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { AccountJson, AuthorizeRequest, RequestAuthorizeTab, RequestSign, ResponseSigning, SigningRequest } from '../types'; +import { ProviderMeta } from '@polkadot/extension-inject/types'; +import { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback } from '@polkadot/rpc-provider/types'; + +import { AccountJson, AuthorizeRequest, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning, SigningRequest, RequestRpcUnsubscribe } from '../types'; import extension from 'extensionizer'; import { BehaviorSubject } from 'rxjs'; @@ -25,6 +28,15 @@ type AuthUrls = Record; +// List of providers passed into constructor. This is the list of providers +// exposed by the extension. +type Providers = Record ProviderInterface; +}> + interface SignRequest { account: AccountJson; id: string; @@ -56,6 +68,12 @@ export default class State { readonly #authRequests: Record = {}; + // Map of providers currently injected in tabs + readonly #injectedProviders: Map = new Map(); + + // Map of all providers exposed by the extension, they are retrievable by key + readonly #providers: Providers; + readonly #signRequests: Record = {}; #windows: number[] = []; @@ -64,6 +82,10 @@ export default class State { public readonly signSubject: BehaviorSubject = new BehaviorSubject([] as SigningRequest[]); + constructor (providers: Providers = {}) { + this.#providers = providers; + } + public get hasAuthRequests (): boolean { return this.numAuthRequests === 0; } @@ -213,6 +235,69 @@ export default class State { return this.#signRequests[id]; } + // List all providers the extension is exposing + public rpcListProviders (): Promise { + return Promise.resolve(Object.keys(this.#providers).reduce((acc, key) => { + acc[key] = this.#providers[key].meta; + + return acc; + }, {} as ResponseRpcListProviders)); + } + + public rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.subscribe) before provider has been set'); + + return provider.send(request.method, request.params); + } + + // Start a provider, return its meta + public rpcStartProvider (key: string, port: chrome.runtime.Port): Promise { + assert(Object.keys(this.#providers).includes(key), `Provider ${key} is not exposed by extension`); + + if (this.#injectedProviders.get(port)) { + return Promise.resolve(this.#providers[key].meta); + } + + // Instantiate the provider + this.#injectedProviders.set(port, this.#providers[key].start()); + + // Close provider connection when page is closed + port.onDisconnect.addListener((): void => { + const provider = this.#injectedProviders.get(port); + if (provider) { + provider.disconnect(); + } + this.#injectedProviders.delete(port); + }); + + return Promise.resolve(this.#providers[key].meta); + } + + public rpcSubscribe (request: RequestRpcSubscribe, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): Promise { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.subscribe) before provider has been set'); + + return provider.subscribe( + request.type, + request.method, + request.params, + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore WsProvider gives me (error, result)=>void, whereas (result)=>void is expected + (_error, res) => { cb(res); } + ); + } + + public rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise { + const provider = this.#injectedProviders.get(port); + + assert(provider, 'Cannot call pub(rpc.unsubscribe) before provider has been set'); + + return provider.unsubscribe(request.type, request.method, request.subscriptionId); + } + public sign (url: string, request: RequestSign, account: AccountJson): Promise { const id = getId(); diff --git a/packages/extension/src/background/handlers/Tabs.ts b/packages/extension/src/background/handlers/Tabs.ts index 5a7de6d5..3160e24a 100644 --- a/packages/extension/src/background/handlers/Tabs.ts +++ b/packages/extension/src/background/handlers/Tabs.ts @@ -2,11 +2,12 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { InjectedAccount } from '@polkadot/extension-inject/types'; +import { InjectedAccount, ProviderMeta } from '@polkadot/extension-inject/types'; import { KeyringPair } from '@polkadot/keyring/types'; -import { RequestAuthorizeTab, ResponseSigning, RequestTypes, ResponseTypes, MessageTypes } from '../types'; +import { JsonRpcResponse } from '@polkadot/rpc-provider/types'; import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; +import { RequestAuthorizeTab, ResponseSigning, RequestTypes, ResponseTypes, MessageTypes, ResponseRpcListProviders, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe } from '../types'; import keyring from '@polkadot/ui-keyring'; import accountsObservable from '@polkadot/ui-keyring/observable/accounts'; @@ -72,6 +73,34 @@ export default class Tabs { return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta }); } + private rpcListProviders (): Promise { + return this.#state.rpcListProviders(); + } + + private rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise { + return this.#state.rpcSend(request, port); + } + + private rpcStartProvider (key: string, port: chrome.runtime.Port): Promise { + return this.#state.rpcStartProvider(key, port); + } + + private async rpcSubscribe (request: RequestRpcSubscribe, id: string, port: chrome.runtime.Port): Promise { + const cb = createSubscription<'pub(rpc.subscribe)'>(id, port); + const subscriptionId = await this.#state.rpcSubscribe(request, cb, port); + + port.onDisconnect.addListener((): void => { + unsubscribe(id); + this.rpcUnsubscribe({ ...request, subscriptionId }, port); + }); + + return true; + } + + private async rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise { + return this.#state.rpcUnsubscribe(request, port); + } + public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], url: string, port: chrome.runtime.Port): Promise { if (type !== 'pub(authorize.tab)') { this.#state.ensureUrlAuthorized(url); @@ -93,6 +122,21 @@ export default class Tabs { case 'pub(extrinsic.sign)': return this.extrinsicSign(url, request as SignerPayloadJSON); + case 'pub(rpc.listProviders)': + return this.rpcListProviders(); + + case 'pub(rpc.send)': + return this.rpcSend(request as RequestRpcSend, port); + + case 'pub(rpc.startProvider)': + return this.rpcStartProvider(request as string, port); + + case 'pub(rpc.subscribe)': + return this.rpcSubscribe(request as RequestRpcSubscribe, id, port); + + case 'pub(rpc.unsubscribe)': + return this.rpcUnsubscribe(request as RequestRpcUnsubscribe, port); + default: throw new Error(`Unable to handle message of type ${type}`); } diff --git a/packages/extension/src/background/types.ts b/packages/extension/src/background/types.ts index 7e95bf20..4a934997 100644 --- a/packages/extension/src/background/types.ts +++ b/packages/extension/src/background/types.ts @@ -2,7 +2,8 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. -import { InjectedAccount } from '@polkadot/extension-inject/types'; +import { InjectedAccount, ProviderList, ProviderMeta } from '@polkadot/extension-inject/types'; +import { JsonRpcResponse } from '@polkadot/rpc-provider/types'; import { KeypairType } from '@polkadot/util-crypto/types'; import { KeyringPair } from '@polkadot/keyring/types'; import { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; @@ -67,6 +68,11 @@ export interface RequestSignatures { 'pub(authorize.tab)': [RequestAuthorizeTab, null]; 'pub(bytes.sign)': [SignerPayloadRaw, ResponseSigning]; 'pub(extrinsic.sign)': [SignerPayloadJSON, ResponseSigning]; + 'pub(rpc.listProviders)': [void, ResponseRpcListProviders]; + 'pub(rpc.send)': [RequestRpcSend, JsonRpcResponse]; + 'pub(rpc.startProvider)': [string, ProviderMeta]; + 'pub(rpc.subscribe)': [RequestRpcSubscribe, number, JsonRpcResponse]; + 'pub(rpc.unsubscribe)': [RequestRpcUnsubscribe, boolean]; } export type MessageTypes = keyof RequestSignatures; @@ -133,6 +139,22 @@ export type RequestAccountList = null; export type RequestAccountSubscribe = null; +export interface RequestRpcSend { + method: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: any[]; +} + +export interface RequestRpcSubscribe extends RequestRpcSend { + type: string; +} + +export interface RequestRpcUnsubscribe { + method: string; + subscriptionId: number; + type: string; +} + export interface RequestSigningApprovePassword { id: string; password: string; @@ -204,6 +226,8 @@ export interface ResponseAccountExport { exportedJson: string; } +export type ResponseRpcListProviders = ProviderList; + // Subscriptions export type SubscriptionMessageTypes = NoUndefinedValues<{ diff --git a/packages/extension/src/page/Injected.ts b/packages/extension/src/page/Injected.ts index 1cedb8ec..26c62eb2 100644 --- a/packages/extension/src/page/Injected.ts +++ b/packages/extension/src/page/Injected.ts @@ -2,6 +2,7 @@ // This software may be modified and distributed under the terms // of the Apache-2.0 license. See the LICENSE file for details. +import PostMessageProvider from './PostMessageProvider'; import { Injected } from '@polkadot/extension-inject/types'; import { SendRequest } from './types'; @@ -11,10 +12,13 @@ import Signer from './Signer'; export default class implements Injected { public readonly accounts: Accounts; + public readonly provider: PostMessageProvider; + public readonly signer: Signer; constructor (sendRequest: SendRequest) { this.accounts = new Accounts(sendRequest); + this.provider = new PostMessageProvider(sendRequest); this.signer = new Signer(sendRequest); } } diff --git a/packages/extension/src/page/PostMessageProvider.ts b/packages/extension/src/page/PostMessageProvider.ts new file mode 100644 index 00000000..8f129c35 --- /dev/null +++ b/packages/extension/src/page/PostMessageProvider.ts @@ -0,0 +1,157 @@ +// Copyright 2019 @polkadot/extension authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { ProviderInterfaceEmitCb, ProviderInterfaceEmitted } from '@polkadot/rpc-provider/types'; +import { AnyFunction } from '@polkadot/types/types'; +import { isUndefined, logger } from '@polkadot/util'; +import EventEmitter from 'eventemitter3'; + +import { SendRequest } from './types'; +import { ProviderList, ProviderMeta, InjectedProvider } from '@polkadot/extension-inject/types'; + +const l = logger('PostMessageProvider'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CallbackHandler = (error?: null | Error, value?: any) => void; + +// Same as https://github.com/polkadot-js/api/blob/57ca9a9c3204339e1e1f693fcacc33039868dc27/packages/rpc-provider/src/ws/Provider.ts#L17 +interface SubscriptionHandler { + callback: CallbackHandler; + type: string; +} + +/** + * @name PostMessageProvider + * + * @description Extension provider to be used by dapps + */ +export default class PostMessageProvider implements InjectedProvider { + readonly #eventemitter: EventEmitter; + + readonly #sendRequest: SendRequest; + + // Subscription IDs are (historically) not guaranteed to be globally unique; + // only unique for a given subscription method; which is why we identify + // the subscriptions based on subscription id + type + readonly #subscriptions: Record = {}; // {[(type,subscriptionId)]: callback} + + /** + * @param {function} sendRequest The function to be called to send requests to the node + * @param {function} subscriptionNotificationHandler Channel for receiving subscription messages + */ + public constructor (sendRequest: SendRequest) { + this.#eventemitter = new EventEmitter(); + this.#sendRequest = sendRequest; + + // Give subscribers time to subscribe + setTimeout((): void => { + this.#eventemitter.emit('connected'); + }); + } + + /** + * @description Returns a clone of the object + */ + public clone (): PostMessageProvider { + return new PostMessageProvider(this.#sendRequest); + } + + /** + * @description Manually disconnect from the connection, clearing autoconnect logic + */ + public disconnect (): void { + // FIXME This should see if the extension's state's provider can disconnect + console.error('PostMessageProvider.disconnect() is not implemented.'); + } + + /** + * @summary `true` when this provider supports subscriptions + */ + public get hasSubscriptions (): boolean { + // FIXME This should see if the extension's state's provider has subscriptions + return true; + } + + /** + * @summary Whether the node is connected or not. + * @return {boolean} true if connected + */ + public isConnected (): boolean { + // FIXME This should see if the extension's state's provider is connected + return true; + } + + public listProviders (): Promise { + return this.#sendRequest('pub(rpc.listProviders)', undefined); + } + + /** + * @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); + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public send (method: string, params: any[], subscription?: SubscriptionHandler): Promise { + if (subscription) { + const { callback, type } = subscription; + + return this.#sendRequest( + 'pub(rpc.subscribe)', + { type, method, params }, + (res) => { + subscription.callback(null, res); + } + ).then((id): number => { + this.#subscriptions[`${type}::${id}`] = callback; + + return id; + }); + } else { + return this.#sendRequest('pub(rpc.send)', { method, params }); + } + } + + public startProvider (key: string): Promise { + return this.#sendRequest('pub(rpc.startProvider)', key); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async subscribe (type: string, method: string, params: any[], callback: AnyFunction): Promise { + const id = await this.send(method, params, { type, callback }); + + return id as number; + } + + /** + * @summary Allows unsubscribing to subscriptions made with [[subscribe]]. + */ + public async unsubscribe (type: string, method: string, id: number): 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((): string => `Unable to find active subscription=${subscription}`); + + return false; + } + + delete this.#subscriptions[subscription]; + + const result = await this.send(method, [id]); + + return result as boolean; + } +}