import EventEmitter from 'events'; import log from 'loglevel'; import { captureException } from '@sentry/browser'; import { isEqual } from 'lodash'; import { CUSTODIAN_TYPES } from '@metamask-institutional/custody-keyring'; import { updateCustodianTransactions, custodianEventHandlerFactory, } from '@metamask-institutional/extension'; import { REFRESH_TOKEN_CHANGE_EVENT, INTERACTIVE_REPLACEMENT_TOKEN_CHANGE_EVENT, } from '@metamask-institutional/sdk'; import { handleMmiPortfolio, setDashboardCookie, } from '@metamask-institutional/portfolio-dashboard'; import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; import { CHAIN_IDS } from '../../../shared/constants/network'; import { BUILD_QUOTE_ROUTE, CONNECT_HARDWARE_ROUTE, } from '../../../ui/helpers/constants/routes'; import { previousValueComparator } from '../lib/util'; import { getPermissionBackgroundApiMethods } from './permissions'; export default class MMIController extends EventEmitter { constructor(opts) { super(); this.opts = opts; this.mmiConfigurationController = opts.mmiConfigurationController; this.keyringController = opts.keyringController; this.txController = opts.txController; this.securityProviderRequest = opts.securityProviderRequest; this.preferencesController = opts.preferencesController; this.appStateController = opts.appStateController; this.transactionUpdateController = opts.transactionUpdateController; this.custodyController = opts.custodyController; this.institutionalFeaturesController = opts.institutionalFeaturesController; this.getState = opts.getState; this.getPendingNonce = opts.getPendingNonce; this.accountTracker = opts.accountTracker; this.metaMetricsController = opts.metaMetricsController; this.networkController = opts.networkController; this.permissionController = opts.permissionController; this.signatureController = opts.signatureController; this.platform = opts.platform; this.extension = opts.extension; // Prepare event listener after transactionUpdateController gets initiated this.transactionUpdateController.prepareEventListener( this.custodianEventHandlerFactory.bind(this), ); // Get configuration from MMIConfig controller if (!process.env.IN_TEST) { this.mmiConfigurationController.storeConfiguration().then(() => { // This must happen after the configuration is fetched // Otherwise websockets will always be disabled in the first run this.transactionUpdateController.subscribeToEvents(); }); } this.preferencesController.store.subscribe( previousValueComparator(async (prevState, currState) => { const { identities: prevIdentities } = prevState; const { identities: currIdentities } = currState; if (isEqual(prevIdentities, currIdentities)) { return; } await this.prepareMmiPortfolio(); }, this.preferencesController.store.getState()), ); this.signatureController.hub.on( 'personal_sign:signed', async ({ signature, messageId }) => { await this.handleSigningEvents(signature, messageId, 'personal'); }, ); this.signatureController.hub.on( 'eth_signTypedData:signed', async ({ signature, messageId }) => { await this.handleSigningEvents(signature, messageId, 'v4'); }, ); } // End of constructor async persistKeyringsAfterRefreshTokenChange() { this.keyringController.persistAllKeyrings(); } async trackTransactionEventFromCustodianEvent(txMeta, event) { this.txController._trackTransactionMetricsEvent(txMeta, event); } async addKeyringIfNotExists(type) { let keyring = await this.keyringController.getKeyringsByType(type)[0]; if (!keyring) { keyring = await this.keyringController.addNewKeyring(type); } return keyring; } custodianEventHandlerFactory() { return custodianEventHandlerFactory({ log, getState: () => this.getState(), getPendingNonce: (address) => this.getPendingNonce(address), setTxHash: (txId, txHash) => this.txController.setTxHash(txId, txHash), signatureController: this.signatureController, txStateManager: this.txController.txStateManager, custodyController: this.custodyController, trackTransactionEvent: this.trackTransactionEventFromCustodianEvent.bind(this), }); } async storeCustodianSupportedChains(address) { const custodyType = this.custodyController.getCustodyTypeByAddress( toChecksumHexAddress(address), ); const keyring = await this.addKeyringIfNotExists(custodyType); const supportedChains = await keyring.getSupportedChains(address); if (supportedChains?.status === 401) { return; } const accountDetails = this.custodyController.getAccountDetails(address); await this.custodyController.storeSupportedChainsForAddress( toChecksumHexAddress(address), supportedChains, accountDetails.custodianName, ); } async onSubmitPassword() { // Create a keyring for each custodian type let addresses = []; const custodyTypes = this.custodyController.getAllCustodyTypes(); for (const type of custodyTypes) { try { const keyring = await this.addKeyringIfNotExists(type); keyring.on(REFRESH_TOKEN_CHANGE_EVENT, () => { log.info(`Refresh token change event for ${type}`); this.persistKeyringsAfterRefreshTokenChange(); }); // Trigger this event, listen to sdk, sdk change the state and then Ui is listening for the state changed keyring.on(INTERACTIVE_REPLACEMENT_TOKEN_CHANGE_EVENT, (payload) => { log.info(`Interactive refresh token change event for ${payload}`); this.appStateController.showInteractiveReplacementTokenBanner( payload, ); }); // store the supported chains for this custodian type const accounts = await keyring.getAccounts(); addresses = addresses.concat(...accounts); for (const address of accounts) { try { await this.storeCustodianSupportedChains(address); } catch (error) { captureException(error); log.error('Error while unlocking extension.', error); } } const txList = this.txController.txStateManager.getTransactions( {}, [], false, ); // Includes all transactions, but we are looping through keyrings. Currently filtering is done in updateCustodianTransactions :-/ try { updateCustodianTransactions({ keyring, type, txList, getPendingNonce: (address) => this.getPendingNonce(address), setTxHash: (txId, txHash) => this.txController.setTxHash(txId, txHash), txStateManager: this.txController.txStateManager, custodyController: this.custodyController, transactionUpdateController: this.transactionUpdateController, }); } catch (error) { log.error('Error doing offline transaction updates', error); captureException(error); } } catch (error) { log.error( `Error while unlocking extension with custody type ${type}`, error, ); captureException(error); } } try { await this.mmiConfigurationController.storeConfiguration(); } catch (error) { log.error('Error while unlocking extension.', error); } try { await this.transactionUpdateController.subscribeToEvents(); } catch (error) { log.error('Error while unlocking extension.', error); } const mmiConfigData = await this.mmiConfigurationController.store.getState(); if ( mmiConfigData && mmiConfigData.mmiConfiguration.features?.websocketApi ) { this.transactionUpdateController.getCustomerProofForAddresses(addresses); } } async connectCustodyAddresses(custodianType, custodianName, accounts) { if (!custodianType) { throw new Error('No custodian'); } const custodian = CUSTODIAN_TYPES[custodianType.toUpperCase()]; if (!custodian) { throw new Error('No such custodian'); } const newAccounts = Object.keys(accounts); // Check if any address is already added const identities = Object.keys( this.preferencesController.store.getState().identities, ); if (newAccounts.some((address) => identities.indexOf(address) !== -1)) { throw new Error('Cannot import duplicate accounts'); } const keyring = await this.addKeyringIfNotExists( custodian.keyringClass.type, ); keyring.on(REFRESH_TOKEN_CHANGE_EVENT, () => { log.info(`Refresh token change event for ${keyring.type}`); this.persistKeyringsAfterRefreshTokenChange(); }); // Trigger this event, listen to sdk, sdk change the state and then Ui is listening for the state changed keyring.on(INTERACTIVE_REPLACEMENT_TOKEN_CHANGE_EVENT, (payload) => { log.info(`Interactive refresh token change event for ${payload}`); this.appStateController.showInteractiveReplacementTokenBanner(payload); }); if (!keyring) { throw new Error('Unable to get keyring'); } const oldAccounts = await this.keyringController.getAccounts(); await keyring.setSelectedAddresses( newAccounts.map((item) => ({ address: toChecksumHexAddress(item), name: accounts[item].name, custodianDetails: accounts[item].custodianDetails, labels: accounts[item].labels, token: accounts[item].token, apiUrl: accounts[item].apiUrl, custodyType: custodian.keyringClass.type, chainId: accounts[item].chainId, })), ); this.custodyController.setAccountDetails( newAccounts.map((item) => ({ address: toChecksumHexAddress(item), name: accounts[item].name, custodianDetails: accounts[item].custodianDetails, labels: accounts[item].labels, apiUrl: accounts[item].apiUrl, custodyType: custodian.keyringClass.type, custodianName, chainId: accounts[item].chainId, })), ); newAccounts.forEach( async () => await this.keyringController.addNewAccount(keyring), ); const allAccounts = await this.keyringController.getAccounts(); this.preferencesController.setAddresses(allAccounts); const accountsToTrack = [ ...new Set(oldAccounts.concat(allAccounts.map((a) => a.toLowerCase()))), ]; allAccounts.forEach((address) => { if (!oldAccounts.includes(address.toLowerCase())) { const label = newAccounts .filter((item) => item.toLowerCase() === address) .map((item) => accounts[item].name)[0]; this.preferencesController.setAccountLabel(address, label); } }); this.accountTracker.syncWithAddresses(accountsToTrack); for (const address of newAccounts) { try { await this.storeCustodianSupportedChains(address); } catch (error) { captureException(error); } } // FIXME: status maps are not a thing anymore this.custodyController.storeCustodyStatusMap( custodian.name, keyring.getStatusMap(), ); // MMI - get a WS stream for this account const mmiConfigData = await this.mmiConfigurationController.store.getState(); if ( mmiConfigData && mmiConfigData.mmiConfiguration.features?.websocketApi ) { this.transactionUpdateController.getCustomerProofForAddresses( newAccounts, ); } return newAccounts; } async getCustodianAccounts( token, apiUrl, custodianType, getNonImportedAccounts, ) { let currentCustodyType; if (!custodianType) { const address = this.preferencesController.getSelectedAddress(); currentCustodyType = this.custodyController.getCustodyTypeByAddress( toChecksumHexAddress(address), ); } let keyring; if (custodianType) { const custodian = CUSTODIAN_TYPES[custodianType.toUpperCase()]; if (!custodian) { throw new Error('No such custodian'); } keyring = await this.addKeyringIfNotExists(custodian.keyringClass.type); } else if (currentCustodyType) { keyring = await this.addKeyringIfNotExists(currentCustodyType); } else { throw new Error('No custodian specified'); } const accounts = await keyring.getCustodianAccounts( token, apiUrl, null, getNonImportedAccounts, ); return accounts; } async getCustodianAccountsByAddress(token, apiUrl, address, custodianType) { let keyring; if (custodianType) { const custodian = CUSTODIAN_TYPES[custodianType.toUpperCase()]; if (!custodian) { throw new Error('No such custodian'); } keyring = await this.addKeyringIfNotExists(custodian.keyringClass.type); } else { throw new Error('No custodian specified'); } const accounts = await keyring.getCustodianAccounts(token, apiUrl, address); return accounts; } async getCustodianTransactionDeepLink(address, txId) { const custodyType = this.custodyController.getCustodyTypeByAddress( toChecksumHexAddress(address), ); const keyring = await this.addKeyringIfNotExists(custodyType); return keyring.getTransactionDeepLink(address, txId); } async getCustodianConfirmDeepLink(txId) { const txMeta = this.txController.txStateManager.getTransaction(txId); const address = txMeta.txParams.from; const custodyType = this.custodyController.getCustodyTypeByAddress( toChecksumHexAddress(address), ); const keyring = await this.addKeyringIfNotExists(custodyType); return { deepLink: await keyring.getTransactionDeepLink( txMeta.txParams.from, txMeta.custodyId, ), custodyId: txMeta.custodyId, }; } async getCustodianSignMessageDeepLink(from, custodyTxId) { const custodyType = this.custodyController.getCustodyTypeByAddress( toChecksumHexAddress(from), ); const keyring = await this.addKeyringIfNotExists(custodyType); return keyring.getTransactionDeepLink(from, custodyTxId); } async getCustodianToken(address) { const keyring = await this.keyringController.getKeyringForAccount(address); const { authDetails } = keyring.getAccountDetails(address); return keyring ? authDetails.jwt || authDetails.refreshToken : ''; } // Based on a custodian name, get all the tokens associated with that custodian async getCustodianJWTList(custodianName) { console.log('getCustodianJWTList', custodianName); const { identities } = this.preferencesController.store.getState(); const { mmiConfiguration } = this.mmiConfigurationController.store.getState(); const addresses = Object.keys(identities); const tokenList = []; const { custodians } = mmiConfiguration; const custodian = custodians.find((item) => item.name === custodianName); if (!custodian) { return []; } const keyrings = await this.keyringController.getKeyringsByType( `Custody - ${custodian.type}`, ); for (const address of addresses) { for (const keyring of keyrings) { // Narrow down to custodian Type const accountDetails = keyring.getAccountDetails(address); if (!accountDetails) { log.debug(`${address} does not belong to ${custodian.type} keyring`); continue; } const custodyAccountDetails = this.custodyController.getAccountDetails(address); if ( !custodyAccountDetails || custodyAccountDetails.custodianName !== custodianName ) { log.debug(`${address} does not belong to ${custodianName} keyring`); continue; } const { authDetails } = accountDetails; let token; if (authDetails.jwt) { token = authDetails.jwt; } else if (authDetails.refreshToken) { token = authDetails.refreshToken; } if (!tokenList.includes(token)) { tokenList.push(token); } } } return tokenList; } async getAllCustodianAccountsWithToken(custodyType, token) { const keyring = await this.keyringController.getKeyringsByType( `Custody - ${custodyType}`, )[0]; return keyring ? keyring.getAllAccountsWithToken(token) : []; } async setCustodianNewRefreshToken({ address, newAuthDetails }) { const custodyType = this.custodyController.getCustodyTypeByAddress( toChecksumHexAddress(address), ); const keyring = await this.addKeyringIfNotExists(custodyType); await keyring.replaceRefreshTokenAuthDetails(address, newAuthDetails); } async handleMmiCheckIfTokenIsPresent(req) { const { token, apiUrl } = req.params; const custodyType = 'Custody - JSONRPC'; // Only JSONRPC is supported for now // This can only work if the extension is unlocked await this.appStateController.getUnlockPromise(true); const keyring = await this.addKeyringIfNotExists(custodyType); return await this.custodyController.handleMmiCheckIfTokenIsPresent({ token, apiUrl, keyring, }); } async handleMmiDashboardData() { await this.appStateController.getUnlockPromise(true); const keyringAccounts = await this.keyringController.getAccounts(); const { identities } = this.preferencesController.store.getState(); const { metaMetricsId } = this.metaMetricsController.store.getState(); const getAccountDetails = (address) => this.custodyController.getAccountDetails(address); const extensionId = this.extension.runtime.id; const { networkConfigurations: networkConfigurationsById } = this.networkController.state; const networkConfigurations = Object.values(networkConfigurationsById); const networks = [ ...networkConfigurations, { chainId: CHAIN_IDS.MAINNET }, { chainId: CHAIN_IDS.GOERLI }, ]; return handleMmiPortfolio({ keyringAccounts, identities, metaMetricsId, networks, getAccountDetails, extensionId, }); } async prepareMmiPortfolio() { if (!process.env.IN_TEST) { try { const mmiDashboardData = await this.handleMmiDashboardData(); const cookieSetUrls = this.mmiConfigurationController.store.mmiConfiguration?.portfolio ?.cookieSetUrls; setDashboardCookie(mmiDashboardData, cookieSetUrls); } catch (error) { console.error(error); } } } async newUnsignedMessage(msgParams, req, version) { const updatedMsgParams = { ...msgParams, deferSetAsSigned: true }; if (req.method.includes('eth_signTypedData')) { return await this.signatureController.newUnsignedTypedMessage( updatedMsgParams, req, version, ); } else if (req.method.includes('personal_sign')) { return await this.signatureController.newUnsignedPersonalMessage( updatedMsgParams, req, ); } return await this.signatureController.newUnsignedMessage( updatedMsgParams, req, ); } async handleSigningEvents(signature, messageId, signOperation) { if (signature.custodian_transactionId) { this.transactionUpdateController.addTransactionToWatchList( signature.custodian_transactionId, signature.from, signOperation, true, ); } this.signatureController.setMessageMetadata(messageId, signature); return this.getState(); } async setAccountAndNetwork(origin, address, chainId) { await this.appStateController.getUnlockPromise(true); const selectedAddress = this.preferencesController.getSelectedAddress(); if (selectedAddress.toLowerCase() !== address.toLowerCase()) { this.preferencesController.setSelectedAddress(address); } const selectedChainId = parseInt( this.networkController.state.providerConfig.chainId, 16, ); if (selectedChainId !== chainId && chainId === 1) { await this.networkController.setProviderType('mainnet'); } else if (selectedChainId !== chainId) { const foundNetworkConfiguration = Object.values( this.networkController.state.networkConfigurations, ).find((networkConfiguration) => { return parseInt(networkConfiguration.chainId, 16) === chainId; }); if (foundNetworkConfiguration !== undefined) { await this.networkConfiguration.setActiveNetwork( foundNetworkConfiguration.id, ); } } getPermissionBackgroundApiMethods( this.permissionController, ).addPermittedAccount(origin, address); return true; } async handleMmiOpenSwaps(origin, address, chainId) { await this.setAccountAndNetwork(origin, address, chainId); this.platform.openExtensionInBrowser(BUILD_QUOTE_ROUTE); return true; } async handleMmiOpenAddHardwareWallet() { await this.appStateController.getUnlockPromise(true); this.platform.openExtensionInBrowser(CONNECT_HARDWARE_ROUTE); return true; } }