From 728ed44eb7ddddd527991161bde266a80174c275 Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Thu, 30 Nov 2023 21:44:19 +0800 Subject: [PATCH] feat: android sdk connectsign + batch request (#7921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Depends on Connection Refactor PR https://github.com/MetaMask/metamask-mobile/pull/7895 - Adds android sdk `metamask_connectSign` + `metamask_batch` - Improve loading status ## **Related issues** Fixes: # ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've clearly explained what problem this PR is solving and how it is solved. - [ ] I've linked related issues - [ ] I've included manual testing steps - [ ] I've included screenshots/recordings if applicable - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [ ] I’ve properly set the pull request status: - [ ] In case it's not yet "ready for review", I've set it to "draft". - [ ] In case it's "ready for review", I've changed it from "draft" to "non-draft". ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- app/components/Nav/App/index.js | 1 + app/core/DeeplinkManager.js | 2 +- .../SDKConnect/AndroidSDK/AndroidService.ts | 152 +++- app/core/SDKConnect/BatchRPCManager.ts | 3 + app/core/SDKConnect/Connection.ts | 723 ++---------------- app/core/SDKConnect/RPCQueueManager.ts | 22 + app/core/SDKConnect/SDKConnect.ts | 142 ++-- .../SDKConnect/handlers/checkPermissions.ts | 99 +++ .../handlers/handleBatchRpcResponse.ts | 99 +++ .../handlers/handleConnectionMessage.ts | 133 ++++ .../handlers/handleConnectionReady.ts | 196 +++++ .../handlers/handleCustomRpcCalls.ts | 88 +++ .../{ => handlers}/handleDeeplink.ts | 12 +- .../SDKConnect/handlers/handleRpcOverwrite.ts | 48 ++ .../SDKConnect/handlers/handleSendMessage.ts | 111 +++ app/core/SDKConnect/handlers/setupBridge.ts | 102 +++ app/core/SDKConnect/utils/DevLogger.ts | 2 +- app/util/Logger.ts | 11 +- 18 files changed, 1220 insertions(+), 726 deletions(-) create mode 100644 app/core/SDKConnect/handlers/checkPermissions.ts create mode 100644 app/core/SDKConnect/handlers/handleBatchRpcResponse.ts create mode 100644 app/core/SDKConnect/handlers/handleConnectionMessage.ts create mode 100644 app/core/SDKConnect/handlers/handleConnectionReady.ts create mode 100644 app/core/SDKConnect/handlers/handleCustomRpcCalls.ts rename app/core/SDKConnect/{ => handlers}/handleDeeplink.ts (90%) create mode 100644 app/core/SDKConnect/handlers/handleRpcOverwrite.ts create mode 100644 app/core/SDKConnect/handlers/handleSendMessage.ts create mode 100644 app/core/SDKConnect/handlers/setupBridge.ts diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 0756b076068..9dda1d8d49b 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -245,6 +245,7 @@ const App = ({ userLoggedIn }) => { const triggerSetCurrentRoute = (route) => { dispatch(setCurrentRoute(route)); if (route === 'Wallet' || route === 'BrowserView') { + setOnboarded(true); dispatch(setCurrentBottomNavRoute(route)); } }; diff --git a/app/core/DeeplinkManager.js b/app/core/DeeplinkManager.js index 07ac7100007..01fb82d21f7 100644 --- a/app/core/DeeplinkManager.js +++ b/app/core/DeeplinkManager.js @@ -25,7 +25,7 @@ import Engine from './Engine'; import { Minimizer } from './NativeModules'; import DevLogger from './SDKConnect/utils/DevLogger'; import WC2Manager from './WalletConnect/WalletConnectV2'; -import handleDeeplink from './SDKConnect/handleDeeplink'; +import handleDeeplink from './SDKConnect/handlers/handleDeeplink'; import Logger from '../../app/util/Logger'; class DeeplinkManager { diff --git a/app/core/SDKConnect/AndroidSDK/AndroidService.ts b/app/core/SDKConnect/AndroidSDK/AndroidService.ts index b3a0b758b4d..644994094ab 100644 --- a/app/core/SDKConnect/AndroidSDK/AndroidService.ts +++ b/app/core/SDKConnect/AndroidSDK/AndroidService.ts @@ -1,7 +1,13 @@ +import { NetworkController } from '@metamask/network-controller'; +import { Json } from '@metamask/utils'; import { EventEmitter2 } from 'eventemitter2'; import { NativeModules } from 'react-native'; import Engine from '../../Engine'; import { Minimizer } from '../../NativeModules'; +import getRpcMethodMiddleware, { + ApprovalTypes, +} from '../../RPCMethods/RPCMethodMiddleware'; +import { RPCQueueManager } from '../RPCQueueManager'; import { EventType, @@ -14,23 +20,25 @@ import AppConstants from '../../AppConstants'; import { wait, waitForAndroidServiceBinding, - waitForEmptyRPCQueue, waitForKeychainUnlocked, } from '../utils/wait.util'; import BackgroundBridge from '../../BackgroundBridge/BackgroundBridge'; -import getRpcMethodMiddleware from '../../RPCMethods/RPCMethodMiddleware'; import { DEFAULT_SESSION_TIMEOUT_MS, METHODS_TO_DELAY, - METHODS_TO_REDIRECT, SDKConnect, } from '../SDKConnect'; import { KeyringController } from '@metamask/keyring-controller'; +import { ApprovalController } from '@metamask/approval-controller'; +import { PreferencesController } from '@metamask/preferences-controller'; import { PROTOCOLS } from '../../../constants/deeplinks'; -import RPCQueueManager from '../RPCQueueManager'; +import BatchRPCManager from '../BatchRPCManager'; +import { RPC_METHODS } from '../Connection'; +import handleBatchRpcResponse from '../handlers/handleBatchRpcResponse'; +import handleCustomRpcCalls from '../handlers/handleCustomRpcCalls'; import DevLogger from '../utils/DevLogger'; import AndroidSDKEventHandler from './AndroidNativeSDKEventHandler'; import { AndroidClient } from './android-sdk-types'; @@ -41,6 +49,9 @@ export default class AndroidService extends EventEmitter2 { private rpcQueueManager = new RPCQueueManager(); private bridgeByClientId: { [clientId: string]: BackgroundBridge } = {}; private eventHandler: AndroidSDKEventHandler; + private batchRPCManager: BatchRPCManager = new BatchRPCManager('android'); + // To keep track in order to get the associated bridge to handle batch rpc calls + private currentClientId?: string; constructor() { super(); @@ -139,6 +150,13 @@ export default class AndroidService extends EventEmitter2 { try { if (!this.connectedClients?.[clientInfo.clientId]) { + DevLogger.log(`AndroidService::clients_connected - new client`); + // Ask for account permissions + await this.checkPermission({ + originatorInfo: clientInfo.originatorInfo, + channelId: clientInfo.clientId, + }); + this.setupBridge(clientInfo); // Save session to SDKConnect await SDKConnect.getInstance().addAndroidConnection({ @@ -198,6 +216,42 @@ export default class AndroidService extends EventEmitter2 { }); } + private async checkPermission({ + originatorInfo, + channelId, + }: { + originatorInfo: OriginatorInfo; + channelId: string; + }): Promise { + const approvalController = ( + Engine.context as { ApprovalController: ApprovalController } + ).ApprovalController; + + const approvalRequest = { + origin: AppConstants.MM_SDK.ANDROID_SDK, + type: ApprovalTypes.CONNECT_ACCOUNTS, + requestData: { + hostname: originatorInfo?.title ?? '', + pageMeta: { + channelId, + reconnect: false, + origin: AppConstants.MM_SDK.ANDROID_SDK, + url: originatorInfo?.url ?? '', + title: originatorInfo?.title ?? '', + icon: originatorInfo?.icon ?? '', + otps: [], + analytics: { + request_source: AppConstants.REQUEST_SOURCES.SDK_REMOTE_CONN, + request_platform: + originatorInfo?.platform ?? AppConstants.MM_SDK.UNKNOWN_PARAM, + }, + } as Json, + }, + id: channelId, + }; + return approvalController.add(approvalRequest); + } + private setupOnMessageReceivedListener() { this.eventHandler.onMessageReceived((jsonMessage: string) => { const handleEventAsync = async () => { @@ -260,11 +314,40 @@ export default class AndroidService extends EventEmitter2 { return; } + const preferencesController = ( + Engine.context as { + PreferencesController: PreferencesController; + } + ).PreferencesController; + const selectedAddress = preferencesController.state.selectedAddress; + + const networkController = ( + Engine.context as { + NetworkController: NetworkController; + } + ).NetworkController; + const networkId = networkController.state.networkId ?? 1; // default to mainnet; + // transform networkId to 0x value + const hexChainId = `0x${networkId.toString(16)}`; + + this.currentClientId = sessionId; + // Handle custom rpc method + const processedRpc = await handleCustomRpcCalls({ + batchRPCManager: this.batchRPCManager, + selectedChainId: hexChainId, + selectedAddress, + rpc: { id: data.id, method: data.method, params: data.params }, + }); + + DevLogger.log( + `AndroidService::onMessageReceived processedRpc`, + processedRpc, + ); this.rpcQueueManager.add({ - id: data.id, - method: data.method, + id: processedRpc?.id ?? data.id, + method: processedRpc?.method ?? data.method, }); - bridge.onMessage({ name: 'metamask-provider', data }); + bridge.onMessage({ name: 'metamask-provider', data: processedRpc }); }; handleEventAsync().catch((err) => { Logger.log( @@ -378,14 +461,53 @@ export default class AndroidService extends EventEmitter2 { async sendMessage(message: any, forceRedirect?: boolean) { const id = message?.data?.id; this.communicationClient.sendMessage(JSON.stringify(message)); - const rpcMethod = this.rpcQueueManager.getId(id); + let rpcMethod = this.rpcQueueManager.getId(id); + + DevLogger.log(`AndroidService::sendMessage method=${rpcMethod}`, message); + // handle multichain rpc call responses separately + const chainRPCs = this.batchRPCManager.getById(id); + if (chainRPCs) { + const isLastRpcOrError = await handleBatchRpcResponse({ + chainRpcs: chainRPCs, + msg: message, + backgroundBridge: this.bridgeByClientId[this.currentClientId ?? ''], + batchRPCManager: this.batchRPCManager, + sendMessage: ({ msg }) => this.sendMessage(msg), + }); + DevLogger.log( + `AndroidService::sendMessage isLastRpc=${isLastRpcOrError}`, + chainRPCs, + ); + + if (!isLastRpcOrError) { + DevLogger.log( + `AndroidService::sendMessage NOT last rpc --- skip goBack()`, + chainRPCs, + ); + this.rpcQueueManager.remove(id); + // Only continue processing the message and goback if all rpcs in the batch have been handled + return; + } + + // Always set the method to metamask_batch otherwise it may not have been set correctly because of the batch rpc flow. + rpcMethod = RPC_METHODS.METAMASK_BATCH; + DevLogger.log( + `Connection::sendMessage chainRPCs=${chainRPCs} COMPLETED!`, + ); + } + + this.rpcQueueManager.remove(id); if (!rpcMethod && forceRedirect !== true) { + DevLogger.log( + `Connection::sendMessage no rpc method --- rpcMethod=${rpcMethod} forceRedirect=${forceRedirect} --- skip goBack()`, + ); return; } - const needsRedirect = METHODS_TO_REDIRECT[rpcMethod]; - this.rpcQueueManager.remove(id); + const needsRedirect = this.rpcQueueManager.canRedirect({ + method: rpcMethod, + }); if (needsRedirect || forceRedirect === true) { try { @@ -393,8 +515,14 @@ export default class AndroidService extends EventEmitter2 { // Add delay to see the feedback modal await wait(1000); } - // Make sure we have replied to all messages before redirecting - await waitForEmptyRPCQueue(this.rpcQueueManager); + + if (!this.rpcQueueManager.isEmpty()) { + DevLogger.log( + `Connection::sendMessage NOT empty --- skip goBack()`, + this.rpcQueueManager.get(), + ); + return; + } Minimizer.goBack(); } catch (error) { diff --git a/app/core/SDKConnect/BatchRPCManager.ts b/app/core/SDKConnect/BatchRPCManager.ts index 75b705179a4..9a6c1914dd2 100644 --- a/app/core/SDKConnect/BatchRPCManager.ts +++ b/app/core/SDKConnect/BatchRPCManager.ts @@ -1,3 +1,5 @@ +import DevLogger from './utils/DevLogger'; + interface RPCMethod { id: string; method: string; @@ -26,6 +28,7 @@ export class BatchRPCManager { } add({ id, rpcs }: { id: string; rpcs: RPCMethod[] }) { + DevLogger.log(`BatchRPCManager::add id=${id} rpcs=`, rpcs); this.rpcChain[id] = rpcs; } diff --git a/app/core/SDKConnect/Connection.ts b/app/core/SDKConnect/Connection.ts index 0e7d9d412c0..27881483c2c 100644 --- a/app/core/SDKConnect/Connection.ts +++ b/app/core/SDKConnect/Connection.ts @@ -1,16 +1,9 @@ -import { Platform } from 'react-native'; import Logger from '../../util/Logger'; import AppConstants from '../AppConstants'; import BackgroundBridge from '../BackgroundBridge/BackgroundBridge'; import Engine from '../Engine'; -import getRpcMethodMiddleware, { - ApprovalTypes, - RPCMethodsMiddleParameters, -} from '../RPCMethods/RPCMethodMiddleware'; -import { ApprovalController } from '@metamask/approval-controller'; import { KeyringController } from '@metamask/keyring-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; import { CommunicationLayerMessage, CommunicationLayerPreference, @@ -19,31 +12,23 @@ import { OriginatorInfo, RemoteCommunication, } from '@metamask/sdk-communication-layer'; -import { Json } from '@metamask/utils'; import { NavigationContainerRef } from '@react-navigation/native'; -import { ethErrors } from 'eth-rpc-errors'; import { EventEmitter2 } from 'eventemitter2'; -import { PROTOCOLS } from '../../constants/deeplinks'; -import Routes from '../../constants/navigation/Routes'; -import { Minimizer } from '../NativeModules'; -import BatchRPCManager, { BatchRPCState } from './BatchRPCManager'; +import BatchRPCManager from './BatchRPCManager'; import RPCQueueManager from './RPCQueueManager'; import { ApprovedHosts, CONNECTION_LOADING_EVENT, - HOUR_IN_MS, - METHODS_TO_DELAY, - METHODS_TO_REDIRECT, approveHostProps, } from './SDKConnect'; +import { handleConnectionMessage } from './handlers/handleConnectionMessage'; +import handleConnectionReady from './handlers/handleConnectionReady'; import DevLogger from './utils/DevLogger'; -import generateOTP from './utils/generateOTP.util'; -import { - wait, - waitForConnectionReadiness, - waitForKeychainUnlocked, -} from './utils/wait.util'; +import { waitForKeychainUnlocked } from './utils/wait.util'; import Device from '../../util/device'; +import { Minimizer } from '../NativeModules'; +import { Platform } from 'react-native'; +import Routes from '../../../app/constants/navigation/Routes'; export interface ConnectionProps { id: string; @@ -65,6 +50,7 @@ const { version } = require('../../../package.json'); export const RPC_METHODS = { METAMASK_GETPROVIDERSTATE: 'metamask_getProviderState', METAMASK_CONNECTSIGN: 'metamask_connectSign', + METAMASK_CONNECTWITH: 'metamask_connectWith', METAMASK_BATCH: 'metamask_batch', PERSONAL_SIGN: 'personal_sign', ETH_SIGN: 'eth_sign', @@ -83,7 +69,6 @@ export const RPC_METHODS = { export class Connection extends EventEmitter2 { channelId; remote: RemoteCommunication; - requestsToRedirect: { [request: string]: boolean } = {}; origin: string; host: string; navigation?: NavigationContainerRef; @@ -122,17 +107,18 @@ export class Connection extends EventEmitter2 { */ otps?: number[]; + approvalPromise?: Promise; + /** * Should only be accesses via getter / setter. */ - private _loading = false; - private approvalPromise?: Promise; + _loading = false; - private rpcQueueManager: RPCQueueManager; + rpcQueueManager: RPCQueueManager; - private batchRPCManager: BatchRPCManager; + batchRPCManager: BatchRPCManager; - private socketServerUrl: string; + socketServerUrl: string; approveHost: ({ host, hostname }: approveHostProps) => void; getApprovedHosts: (context: string) => ApprovedHosts; @@ -202,8 +188,6 @@ export class Connection extends EventEmitter2 { this.isApproved = isApproved; this.onTerminate = onTerminate; - this.setLoading(true); - DevLogger.log( `Connection::constructor() id=${this.channelId} initialConnection=${this.initialConnection} lastAuthorized=${this.lastAuthorized} trigger=${this.trigger}`, socketServerUrl, @@ -238,38 +222,33 @@ export class Connection extends EventEmitter2 { }, }); - this.requestsToRedirect = {}; - - this.sendMessage = this.sendMessage.bind(this); - - this.remote.on(EventType.CLIENTS_CONNECTED, () => { + this.remote.on(EventType.CLIENTS_CONNECTED, async () => { DevLogger.log( `Connection::CLIENTS_CONNECTED id=${this.channelId} receivedDisconnect=${this.receivedDisconnect} origin=${this.origin}`, ); this.setLoading(true); this.receivedDisconnect = false; - // Auto hide 3seconds after keychain has unlocked if 'ready' wasn't received - const keyringController = ( - Engine.context as { KeyringController: KeyringController } - ).KeyringController; - waitForKeychainUnlocked({ keyringController }) - .then(() => { - setTimeout(() => { - if (this._loading) { - DevLogger.log( - `Connection::CLIENTS_CONNECTED auto-hide loading after 3s`, - ); - this.setLoading(false); - } - }, 3000); - }) - .catch((err) => { - Logger.log( - err, - `Connection::CLIENTS_CONNECTED error while waiting for keychain to be unlocked`, - ); - }); + try { + // Auto hide 3seconds after keychain has unlocked if 'ready' wasn't received + const keyringController = ( + Engine.context as { KeyringController: KeyringController } + ).KeyringController; + await waitForKeychainUnlocked({ keyringController }); + setTimeout(() => { + if (this._loading) { + DevLogger.log( + `Connection::CLIENTS_CONNECTED auto-hide loading after 4s`, + ); + this.setLoading(false); + } + }, 4000); + } catch (error) { + Logger.log( + error as Error, + `Connection::CLIENTS_CONNECTED error while waiting for keychain to be unlocked`, + ); + } }); this.remote.on(EventType.CLIENTS_DISCONNECTED, () => { @@ -294,7 +273,9 @@ export class Connection extends EventEmitter2 { // detect interruption of connection (can happen on mobile browser ios) - We need to warm the user to redo the connection. if (!this.receivedClientsReady && !this.remote.isPaused()) { // SOCKET CONNECTION WAS INTERRUPTED - console.warn(`dApp connection interrupted - please try again`); + console.warn( + `Connected::clients_disconnected dApp connection disconnected before ready`, + ); // Terminate to prevent bypassing initial approval when auto-reconnect on deeplink. this.disconnect({ terminate: true, context: 'CLIENTS_DISCONNECTED' }); } @@ -311,293 +292,52 @@ export class Connection extends EventEmitter2 { this.remote.on( EventType.CLIENTS_READY, async (clientsReadyMsg: { originatorInfo: OriginatorInfo }) => { - const approvalController = ( - Engine.context as { ApprovalController: ApprovalController } - ).ApprovalController; - - // clients_ready may be sent multple time (from sdk <0.2.0). - const updatedOriginatorInfo = clientsReadyMsg?.originatorInfo; - const apiVersion = updatedOriginatorInfo?.apiVersion; - this.receivedClientsReady = true; - - // backward compatibility with older sdk -- always first request approval - if (!apiVersion) { - // clear previous pending approval - if (approvalController.get(this.channelId)) { - approvalController.reject( - this.channelId, - ethErrors.provider.userRejectedRequest(), - ); - } - - this.approvalPromise = undefined; - } - - DevLogger.log( - `SDKConnect::CLIENTS_READY id=${this.channelId} apiVersion=${apiVersion}`, - ); - if (!updatedOriginatorInfo) { - return; - } - - this.originatorInfo = updatedOriginatorInfo; - updateOriginatorInfos({ - channelId: this.channelId, - originatorInfo: updatedOriginatorInfo, - }); - - if (this.isReady) { - return; - } - - // TODO following logic blocks should be simplified (too many conditions) - // Should be done in a separate PR to avoid breaking changes and separate SDKConnect / Connection logic in different files. - if ( - this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE - ) { - // Ask for authorisation? - // Always need to re-approve connection first. - await this.checkPermissions({ - lastAuthorized: this.lastAuthorized, - }); - - this.sendAuthorized(true); - } else if ( - !this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE - ) { - const currentTime = Date.now(); - - const OTPExpirationDuration = - Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; - - const channelWasActiveRecently = - !!this.lastAuthorized && - currentTime - this.lastAuthorized < OTPExpirationDuration; - - if (channelWasActiveRecently) { - this.approvalPromise = undefined; - - // Prevent auto approval if metamask is killed and restarted - disapprove(this.channelId); - - // Always need to re-approve connection first. - await this.checkPermissions({ - lastAuthorized: this.lastAuthorized, - }); - - this.sendAuthorized(true); - } else { - if (approvalController.get(this.channelId)) { - // cleaning previous pending approval - approvalController.reject( - this.channelId, - ethErrors.provider.userRejectedRequest(), - ); - } - this.approvalPromise = undefined; - - if (!this.otps) { - this.otps = generateOTP(); - } - this.sendMessage({ - type: MessageType.OTP, - otpAnswer: this.otps?.[0], - }).catch((err) => { - Logger.log(err, `SDKConnect:: Connection failed to send otp`); - }); - // Prevent auto approval if metamask is killed and restarted - disapprove(this.channelId); - - // Always need to re-approve connection first. - await this.checkPermissions(); - this.sendAuthorized(true); - this.lastAuthorized = Date.now(); - } - } else if ( - !this.initialConnection && - (this.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK || - this.trigger === 'deeplink') - ) { - // Deeplink channels are automatically approved on re-connection. - const hostname = - AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + this.channelId; - approveHost({ - host: hostname, - hostname, - context: 'clients_ready', + try { + await handleConnectionReady({ + originatorInfo: clientsReadyMsg.originatorInfo, + engine: Engine, + updateOriginatorInfos, + approveHost, + onError: (error) => { + Logger.error(error, ''); + // Redirect on deeplinks + if (this.trigger === 'deeplink') { + // Check for iOS 17 and above to use a custom modal, as Minimizer.goBack() is incompatible with these versions + if ( + Device.isIos() && + parseInt(Platform.Version as string) >= 17 + ) { + this.navigation?.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.RETURN_TO_DAPP_MODAL, + }); + } else { + Minimizer.goBack(); + } + } + }, + disapprove, + connection: this, }); - this.remote - .sendMessage({ type: 'authorized' as MessageType }) - .catch((err) => { - Logger.log(err, `Connection failed to send 'authorized`); - }); - } else if ( - this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK - ) { - // Should ask for confirmation to reconnect? - await this.checkPermissions(); - this.sendAuthorized(true); + } catch (error) { + DevLogger.log(`Connection::CLIENTS_READY error`, error); + // Send error message to user } - - this.setupBridge(updatedOriginatorInfo); - this.isReady = true; }, ); this.remote.on( EventType.MESSAGE, async (message: CommunicationLayerMessage) => { - // TODO should probably handle this in a separate EventType.TERMINATE event. - // handle termination message - if (message.type === MessageType.TERMINATE) { - // Delete connection from storage - this.onTerminate({ channelId: this.channelId }); - return; - } else if (message.type === 'ping') { - DevLogger.log(`Connection::ping id=${this.channelId}`); - return; - } - - // ignore anything other than RPC methods - if (!message.method || !message.id) { - return; - } - - const lcMethod = message.method.toLowerCase(); - let needsRedirect = METHODS_TO_REDIRECT[message?.method] ?? false; - - if (needsRedirect) { - this.requestsToRedirect[message?.id] = true; - } - - // Keep this section only for backward compatibility otherwise metamask doesn't redirect properly. - if ( - !this.originatorInfo?.apiVersion && - !needsRedirect && - // this.originatorInfo?.platform !== 'unity' && - lcMethod === RPC_METHODS.METAMASK_GETPROVIDERSTATE.toLowerCase() - ) { - // Manually force redirect if apiVersion isn't defined for backward compatibility - needsRedirect = true; - this.requestsToRedirect[message?.id] = true; - } - - // Wait for keychain to be unlocked before handling rpc calls. - const keyringController = ( - Engine.context as { KeyringController: KeyringController } - ).KeyringController; - await waitForKeychainUnlocked({ - keyringController, - context: 'connection::on_message', - }); - - this.setLoading(false); - - // Wait for bridge to be ready before handling messages. - // It will wait until user accept/reject the connection request. try { - await this.checkPermissions({ message }); - if (!this.receivedDisconnect) { - await waitForConnectionReadiness({ connection: this }); - this.sendAuthorized(); - } else { - // Reset state to continue communication after reconnection. - this.isReady = true; - this.receivedDisconnect = false; - } - } catch (error) { - // Approval failed - redirect to app with error. - this.sendMessage({ - data: { - error, - id: message.id, - jsonrpc: '2.0', - }, - name: 'metamask-provider', - }).catch(() => { - Logger.log(error, `Connection approval failed`); - }); - this.approvalPromise = undefined; - return; - } - - // Special case for metamask_connectSign - if (lcMethod === RPC_METHODS.METAMASK_CONNECTSIGN.toLowerCase()) { - // Replace with personal_sign - message.method = RPC_METHODS.PERSONAL_SIGN; - if ( - !( - message?.params && - Array.isArray(message.params) && - message.params.length > 0 - ) - ) { - throw new Error('Invalid message format'); - } - - if (Platform.OS === 'ios') { - // TODO: why does ios (older devices) requires a delay after request is initially approved? - await wait(1000); - } - // Append selected address to params - const preferencesController = ( - Engine.context as { - PreferencesController: PreferencesController; - } - ).PreferencesController; - const selectedAddress = preferencesController.state.selectedAddress; - message.params = [(message.params as string[])[0], selectedAddress]; - - Logger.log( - `metamask_connectSign selectedAddress=${selectedAddress}`, - message.params, - ); - } else if (lcMethod === RPC_METHODS.METAMASK_BATCH.toLowerCase()) { - DevLogger.log(`metamask_batch`, JSON.stringify(message, null, 2)); - if ( - !( - message?.params && - Array.isArray(message.params) && - message.params.length > 0 - ) - ) { - throw new Error('Invalid message format'); - } - const rpcs = message.params; - // Add rpcs to the batch manager - this.batchRPCManager.add({ id: message.id, rpcs }); - - // Send the first rpc method to the background bridge - const rpc = rpcs[0]; - rpc.id = message.id + `_0`; // Add index to id to keep track of the order - rpc.jsonrpc = '2.0'; - DevLogger.log( - `metamask_batch method=${rpc.method} id=${rpc.id}`, - rpc.params, - ); - - this.backgroundBridge?.onMessage({ - name: 'metamask-provider', - data: rpc, - origin: 'sdk', + await handleConnectionMessage({ + message, + engine: Engine, + connection: this, }); - - return; + } catch (error) { + Logger.error(error as Error, 'Connection not initialized'); + throw error; } - - this.rpcQueueManager.add({ - id: (message.id as string) ?? 'unknown', - method: message.method, - }); - - this.backgroundBridge?.onMessage({ - name: 'metamask-provider', - data: message, - origin: 'sdk', - }); }, ); } @@ -629,6 +369,9 @@ export class Connection extends EventEmitter2 { setLoading(loading: boolean) { this._loading = loading; + DevLogger.log( + `Connection::setLoading() id=${this.channelId} loading=${loading}`, + ); this.emit(CONNECTION_LOADING_EVENT, { loading }); } @@ -636,169 +379,9 @@ export class Connection extends EventEmitter2 { return this._loading; } - private setupBridge(originatorInfo: OriginatorInfo) { - if (this.backgroundBridge) { - return; - } - - this.backgroundBridge = new BackgroundBridge({ - webview: null, - isMMSDK: true, - // TODO: need to rewrite backgroundBridge to directly provide the origin instead of url format. - url: PROTOCOLS.METAMASK + '://' + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN, - isRemoteConn: true, - sendMessage: this.sendMessage, - getApprovedHosts: () => this.getApprovedHosts('backgroundBridge'), - remoteConnHost: this.host, - getRpcMethodMiddleware: ({ - getProviderState, - }: RPCMethodsMiddleParameters) => { - DevLogger.log( - `getRpcMethodMiddleware hostname=${this.host} url=${originatorInfo.url} `, - ); - return getRpcMethodMiddleware({ - hostname: this.host, - getProviderState, - isMMSDK: true, - navigation: null, //props.navigation, - getApprovedHosts: () => this.getApprovedHosts('rpcMethodMiddleWare'), - setApprovedHosts: (hostname: string) => { - this.approveHost({ - host: hostname, - hostname, - context: 'setApprovedHosts', - }); - }, - approveHost: (approveHostname) => - this.approveHost({ - host: this.host, - hostname: approveHostname, - context: 'rpcMethodMiddleWare', - }), - // Website info - url: { - current: originatorInfo?.url, - }, - title: { - current: originatorInfo?.title, - }, - icon: { current: originatorInfo?.icon }, - // Bookmarks - isHomepage: () => false, - // Show autocomplete - fromHomepage: { current: false }, - // Wizard - wizardScrollAdjusted: { current: false }, - tabId: '', - isWalletConnect: false, - analytics: { - isRemoteConn: true, - platform: - originatorInfo?.platform ?? AppConstants.MM_SDK.UNKNOWN_PARAM, - }, - toggleUrlModal: () => null, - injectHomePageScripts: () => null, - }); - }, - isMainFrame: true, - isWalletConnect: false, - wcRequestActions: undefined, - }); - } - - /** - * Check if current channel has been allowed. - * - * @param message - * @returns {boolean} true when host is approved or user approved the request. - * @throws error if the user reject approval request. - */ - private async checkPermissions({ - // eslint-disable-next-line - message, - lastAuthorized, - }: { - message?: CommunicationLayerMessage; - lastAuthorized?: number; - } = {}): Promise { - const OTPExpirationDuration = - Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; - - const channelWasActiveRecently = - !!lastAuthorized && Date.now() - lastAuthorized < OTPExpirationDuration; - - DevLogger.log( - `SDKConnect checkPermissions initialConnection=${this.initialConnection} lastAuthorized=${lastAuthorized} OTPExpirationDuration ${OTPExpirationDuration} channelWasActiveRecently ${channelWasActiveRecently}`, - ); - // only ask approval if needed - const approved = this.isApproved({ - channelId: this.channelId, - context: 'checkPermission', - }); - - const preferencesController = ( - Engine.context as { PreferencesController: PreferencesController } - ).PreferencesController; - const selectedAddress = preferencesController.state.selectedAddress; - - if (approved && selectedAddress) { - return true; - } - - const approvalController = ( - Engine.context as { ApprovalController: ApprovalController } - ).ApprovalController; - - if (this.approvalPromise) { - // Wait for result and clean the promise afterwards. - await this.approvalPromise; - this.approvalPromise = undefined; - return true; - } - - if (!this.initialConnection && AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { - this.revalidate({ channelId: this.channelId }); - } - - if (channelWasActiveRecently) { - return true; - } - - const approvalRequest = { - origin: this.origin, - type: ApprovalTypes.CONNECT_ACCOUNTS, - requestData: { - hostname: this.originatorInfo?.title ?? '', - pageMeta: { - channelId: this.channelId, - reconnect: !this.initialConnection, - origin: this.origin, - url: this.originatorInfo?.url ?? '', - title: this.originatorInfo?.title ?? '', - icon: this.originatorInfo?.icon ?? '', - otps: this.otps ?? [], - apiVersion: this.originatorInfo?.apiVersion, - analytics: { - request_source: AppConstants.REQUEST_SOURCES.SDK_REMOTE_CONN, - request_platform: - this.originatorInfo?.platform ?? - AppConstants.MM_SDK.UNKNOWN_PARAM, - }, - } as Json, - }, - id: this.channelId, - }; - this.approvalPromise = approvalController.add(approvalRequest); - - await this.approvalPromise; - // Clear previous permissions if already approved. - this.revalidate({ channelId: this.channelId }); - this.approvalPromise = undefined; - return true; - } - pause() { this.remote.pause(); + this.isResumed = false; } resume() { @@ -850,156 +433,4 @@ export class Connection extends EventEmitter2 { this.backgroundBridge?.onDisconnect(); this.setLoading(false); } - - async handleBatchRpcResponse({ - chainRpcs, - msg, - }: { - chainRpcs: BatchRPCState; - msg: any; - }) { - const isLastRpc = chainRpcs.index === chainRpcs.rpcs.length - 1; - const hasError = msg?.data?.error; - const origRpcId = parseInt(chainRpcs.baseId); - const result = chainRpcs.rpcs - .filter((rpc) => rpc.response !== undefined) - .map((rpc) => rpc.response); - result.push(msg?.data?.result); - - DevLogger.log( - `handleChainRpcResponse origRpcId=${origRpcId} isLastRpc=${isLastRpc} hasError=${hasError}`, - chainRpcs, - ); - - if (hasError) { - // Cancel the whole chain if any of the rpcs fails, send previous responses with current error - const data = { - id: origRpcId, - jsonrpc: '2.0', - result, - error: msg?.data?.error, - }; - const response = { - data, - name: 'metamask-provider', - }; - await this.sendMessage(response); - // Delete the chain from the chainRPCManager - this.batchRPCManager.remove(chainRpcs.baseId); - } else if (isLastRpc) { - // Respond to the original rpc call with the list of responses append the current response - DevLogger.log( - `handleChainRpcResponse id=${chainRpcs.baseId} result`, - result, - ); - const data = { - id: origRpcId, - jsonrpc: '2.0', - result, - }; - const response = { - data, - name: 'metamask-provider', - }; - await this.sendMessage(response); - // Delete the chain from the chainRPCManager - this.batchRPCManager.remove(chainRpcs.baseId); - } else { - // Save response and send the next rpc method - this.batchRPCManager.addResponse({ - id: chainRpcs.baseId, - index: chainRpcs.index, - response: msg?.data?.result, - }); - - // wait 1s before sending the next rpc method To give user time to process UI feedbacks - await wait(1000); - - // Send the next rpc method to the background bridge - const nextRpc = chainRpcs.rpcs[chainRpcs.index + 1]; - nextRpc.id = chainRpcs.baseId + `_${chainRpcs.index + 1}`; // Add index to id to keep track of the order - nextRpc.jsonrpc = '2.0'; - DevLogger.log( - `handleChainRpcResponse method=${nextRpc.method} id=${nextRpc.id}`, - nextRpc.params, - ); - - this.backgroundBridge?.onMessage({ - name: 'metamask-provider', - data: nextRpc, - origin: 'sdk', - }); - } - } - - async sendMessage(msg: any) { - const msgId = msg?.data?.id + ''; - const needsRedirect = this.requestsToRedirect[msgId] !== undefined; - const method = this.rpcQueueManager.getId(msgId); - - DevLogger.log(`Connection::sendMessage`, msg); - // handle multichain rpc call responses separately - const chainRPCs = this.batchRPCManager.getById(msgId); - if (chainRPCs) { - await this.handleBatchRpcResponse({ chainRpcs: chainRPCs, msg }); - return; - } - - if (msgId && method) { - this.rpcQueueManager.remove(msgId); - } - - this.remote.sendMessage(msg).catch((err) => { - Logger.log(err, `Connection::sendMessage failed to send`); - }); - - DevLogger.log( - `Connection::sendMessage method=${method} trigger=${this.trigger} id=${msgId} needsRedirect=${needsRedirect} origin=${this.origin}`, - ); - - if (!needsRedirect) { - return; - } - - delete this.requestsToRedirect[msgId]; - - // hide modal - this.setLoading(false); - - if (this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE) return; - - if (!this.rpcQueueManager.isEmpty()) { - DevLogger.log(`Connection::sendMessage NOT empty --- skip goBack()`); - return; - } - - if (this.trigger !== 'deeplink') { - DevLogger.log(`Connection::sendMessage NOT deeplink --- skip goBack()`); - return; - } - - try { - if (METHODS_TO_DELAY[method]) { - await wait(1200); - } - - DevLogger.log( - `Connection::sendMessage method=${method} trigger=${this.trigger} origin=${this.origin} id=${msgId} goBack()`, - ); - - // Check for iOS 17 and above to use a custom modal, as Minimizer.goBack() is incompatible with these versions - if (Device.isIos() && parseInt(Platform.Version as string) >= 17) { - this.navigation?.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { - screen: Routes.SHEET.RETURN_TO_DAPP_MODAL, - }); - } else { - await Minimizer.goBack(); - } - } catch (err) { - Logger.log( - err, - `Connection::sendMessage error while waiting for empty rpc queue`, - ); - } - } } diff --git a/app/core/SDKConnect/RPCQueueManager.ts b/app/core/SDKConnect/RPCQueueManager.ts index 531adef9b3f..1d8436e4cec 100644 --- a/app/core/SDKConnect/RPCQueueManager.ts +++ b/app/core/SDKConnect/RPCQueueManager.ts @@ -1,7 +1,11 @@ +import { METHODS_TO_REDIRECT } from './SDKConnect'; +import DevLogger from './utils/DevLogger'; + export class RPCQueueManager { private rpcQueue: { [id: string]: string } = {}; add({ id, method }: { id: string; method: string }) { + DevLogger.log(`RPCQueueManager::add id=${id} method=${method}`); this.rpcQueue[id] = method; } @@ -20,6 +24,24 @@ export class RPCQueueManager { return Object.keys(this.rpcQueue).length === 0; } + /** + * Check if the queue doesn't contains a redirectable RPC + * if it does, we can't redirect the user to the app + * + * We also pass the current rpc method as a prameters because not all message are saved inside the rpcqueue. + * For example metamask_getProviderState is sent directly to the backgroundBridge. + */ + canRedirect({ method }: { method: string }) { + const redirect = METHODS_TO_REDIRECT[method]; + Object.keys(this.rpcQueue).forEach((id) => { + const rpcMethod = this.rpcQueue[id]; + if (METHODS_TO_REDIRECT[rpcMethod]) { + return false; + } + }); + return redirect; + } + remove(id: string) { delete this.rpcQueue[id]; } diff --git a/app/core/SDKConnect/SDKConnect.ts b/app/core/SDKConnect/SDKConnect.ts index 83e66e0fa58..4648bcf0c6d 100644 --- a/app/core/SDKConnect/SDKConnect.ts +++ b/app/core/SDKConnect/SDKConnect.ts @@ -109,7 +109,7 @@ export class SDKConnect extends EventEmitter2 { private disabledHosts: ApprovedHosts = {}; private rpcqueueManager = new RPCQueueManager(); private appStateListener: NativeEventSubscription | undefined; - private socketServerUrl = AppConstants.MM_SDK.SERVER_URL; // Allow to customize different socket server url + private socketServerUrl: string = AppConstants.MM_SDK.SERVER_URL; // Allow to customize different socket server url private SDKConnect() { // Keep empty to manage singleton @@ -241,15 +241,6 @@ export class SDKConnect extends EventEmitter2 { } }); - connection.remote.on(EventType.MESSAGE, () => { - if (this.connecting[connection.channelId] === true) { - DevLogger.log( - `SDKConnect::watchConnection - done connecting - reset status.`, - ); - this.connecting[connection.channelId] = false; - } - }); - connection.on(CONNECTION_LOADING_EVENT, (event: { loading: boolean }) => { const channelId = connection.channelId; const { loading } = event; @@ -270,7 +261,17 @@ export class SDKConnect extends EventEmitter2 { loading: boolean; }) { if (loading === true) { - DevLogger.log(``); + this.sdkLoadingState[channelId] = true; + } else { + delete this.sdkLoadingState[channelId]; + } + + const loadingSessionsLen = Object.keys(this.sdkLoadingState).length; + DevLogger.log( + `SDKConnect::updateSDKLoadingState channel=${channelId} loading=${loading} loadingSessions=${loadingSessionsLen}`, + ); + if (loadingSessionsLen > 0) { + // Prevent loading state from showing if keychain is locked. const keyringController = ( Engine.context as { KeyringController: KeyringController } ).KeyringController; @@ -278,24 +279,12 @@ export class SDKConnect extends EventEmitter2 { keyringController, context: 'updateSDKLoadingState', }); - this.sdkLoadingState[channelId] = true; - } else { - delete this.sdkLoadingState[channelId]; - } - const loadingSessions = Object.keys(this.sdkLoadingState).length; - if (loadingSessions > 0) { this.navigation?.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.SHEET.SDK_LOADING, }); } else { - const currentRoute = this.navigation?.getCurrentRoute()?.name; - if (currentRoute === Routes.SHEET.SDK_LOADING) { - DevLogger.log(`updateSDKLoadingState - goBack`); - this.navigation?.goBack(); - } else { - DevLogger.log(`updateSDKLoadingState - currentRoute=${currentRoute}`); - } + await this.hideLoadingState(); } } @@ -334,23 +323,30 @@ export class SDKConnect extends EventEmitter2 { public async resume({ channelId }: { channelId: string }) { const session = this.connected[channelId]?.remote; + const alreadyResumed = this.connected[channelId].isResumed ?? false; DevLogger.log( - `SDKConnect::resume channel=${channelId} session=${session} paused=${session.isPaused()} connected=${session?.isConnected()} connecting=${ + `SDKConnect::resume channel=${channelId} alreadyResumed=${alreadyResumed} session=${session} paused=${session.isPaused()} connected=${session?.isConnected()} connecting=${ this.connecting[channelId] }`, ); - if (session && !session?.isConnected() && !this.connecting[channelId]) { + if ( + session && + !session?.isConnected() && + !alreadyResumed && + !this.connecting[channelId] + ) { this.connected[channelId].resume(); - if (Platform.OS === 'android') { - // Some devices (especially android) need time to update socket status after resuming. - await wait(500); // at least 500ms - } + await wait(500); // Some devices (especially android) need time to update socket status after resuming. DevLogger.log( `SDKConnect::_handleAppState - done resuming - socket_connected=${this.connected[ channelId ].remote.isConnected()}`, ); + } else { + DevLogger.log( + `SDKConnect::_handleAppState - SKIP - connection.resumed=${this.connected[channelId]?.isResumed}`, + ); } } @@ -369,7 +365,8 @@ export class SDKConnect extends EventEmitter2 { trigger?: ConnectionProps['trigger']; initialConnection: boolean; }) { - const existingConnection = this.connected[channelId]; + const existingConnection: Connection | undefined = + this.connected[channelId]; // Check if already connected if (existingConnection?.remote.isReady()) { @@ -402,11 +399,15 @@ export class SDKConnect extends EventEmitter2 { } } + const wasPaused = existingConnection?.remote.isPaused(); // Make sure the connection has resumed from pause before reconnecting. await waitForCondition({ fn: () => !this.paused, context: 'reconnect_from_pause', }); + if (wasPaused) { + DevLogger.log(`SDKConnect::reconnect[${context}] - not paused anymore`); + } const connecting = this.connecting[channelId] === true; const socketConnected = existingConnection?.remote.isConnected() ?? false; @@ -421,8 +422,22 @@ export class SDKConnect extends EventEmitter2 { let interruptReason = ''; - if (connecting) { + if (connecting && trigger !== 'deeplink') { + // Prioritize deeplinks -- interrup other connection attempts. interruptReason = 'already connecting'; + } else if (connecting && trigger === 'deeplink') { + // Keep comment for future reference in case android issue re-surface + // special case on android where the socket was not updated + // if (Platform.OS === 'android') { + // interruptReason = 'already connecting'; + // } else { + // console.warn(`Priotity to deeplink - overwrite previous connection`); + // this.removeChannel(channelId, true); + // } + + // This condition should not happen keeping it for debug purpose. + console.warn(`Priotity to deeplink - overwrite previous connection`); + this.removeChannel(channelId, true); } if (!this.connections[channelId]) { @@ -441,9 +456,15 @@ export class SDKConnect extends EventEmitter2 { const ready = existingConnection?.isReady; if (connected) { if (trigger) { - this.connected[channelId].setTrigger('deeplink'); + this.connected[channelId].setTrigger(trigger); } + DevLogger.log( + `SDKConnect::reconnect - already connected [connected] -- trigger updated to '${trigger}'`, + ); + return; + } + if (ready) { DevLogger.log( `SDKConnect::reconnect - already connected [ready=${ready}] -- ignoring`, ); @@ -524,6 +545,10 @@ export class SDKConnect extends EventEmitter2 { if (this.paused) return; for (const id in this.connected) { + if (!this.connected[id].remote.isReady()) { + DevLogger.log(`SDKConnect::pause - SKIP - non active connection ${id}`); + continue; + } DevLogger.log(`SDKConnect::pause - pausing ${id}`); this.connected[id].pause(); // check for paused status? @@ -535,8 +560,6 @@ export class SDKConnect extends EventEmitter2 { } this.paused = true; this.connecting = {}; - - // this.rpcqueueManager.reset(); } public async bindAndroidSDK() { @@ -544,12 +567,13 @@ export class SDKConnect extends EventEmitter2 { return; } + if (this.androidSDKBound) return; + try { - // Always bind native module to client during deeplinks otherwise connection may have an invalid status + // Always bind native module to client as early as possible otherwise connection may have an invalid status await NativeModules.CommunicationClient.bindService(); this.androidSDKBound = true; } catch (err) { - if (this.androidSDKBound) return; Logger.log(err, `SDKConnect::bindAndroiSDK failed`); } } @@ -642,7 +666,6 @@ export class SDKConnect extends EventEmitter2 { delete this.connected[channelId]; delete this.connections[channelId]; - delete this.connecting[channelId]; delete this.approvedHosts[ AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + channelId ]; @@ -662,6 +685,7 @@ export class SDKConnect extends EventEmitter2 { throw err; }); } + delete this.connecting[channelId]; this.emit('refresh'); } @@ -669,6 +693,9 @@ export class SDKConnect extends EventEmitter2 { for (const id in this.connections) { this.removeChannel(id, true); } + + // Remove all android connections + await DefaultPreference.clear(AppConstants.MM_SDK.ANDROID_CONNECTIONS); // Also remove approved hosts that may have been skipped. this.approvedHosts = {}; this.disabledHosts = {}; @@ -779,6 +806,15 @@ export class SDKConnect extends EventEmitter2 { if (this.timeout) { BackgroundTimer.clearInterval(this.timeout); } + // Android cannot process deeplinks until keychain is unlocked and we want to process deeplinks first + // so we wait for keychain to be unlocked before resuming connections. + const keyringController = ( + Engine.context as { KeyringController: KeyringController } + ).KeyringController; + await waitForKeychainUnlocked({ + keyringController, + context: 'handleAppState', + }); } else if (this.timeout) { clearTimeout(this.timeout); } @@ -791,13 +827,14 @@ export class SDKConnect extends EventEmitter2 { console.warn( `SDKConnect::_handleAppState - resuming from pause - reset connecting status`, ); + this.connecting = {}; } - const connectCount = Object.keys(this.connected).length; - if (connectCount > 0) { + const connectedCount = Object.keys(this.connected).length; + if (connectedCount > 0) { // Add delay to pioritize reconnecting from deeplink because it contains the updated connection info (channel dapp public key) - await wait(1500); + await wait(2000); DevLogger.log( - `SDKConnect::_handleAppState - resuming ${connectCount} connections`, + `SDKConnect::_handleAppState - resuming ${connectedCount} connections`, ); for (const id in this.connected) { try { @@ -877,13 +914,6 @@ export class SDKConnect extends EventEmitter2 { `SDKConnect::init()[${context}] -- already initializing -- wait for completion`, ); return await this._initializing; - // // Wait for initialization to finish. - // await waitForCondition({ - // fn: () => this._initialized, - // context: 'init', - // }); - // DevLogger.log(`SDKConnect::init() -- done waiting for initialization`); - // return; } else if (this._initialized) { DevLogger.log( `SDKConnect::init()[${context}] -- SKIP -- already initialized`, @@ -892,6 +922,12 @@ export class SDKConnect extends EventEmitter2 { return; } + if (!this.androidSDKStarted && Platform.OS === 'android') { + DevLogger.log(`SDKConnect::init() - starting android service`); + this.androidService = new AndroidService(); + this.androidSDKStarted = true; + } + const doAsyncInit = async () => { this.navigation = navigation; DevLogger.log(`SDKConnect::init()[${context}] - starting`); @@ -903,12 +939,6 @@ export class SDKConnect extends EventEmitter2 { await wait(1000); DevLogger.log(`SDKConnect::init() - waited 1000ms - keep initializing`); - if (!this.androidSDKStarted && Platform.OS === 'android') { - DevLogger.log(`SDKConnect::init() - starting android service`); - this.androidService = new AndroidService(); - this.androidSDKStarted = true; - } - try { DevLogger.log(`SDKConnect::init() - loading connections`); // On Android the DefaultPreferences will start loading after the biometrics @@ -1012,7 +1042,7 @@ export class SDKConnect extends EventEmitter2 { ); // Add delay to pioritize reconnecting from deeplink because it contains the updated connection info (channel dapp public key) - await wait(2000); + await wait(3000); await this.reconnectAll(); this._postInitialized = true; diff --git a/app/core/SDKConnect/handlers/checkPermissions.ts b/app/core/SDKConnect/handlers/checkPermissions.ts new file mode 100644 index 00000000000..17e464e2998 --- /dev/null +++ b/app/core/SDKConnect/handlers/checkPermissions.ts @@ -0,0 +1,99 @@ +import { CommunicationLayerMessage } from '@metamask/sdk-communication-layer'; +import { Connection } from '../Connection'; +import { HOUR_IN_MS } from '../SDKConnect'; +import DevLogger from '../utils/DevLogger'; +import AppConstants from '../../AppConstants'; +import { ApprovalTypes } from '../../RPCMethods/RPCMethodMiddleware'; +import { PreferencesController } from '@metamask/preferences-controller'; +import { ApprovalController } from '@metamask/approval-controller'; +import { Json } from '@metamask/utils'; +import Engine from '../../Engine'; + +// TODO: should be more generic and be used in wallet connect and android service as well +export const checkPermissions = async ({ + connection, + engine, + lastAuthorized, +}: { + connection: Connection; + engine: typeof Engine; + message?: CommunicationLayerMessage; + lastAuthorized?: number; +}) => { + const OTPExpirationDuration = + Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; + + const channelWasActiveRecently = + !!lastAuthorized && Date.now() - lastAuthorized < OTPExpirationDuration; + + DevLogger.log( + `SDKConnect checkPermissions initialConnection=${connection.initialConnection} lastAuthorized=${lastAuthorized} OTPExpirationDuration ${OTPExpirationDuration} channelWasActiveRecently ${channelWasActiveRecently}`, + ); + // only ask approval if needed + const approved = connection.isApproved({ + channelId: connection.channelId, + context: 'checkPermission', + }); + + const preferencesController = ( + engine.context as { PreferencesController: PreferencesController } + ).PreferencesController; + const selectedAddress = preferencesController.state.selectedAddress; + + if (approved && selectedAddress) { + return true; + } + + const approvalController = ( + engine.context as { ApprovalController: ApprovalController } + ).ApprovalController; + + if (connection.approvalPromise) { + // Wait for result and clean the promise afterwards. + await connection.approvalPromise; + connection.approvalPromise = undefined; + return true; + } + + if (!connection.initialConnection && AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { + connection.revalidate({ channelId: connection.channelId }); + } + + if (channelWasActiveRecently) { + return true; + } + + const approvalRequest = { + origin: connection.origin, + type: ApprovalTypes.CONNECT_ACCOUNTS, + requestData: { + hostname: connection.originatorInfo?.title ?? '', + pageMeta: { + channelId: connection.channelId, + reconnect: !connection.initialConnection, + origin: connection.origin, + url: connection.originatorInfo?.url ?? '', + title: connection.originatorInfo?.title ?? '', + icon: connection.originatorInfo?.icon ?? '', + otps: connection.otps ?? [], + apiVersion: connection.originatorInfo?.apiVersion, + analytics: { + request_source: AppConstants.REQUEST_SOURCES.SDK_REMOTE_CONN, + request_platform: + connection.originatorInfo?.platform ?? + AppConstants.MM_SDK.UNKNOWN_PARAM, + }, + } as Json, + }, + id: connection.channelId, + }; + connection.approvalPromise = approvalController.add(approvalRequest); + + await connection.approvalPromise; + // Clear previous permissions if already approved. + connection.revalidate({ channelId: connection.channelId }); + connection.approvalPromise = undefined; + return true; +}; + +export default checkPermissions; diff --git a/app/core/SDKConnect/handlers/handleBatchRpcResponse.ts b/app/core/SDKConnect/handlers/handleBatchRpcResponse.ts new file mode 100644 index 00000000000..da63dbc009a --- /dev/null +++ b/app/core/SDKConnect/handlers/handleBatchRpcResponse.ts @@ -0,0 +1,99 @@ +import BackgroundBridge from 'app/core/BackgroundBridge/BackgroundBridge'; +import BatchRPCManager, { BatchRPCState } from '../BatchRPCManager'; +import DevLogger from '../utils/DevLogger'; +import { wait } from '../utils/wait.util'; + +export const handleBatchRpcResponse = async ({ + chainRpcs, + batchRPCManager, + backgroundBridge, + msg, + sendMessage, +}: { + chainRpcs: BatchRPCState; + batchRPCManager: BatchRPCManager; + backgroundBridge?: BackgroundBridge; + sendMessage: ({ msg }: { msg: any }) => Promise; + msg: any; +}): Promise => { + const isLastRpc = chainRpcs.index === chainRpcs.rpcs.length - 1; + const hasError = msg?.data?.error; + const origRpcId = parseInt(chainRpcs.baseId); + const result = chainRpcs.rpcs + .filter((rpc) => rpc.response !== undefined) + .map((rpc) => rpc.response); + result.push(msg?.data?.result); + + DevLogger.log( + `handleBatchRpcResponse origRpcId=${origRpcId} isLastRpc=${isLastRpc} hasError=${hasError}`, + chainRpcs, + ); + + if (hasError) { + // Cancel the whole chain if any of the rpcs fails, send previous responses with current error + const data = { + id: `${origRpcId}`, + jsonrpc: '2.0', + result, + error: msg?.data?.error, + }; + const response = { + data, + name: 'metamask-provider', + }; + + // Delete the chain from the chainRPCManager + batchRPCManager.remove(chainRpcs.baseId); + + await sendMessage({ msg: response }); + } else if (isLastRpc) { + // Respond to the original rpc call with the list of responses append the current response + DevLogger.log( + `handleChainRpcResponse id=${chainRpcs.baseId} result`, + result, + ); + const data = { + id: `${origRpcId}`, + jsonrpc: '2.0', + result, + }; + const response = { + data, + name: 'metamask-provider', + }; + + // all batch have been handled can remove from the batch manager before processing it + batchRPCManager.remove(chainRpcs.baseId); + + // Process the reponse as a normal rpc call + await sendMessage({ msg: response }); + } else { + // Save response and send the next rpc method + batchRPCManager.addResponse({ + id: chainRpcs.baseId, + index: chainRpcs.index, + response: msg?.data?.result, + }); + + // wait 500ms before sending the next rpc method To give user time to process UI feedbacks + await wait(500); + + // Send the next rpc method to the background bridge + const nextRpc = chainRpcs.rpcs[chainRpcs.index + 1]; + nextRpc.id = chainRpcs.baseId + `_${chainRpcs.index + 1}`; // Add index to id to keep track of the order + nextRpc.jsonrpc = '2.0'; + DevLogger.log( + `handleChainRpcResponse method=${nextRpc.method} id=${nextRpc.id}`, + nextRpc.params, + ); + + backgroundBridge?.onMessage({ + name: 'metamask-provider', + data: nextRpc, + origin: 'sdk', + }); + } + return isLastRpc || hasError; +}; + +export default handleBatchRpcResponse; diff --git a/app/core/SDKConnect/handlers/handleConnectionMessage.ts b/app/core/SDKConnect/handlers/handleConnectionMessage.ts new file mode 100644 index 00000000000..96fc286634f --- /dev/null +++ b/app/core/SDKConnect/handlers/handleConnectionMessage.ts @@ -0,0 +1,133 @@ +import { KeyringController } from '@metamask/keyring-controller'; +import { NetworkController } from '@metamask/network-controller'; +import { PreferencesController } from '@metamask/preferences-controller'; +import { + CommunicationLayerMessage, + MessageType, +} from '@metamask/sdk-communication-layer'; +import Logger from '../../../util/Logger'; +import Engine from '../../Engine'; +import { Connection } from '../Connection'; +import DevLogger from '../utils/DevLogger'; +import { + waitForConnectionReadiness, + waitForKeychainUnlocked, +} from '../utils/wait.util'; +import checkPermissions from './checkPermissions'; +import handleCustomRpcCalls from './handleCustomRpcCalls'; +import handleSendMessage from './handleSendMessage'; + +export const handleConnectionMessage = async ({ + message, + engine, + connection, +}: { + message: CommunicationLayerMessage; + engine: typeof Engine; + connection: Connection; +}) => { + // TODO should probably handle this in a separate EventType.TERMINATE event. + // handle termination message + if (message.type === MessageType.TERMINATE) { + // Delete connection from storage + connection.onTerminate({ channelId: connection.channelId }); + return; + } else if (message.type === 'ping') { + DevLogger.log(`Connection::ping id=${connection.channelId}`); + return; + } + + // ignore anything other than RPC methods + if (!message.method || !message.id) { + DevLogger.log(`Connection::onMessage invalid message`, message); + return; + } + + DevLogger.log( + `Connection::onMessage id=${connection.channelId} method=${message.method}`, + ); + + connection.setLoading(false); + + // Wait for keychain to be unlocked before handling rpc calls. + const keyringController = ( + engine.context as { KeyringController: KeyringController } + ).KeyringController; + await waitForKeychainUnlocked({ + keyringController, + context: 'connection::on_message', + }); + + const preferencesController = ( + engine.context as { + PreferencesController: PreferencesController; + } + ).PreferencesController; + const selectedAddress = preferencesController.state.selectedAddress; + + const networkController = ( + engine.context as { + NetworkController: NetworkController; + } + ).NetworkController; + const networkId = networkController.state.networkId ?? 1; // default to mainnet; + // transform networkId to 0x value + const hexChainId = `0x${networkId.toString(16)}`; + + // Wait for bridge to be ready before handling messages. + // It will wait until user accept/reject the connection request. + try { + await checkPermissions({ message, connection, engine }); + if (!connection.receivedDisconnect) { + await waitForConnectionReadiness({ connection }); + connection.sendAuthorized(); + } else { + // Reset state to continue communication after reconnection. + connection.isReady = true; + connection.receivedDisconnect = false; + } + } catch (error) { + // Approval failed - redirect to app with error. + const msg = { + data: { + error, + id: message.id, + jsonrpc: '2.0', + }, + name: 'metamask-provider', + }; + handleSendMessage({ + msg, + connection, + }).catch(() => { + Logger.log(error, `Connection approval failed`); + }); + connection.approvalPromise = undefined; + return; + } + + const processedRpc = await handleCustomRpcCalls({ + batchRPCManager: connection.batchRPCManager, + selectedAddress, + selectedChainId: hexChainId, + rpc: { + id: message.id, + method: message.method, + params: message.params as any, + }, + }); + DevLogger.log(`[handleConnectionMessage] processedRpc`, processedRpc); + + connection.rpcQueueManager.add({ + id: processedRpc?.id ?? message.id, + method: processedRpc?.method ?? message.method, + }); + + connection.backgroundBridge?.onMessage({ + name: 'metamask-provider', + data: processedRpc, + origin: 'sdk', + }); +}; + +export default handleConnectionMessage; diff --git a/app/core/SDKConnect/handlers/handleConnectionReady.ts b/app/core/SDKConnect/handlers/handleConnectionReady.ts new file mode 100644 index 00000000000..44d1f17e380 --- /dev/null +++ b/app/core/SDKConnect/handlers/handleConnectionReady.ts @@ -0,0 +1,196 @@ +import { ApprovalController } from '@metamask/approval-controller'; +import { MessageType, OriginatorInfo } from '@metamask/sdk-communication-layer'; +import AppConstants from '../../../../app/core/AppConstants'; +import Logger from '../../../util/Logger'; +import { Connection } from '../Connection'; +import DevLogger from '../utils/DevLogger'; +import checkPermissions from './checkPermissions'; +import handleSendMessage from './handleSendMessage'; + +import { ethErrors } from 'eth-rpc-errors'; +import Engine from '../../Engine'; +import { HOUR_IN_MS, approveHostProps } from '../SDKConnect'; +import generateOTP from '../utils/generateOTP.util'; +import { setupBridge } from './setupBridge'; + +export const handleConnectionReady = async ({ + originatorInfo, + engine, + connection, + approveHost, + disapprove, + onError, + updateOriginatorInfos, +}: { + originatorInfo: OriginatorInfo; + engine: typeof Engine; + connection: Connection; + onError?: (error: unknown) => void; + approveHost: ({ host, hostname }: approveHostProps) => void; + disapprove: (channelId: string) => void; + updateOriginatorInfos: (params: { + channelId: string; + originatorInfo: OriginatorInfo; + }) => void; +}) => { + const approvalController = ( + engine.context as { ApprovalController: ApprovalController } + ).ApprovalController; + + // clients_ready may be sent multple time (from sdk <0.2.0). + const apiVersion = originatorInfo?.apiVersion; + connection.receivedClientsReady = true; + + // backward compatibility with older sdk -- always first request approval + if (!apiVersion) { + // clear previous pending approval + if (approvalController.get(connection.channelId)) { + approvalController.reject( + connection.channelId, + ethErrors.provider.userRejectedRequest(), + ); + } + + connection.approvalPromise = undefined; + } + + DevLogger.log( + `SDKConnect::CLIENTS_READY id=${connection.channelId} apiVersion=${apiVersion} origin=${connection.origin} trigger=${connection.trigger}`, + ); + if (!originatorInfo) { + return; + } + + connection.originatorInfo = originatorInfo; + updateOriginatorInfos({ + channelId: connection.channelId, + originatorInfo, + }); + DevLogger.log( + `SDKConnect::CLIENTS_READY originatorInfo updated`, + originatorInfo, + ); + + if (connection.isReady) { + DevLogger.log(`SDKConnect::CLIENTS_READY already ready`); + return; + } + + try { + // TODO following logic blocks should be simplified (too many conditions) + // Should be done in a separate PR to avoid breaking changes and separate SDKConnect / Connection logic in different files. + if ( + connection.initialConnection && + connection.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE + ) { + // Ask for authorisation? + // Always need to re-approve connection first. + await checkPermissions({ + connection, + engine, + lastAuthorized: connection.lastAuthorized, + }); + + connection.sendAuthorized(true); + } else if ( + !connection.initialConnection && + connection.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE + ) { + const currentTime = Date.now(); + + const OTPExpirationDuration = + Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; + + const channelWasActiveRecently = + !!connection.lastAuthorized && + currentTime - connection.lastAuthorized < OTPExpirationDuration; + + if (channelWasActiveRecently) { + connection.approvalPromise = undefined; + + // Prevent auto approval if metamask is killed and restarted + disapprove(connection.channelId); + + // Always need to re-approve connection first. + await checkPermissions({ + connection, + engine, + lastAuthorized: connection.lastAuthorized, + }); + + connection.sendAuthorized(true); + } else { + if (approvalController.get(connection.channelId)) { + DevLogger.log(`SDKConnect::CLIENTS_READY reject previous approval`); + // cleaning previous pending approval + approvalController.reject( + connection.channelId, + ethErrors.provider.userRejectedRequest(), + ); + } + connection.approvalPromise = undefined; + + if (!connection.otps) { + connection.otps = generateOTP(); + } + + const msg = { + type: MessageType.OTP, + otpAnswer: connection.otps?.[0], + }; + handleSendMessage({ + msg, + connection, + }).catch((err) => { + Logger.log(err, `SDKConnect:: Connection failed to send otp`); + }); + // Prevent auto approval if metamask is killed and restarted + disapprove(connection.channelId); + + // Always need to re-approve connection first. + await checkPermissions({ + connection, + engine, + }); + connection.sendAuthorized(true); + connection.lastAuthorized = Date.now(); + } + } else if ( + !connection.initialConnection && + (connection.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK || + connection.trigger === 'deeplink') + ) { + // Deeplink channels are automatically approved on re-connection. + const hostname = + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + connection.channelId; + approveHost({ + host: hostname, + hostname, + context: 'clients_ready', + }); + connection.remote + .sendMessage({ type: 'authorized' as MessageType }) + .catch((err: Error) => { + Logger.log(err, `Connection failed to send 'authorized`); + }); + } else if ( + connection.initialConnection && + connection.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK + ) { + // Should ask for confirmation to reconnect? + await checkPermissions({ connection, engine }); + connection.sendAuthorized(true); + } + + DevLogger.log(`SDKConnect::CLIENTS_READY setup bridge`); + connection.backgroundBridge = setupBridge({ + originatorInfo, + connection, + }); + connection.isReady = true; + } catch (error) { + onError?.(error); + } +}; + +export default handleConnectionReady; diff --git a/app/core/SDKConnect/handlers/handleCustomRpcCalls.ts b/app/core/SDKConnect/handlers/handleCustomRpcCalls.ts new file mode 100644 index 00000000000..0dd45fa148c --- /dev/null +++ b/app/core/SDKConnect/handlers/handleCustomRpcCalls.ts @@ -0,0 +1,88 @@ +import { Platform } from 'react-native'; +import Logger from '../../../util/Logger'; +import BatchRPCManager from '../BatchRPCManager'; +import { RPC_METHODS } from '../Connection'; +import DevLogger from '../utils/DevLogger'; +import { wait } from '../utils/wait.util'; +import overwriteRPCWith from './handleRpcOverwrite'; + +export const handleCustomRpcCalls = async ({ + rpc, + batchRPCManager, + selectedAddress, + selectedChainId, +}: { + selectedAddress: string; + selectedChainId: string; + batchRPCManager: BatchRPCManager; + rpc: { id: string; method: string; params: any[] }; +}) => { + const { id, method, params } = rpc; + const lcMethod = method.toLowerCase(); + const processedMessage = { method, id, params, jsonrpc: '2.0' }; + DevLogger.log( + `handleCustomRpcCalls selectedAddress=${selectedAddress} selectedChainId=${selectedChainId}`, + processedMessage, + ); + // Special case for metamask_connectSign + if (lcMethod === RPC_METHODS.METAMASK_CONNECTWITH.toLowerCase()) { + if (!(Array.isArray(params) && params.length > 0)) { + throw new Error('Invalid message format'); + } + + if (Platform.OS === 'ios') { + // TODO: why does ios (older devices) requires a delay after request is initially approved? + await wait(1000); + } + + const targetRpc = params[0]; + const wrapedRpc = overwriteRPCWith({ + rpc: targetRpc as any, + accountAddress: selectedAddress, + selectedChainId, + }); + processedMessage.params = wrapedRpc.params; + processedMessage.method = wrapedRpc.method; + } else if (lcMethod === RPC_METHODS.METAMASK_CONNECTSIGN.toLowerCase()) { + if (!(Array.isArray(params) && params.length > 0)) { + throw new Error('Invalid message format'); + } + + if (Platform.OS === 'ios') { + // TODO: why does ios (older devices) requires a delay after request is initially approved? + await wait(1000); + } + + processedMessage.method = RPC_METHODS.PERSONAL_SIGN; + processedMessage.params = [...params, selectedAddress]; + DevLogger.log( + `metamask_connectSign selectedAddress=${selectedAddress} id=${id}`, + processedMessage, + ); + Logger.log( + `metamask_connectSign selectedAddress=${selectedAddress}`, + params, + ); + } else if (lcMethod === RPC_METHODS.METAMASK_BATCH.toLowerCase()) { + if (!(Array.isArray(params) && params.length > 0)) { + throw new Error('Invalid message format'); + } + const rpcs = params; + // Add rpcs to the batch manager + batchRPCManager.add({ id, rpcs }); + + // Send the first rpc method to the background bridge + const batchRpc = rpcs[0]; + processedMessage.id = id + `_0`; // Add index to id to keep track of the order + processedMessage.jsonrpc = '2.0'; + processedMessage.method = batchRpc.method; + processedMessage.params = batchRpc.params; + DevLogger.log( + `handleCustomRpcCalls method=${method} id=${id}`, + processedMessage, + ); + } + return processedMessage; +}; + +export default handleCustomRpcCalls; diff --git a/app/core/SDKConnect/handleDeeplink.ts b/app/core/SDKConnect/handlers/handleDeeplink.ts similarity index 90% rename from app/core/SDKConnect/handleDeeplink.ts rename to app/core/SDKConnect/handlers/handleDeeplink.ts index 8f8e70bd31f..591028da0b9 100644 --- a/app/core/SDKConnect/handleDeeplink.ts +++ b/app/core/SDKConnect/handlers/handleDeeplink.ts @@ -1,8 +1,8 @@ -import AppConstants from '../AppConstants'; -import SDKConnect from './SDKConnect'; -import DevLogger from './utils/DevLogger'; -import { waitForCondition } from './utils/wait.util'; -import Logger from '../../util/Logger'; +import AppConstants from '../../AppConstants'; +import SDKConnect from '../SDKConnect'; +import DevLogger from '../utils/DevLogger'; +import { waitForCondition } from '../utils/wait.util'; +import Logger from '../../../util/Logger'; const QRCODE_PARAM_PATTERN = '&t=q'; @@ -91,7 +91,7 @@ const handleDeeplink = async ({ }); } } catch (error) { - Logger.error('Failed to connect to channel', error); + Logger.error(error, 'Failed to connect to channel'); } }; diff --git a/app/core/SDKConnect/handlers/handleRpcOverwrite.ts b/app/core/SDKConnect/handlers/handleRpcOverwrite.ts new file mode 100644 index 00000000000..3251bb85994 --- /dev/null +++ b/app/core/SDKConnect/handlers/handleRpcOverwrite.ts @@ -0,0 +1,48 @@ +import { RPC_METHODS } from '../Connection'; +import DevLogger from '../utils/DevLogger'; + +export const overwriteRPCWith = ({ + rpc, + accountAddress, + selectedChainId, +}: { + rpc: { method: string; params: any; [key: string]: any }; + accountAddress: string; + selectedChainId: string; +}) => { + DevLogger.log(`overwriteRPCWith:: method=${rpc?.method}`, rpc); + // Handle + if (rpc.method.toLowerCase() === RPC_METHODS.PERSONAL_SIGN.toLowerCase()) { + // Replace address value with the selected address + rpc.params = [rpc.params[0], accountAddress]; + } else if ( + rpc.method.toLowerCase() === RPC_METHODS.ETH_SENDTRANSACTION.toLowerCase() + ) { + const originalParams = rpc.params[0]; + const { from, ...rest } = originalParams; + rpc.params = [{ ...rest, from: accountAddress }]; + } else if ( + rpc.method.toLowerCase() === RPC_METHODS.ETH_SIGNTYPEDEATA.toLowerCase() + ) { + const originalParams = rpc.params[1]; + // overwrite domain.chainId + originalParams.domain.chainId = selectedChainId; + rpc.params = [accountAddress, originalParams]; + } else if ( + [ + RPC_METHODS.ETH_SIGNTYPEDEATAV4.toLowerCase(), + RPC_METHODS.ETH_SIGNTYPEDEATAV3.toLowerCase(), + ].includes(rpc.method.toLowerCase()) + ) { + const originalParams = rpc.params[1]; + // overwrite domain.chainId + originalParams.domain.chainId = selectedChainId; + rpc.params = [accountAddress, JSON.stringify(originalParams)]; + } else { + DevLogger.log(`overwriteRPCWith:: method=${rpc.method} not handled`); + } + + return rpc; +}; + +export default overwriteRPCWith; diff --git a/app/core/SDKConnect/handlers/handleSendMessage.ts b/app/core/SDKConnect/handlers/handleSendMessage.ts new file mode 100644 index 00000000000..683bc34f1f6 --- /dev/null +++ b/app/core/SDKConnect/handlers/handleSendMessage.ts @@ -0,0 +1,111 @@ +import { Platform } from 'react-native'; +import Routes from '../../../../app/constants/navigation/Routes'; +import AppConstants from '../../../../app/core/AppConstants'; +import Logger from '../../../util/Logger'; +import Device from '../../../util/device'; +import { Minimizer } from '../../NativeModules'; +import { Connection, RPC_METHODS } from '../Connection'; +import { METHODS_TO_DELAY } from '../SDKConnect'; +import DevLogger from '../utils/DevLogger'; +import { wait } from '../utils/wait.util'; +import handleBatchRpcResponse from './handleBatchRpcResponse'; + +export const handleSendMessage = async ({ + msg, + connection, +}: { + msg: any; + connection: Connection; +}) => { + DevLogger.log(`[handleSendMessage] msg`, msg); + connection.setLoading(false); + + const msgId = msg?.data?.id + ''; + let method = connection.rpcQueueManager.getId(msgId); + // handle multichain rpc call responses separately + const chainRPCs = connection.batchRPCManager.getById(msgId); + DevLogger.log(`[handleSendMessage] chainRPCs`, chainRPCs); + if (chainRPCs) { + const isLastRpcOrError = await handleBatchRpcResponse({ + chainRpcs: chainRPCs, + msg, + batchRPCManager: connection.batchRPCManager, + backgroundBridge: connection.backgroundBridge, + sendMessage: ({ msg: newmsg }: { msg: any }) => { + DevLogger.log(`[handleSendMessage] initial msg`, msg); + DevLogger.log(`[handleSendMessage] new msg`, newmsg); + return handleSendMessage({ msg: newmsg, connection }); + }, + }); + + // check if lastrpc or if an error occured during the chain + if (!isLastRpcOrError) { + // Only continue processing the message and goback if all rpcs in the batch have been handled + DevLogger.log( + `[handleSendMessage] chainRPCs=${chainRPCs} NOT COMPLETED!`, + ); + return; + } + + // Always set the method to metamask_batch otherwise it may not have been set correctly because of the batch rpc flow. + method = RPC_METHODS.METAMASK_BATCH; + DevLogger.log(`[handleSendMessage] chainRPCs=${chainRPCs} COMPLETED!`); + } + + if (msgId && method) { + connection.rpcQueueManager.remove(msgId); + } + + const canRedirect = connection.rpcQueueManager.canRedirect({ method }); + DevLogger.log( + `[handleSendMessage] method=${method} trigger=${connection.trigger} id=${msgId} origin=${connection.origin} canRedirect=${canRedirect}`, + ); + + connection.remote.sendMessage(msg).catch((err) => { + Logger.log(err, `Connection::sendMessage failed to send`); + }); + + if (connection.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE) return; + + if (!canRedirect) { + DevLogger.log( + `[handleSendMessage] canDirect=false method=${method} --- skip goBack()`, + connection.rpcQueueManager, + ); + return; + } + + if (connection.trigger !== 'deeplink') { + DevLogger.log(`[handleSendMessage] NOT deeplink --- skip goBack()`); + return; + } + + try { + if (METHODS_TO_DELAY[method]) { + await wait(1200); + } + + DevLogger.log( + `[handleSendMessage] method=${method} trigger=${connection.trigger} origin=${connection.origin} id=${msgId} goBack()`, + ); + + // Trigger should be removed changed after redirect so we don't redirect the dapp next time and go back to nothing. + connection.trigger = 'resume'; + + // Check for iOS 17 and above to use a custom modal, as Minimizer.goBack() is incompatible with these versions + if (Device.isIos() && parseInt(Platform.Version as string) >= 17) { + connection.navigation?.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.RETURN_TO_DAPP_MODAL, + }); + } else { + await Minimizer.goBack(); + } + } catch (err) { + Logger.log( + err, + `Connection::sendMessage error while waiting for empty rpc queue`, + ); + } +}; + +export default handleSendMessage; diff --git a/app/core/SDKConnect/handlers/setupBridge.ts b/app/core/SDKConnect/handlers/setupBridge.ts new file mode 100644 index 00000000000..ce8caf27c60 --- /dev/null +++ b/app/core/SDKConnect/handlers/setupBridge.ts @@ -0,0 +1,102 @@ +import AppConstants from '../../AppConstants'; +import BackgroundBridge from '../../BackgroundBridge/BackgroundBridge'; +import getRpcMethodMiddleware, { + RPCMethodsMiddleParameters, +} from '../../RPCMethods/RPCMethodMiddleware'; + +import { OriginatorInfo } from '@metamask/sdk-communication-layer'; +import { PROTOCOLS } from '../../../constants/deeplinks'; +import Logger from '../../../util/Logger'; +import { Connection } from '../Connection'; +import DevLogger from '../utils/DevLogger'; +import handleSendMessage from './handleSendMessage'; + +export const setupBridge = ({ + originatorInfo, + connection, +}: { + originatorInfo: OriginatorInfo; + connection: Connection; +}): BackgroundBridge => { + if (connection.backgroundBridge) { + DevLogger.log(`setupBridge:: backgroundBridge already exists`); + return connection.backgroundBridge; + } + + const backgroundBridge = new BackgroundBridge({ + webview: null, + isMMSDK: true, + // TODO: need to rewrite backgroundBridge to directly provide the origin instead of url format. + url: PROTOCOLS.METAMASK + '://' + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN, + isRemoteConn: true, + sendMessage: (msg: any) => { + DevLogger.log(`setupBride::sendMessage`, msg); + handleSendMessage({ + msg, + connection, + }).catch((err) => { + Logger.error(err, 'Connection::sendMessage failed to send'); + }); + }, + getApprovedHosts: () => connection.getApprovedHosts('backgroundBridge'), + remoteConnHost: connection.host, + getRpcMethodMiddleware: ({ + getProviderState, + }: RPCMethodsMiddleParameters) => { + DevLogger.log( + `getRpcMethodMiddleware hostname=${connection.host} url=${originatorInfo.url} `, + ); + return getRpcMethodMiddleware({ + hostname: connection.host, + getProviderState, + isMMSDK: true, + navigation: null, //props.navigation, + getApprovedHosts: () => + connection.getApprovedHosts('rpcMethodMiddleWare'), + setApprovedHosts: (hostname: string) => { + connection.approveHost({ + host: hostname, + hostname, + context: 'setApprovedHosts', + }); + }, + approveHost: (approveHostname) => + connection.approveHost({ + host: connection.host, + hostname: approveHostname, + context: 'rpcMethodMiddleWare', + }), + // Website info + url: { + current: originatorInfo?.url, + }, + title: { + current: originatorInfo?.title, + }, + icon: { current: undefined }, + // Bookmarks + isHomepage: () => false, + // Show autocomplete + fromHomepage: { current: false }, + // Wizard + wizardScrollAdjusted: { current: false }, + tabId: '', + isWalletConnect: false, + analytics: { + isRemoteConn: true, + platform: + originatorInfo?.platform ?? AppConstants.MM_SDK.UNKNOWN_PARAM, + }, + toggleUrlModal: () => null, + injectHomePageScripts: () => null, + }); + }, + isMainFrame: true, + isWalletConnect: false, + wcRequestActions: undefined, + }); + + return backgroundBridge; +}; + +export default setupBridge; diff --git a/app/core/SDKConnect/utils/DevLogger.ts b/app/core/SDKConnect/utils/DevLogger.ts index de1afab7a60..461a7da7696 100644 --- a/app/core/SDKConnect/utils/DevLogger.ts +++ b/app/core/SDKConnect/utils/DevLogger.ts @@ -1,4 +1,4 @@ -const DevLogger = { +export const DevLogger = { log: (...args: any[]) => { if (process.env.SDK_DEV === 'DEV') { // eslint-disable-next-line no-console diff --git a/app/util/Logger.ts b/app/util/Logger.ts index 9a1062734bf..851b6784d6a 100644 --- a/app/util/Logger.ts +++ b/app/util/Logger.ts @@ -44,12 +44,12 @@ export class AsyncLogger { /** * console.error wrapper * - * @param {Error|string|object} error - error to be logged + * @param {Error|string|unknown} error - error to be logged * @param {string|object} extra - Extra error info * @returns - void */ static async error( - error: Error | string, + error: Error | string | unknown, extra: ExtraInfo | string | any, ): Promise { if (__DEV__) { @@ -130,11 +130,14 @@ export default class Logger { /** * console.error wrapper * - * @param {Error|string|object} error - error to be logged + * @param {Error|string|unknown} error - error to be logged * @param {string|object} extra - Extra error info * @returns - void */ - static error(error: Error | string, extra: ExtraInfo | string | any) { + static error( + error: Error | string | unknown, + extra: ExtraInfo | string | any, + ) { AsyncLogger.error(error, extra).catch(() => { // ignore error but avoid dangling promises });