diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 4201574ddf..1e3f44bab3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -423,12 +423,12 @@ }, "packages/assets-controllers/src/TokenDetectionController.test.ts": { "no-restricted-syntax": { - "count": 2 + "count": 1 } }, "packages/assets-controllers/src/TokenDetectionController.ts": { "no-restricted-syntax": { - "count": 6 + "count": 3 } }, "packages/assets-controllers/src/TokenListController.test.ts": { @@ -475,7 +475,7 @@ "count": 6 }, "no-restricted-syntax": { - "count": 4 + "count": 3 } }, "packages/assets-controllers/src/TokensController.ts": { @@ -486,7 +486,7 @@ "count": 1 }, "@typescript-eslint/prefer-optional-chain": { - "count": 4 + "count": 3 }, "id-length": { "count": 1 @@ -498,7 +498,7 @@ "count": 1 }, "no-restricted-syntax": { - "count": 2 + "count": 1 }, "require-atomic-updates": { "count": 1 diff --git a/package.json b/package.json index 652ab0d6bf..d45a691947 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "960.0.0", + "version": "963.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 197d72af33..8944cca95d 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose missing public `AccountTreeController` methods through its messenger ([#8716](https://github.com/MetaMask/core/pull/8716)) + - The following actions are now available: + - `AccountTreeController:init` + - `AccountTreeController:reinit` + - Corresponding action types are available as well. + ## [7.2.0] ### Changed diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index ae86e20f01..abc6ca61e9 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -5,6 +5,29 @@ import type { AccountTreeController } from './AccountTreeController'; +/** + * Initialize the controller's state. + * + * It constructs the initial state of the account tree (tree nodes, nodes + * names, metadata, etc..) and will automatically update the controller's + * state with it. + */ +export type AccountTreeControllerInitAction = { + type: `AccountTreeController:init`; + handler: AccountTreeController['init']; +}; + +/** + * Re-initialize the controller's state. + * + * This is done in one single (atomic) `update` block to avoid having a temporary + * cleared state. Use this when you need to force a full re-init even if already initialized. + */ +export type AccountTreeControllerReinitAction = { + type: `AccountTreeController:reinit`; + handler: AccountTreeController['reinit']; +}; + /** * Gets the account wallet object from its ID. * @@ -180,6 +203,8 @@ export type AccountTreeControllerSyncWithUserStorageAtLeastOnceAction = { * Union of all AccountTreeController action types. */ export type AccountTreeControllerMethodActions = + | AccountTreeControllerInitAction + | AccountTreeControllerReinitAction | AccountTreeControllerGetAccountWalletObjectAction | AccountTreeControllerGetAccountWalletObjectsAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 676df296fa..211b16041f 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -59,6 +59,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'clearState', 'syncWithUserStorage', 'syncWithUserStorageAtLeastOnce', + 'init', + 'reinit', ] as const; const accountTreeControllerMetadata: StateMetadata = diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 96df965c0a..ab9fdcd59f 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -33,6 +33,8 @@ export type { AccountTreeControllerClearStateAction, AccountTreeControllerSyncWithUserStorageAction, AccountTreeControllerSyncWithUserStorageAtLeastOnceAction, + AccountTreeControllerInitAction, + AccountTreeControllerReinitAction, } from './AccountTreeController-method-action-types'; export type { AccountContext } from './AccountTreeController'; diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index f07deedc2f..7519b073e3 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.4.0] + +### Added + +- Expose missing `AssetsController:setSelectedCurrency` action through its messenger ([#8719](https://github.com/MetaMask/core/pull/8719)) + - Corresponding action type is available as well. + ### Changed - Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691)) @@ -14,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/account-tree-controller` from `^7.1.0` to `^7.2.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) +- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) ## [6.3.0] @@ -412,7 +420,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.4.0...HEAD +[6.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...@metamask/assets-controller@6.4.0 [6.3.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.2.1...@metamask/assets-controller@6.3.0 [6.2.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.2.0...@metamask/assets-controller@6.2.1 [6.2.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.1.0...@metamask/assets-controller@6.2.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 4cd6586553..aa20a150df 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "6.3.0", + "version": "6.4.0", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -58,7 +58,7 @@ "@ethersproject/providers": "^5.7.0", "@metamask/account-tree-controller": "^7.2.0", "@metamask/accounts-controller": "^38.0.0", - "@metamask/assets-controllers": "^105.1.0", + "@metamask/assets-controllers": "^106.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", "@metamask/controller-utils": "^11.20.0", diff --git a/packages/assets-controller/src/AssetsController-method-action-types.ts b/packages/assets-controller/src/AssetsController-method-action-types.ts index 9ef3f3ba84..8cdd37b170 100644 --- a/packages/assets-controller/src/AssetsController-method-action-types.ts +++ b/packages/assets-controller/src/AssetsController-method-action-types.ts @@ -115,6 +115,16 @@ export type AssetsControllerUnhideAssetAction = { handler: AssetsController['unhideAsset']; }; +/** + * Set the current currency. + * + * @param selectedCurrency - The ISO 4217 currency code to set. + */ +export type AssetsControllerSetSelectedCurrencyAction = { + type: `AssetsController:setSelectedCurrency`; + handler: AssetsController['setSelectedCurrency']; +}; + /** * Union of all AssetsController action types. */ @@ -129,4 +139,5 @@ export type AssetsControllerMethodActions = | AssetsControllerRemoveCustomAssetAction | AssetsControllerGetCustomAssetsAction | AssetsControllerHideAssetAction - | AssetsControllerUnhideAssetAction; + | AssetsControllerUnhideAssetAction + | AssetsControllerSetSelectedCurrencyAction; diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index f18fe2d56e..7a8d0a5416 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -175,6 +175,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getCustomAssets', 'hideAsset', 'unhideAsset', + 'setSelectedCurrency', ] as const; /** Default polling interval hint for data sources (30 seconds) */ @@ -3165,5 +3166,8 @@ export class AssetsController extends BaseController< this.messenger.unregisterActionHandler('AssetsController:getCustomAssets'); this.messenger.unregisterActionHandler('AssetsController:hideAsset'); this.messenger.unregisterActionHandler('AssetsController:unhideAsset'); + this.messenger.unregisterActionHandler( + 'AssetsController:setSelectedCurrency', + ); } } diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 2060f832f9..8b4aa80433 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -39,6 +39,7 @@ export type { AssetsControllerUnhideAssetAction, AssetsControllerGetExchangeRatesForBridgeAction, AssetsControllerGetStateForTransactionPayAction, + AssetsControllerSetSelectedCurrencyAction, } from './AssetsController-method-action-types'; // Core types diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 3d775a23ab..61004f023c 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [106.0.0] + ### Added +- Add `TokenListService`, a shared service for fetching and caching the token list per chain ([#8700](https://github.com/MetaMask/core/pull/8700)) + - Wraps `@tanstack/query-core` to cache results in-memory for 4 hours per chain ID, matching `TokenListController`'s existing threshold. + - Multiple controllers sharing the same `TokenListService` instance share the same cache: only one HTTP request is made per chain per 4-hour window regardless of how many callers invoke `fetchTokensByChainId`. + - Exported from the package as `TokenListService` and `buildTokenListMap`. +- Add `@tanstack/query-core` `^5.62.16` as a direct dependency ([#8700](https://github.com/MetaMask/core/pull/8700)) - Expose missing public `AccountTrackerController` methods through its messenger ([#8693](https://github.com/MetaMask/core/pull/8693)) - The following actions are now available: - `AccountTrackerController:refresh` @@ -22,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `TokenDetectionController` now requires a `tokenListService: TokenListService` constructor option ([#8700](https://github.com/MetaMask/core/pull/8700)) + - Token list data is fetched directly from `TokenListService` instead of reading `TokenListController` state on each detection pass. + - `GetTokenListState` has been removed from `AllowedActions` and `TokenListStateChange` has been removed from `AllowedEvents` on `TokenDetectionControllerMessenger`. +- **BREAKING:** `TokensController` now requires a `tokenListService: TokenListService` constructor option ([#8700](https://github.com/MetaMask/core/pull/8700)) + - `TokenListStateChange` has been removed from `AllowedEvents` on `TokensControllerMessenger`. + - Token `name` and `rwaData` enrichment now happens once at controller initialization instead of reactively on every `TokenListController:stateChange` event. - Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691)) - Switch the default mUSD asset from upfront `allTokens` state seeding to detection-based discovery on Ethereum mainnet (`0x1`), Linea (`0xe708`), and Monad mainnet (`0x8f`) ([#8688](https://github.com/MetaMask/core/pull/8688)) - `TokenDetectionController` now merges mUSD into the in-memory token list cache for these chains so it is treated as a regular detection candidate, replacing the previous `start()`-time `TokensController:addTokens` call and the per-event re-seed runs. @@ -34,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- `TokenDetectionController` no longer restarts token detection when `TokenListController` publishes a `stateChange` event ([#8700](https://github.com/MetaMask/core/pull/8700)) + - Detection is still triggered on wallet unlock, account change, network change, and preference changes; the extra restart that occurred whenever `TokenListController` refreshed its cache is gone. - Stop seeding mUSD directly into `TokensController` state and remove the related event subscriptions ([#8688](https://github.com/MetaMask/core/pull/8688)) - `TokensController` no longer subscribes to `KeyringController:unlock`, `AccountsController:accountAdded`, `AccountsController:selectedEvmAccountChange`, `NetworkController:networkAdded`, or `NetworkController:stateChange` for mUSD seeding purposes. - `TokensControllerMessenger` no longer requires `NetworkControllerNetworkAddedEvent`, `AccountsControllerAccountAddedEvent`, or `KeyringControllerUnlockEvent` as allowed events. @@ -3028,7 +3043,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...HEAD +[106.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...@metamask/assets-controllers@106.0.0 [105.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.0.0...@metamask/assets-controllers@105.1.0 [105.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@104.3.0...@metamask/assets-controllers@105.0.0 [104.3.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@104.2.0...@metamask/assets-controllers@104.3.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index ffd286abbe..e842ea6e70 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "105.1.0", + "version": "106.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", @@ -87,6 +87,7 @@ "@metamask/storage-service": "^1.0.1", "@metamask/transaction-controller": "^65.1.0", "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^5.62.16", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", "async-mutex": "^0.5.0", diff --git a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts index 1ec590fcea..80ac096584 100644 --- a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts +++ b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts @@ -38,7 +38,7 @@ export type TokenDetectionControllerStopAction = { }; /** - * For each token in the token list provided by the TokenListController, checks the token's balance for the selected account address on the active network. + * For each token in the token list provided by the TokenListService, checks the token's balance for the selected account address on the active network. * On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo. * * @param options - Options for token detection. diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index d4b746114c..33b19d453e 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -44,10 +44,14 @@ import type { TokenDetectionControllerMessenger } from './TokenDetectionControll import { TokenDetectionController, controllerName, - mapChainIdWithTokenListMap, } from './TokenDetectionController'; import { getDefaultTokenListState } from './TokenListController'; -import type { TokenListState, TokenListToken } from './TokenListController'; +import type { + TokenListMap, + TokenListState, + TokenListToken, +} from './TokenListController'; +import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensController, @@ -207,7 +211,6 @@ function buildTokenDetectionControllerMessenger( 'NetworkController:getState', 'TokensController:getState', 'TokensController:addDetectedTokens', - 'TokenListController:getState', 'PreferencesController:getState', 'TokensController:addTokens', 'NetworkController:findNetworkClientIdByChainId', @@ -217,7 +220,6 @@ function buildTokenDetectionControllerMessenger( 'KeyringController:lock', 'KeyringController:unlock', 'NetworkController:networkDidChange', - 'TokenListController:stateChange', 'PreferencesController:stateChange', 'TransactionController:transactionConfirmed', ], @@ -1870,7 +1872,7 @@ describe('TokenDetectionController', () => { }); }); - describe('TokenListController:stateChange', () => { + describe('startPolling', () => { beforeEach(() => { jest.useFakeTimers(); }); @@ -1879,542 +1881,101 @@ describe('TokenDetectionController', () => { jest.useRealTimers(); }); - describe('when "disabled" is false', () => { - it('should detect tokens if the token list is non-empty', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, + it('should call detect tokens with networkClientId and address params', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - mockNetworkState, - }) => { - // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4) - mockNetworkState({ - ...getDefaultNetworkControllerState(), - selectedNetworkClientId: 'avalanche', - }); - const tokenList = { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }; - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - '0xa86a': { - timestamp: 0, - data: tokenList, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ controller, mockTokenListGetState }) => { + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + [ChainId.sepolia]: { + data: { + [sampleTokenA.address]: { + name: sampleTokenA.name, + symbol: sampleTokenA.symbol, + decimals: sampleTokenA.decimals, + address: sampleTokenA.address, + occurrences: 1, + aggregators: sampleTokenA.aggregators, + iconUrl: sampleTokenA.image, + }, }, + timestamp: 0, }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).toHaveBeenCalledWith( - 'TokensController:addTokens', - [sampleTokenA], - 'avalanche', - ); - }, - ); - }); - - it('should not detect tokens if the token list is empty', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: {}, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); + }); + const spy = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(() => { + return Promise.resolve(); + }); - describe('when keyring is locked', () => { - it('should not detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), + controller.startPolling({ + chainIds: ['0xa86a'], + address: '0x1', }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', + controller.startPolling({ + chainIds: ['0xa86a'], + address: '0xdeadbeef', }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - isKeyringUnlocked: false, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); + controller.startPolling({ + chainIds: ['0x5'], + address: '0x3', + }); + await jestAdvanceTime({ duration: 0 }); - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); + expect(spy.mock.calls).toMatchObject([ + [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], + [{ chainIds: ['0x5'], selectedAddress: '0x3' }], + ]); - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); + await jestAdvanceTime({ duration: DEFAULT_INTERVAL }); + expect(spy.mock.calls).toMatchObject([ + [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], + [{ chainIds: ['0x5'], selectedAddress: '0x3' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], + [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], + [{ chainIds: ['0x5'], selectedAddress: '0x3' }], + ]); + }, + ); }); + }); - describe('when "disabled" is true', () => { - it('should not detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: true, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, + describe('detectTokens', () => { + it('should not detect tokens if token detection is disabled and current network is not mainnet', async () => { + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, }, - async ({ - mockTokenListGetState, - callActionSpy, - triggerTokenListStateChange, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - expect(callActionSpy).not.toHaveBeenCalledWith( - 'TokensController:addDetectedTokens', - ); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are equal with the same timestamp', () => { - it('should not call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are equal with different timestamp', () => { - it('should not call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 3424, // same list with different timestamp should not trigger detectTokens again - }, - }, - }); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(0); - }, - ); - }); - }); - - describe('when previous and incoming tokensChainsCache are not equal', () => { - it('should call detect tokens', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ - mockTokenListGetState, - triggerTokenListStateChange, - controller, - }) => { - const tokenListState = { - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }; - mockTokenListGetState(tokenListState); - // This should set the tokensChainsCache value - triggerTokenListStateChange(tokenListState); - await jestAdvanceTime({ duration: 1 }); - - const mockTokens = jest.spyOn(controller, 'detectTokens'); - - // Re-trigger state change so that incoming list is equal the current list in state - triggerTokenListStateChange({ - ...tokenListState, - tokensChainsCache: { - ...tokenListState.tokensChainsCache, - [ChainId['linea-mainnet']]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 5546454, - }, - }, - }); - await jestAdvanceTime({ duration: 1 }); - expect(mockTokens).toHaveBeenCalledTimes(1); - }, - ); - }); - }); - }); - - describe('startPolling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should call detect tokens with networkClientId and address params', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, - }, - }, - async ({ controller, mockTokenListGetState }) => { - mockTokenListGetState({ - ...getDefaultTokenListState(), - tokensChainsCache: { - [ChainId.sepolia]: { - data: { - [sampleTokenA.address]: { - name: sampleTokenA.name, - symbol: sampleTokenA.symbol, - decimals: sampleTokenA.decimals, - address: sampleTokenA.address, - occurrences: 1, - aggregators: sampleTokenA.aggregators, - iconUrl: sampleTokenA.image, - }, - }, - timestamp: 0, - }, - }, - }); - const spy = jest - .spyOn(controller, 'detectTokens') - .mockImplementation(() => { - return Promise.resolve(); - }); - - controller.startPolling({ - chainIds: ['0xa86a'], - address: '0x1', - }); - controller.startPolling({ - chainIds: ['0xa86a'], - address: '0xdeadbeef', - }); - controller.startPolling({ - chainIds: ['0x5'], - address: '0x3', - }); - await jestAdvanceTime({ duration: 0 }); - - expect(spy.mock.calls).toMatchObject([ - [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], - [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], - [{ chainIds: ['0x5'], selectedAddress: '0x3' }], - ]); - - await jestAdvanceTime({ duration: DEFAULT_INTERVAL }); - expect(spy.mock.calls).toMatchObject([ - [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], - [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], - [{ chainIds: ['0x5'], selectedAddress: '0x3' }], - [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], - [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], - [{ chainIds: ['0x5'], selectedAddress: '0x3' }], - ]); - }, - ); - }); - }); - - describe('detectTokens', () => { - it('should not detect tokens if token detection is disabled and current network is not mainnet', async () => { - const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ - [sampleTokenA.address]: new BN(1), - }); - const selectedAccount = createMockInternalAccount({ - address: '0x0000000000000000000000000000000000000001', - }); - await withController( - { - options: { - disabled: false, - getBalancesInSingleCall: mockGetBalancesInSingleCall, - }, - mocks: { - getSelectedAccount: selectedAccount, - getAccount: selectedAccount, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, }, }, async ({ @@ -3241,57 +2802,6 @@ describe('TokenDetectionController', () => { }); }); - describe('mapChainIdWithTokenListMap', () => { - it('should return an empty object when given an empty input', () => { - const tokensChainsCache = {}; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual({}); - }); - - it('should return the same structure when there is no "data" property in the object', () => { - const tokensChainsCache = { - chain1: { info: 'no data property' }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual(tokensChainsCache); // Expect unchanged structure - }); - - it('should map "data" property if present in the object', () => { - const tokensChainsCache = { - chain1: { data: 'someData' }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual({ chain1: 'someData' }); - }); - - it('should handle multiple chains with mixed "data" properties', () => { - const tokensChainsCache = { - chain1: { data: 'someData1' }, - chain2: { info: 'no data property' }, - chain3: { data: 'someData3' }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - - expect(result).toStrictEqual({ - chain1: 'someData1', - chain2: { info: 'no data property' }, - chain3: 'someData3', - }); - }); - - it('should handle nested object with "data" property correctly', () => { - const tokensChainsCache = { - chain1: { - data: { - nested: 'nestedData', - }, - }, - }; - const result = mapChainIdWithTokenListMap(tokensChainsCache); - expect(result).toStrictEqual({ chain1: { nested: 'nestedData' } }); - }); - }); - describe('constructor options', () => { describe('useTokenDetection', () => { it('should disable token detection when useTokenDetection is false', async () => { @@ -3647,7 +3157,121 @@ describe('TokenDetectionController', () => { aggregators: [], image: 'https://example.com/bnt.png', isERC721: false, - name: 'Bancor', + name: 'Bancor', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('should track metrics when adding tokens from websocket', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + const mockTrackMetricsEvent = jest.fn(); + + await withController( + { + options: { + disabled: false, + trackMetaMetricsEvent: mockTrackMetricsEvent, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + // Should track metrics event + expect(mockTrackMetricsEvent).toHaveBeenCalledWith({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: [`USDC - ${checksummedTokenAddress}`], + token_standard: 'ERC20', + asset_type: 'TOKEN', + }, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should be callable directly as a public method on the controller instance', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + // Call the public method directly on the controller instance + await controller.addDetectedTokensViaWs({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', }, ], 'avalanche', @@ -3656,18 +3280,15 @@ describe('TokenDetectionController', () => { ); }); - it('should track metrics when adding tokens from websocket', async () => { + it('should not add tokens when useTokenDetection is false', async () => { const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; - const checksummedTokenAddress = - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const chainId = '0xa86a'; - const mockTrackMetricsEvent = jest.fn(); await withController( { options: { disabled: false, - trackMetaMetricsEvent: mockTrackMetricsEvent, + useTokenDetection: () => false, }, mockTokenListState: { tokensChainsCache: { @@ -3694,18 +3315,7 @@ describe('TokenDetectionController', () => { chainId: chainId as Hex, }); - // Should track metrics event - expect(mockTrackMetricsEvent).toHaveBeenCalledWith({ - event: 'Token Detected', - category: 'Wallet', - properties: { - tokens: [`USDC - ${checksummedTokenAddress}`], - token_standard: 'ERC20', - asset_type: 'TOKEN', - }, - }); - - expect(callActionSpy).toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addTokens', expect.anything(), expect.anything(), @@ -3714,16 +3324,15 @@ describe('TokenDetectionController', () => { ); }); - it('should be callable directly as a public method on the controller instance', async () => { + it('should not add tokens when useExternalServices is false', async () => { const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; - const checksummedTokenAddress = - '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const chainId = '0xa86a'; await withController( { options: { disabled: false, + useExternalServices: () => false, }, mockTokenListState: { tokensChainsCache: { @@ -3745,27 +3354,79 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, callActionSpy }) => { - // Call the public method directly on the controller instance await controller.addDetectedTokensViaWs({ tokensSlice: [mockTokenAddress], chainId: chainId as Hex, }); - expect(callActionSpy).toHaveBeenCalledWith( + expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addTokens', - [ - { - address: checksummedTokenAddress, - decimals: 6, - symbol: 'USDC', - aggregators: [], - image: 'https://example.com/usdc.png', - isERC721: false, - name: 'USD Coin', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { + const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + + await withController( + { + options: { disabled: false }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: { + [usdcAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: usdcAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaWs({ + tokensSlice: [ + toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + usdcAddress, ], - 'avalanche', + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + }), + expect.objectContaining({ address: usdcChecksummed }), + ]), + 'mainnet', + ); + const addTokensCall = callActionSpy.mock.calls.find( + (call) => call[0] === 'TokensController:addTokens', ); + const payload = addTokensCall?.[1] as { address: string }[]; + const musdRows = payload.filter( + (tokenRow) => + tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + ); + expect(musdRows).toHaveLength(1); }, ); }); @@ -4044,10 +3705,10 @@ describe('TokenDetectionController', () => { ); }); - it('should fetch fresh token metadata cache from TokenListController at call time', async () => { - // This test verifies the fix for the bug where addDetectedTokensViaPolling used - // a stale/empty tokensChainsCache from construction time instead of fetching - // fresh data from TokenListController:getState at call time. + it('should fetch fresh token metadata cache from TokenListService at call time', async () => { + // This test verifies that addDetectedTokensViaPolling fetches the token list + // from the TokenListService at call time (not at construction time), so that + // tokens added to the service after construction are still detected. const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const checksummedTokenAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; @@ -4222,6 +3883,183 @@ describe('TokenDetectionController', () => { }, ); }); + + it('should skip if useExternalServices is disabled', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + useExternalServices: () => false, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).not.toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.anything(), + expect.anything(), + ); + }, + ); + }); + + it('should ignore slice addresses that are not in the token list map', async () => { + const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const checksummedTokenAddress = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const unknownAddress = '0x0000000000000000000000000000000000000002'; + const chainId = '0xa86a'; + + await withController( + { + options: { + disabled: false, + useTokenDetection: () => true, + }, + mockTokenListState: { + tokensChainsCache: { + [chainId]: { + timestamp: 0, + data: { + [mockTokenAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: mockTokenAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ controller, callActionSpy }) => { + await controller.addDetectedTokensViaPolling({ + tokensSlice: [mockTokenAddress, unknownAddress], + chainId: chainId as Hex, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + [ + { + address: checksummedTokenAddress, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: 'https://example.com/usdc.png', + isERC721: false, + name: 'USD Coin', + }, + ], + 'avalanche', + ); + }, + ); + }); + + it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => { + const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + await withController( + { + options: { disabled: false, useTokenDetection: () => true }, + mocks: { + getAccount: selectedAccount, + getSelectedAccount: selectedAccount, + }, + mockTokensState: { + allTokens: {}, + allDetectedTokens: {}, + allIgnoredTokens: {}, + }, + mockTokenListState: { + tokensChainsCache: { + [ChainId.mainnet]: { + timestamp: 0, + data: { + [usdcAddress]: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + address: usdcAddress, + aggregators: [], + iconUrl: 'https://example.com/usdc.png', + occurrences: 11, + }, + }, + }, + }, + }, + }, + async ({ + controller, + callActionSpy, + mockFindNetworkClientIdByChainId, + }) => { + mockFindNetworkClientIdByChainId(() => 'mainnet'); + await controller.addDetectedTokensViaPolling({ + tokensSlice: [ + toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + usdcAddress, + ], + chainId: ChainId.mainnet, + }); + + expect(callActionSpy).toHaveBeenCalledWith( + 'TokensController:addTokens', + expect.arrayContaining([ + expect.objectContaining({ + address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER), + }), + expect.objectContaining({ address: usdcChecksummed }), + ]), + 'mainnet', + ); + const addTokensCall = callActionSpy.mock.calls.find( + (call) => call[0] === 'TokensController:addTokens', + ); + const payload = addTokensCall?.[1] as { address: string }[]; + const musdRows = payload.filter( + (tokenRow) => + tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER, + ); + expect(musdRows).toHaveLength(1); + }, + ); + }); }); }); @@ -4252,7 +4090,6 @@ type WithControllerCallback = ({ callActionSpy, triggerKeyringUnlock, triggerKeyringLock, - triggerTokenListStateChange, triggerPreferencesStateChange, triggerSelectedAccountChange, triggerNetworkDidChange, @@ -4263,6 +4100,7 @@ type WithControllerCallback = ({ mockGetSelectedAccount: (address: string) => void; mockKeyringGetState: (state: KeyringControllerState) => void; mockTokensGetState: (state: TokensControllerState) => void; + /** Updates the mock TokenListService to return a specific token list state. */ mockTokenListGetState: (state: TokenListState) => void; mockPreferencesGetState: (state: PreferencesState) => void; mockGetNetworkClientById: ( @@ -4280,7 +4118,6 @@ type WithControllerCallback = ({ callActionSpy: jest.SpyInstance; triggerKeyringUnlock: () => void; triggerKeyringLock: () => void; - triggerTokenListStateChange: (state: TokenListState) => void; triggerPreferencesStateChange: (state: PreferencesState) => void; triggerSelectedAccountChange: (account: InternalAccount) => void; triggerNetworkDidChange: (state: NetworkState) => void; @@ -4394,14 +4231,31 @@ async function withController( ...mockTokensState, }), ); - const mockTokenListStateFunc = jest.fn(); - messenger.registerActionHandler( - 'TokenListController:getState', - mockTokenListStateFunc.mockReturnValue({ - ...getDefaultTokenListState(), - ...mockTokenListState, - }), - ); + + // Build the initial TokenListState and a mutable reference so tests can update it. + let currentTokenListState: TokenListState = { + ...getDefaultTokenListState(), + ...mockTokenListState, + }; + const mockFetchTokensByChainId = jest + .fn, [Hex]>() + .mockImplementation((chainId: Hex) => { + const data = currentTokenListState.tokensChainsCache[chainId]?.data ?? {}; + // Normalise keys to lowercase to match buildTokenListMap's output so that + // lookups using lowercased addresses (as done in production code) work correctly. + return Promise.resolve( + Object.fromEntries( + Object.entries(data).map(([addr, token]) => [ + addr.toLowerCase(), + token, + ]), + ), + ); + }); + const tokenListService = { + fetchTokensByChainId: mockFetchTokensByChainId, + } as unknown as TokenListService; + const mockPreferencesState = jest.fn(); messenger.registerActionHandler( 'PreferencesController:getState', @@ -4448,6 +4302,7 @@ async function withController( getBalancesInSingleCall: jest.fn(), trackMetaMetricsEvent: jest.fn(), messenger: tokenDetectionControllerMessenger, + tokenListService, ...options, }); try { @@ -4470,7 +4325,19 @@ async function withController( mockPreferencesState.mockReturnValue(state); }, mockTokenListGetState: (state: TokenListState) => { - mockTokenListStateFunc.mockReturnValue(state); + currentTokenListState = state; + mockFetchTokensByChainId.mockImplementation((chainId: Hex) => { + const data = state.tokensChainsCache[chainId]?.data ?? {}; + // Normalise keys to lowercase to match buildTokenListMap's output. + return Promise.resolve( + Object.fromEntries( + Object.entries(data).map(([addr, token]) => [ + addr.toLowerCase(), + token, + ]), + ), + ); + }); }, mockGetNetworkClientById: ( handler: ( @@ -4501,9 +4368,6 @@ async function withController( triggerKeyringLock: () => { messenger.publish('KeyringController:lock'); }, - triggerTokenListStateChange: (state: TokenListState) => { - messenger.publish('TokenListController:stateChange', state, []); - }, triggerPreferencesStateChange: (state: PreferencesState) => { messenger.publish('PreferencesController:stateChange', state, []); }, diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 6ba8fb4d91..9ac70d8645 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -39,7 +39,6 @@ import type { import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { isEqual, mapValues, isObject, get } from 'lodash'; import type { AssetsContractController } from './AssetsContractController'; import { @@ -54,12 +53,11 @@ import { } from './constants'; import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types'; import type { - GetTokenListState, TokenListMap, - TokenListStateChange, TokenListToken, TokensChainsCache, } from './TokenListController'; +import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensControllerGetStateAction } from './TokensController'; import type { @@ -106,23 +104,6 @@ const MUSD_TOKEN_DETECTION_CHAIN_ID_SET = new Set( MUSD_TOKEN_DETECTION_CHAIN_IDS, ); -/** - * Function that takes a TokensChainsCache object and maps chainId with TokenListMap. - * - * @param tokensChainsCache - TokensChainsCache input object - * @returns returns the map of chainId with TokenListMap - */ -export function mapChainIdWithTokenListMap( - tokensChainsCache: TokensChainsCache, -): Record { - return mapValues(tokensChainsCache, (value) => { - if (isObject(value) && 'data' in value) { - return get(value, ['data']); - } - return value; - }); -} - export const controllerName = 'TokenDetectionController'; export type TokenDetectionState = Record; @@ -142,7 +123,6 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByNetworkClientId | NetworkControllerGetStateAction - | GetTokenListState | KeyringControllerGetStateAction | PreferencesControllerGetStateAction | TokensControllerGetStateAction @@ -160,7 +140,6 @@ export type TokenDetectionControllerEvents = export type AllowedEvents = | AccountsControllerSelectedEvmAccountChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange | KeyringControllerLockEvent | KeyringControllerUnlockEvent | PreferencesControllerStateChangeEvent @@ -213,7 +192,7 @@ export class TokenDetectionController extends StaticIntervalPollingController true, useExternalServices = (): boolean => true, }: { @@ -275,6 +256,7 @@ export class TokenDetectionController extends StaticIntervalPollingController void; messenger: TokenDetectionControllerMessenger; + tokenListService: TokenListService; useTokenDetection?: () => boolean; useExternalServices?: () => boolean; }) { @@ -292,11 +274,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { - const isEqualValues = this.#compareTokensChainsCache( - tokensChainsCache, - this.#tokensChainsCache, - ); - if (!isEqualValues) { - this.#restartTokenDetection().catch(() => { - // Silently handle token detection errors - }); - } - }, - ); - this.messenger.subscribe( 'PreferencesController:stateChange', ({ useTokenDetection }) => { @@ -461,30 +424,6 @@ export class TokenDetectionController extends StaticIntervalPollingController { if (!isTokenDetectionSupportedForNetwork(chainId)) { - return false; + return null; } if ( !this.#isDetectionEnabledFromPreferences && chainId !== ChainId.mainnet ) { - return false; + return null; } const isMainnetDetectionInactive = !this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet; if (isMainnetDetectionInactive) { - this.#tokensChainsCache = this.#getConvertedStaticMainnetTokenList(); - } else { - const { tokensChainsCache } = this.messenger.call( - 'TokenListController:getState', - ); - this.#tokensChainsCache = this.#applyMusdDefaultToTokensChainsCache( - chainId, - tokensChainsCache ?? {}, - ); + return this.#getConvertedStaticMainnetTokenList(); } - return true; + const tokenListMap = + await this.#tokenListService.fetchTokensByChainId(chainId); + return this.#applyMusdDefaultToTokensChainsCache(chainId, { + [chainId]: { data: tokenListMap, timestamp: Date.now() }, + }); } async #detectTokensUsingRpc( @@ -584,12 +530,14 @@ export class TokenDetectionController extends StaticIntervalPollingController { for (const { chainId, networkClientId } of chainsToDetectUsingRpc) { - if (!this.#shouldDetectTokens(chainId)) { + const chainCache = await this.#getChainCacheForDetection(chainId); + if (!chainCache) { continue; } const tokenCandidateSlices = this.#getSlicesOfTokensToDetect({ chainId, + chainCache, selectedAddress: addressToDetect, }); const tokenDetectionPromises = tokenCandidateSlices.map((tokensSlice) => @@ -598,6 +546,7 @@ export class TokenDetectionController extends StaticIntervalPollingController { await safelyExecute(async () => { const balances = await this.#getBalancesInSingleCall( @@ -837,11 +788,18 @@ export class TokenDetectionController extends StaticIntervalPollingController + const musdListToken = Object.entries(chainData).find(([key]) => isEqualCaseInsensitive(key, MUSD_ERC20_ADDRESS_LOWER), )?.[1]; if (musdListToken) { @@ -939,15 +896,18 @@ export class TokenDetectionController extends StaticIntervalPollingController ({ + fetchTokenListByChainId: jest.fn(), +})); + +const mockedFetchTokenListByChainId = jest.mocked(fetchTokenListByChainId); + +describe('buildTokenListMap', () => { + it('maps tokens by address and applies aggregator and icon formatting', () => { + const chainId = '0x1' as Hex; + const tokens: TokenListToken[] = [ + { + name: 'Sample', + symbol: 'SMP', + decimals: 18, + address: '0xabc0000000000000000000000000000000000001', + occurrences: 3, + aggregators: ['bancor', 'cmc'], + iconUrl: 'https://example.com/icon.png', + }, + ]; + + const map = buildTokenListMap(tokens, chainId); + + expect(Object.keys(map)).toStrictEqual([ + '0xabc0000000000000000000000000000000000001', + ]); + expect(map['0xabc0000000000000000000000000000000000001']).toMatchObject({ + name: 'Sample', + symbol: 'SMP', + decimals: 18, + address: '0xabc0000000000000000000000000000000000001', + aggregators: ['Bancor', 'CMC'], + }); + expect(map['0xabc0000000000000000000000000000000000001'].iconUrl).toContain( + 'https://static.cx.metamask.io', + ); + }); + + it('returns an empty map when the token array is empty', () => { + expect(buildTokenListMap([], '0x1' as Hex)).toStrictEqual({}); + }); +}); + +describe('TokenListService', () => { + beforeEach(() => { + mockedFetchTokenListByChainId.mockReset(); + }); + + it('fetches via token-service and caches results for the same chain', async () => { + const chainId = '0xa86a' as Hex; + const apiToken = { + name: 'Avalanche Token', + symbol: 'AVT', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 5, + aggregators: [] as string[], + iconUrl: '', + }; + mockedFetchTokenListByChainId.mockResolvedValue([apiToken]); + + const service = new TokenListService(); + const first = await service.fetchTokensByChainId(chainId); + const second = await service.fetchTokensByChainId(chainId); + + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(first).toStrictEqual(second); + expect(first[apiToken.address]).toMatchObject({ + symbol: 'AVT', + name: 'Avalanche Token', + }); + + service.destroy(); + }); + + it('does not re-run map formatting on cache hits for the same chain', async () => { + const chainId = '0xa86a' as Hex; + const apiTokens = [ + { + name: 'Token A', + symbol: 'TKA', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 1, + aggregators: ['bancor'] as string[], + iconUrl: '', + }, + { + name: 'Token B', + symbol: 'TKB', + decimals: 6, + address: '0x2000000000000000000000000000000000000002', + occurrences: 1, + aggregators: ['cmc'] as string[], + iconUrl: '', + }, + ]; + mockedFetchTokenListByChainId.mockResolvedValue(apiTokens); + + const formatAggregatorsSpy = jest.spyOn( + assetsUtil, + 'formatAggregatorNames', + ); + const formatIconSpy = jest.spyOn(assetsUtil, 'formatIconUrlWithProxy'); + + const service = new TokenListService(); + await service.fetchTokensByChainId(chainId); + await service.fetchTokensByChainId(chainId); + + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(formatAggregatorsSpy).toHaveBeenCalledTimes(2); + expect(formatIconSpy).toHaveBeenCalledTimes(2); + + formatAggregatorsSpy.mockRestore(); + formatIconSpy.mockRestore(); + service.destroy(); + }); + + it('treats an undefined API response as an empty list', async () => { + mockedFetchTokenListByChainId.mockResolvedValue(undefined); + + const service = new TokenListService(); + expect(await service.fetchTokensByChainId('0x1' as Hex)).toStrictEqual({}); + + service.destroy(); + }); + + it('clearing the cache via destroy causes the next fetch to hit the network again', async () => { + const chainId = '0x1' as Hex; + const apiToken = { + name: 'Restored After Destroy', + symbol: 'RAD', + decimals: 18, + address: '0x1000000000000000000000000000000000000001', + occurrences: 1, + aggregators: [] as string[], + iconUrl: '', + }; + mockedFetchTokenListByChainId.mockImplementation( + async (_chainId, abortSignal) => { + // Mirror token-service: aborted fetches resolve to undefined, not a list. + if (abortSignal.aborted) { + return undefined; + } + return [apiToken]; + }, + ); + + const service = new TokenListService(); + const first = await service.fetchTokensByChainId(chainId); + const cached = await service.fetchTokensByChainId(chainId); + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1); + expect(cached).toStrictEqual(first); + expect(first[apiToken.address]).toMatchObject({ symbol: 'RAD' }); + + service.destroy(); + + const afterDestroy = await service.fetchTokensByChainId(chainId); + expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(2); + expect(afterDestroy[apiToken.address]).toMatchObject({ symbol: 'RAD' }); + + service.destroy(); + }); +}); diff --git a/packages/assets-controllers/src/TokenListService.ts b/packages/assets-controllers/src/TokenListService.ts new file mode 100644 index 0000000000..4f6db1a015 --- /dev/null +++ b/packages/assets-controllers/src/TokenListService.ts @@ -0,0 +1,104 @@ +import type { Hex } from '@metamask/utils'; +import { QueryClient } from '@tanstack/query-core'; + +import { formatAggregatorNames, formatIconUrlWithProxy } from './assetsUtil'; +import { fetchTokenListByChainId } from './token-service'; +import type { TokenListMap, TokenListToken } from './TokenListController'; + +// 4 hours — mirrors TokenListController's DEFAULT_THRESHOLD +const FOUR_HOURS_MS = 4 * 60 * 60 * 1000; + +/** + * Shared service for fetching and caching the token list per chain. + * + * Callers invoke `fetchTokensByChainId` directly. TanStack Query caches the + * normalised `TokenListMap` for 4 hours so that multiple controllers share the + * same in-memory cache without redundant network requests or per-token + * formatting work on cache hits. + */ +export class TokenListService { + readonly #queryClient: QueryClient; + + #abortController: AbortController; + + constructor() { + this.#abortController = new AbortController(); + this.#queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: FOUR_HOURS_MS, + // fetchQuery never creates an observer, so entries are immediately + // inactive. Without an explicit gcTime the default 5-minute GC would + // evict them long before the 4-hour staleTime window expires. + gcTime: FOUR_HOURS_MS, + retry: false, + }, + }, + }); + } + + /** + * Fetch the token list for a given chain, normalising the raw API response + * into a `TokenListMap` keyed by lowercase address. + * + * Results are cached in-memory for 4 hours. A second call within the cache + * window returns the cached value without a network request. + * + * @param chainId - The hex chain ID to fetch tokens for. + * @returns A map of lowercase token address → token metadata. + */ + async fetchTokensByChainId(chainId: Hex): Promise { + const queryKey = ['TokenListService:fetchTokensByChainId', chainId]; + // On failure, TanStack Query v5 sets isInvalidated=true and leaves state.data + // undefined, so the next fetchQuery call always triggers a fresh network request + // rather than serving the cached error. No manual cache eviction is needed. + return this.#queryClient.fetchQuery({ + queryKey, + queryFn: async () => { + const list = (await fetchTokenListByChainId( + chainId, + this.#abortController.signal, + )) as TokenListToken[] | undefined; + return buildTokenListMap(list ?? [], chainId); + }, + staleTime: FOUR_HOURS_MS, + gcTime: FOUR_HOURS_MS, + }); + } + + /** + * Abort in-flight requests, clear the query cache, and reset the abort + * controller so subsequent `fetchTokensByChainId` calls are not stuck with an + * already-aborted signal (which would cache empty results). + */ + destroy(): void { + this.#abortController.abort(); + this.#queryClient.clear(); + this.#abortController = new AbortController(); + } +} + +/** + * Normalise a raw token list array (from the token API) into a `TokenListMap`. + * + * @param tokens - Raw array of token objects returned by the API. + * @param chainId - The chain the tokens belong to (used for icon URL proxy). + * @returns A record keyed by lowercased token address. + */ +export function buildTokenListMap( + tokens: TokenListToken[], + chainId: Hex, +): TokenListMap { + const tokenListMap: TokenListMap = {}; + for (const token of tokens) { + tokenListMap[token.address.toLowerCase()] = { + ...token, + aggregators: formatAggregatorNames(token.aggregators), + iconUrl: formatIconUrlWithProxy({ + chainId, + tokenAddress: token.address, + }), + }; + } + return tokenListMap; +} diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index d374840e5a..db3bf37c7a 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -3261,124 +3261,104 @@ describe('TokensController', () => { }); }); - describe('when TokenListController:stateChange is published', () => { + describe('on initialization, token list enrichment', () => { it('updates the name of each token to match its counterpart in the token list', async () => { - await withController(async ({ controller, messenger }) => { - ContractMock.mockReturnValue( - buildMockEthersERC721Contract({ supportsInterface: false }), - ); - await controller.addToken({ - address: '0x01', - symbol: 'bar', - decimals: 2, - networkClientId: 'mainnet', - }); - expect( - controller.state.allTokens[ChainId.mainnet][ - defaultMockInternalAccount.address - ][0], - ).toStrictEqual({ - address: '0x01', - decimals: 2, - image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: undefined, - }); - - messenger.publish( - 'TokenListController:stateChange', - { - tokensChainsCache: { - [ChainId.mainnet]: { - timestamp: 1, - data: { - '0x01': { - address: '0x01', - symbol: 'bar', - decimals: 2, - occurrences: 1, - name: 'BarName', - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - aggregators: ['Aave'], - }, + await withController( + { + options: { + state: { + allTokens: { + [ChainId.mainnet]: { + [defaultMockInternalAccount.address]: [ + { + address: '0x01', + decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + }, + ], }, }, }, }, - [], - ); - - expect( - controller.state.allTokens[ChainId.mainnet][ - defaultMockInternalAccount.address - ][0], - ).toStrictEqual({ - address: '0x01', - decimals: 2, - image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - symbol: 'bar', - isERC721: false, - aggregators: [], - name: 'BarName', - }); - }); + }, + async ({ controller }) => { + // The enrichment is async (fires in constructor); wait for it. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // TokenListService returns the token list for mainnet with a name. + // withController stubs fetchTokensByChainId to return {} by default; + // for this test we rely on the fact that the name stays undefined + // because the service returned nothing — verifying the plumbing at + // a unit level would require a more detailed setup tested below. + expect( + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0].name, + ).toBeUndefined(); + }, + ); }); - it('overwrites rwaData for tokens with cached rwaData', async () => { - await withController(async ({ controller, messenger }) => { - ContractMock.mockReturnValue( - buildMockEthersERC721Contract({ supportsInterface: false }), - ); - - await controller.addTokens( - [ - { - address: '0x01', - symbol: 'bar', - decimals: 2, - aggregators: [], - image: undefined, - name: undefined, - rwaData: { ticker: 'OLD' }, - }, - ], - 'mainnet', - ); + it('enriches name and rwaData from the token list service at init time', async () => { + const tokenAddress = '0x01'; - messenger.publish( - 'TokenListController:stateChange', - { - tokensChainsCache: { - [ChainId.mainnet]: { - timestamp: 1, - data: { - '0x01': { - address: '0x01', - symbol: 'bar', - decimals: 2, - occurrences: 1, - name: 'BarName', - iconUrl: - 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', - aggregators: ['Aave'], - rwaData: { ticker: 'NEW' }, - }, + await withController( + { + options: { + state: { + allTokens: { + [ChainId.mainnet]: { + [defaultMockInternalAccount.address]: [ + { + address: tokenAddress, + decimals: 2, + image: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + symbol: 'bar', + isERC721: false, + aggregators: [], + name: undefined, + rwaData: { ticker: 'OLD' } as TokenRwaData, + }, + ], }, }, }, + tokenListService: { + fetchTokensByChainId: jest.fn().mockResolvedValue({ + [tokenAddress]: { + address: tokenAddress, + symbol: 'bar', + decimals: 2, + occurrences: 1, + name: 'BarName', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png', + aggregators: ['Aave'], + rwaData: { ticker: 'NEW' }, + }, + }), + } as unknown as import('./TokenListService').TokenListService, }, - [], - ); + }, + async ({ controller }) => { + // Enrichment is a fire-and-forget async call in the constructor. + await new Promise((resolve) => setTimeout(resolve, 0)); - expect( - controller.state.allTokens[ChainId.mainnet][ - defaultMockInternalAccount.address - ][0].rwaData, - ).toStrictEqual({ ticker: 'NEW' }); - }); + const token = + controller.state.allTokens[ChainId.mainnet][ + defaultMockInternalAccount.address + ][0]; + + expect(token.name).toBe('BarName'); + expect(token.rwaData).toStrictEqual({ ticker: 'NEW' }); + }, + ); }); }); @@ -3934,7 +3914,6 @@ async function withController( 'NetworkController:networkDidChange', 'NetworkController:stateChange', 'AccountsController:selectedEvmAccountChange', - 'TokenListController:stateChange', 'KeyringController:accountRemoved', ], }); @@ -3961,6 +3940,10 @@ async function withController( mockListAccounts, ); + const tokenListService = { + fetchTokensByChainId: jest.fn().mockResolvedValue({}), + } as unknown as import('./TokenListService').TokenListService; + const controller = new TokensController({ chainId: ChainId.mainnet, // The tests assume that this is set, but they shouldn't make that @@ -3969,6 +3952,7 @@ async function withController( // not specified. provider: new FakeProvider(), messenger: tokensControllerMessenger, + tokenListService, ...options, }); diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts index 5b2ae1e351..c386f4e4f1 100644 --- a/packages/assets-controllers/src/TokensController.ts +++ b/packages/assets-controllers/src/TokensController.ts @@ -53,10 +53,8 @@ import { TOKEN_METADATA_NO_SUPPORT_ERROR, TokenRwaData, } from './token-service'; -import type { - TokenListStateChange, - TokenListToken, -} from './TokenListController'; +import type { TokenListMap, TokenListToken } from './TokenListController'; +import type { TokenListService } from './TokenListService'; import type { Token } from './TokenRatesController'; import type { TokensControllerMethodActions } from './TokensController-method-action-types'; @@ -161,7 +159,6 @@ export type TokensControllerEvents = TokensControllerStateChangeEvent; export type AllowedEvents = | NetworkControllerStateChangeEvent | NetworkControllerNetworkDidChangeEvent - | TokenListStateChange | AccountsControllerSelectedEvmAccountChangeEvent | KeyringControllerAccountRemovedEvent; @@ -217,16 +214,19 @@ export class TokensController extends BaseController< * @param options.provider - Network provider. * @param options.state - Initial state to set on this controller. * @param options.messenger - The messenger. + * @param options.tokenListService - Shared service for fetching token metadata per chain. */ constructor({ provider, state, messenger, + tokenListService, }: { chainId: Hex; provider: Provider; state?: Partial; messenger: TokensControllerMessenger; + tokenListService: TokenListService; }) { super({ name: controllerName, @@ -261,44 +261,64 @@ export class TokensController extends BaseController< (accountAddress: string) => this.#handleOnAccountRemoved(accountAddress), ); - this.messenger.subscribe( - 'TokenListController:stateChange', - ({ tokensChainsCache }) => { - const { allTokens } = this.state; - const selectedAddress = this.#getSelectedAddress(); - - // Deep clone the `allTokens` object to ensure mutability - const updatedAllTokens = cloneDeep(allTokens); - - for (const [chainId, chainCache] of Object.entries(tokensChainsCache)) { - const chainData = chainCache?.data ?? {}; - - if (updatedAllTokens[chainId as Hex]) { - if (updatedAllTokens[chainId as Hex][selectedAddress]) { - const tokens = updatedAllTokens[chainId as Hex][selectedAddress]; - - for (const [, token] of Object.entries(tokens)) { - const cachedToken = chainData[token.address.toLowerCase()]; - if (cachedToken && cachedToken.name && !token.name) { - token.name = cachedToken.name; // Update the token name - } - if (cachedToken?.rwaData) { - token.rwaData = cachedToken.rwaData; // Update the token RWA data - } - } - } - } - } + // Enrich persisted tokens with name/rwaData from the token list once at init. + this.#enrichTokensFromTokenList(tokenListService).catch(() => { + // Tokens remain usable without metadata enrichment + }); + } - // Update the state with the modified tokens - this.update(() => { - return { - ...this.state, - allTokens: updatedAllTokens, - }; - }); - }, + async #enrichTokensFromTokenList( + tokenListService: TokenListService, + ): Promise { + const chainIds = Object.keys(this.state.allTokens) as Hex[]; + if (chainIds.length === 0) { + return; + } + + // Fetch all chain data concurrently before touching state so the async gap + // is as short as possible and we never hold a stale T0 snapshot while + // awaiting individual chain requests. + // Promise.allSettled ensures a transient error on one chain does not + // prevent other chains from being enriched. + const results = await Promise.allSettled( + chainIds.map(async (chainId) => { + const data = await tokenListService.fetchTokensByChainId(chainId); + return [chainId, data] as const; + }), + ); + const chainDataMap = Object.fromEntries( + results + .filter( + ( + result, + ): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value), ); + + // Read selectedAddress inside the updater so it reflects the live account + // at the moment the state write happens, not a snapshot taken before the + // async fetch gap above. + this.update((state) => { + const selectedAddress = this.#getSelectedAddress(); + for (const chainId of chainIds) { + const chainData = chainDataMap[chainId]; + const tokens = state.allTokens[chainId]?.[selectedAddress]; + if (!tokens || !chainData) { + continue; + } + for (const token of tokens) { + const cachedToken = chainData[token.address.toLowerCase()]; + if (cachedToken?.name && !token.name) { + token.name = cachedToken.name; + } + if (cachedToken?.rwaData) { + token.rwaData = cachedToken.rwaData; + } + } + } + }); } #handleOnAccountRemoved(accountAddress: string) { diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 8b51e77e4a..8bc2c17247 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -128,6 +128,7 @@ export type { TokenListControllerMessenger, } from './TokenListController'; export { TokenListController } from './TokenListController'; +export { TokenListService, buildTokenListMap } from './TokenListService'; export type { ContractExchangeRates, ContractMarketData, diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 6e44cbccdc..f32f937c94 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [71.1.1] + +### Changed + +- Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) +- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) + ## [71.1.0] ### Added @@ -1409,7 +1416,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...HEAD +[71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.0...@metamask/bridge-controller@71.1.1 [71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.0.0...@metamask/bridge-controller@71.1.0 [71.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.2.0...@metamask/bridge-controller@71.0.0 [70.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.1.1...@metamask/bridge-controller@70.2.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 82f8122ee0..9d7c69b7e4 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "71.1.0", + "version": "71.1.1", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -58,8 +58,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^38.0.0", - "@metamask/assets-controller": "^6.3.0", - "@metamask/assets-controllers": "^105.1.0", + "@metamask/assets-controller": "^6.4.0", + "@metamask/assets-controllers": "^106.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^11.20.0", "@metamask/gas-fee-controller": "^26.1.1", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index c6043dc99f..c962d2c1d2 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.0` ([#8706](https://github.com/MetaMask/core/pull/8706)) +- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.1` ([#8706](https://github.com/MetaMask/core/pull/8706), [#8721](https://github.com/MetaMask/core/pull/8721)) - Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691)) - Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 1b8ed01e5b..0815bc942c 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -54,7 +54,7 @@ "dependencies": { "@metamask/accounts-controller": "^38.0.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^71.1.0", + "@metamask/bridge-controller": "^71.1.1", "@metamask/controller-utils": "^11.20.0", "@metamask/gas-fee-controller": "^26.1.1", "@metamask/keyring-controller": "^25.4.0", diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md index 31c761889e..cc63dae2f8 100644 --- a/packages/controller-utils/CHANGELOG.md +++ b/packages/controller-utils/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The event still emits a `FailureReason` when retries are exhausted. - Update `normalizeEnsName` regex to allow ENS names with 3 or more characters (previously required 7 or more) ([#8510](https://github.com/MetaMask/core/pull/8510)) - Update default Sei Mainnet block explorer URL from `seitrace.com` to `seiscan.io` ([#8545](https://github.com/MetaMask/core/pull/8545)) -- Update `InfuraNetworkType`, `ChainId`, `NetworksTicker`, `BlockExplorerUrl`, and `NetworkNickname` to include missing Infura networks ([#8680](https://github.com/MetaMask/core/pull/8680)) +- Update `BUILT_IN_NETWORKS`, `InfuraNetworkType`, `ChainId`, `NetworksTicker`, `BlockExplorerUrl`, `NetworkNickname` to include missing Infura networks ([#8680](https://github.com/MetaMask/core/pull/8680), [#8713](https://github.com/MetaMask/core/pull/8713)) ## [11.20.0] diff --git a/packages/controller-utils/src/constants.ts b/packages/controller-utils/src/constants.ts index 49a1162abc..99f23aa72d 100644 --- a/packages/controller-utils/src/constants.ts +++ b/packages/controller-utils/src/constants.ts @@ -178,6 +178,34 @@ export const BUILT_IN_NETWORKS = { blockExplorerUrl: BlockExplorerUrl['sei-mainnet'], }, }, + [NetworkType['monad-mainnet']]: { + chainId: ChainId['monad-mainnet'], + ticker: NetworksTicker['monad-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['monad-mainnet'], + }, + }, + [NetworkType['zksync-mainnet']]: { + chainId: ChainId['zksync-mainnet'], + ticker: NetworksTicker['zksync-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['zksync-mainnet'], + }, + }, + [NetworkType['megaeth-mainnet']]: { + chainId: ChainId['megaeth-mainnet'], + ticker: NetworksTicker['megaeth-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['megaeth-mainnet'], + }, + }, + [NetworkType['avalanche-mainnet']]: { + chainId: ChainId['avalanche-mainnet'], + ticker: NetworksTicker['avalanche-mainnet'], + rpcPrefs: { + blockExplorerUrl: BlockExplorerUrl['avalanche-mainnet'], + }, + }, [NetworkType.rpc]: { chainId: undefined, blockExplorerUrl: undefined, diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index 629bc3f7e7..04599cd0bd 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -7,14 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.0] + ### Added -- Add `payee` rule to execution permission decoding for all known permission types ([#8668](https://github.com/MetaMask/core/pull/8668)) -- Support `RedeemerEnforcer` caveat when decoding execution permissions ([#8537](https://github.com/MetaMask/core/pull/8537)) - - Permission decoding now recognizes the `RedeemerEnforcer` as an optional caveat on all execution permission types and extracts a `redeemer` rule containing the allowlisted addresses. - - `DecodedPermission` type now includes an optional `rules` property for rules recovered from caveats. - - Export new `EXECUTION_PERMISSION_REDEEMER_RULE_TYPE` constant and `RedeemerRule` type. -- New decoding rules for `native-token-allowance` and `erc20-token-allowance` ([#8553](https://github.com/MetaMask/core/pull/8553)) +- Add `payee` rule decoding for execution permissions, extracting allowed recipient addresses from `AllowedTargetsEnforcer` (native token) and `AllowedCalldataEnforcer` (ERC-20 token) caveats ([#8668](https://github.com/MetaMask/core/pull/8668)) +- Add `redeemer` rule decoding for execution permissions, extracting addresses from `RedeemerEnforcer` caveats ([#8537](https://github.com/MetaMask/core/pull/8537)) +- Add `native-token-allowance` and `erc20-token-allowance` execution permission type decoding ([#8553](https://github.com/MetaMask/core/pull/8553)) ### Changed @@ -234,7 +233,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6033](https://github.com/MetaMask/core/pull/6033)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.1.0...HEAD +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.0.0...@metamask/gator-permissions-controller@4.1.0 [4.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@3.0.1...@metamask/gator-permissions-controller@4.0.0 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@3.0.0...@metamask/gator-permissions-controller@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.2.0...@metamask/gator-permissions-controller@3.0.0 diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index 117b5086a9..917cf5e131 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/gator-permissions-controller", - "version": "4.0.0", + "version": "4.1.0", "description": "Controller for managing gator permissions with profile sync integration", "keywords": [ "Ethereum", diff --git a/packages/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md index 0fa137fdf8..58295ebf4e 100644 --- a/packages/money-account-controller/CHANGELOG.md +++ b/packages/money-account-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Expose missing `MoneyAccountController:init` action through its messenger ([#8718](https://github.com/MetaMask/core/pull/8718)) + - Corresponding action type is available as well. + ## [0.2.0] ### Changed diff --git a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts index b3f244ba9a..5edf280cef 100644 --- a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts +++ b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts @@ -5,6 +5,15 @@ import type { MoneyAccountController } from './MoneyAccountController'; +/** + * Initializes the controller by creating a money account for the primary + * entropy source if one does not already exist. + */ +export type MoneyAccountControllerInitAction = { + type: `MoneyAccountController:init`; + handler: MoneyAccountController['init']; +}; + /** * Creates a money account for the given entropy source. If an account * already exists for that entropy source, it is returned as-is (idempotent). @@ -46,6 +55,7 @@ export type MoneyAccountControllerClearStateAction = { * Union of all MoneyAccountController action types. */ export type MoneyAccountControllerMethodActions = + | MoneyAccountControllerInitAction | MoneyAccountControllerCreateMoneyAccountAction | MoneyAccountControllerGetMoneyAccountAction | MoneyAccountControllerClearStateAction; diff --git a/packages/money-account-controller/src/MoneyAccountController.ts b/packages/money-account-controller/src/MoneyAccountController.ts index 8c1f2f51ff..3898debb25 100644 --- a/packages/money-account-controller/src/MoneyAccountController.ts +++ b/packages/money-account-controller/src/MoneyAccountController.ts @@ -59,6 +59,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'createMoneyAccount', 'getMoneyAccount', 'clearState', + 'init', ] as const; export type MoneyAccountControllerGetStateAction = ControllerGetStateAction< diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index cfc0f01c00..6ad8e5fe97 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -17,4 +17,5 @@ export type { MoneyAccountControllerClearStateAction, MoneyAccountControllerCreateMoneyAccountAction, MoneyAccountControllerGetMoneyAccountAction, + MoneyAccountControllerInitAction, } from './MoneyAccountController-method-action-types'; diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 317a8d9cea..61e18b1741 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expose missing `MultichainAccountService:init` action through its messenger ([#8717](https://github.com/MetaMask/core/pull/8717)) + - Corresponding action type is available as well. - Filter out `KeyringController` locked errors from sentry reporting ([#8619](https://github.com/MetaMask/core/pull/8619)) ### Changed diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts index 7b4d112206..535e341433 100644 --- a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts +++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts @@ -5,6 +5,15 @@ import type { MultichainAccountService } from './MultichainAccountService'; +/** + * Initialize the service and constructs the internal reprensentation of + * multichain accounts and wallets. + */ +export type MultichainAccountServiceInitAction = { + type: `MultichainAccountService:init`; + handler: MultichainAccountService['init']; +}; + /** * Re-synchronize MetaMask accounts and the providers accounts if needed. * @@ -190,6 +199,7 @@ export type MultichainAccountServiceAlignWalletAction = { * Union of all MultichainAccountService action types. */ export type MultichainAccountServiceMethodActions = + | MultichainAccountServiceInitAction | MultichainAccountServiceResyncAccountsAction | MultichainAccountServiceEnsureCanUseSnapPlatformAction | MultichainAccountServiceGetMultichainAccountWalletAction diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts index 283e747c46..16db4776a3 100644 --- a/packages/multichain-account-service/src/MultichainAccountService.ts +++ b/packages/multichain-account-service/src/MultichainAccountService.ts @@ -117,6 +117,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'resyncAccounts', 'removeMultichainAccountWallet', 'ensureCanUseSnapPlatform', + 'init', ] as const; /** diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts index 802cbfbc97..940b2fe675 100644 --- a/packages/multichain-account-service/src/index.ts +++ b/packages/multichain-account-service/src/index.ts @@ -21,6 +21,7 @@ export type { MultichainAccountServiceSetBasicFunctionalityAction, MultichainAccountServiceAlignWalletsAction, MultichainAccountServiceAlignWalletAction, + MultichainAccountServiceInitAction, } from './MultichainAccountService-method-action-types'; export { AccountProviderWrapper, diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md index cbf1c3c755..bb68b38f9d 100644 --- a/packages/passkey-controller/CHANGELOG.md +++ b/packages/passkey-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `PasskeyController` verifies registration and authentication responses with `requireUserVerification: true`, so the WebAuthn user verification (UV) flag must be set; assertions with user presence only no longer pass verification ([#8696](https://github.com/MetaMask/core/pull/8696)) + +### Fixed + +- `generateAuthenticationOptions` now sets `userVerification: 'required'` so client WebAuthn requests align with server-side verification requirements and do not fail on authenticators that skip UV when set to `'preferred'` ([#8696](https://github.com/MetaMask/core/pull/8696)) + ## [2.0.0] ### Added diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts index de37013629..75d6698b5a 100644 --- a/packages/passkey-controller/src/PasskeyController.test.ts +++ b/packages/passkey-controller/src/PasskeyController.test.ts @@ -284,6 +284,11 @@ describe('PasskeyController', () => { ]); expect(options.attestation).toBe('none'); expect(options.timeout).toBe(WEBAUTHN_TIMEOUT_MS); + expect(options.authenticatorSelection).toStrictEqual({ + userVerification: 'required', + authenticatorAttachment: 'platform', + residentKey: 'preferred', + }); expect( (options.extensions as Record)?.prf, ).toBeDefined(); @@ -405,6 +410,21 @@ describe('PasskeyController', () => { }), ).toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony); }); + + it('returns options with userVerification required', () => { + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + const authOpts = controller.generatePostRegistrationAuthenticationOptions( + { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + }, + ); + + expect(authOpts.userVerification).toBe('required'); + }); }); describe('generateAuthenticationOptions', () => { @@ -447,6 +467,26 @@ describe('PasskeyController', () => { (authOpts.extensions as Record)?.prf, ).toBeDefined(); }); + + it('returns options with userVerification required', async () => { + setupRegistrationMocks(); + setupAuthenticationMocks(); + const controller = createController(); + const regOpts = controller.generateRegistrationOptions(); + + await enrollWithPostRegistrationAuth(controller, { + registrationResponse: minimalRegistrationResponse( + undefined, + regOpts.challenge, + ), + vaultKey: 'vault-key', + userHandle: regOpts.user.id, + }); + + const authOpts = controller.generateAuthenticationOptions(); + + expect(authOpts.userVerification).toBe('required'); + }); }); describe('protectVaultKeyWithPasskey', () => { @@ -1673,7 +1713,7 @@ describe('PasskeyController', () => { expect.objectContaining({ expectedOrigin: 'chrome-extension://abc123', expectedRPIDs: ['custom-rp.com'], - requireUserVerification: false, + requireUserVerification: true, }), ); }); @@ -1723,7 +1763,7 @@ describe('PasskeyController', () => { id: TEST_CREDENTIAL_ID, counter: 0, }), - requireUserVerification: false, + requireUserVerification: true, }), ); }); diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts index f8f0298e50..7b5bc2b8b1 100644 --- a/packages/passkey-controller/src/PasskeyController.ts +++ b/packages/passkey-controller/src/PasskeyController.ts @@ -253,7 +253,7 @@ export class PasskeyController extends BaseController< ], timeout: WEBAUTHN_TIMEOUT_MS, authenticatorSelection: { - userVerification: 'preferred', + userVerification: 'required', authenticatorAttachment: 'platform', residentKey: 'preferred', }, @@ -316,7 +316,7 @@ export class PasskeyController extends BaseController< | undefined, }, ], - userVerification: 'preferred', + userVerification: 'required', hints: ['client-device', 'hybrid'], timeout: WEBAUTHN_TIMEOUT_MS, extensions, @@ -356,7 +356,7 @@ export class PasskeyController extends BaseController< transports: record.credential.transports, }, ], - userVerification: 'preferred', + userVerification: 'required', hints: ['client-device', 'hybrid'], timeout: WEBAUTHN_TIMEOUT_MS, extensions, @@ -413,7 +413,7 @@ export class PasskeyController extends BaseController< expectedChallenge: registrationCeremony.challenge, expectedOrigin: this.#expectedOrigin, expectedRPIDs: this.#expectedRPIDs, - requireUserVerification: false, + requireUserVerification: true, }).catch((error) => { log('Error verifying passkey registration response', error); throw new PasskeyControllerError( @@ -724,8 +724,7 @@ export class PasskeyController extends BaseController< counter: credential.counter, transports: credential.transports, }, - // UV optional for device compatibility; vault key remains password-gated. - requireUserVerification: false, + requireUserVerification: true, }).catch((error) => { log( 'Error verifying passkey authentication response', diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index c27f796c78..0f884f1541 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.0.0] + ### Changed - **BREAKING:** Rename `AccountState.availableBalance` to `spendableBalance` and `AccountState.availableToTradeBalance` to `withdrawableBalance` for clearer semantics across abstraction modes ([#8678](https://github.com/MetaMask/core/pull/8678)) @@ -277,7 +279,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@5.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.0...HEAD +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@5.0.0...@metamask/perps-controller@6.0.0 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@4.0.0...@metamask/perps-controller@5.0.0 [4.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.2.0...@metamask/perps-controller@4.0.0 [3.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.1.1...@metamask/perps-controller@3.2.0 diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index c325bdb981..125b1d166c 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/perps-controller", - "version": "5.0.0", + "version": "6.0.0", "description": "Controller for perpetual trading functionality in MetaMask", "keywords": [ "Ethereum", diff --git a/packages/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index cc5baed7a2..2ddacce1db 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) - Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.4.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636)) +- Bump `@metamask/gator-permissions-controller` from `^4.0.0` to `^4.1.0` ([#8712](https://github.com/MetaMask/core/pull/8712)) ## [39.2.0] diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 3df322b587..a89c9bfead 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -58,7 +58,7 @@ "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^11.20.0", "@metamask/eth-sig-util": "^8.2.0", - "@metamask/gator-permissions-controller": "^4.0.0", + "@metamask/gator-permissions-controller": "^4.1.0", "@metamask/keyring-controller": "^25.4.0", "@metamask/logging-controller": "^8.0.1", "@metamask/messenger": "^1.2.0", diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index b1ea46c656..c2fbde7f2b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,16 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix fiat strategy never being selected by routing fiat payment method through `getStrategyOrder` and allowing quote retrieval when no crypto payment token is set ([#8720](https://github.com/MetaMask/core/pull/8720)) + +## [21.1.0] + ### Added -- Allow EIP-7702 authorizations from accounts in the Money keyring ([#8687](https://github.com/MetaMask/core/pull/8687)). +- Allow EIP-7702 authorizations from accounts in the Money keyring ([#8687](https://github.com/MetaMask/core/pull/8687)) - Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347)) ### Changed -- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.0` ([#8706](https://github.com/MetaMask/core/pull/8706)) +- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.1` ([#8706](https://github.com/MetaMask/core/pull/8706), [#8721](https://github.com/MetaMask/core/pull/8721)) - Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691)) - Bump `@metamask/ramps-controller` from `^13.2.0` to `^13.3.0` ([#8698](https://github.com/MetaMask/core/pull/8698)) +- Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) +- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) ## [21.0.0] @@ -775,7 +783,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.1.0...HEAD +[21.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.0.0...@metamask/transaction-pay-controller@21.1.0 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.2.0...@metamask/transaction-pay-controller@21.0.0 [20.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.1.0...@metamask/transaction-pay-controller@20.2.0 [20.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.0.1...@metamask/transaction-pay-controller@20.1.0 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index da58bbfbc1..8d181c5714 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "21.0.0", + "version": "21.1.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -57,10 +57,10 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^6.3.0", - "@metamask/assets-controllers": "^105.1.0", + "@metamask/assets-controller": "^6.4.0", + "@metamask/assets-controllers": "^106.0.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^71.1.0", + "@metamask/bridge-controller": "^71.1.1", "@metamask/bridge-status-controller": "^71.1.0", "@metamask/controller-utils": "^11.20.0", "@metamask/gas-fee-controller": "^26.1.1", diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index bd8e5a4cfe..1e18803732 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -540,6 +540,48 @@ describe('TransactionPayController', () => { CHAIN_ID_MOCK, TOKEN_ADDRESS_MOCK, 'perpsDeposit', + undefined, + ); + }); + + it('passes fiat payment method ID into getStrategyOrder', async () => { + const controller = createController(); + + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1]; + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.paymentToken = { + address: TOKEN_ADDRESS_MOCK, + balanceFiat: '1', + balanceHuman: '1', + balanceRaw: '1', + balanceUsd: '1', + chainId: CHAIN_ID_MOCK, + decimals: 6, + symbol: 'USDC', + }; + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + const transactionMeta = { + id: TRANSACTION_ID_MOCK, + type: 'perpsDeposit', + } as TransactionMeta; + + messenger.call('TransactionPayController:getStrategy', transactionMeta); + + expect(getStrategyOrderMock).toHaveBeenCalledWith( + messenger, + CHAIN_ID_MOCK, + TOKEN_ADDRESS_MOCK, + 'perpsDeposit', + 'card-123', ); }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index e1b7670379..61cb6b4790 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -324,14 +324,15 @@ export class TransactionPayController extends BaseController< return validStrategies; } - const paymentToken = - this.state.transactionData[transaction.id]?.paymentToken; + const transactionData = this.state.transactionData[transaction.id]; + const paymentToken = transactionData?.paymentToken; return getStrategyOrder( this.messenger, paymentToken?.chainId, paymentToken?.address, transaction.type, + transactionData?.fiatPayment?.selectedPaymentMethodId, ); } } diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 6e16c174cb..2257127c07 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -809,6 +809,49 @@ describe('Feature Flags Utils', () => { TransactionPayStrategy.Relay, ]); }); + + it('returns only Fiat strategy when fiatPaymentMethodId is provided', () => { + const strategyOrder = getStrategyOrder( + messenger, + undefined, + undefined, + undefined, + 'card-123', + ); + + expect(strategyOrder).toStrictEqual([TransactionPayStrategy.Fiat]); + }); + + it('returns only Fiat strategy regardless of other routing config when fiatPaymentMethodId is provided', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + strategyOrder: [ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ], + strategyOverrides: { + default: { + chains: { + [CHAIN_ID_MOCK]: [TransactionPayStrategy.Bridge], + }, + }, + }, + }, + }, + }); + + const strategyOrder = getStrategyOrder( + messenger, + CHAIN_ID_MOCK, + TOKEN_ADDRESS_MOCK, + 'perpsDeposit', + '/payments/debit-credit-card', + ); + + expect(strategyOrder).toStrictEqual([TransactionPayStrategy.Fiat]); + }); }); describe('getStrategyOrder route-aware resolution', () => { diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 911c5bc605..491853bfdf 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -312,6 +312,7 @@ function getDefaultOverrideStrategies( * @param tokenAddress - Optional token address used to match route overrides. * @param transactionType - Optional transaction type used to match route * overrides. + * @param fiatPaymentMethodId - Optional fiat payment method ID used to match route overrides. * @returns Ordered strategy list. */ export function getStrategyOrder( @@ -319,7 +320,13 @@ export function getStrategyOrder( chainId?: Hex, tokenAddress?: Hex, transactionType?: string, + fiatPaymentMethodId?: string, ): StrategyOrder { + // If fiat payment method is selected, use Fiat strategy only + if (fiatPaymentMethodId) { + return [TransactionPayStrategy.Fiat]; + } + const routingConfig = getStrategyRoutingConfig(messenger); const normalizedChainId = normalizeHex(chainId); const normalizedTokenAddress = normalizeHex(tokenAddress); diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index c0a9eabcf3..30e37f1040 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -589,6 +589,42 @@ describe('Quotes Utils', () => { }); }); + it('still invokes strategies when no payment token but fiat payment method is set', async () => { + await run({ + transactionData: { + ...TRANSACTION_DATA_MOCK, + paymentToken: undefined, + fiatPayment: { selectedPaymentMethodId: 'card-123' }, + }, + }); + + expect(getQuotesMock).toHaveBeenCalled(); + }); + + it('does not invoke strategies when no payment token and no fiat payment method', async () => { + await run({ + transactionData: { + ...TRANSACTION_DATA_MOCK, + paymentToken: undefined, + fiatPayment: {}, + }, + }); + + const transactionDataMock = { + quotes: [QUOTE_MOCK], + quotesLastUpdated: undefined, + }; + + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + quotesLastUpdated: expect.any(Number), + }); + }); + it('gets quotes from strategy', async () => { await run(); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 3ac79bd2a3..e6d47df328 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -571,7 +571,7 @@ async function getQuotes( }, ); - if (!requests?.length) { + if (!requests?.length && !fiatPaymentMethod) { return { batchTransactions: [], quotes: [], diff --git a/yarn.lock b/yarn.lock index 22b95c7789..2268bfb398 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,7 +2768,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^6.3.0, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^6.4.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -2777,7 +2777,7 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.2.0" "@metamask/accounts-controller": "npm:^38.0.0" - "@metamask/assets-controllers": "npm:^105.1.0" + "@metamask/assets-controllers": "npm:^106.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" @@ -2815,7 +2815,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^105.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^106.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2860,6 +2860,7 @@ __metadata: "@metamask/storage-service": "npm:^1.0.1" "@metamask/transaction-controller": "npm:^65.1.0" "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^29.5.14" @@ -3014,7 +3015,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^71.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^71.1.1, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -3024,8 +3025,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^38.0.0" - "@metamask/assets-controller": "npm:^6.3.0" - "@metamask/assets-controllers": "npm:^105.1.0" + "@metamask/assets-controller": "npm:^6.4.0" + "@metamask/assets-controllers": "npm:^106.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^11.20.0" @@ -3068,7 +3069,7 @@ __metadata: "@metamask/accounts-controller": "npm:^38.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^71.1.0" + "@metamask/bridge-controller": "npm:^71.1.1" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/gas-fee-controller": "npm:^26.1.1" "@metamask/keyring-controller": "npm:^25.4.0" @@ -4099,7 +4100,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/gator-permissions-controller@npm:^4.0.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": +"@metamask/gator-permissions-controller@npm:^4.1.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": version: 0.0.0-use.local resolution: "@metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller" dependencies: @@ -5397,7 +5398,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/gator-permissions-controller": "npm:^4.0.0" + "@metamask/gator-permissions-controller": "npm:^4.1.0" "@metamask/keyring-controller": "npm:^25.4.0" "@metamask/logging-controller": "npm:^8.0.1" "@metamask/messenger": "npm:^1.2.0" @@ -5730,11 +5731,11 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^6.3.0" - "@metamask/assets-controllers": "npm:^105.1.0" + "@metamask/assets-controller": "npm:^6.4.0" + "@metamask/assets-controllers": "npm:^106.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^71.1.0" + "@metamask/bridge-controller": "npm:^71.1.1" "@metamask/bridge-status-controller": "npm:^71.1.0" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/gas-fee-controller": "npm:^26.1.1"