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