diff --git a/packages/connect-multichain/package.json b/packages/connect-multichain/package.json index 4881d4d..e8256b4 100644 --- a/packages/connect-multichain/package.json +++ b/packages/connect-multichain/package.json @@ -62,7 +62,8 @@ "test:ci": "yarn pretest:ci && vitest run --coverage --coverage.reporter=text --silent", "test:unit": "vitest run", "test:verbose": "vitest run --reporter=verbose", - "test:watch": "vitest watch" + "test:watch": "vitest watch", + "allow-scripts": "" }, "dependencies": { "@metamask/analytics": "workspace:^", @@ -71,6 +72,7 @@ "@metamask/multichain-api-client": "^0.8.1", "@metamask/multichain-ui": "workspace:^", "@metamask/onboarding": "^1.0.1", + "@metamask/rpc-errors": "^7.0.3", "@metamask/utils": "^11.8.1", "@paulmillr/qr": "^0.2.1", "bowser": "^2.11.0", diff --git a/packages/connect-multichain/src/domain/multichain/types.ts b/packages/connect-multichain/src/domain/multichain/types.ts index 74448cd..5feab4b 100644 --- a/packages/connect-multichain/src/domain/multichain/types.ts +++ b/packages/connect-multichain/src/domain/multichain/types.ts @@ -1,5 +1,5 @@ import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; -import type { Transport, TransportRequest, TransportResponse } from '@metamask/multichain-api-client'; +import type { SessionProperties, Transport, TransportRequest, TransportResponse } from '@metamask/multichain-api-client'; import type { CaipAccountId } from '@metamask/utils'; import type { MultichainCore } from '.'; @@ -93,6 +93,7 @@ export type ExtendedTransport = Omit & { connect: (props?: { scopes: Scope[]; caipAccountIds: CaipAccountId[]; + sessionProperties?: SessionProperties; }) => Promise; sendEip1193Message: (request: TRequest, options?: { diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index e7453c2..78709ff 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -27,6 +27,7 @@ import { import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; import { getMultichainClient, + SessionProperties, type MultichainApiClient, type SessionData, } from '@metamask/multichain-api-client'; @@ -69,6 +70,7 @@ import { DefaultTransport } from './transports/default'; import { MWPTransport } from './transports/mwp'; import { keymanager } from './transports/mwp/KeyManager'; import { getDappId, openDeeplink, setupDappMetadata } from './utils'; +import { MultichainApiClientWrapperTransport } from './transports/multichainApiClientWrapper'; export { getInfuraRpcUrls } from '../domain/multichain/api/infura'; @@ -76,7 +78,9 @@ export { getInfuraRpcUrls } from '../domain/multichain/api/infura'; const logger = createLogger('metamask-sdk:core'); export class MultichainSDK extends MultichainCore { - private __provider: MultichainApiClient | undefined = undefined; + private __provider: MultichainApiClient; + + private __providerTransportWrapper: MultichainApiClientWrapperTransport; private __transport: ExtendedTransport | undefined = undefined; @@ -101,15 +105,6 @@ export class MultichainSDK extends MultichainCore { } get provider(): MultichainApiClient { - if (!this.__provider && this.__transport) { - this.__provider = getMultichainClient({ transport: this.__transport }); - return this.__provider; - } - - if (!this.__provider) { - throw new Error('Provider not initialized, establish connection first'); - } - return this.__provider; } @@ -153,6 +148,9 @@ export class MultichainSDK extends MultichainCore { }; super(allOptions); + + this.__providerTransportWrapper = new MultichainApiClientWrapperTransport(this); + this.__provider = getMultichainClient({ transport: this.__providerTransportWrapper }); } static async create(options: MultichainOptions): Promise { @@ -220,6 +218,7 @@ export class MultichainSDK extends MultichainCore { if (hasExtensionInstalled) { const apiTransport = new DefaultTransport(); this.__transport = apiTransport; + this.__providerTransportWrapper.setupNotifcationListener(); this.listener = apiTransport.onNotification( this.onTransportNotification.bind(this), ); @@ -231,6 +230,7 @@ export class MultichainSDK extends MultichainCore { const apiTransport = new MWPTransport(dappClient, kvstore); this.__dappClient = dappClient; this.__transport = apiTransport; + this.__providerTransportWrapper.setupNotifcationListener(); this.listener = apiTransport.onNotification( this.onTransportNotification.bind(this), ); @@ -309,6 +309,7 @@ export class MultichainSDK extends MultichainCore { this.__dappClient = dappClient; const apiTransport = new MWPTransport(dappClient, kvstore); this.__transport = apiTransport; + this.__providerTransportWrapper.setupNotifcationListener(); this.listener = this.transport.onNotification( this.onTransportNotification.bind(this), ); @@ -346,6 +347,7 @@ export class MultichainSDK extends MultichainCore { desktopPreferred: boolean, scopes: Scope[], caipAccountIds: CaipAccountId[], + sessionProperties?: SessionProperties, ) { // create the listener only once to avoid memory leaks this.__beforeUnloadListener ??= this.createBeforeUnloadListener(); @@ -378,7 +380,7 @@ export class MultichainSDK extends MultichainCore { ); this.transport - .connect({ scopes, caipAccountIds }) + .connect({ scopes, caipAccountIds, sessionProperties }) .then(() => { this.options.ui.factory.unload(); this.options.ui.factory.modal?.unmount(); @@ -421,12 +423,14 @@ export class MultichainSDK extends MultichainCore { this.onTransportNotification.bind(this), ); this.__transport = transport; + this.__providerTransportWrapper.setupNotifcationListener(); return transport; } private async deeplinkConnect( scopes: Scope[], caipAccountIds: CaipAccountId[], + sessionProperties?: SessionProperties, ) { return new Promise(async (resolve, reject) => { this.dappClient.on('message', (payload: any) => { @@ -486,7 +490,7 @@ export class MultichainSDK extends MultichainCore { } this.transport - .connect({ scopes, caipAccountIds }) + .connect({ scopes, caipAccountIds, sessionProperties }) .then(resolve) .catch((error) => { this.storage.removeTransport(); @@ -515,6 +519,7 @@ export class MultichainSDK extends MultichainCore { async connect( scopes: Scope[], caipAccountIds: CaipAccountId[], + sessionProperties?: SessionProperties, ): Promise { const { ui } = this.options; const platformType = getPlatformType(); @@ -527,7 +532,7 @@ export class MultichainSDK extends MultichainCore { if (this.__transport?.isConnected() && !secure) { return this.handleConnection( - this.__transport.connect({ scopes, caipAccountIds }).then(() => { + this.__transport.connect({ scopes, caipAccountIds, sessionProperties }).then(() => { if (this.__transport instanceof MWPTransport) { return this.storage.setTransport(TransportType.MPW); } else { @@ -541,7 +546,7 @@ export class MultichainSDK extends MultichainCore { if (platformType === PlatformType.MetaMaskMobileWebview) { const defaultTransport = await this.setupDefaultTransport(); return this.handleConnection( - defaultTransport.connect({ scopes, caipAccountIds }), + defaultTransport.connect({ scopes, caipAccountIds, sessionProperties }), ); } @@ -550,7 +555,7 @@ export class MultichainSDK extends MultichainCore { const defaultTransport = await this.setupDefaultTransport(); // Web transport has no initial payload return this.handleConnection( - defaultTransport.connect({ scopes, caipAccountIds }), + defaultTransport.connect({ scopes, caipAccountIds, sessionProperties }), ); } @@ -565,13 +570,13 @@ export class MultichainSDK extends MultichainCore { if (secure && !isDesktopPreferred) { // Desktop is not preferred option, so we use deeplinks (mobile web) return this.handleConnection( - this.deeplinkConnect(scopes, caipAccountIds), + this.deeplinkConnect(scopes, caipAccountIds, sessionProperties), ); } // Show install modal for RN, Web + Node return this.handleConnection( - this.showInstallModal(isDesktopPreferred, scopes, caipAccountIds), + this.showInstallModal(isDesktopPreferred, scopes, caipAccountIds, sessionProperties), ); } @@ -593,17 +598,17 @@ export class MultichainSDK extends MultichainCore { this.listener = undefined; this.__beforeUnloadListener = undefined; this.__transport = undefined; - this.__provider = undefined; + this.__providerTransportWrapper.clearNotificationCallbacks(); this.__dappClient = undefined; } async invokeMethod(request: InvokeMethodOptions): Promise { const { sdkInfo, transport, options } = this; - this.__provider ??= getMultichainClient({ transport }); const rpcClient = new RpcClient(options, sdkInfo); const requestRouter = new RequestRouter(transport, rpcClient, options); + // need read only methods for solana return requestRouter.invokeMethod(request); } diff --git a/packages/connect-multichain/src/multichain/transports/default/index.ts b/packages/connect-multichain/src/multichain/transports/default/index.ts index d299627..abf5039 100644 --- a/packages/connect-multichain/src/multichain/transports/default/index.ts +++ b/packages/connect-multichain/src/multichain/transports/default/index.ts @@ -1,6 +1,7 @@ import { type CreateSessionParams, getDefaultTransport, + SessionProperties, type Transport, type TransportRequest, type TransportResponse, @@ -188,6 +189,7 @@ export class DefaultTransport implements ExtendedTransport { async connect(options?: { scopes: Scope[]; caipAccountIds: CaipAccountId[]; + sessionProperties?: SessionProperties; }): Promise { // Ensure message listener is set up before connecting this.#setupMessageListener(); @@ -243,6 +245,7 @@ export class DefaultTransport implements ExtendedTransport { ); const createSessionParams: CreateSessionParams = { optionalScopes, + sessionProperties: options?.sessionProperties, }; const response = await this.request( { method: 'wallet_createSession', params: createSessionParams }, diff --git a/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts b/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts new file mode 100644 index 0000000..d88cf52 --- /dev/null +++ b/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts @@ -0,0 +1,150 @@ +import { CreateSessionParams, Transport, TransportRequest, TransportResponse } from "@metamask/multichain-api-client"; +import { providerErrors } from "@metamask/rpc-errors"; +import { CaipAccountId } from "@metamask/utils"; +import { InvokeMethodOptions, RPCAPI, Scope } from "src/domain"; +import { MultichainSDK } from "src/multichain"; + +// uint32 (two's complement) max +// more conservative than Number.MAX_SAFE_INTEGER +const MAX = 4_294_967_295; +let idCounter = Math.floor(Math.random() * MAX); + +const getUniqueId = (): number => { + idCounter = (idCounter + 1) % MAX; + return idCounter; +}; + +type TransportRequestWithId = TransportRequest & { id: number }; + +export class MultichainApiClientWrapperTransport implements Transport { + private requestId = getUniqueId(); + private notificationCallbacks = new Set<(data: unknown) => void>(); + constructor(private multichainSDK: MultichainSDK) { + } + + isTransportDefined(): boolean { + try { + return Boolean(this.multichainSDK.transport) + } catch (error) { + return false; + } + } + + clearNotificationCallbacks() { + this.notificationCallbacks.clear(); + } + + notifyCallbacks(data: unknown) { + this.notificationCallbacks.forEach((callback) => { + callback(data); + }); + } + + setupNotifcationListener() { + this.multichainSDK.transport.onNotification(this.notifyCallbacks.bind(this)); + } + + connect(): Promise { + // noop + return Promise.resolve(); + } + + disconnect(): Promise { + return Promise.resolve(); + } + + isConnected(): boolean { + return true + } + + async request( + params: ParamsType, + options: { timeout?: number } = {}, + ): Promise { + const id = this.requestId++; + const requestPayload = { + id, + jsonrpc: '2.0', + ...params, + }; + + if (requestPayload.method === 'wallet_createSession') { + return this.#walletCreateSession(requestPayload) as Promise; + } else if (requestPayload.method === 'wallet_getSession') { + return this.#walletGetSession(requestPayload) as Promise; + } else if (requestPayload.method === 'wallet_revokeSession') { + return this.#walletRevokeSession(requestPayload) as Promise; + } else if (requestPayload.method === 'wallet_invokeMethod') { + return this.#walletInvokeMethod(requestPayload) as Promise; + } + + throw new Error(`Unknown method: ${requestPayload.method}`); + } + + onNotification(callback: (data: unknown) => void) { + if (!this.isTransportDefined()) { + this.notificationCallbacks.add(callback); + return () => { + this.notificationCallbacks.delete(callback); + }; + } + + return this.multichainSDK.transport.onNotification(callback); + } + + async #walletCreateSession(request: TransportRequestWithId) { + const createSessionParams = request.params as CreateSessionParams; + const scopes = Object.keys({...createSessionParams.optionalScopes, ...createSessionParams.requiredScopes}) as Scope[] + const scopeAccounts: CaipAccountId[] = []; + + scopes.forEach((scope) => { + const requiredScope = createSessionParams.requiredScopes?.[scope]; + const optionalScope = createSessionParams.optionalScopes?.[scope]; + if (requiredScope) { + scopeAccounts.push(...(requiredScope.accounts ?? [])); + } + + if (optionalScope) { + scopeAccounts.push(...(optionalScope.accounts ?? [])); + } + }); + const accounts = [...new Set(scopeAccounts)]; + + + await this.multichainSDK.connect(scopes, accounts, createSessionParams.sessionProperties) + return this.multichainSDK.transport.request({ method: 'wallet_getSession' }); + } + + async #walletGetSession(request: TransportRequestWithId) { + if (!this.isTransportDefined()) { + return { + jsonrpc: '2.0', + id: request.id, + result: { + "sessionScopes": {} + } + } + } + return this.multichainSDK.transport.request({ method: 'wallet_getSession' }); + } + + async #walletRevokeSession(request: TransportRequestWithId) { + if (!this.isTransportDefined()) { + return { jsonrpc: '2.0', id: request.id, result: true }; + } + + try { + this.multichainSDK.disconnect() + return { jsonrpc: '2.0', id: request.id, result: true } + } catch (error) { + return { jsonrpc: '2.0', id: request.id, result: false } + } + } + + async #walletInvokeMethod(request: TransportRequestWithId) { + if (!this.isTransportDefined()) { + return { error: providerErrors.unauthorized() } + } + return this.multichainSDK.invokeMethod(request.params as InvokeMethodOptions) + } +} diff --git a/packages/connect-multichain/src/multichain/transports/mwp/index.ts b/packages/connect-multichain/src/multichain/transports/mwp/index.ts index 61b10f5..4870e03 100644 --- a/packages/connect-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/connect-multichain/src/multichain/transports/mwp/index.ts @@ -20,6 +20,7 @@ import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; import { type CreateSessionParams, + SessionProperties, type TransportRequest, type TransportResponse, TransportTimeoutError, @@ -319,6 +320,7 @@ export class MWPTransport implements ExtendedTransport { async connect(options?: { scopes: Scope[]; caipAccountIds: CaipAccountId[]; + sessionProperties?: SessionProperties; }): Promise { const { dappClient, kvstore } = this; const sessionStore = new SessionStore(kvstore); @@ -357,6 +359,7 @@ export class MWPTransport implements ExtendedTransport { ); const sessionRequest: CreateSessionParams = { optionalScopes, + sessionProperties: options?.sessionProperties, }; const request = { jsonrpc: '2.0', diff --git a/yarn.lock b/yarn.lock index a766f42..06a09d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4045,6 +4045,7 @@ __metadata: "@metamask/multichain-api-client": "npm:^0.8.1" "@metamask/multichain-ui": "workspace:^" "@metamask/onboarding": "npm:^1.0.1" + "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/utils": "npm:^11.8.1" "@paulmillr/qr": "npm:^0.2.1" "@react-native-async-storage/async-storage": "npm:^2.2.0" @@ -4628,7 +4629,7 @@ __metadata: languageName: node linkType: hard -"@metamask/rpc-errors@npm:^7.0.2": +"@metamask/rpc-errors@npm:^7.0.2, @metamask/rpc-errors@npm:^7.0.3": version: 7.0.3 resolution: "@metamask/rpc-errors@npm:7.0.3" dependencies: