diff --git a/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap b/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap index 9e57dc2ffc7..d1dd983557a 100644 --- a/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/EditGasFee1559/__snapshots__/index.test.tsx.snap @@ -135,7 +135,40 @@ exports[`EditGasFee1559 should render correctly 1`] = ` + Low + , + "name": "low", + "topLabel": false, + }, + { + "label": + Market + , + "name": "medium", + "topLabel": false, + }, + { + "label": + Aggressive + , + "name": "high", + "topLabel": false, + }, + ] + } /> diff --git a/app/components/Views/AccountPermissions/AccountPermissions.ff-on.test.tsx b/app/components/Views/AccountPermissions/AccountPermissions.ff-on.test.tsx new file mode 100644 index 00000000000..46932be4bbc --- /dev/null +++ b/app/components/Views/AccountPermissions/AccountPermissions.ff-on.test.tsx @@ -0,0 +1,79 @@ +// Mock the networks module before any imports + +import React from 'react'; +import renderWithProvider, { + DeepPartial, +} from '../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../util/test/initial-root-state'; +import { RootState } from '../../../reducers'; +import AccountPermissions from './AccountPermissions'; + +const mockedNavigate = jest.fn(); +const mockedGoBack = jest.fn(); +const mockedTrackEvent = jest.fn(); + +// Mock navigation +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: mockedNavigate, + goBack: mockedGoBack, + setOptions: jest.fn(), + }), + }; +}); + +// Mock safe area context +jest.mock('react-native-safe-area-context', () => { + const inset = { top: 0, right: 0, bottom: 0, left: 0 }; + const frame = { width: 0, height: 0, x: 0, y: 0 }; + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaConsumer: jest + .fn() + .mockImplementation(({ children }) => children(inset)), + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + useSafeAreaFrame: jest.fn().mockImplementation(() => frame), + }; +}); + +// Mock metrics +jest.mock('../../../components/hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockedTrackEvent, + }), +})); + +jest.mock('../../../util/networks/index.js', () => ({ + ...jest.requireActual('../../../util/networks/index.js'), + isMultichainVersion1Enabled: true, + isChainPermissionsFeatureEnabled: true, +})); + +const mockInitialState: DeepPartial = { + settings: {}, + engine: { + backgroundState: { + ...backgroundState, + }, + }, +}; + +describe('AccountPermissions with feature flags ON', () => { + it('should render AccountPermissions with multichain and chain permissions enabled', () => { + const { toJSON } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(toJSON()).toMatchSnapshot('AccountPermissions-FeatureFlagsOn'); + }); +}); diff --git a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx index dbe74031c16..13073b9ce52 100644 --- a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx @@ -27,6 +27,12 @@ jest.mock('../../../components/hooks/useMetrics', () => ({ }), })); +jest.mock('../../../util/networks/index.js', () => ({ + ...jest.requireActual('../../../util/networks/index.js'), + isMultichainVersion1Enabled: false, + isChainPermissionsFeatureEnabled: false, +})); + jest.mock('react-native-safe-area-context', () => { const inset = { top: 0, right: 0, bottom: 0, left: 0 }; const frame = { width: 0, height: 0, x: 0, y: 0 }; diff --git a/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap b/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap new file mode 100644 index 00000000000..120a251bf7c --- /dev/null +++ b/app/components/Views/AccountPermissions/__snapshots__/AccountPermissions.ff-on.test.tsx.snap @@ -0,0 +1,309 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountPermissions with feature flags ON should render AccountPermissions with multichain and chain permissions enabled: AccountPermissions-FeatureFlagsOn 1`] = ` + + + + + + + + + + + + + + + test + + + + + Connected accounts + + + + + + + + + + Connect more accounts + + + + + + + Manage Permissions + + + + + +`; diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts index 154e22a954a..51c67f10736 100644 --- a/app/core/AppConstants.ts +++ b/app/core/AppConstants.ts @@ -88,7 +88,7 @@ export default { ONLY_MAINNET: true, CLIENT_ID: 'mobile', LIVENESS_POLLING_FREQUENCY: 5 * 60 * 1000, - POLL_COUNT_LIMIT: 3, + POLL_COUNT_LIMIT: 4, DEFAULT_SLIPPAGE: 2, CACHE_AGGREGATOR_METADATA_THRESHOLD: 5 * 60 * 1000, CACHE_TOKENS_THRESHOLD: 5 * 60 * 1000, diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index d94a67b96b0..46619417a86 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -62,7 +62,7 @@ import { } from '@metamask/snaps-controllers'; import { WebViewExecutionService } from '@metamask/snaps-controllers/react-native'; -import { NotificationParameters } from '@metamask/snaps-rpc-methods/dist/restricted/notify.cjs'; +import { NotificationArgs } from '@metamask/snaps-rpc-methods/dist/restricted/notify.cjs'; import { getSnapsWebViewPromise } from '../../lib/snaps'; import { buildSnapEndowmentSpecifications, @@ -633,7 +633,7 @@ export class Engine { type, requestData: { content, placeholder }, }), - showInAppNotification: (origin: string, args: NotificationParameters) => { + showInAppNotification: (origin: string, args: NotificationArgs) => { Logger.log( 'Snaps/ showInAppNotification called with args: ', args, @@ -696,7 +696,6 @@ export class Engine { includeStakedAssets: isPooledStakingFeatureEnabled(), }); const permissionController = new PermissionController({ - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. messenger: this.controllerMessenger.getRestricted({ name: 'PermissionController', allowedActions: [ @@ -788,7 +787,6 @@ export class Engine { ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) this.subjectMetadataController = new SubjectMetadataController({ - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. messenger: this.controllerMessenger.getRestricted({ name: 'SubjectMetadataController', allowedActions: [`${permissionController.name}:hasPermissions`], @@ -838,7 +836,6 @@ export class Engine { const requireAllowlist = process.env.METAMASK_BUILD_TYPE === 'main'; const disableSnapInstallation = process.env.METAMASK_BUILD_TYPE === 'main'; const allowLocalSnaps = process.env.METAMASK_BUILD_TYPE === 'flask'; - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. const snapsRegistryMessenger: SnapsRegistryMessenger = this.controllerMessenger.getRestricted({ name: 'SnapsRegistry', @@ -858,7 +855,6 @@ export class Engine { }); this.snapExecutionService = new WebViewExecutionService({ - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. messenger: this.controllerMessenger.getRestricted({ name: 'ExecutionService', allowedActions: [], @@ -1379,6 +1375,7 @@ export class Engine { ], allowedEvents: [], }), + pollCountLimit: AppConstants.SWAPS.POLL_COUNT_LIMIT, // TODO: Remove once GasFeeController exports this action type fetchGasFeeEstimates: () => gasFeeController.fetchGasFeeEstimates(), // @ts-expect-error TODO: Resolve mismatch between gas fee and swaps controller types diff --git a/app/core/RPCMethods/RPCMethodMiddleware.test.ts b/app/core/RPCMethods/RPCMethodMiddleware.test.ts index 4f46dc579f3..9f02e4ae2f5 100644 --- a/app/core/RPCMethods/RPCMethodMiddleware.test.ts +++ b/app/core/RPCMethods/RPCMethodMiddleware.test.ts @@ -390,7 +390,6 @@ describe('getRpcMethodMiddleware', () => { }, ]); const permissionController = new PermissionController({ - // @ts-expect-error TODO: Resolve mismatch between base-controller versions. messenger: controllerMessenger.getRestricted({ name: 'PermissionController', allowedActions: [], diff --git a/app/util/importAdditionalAccounts.js b/app/util/importAdditionalAccounts.js deleted file mode 100644 index 9b015ce661b..00000000000 --- a/app/util/importAdditionalAccounts.js +++ /dev/null @@ -1,60 +0,0 @@ -import Engine from '../core/Engine'; -import { BNToHex } from '../util/number'; -import Logger from '../util/Logger'; -import ExtendedKeyringTypes from '../../app/constants/keyringTypes'; - -const HD_KEY_TREE_ERROR = 'MetamaskController - No HD Key Tree found'; -const ZERO_BALANCE = '0x0'; -const MAX = 20; - -/** - * Get an account balance from the network. - * @param {string} address - The account address - * @param {EthQuery} ethQuery - The EthQuery instance to use when asking the network - */ -const getBalance = async (address, ethQuery) => - new Promise((resolve, reject) => { - ethQuery.getBalance(address, (error, balance) => { - if (error) { - reject(error); - Logger.error(error); - } else { - const balanceHex = BNToHex(balance); - resolve(balanceHex || ZERO_BALANCE); - } - }); - }); - -/** - * Add additional accounts in the wallet based on balance - */ -export default async () => { - const { KeyringController } = Engine.context; - - const ethQuery = Engine.getGlobalEthQuery(); - let accounts = await KeyringController.getAccounts(); - let lastBalance = await getBalance(accounts[accounts.length - 1], ethQuery); - - const { keyrings } = KeyringController.state; - const filteredKeyrings = keyrings.filter( - (keyring) => keyring.type === ExtendedKeyringTypes.hd, - ); - const primaryKeyring = filteredKeyrings[0]; - if (!primaryKeyring) throw new Error(HD_KEY_TREE_ERROR); - - let i = 0; - // seek out the first zero balance - while (lastBalance !== ZERO_BALANCE) { - if (i === MAX) break; - await KeyringController.addNewAccountWithoutUpdate(primaryKeyring); - accounts = await KeyringController.getAccounts(); - lastBalance = await getBalance(accounts[accounts.length - 1], ethQuery); - i++; - } - - // remove extra zero balance account potentially created from seeking ahead - if (accounts.length > 1 && lastBalance === ZERO_BALANCE) { - await KeyringController.removeAccount(accounts[accounts.length - 1]); - accounts = await KeyringController.getAccounts(); - } -}; diff --git a/app/util/importAdditionalAccounts.test.ts b/app/util/importAdditionalAccounts.test.ts new file mode 100644 index 00000000000..bc72c072edd --- /dev/null +++ b/app/util/importAdditionalAccounts.test.ts @@ -0,0 +1,111 @@ +import importAdditionalAccounts from './importAdditionalAccounts'; +import { BN } from 'ethereumjs-util'; + +const mockKeyring = { + addAccounts: jest.fn(), + removeAccount: jest.fn(), +}; + +const mockEthQuery = { + getBalance: jest.fn(), +}; + +jest.mock('../core/Engine', () => ({ + context: { + KeyringController: { + withKeyring: jest.fn((_keyring, callback) => callback(mockKeyring)), + }, + }, + getGlobalEthQuery: () => mockEthQuery, +})); + +/** + * Set the balance that will be queried for the account + * + * @param balance - The balance to be queried + */ +function setQueriedBalance(balance: BN) { + mockEthQuery.getBalance.mockImplementation((_, callback) => + callback(null, balance), + ); +} + +/** + * Set the balance that will be queried for the account once + * + * @param balance - The balance to be queried + */ +function setQueriedBalanceOnce(balance: BN) { + mockEthQuery.getBalance.mockImplementationOnce((_, callback) => { + callback(null, balance); + }); +} + +describe('importAdditionalAccounts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when there is no account with balance', () => { + it('should not add any account', async () => { + setQueriedBalance(new BN(0)); + mockKeyring.addAccounts.mockResolvedValue(['0x1234']); + + await importAdditionalAccounts(); + + expect(mockKeyring.addAccounts).toHaveBeenCalledTimes(1); + expect(mockKeyring.removeAccount).toHaveBeenCalledTimes(1); + expect(mockKeyring.removeAccount).toHaveBeenCalledWith('0x1234'); + }); + }); + + describe('when there is an account with balance', () => { + it('should add 1 account', async () => { + setQueriedBalanceOnce(new BN(1)); + setQueriedBalanceOnce(new BN(0)); + mockKeyring.addAccounts + .mockResolvedValueOnce(['0x1234']) + .mockResolvedValueOnce(['0x5678']); + + await importAdditionalAccounts(); + + expect(mockKeyring.addAccounts).toHaveBeenCalledTimes(2); + expect(mockKeyring.removeAccount).toHaveBeenCalledWith('0x5678'); + }); + }); + + describe('when there are multiple accounts with balance', () => { + it('should add 2 accounts', async () => { + setQueriedBalanceOnce(new BN(1)); + setQueriedBalanceOnce(new BN(2)); + setQueriedBalanceOnce(new BN(0)); + mockKeyring.addAccounts + .mockResolvedValueOnce(['0x1234']) + .mockResolvedValueOnce(['0x5678']) + .mockResolvedValueOnce(['0x9abc']); + + await importAdditionalAccounts(); + + expect(mockKeyring.addAccounts).toHaveBeenCalledTimes(3); + expect(mockKeyring.removeAccount).toHaveBeenCalledWith('0x9abc'); + }); + }); + + describe('when ethQuery.getBalance throws an error', () => { + it('should not remove all the accounts', async () => { + setQueriedBalanceOnce(new BN(1)); + mockEthQuery.getBalance.mockImplementationOnce((_, callback) => + callback(new Error('error')), + ); + mockKeyring.addAccounts + .mockResolvedValueOnce(['0x1234']) + .mockResolvedValueOnce(['0x5678']); + + await importAdditionalAccounts(); + + expect(mockKeyring.addAccounts).toHaveBeenCalledTimes(2); + expect(mockKeyring.removeAccount).toHaveBeenCalledTimes(1); + expect(mockKeyring.removeAccount).toHaveBeenCalledWith('0x5678'); + }); + }); +}); diff --git a/app/util/importAdditionalAccounts.ts b/app/util/importAdditionalAccounts.ts new file mode 100644 index 00000000000..8da7200d9f4 --- /dev/null +++ b/app/util/importAdditionalAccounts.ts @@ -0,0 +1,60 @@ +import Engine from '../core/Engine'; +import { BNToHex } from '../util/number'; +import Logger from '../util/Logger'; +import ExtendedKeyringTypes from '../../app/constants/keyringTypes'; +import type EthQuery from '@metamask/eth-query'; +import type { BN } from 'ethereumjs-util'; +import { Hex } from '@metamask/utils'; + +const ZERO_BALANCE = '0x0'; +const MAX = 20; + +/** + * Get an account balance from the network. + * @param address - The account address + * @param ethQuery - The EthQuery instance to use when asking the network + */ +const getBalance = async (address: string, ethQuery: EthQuery): Promise => + new Promise((resolve, reject) => { + ethQuery.getBalance(address, (error: Error, balance: BN) => { + if (error) { + reject(error); + Logger.error(error); + } else { + const balanceHex = BNToHex(balance); + resolve(balanceHex || ZERO_BALANCE); + } + }); + }); + +/** + * Add additional accounts in the wallet based on balance + */ +export default async () => { + const { KeyringController } = Engine.context; + const ethQuery = Engine.getGlobalEthQuery(); + + await KeyringController.withKeyring( + { type: ExtendedKeyringTypes.hd }, + async (primaryKeyring) => { + for (let i = 0; i < MAX; i++) { + const [newAccount] = await primaryKeyring.addAccounts(1); + + let newAccountBalance = ZERO_BALANCE; + try { + newAccountBalance = await getBalance(newAccount, ethQuery); + } catch (error) { + // Errors are gracefully handled so that `withKeyring` + // will not rollback the primary keyring, and accounts + // created in previous loop iterations will remain in place. + } + + if (newAccountBalance === ZERO_BALANCE) { + // remove extra zero balance account we just added and break the loop + primaryKeyring.removeAccount?.(newAccount); + break; + } + } + }, + ); +}; diff --git a/e2e/fixtures/fixture-builder.js b/e2e/fixtures/fixture-builder.js index d01e3fa41fe..055b46d0ad0 100644 --- a/e2e/fixtures/fixture-builder.js +++ b/e2e/fixtures/fixture-builder.js @@ -389,7 +389,7 @@ class FixtureBuilder { topAggId: null, tokensLastFetched: 0, isInPolling: false, - pollingCyclesLeft: 3, + pollingCyclesLeft: 4, quoteRefreshSeconds: null, usedGasEstimate: null, usedCustomGas: null, diff --git a/patches/@metamask+preferences-controller+13.3.0.patch b/patches/@metamask+preferences-controller+14.0.0.patch similarity index 100% rename from patches/@metamask+preferences-controller+13.3.0.patch rename to patches/@metamask+preferences-controller+14.0.0.patch