mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-26 12:29:06 +01:00
13faeeac2b
* listen for the networkController instead of preferencesController * type of networkConfigurations is an object * typo fix * prettier
661 lines
21 KiB
JavaScript
661 lines
21 KiB
JavaScript
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;
|
|
}
|
|
}
|