diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0add82d6343..9e666887537 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -897,6 +897,7 @@ /packages/wallet/src/initialization/instances/accounts-controller/ @MetaMask/accounts-engineers /packages/wallet/src/initialization/instances/address-book-controller/ @MetaMask/confirmations /packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations +/packages/wallet/src/initialization/instances/assets-controller/ @MetaMask/metamask-assets /packages/wallet/src/initialization/instances/claims-controller/ @MetaMask/web3auth /packages/wallet/src/initialization/instances/claims-service/ @MetaMask/web3auth /packages/wallet/src/initialization/instances/client-controller/ @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform diff --git a/codeowners.ts b/codeowners.ts index 17eab35aaae..a094d6de8c3 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -44,6 +44,7 @@ const config = { }, 'assets-controller': { teams: ['@MetaMask/metamask-assets'], + initializationPath: 'assets-controller', }, 'assets-controllers': { teams: ['@MetaMask/metamask-assets'], diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 4e2dc5286d9..d76a8da86d8 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `start()` and the `AssetsController:start` messenger action ([#10885](https://github.com/MetaMask/core/pull/10885)) + - `start()` reads enabled networks from `NetworkEnablementController:getState`, creates the data sources, subscribes to external events and to `subscribeToBasicFunctionalityChange`, waits for the Accounts API supported networks, runs the unlock cleanups when the keyring is already unlocked, and starts asset tracking when the UI is open, the keyring is unlocked and the account tree is initialized. + - Call it once the client has constructed and initialized the controllers `AssetsController` depends on, such as `AccountTreeController` and `SnapController`. Repeated calls return the same promise; calls after `destroy()` do nothing. +- Export the `AssetsControllerStartAction` type ([#10885](https://github.com/MetaMask/core/pull/10885)) + +### Changed + +- **BREAKING:** The `AssetsController` constructor no longer calls messenger actions, creates data sources or subscribes to external events; asset tracking does not run until `start()` is called ([#10885](https://github.com/MetaMask/core/pull/10885)) + - Call `start()` (or the `AssetsController:start` action) after the controllers `AssetsController` depends on are registered. Data sources are created in `start()`, so their constructors still read `NetworkController`, `NetworkEnablementController`, `SnapController`, `PermissionController` and `RemoteFeatureFlagController` as before, only later. + - `subscribeToBasicFunctionalityChange` is invoked by `start()` instead of the constructor. + - Before `start()`, `getAssets` (and the methods built on it) with `forceUpdate: true` return what is already in state without fetching and without changing `assetsLoadingStatus`; `handleAssetsUpdate`, `syncAssetsPrices`, `subscribeAssetsPrice` and `handleBasicFunctionalityChange` do nothing; `setSelectedCurrency` updates state without refreshing prices. + ### Fixed - Stop removing launchpad tokens as spam: EVM ERC-20s below the occurrence floor are now kept when Token API `/v2/assets` identifies them as launchpad tokens and their security data does not classify them as malicious ([#11251](https://github.com/MetaMask/core/pull/11251)) diff --git a/packages/assets-controller/src/AssetsController-method-action-types.ts b/packages/assets-controller/src/AssetsController-method-action-types.ts index f456418eb3b..5914febd083 100644 --- a/packages/assets-controller/src/AssetsController-method-action-types.ts +++ b/packages/assets-controller/src/AssetsController-method-action-types.ts @@ -5,6 +5,47 @@ import type { AssetsController } from './AssetsController.js'; +/** + * Start the controller once every controller it depends on is registered. + * + * The constructor does not touch other controllers. `start()` reads enabled + * networks, creates the data sources (whose constructors read network, snap + * and feature-flag state), subscribes to external events and to + * basic-functionality changes, and begins asset tracking when the UI is + * open, the keyring is unlocked and the account tree is initialized. + * + * Call it after the client has constructed and initialized its own + * controllers, such as `AccountTreeController` and `SnapController`. + * Until then, asset tracking does not run: forced fetches return what is + * already in state, and pushed updates are ignored. + * + * Repeated calls return the same promise. Calls after `destroy()` do + * nothing. + * + * @returns A promise that resolves once the controller has started. + */ +export type AssetsControllerStartAction = { + type: `AssetsController:start`; + handler: AssetsController['start']; +}; + +/** + * Get assets for the given accounts, optionally force-fetching them first. + * + * Before `start()` there are no data sources, so a forced fetch is skipped + * and the assets already in state are returned. The loading status of the + * accounts is left untouched, so the first fetch after `start()` still + * reports them as loading. + * + * @param accounts - Accounts to get assets for. + * @param options - Fetch options. + * @param options.chainIds - Chains to include. Defaults to the enabled chains. + * @param options.assetTypes - Asset types to include. Defaults to fungible. + * @param options.forceUpdate - Fetch from the data sources before reading state. + * @param options.bypassServerCache - Also bypass server-side HTTP caches. + * @param options.dataTypes - Data types to fetch. + * @returns Assets keyed by account ID and asset ID. + */ export type AssetsControllerGetAssetsAction = { type: `AssetsController:getAssets`; handler: AssetsController['getAssets']; @@ -217,6 +258,7 @@ export type AssetsControllerSetSelectedCurrencyAction = { * Union of all AssetsController action types. */ export type AssetsControllerMethodActions = + | AssetsControllerStartAction | AssetsControllerGetAssetsAction | AssetsControllerSyncAssetsPricesAction | AssetsControllerGetAssetsBalanceAction diff --git a/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.accounts-api-v6.integration.test.ts b/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.accounts-api-v6.integration.test.ts index fffe15e853a..f7416ec8d30 100644 --- a/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.accounts-api-v6.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.accounts-api-v6.integration.test.ts @@ -82,6 +82,7 @@ async function withController( queryApiClient, isBasicFunctionality: (): boolean => true, }); + await controller.start(); try { return await fn({ controller, messenger: rootMessenger }); diff --git a/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts index a22e8d32317..8d9cbfaf677 100644 --- a/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts @@ -70,6 +70,7 @@ async function withController( queryApiClient, isBasicFunctionality: (): boolean => true, }); + await controller.start(); try { return await fn({ controller, messenger: rootMessenger }); diff --git a/packages/assets-controller/src/AssetsController.launchpad-filtering.integration.test.ts b/packages/assets-controller/src/AssetsController.launchpad-filtering.integration.test.ts index 53b6d6ce305..39c9548ea44 100644 --- a/packages/assets-controller/src/AssetsController.launchpad-filtering.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.launchpad-filtering.integration.test.ts @@ -109,6 +109,7 @@ async function withController( queryApiClient, isBasicFunctionality: (): boolean => true, }); + await controller.start(); try { return await fn({ controller, messenger: rootMessenger }); diff --git a/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts b/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts index b20f349ee67..85444f6bcdf 100644 --- a/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts @@ -102,6 +102,7 @@ async function withController( isBasicFunctionality, captureException, }); + await controller.start(); try { return await fn({ controller, messenger: rootMessenger }); diff --git a/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts b/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts index 066369674f4..087ee81502b 100644 --- a/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts +++ b/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts @@ -120,6 +120,7 @@ async function withController( isBasicFunctionality, captureException, }); + await controller.start(); try { return await fn({ controller, messenger: rootMessenger }); diff --git a/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts b/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts index c7c9425ed77..b6de49e016b 100644 --- a/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.stale-balance-scenarios.integration.test.ts @@ -142,6 +142,7 @@ async function withStaleBalanceController( queryApiClient, isBasicFunctionality: (): boolean => true, }); + await controller.start(); try { // `AccountsApiDataSource` loads `/v2/supportedNetworks` asynchronously diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 4843c19f298..c10f365bcc8 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -278,6 +278,11 @@ type WithControllerOptions = { * Required for tests that rely on asset tracking running (e.g. trace on unlock). */ clientControllerState?: { isUiOpen: boolean }; + /** + * When true, the controller is constructed but `start()` is not called, so + * no data source exists yet. Defaults to false. + */ + skipStart?: boolean; /** * When set, registers RemoteFeatureFlagController:getState so the controller can * read feature flags (e.g. `assetsAccountsApiV6` gating the balances endpoint). @@ -291,6 +296,9 @@ type WithControllerOptions = { isEnabled: () => boolean; captureException: (error: Error) => void; tempMigrateAssetsInfoMetadataAssets3346: () => Assets3346MigrationState; + subscribeToBasicFunctionalityChange: ( + onChange: (isBasic: boolean) => void, + ) => void | (() => void); }>; }; @@ -324,6 +332,7 @@ async function withController( remoteFeatureFlags = {}, queryApiClient = createMockQueryApiClient(), controllerOptions = {}, + skipStart = false, }, fn, ]: [WithControllerOptions, WithControllerCallback] = @@ -440,6 +449,9 @@ async function withController( ...incomingPriceDataSourceConfig, }, }); + if (!skipStart) { + await controller.start(); + } try { return await fn({ controller, messenger, getSelectedAccountsMock }); @@ -773,6 +785,366 @@ describe('AssetsController', () => { }); }); + describe('start', () => { + it('does not call any messenger action during construction', async () => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const callSpy = vi.spyOn(messenger, 'call'); + + const controller = new AssetsController({ + messenger: messenger as unknown as AssetsControllerMessenger, + queryApiClient: createMockQueryApiClient(), + }); + await flushPromises(); + + expect(callSpy).not.toHaveBeenCalled(); + controller.destroy(); + }); + + it('does not invoke subscribeToBasicFunctionalityChange until started', async () => { + const subscribeToBasicFunctionalityChange = vi.fn(); + await withController( + { + skipStart: true, + controllerOptions: { subscribeToBasicFunctionalityChange }, + }, + async ({ controller }) => { + expect(subscribeToBasicFunctionalityChange).not.toHaveBeenCalled(); + + await controller.start(); + + expect(subscribeToBasicFunctionalityChange).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('refreshes subscriptions on basic functionality changes and unsubscribes on destroy', async () => { + let notifyBasicFunctionalityChange: + | ((isBasic: boolean) => void) + | undefined; + const unsubscribe = vi.fn(); + await withController( + { + controllerOptions: { + subscribeToBasicFunctionalityChange: (onChange) => { + notifyBasicFunctionalityChange = onChange; + return unsubscribe; + }, + }, + }, + async ({ controller }) => { + const handleSpy = vi.spyOn( + controller, + 'handleBasicFunctionalityChange', + ); + + notifyBasicFunctionalityChange?.(false); + expect(handleSpy).toHaveBeenCalledWith(false); + + controller.destroy(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not start tracking when destroyed while starting', async () => { + await withController( + { + skipStart: true, + clientControllerState: { isUiOpen: true }, + }, + async ({ controller, messenger }) => { + messenger.publish('AccountTreeController:initialized', {} as never); + messenger.publish('KeyringController:unlock'); + const getAssetsSpy = vi.spyOn(controller, 'getAssets'); + + const startPromise = controller.start(); + controller.destroy(); + await startPromise; + await flushPromises(); + + expect(getAssetsSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('reads other controllers only once started', async () => { + await withController( + { skipStart: true }, + async ({ controller, messenger }) => { + const callSpy = vi.spyOn(messenger, 'call'); + + await controller.start(); + + const calledActions = callSpy.mock.calls.map(([action]) => action); + expect(calledActions).toContain( + 'NetworkEnablementController:getState', + ); + expect(calledActions).toContain('NetworkController:getState'); + }, + ); + }); + + it('is idempotent', async () => { + await withController( + { skipStart: true }, + async ({ controller, messenger }) => { + const callSpy = vi.spyOn(messenger, 'call'); + + await Promise.all([controller.start(), controller.start()]); + const callCountAfterFirstStart = callSpy.mock.calls.length; + await controller.start(); + + expect(callSpy.mock.calls).toHaveLength(callCountAfterFirstStart); + }, + ); + }); + + it('is exposed as the AssetsController:start messenger action', async () => { + await withController({ skipStart: true }, async ({ messenger }) => { + const callSpy = vi.spyOn(messenger, 'call'); + + await messenger.call('AssetsController:start'); + + expect(callSpy.mock.calls.map(([action]) => action)).toContain( + 'NetworkEnablementController:getState', + ); + }); + }); + + it('does nothing after the controller is destroyed', async () => { + await withController( + { skipStart: true }, + async ({ controller, messenger }) => { + controller.destroy(); + const callSpy = vi.spyOn(messenger, 'call'); + + await controller.start(); + + expect(callSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores external events published before start', async () => { + await withController( + { skipStart: true, clientControllerState: { isUiOpen: true } }, + async ({ messenger }) => { + const callSpy = vi.spyOn(messenger, 'call'); + + messenger.publish('AccountTreeController:initialized', {} as never); + messenger.publish('KeyringController:unlock'); + await flushPromises(); + + expect(callSpy.mock.calls.map(([action]) => action)).not.toContain( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + }, + ); + }); + + it('starts tracking when the UI is open, the keyring unlocked and the account tree initialized', async () => { + await withController( + { skipStart: true, clientControllerState: { isUiOpen: true } }, + async ({ controller, messenger }) => { + messenger.publish('AccountTreeController:initialized', {} as never); + messenger.publish('KeyringController:unlock'); + await flushPromises(); + const getAssetsSpy = vi.spyOn(controller, 'getAssets'); + + await controller.start(); + await flushPromises(); + + expect(getAssetsSpy).toHaveBeenCalledWith( + [expect.objectContaining({ id: MOCK_ACCOUNT_ID })], + expect.objectContaining({ forceUpdate: true }), + ); + }, + ); + }); + + it('waits for Accounts API supported networks before the first forced fetch', async () => { + let supportedNetworksResolved = false; + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => { + supportedNetworksResolved = true; + resolve({ fullSupport: [1], partialSupport: [] }); + }, 10); + }), + ), + fetchV5MultiAccountBalances: vi.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + await withController( + { + skipStart: true, + queryApiClient, + clientControllerState: { isUiOpen: true }, + }, + async ({ controller, messenger }) => { + messenger.publish('AccountTreeController:initialized', {} as never); + messenger.publish('KeyringController:unlock'); + const supportedNetworksResolvedAtFirstFetch: boolean[] = []; + const getAssets = controller.getAssets.bind(controller); + vi.spyOn(controller, 'getAssets').mockImplementation( + async (...args) => { + supportedNetworksResolvedAtFirstFetch.push( + supportedNetworksResolved, + ); + return getAssets(...args); + }, + ); + + await controller.start(); + await flushPromises(); + + expect(supportedNetworksResolvedAtFirstFetch[0]).toBe(true); + }, + ); + }); + + it('drops balances outside account scopes on start when already unlocked', async () => { + const solanaAssetId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; + await withController( + { + skipStart: true, + state: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '1' }, + [solanaAssetId]: { amount: '2' }, + }, + }, + }, + }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + + await controller.start(); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID][solanaAssetId], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID][ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '1' }); + }, + ); + }); + + describe('before start', () => { + it('returns assets from state without fetching on a forced getAssets', async () => { + const queryApiClient = createMockQueryApiClient(); + await withController( + { + skipStart: true, + queryApiClient, + state: { + assetsInfo: { + [MOCK_NATIVE_ASSET_ID]: { + type: 'native', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '1' }, + }, + }, + }, + }, + async ({ controller }) => { + const assets = await controller.getAssets( + [createMockInternalAccount()], + { chainIds: ['eip155:1'], forceUpdate: true }, + ); + + expect( + assets[MOCK_ACCOUNT_ID][MOCK_NATIVE_ASSET_ID].balance, + ).toStrictEqual({ amount: '1' }); + }, + ); + }); + + it('ignores pushed asset updates', async () => { + await withController({ skipStart: true }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_NATIVE_ASSET_ID]: { amount: '5' } }, + }, + }, + 'AccountsApiDataSource', + ); + + expect(controller.state.assetsBalance).toStrictEqual({}); + }); + }); + + it('does not fetch prices on syncAssetsPrices', async () => { + await withController({ skipStart: true }, async ({ controller }) => { + await controller.syncAssetsPrices([MOCK_NATIVE_ASSET_ID]); + + expect(controller.state.assetsPrice).toStrictEqual({}); + }); + }); + + it('updates the selected currency without fetching prices', async () => { + await withController({ skipStart: true }, async ({ controller }) => { + controller.setSelectedCurrency('eur'); + await flushPromises(); + + expect(controller.state.selectedCurrency).toBe('eur'); + }); + }); + + it('does not throw on subscription and basic functionality calls', async () => { + await withController({ skipStart: true }, async ({ controller }) => { + expect(() => { + controller.subscribeAssetsPrice( + [createMockInternalAccount()], + ['eip155:1'], + ); + controller.unsubscribeAssetsPrice(); + controller.handleBasicFunctionalityChange(true); + }).not.toThrow(); + }); + }); + + it('ignores data source active chain updates', async () => { + await withController( + { skipStart: true, clientControllerState: { isUiOpen: true } }, + async ({ controller, messenger }) => { + messenger.publish('KeyringController:unlock'); + const callSpy = vi.spyOn(messenger, 'call'); + + controller.getOnActiveChainsUpdated()( + 'RpcDataSource', + ['eip155:1'], + [], + ); + + expect(callSpy).not.toHaveBeenCalled(); + }, + ); + }); + }); + }); + describe('addCustomAsset', () => { it('adds a custom asset to an account', async () => { await withController(async ({ controller }) => { @@ -5498,6 +5870,7 @@ describe('AssetsController', () => { /* no-op */ }, }); + await controller.start(); const getAssetsSpy = vi.spyOn(controller, 'getAssets'); @@ -5637,6 +6010,7 @@ describe('AssetsController', () => { /* no-op */ }, }); + await controller.start(); let nativePresentAtPriceFetch: boolean | undefined; const fetchSpy = vi @@ -6154,6 +6528,7 @@ describe('AssetsController', () => { /* no-op */ }, }); + await controller.start(); const getAssetsSpy = vi.spyOn(controller, 'getAssets'); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7a046a342be..bd7c2cc30dc 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -207,6 +207,7 @@ const CONTROLLER_NAME = 'AssetsController' as const; /** Method names exposed as messenger actions (AssetsController:getAssets, etc.) */ const MESSENGER_EXPOSED_METHODS = [ + 'start', 'getAssets', 'getAssetsBalance', 'getAssetMetadata', @@ -697,6 +698,37 @@ function effectiveAccountBalancesV6( * - Data sources are responsible for their own update mechanisms (WebSocket, polling, events) * - The controller does NOT manage polling - it simply receives pushed updates */ +/** + * Data sources and middlewares created by {@link AssetsController.start}. + */ +type AssetsControllerSources = { + accountActivityDataSource: AccountActivityDataSource; + accountsApiDataSource: AccountsApiDataSource; + snapDataSource: SnapDataSource; + rpcDataSource: RpcDataSource; + stakedBalanceDataSource: StakedBalanceDataSource; + tokenDataSource: TokenDataSource; + priceDataSource: PriceDataSource; + customAssetGraduationMiddleware: CustomAssetGraduationMiddleware; + rpcFallbackMiddleware: RpcFallbackMiddleware; +}; + +/** + * Constructor options kept until {@link AssetsController.start} creates the + * data sources. + */ +type AssetsControllerSourceOptions = Pick< + AssetsControllerOptions, + | 'queryApiClient' + | 'accountsApiDataSourceConfig' + | 'priceDataSourceConfig' + | 'stakedBalanceDataSourceConfig' + | 'isOnboarded' + | 'subscribeToBasicFunctionalityChange' +> & { + rpcDataSourceConfig: RpcDataSourceConfig; +}; + export class AssetsController extends BaseController< typeof CONTROLLER_NAME, AssetsControllerState, @@ -862,15 +894,60 @@ export class AssetsController extends BaseController< return this.messenger.call('KeyringController:isUnlocked'); } - readonly #accountActivityDataSource: AccountActivityDataSource; + /** + * Data sources and middlewares. Created by {@link AssetsController.start}, + * not by the constructor: their constructors read other controllers + * (network, snaps, feature flags), which may not be registered yet when this + * controller is constructed. + */ + #sources: AssetsControllerSources | undefined; - readonly #accountsApiDataSource: AccountsApiDataSource; + /** Options forwarded to the data sources when `start()` creates them. */ + readonly #sourceOptions: AssetsControllerSourceOptions; - readonly #snapDataSource: SnapDataSource; + /** The pending or settled `start()` call, so repeated calls share it. */ + #startPromise: Promise | undefined; - readonly #rpcDataSource: RpcDataSource; + /** Whether `start()` has finished creating sources and subscribing. */ + #isStarted = false; - readonly #stakedBalanceDataSource: StakedBalanceDataSource; + /** Whether `destroy()` has been called. */ + #isDestroyed = false; + + /** + * Get the data sources, throwing if `start()` has not created them yet. + * Entry points that can run before `start()` check `#sources` first. + * + * @returns The data sources and middlewares. + */ + #getSources(): AssetsControllerSources { + if (!this.#sources) { + throw new Error( + 'AssetsController has not been started. Call `start()` first.', + ); + } + return this.#sources; + } + + get #accountActivityDataSource(): AccountActivityDataSource { + return this.#getSources().accountActivityDataSource; + } + + get #accountsApiDataSource(): AccountsApiDataSource { + return this.#getSources().accountsApiDataSource; + } + + get #snapDataSource(): SnapDataSource { + return this.#getSources().snapDataSource; + } + + get #rpcDataSource(): RpcDataSource { + return this.#getSources().rpcDataSource; + } + + get #stakedBalanceDataSource(): StakedBalanceDataSource { + return this.#getSources().stakedBalanceDataSource; + } /** * All balance data sources in priority order for chain-claiming and cleanup. @@ -893,13 +970,21 @@ export class AssetsController extends BaseController< ]; } - readonly #priceDataSource: PriceDataSource; + get #priceDataSource(): PriceDataSource { + return this.#getSources().priceDataSource; + } - readonly #customAssetGraduationMiddleware: CustomAssetGraduationMiddleware; + get #customAssetGraduationMiddleware(): CustomAssetGraduationMiddleware { + return this.#getSources().customAssetGraduationMiddleware; + } - readonly #rpcFallbackMiddleware: RpcFallbackMiddleware; + get #rpcFallbackMiddleware(): RpcFallbackMiddleware { + return this.#getSources().rpcFallbackMiddleware; + } - readonly #tokenDataSource: TokenDataSource; + get #tokenDataSource(): TokenDataSource { + return this.#getSources().tokenDataSource; + } #unsubscribeBasicFunctionality: (() => void) | null = null; @@ -975,17 +1060,135 @@ export class AssetsController extends BaseController< } }; - this.#accountActivityDataSource = new AccountActivityDataSource({ - messenger: this.messenger, - onActiveChainsUpdated: this.#onActiveChainsUpdated, - onAssetsUpdate: (response, request): Promise => - this.handleAssetsUpdate( - response, - this.#accountActivityDataSource.getName(), - request, - ), + this.#sourceOptions = { + queryApiClient, + rpcDataSourceConfig: rpcConfig, + accountsApiDataSourceConfig, + priceDataSourceConfig, + stakedBalanceDataSourceConfig, + isOnboarded, + subscribeToBasicFunctionalityChange, + }; + + log('Initializing AssetsController', { + defaultUpdateInterval: this.#defaultUpdateInterval, }); - this.#accountsApiDataSource = new AccountsApiDataSource({ + + // The constructor does not call messenger actions, so construction does + // not depend on the order in which controllers are constructed. Reading + // other controllers, creating data sources and subscribing to events all + // happen in `start()`. + this.#registerActionHandlers(); + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + /** + * Start the controller once every controller it depends on is registered. + * + * The constructor does not touch other controllers. `start()` reads enabled + * networks, creates the data sources (whose constructors read network, snap + * and feature-flag state), subscribes to external events and to + * basic-functionality changes, and begins asset tracking when the UI is + * open, the keyring is unlocked and the account tree is initialized. + * + * Call it after the client has constructed and initialized its own + * controllers, such as `AccountTreeController` and `SnapController`. + * Until then, asset tracking does not run: forced fetches return what is + * already in state, and pushed updates are ignored. + * + * Repeated calls return the same promise. Calls after `destroy()` do + * nothing. + * + * @returns A promise that resolves once the controller has started. + */ + async start(): Promise { + this.#startPromise ??= this.#runStart(); + return this.#startPromise; + } + + async #runStart(): Promise { + if (this.#isDestroyed) { + return; + } + + log('Starting AssetsController'); + + this.#initializeState(); + this.#sources = this.#createSources(); + this.#subscribeToEvents(); + this.#subscribeToBasicFunctionality(); + + await Promise.all([ + // AccountsApiDataSource fetches its supported networks asynchronously + // from its constructor. Wait for them so the first forced fetch can use + // the Accounts API instead of routing every chain to the slow lane. + // `fetchQuery` shares the in-flight request, so this does not issue a + // second one. Failures are logged by the data source and do not reject. + this.#getSources().accountsApiDataSource.refreshActiveChains(), + this.#runUnlockCleanupsIfUnlocked(), + ]); + + if (this.#isDestroyed) { + return; + } + // Chain updates reported while starting are ignored; `#updateActive` + // subscribes with every source's current chains. + this.#isStarted = true; + this.#updateActive(); + } + + /** + * Run the unlock cleanups when the keyring is already unlocked. `start()` + * subscribes to `KeyringController:unlock` too late to see an unlock that + * happened before it. + */ + async #runUnlockCleanupsIfUnlocked(): Promise { + if (!this.#isKeyringUnlocked()) { + return; + } + try { + this.#runOutOfScopeBalanceCleanup(); + } catch { + /* Do nothing */ + } + await this.#runSpamCleanup(); + } + + /** + * Create every data source and middleware. + * + * Data source constructors read other controllers and may report active + * chains synchronously. `#handleActiveChainsUpdate` ignores those reports + * until `#sources` is assigned, and `start()` subscribes afterwards with + * every source's chains. + * + * @returns The data sources and middlewares. + */ + #createSources(): AssetsControllerSources { + const { + queryApiClient, + rpcDataSourceConfig, + accountsApiDataSourceConfig, + priceDataSourceConfig, + stakedBalanceDataSourceConfig, + isOnboarded, + } = this.#sourceOptions; + + const accountActivityDataSource: AccountActivityDataSource = + new AccountActivityDataSource({ + messenger: this.messenger, + onActiveChainsUpdated: this.#onActiveChainsUpdated, + onAssetsUpdate: (response, request): Promise => + this.handleAssetsUpdate( + response, + accountActivityDataSource.getName(), + request, + ), + }); + const accountsApiDataSource = new AccountsApiDataSource({ messenger: this.messenger, queryApiClient, onActiveChainsUpdated: this.#onActiveChainsUpdated, @@ -994,7 +1197,7 @@ export class AssetsController extends BaseController< getAssetsState: (): AssetsControllerState => this.state, getAssetVisibility: this.#getAssetVisibility.bind(this), }); - this.#snapDataSource = new SnapDataSource({ + const snapDataSource = new SnapDataSource({ messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, onAssetsUpdate: (response): Promise => @@ -1003,7 +1206,7 @@ export class AssetsController extends BaseController< getAssetVisibility: this.#getAssetVisibility.bind(this), getAssetsState: (): AssetsControllerState => this.state, }); - this.#rpcDataSource = new RpcDataSource({ + const rpcDataSource = new RpcDataSource({ messenger: this.messenger, getAssetsState: (): AssetsControllerState => this.state, isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), @@ -1012,17 +1215,17 @@ export class AssetsController extends BaseController< getNativeAssetForChain: (chainId: ChainId): Caip19AssetId | undefined => this.#getNativeAssetForChain(chainId), queryApiClient, - ...rpcConfig, - isOnboarded: rpcConfig.isOnboarded ?? isOnboarded, + ...rpcDataSourceConfig, + isOnboarded: rpcDataSourceConfig.isOnboarded ?? isOnboarded, getAssetType: (assetId: Caip19AssetId): 'native' | 'erc20' | 'spl' => this.#getAssetType(assetId), }); - this.#stakedBalanceDataSource = new StakedBalanceDataSource({ + const stakedBalanceDataSource = new StakedBalanceDataSource({ messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, ...stakedBalanceDataSourceConfig, }); - this.#tokenDataSource = new TokenDataSource(this.messenger, { + const tokenDataSource = new TokenDataSource(this.messenger, { queryApiClient, getNativeAssetIds: (): Caip19AssetId[] => { return this.#getNativeAssetIdsForEnabledChains(); @@ -1031,13 +1234,13 @@ export class AssetsController extends BaseController< this.#getAssetType(assetId), getAssetsState: (): AssetsControllerState => this.state, }); - this.#priceDataSource = new PriceDataSource({ + const priceDataSource = new PriceDataSource({ queryApiClient, getSelectedCurrency: (): SupportedCurrency => this.state.selectedCurrency, getAssetsState: (): AssetsControllerState => this.state, ...priceDataSourceConfig, }); - this.#customAssetGraduationMiddleware = new CustomAssetGraduationMiddleware( + const customAssetGraduationMiddleware = new CustomAssetGraduationMiddleware( { getSelectedAccountId: (): AccountId | undefined => { try { @@ -1051,37 +1254,43 @@ export class AssetsController extends BaseController< getAssetsState: (): AssetsControllerState => this.state, }, ); - this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({ - rpcDataSource: this.#rpcDataSource, + const rpcFallbackMiddleware = new RpcFallbackMiddleware({ + rpcDataSource, isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), getAssetsState: (): AssetsControllerState => this.state, }); - log('Initializing AssetsController', { - defaultUpdateInterval: this.#defaultUpdateInterval, - }); - - this.#initializeState(); - this.#subscribeToEvents(); - this.#registerActionHandlers(); - // Subscriptions start only when both UI is open and keyring unlocked -> #updateActive(). + return { + accountActivityDataSource, + accountsApiDataSource, + snapDataSource, + rpcDataSource, + stakedBalanceDataSource, + tokenDataSource, + priceDataSource, + customAssetGraduationMiddleware, + rpcFallbackMiddleware, + }; + } - // Subscribe to basic-functionality changes after construction so a synchronous - // onChange during subscribe cannot run before data sources are initialized. - if (subscribeToBasicFunctionalityChange) { - const unsubscribe = subscribeToBasicFunctionalityChange((isBasic) => - this.handleBasicFunctionalityChange(isBasic), - ); - if (typeof unsubscribe === 'function') { - this.#unsubscribeBasicFunctionality = unsubscribe; - } + /** + * Subscribe to the host's basic-functionality setting, if the host provided + * a way to. Runs from `start()`, after the data sources exist, so a + * synchronous `onChange` cannot run before they are created. + */ + #subscribeToBasicFunctionality(): void { + const { subscribeToBasicFunctionalityChange } = this.#sourceOptions; + if (!subscribeToBasicFunctionalityChange) { + return; + } + const unsubscribe = subscribeToBasicFunctionalityChange((isBasic) => + this.handleBasicFunctionalityChange(isBasic), + ); + if (typeof unsubscribe === 'function') { + this.#unsubscribeBasicFunctionality = unsubscribe; } } - // ============================================================================ - // INITIALIZATION - // ============================================================================ - #initializeNativeAssetsMap(queryApiClient: ApiPlatformClient): void { queryApiClient.queryClient .fetchQuery({ @@ -1453,9 +1662,9 @@ export class AssetsController extends BaseController< this.#isKeyringUnlocked() && this.#isAccountTreeInitialized(); if (shouldRun) { - this.#start(); + this.#startTracking(); } else { - this.#stop(); + this.#stopTracking(); } } @@ -1516,6 +1725,12 @@ export class AssetsController extends BaseController< activeChains: ChainId[], previousChains: ChainId[], ): void { + // Data sources report their chains while `start()` is still running. + // `start()` subscribes afterwards with every source's chains. Subscribing + // here first would make `#startTracking` skip the startup refresh. + if (!this.#isStarted) { + return; + } if (!this.#isUiOpen() || !this.#isKeyringUnlocked() || !this.#isEnabled()) { return; } @@ -1591,7 +1806,23 @@ export class AssetsController extends BaseController< // PUBLIC API: QUERY METHODS // ============================================================================ - @trackAssetsLoading + /** + * Get assets for the given accounts, optionally force-fetching them first. + * + * Before `start()` there are no data sources, so a forced fetch is skipped + * and the assets already in state are returned. The loading status of the + * accounts is left untouched, so the first fetch after `start()` still + * reports them as loading. + * + * @param accounts - Accounts to get assets for. + * @param options - Fetch options. + * @param options.chainIds - Chains to include. Defaults to the enabled chains. + * @param options.assetTypes - Asset types to include. Defaults to fungible. + * @param options.forceUpdate - Fetch from the data sources before reading state. + * @param options.bypassServerCache - Also bypass server-side HTTP caches. + * @param options.dataTypes - Data types to fetch. + * @returns Assets keyed by account ID and asset ID. + */ async getAssets( accounts: InternalAccount[], options?: { @@ -1607,6 +1838,33 @@ export class AssetsController extends BaseController< bypassServerCache?: boolean; dataTypes?: DataType[]; }, + ): Promise>> { + if (options?.forceUpdate && !this.#sources) { + log('Skipping forced asset fetch: controller has not been started'); + return this.#getAssetsTracked(accounts, { + ...options, + forceUpdate: false, + }); + } + return this.#getAssetsTracked(accounts, options); + } + + @trackAssetsLoading + async #getAssetsTracked( + accounts: InternalAccount[], + options?: { + chainIds?: ChainId[]; + assetTypes?: AssetType[]; + forceUpdate?: boolean; + /** + * Also bypass server-side HTTP caches (e.g. the Accounts API's 60s + * cache, via a random `bypassServerCache` query param). Only meaningful + * together with `forceUpdate`. Use sparingly — e.g. right after a + * transaction confirms, when the API's cached snapshot is known stale. + */ + bypassServerCache?: boolean; + dataTypes?: DataType[]; + }, ): Promise>> { const chainIds = options?.chainIds ?? [...this.#enabledChains]; const assetTypes = options?.assetTypes ?? ['fungible']; @@ -1647,7 +1905,11 @@ export class AssetsController extends BaseController< * @param assetIds - CAIP-19 asset IDs to sync. */ async syncAssetsPrices(assetIds: Caip19AssetId[]): Promise { - if (!this.#isBasicFunctionality() || assetIds.length === 0) { + if ( + !this.#sources || + !this.#isBasicFunctionality() || + assetIds.length === 0 + ) { return; } @@ -2552,7 +2814,7 @@ export class AssetsController extends BaseController< selectedCurrency, }); - if (!this.#isBasicFunctionality()) { + if (!this.#sources || !this.#isBasicFunctionality()) { return; } @@ -2580,7 +2842,7 @@ export class AssetsController extends BaseController< * @param chainIds - Chain IDs to filter prices for. */ subscribeAssetsPrice(accounts: InternalAccount[], chainIds: ChainId[]): void { - if (!this.#isBasicFunctionality()) { + if (!this.#sources || !this.#isBasicFunctionality()) { return; } const subscriptionKey = 'ds:PriceDataSource'; @@ -3452,7 +3714,7 @@ export class AssetsController extends BaseController< * Idempotent — returns early if accounts/chains are not yet available or * subscriptions are already active. */ - #start(): void { + #startTracking(): void { const accounts = this.#getSelectedAccounts(); const chainIds = [...this.#enabledChains]; @@ -3483,7 +3745,7 @@ export class AssetsController extends BaseController< * Stop asset tracking: unsubscribe from all updates. * Called when app closes or keyring locks. */ - #stop(): void { + #stopTracking(): void { log('Stopping asset tracking', { activeSubscriptionCount: this.#activeSubscriptions.size, hasPriceSubscription: this.#activeSubscriptions.has('ds:PriceDataSource'), @@ -3492,6 +3754,10 @@ export class AssetsController extends BaseController< this.#firstInitFetchReported = false; this.#stateSizeReported = false; + if (!this.#sources) { + return; + } + // Stop price subscription first (uses direct messenger call) this.unsubscribeAssetsPrice(); @@ -3527,7 +3793,10 @@ export class AssetsController extends BaseController< * @param _isBasic - The new value (for call-site clarity; the getter is the source of truth). */ handleBasicFunctionalityChange(_isBasic: boolean): void { - this.#stop(); + if (!this.#sources) { + return; + } + this.#stopTracking(); this.#subscribeAssets(); } @@ -4224,6 +4493,13 @@ export class AssetsController extends BaseController< assetsPrice: response.assetsPrice, }); + if (!this.#sources) { + log('Ignoring assets update: controller has not been started', { + sourceId, + }); + return; + } + if (this.#isBalanceV6Enabled()) { await this.#handleAssetsUpdateV6(response, sourceId, request); return; @@ -4429,19 +4705,23 @@ export class AssetsController extends BaseController< destroy(): void { log('Destroying AssetsController', { - dataSourceCount: this.#allBalanceDataSources.length, + isStarted: this.#sources !== undefined, subscriptionCount: this.#activeSubscriptions.size, }); - // Destroy instantiated data sources - this.#accountActivityDataSource?.destroy?.(); - this.#accountsApiDataSource?.destroy?.(); - this.#snapDataSource?.destroy?.(); - this.#rpcDataSource?.destroy?.(); - this.#stakedBalanceDataSource?.destroy?.(); + this.#isDestroyed = true; + + // Destroy instantiated data sources (none exist before `start()`) + if (this.#sources) { + this.#sources.accountActivityDataSource.destroy(); + this.#sources.accountsApiDataSource.destroy(); + this.#sources.snapDataSource.destroy(); + this.#sources.rpcDataSource.destroy(); + this.#sources.stakedBalanceDataSource.destroy(); + } // Stop all active subscriptions - this.#stop(); + this.#stopTracking(); if (this.#unsubscribeBasicFunctionality) { this.#unsubscribeBasicFunctionality(); @@ -4449,6 +4729,7 @@ export class AssetsController extends BaseController< } // Unregister action handlers + this.messenger.unregisterActionHandler('AssetsController:start'); this.messenger.unregisterActionHandler('AssetsController:getAssets'); this.messenger.unregisterActionHandler('AssetsController:getAssetsBalance'); this.messenger.unregisterActionHandler('AssetsController:getAssetMetadata'); diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts index ebbaf01d5ca..01d16e7de20 100644 --- a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -113,6 +113,7 @@ async function withController( queryApiClient, isBasicFunctionality: (): boolean => true, }); + await controller.start(); try { return await fn({ controller, messenger: rootMessenger }); diff --git a/packages/assets-controller/src/README.md b/packages/assets-controller/src/README.md index 8cd99a71f61..31b4497113e 100644 --- a/packages/assets-controller/src/README.md +++ b/packages/assets-controller/src/README.md @@ -39,9 +39,14 @@ The `AssetsController` is a unified asset management system that provides real-t ### 1. Initialization -When the `AssetsController` constructor is called, it performs the following steps **synchronously**: +Initialization has two phases, so that construction does not depend on the order in which controllers are constructed: -#### 1.1 Base Controller Setup +- **Constructor**: sets up state, stores options and registers action handlers. It does not call messenger actions, create data sources or subscribe to external events. +- **`start()`** (also the `AssetsController:start` action): called by the client once the controllers `AssetsController` depends on (for example `AccountTreeController` and `SnapController`) are registered. It performs steps 1.2, 1.3 and 1.5, subscribes to `subscribeToBasicFunctionalityChange`, runs the unlock cleanups if the keyring is already unlocked, and starts tracking (section 2) when the UI is open, the keyring is unlocked and the account tree is initialized. Repeated calls return the same promise. + +Before `start()`, forced fetches return what is already in state, and pushed updates are ignored. + +#### 1.1 Base Controller Setup (constructor) ```typescript super({ @@ -52,7 +57,7 @@ super({ }); ``` -#### 1.2 Initialize Internal State +#### 1.2 Initialize Internal State (`start()`) ```typescript initializeState() @@ -64,7 +69,7 @@ get selectedAccounts(): InternalAccount[] └── return messenger.call('AccountTreeController:getAccountsFromSelectedAccountGroup') ``` -#### 1.3 Subscribe to External Events +#### 1.3 Subscribe to External Events (`start()`) | Event | Handler | Purpose | | -------------------------------------------------- | -------------------------------- | -------------------------------------- | @@ -72,13 +77,14 @@ get selectedAccounts(): InternalAccount[] | `AccountTreeController:initialized` | `updateActive()` | Start tracking once the tree is built | | `AccountTreeController:uninitialized` | `updateActive()` | Stop tracking when the tree is cleared | | `NetworkEnablementController:stateChange` | `handleEnabledNetworksChanged()` | Track enabled chains | -| `KeyringController:unlock` | `start()` | Start subscriptions | -| `KeyringController:lock` | `stop()` | Stop subscriptions | +| `KeyringController:unlock` | `startTracking()` | Start subscriptions | +| `KeyringController:lock` | `stopTracking()` | Stop subscriptions | -#### 1.4 Register Action Handlers +#### 1.4 Register Action Handlers (constructor) ```typescript registerActionHandlers() +├── AssetsController:start ├── AssetsController:getAssets ├── AssetsController:getAssetsBalance ├── AssetsController:getAssetMetadata @@ -87,7 +93,7 @@ registerActionHandlers() └── AssetsController:assetsUpdate // Data sources push asset updates ``` -#### 1.5 Balance data source priority +#### 1.5 Balance data source priority (data sources created in `start()`) Built-in balance data sources are fixed and processed in priority order: AccountActivityDataSource, AccountsApiDataSource, SnapDataSource, RpcDataSource. Earlier sources get first pick for chain assignment; later sources act as fallbacks. Data sources report active chains via the `onActiveChainsUpdated` callback passed at construction. @@ -125,7 +131,7 @@ executeMiddlewares([ When the keyring unlocks: ``` -start() // Called by KeyringController:unlock +startTracking() // Called by KeyringController:unlock │ ├── subscribeAssets() │ │ @@ -239,7 +245,7 @@ Result: Earlier registered sources get first pick; later sources act as fallback When the keyring locks: ``` -stop() // Called by KeyringController:lock +stopTracking() // Called by KeyringController:lock │ ├── For each activeSubscription: │ └── subscription.unsubscribe() @@ -265,7 +271,7 @@ destroy() │ └── dataSources.clear() │ ├── Stop all active subscriptions -│ └── stop() +│ └── stopTracking() │ └── Unregister action handlers ├── AssetsController:getAssets @@ -890,6 +896,9 @@ const assetsController = new AssetsController({ state: existingState, // Optional: restore persisted state defaultUpdateInterval: 30_000, // Optional: polling hint (30s default) }); + +// After the client has registered AccountTreeController, SnapController, etc. +await assetsController.start(); ``` ### Basic Usage (Controllers) diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index dd56b5ee3c7..2224eed60da 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -39,6 +39,7 @@ export type { AssetsControllerEvents, } from './AssetsController.js'; export type { + AssetsControllerStartAction, AssetsControllerGetAssetsAction, AssetsControllerGetAssetsBalanceAction, AssetsControllerGetAssetMetadataAction, diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 0e6c46ef2d9..dc3ef36678e 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Add `AssetsController` to the default initialization configurations ([#10885](https://github.com/MetaMask/core/pull/10885)) + - Requires `instanceOptions.assetsController.queryApiClient`. + - `AssetsController` does not track assets until the client calls `AssetsController:start` (or `AssetsController.start()`). Call it after the client has registered and initialized the controllers `AssetsController` depends on that `Wallet` does not construct, such as `AccountTreeController` and `SnapController`. `Wallet.init()` does not start it. + - Delegates `AccountTreeController` and `SnapController` actions and events, which the client registers. + - Also delegates the other external `AssetsControllerMessenger` actions and events (accounts, client, keyring, network, network enablement, permission, phishing, remote feature flags, preferences, and transactions). `Wallet` constructs the ones it already initializes by default. + - Hosts that already register `AssetsController` on the wallet root messenger must remove that registration before upgrading, or messenger registration will collide. + +### Changed + +- Add `@metamask/assets-controller` `^20.2.0` as a dependency ([#10885](https://github.com/MetaMask/core/pull/10885)) + ## [21.0.0] ### Added diff --git a/packages/wallet/package.json b/packages/wallet/package.json index f3530821ed6..7fa9a7eff51 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -55,6 +55,7 @@ "@metamask/address-book-controller": "^8.0.0", "@metamask/analytics-controller": "^4.1.0", "@metamask/approval-controller": "^10.0.0", + "@metamask/assets-controller": "^20.2.0", "@metamask/base-controller": "^10.1.0", "@metamask/browser-passworder": "^6.0.0", "@metamask/claims-controller": "^1.1.0", diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index 1cc3dbad7a3..69f96e40fc8 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -42,8 +42,22 @@ const REMOTE_FEATURE_FLAG_OPTIONS = { const TEST_ANALYTICS_ID = '11111111-1111-4111-8111-111111111111'; +function createMockQueryApiClient(): WalletOptions['instanceOptions']['assetsController']['queryApiClient'] { + const cache = new Map(); + return { + getCachedData: vi.fn((key: string[]) => cache.get(JSON.stringify(key))), + setCachedData: vi.fn((key: string[], data: unknown) => { + cache.set(JSON.stringify(key), data); + }), + queryClient: { + fetchQuery: vi.fn().mockResolvedValue({}), + }, + } as unknown as WalletOptions['instanceOptions']['assetsController']['queryApiClient']; +} + function getInstanceOptions(): WalletOptions['instanceOptions'] { return { + assetsController: { queryApiClient: createMockQueryApiClient() }, analyticsController: { platformAdapter: { track: vi.fn(), diff --git a/packages/wallet/src/initialization/instances/assets-controller/assets-controller.test.ts b/packages/wallet/src/initialization/instances/assets-controller/assets-controller.test.ts new file mode 100644 index 00000000000..95e8d15cadf --- /dev/null +++ b/packages/wallet/src/initialization/instances/assets-controller/assets-controller.test.ts @@ -0,0 +1,413 @@ +import { + AssetsController, + getDefaultAssetsControllerState, +} from '@metamask/assets-controller'; +import type { AssetsControllerGetStateAction } from '@metamask/assets-controller'; +import { ConfigRegistryApiEnv } from '@metamask/config-registry-controller'; +import { Messenger } from '@metamask/messenger'; +import { InMemoryStorageAdapter } from '@metamask/storage-service'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { WalletOptions } from '../../../types.js'; +import { Wallet } from '../../../Wallet.js'; +import { defaultConfigurations } from '../../defaults.js'; +import type { + DefaultActions, + DefaultEvents, + RootMessenger, +} from '../../defaults.js'; +import { AlwaysOnlineAdapter } from '../connectivity-controller/always-online-adapter.js'; +import { assetsController } from './assets-controller.js'; +import type { AssetsControllerInstanceOptions } from './types.js'; + +const controllers: AssetsController[] = []; +const wallets: Wallet[] = []; + +const REMOTE_FEATURE_FLAG_OPTIONS = { + clientConfigApiService: { + fetchRemoteFeatureFlags: async (): Promise<{ + remoteFeatureFlags: Record; + cacheTimestamp: number; + }> => ({ remoteFeatureFlags: {}, cacheTimestamp: Date.now() }), + }, +}; + +const NETWORK_ENABLEMENT_STATE = { + enabledNetworkMap: { eip155: { '0x1': true } }, + nativeAssetIdentifiers: { 'eip155:1': 'eip155:1/slip44:60' }, +}; + +type ActionHandler = (...args: unknown[]) => unknown; + +type AnyMessenger = Messenger; + +describe('assetsController', () => { + afterEach(async () => { + for (const controller of controllers.splice(0)) { + controller.destroy(); + } + + await Promise.all(wallets.splice(0).map((wallet) => wallet.destroy())); + }); + + it('is part of the default initialization configurations', () => { + expect(Object.values(defaultConfigurations)).toContain(assetsController); + }); + + it('initializes an AssetsController with default state', () => { + const rootMessenger = getRootMessenger(); + const messenger = assetsController.getMessenger(rootMessenger); + + const instance = assetsController.init({ + state: undefined, + messenger, + options: { queryApiClient: createMockQueryApiClient() }, + }); + controllers.push(instance); + + expect(instance).toBeInstanceOf(AssetsController); + expect(instance.state).toStrictEqual(getDefaultAssetsControllerState()); + }); + + it('forwards the provided state to the controller', () => { + const rootMessenger = getRootMessenger(); + const messenger = assetsController.getMessenger(rootMessenger); + + const instance = assetsController.init({ + state: { + ...getDefaultAssetsControllerState(), + selectedCurrency: 'eur', + }, + messenger, + options: { queryApiClient: createMockQueryApiClient() }, + }); + controllers.push(instance); + + expect(instance.state.selectedCurrency).toBe('eur'); + }); + + it('forwards the instance options to the controller', async () => { + const unsubscribe = vi.fn(); + const subscribeToBasicFunctionalityChange = vi + .fn() + .mockReturnValue(unsubscribe); + const wallet = new Wallet({ + instanceOptions: { + ...getInstanceOptions(), + assetsController: { + queryApiClient: createMockQueryApiClient(), + subscribeToBasicFunctionalityChange, + }, + }, + }); + + await wallet.getInstance('AssetsController').start(); + await wallet.destroy(); + + expect(subscribeToBasicFunctionalityChange).toHaveBeenCalledTimes(1); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('exposes AssetsController actions over the root messenger', () => { + const rootMessenger = getRootMessenger(); + const messenger = assetsController.getMessenger(rootMessenger); + + const instance = assetsController.init({ + state: undefined, + messenger, + options: { queryApiClient: createMockQueryApiClient() }, + }); + controllers.push(instance); + + expect( + ( + rootMessenger as unknown as Messenger< + 'Root', + AssetsControllerGetStateAction + > + ).call('AssetsController:getState'), + ).toStrictEqual(getDefaultAssetsControllerState()); + }); + + it.each([ + 'AccountsController:getSelectedAccount', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'AccountTreeController:isInitialized', + 'ClientController:getState', + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + 'KeyringController:isUnlocked', + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'NetworkEnablementController:getState', + 'PermissionController:getPermissions', + 'PhishingController:bulkScanTokens', + 'RemoteFeatureFlagController:getState', + 'SnapController:getRunnableSnaps', + 'SnapController:handleRequest', + ])('delegates the %s action', (actionType) => { + const rootMessenger = new Messenger<'Root', DefaultActions, DefaultEvents>({ + namespace: 'Root', + }); + const handler = vi.fn().mockReturnValue('result'); + registerActionHandler(rootMessenger, actionType, handler); + const messenger = assetsController.getMessenger(rootMessenger); + + const result = ( + messenger as unknown as { call(type: string): unknown } + ).call(actionType); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toBe('result'); + }); + + it.each([ + 'AccountActivityService:balanceUpdated', + 'AccountActivityService:statusChanged', + 'AccountsController:accountBalancesUpdated', + 'AccountTreeController:initialized', + 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:uninitialized', + 'ClientController:stateChange', + 'KeyringController:lock', + 'KeyringController:unlock', + 'MultichainTransactionsController:transactionConfirmed', + 'NetworkController:networkAdded', + 'NetworkController:networkDidChange', + 'NetworkController:networkRemoved', + 'NetworkController:stateChange', + 'NetworkEnablementController:stateChange', + 'PermissionController:stateChange', + 'PreferencesController:stateChange', + 'RemoteFeatureFlagController:stateChange', + 'SnapController:snapInstalled', + 'TransactionController:transactionConfirmed', + 'TransactionController:unapprovedTransactionAdded', + ])('delegates the %s event', (eventType) => { + const rootMessenger = new Messenger<'Root', DefaultActions, DefaultEvents>({ + namespace: 'Root', + }); + const messenger = assetsController.getMessenger(rootMessenger); + const listener = vi.fn(); + ( + messenger as unknown as { + subscribe(type: string, handler: ActionHandler): void; + } + ).subscribe(eventType, listener); + + publishEvent(rootMessenger, eventType, 'payload'); + + expect(listener).toHaveBeenCalledWith('payload'); + }); + + it('is constructed and initialized by default while AccountTreeController and SnapController are absent', async () => { + const accountId = 'account-1'; + const nativeAssetId = 'eip155:1/slip44:60'; + const wallet = new Wallet({ + state: { + AssetsController: { + assetsBalance: { + [accountId]: { + [nativeAssetId]: { amount: '1' }, + }, + }, + assetsInfo: { + [nativeAssetId]: { + type: 'native', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + }, + }, + }, + }, + instanceOptions: getInstanceOptions(), + }); + wallets.push(wallet); + + const instance = wallet.getInstance('AssetsController'); + expect(instance).toBeInstanceOf(AssetsController); + + expect(() => + ( + wallet.messenger as unknown as { + call(action: string): unknown; + } + ).call('AccountTreeController:isInitialized'), + ).toThrow(/AccountTreeController:isInitialized has not been/u); + expect(() => + ( + wallet.messenger as unknown as { + call(action: string): unknown; + } + ).call('SnapController:getRunnableSnaps'), + ).toThrow(/SnapController:getRunnableSnaps has not been/u); + + await wallet.init(); + + // Without `start()`, a forced fetch reads what is already in state. + const assets = await instance.getAssets( + [ + { id: accountId } as Parameters< + AssetsController['getAssets'] + >[0][number], + ], + { chainIds: ['eip155:1'], forceUpdate: true }, + ); + + expect(Object.keys(assets[accountId])).toStrictEqual([nativeAssetId]); + }); + + it('exposes AssetsController:start on the wallet messenger', async () => { + const wallet = new Wallet({ + instanceOptions: getInstanceOptions(), + }); + wallets.push(wallet); + await wallet.init(); + + expect( + await ( + wallet.messenger as unknown as { + call(action: 'AssetsController:start'): Promise; + } + ).call('AssetsController:start'), + ).toBeUndefined(); + }); + + it('exposes AssetsController state on the wallet messenger by default', () => { + const wallet = new Wallet({ + instanceOptions: getInstanceOptions(), + }); + wallets.push(wallet); + + expect(wallet.getInstance('AssetsController')).toBeInstanceOf( + AssetsController, + ); + expect( + ( + wallet.messenger as unknown as Messenger< + 'Root', + AssetsControllerGetStateAction + > + ).call('AssetsController:getState'), + ).toStrictEqual(getDefaultAssetsControllerState()); + }); + + it('is destroyed with the Wallet', async () => { + const wallet = new Wallet({ + instanceOptions: getInstanceOptions(), + }); + const destroy = vi.spyOn(wallet.getInstance('AssetsController'), 'destroy'); + + await wallet.destroy(); + + expect(destroy).toHaveBeenCalledTimes(1); + }); +}); + +/** + * Builds a minimal `queryApiClient` that serves the controller's native asset + * query from its own `queryFn`, so no API request is made. + * + * @returns The mock client. + */ +function createMockQueryApiClient(): AssetsControllerInstanceOptions['queryApiClient'] { + const cache = new Map(); + return { + getCachedData: vi.fn((key: string[]) => cache.get(JSON.stringify(key))), + setCachedData: vi.fn((key: string[], data: unknown) => + cache.set(JSON.stringify(key), data), + ), + queryClient: { + fetchQuery: vi.fn( + async (opts: { + queryKey: string[]; + queryFn: () => Promise; + }) => { + const data = await opts.queryFn(); + cache.set(JSON.stringify(opts.queryKey), data); + return data; + }, + ), + }, + } as unknown as AssetsControllerInstanceOptions['queryApiClient']; +} + +function getRootMessenger(): RootMessenger { + const rootMessenger = new Messenger<'Root', DefaultActions, DefaultEvents>({ + namespace: 'Root', + }); + + registerActionHandler( + rootMessenger, + 'NetworkEnablementController:getState', + () => NETWORK_ENABLEMENT_STATE, + ); + + return rootMessenger; +} + +function getInstanceOptions(): WalletOptions['instanceOptions'] { + return { + assetsController: { queryApiClient: createMockQueryApiClient() }, + analyticsController: { + platformAdapter: { + track: vi.fn(), + identify: vi.fn(), + view: vi.fn(), + onSetupCompleted: vi.fn(), + }, + analyticsId: '11111111-1111-4111-8111-111111111111', + }, + configRegistryApiService: { env: ConfigRegistryApiEnv.PRD }, + connectivityController: { + connectivityAdapter: new AlwaysOnlineAdapter(), + }, + gasFeeController: { + clientId: 'test', + }, + networkController: { + infuraProjectId: 'test-infura-project-id', + }, + selectedNetworkController: { + domainProxyMap: new Map(), + }, + storageService: { + storage: new InMemoryStorageAdapter(), + }, + remoteFeatureFlagController: REMOTE_FEATURE_FLAG_OPTIONS, + }; +} + +function registerActionHandler( + parent: RootMessenger, + actionType: string, + handler: ActionHandler, +): void { + const messenger = new Messenger({ + namespace: actionType.split(':')[0], + parent: parent as unknown as AnyMessenger, + }); + + ( + messenger as unknown as { + registerActionHandler(type: string, handler: ActionHandler): void; + } + ).registerActionHandler(actionType, handler); +} + +function publishEvent( + parent: RootMessenger, + eventType: string, + ...payload: unknown[] +): void { + const messenger = new Messenger({ + namespace: eventType.split(':')[0], + parent: parent as unknown as AnyMessenger, + }); + + ( + messenger as unknown as { + publish(type: string, ...payload: unknown[]): void; + } + ).publish(eventType, ...payload); +} diff --git a/packages/wallet/src/initialization/instances/assets-controller/assets-controller.ts b/packages/wallet/src/initialization/instances/assets-controller/assets-controller.ts new file mode 100644 index 00000000000..6f2672837cb --- /dev/null +++ b/packages/wallet/src/initialization/instances/assets-controller/assets-controller.ts @@ -0,0 +1,84 @@ +import { AssetsController } from '@metamask/assets-controller'; +import type { AssetsControllerMessenger } from '@metamask/assets-controller'; +import { Messenger } from '@metamask/messenger'; + +import type { InitializationConfiguration } from '../../types.js'; + +export type { AssetsControllerInstanceOptions } from './types.js'; + +/** + * Initialization configuration for `AssetsController`. + * + * The controller's constructor does not call other controllers, so the + * Wallet can construct it before controllers it depends on that are not + * Wallet defaults yet, such as `AccountTreeController` and `SnapController`. + * + * Asset tracking does not start on its own. Once the client has registered + * and initialized those controllers, it must call `AssetsController:start` + * (or `AssetsController.start()`). That reads enabled networks, creates the + * data sources and subscribes to external events. + */ +export const assetsController: InitializationConfiguration< + AssetsController, + AssetsControllerMessenger +> = { + name: 'AssetsController', + reference: AssetsController, + init: ({ state, messenger, options }) => + new AssetsController({ + ...options, + messenger, + state, + }), + getMessenger: (parent) => { + const messenger: AssetsControllerMessenger = new Messenger({ + namespace: 'AssetsController', + parent, + }); + + parent.delegate({ + messenger, + actions: [ + 'AccountsController:getSelectedAccount', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'AccountTreeController:isInitialized', + 'ClientController:getState', + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + 'KeyringController:isUnlocked', + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + 'NetworkEnablementController:getState', + 'PermissionController:getPermissions', + 'PhishingController:bulkScanTokens', + 'RemoteFeatureFlagController:getState', + 'SnapController:getRunnableSnaps', + 'SnapController:handleRequest', + ], + events: [ + 'AccountActivityService:balanceUpdated', + 'AccountActivityService:statusChanged', + 'AccountsController:accountBalancesUpdated', + 'AccountTreeController:initialized', + 'AccountTreeController:selectedAccountGroupChange', + 'AccountTreeController:uninitialized', + 'ClientController:stateChange', + 'KeyringController:lock', + 'KeyringController:unlock', + 'MultichainTransactionsController:transactionConfirmed', + 'NetworkController:networkAdded', + 'NetworkController:networkDidChange', + 'NetworkController:networkRemoved', + 'NetworkController:stateChange', + 'NetworkEnablementController:stateChange', + 'PermissionController:stateChange', + 'PreferencesController:stateChange', + 'RemoteFeatureFlagController:stateChange', + 'SnapController:snapInstalled', + 'TransactionController:transactionConfirmed', + 'TransactionController:unapprovedTransactionAdded', + ], + }); + + return messenger; + }, +}; diff --git a/packages/wallet/src/initialization/instances/assets-controller/types.ts b/packages/wallet/src/initialization/instances/assets-controller/types.ts new file mode 100644 index 00000000000..92f7042da2d --- /dev/null +++ b/packages/wallet/src/initialization/instances/assets-controller/types.ts @@ -0,0 +1,12 @@ +import type { AssetsControllerOptions } from '@metamask/assets-controller'; + +/** + * Per-instance options for the wallet's `AssetsController`. `queryApiClient` + * is required because each client constructs its own API Platform client + * (product name, bearer token, cache); every other field is optional and + * falls back to the controller's own default. + */ +export type AssetsControllerInstanceOptions = Omit< + AssetsControllerOptions, + 'messenger' | 'state' +>; diff --git a/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.test.ts b/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.test.ts index 17611f8ae39..cc8d905d623 100644 --- a/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.test.ts +++ b/packages/wallet/src/initialization/instances/gas-fee-controller/gas-fee-controller.test.ts @@ -203,8 +203,22 @@ function getRootMessenger(): RootMessenger { return rootMessenger; } +function createMockQueryApiClient(): WalletOptions['instanceOptions']['assetsController']['queryApiClient'] { + const cache = new Map(); + return { + getCachedData: vi.fn((key: string[]) => cache.get(JSON.stringify(key))), + setCachedData: vi.fn((key: string[], data: unknown) => { + cache.set(JSON.stringify(key), data); + }), + queryClient: { + fetchQuery: vi.fn().mockResolvedValue({}), + }, + } as unknown as WalletOptions['instanceOptions']['assetsController']['queryApiClient']; +} + function getInstanceOptions(): WalletOptions['instanceOptions'] { return { + assetsController: { queryApiClient: createMockQueryApiClient() }, analyticsController: { platformAdapter: { track: vi.fn(), diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index 4055cad6ada..55e4d7d1c0a 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -2,6 +2,7 @@ export { accountsController } from './accounts-controller/accounts-controller.js export { addressBookController } from './address-book-controller/address-book-controller.js'; export { analyticsController } from './analytics-controller/analytics-controller.js'; export { approvalController } from './approval-controller/approval-controller.js'; +export { assetsController } from './assets-controller/assets-controller.js'; export { claimsController } from './claims-controller/claims-controller.js'; export { claimsService } from './claims-service/claims-service.js'; export { clientController } from './client-controller/client-controller.js'; diff --git a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts index 05d8f4c3552..9d4dd6fb2e8 100644 --- a/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts +++ b/packages/wallet/src/initialization/instances/transaction-controller/transaction-controller.test.ts @@ -139,8 +139,22 @@ function getRootMessenger(): RootMessenger { return rootMessenger; } +function createMockQueryApiClient(): WalletOptions['instanceOptions']['assetsController']['queryApiClient'] { + const cache = new Map(); + return { + getCachedData: vi.fn((key: string[]) => cache.get(JSON.stringify(key))), + setCachedData: vi.fn((key: string[], data: unknown) => { + cache.set(JSON.stringify(key), data); + }), + queryClient: { + fetchQuery: vi.fn().mockResolvedValue({}), + }, + } as unknown as WalletOptions['instanceOptions']['assetsController']['queryApiClient']; +} + function getInstanceOptions(): WalletOptions['instanceOptions'] { return { + assetsController: { queryApiClient: createMockQueryApiClient() }, analyticsController: { platformAdapter: { track: vi.fn(), diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 5b058134ecc..397877f129a 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -7,6 +7,7 @@ import type { } from './initialization/defaults.js'; import type { AnalyticsControllerInstanceOptions } from './initialization/instances/analytics-controller/types.js'; import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-controller/types.js'; +import type { AssetsControllerInstanceOptions } from './initialization/instances/assets-controller/types.js'; import type { ClaimsServiceInstanceOptions } from './initialization/instances/claims-service/types.js'; import type { ConfigRegistryApiServiceInstanceOptions } from './initialization/instances/config-registry-api-service/types.js'; import type { ConfigRegistryControllerInstanceOptions } from './initialization/instances/config-registry-controller/types.js'; @@ -38,6 +39,7 @@ export type WalletOptions = { export type InstanceSpecificOptions = { analyticsController: AnalyticsControllerInstanceOptions; approvalController?: ApprovalControllerInstanceOptions; + assetsController: AssetsControllerInstanceOptions; claimsService?: ClaimsServiceInstanceOptions; configRegistryApiService: ConfigRegistryApiServiceInstanceOptions; configRegistryController?: ConfigRegistryControllerInstanceOptions; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index dfdc85cf60f..340a61a5cda 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -17,6 +17,9 @@ { "path": "../approval-controller/tsconfig.build.json" }, + { + "path": "../assets-controller/tsconfig.build.json" + }, { "path": "../claims-controller/tsconfig.build.json" }, diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index d16557d7cd7..2e832488a1c 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -13,6 +13,9 @@ { "path": "../approval-controller" }, + { + "path": "../assets-controller" + }, { "path": "../claims-controller" }, diff --git a/packages/wallet/tsconfig.lint.json b/packages/wallet/tsconfig.lint.json index ae0727ede27..08640923e0f 100644 --- a/packages/wallet/tsconfig.lint.json +++ b/packages/wallet/tsconfig.lint.json @@ -23,6 +23,9 @@ { "path": "../approval-controller/tsconfig.lint.json" }, + { + "path": "../assets-controller/tsconfig.lint.json" + }, { "path": "../base-controller/tsconfig.lint.json" }, diff --git a/packages/wallet/vitest.config.ts b/packages/wallet/vitest.config.ts index 831301fb708..82f5ac80ea0 100644 --- a/packages/wallet/vitest.config.ts +++ b/packages/wallet/vitest.config.ts @@ -1,11 +1,16 @@ import { basename } from 'node:path'; import { defineConfig, mergeConfig } from 'vitest/config'; +import { lowerDecorators } from '../../scripts/vitest/lower-decorators.js'; import baseConfig from '../../vitest.config.packages.js'; export default mergeConfig( baseConfig, defineConfig({ + // `AssetsController` uses a decorator that Vitest's type stripper leaves in + // place. Lower it before Node parses the controller source. + plugins: [lowerDecorators()], + test: { name: basename(import.meta.dirname), diff --git a/yarn.lock b/yarn.lock index 3aa875da08d..f6ddb4214bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9051,6 +9051,7 @@ __metadata: "@metamask/address-book-controller": "npm:^8.0.0" "@metamask/analytics-controller": "npm:^4.1.0" "@metamask/approval-controller": "npm:^10.0.0" + "@metamask/assets-controller": "npm:^20.2.0" "@metamask/auto-changelog": "npm:^7.1.0" "@metamask/base-controller": "npm:^10.1.0" "@metamask/browser-passworder": "npm:^6.0.0"