From b0821d89c03723a52f95099267d4440eaa428c8a Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Thu, 3 Sep 2026 17:16:38 +0200 Subject: [PATCH 01/30] feat(assets-controller): resolve customAssets in AccountsAPI --- packages/assets-controller/CHANGELOG.md | 10 + .../src/AssetsController.test.ts | 750 +++++++++-- .../assets-controller/src/AssetsController.ts | 1104 +++++++++++++---- .../data-sources/AbstractDataSource.test.ts | 39 +- .../src/data-sources/AbstractDataSource.ts | 28 + .../data-sources/AccountActivityDataSource.ts | 3 + .../AccountsApiDataSource.test.ts | 335 ++++- .../src/data-sources/AccountsApiDataSource.ts | 271 +++- .../src/data-sources/RpcDataSource.test.ts | 397 +++++- .../src/data-sources/RpcDataSource.ts | 284 ++++- .../services/BalanceFetcher.test.ts | 34 +- .../services/BalanceFetcher.ts | 53 +- .../evm-rpc-services/types/state.ts | 5 +- .../src/middlewares/ParallelMiddleware.ts | 11 + .../middlewares/RpcFallbackMiddleware.test.ts | 526 ++------ .../src/middlewares/RpcFallbackMiddleware.ts | 379 +++--- packages/assets-controller/src/types.ts | 46 +- 17 files changed, 3207 insertions(+), 1068 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 495facb9eb3..14054f7c3dc 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add an optional `customAssets` option to `AssetsController.getAssets`, used only when `assetsAccountsApiV6` is enabled. It overrides which pinned assets are attached to the fetch (sent as `includeAssetIds` and to RPC). When the flag is off, `getAssets` still attaches every pin of the requested accounts, unscoped, matching the previous v5 request ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Add an optional `isBalanceV6Enabled` getter to `AccountsApiDataSource` and `RpcDataSource`. `AssetsController` injects it so the `assetsAccountsApiV6` flag is read in one place; both default to v5 when the getter is omitted([#9651](https://github.com/MetaMask/core/pull/9651)) + +### Changed + +- When `assetsAccountsApiV6` is enabled, Accounts API fetch and middleware report `updateMode: 'full'` so assets on chains in the snapshot (including custom assets returned via `includeAssetIds`) are replaced; pins left in `unprocessedCustomAssets` after RPC fallback keep their prior balances. The v5 path keeps `updateMode: 'merge'` with `replaceCoveredChainBalances` so custom assets are preserved ([#9651](https://github.com/MetaMask/core/pull/9651)) +- **BREAKING:** Remove the unused `updateMode` option from `AssetsController.getAssets`. Apply mode comes only from `DataResponse.updateMode` (data sources). Remove `'update'` from `AssetsUpdateMode` — it was never produced; use `'merge'` to overlay or `'full'` to replace a covered chain slice ([#9651](https://github.com/MetaMask/core/pull/9651)) + ## [16.0.0] ### Changed diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index dc31450f492..1e19332fcdd 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -20,7 +20,9 @@ import type { AssetsControllerMessenger, AssetsControllerState, } from './AssetsController.js'; +import { AccountActivityDataSource } from './data-sources/AccountActivityDataSource.js'; import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource.js'; +import { AccountsApiDataSource } from './data-sources/AccountsApiDataSource.js'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource.js'; import { PriceDataSource } from './data-sources/PriceDataSource.js'; import { RpcDataSource } from './data-sources/RpcDataSource.js'; @@ -703,6 +705,47 @@ describe('AssetsController', () => { }); }); + it('fetches only the newly added asset instead of every pinned asset', async () => { + // Use a valid checksummed address (DAI token address) + const secondAssetId = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + + const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const accountsApiMiddleware = jest.fn(async (ctx, next) => { + capturedCustomAssets.push(ctx.request.customAssets); + return next(ctx); + }); + const middlewareGetter = jest + .spyOn( + AccountsApiDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + + await withController( + { + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + capturedCustomAssets.length = 0; + await controller.addCustomAsset(MOCK_ACCOUNT_ID, secondAssetId); + }, + ); + + // The fetch triggered by adding the second pin must not re-request the + // first pin — the subscription refresh covers it on the next poll. + expect(capturedCustomAssets.length).toBeGreaterThan(0); + for (const customAssets of capturedCustomAssets) { + expect(customAssets).toStrictEqual([secondAssetId]); + } + + middlewareGetter.mockRestore(); + }); + it('does not overwrite an existing balance when re-adding a custom asset', async () => { await withController( { @@ -763,99 +806,6 @@ describe('AssetsController', () => { }); }); - describe('custom asset graduation', () => { - const SOLANA_ASSET_ID = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; - - it('graduates an EVM custom asset when AccountsApiDataSource reports a balance for it', async () => { - await withController(async ({ controller }) => { - await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); - expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain( - MOCK_ASSET_ID, - ); - - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '1000000' }, - }, - }, - }, - 'AccountsApiDataSource', - ); - - expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toBeUndefined(); - }); - }); - - it('graduates an EVM custom asset when AccountActivityDataSource reports a balance for it', async () => { - await withController(async ({ controller }) => { - await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); - - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '1000000' }, - }, - }, - }, - 'AccountActivityDataSource', - ); - - expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toBeUndefined(); - }); - }); - - it('does not graduate when RpcDataSource reports a balance for a custom asset', async () => { - await withController(async ({ controller }) => { - await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); - - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '1000000' }, - }, - }, - }, - 'RpcDataSource', - ); - - expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain( - MOCK_ASSET_ID, - ); - }); - }); - - it('does not graduate a non-EVM (Solana) custom asset', async () => { - await withController( - { - state: { - customAssets: { [MOCK_ACCOUNT_ID]: [SOLANA_ASSET_ID] }, - }, - }, - async ({ controller }) => { - await controller.handleAssetsUpdate( - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [SOLANA_ASSET_ID]: { amount: '1000000' }, - }, - }, - }, - 'AccountsApiDataSource', - ); - - expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain( - SOLANA_ASSET_ID, - ); - }, - ); - }); - }); - describe('getCustomAssets', () => { it('returns empty array for account with no custom assets', async () => { await withController(({ controller }) => { @@ -1463,6 +1413,317 @@ describe('AssetsController', () => { ); }); + it('forwards user-pinned custom assets to the Accounts API v6 endpoint as includeAssetIds', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await withController( + { + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await flushPromises(); + + await controller.addCustomAsset(MOCK_ACCOUNT_ID, customToken); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + + expect(fetchV6MultiAccountBalances).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + includeAssetIds: expect.arrayContaining([customToken]), + }), + expect.anything(), + ); + }, + ); + }); + + it('includes every pinned asset on the v5 force-update request, including other chains', async () => { + const mainnetToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const polygonToken = + 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; + + const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const accountsApiMiddleware = jest.fn(async (ctx, next) => { + capturedCustomAssets.push(ctx.request.customAssets); + return next(ctx); + }); + const middlewareGetter = jest + .spyOn( + AccountsApiDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + + await withController(async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, mainnetToken); + await controller.addCustomAsset(MOCK_ACCOUNT_ID, polygonToken); + + capturedCustomAssets.length = 0; + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + }); + + expect(capturedCustomAssets.length).toBeGreaterThan(0); + for (const customAssets of capturedCustomAssets) { + expect(customAssets).toStrictEqual( + expect.arrayContaining([mainnetToken, polygonToken]), + ); + } + + middlewareGetter.mockRestore(); + }); + + it('scopes the custom assets on the request to the requested chains', async () => { + const mainnetToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const polygonToken = + 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; + + const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const accountsApiMiddleware = jest.fn(async (ctx, next) => { + capturedCustomAssets.push(ctx.request.customAssets); + return next(ctx); + }); + const middlewareGetter = jest + .spyOn( + AccountsApiDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + + await withController( + { + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, mainnetToken); + await controller.addCustomAsset(MOCK_ACCOUNT_ID, polygonToken); + + capturedCustomAssets.length = 0; + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + }, + ); + + // Pins on chains outside the request are dropped when the request is + // built — every data source would only ignore them at fetch time. + expect(capturedCustomAssets.length).toBeGreaterThan(0); + for (const customAssets of capturedCustomAssets) { + expect(customAssets).toStrictEqual([mainnetToken]); + } + + middlewareGetter.mockRestore(); + }); + + it('uses the customAssets option instead of state-pinned assets when provided', async () => { + const pinnedToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const requestedToken = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + + const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const accountsApiMiddleware = jest.fn(async (ctx, next) => { + capturedCustomAssets.push(ctx.request.customAssets); + return next(ctx); + }); + const middlewareGetter = jest + .spyOn( + AccountsApiDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + + await withController( + { + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, pinnedToken); + await controller.addCustomAsset(MOCK_ACCOUNT_ID, requestedToken); + + capturedCustomAssets.length = 0; + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + customAssets: [requestedToken], + }); + }, + ); + + expect(capturedCustomAssets.length).toBeGreaterThan(0); + for (const customAssets of capturedCustomAssets) { + expect(customAssets).toStrictEqual([requestedToken]); + } + + middlewareGetter.mockRestore(); + }); + + it('scopes the customAssets option to the requested chains and drops invalid IDs', async () => { + const polygonToken = + 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; + + const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const accountsApiMiddleware = jest.fn(async (ctx, next) => { + capturedCustomAssets.push(ctx.request.customAssets); + return next(ctx); + }); + const middlewareGetter = jest + .spyOn( + AccountsApiDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + + await withController( + { + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + customAssets: [polygonToken, 'not-a-caip-id' as Caip19AssetId], + }); + }, + ); + + // The off-chain pin and the unparseable ID are both dropped, leaving no + // custom assets on the request. + expect(capturedCustomAssets.length).toBeGreaterThan(0); + for (const customAssets of capturedCustomAssets) { + expect(customAssets).toBeUndefined(); + } + + middlewareGetter.mockRestore(); + }); + + it('normalizes asset IDs passed via the customAssets option', async () => { + const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const accountsApiMiddleware = jest.fn(async (ctx, next) => { + capturedCustomAssets.push(ctx.request.customAssets); + return next(ctx); + }); + const middlewareGetter = jest + .spyOn( + AccountsApiDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + + await withController( + { + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + customAssets: [MOCK_ASSET_ID_LOWERCASE], + }); + }, + ); + + expect(capturedCustomAssets.length).toBeGreaterThan(0); + for (const customAssets of capturedCustomAssets) { + expect(customAssets).toStrictEqual([MOCK_ASSET_ID]); + } + + middlewareGetter.mockRestore(); + }); + + it('forwards user-hidden assets to the Accounts API v6 endpoint as excludeAssetIds', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + const hiddenToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await withController( + { + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await flushPromises(); + + controller.hideAsset(hiddenToken); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + + expect(fetchV6MultiAccountBalances).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + excludeAssetIds: expect.arrayContaining([hiddenToken]), + }), + expect.anything(), + ); + }, + ); + }); + describe('pipeline splitting', () => { it('returns from getAssets before background pipelines complete', async () => { // Spy on handleAssetsUpdate to count how many times state is written. @@ -1540,6 +1801,76 @@ describe('AssetsController', () => { ); }); + it('routes chains carrying unprocessed pinned assets (unprocessedCustomAssets) to the slow-pipeline RPC fetch', async () => { + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [customToken], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + const rpcRequestChainIds: ChainId[][] = []; + const rpcMiddleware = jest.fn(async (ctx, next) => { + rpcRequestChainIds.push(ctx.request.chainIds); + return next(ctx); + }); + const rpcMiddlewareGetter = jest + .spyOn( + RpcDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) + .mockReturnValue(rpcMiddleware) as unknown as jest.SpyInstance; + + await withController( + { + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await flushPromises(); + + await controller.addCustomAsset(MOCK_ACCOUNT_ID, customToken); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + + // Slow pipeline is fire-and-forget; let it run. + await flushPromises(); + }, + ); + + // The chain of the unresolved pin (eip155:1) — a chain AccountsApi + // handled and did NOT flag as errored — is still routed to RPC in the + // slow pipeline so the pin gets fetched. + expect(rpcMiddleware).toHaveBeenCalled(); + expect( + rpcRequestChainIds.some((chains) => chains.includes('eip155:1')), + ).toBe(true); + + rpcMiddlewareGetter.mockRestore(); + }); + it('does not run token or price middleware in getAssets pipelines when isBasicFunctionality is false', async () => { const tokenMiddlewareGetter = jest.spyOn( TokenDataSource.prototype, @@ -2028,6 +2359,119 @@ describe('AssetsController', () => { tokenMiddlewareGetter.mockRestore(); priceMiddlewareGetter.mockRestore(); }); + + it('falls back to RPC for chains a subscription update flagged as errored (e.g. unprocessedNetworks)', async () => { + const rpcMiddlewareGetter = jest.spyOn( + RpcDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) as unknown as jest.SpyInstance; + + const request: DataRequest = { + accountsWithSupportedChains: [], + chainIds: ['eip155:1'], + dataTypes: ['balance'], + }; + + await withController( + { remoteFeatureFlags: { assetsAccountsApiV6: { value: true } } }, + async ({ controller }) => { + rpcMiddlewareGetter.mockClear(); + + await controller.handleAssetsUpdate( + { + assetsBalance: {}, + errors: { 'eip155:1': 'Unprocessed networks' }, + }, + 'AccountsApiDataSource', + request, + ); + }, + ); + + // The RpcFallbackMiddleware pulls the RPC data source middleware only when + // there are errored chains to recover. + expect(rpcMiddlewareGetter).toHaveBeenCalled(); + + rpcMiddlewareGetter.mockRestore(); + }); + + it('falls back to RPC for pinned assets a subscription update reported as unprocessed (unprocessedCustomAssets)', async () => { + const rpcMiddlewareGetter = jest.spyOn( + RpcDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) as unknown as jest.SpyInstance; + + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const request: DataRequest = { + accountsWithSupportedChains: [], + chainIds: ['eip155:1'], + dataTypes: ['balance'], + customAssets: [customToken], + }; + + await withController( + { remoteFeatureFlags: { assetsAccountsApiV6: { value: true } } }, + async ({ controller }) => { + rpcMiddlewareGetter.mockClear(); + + await controller.handleAssetsUpdate( + { + assetsBalance: {}, + unprocessedCustomAssets: [customToken], + }, + 'AccountsApiDataSource', + request, + ); + }, + ); + + // The asset-axis signal also pulls the RPC data source middleware for an + // asset-scoped recovery. + expect(rpcMiddlewareGetter).toHaveBeenCalled(); + + rpcMiddlewareGetter.mockRestore(); + }); + + it('does not run the RPC fallback when a subscription update has no errored chains', async () => { + const rpcMiddlewareGetter = jest.spyOn( + RpcDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) as unknown as jest.SpyInstance; + + const request: DataRequest = { + accountsWithSupportedChains: [], + chainIds: ['eip155:1'], + dataTypes: ['balance'], + }; + + await withController(async ({ controller }) => { + rpcMiddlewareGetter.mockClear(); + + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '1' }, + }, + }, + }, + 'AccountsApiDataSource', + request, + ); + }); + + expect(rpcMiddlewareGetter).not.toHaveBeenCalled(); + + rpcMiddlewareGetter.mockRestore(); + }); }); describe('getAssetsBalance', () => { @@ -2188,6 +2632,58 @@ describe('AssetsController', () => { }); }); + describe('two-axis subscription handoff (chains + custom assets)', () => { + it('claims pinned assets on account-activity-claimed chains instead of letting them fall through', async () => { + // Account activity claims eip155:1; its stream covers pins, so they + // stay with its subscription instead of falling through to a poller. + jest + .spyOn(AccountActivityDataSource.prototype, 'getActiveChainsSync') + .mockReturnValue(['eip155:1' as ChainId]); + const wsSubscribeSpy = jest + .spyOn(AccountActivityDataSource.prototype, 'subscribe') + .mockResolvedValue(undefined); + const rpcSubscribeSpy = jest + .spyOn(RpcDataSource.prototype, 'subscribe') + .mockResolvedValue(undefined); + + await withController( + { remoteFeatureFlags: { assetsAccountsApiV6: { value: true } } }, + async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + const wsRequest = wsSubscribeSpy.mock.calls.at(-1)?.[0].request; + expect(wsRequest?.chainIds).toStrictEqual(['eip155:1']); + expect(wsRequest?.customAssets).toStrictEqual([MOCK_ASSET_ID]); + + // Nothing was left for lower-priority sources to claim. + expect(rpcSubscribeSpy).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not create an RPC subscription for pinned assets no source can claim', async () => { + // Account activity is not active on the pin's chain, so the pin falls + // through the whole handoff... + jest + .spyOn(AccountActivityDataSource.prototype, 'getActiveChainsSync') + .mockReturnValue([]); + jest + .spyOn(AccountActivityDataSource.prototype, 'subscribe') + .mockResolvedValue(undefined); + const rpcSubscribeSpy = jest + .spyOn(RpcDataSource.prototype, 'subscribe') + .mockResolvedValue(undefined); + + await withController(async ({ controller }) => { + // ...and RPC has no provider for the chain (no networks configured in + // the mocked NetworkController), so its real claim returns nothing. + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + expect(rpcSubscribeSpy).not.toHaveBeenCalled(); + }); + }); + }); + describe('handleAssetsUpdate - state updates', () => { it('updates state with balance data', async () => { await withController(async ({ controller }) => { @@ -2560,7 +3056,7 @@ describe('AssetsController', () => { }); }); - it('replaces covered-chain balances in merge mode when replaceCoveredChainBalances is set', async () => { + it('replaces covered-chain balances in full mode, including custom assets', async () => { const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { @@ -2568,13 +3064,15 @@ describe('AssetsController', () => { [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, }, }, + customAssets: { + [MOCK_ACCOUNT_ID]: [MOCK_ASSET_ID], + }, }; await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'merge', - replaceCoveredChainBalances: true, + updateMode: 'full', assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, @@ -2595,7 +3093,48 @@ describe('AssetsController', () => { }); }); - it('preserves existing staked balances when replaceCoveredChainBalances omits them', async () => { + it('preserves unprocessed custom assets that RPC fallback could not recover', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + }, + }, + customAssets: { + [MOCK_ACCOUNT_ID]: [MOCK_ASSET_ID], + }, + }; + + await withController( + { state: initialState, isBasicFunctionality: () => false }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + unprocessedCustomAssets: [MOCK_ASSET_ID], + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ amount: '1' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '2' }); + }, + ); + }); + + it('preserves existing staked balances when a full update omits them', async () => { const stakingAssetId = 'eip155:1/erc20:0x4FEF9D741011476750A243aC70b9789a63dd47Df' as Caip19AssetId; const initialState: Partial = { @@ -2611,8 +3150,7 @@ describe('AssetsController', () => { await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'merge', - replaceCoveredChainBalances: true, + updateMode: 'full', assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, @@ -3193,16 +3731,19 @@ describe('AssetsController', () => { ); }); - it('replaces pre-lock balances on unlock via merge with covered-chain replacement', async () => { - const fetchV5MultiAccountBalances = jest.fn().mockResolvedValue({ + it('replaces pre-lock balances on unlock with a full Accounts API v6 snapshot', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ balances: [ { accountId: 'eip155:1:0x1234567890123456789012345678901234567890', + object: 'token', + type: 'native', assetId: MOCK_NATIVE_ASSET_ID, balance: '2', }, ], unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], }); const queryApiClient = { @@ -3212,7 +3753,11 @@ describe('AssetsController', () => { fullSupport: [1], partialSupport: [], }), - fetchV5MultiAccountBalances, + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), }, } as unknown as ApiPlatformClient; @@ -3220,6 +3765,7 @@ describe('AssetsController', () => { { clientControllerState: { isUiOpen: true }, queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, state: { assetsBalance: { [MOCK_ACCOUNT_ID]: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index e3dffd5630b..5917800101c 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -164,6 +164,7 @@ import type { TransactionPayLegacyFormat, } from './utils/index.js'; import { emitTrace, withTrace } from './utils/trace.js'; +import type { TraceSpanData } from './utils/trace.js'; const NATIVE_ASSETS_QUERY_KEY = ['nativeAssets']; @@ -580,6 +581,15 @@ function normalizeResponse(response: DataResponse): DataResponse { normalized.replaceCoveredChainBalances = true; } + if ( + response.unprocessedCustomAssets && + response.unprocessedCustomAssets.length > 0 + ) { + normalized.unprocessedCustomAssets = response.unprocessedCustomAssets.map( + (assetId) => normalizeAssetId(assetId), + ); + } + return normalized; } @@ -588,16 +598,17 @@ function normalizeResponse(response: DataResponse): DataResponse { * * @param previousBalances - Balances already in state for this account. * @param accountBalances - Balances from the incoming response. - * @param customAssetIds - Custom assets to preserve when replacing covered chains. - * @param replaceCoveredChains - When true, drop prior balances on chains present - * in the response before applying it (authoritative chain slice). + * @param customAssetIds - Custom assets to preserve when replacing covered chains + * on the Accounts API v5 path (`replaceCoveredChainBalances`). + * @param replaceCoveredChains - When true (v5 force refresh), drop prior balances + * on chains present in the response before applying it, then restore custom assets. * @returns The merged balance map for the account. */ -function mergeAccountBalances( +function mergeAccountBalancesV5( previousBalances: Record, accountBalances: Record, - customAssetIds: Caip19AssetId[], - replaceCoveredChains: boolean, + customAssetIds: Caip19AssetId[] = [], + replaceCoveredChains = false, ): Record { if (!replaceCoveredChains) { return { ...previousBalances, ...accountBalances }; @@ -618,8 +629,8 @@ function mergeAccountBalances( for (const customId of customAssetIds) { if (!Object.prototype.hasOwnProperty.call(next, customId)) { - const prev = previousBalances[customId]; - next[customId] = prev ?? ({ amount: '0' } as AssetBalance); + const previous = previousBalances[customId]; + next[customId] = previous ?? ({ amount: '0' } as AssetBalance); } } @@ -638,6 +649,52 @@ function mergeAccountBalances( return next; } +/** + * Apply an Accounts API v6 authoritative chain snapshot. + * + * @param previousBalances - Balances already in state for this account. + * @param accountBalances - Balances from the incoming response. + * @param unprocessedCustomAssets - Pins that v6 and RPC could not resolve. + * @returns The merged balance map for the account. + */ +function mergeAccountBalancesV6( + previousBalances: Record, + accountBalances: Record, + unprocessedCustomAssets: Caip19AssetId[] = [], +): Record { + const coveredChains = new Set( + Object.keys(accountBalances).map((assetId) => assetId.split('/')[0]), + ); + const next: Record = {}; + for (const [assetId, balance] of Object.entries(previousBalances)) { + if (!coveredChains.has(assetId.split('/')[0])) { + next[assetId] = balance; + } + } + Object.assign(next, accountBalances); + + const unprocessedSet = new Set( + unprocessedCustomAssets.map((assetId) => normalizeAssetId(assetId)), + ); + for (const [assetId, balance] of Object.entries(previousBalances)) { + if ( + unprocessedSet.has(normalizeAssetId(assetId as Caip19AssetId)) && + !Object.prototype.hasOwnProperty.call(next, assetId) + ) { + next[assetId] = balance; + } + } + for (const [assetId, balance] of Object.entries(previousBalances)) { + if ( + isStakingContractAssetId(assetId) && + !Object.prototype.hasOwnProperty.call(next, assetId) + ) { + next[assetId] = balance; + } + } + return next; +} + // ============================================================================ // CONTROLLER IMPLEMENTATION // ============================================================================ @@ -966,6 +1023,7 @@ export class AssetsController extends BaseController< queryApiClient, onActiveChainsUpdated: this.#onActiveChainsUpdated, ...accountsApiDataSourceConfig, + isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), }); this.#snapDataSource = new SnapDataSource({ messenger: this.messenger, @@ -988,6 +1046,7 @@ export class AssetsController extends BaseController< isOnboarded: rpcConfig.isOnboarded ?? isOnboarded, getAssetType: (assetId: Caip19AssetId): 'native' | 'erc20' | 'spl' => this.#getAssetType(assetId), + isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), }); this.#stakedBalanceDataSource = new StakedBalanceDataSource({ messenger: this.messenger, @@ -1023,6 +1082,7 @@ export class AssetsController extends BaseController< ); this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({ rpcDataSource: this.#rpcDataSource, + isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), }); log('Initializing AssetsController', { @@ -1619,8 +1679,12 @@ export class AssetsController extends BaseController< bypassServerCache?: boolean; dataTypes?: DataType[]; assetsForPriceUpdate?: Caip19AssetId[]; - /** When set to `'merge'`, fetch result is merged with existing state instead of replacing. Use for partial fetches (e.g. newly added chains). */ - updateMode?: AssetsUpdateMode; + /** + * v6 only: pinned assets to attach instead of every pin in state. + * Ignored when `assetsAccountsApiV6` is off. Entries outside the + * requested chains are dropped. + */ + customAssets?: Caip19AssetId[]; }, ): Promise>> { const chainIds = options?.chainIds ?? [...this.#enabledChains]; @@ -1631,171 +1695,427 @@ export class AssetsController extends BaseController< return this.#getAssetsFromState(accounts, chainIds, assetTypes); } - // Collect custom assets for all requested accounts - const customAssets: Caip19AssetId[] = []; - for (const account of accounts) { - const accountCustomAssets = this.getCustomAssets(account.id); - customAssets.push(...accountCustomAssets); - } - if (options?.forceUpdate) { - // Pipeline spans only on unlock/first-init fetch; later forceUpdates pass - // `undefined` so `emitTrace` / `withTrace` no-op without call-site if/else. const pipelineTrace = this.#firstInitFetchReported ? undefined : this.#trace; - - const request = this.#buildDataRequest(accounts, chainIds, { + const requestOptions = { assetTypes, dataTypes, - customAssets: customAssets.length > 0 ? customAssets : undefined, - forceUpdate: true, + forceUpdate: true as const, bypassServerCache: options?.bypassServerCache, assetsForPriceUpdate: options?.assetsForPriceUpdate, - }); + }; + + if (this.#isBalanceV6Enabled()) { + await this.#forceUpdateAssetsV6({ + accounts, + chainIds, + request: this.#buildForceUpdateRequestV6(accounts, chainIds, { + ...requestOptions, + customAssetsOverride: options?.customAssets, + }), + pipelineTrace, + }); + } else { + await this.#forceUpdateAssetsV5({ + accounts, + chainIds, + request: this.#buildForceUpdateRequestV5( + accounts, + chainIds, + requestOptions, + ), + pipelineTrace, + }); + } + } - // Fast pipeline: accountsApi + stakedBalance → detection → token + price. - // Snap and RPC are excluded here due to their latency (snap triggers account - // creation, RPC is slow on many chains). Results are committed to state - // immediately so the UI can display balances without waiting for them. - // - // Fast/slow pipelines use merge so partial API snapshots cannot wipe - // tokens missing from the response (e.g. USDC when only native balance - // is returned). Balances present in the response are still refreshed. - const fastSources = this.#isBasicFunctionality() - ? [ - createParallelBalanceMiddleware([ - this.#accountsApiDataSource, - this.#stakedBalanceDataSource, - ]), - // Graduation must run BEFORE the RPC fallback so it only sees - // AccountsApi/Websocket balances. RPC intentionally carries - // custom assets and must never trigger graduation. - this.#customAssetGraduationMiddleware, - this.#rpcFallbackMiddleware, - this.#detectionMiddleware, - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : [this.#stakedBalanceDataSource, this.#detectionMiddleware]; + const result = this.#getAssetsFromState(accounts, chainIds, assetTypes); + return result; + } - const { response } = await withTrace({ - name: TRACE_FETCH_PIPELINE, - trace: pipelineTrace, - data: { - chain_count: chainIds.length, - account_count: accounts.length, - basic_functionality: this.#isBasicFunctionality(), - }, - fn: async (parentContext) => { - const startTime = performance.now(); - const result = await this.#executeMiddlewares({ - sources: fastSources, - request, - parentContext, - trace: pipelineTrace, - }); - await this.#updateState({ - ...result.response, - updateMode: 'merge', - replaceCoveredChainBalances: true, - }); + /** + * v5 force-update request: every pin of the requested accounts, unscoped. + * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default. + * + * @param accounts - Accounts in this fetch. + * @param chainIds - Chains in this fetch. + * @param requestOptions - Shared force-update request fields. + * @param requestOptions.assetTypes - Asset types to fetch. + * @param requestOptions.dataTypes - Data types to fetch. + * @param requestOptions.forceUpdate - Always `true` to bypass caches. + * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for. + * @returns The v5 data request. + */ + #buildForceUpdateRequestV5( + accounts: InternalAccount[], + chainIds: ChainId[], + requestOptions: { + assetTypes: AssetType[]; + dataTypes: DataType[]; + forceUpdate: true; + assetsForPriceUpdate?: Caip19AssetId[]; + }, + ): DataRequest { + const customAssets: Caip19AssetId[] = []; + for (const account of accounts) { + customAssets.push(...this.getCustomAssets(account.id)); + } - const durationMs = performance.now() - startTime; - - // Summary fields for Assets Health (nested under the parent span). - emitTrace({ - name: TRACE_FULL_FETCH, - trace: pipelineTrace, - data: { - duration_ms: durationMs, - chain_count: chainIds.length, - account_count: accounts.length, - basic_functionality: this.#isBasicFunctionality(), - asset_count: result.response.assetsBalance - ? Object.values(result.response.assetsBalance).reduce( - (sum, acct) => sum + Object.keys(acct).length, - 0, - ) - : 0, - price_count: result.response.assetsPrice - ? Object.keys(result.response.assetsPrice).length - : 0, - ...result.durationByDataSource, - }, - parentContext, - }); + return this.#buildDataRequest(accounts, chainIds, { + ...requestOptions, + customAssets: customAssets.length > 0 ? customAssets : undefined, + }); + } - emitTrace({ - name: TRACE_FIRST_INIT_FETCH, - trace: pipelineTrace, - data: { - duration_ms: durationMs, - chain_ids: JSON.stringify(chainIds), - ...result.durationByDataSource, - }, - parentContext, - }); + /** + * v6 force-update request: chain-scoped pins (or `customAssets` override) + * plus hidden assets as `excludeAssetIds`. + * + * @param accounts - Accounts in this fetch. + * @param chainIds - Chains in this fetch. + * @param requestOptions - Shared force-update request fields and optional pin override. + * @param requestOptions.assetTypes - Asset types to fetch. + * @param requestOptions.dataTypes - Data types to fetch. + * @param requestOptions.forceUpdate - Always `true` to bypass caches. + * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for. + * @param requestOptions.customAssetsOverride - Pinned assets to use instead of the stored custom assets. + * @returns The v6 data request. + */ + #buildForceUpdateRequestV6( + accounts: InternalAccount[], + chainIds: ChainId[], + requestOptions: { + assetTypes: AssetType[]; + dataTypes: DataType[]; + forceUpdate: true; + assetsForPriceUpdate?: Caip19AssetId[]; + customAssetsOverride?: Caip19AssetId[]; + }, + ): DataRequest { + const requestedChains = new Set(chainIds); + const candidateCustomAssets = + requestOptions.customAssetsOverride ?? + accounts.flatMap((account) => this.getCustomAssets(account.id)); + const customAssetsSet = new Set(); + for (const assetId of candidateCustomAssets) { + try { + const normalizedAssetId = normalizeAssetId(assetId); + if ( + requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) + ) { + customAssetsSet.add(normalizedAssetId); + } + } catch { + // Skip unparseable asset IDs + } + } + const customAssets = [...customAssetsSet]; + const hiddenAssets = this.#getHiddenAssetIds(); + + return this.#buildDataRequest(accounts, chainIds, { + assetTypes: requestOptions.assetTypes, + dataTypes: requestOptions.dataTypes, + forceUpdate: requestOptions.forceUpdate, + assetsForPriceUpdate: requestOptions.assetsForPriceUpdate, + customAssets: customAssets.length > 0 ? customAssets : undefined, + excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, + }); + } - return result; - }, - }); + async #forceUpdateAssetsV5({ + accounts, + chainIds, + request, + pipelineTrace, + }: { + accounts: InternalAccount[]; + chainIds: ChainId[]; + request: DataRequest; + pipelineTrace?: TraceCallback; + }): Promise { + const isBasicFunctionality = this.#isBasicFunctionality(); + const fastSources = isBasicFunctionality + ? [ + createParallelBalanceMiddleware([ + this.#accountsApiDataSource, + this.#stakedBalanceDataSource, + ]), + this.#customAssetGraduationMiddleware, + this.#rpcFallbackMiddleware, + this.#detectionMiddleware, + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ] + : [this.#stakedBalanceDataSource, this.#detectionMiddleware]; + + const fastResponse = await this.#runFastFetchV5({ + accounts, + chainIds, + request, + pipelineTrace, + fastSources, + isBasicFunctionality, + }); + this.#firstInitFetchReported = true; - // Mark after the unlock/first forceUpdate so later polls skip pipeline spans. - this.#firstInitFetchReported = true; + this.#runBackgroundFetch({ + accounts, + request, + pipelineTrace, + chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse), + isBasicFunctionality, + }); + } - // Background (slow) lane — flattened sibling of the fast lane (not nested - // inside its withTrace callback). Still fire-and-forget so UI is not blocked. - const slowPipelineChainIds = this.#getSlowPipelineChainIds( - chainIds, - response, - ); + async #forceUpdateAssetsV6({ + accounts, + chainIds, + request, + pipelineTrace, + }: { + accounts: InternalAccount[]; + chainIds: ChainId[]; + request: DataRequest; + pipelineTrace?: TraceCallback; + }): Promise { + const isBasicFunctionality = this.#isBasicFunctionality(); + const fastSources = isBasicFunctionality + ? [ + createParallelBalanceMiddleware([ + this.#accountsApiDataSource, + this.#stakedBalanceDataSource, + ]), + this.#detectionMiddleware, + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ] + : [this.#stakedBalanceDataSource, this.#detectionMiddleware]; + + const fastResponse = await this.#runFastFetchV6({ + accounts, + chainIds, + request, + pipelineTrace, + fastSources, + isBasicFunctionality, + }); + this.#firstInitFetchReported = true; - if (slowPipelineChainIds.length > 0) { - const slowSources = this.#isBasicFunctionality() - ? [this.#snapDataSource, this.#rpcDataSource] - : [this.#rpcDataSource]; + this.#runBackgroundFetch({ + accounts, + request, + pipelineTrace, + chainIds: this.#getSlowPipelineChainIds(chainIds, fastResponse), + isBasicFunctionality, + }); + } - const slowRequest = { ...request, chainIds: slowPipelineChainIds }; + async #runFastFetchV5({ + accounts, + chainIds, + request, + pipelineTrace, + fastSources, + isBasicFunctionality, + }: { + accounts: InternalAccount[]; + chainIds: ChainId[]; + request: DataRequest; + pipelineTrace?: TraceCallback; + fastSources: AssetsDataSource[]; + isBasicFunctionality: boolean; + }): Promise { + const { response } = await withTrace({ + name: TRACE_FETCH_PIPELINE, + trace: pipelineTrace, + data: { + chain_count: chainIds.length, + account_count: accounts.length, + basic_functionality: isBasicFunctionality, + }, + fn: async (parentContext) => { + const startTime = performance.now(); + const result = await this.#executeMiddlewares({ + sources: fastSources, + request, + parentContext, + trace: pipelineTrace, + }); + await this.#updateState({ + ...result.response, + updateMode: 'merge', + replaceCoveredChainBalances: true, + }); + this.#emitFastFetchTraces({ + accounts, + chainIds, + result, + durationMs: performance.now() - startTime, + isBasicFunctionality, + parentContext, + pipelineTrace, + }); + return result; + }, + }); + return response; + } - withTrace({ - name: TRACE_BACKGROUND_FETCH, + async #runFastFetchV6({ + accounts, + chainIds, + request, + pipelineTrace, + fastSources, + isBasicFunctionality, + }: { + accounts: InternalAccount[]; + chainIds: ChainId[]; + request: DataRequest; + pipelineTrace?: TraceCallback; + fastSources: AssetsDataSource[]; + isBasicFunctionality: boolean; + }): Promise { + const { response } = await withTrace({ + name: TRACE_FETCH_PIPELINE, + trace: pipelineTrace, + data: { + chain_count: chainIds.length, + account_count: accounts.length, + basic_functionality: isBasicFunctionality, + }, + fn: async (parentContext) => { + const startTime = performance.now(); + const result = await this.#executeMiddlewares({ + sources: fastSources, + request, + parentContext, trace: pipelineTrace, - data: { - chain_count: slowPipelineChainIds.length, - account_count: accounts.length, - }, - fn: async (slowParentContext) => { - const { response: slowResponse } = await this.#executeMiddlewares({ - sources: [ - createParallelBalanceMiddleware(slowSources), - this.#detectionMiddleware, - ...(this.#isBasicFunctionality() - ? [ - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : []), - ], - request: slowRequest, - parentContext: slowParentContext, - trace: pipelineTrace, - }); - await this.#updateState({ - ...slowResponse, - updateMode: 'merge', - }); - }, - }).catch((error) => log('Background pipeline failed', { error })); - } + }); + await this.#updateState(result.response); + this.#emitFastFetchTraces({ + accounts, + chainIds, + result, + durationMs: performance.now() - startTime, + isBasicFunctionality, + parentContext, + pipelineTrace, + }); + return result; + }, + }); + return response; + } + + #runBackgroundFetch({ + accounts, + chainIds, + request, + pipelineTrace, + isBasicFunctionality, + }: { + accounts: InternalAccount[]; + chainIds: ChainId[]; + request: DataRequest; + pipelineTrace?: TraceCallback; + isBasicFunctionality: boolean; + }): void { + if (chainIds.length === 0) { + return; } + const slowSources = isBasicFunctionality + ? [this.#snapDataSource, this.#rpcDataSource] + : [this.#rpcDataSource]; + const slowRequest = { ...request, chainIds }; - const result = this.#getAssetsFromState(accounts, chainIds, assetTypes); - return result; + withTrace({ + name: TRACE_BACKGROUND_FETCH, + trace: pipelineTrace, + data: { + chain_count: chainIds.length, + account_count: accounts.length, + }, + fn: async (parentContext) => { + const { response } = await this.#executeMiddlewares({ + sources: [ + createParallelBalanceMiddleware(slowSources), + this.#detectionMiddleware, + ...(isBasicFunctionality + ? [ + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ] + : []), + ], + request: slowRequest, + parentContext, + trace: pipelineTrace, + }); + await this.#updateState({ ...response, updateMode: 'merge' }); + }, + }).catch((error) => log('Background pipeline failed', { error })); + } + + #emitFastFetchTraces({ + accounts, + chainIds, + result, + durationMs, + isBasicFunctionality, + parentContext, + pipelineTrace, + }: { + accounts: InternalAccount[]; + chainIds: ChainId[]; + result: { + response: DataResponse; + durationByDataSource: Record; + }; + durationMs: number; + isBasicFunctionality: boolean; + parentContext?: TraceContext; + pipelineTrace?: TraceCallback; + }): void { + emitTrace({ + name: TRACE_FULL_FETCH, + trace: pipelineTrace, + data: { + duration_ms: durationMs, + chain_count: chainIds.length, + account_count: accounts.length, + basic_functionality: isBasicFunctionality, + asset_count: result.response.assetsBalance + ? Object.values(result.response.assetsBalance).reduce( + (sum, account) => sum + Object.keys(account).length, + 0, + ) + : 0, + price_count: result.response.assetsPrice + ? Object.keys(result.response.assetsPrice).length + : 0, + ...result.durationByDataSource, + }, + parentContext, + }); + emitTrace({ + name: TRACE_FIRST_INIT_FETCH, + trace: pipelineTrace, + data: { + duration_ms: durationMs, + chain_ids: JSON.stringify(chainIds), + ...result.durationByDataSource, + }, + parentContext, + }); } async getAssetsBalance( @@ -2153,21 +2473,28 @@ export class AssetsController extends BaseController< balances[accountId][normalizedAssetId] ??= { amount: '0' }; }); - // Fetch data for the newly added custom asset (merge to preserve other chains) const account = this.#getSelectedAccounts().find((a) => a.id === accountId); if (account) { const chainId = extractChainId(normalizedAssetId); - await this.getAssets([account], { - chainIds: [chainId], - dataTypes: ['balance', 'metadata', 'price'], - assetTypes: ['fungible'], - forceUpdate: true, - updateMode: 'merge', - }); + if (this.#isBalanceV6Enabled()) { + await this.getAssets([account], { + chainIds: [chainId], + dataTypes: ['balance', 'metadata', 'price'], + assetTypes: ['fungible'], + forceUpdate: true, + customAssets: [normalizedAssetId], + }); + } else { + await this.getAssets([account], { + chainIds: [chainId], + dataTypes: ['balance', 'metadata', 'price'], + assetTypes: ['fungible'], + forceUpdate: true, + }); + } } - // Re-evaluate subscriptions so the supplemental RPC poll picks up the - // new customAsset on chains another data source already owns. + // Re-evaluate subscriptions so polls pick up the new pin. this.#subscribeAssets(); } @@ -2195,8 +2522,7 @@ export class AssetsController extends BaseController< } }); - // Re-evaluate subscriptions so the supplemental RPC poll for that chain - // is torn down when no more customAssets remain there. + // Re-evaluate subscriptions so polls drop the removed pin. this.#subscribeAssets(); } @@ -2232,6 +2558,9 @@ export class AssetsController extends BaseController< } state.assetPreferences[normalizedAssetId].hidden = true; }); + + // Re-evaluate subscriptions so polls exclude the newly hidden asset. + this.#subscribeAssets(); } /** @@ -2253,6 +2582,49 @@ export class AssetsController extends BaseController< } } }); + + // Re-evaluate subscriptions so polls stop excluding the asset. + this.#subscribeAssets(); + } + + /** + * Collect globally hidden asset IDs (from `assetPreferences`), forwarded on + * data requests as `excludeAssetIds`. + * + * @returns The CAIP-19 asset IDs the user has hidden. + */ + #getHiddenAssetIds(): Caip19AssetId[] { + const hidden: Caip19AssetId[] = []; + for (const [assetId, prefs] of Object.entries( + this.state.assetPreferences, + )) { + if (prefs.hidden) { + hidden.push(assetId as Caip19AssetId); + } + } + return hidden; + } + + /** + * Whether Accounts API v6 (and the v6 custom-asset path) is enabled. + * Injected into AccountsApiDataSource and RpcFallbackMiddleware. + * + * @returns `true` when the v6 remote flag is on. + */ + #isBalanceV6Enabled(): boolean { + try { + const { remoteFeatureFlags } = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + const flag = remoteFeatureFlags?.assetsAccountsApiV6; + return ( + typeof flag === 'object' && + flag !== null && + Boolean((flag as { value?: unknown }).value) + ); + } catch { + return false; + } } // ============================================================================ @@ -2550,10 +2922,21 @@ export class AssetsController extends BaseController< this.#accountsApiDataSource.getActiveChainsSync(), ); + // Chains whose pins went unresolved (`unprocessedCustomAssets`): route + // them to the slow pipeline so RPC fetches the pins. + const unprocessedCustomAssetChains = new Set( + (fastResponse.unprocessedCustomAssets ?? []).map( + (assetId) => assetId.split('/')[0] as ChainId, + ), + ); + return chainIds.filter((chainId) => { if (fastResponse.errors?.[chainId]) { return true; } + if (unprocessedCustomAssetChains.has(chainId)) { + return true; + } if (!accountsApiChains.has(chainId)) { return true; } @@ -2765,21 +3148,25 @@ export class AssetsController extends BaseController< )) { const previousBalances = previousState.assetsBalance[accountId] ?? {}; + const customAssetIds = (state.customAssets as Record)[ accountId ] ?? []; - const replaceCoveredChains = - mode === 'full' || - normalizedResponse.replaceCoveredChainBalances === true; - - const effective = mergeAccountBalances( - previousBalances, - accountBalances, - customAssetIds, - replaceCoveredChains, - ); + const effective = + mode === 'full' + ? mergeAccountBalancesV6( + previousBalances, + accountBalances, + normalizedResponse.unprocessedCustomAssets ?? [], + ) + : mergeAccountBalancesV5( + previousBalances, + accountBalances, + customAssetIds, + normalizedResponse.replaceCoveredChainBalances === true, + ); // Ensure native tokens have an entry (0 if missing) for chains this account supports const account = this.#getSelectedAccounts().find( @@ -2865,12 +3252,10 @@ export class AssetsController extends BaseController< log('State updated', { changedBalances: changedBalances.length > 0 ? changedBalances : undefined, - changedMetadataCount: - changedMetadata.length > 0 ? changedMetadata.length : undefined, - changedPricesCount: - changedPriceAssets.length > 0 - ? changedPriceAssets.length - : undefined, + changedMetadata: + changedMetadata.length > 0 ? changedMetadata : undefined, + changedPrices: + changedPriceAssets.length > 0 ? changedPriceAssets : undefined, newAssets: Object.keys(detectedAssets).length > 0 ? Object.entries(detectedAssets).map(([accountId, assets]) => ({ @@ -3142,16 +3527,10 @@ export class AssetsController extends BaseController< if (!subscriptionKey.startsWith('ds:')) { continue; } - // Subscription keys take the form `ds:` for the regular - // subscription or `ds::` for supplemental - // subscriptions (e.g. `ds:RpcDataSource:custom`). Split on `:` and - // pick the source-name segment so both shapes resolve correctly. + // Subscription keys take the form `ds:`. const [, sourceId] = subscriptionKey.split(':'); const source = allSources.find((ds) => ds.getName() === sourceId); if (source) { - // Unsubscribe by the actual key — `#unsubscribeDataSource` only - // knows the regular `ds:` shape and would miss - // supplemental subscriptions, leaking their polling timers. this.#unsubscribeBySubscriptionKey(source, subscriptionKey); } } @@ -3201,10 +3580,12 @@ export class AssetsController extends BaseController< * Strategy to minimize data source calls: * 1. Collect all chains to subscribe based on enabled networks * 2. Map chains to accounts based on their scopes - * 3. Split by data source (ordered by priority) - each data source gets ONE subscription + * 3. Split by data source (priority order) - each source gets ONE + * subscription, claiming chains AND pinned assets (`claimCustomAssets`); + * unclaimed assets fall through to lower-priority sources. * * This ensures we make minimal subscriptions to each data source while covering - * all accounts and chains. + * all accounts, chains, and pinned assets. * * @param accounts - Accounts to subscribe balance updates for. * @param chainIds - Chain IDs to subscribe for. @@ -3215,19 +3596,27 @@ export class AssetsController extends BaseController< accounts: InternalAccount[], chainIds: ChainId[], options?: { skipInitialFetch?: boolean }, + ): void { + if (this.#isBalanceV6Enabled()) { + this.#subscribeAssetsBalanceV6(accounts, chainIds, options); + return; + } + this.#subscribeAssetsBalanceV5(accounts, chainIds, options); + } + + #subscribeAssetsBalanceV5( + accounts: InternalAccount[], + chainIds: ChainId[], + options?: { skipInitialFetch?: boolean }, ): void { const chainToAccounts = this.#buildChainToAccountsMap( accounts, new Set(chainIds), ); const remainingChains = new Set(chainToAccounts.keys()); - // When basic functionality is on, use all balance data sources; when off, - // RPC only. - const isBasicFunctionality = this.#isBasicFunctionality(); - const balanceDataSources = isBasicFunctionality + const balanceDataSources = this.#isBasicFunctionality() ? this.#allBalanceDataSources : [this.#rpcDataSource]; - let rpcAssignedChains: Set = new Set(); for (const source of balanceDataSources) { @@ -3240,16 +3629,13 @@ export class AssetsController extends BaseController< remainingChains.delete(chainId); } } - if (assignedChains.length === 0) { this.#unsubscribeDataSource(source); continue; } - if (source === this.#rpcDataSource) { rpcAssignedChains = new Set(assignedChains); } - const seenIds = new Set(); const accountsForSource = assignedChains .flatMap((chainId) => chainToAccounts.get(chainId) ?? []) @@ -3270,12 +3656,6 @@ export class AssetsController extends BaseController< } } - // Supplemental RPC subscription for customAssets on chains another data - // source claimed during regular handoff. RPC is the sole balance fetcher - // for customAssets, so we must always poll them — even when (e.g.) - // AccountsApi is already covering the chain for normal balances. The - // supplemental subscription runs in `customAssetsOnly` mode so it does - // NOT double-poll the regular tracked balances. this.#subscribeRpcCustomAssetsSupplement( accounts, chainToAccounts, @@ -3283,14 +3663,105 @@ export class AssetsController extends BaseController< ); } + #subscribeAssetsBalanceV6( + accounts: InternalAccount[], + chainIds: ChainId[], + options?: { skipInitialFetch?: boolean }, + ): void { + const chainToAccounts = this.#buildChainToAccountsMap( + accounts, + new Set(chainIds), + ); + const remainingChains = new Set(chainToAccounts.keys()); + const remainingCustomAssets = new Set(); + for (const account of accounts) { + for (const assetId of this.getCustomAssets(account.id)) { + try { + if (remainingChains.has(parseCaipAssetType(assetId).chainId)) { + remainingCustomAssets.add(assetId); + } + } catch { + // Skip unparseable asset IDs + } + } + } + const balanceDataSources = this.#isBasicFunctionality() + ? this.#allBalanceDataSources + : [this.#rpcDataSource]; + + for (const source of balanceDataSources) { + const availableChains = new Set(source.getActiveChainsSync()); + const assignedChains: ChainId[] = []; + + for (const chainId of remainingChains) { + if (availableChains.has(chainId)) { + assignedChains.push(chainId); + remainingChains.delete(chainId); + } + } + + const claimedAssets = source.claimCustomAssets( + [...remainingCustomAssets], + assignedChains, + ); + for (const assetId of claimedAssets) { + remainingCustomAssets.delete(assetId); + } + if (assignedChains.length === 0 && claimedAssets.length === 0) { + this.#unsubscribeDataSource(source); + continue; + } + + const claimedAssetsSet = new Set(claimedAssets); + const seenIds = new Set(); + const accountsForSource = assignedChains + .flatMap((chainId) => chainToAccounts.get(chainId) ?? []) + .filter((account) => { + if (seenIds.has(account.id)) { + return false; + } + seenIds.add(account.id); + return true; + }); + for (const account of accounts) { + if ( + !seenIds.has(account.id) && + this.getCustomAssets(account.id).some((assetId) => + claimedAssetsSet.has(assetId), + ) + ) { + seenIds.add(account.id); + accountsForSource.push(account); + } + } + + if (accountsForSource.length > 0) { + const hiddenAssets = this.#getHiddenAssetIds(); + this.#subscribeDataSource(source, accountsForSource, assignedChains, { + customAssets: claimedAssets, + excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, + ...(options?.skipInitialFetch && + source === this.#accountsApiDataSource + ? { skipInitialFetch: true } + : {}), + }); + } else { + this.#unsubscribeDataSource(source); + } + } + + if (remainingCustomAssets.size > 0) { + log('Custom assets unclaimed by any data source', { + assetIds: [...remainingCustomAssets], + }); + } + } + /** * Guarantee that customAssets are **always** polled by RPC, even when * AccountsApi or another data source has claimed the chain in the * regular handoff. RPC is the sole balance fetcher for user-imported - * tokens (see `pickRpcCustomAssetsSupplement` for the full rationale), - * so we run a dedicated subscription in `customAssetsOnly` mode under a - * distinct subscription key (`ds:RpcDataSource:custom`) that does not - * interfere with the regular RPC subscription. + * tokens on the Accounts API v5 path (see `pickRpcCustomAssetsSupplement`). * * @param accounts - Accounts to consider for customAssets. * @param chainToAccounts - Map of chain → accounts (built by caller). @@ -3415,7 +3886,12 @@ export class AssetsController extends BaseController< * @param chains - Array of chain IDs to subscribe for. * @param options - Optional subscription overrides. * @param options.subscriptionKey - Custom subscription key (default: `ds:`). - * @param options.customAssetsOnly - When true, only poll customAssets for these chains. + * @param options.customAssets - Pinned assets this source claimed + * (`claimCustomAssets`), forwarded on the poll request (Accounts API v6). + * @param options.customAssetsOnly - When true, only poll customAssets for these + * chains (Accounts API v5 supplemental RPC subscription). + * @param options.excludeAssetIds - Hidden assets forwarded on the poll request + * (Accounts API v6). * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch. */ #subscribeDataSource( @@ -3424,6 +3900,8 @@ export class AssetsController extends BaseController< chains: ChainId[], options: { subscriptionKey?: string; + customAssets?: Caip19AssetId[]; + excludeAssetIds?: Caip19AssetId[]; customAssetsOnly?: boolean; skipInitialFetch?: boolean; } = {}, @@ -3432,6 +3910,7 @@ export class AssetsController extends BaseController< const subscriptionKey = options.subscriptionKey ?? `ds:${sourceId}`; const existingSubscription = this.#activeSubscriptions.get(subscriptionKey); const isUpdate = existingSubscription !== undefined; + const customAssets = options.customAssets ?? []; log('Subscribe to data source', { sourceId, @@ -3439,6 +3918,7 @@ export class AssetsController extends BaseController< isUpdate, accountCount: accounts.length, chainCount: chains.length, + customAssetCount: customAssets.length, customAssetsOnly: options.customAssetsOnly === true, skipInitialFetch: options.skipInitialFetch === true, }); @@ -3448,6 +3928,11 @@ export class AssetsController extends BaseController< assetTypes: ['fungible'], dataTypes: ['balance'], updateInterval: this.#defaultUpdateInterval, + customAssets: customAssets.length > 0 ? customAssets : undefined, + excludeAssetIds: + options.excludeAssetIds && options.excludeAssetIds.length > 0 + ? options.excludeAssetIds + : undefined, ...(options.customAssetsOnly === true ? { customAssetsOnly: true } : {}), @@ -3685,13 +4170,13 @@ export class AssetsController extends BaseController< // Refresh subscriptions for new chain set this.#subscribeAssets(); - // Do one-time fetch for newly enabled chains; merge so we keep existing chain balances + // One-time fetch for newly enabled chains. v6 `full` replace is scoped to + // those chains; existing balances on other chains are left untouched. const accounts = this.#getSelectedAccounts(); if (addedChains.length > 0 && accounts.length > 0) { await this.getAssets(accounts, { chainIds: addedChains, forceUpdate: true, - updateMode: 'merge', }); } @@ -3878,57 +4363,35 @@ export class AssetsController extends BaseController< ): Promise { log('Assets updated from data source', { sourceId, - hasBalance: Boolean(response.assetsBalance), - hasPrice: Boolean(response.assetsPrice), + assetsBalance: response.assetsBalance, + assetsPrice: response.assetsPrice, }); - // Enrichment spans only before unlock/first-init fetch completes. + if (this.#isBalanceV6Enabled()) { + await this.#handleAssetsUpdateV6(response, sourceId, request); + return; + } + await this.#handleAssetsUpdateV5(response, sourceId, request); + } + + async #handleAssetsUpdateV5( + response: DataResponse, + sourceId: string, + request?: DataRequest, + ): Promise { const pipelineTrace = this.#firstInitFetchReported ? undefined : this.#trace; - await withTrace({ name: TRACE_UPDATE_PARENT, trace: pipelineTrace, - data: { - source: sourceId, - has_balance: Boolean(response.assetsBalance), - has_price: Boolean(response.assetsPrice), - balance_account_count: response.assetsBalance - ? Object.keys(response.assetsBalance).length - : 0, - }, + data: this.#getUpdateTraceData(response, sourceId), fn: async (parentContext) => { const updateStart = performance.now(); - - const resolvedRequest: DataRequest = request ?? { - accountsWithSupportedChains: [], - chainIds: [], - dataTypes: ['balance', 'metadata', 'price'], - }; - - // RPC-only mode (basic functionality off): never run token/price APIs. Strip - // those data types so downstream middleware cannot treat them as requested. - const pipelineRequest: DataRequest = this.#isBasicFunctionality() - ? resolvedRequest - : { - ...resolvedRequest, - dataTypes: resolvedRequest.dataTypes.filter( - (dt) => dt !== 'metadata' && dt !== 'price', - ), - }; - - // Graduate custom assets only when AccountsAPI / AccountActivity reports - // them. RPC already fetches custom assets on purpose, and Snap handles - // non-EVM chains the rule does not apply to, so skip the middleware for - // those. + const pipelineRequest = this.#getUpdatePipelineRequest(request); const shouldGraduateCustomAssets = sourceId === 'AccountsApiDataSource' || sourceId === 'AccountActivityDataSource'; - - // Websocket updates can carry brand-new spam airdrops: enrich them - // with Token API occurrences and drop below-floor tokens BEFORE - // detection, so spam is never detected, enriched, priced or persisted. const shouldFilterOccurrences = sourceId === 'AccountActivityDataSource' && this.#isBasicFunctionality(); @@ -3950,6 +4413,7 @@ export class AssetsController extends BaseController< ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []), this.#detectionMiddleware, ]; + enrichmentSources.push(this.#detectionMiddleware); if (this.#isBasicFunctionality()) { enrichmentSources.push( createParallelMiddleware([ @@ -3971,27 +4435,141 @@ export class AssetsController extends BaseController< ...enrichedResponse, replaceCoveredChainBalances: response.replaceCoveredChainBalances, }); + this.#emitUpdateTrace({ + response, + sourceId, + enrichedResponse, + updateStart, + pipelineTrace, + parentContext, + }); + }, + }); + } - // Summary fields for Assets Health (nested under the parent span). - emitTrace({ - name: TRACE_UPDATE_PIPELINE, + async #handleAssetsUpdateV6( + response: DataResponse, + sourceId: string, + request?: DataRequest, + ): Promise { + const pipelineTrace = this.#firstInitFetchReported + ? undefined + : this.#trace; + await withTrace({ + name: TRACE_UPDATE_PARENT, + trace: pipelineTrace, + data: this.#getUpdateTraceData(response, sourceId), + fn: async (parentContext) => { + const updateStart = performance.now(); + const pipelineRequest = this.#getUpdatePipelineRequest(request); + const shouldFilterOccurrences = + sourceId === 'AccountActivityDataSource' && + this.#isBasicFunctionality(); + const enrichmentSources: AssetsDataSource[] = [ + ...(shouldFilterOccurrences + ? [ + { + getName: () => 'OccurrenceFloorFilter', + assetsMiddleware: + this.#tokenDataSource.occurrenceFilterMiddleware, + }, + ] + : []), + ]; + if (this.#isBasicFunctionality()) { + enrichmentSources.push(this.#rpcFallbackMiddleware); + } + enrichmentSources.push(this.#detectionMiddleware); + if (this.#isBasicFunctionality()) { + enrichmentSources.push( + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ); + } + + const { response: enrichedResponse } = await this.#executeMiddlewares({ + sources: enrichmentSources, + request: pipelineRequest, + initialResponse: response, + parentContext, trace: pipelineTrace, - data: { - source: sourceId, - duration_ms: performance.now() - updateStart, - has_balance: Boolean(response.assetsBalance), - has_price: Boolean(response.assetsPrice), - has_metadata: Boolean(enrichedResponse.assetsInfo), - balance_account_count: response.assetsBalance - ? Object.keys(response.assetsBalance).length - : 0, - }, + }); + + await this.#updateState(enrichedResponse); + this.#emitUpdateTrace({ + response, + sourceId, + enrichedResponse, + updateStart, + pipelineTrace, parentContext, }); }, }); } + #getUpdatePipelineRequest(request?: DataRequest): DataRequest { + const resolvedRequest: DataRequest = request ?? { + accountsWithSupportedChains: [], + chainIds: [], + dataTypes: ['balance', 'metadata', 'price'], + }; + if (this.#isBasicFunctionality()) { + return resolvedRequest; + } + return { + ...resolvedRequest, + dataTypes: resolvedRequest.dataTypes.filter( + (dataType) => dataType !== 'metadata' && dataType !== 'price', + ), + }; + } + + #getUpdateTraceData(response: DataResponse, sourceId: string): TraceSpanData { + return { + source: sourceId, + has_balance: Boolean(response.assetsBalance), + has_price: Boolean(response.assetsPrice), + balance_account_count: response.assetsBalance + ? Object.keys(response.assetsBalance).length + : 0, + }; + } + + #emitUpdateTrace({ + response, + sourceId, + enrichedResponse, + updateStart, + pipelineTrace, + parentContext, + }: { + response: DataResponse; + sourceId: string; + enrichedResponse: DataResponse; + updateStart: number; + pipelineTrace?: TraceCallback; + parentContext?: TraceContext; + }): void { + emitTrace({ + name: TRACE_UPDATE_PIPELINE, + trace: pipelineTrace, + data: { + source: sourceId, + duration_ms: performance.now() - updateStart, + has_balance: Boolean(response.assetsBalance), + has_price: Boolean(response.assetsPrice), + has_metadata: Boolean(enrichedResponse.assetsInfo), + balance_account_count: response.assetsBalance + ? Object.keys(response.assetsBalance).length + : 0, + }, + parentContext, + }); + } + // ============================================================================ // CLEANUP // ============================================================================ diff --git a/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts b/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts index 98c5262e8e6..b4e11723bf6 100644 --- a/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts @@ -1,4 +1,4 @@ -import type { ChainId } from '../types.js'; +import type { Caip19AssetId, ChainId } from '../types.js'; import type { ActiveSubscription, DataSourceState, @@ -113,6 +113,43 @@ describe('AbstractDataSource', () => { expect(dataSource.getSubscriptions().size).toBe(0); }); + describe('claimCustomAssets', () => { + it('claims assets on assigned chains by default and skips others', () => { + const { dataSource } = setupDataSource(); + + const assignedChainAsset = + `${CHAIN_MAINNET}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + const unassignedChainAsset = + `${CHAIN_POLYGON}/erc20:0x2222222222222222222222222222222222222222` as Caip19AssetId; + + expect( + dataSource.claimCustomAssets( + [assignedChainAsset, unassignedChainAsset], + [CHAIN_MAINNET], + ), + ).toStrictEqual([assignedChainAsset]); + }); + + it('skips malformed asset IDs and claims nothing without assigned chains', () => { + const { dataSource } = setupDataSource(); + + expect( + dataSource.claimCustomAssets( + ['not-a-caip-asset' as Caip19AssetId], + [CHAIN_MAINNET], + ), + ).toStrictEqual([]); + expect( + dataSource.claimCustomAssets( + [ + `${CHAIN_MAINNET}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId, + ], + [], + ), + ).toStrictEqual([]); + }); + }); + it.each([ { chains: [], expected: [] }, { chains: [CHAIN_MAINNET], expected: [CHAIN_MAINNET] }, diff --git a/packages/assets-controller/src/data-sources/AbstractDataSource.ts b/packages/assets-controller/src/data-sources/AbstractDataSource.ts index 6023b18dfb2..be690a116e3 100644 --- a/packages/assets-controller/src/data-sources/AbstractDataSource.ts +++ b/packages/assets-controller/src/data-sources/AbstractDataSource.ts @@ -1,4 +1,7 @@ +import { parseCaipAssetType } from '@metamask/utils'; + import type { + Caip19AssetId, ChainId, DataRequest, DataResponse, @@ -117,6 +120,31 @@ export abstract class AbstractDataSource< return this.state.activeChains; } + /** + * Claim the pinned assets this source commits to serving. Called during the + * subscription handoff; claimed assets are not offered to lower-priority + * sources. Assets a source cannot resolve at fetch time are released via + * `DataResponse.unprocessedCustomAssets`. Default: claim assets on this + * source's assigned chains. + * + * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. + * @param assignedChains - Chains assigned to this source in the handoff. + * @returns The claimed subset of `customAssets`. + */ + claimCustomAssets( + customAssets: Caip19AssetId[], + assignedChains: ChainId[], + ): Caip19AssetId[] { + const assigned = new Set(assignedChains); + return customAssets.filter((assetId) => { + try { + return assigned.has(parseCaipAssetType(assetId).chainId); + } catch { + return false; + } + }); + } + /** * Subscribe to updates for the given request. */ diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts index 576d1dceafd..8cd27366515 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts @@ -250,6 +250,9 @@ export class AccountActivityDataSource extends AbstractDataSource< // SUBSCRIBE / UNSUBSCRIBE // ============================================================================ + // Pins on active chains are claimed via the inherited `claimCustomAssets`: + // the activity stream pushes all activity for the address, pins included. + /** * AADS is event-driven and chain-agnostic: it never participates in the * controller's subscribe/unsubscribe handoff. Incoming `balanceUpdated` diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 382439a702d..7776e216372 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -34,6 +34,17 @@ const CHAIN_POLYGON = 'eip155:137' as ChainId; const CHAIN_ARBITRUM = 'eip155:42161' as ChainId; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; +function isBalanceV6EnabledFromFlags( + remoteFeatureFlags: Record, +): boolean { + const flag = remoteFeatureFlags.assetsAccountsApiV6; + return ( + typeof flag === 'object' && + flag !== null && + Boolean((flag as { value?: unknown }).value) + ); +} + type MockApiClient = { accounts: { fetchV2SupportedNetworks: jest.Mock; @@ -67,6 +78,7 @@ function createMockApiClient( balances: V5BalanceItem[] = [], unprocessedNetworks: string[] = [], v6Balances: V6BalanceItem[] = [], + unprocessedIncludeAssetIds: string[] = [], ): MockApiClient { return { accounts: { @@ -81,7 +93,7 @@ function createMockApiClient( fetchV6MultiAccountBalances: jest.fn().mockResolvedValue({ balances: v6Balances, unprocessedNetworks, - unprocessedIncludeAssetIds: [], + unprocessedIncludeAssetIds, }), }, }; @@ -144,6 +156,7 @@ async function setupController( supportedChains?: number[]; balances?: V5BalanceItem[]; unprocessedNetworks?: string[]; + unprocessedIncludeAssetIds?: string[]; fetchTimeoutMs?: number; v6Balances?: V6BalanceItem[]; remoteFeatureFlags?: Record; @@ -153,6 +166,7 @@ async function setupController( supportedChains = [1, 137], balances = [], unprocessedNetworks = [], + unprocessedIncludeAssetIds = [], fetchTimeoutMs, v6Balances = [], remoteFeatureFlags = {}, @@ -196,6 +210,7 @@ async function setupController( balances, unprocessedNetworks, v6Balances, + unprocessedIncludeAssetIds, ); const controller = new AccountsApiDataSource({ @@ -205,6 +220,8 @@ async function setupController( apiClient as unknown as AccountsApiDataSourceOptions['queryApiClient'], onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => activeChainsUpdateHandler(dataSourceName, chains, previousChains), + isBalanceV6Enabled: (): boolean => + isBalanceV6EnabledFromFlags(remoteFeatureFlags), ...(fetchTimeoutMs === undefined ? {} : { fetchTimeoutMs }), }); @@ -648,6 +665,48 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + describe('claimCustomAssets', () => { + const assignedChainAsset = + 'eip155:1/erc20:0x1111111111111111111111111111111111111111'; + const unassignedChainAsset = + 'eip155:137/erc20:0x2222222222222222222222222222222222222222'; + const nonEvmAsset = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFW'; + + it('claims EVM assets on assigned chains when the v6 flag is enabled', async () => { + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + expect( + controller.claimCustomAssets( + [ + assignedChainAsset, + unassignedChainAsset, + nonEvmAsset, + 'not-a-caip-asset', + ] as Caip19AssetId[], + ['eip155:1' as ChainId], + ), + ).toStrictEqual([assignedChainAsset]); + + controller.destroy(); + }); + + it('claims nothing when the v6 flag is disabled (v5 has no includeAssetIds support)', async () => { + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: false } }, + }); + + expect( + controller.claimCustomAssets([assignedChainAsset] as Caip19AssetId[], [ + 'eip155:1' as ChainId, + ]), + ).toStrictEqual([]); + + controller.destroy(); + }); + }); + describe('assetsAccountsApiV6 feature flag', () => { it('uses the v5 endpoint by default', async () => { const { controller, apiClient } = await setupController(); @@ -681,6 +740,18 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('sets updateMode to full for v6 fetches', async () => { + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const response = await controller.fetch(createDataRequest()); + + expect(response.updateMode).toBe('full'); + + controller.destroy(); + }); + it('uses the v5 endpoint when the assetsAccountsApiV6 remote flag is disabled', async () => { const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: { value: false } }, @@ -877,7 +948,7 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('does not pass includeAssetIds to v6 even when custom assets are present', async () => { + it('passes EVM custom assets on requested chains to v6 as includeAssetIds', async () => { const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, }); @@ -889,6 +960,33 @@ describe('AccountsApiDataSource', () => { createDataRequest({ customAssets: [customToken] }), ); + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [customToken] }, + undefined, + ); + + controller.destroy(); + }); + + it('omits custom assets that are not on a requested chain from includeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + // Custom asset on Polygon while only Mainnet is being fetched. + const polygonToken = + 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ + chainIds: [CHAIN_MAINNET], + customAssets: [polygonToken], + }), + ); + expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( @@ -899,6 +997,224 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + + it('surfaces unprocessed include asset ids on the asset axis (unprocessedCustomAssets) without flagging the chain as errored', async () => { + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + unprocessedIncludeAssetIds: [customToken], + }); + + const response = await controller.fetch( + createDataRequest({ customAssets: [customToken] }), + ); + + // The chain itself succeeded — only the specific pinned asset is + // outstanding, so it goes on the asset axis, not `errors`. + expect(response.errors?.[CHAIN_MAINNET]).toBeUndefined(); + expect(response.unprocessedCustomAssets).toStrictEqual([customToken]); + + controller.destroy(); + }); + + it('omits unparseable unprocessed include asset ids from unprocessedCustomAssets', async () => { + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + unprocessedIncludeAssetIds: [ + customToken, + 'not-a-caip-asset' as Caip19AssetId, + ], + }); + + const response = await controller.fetch( + createDataRequest({ customAssets: [customToken] }), + ); + + expect(response.unprocessedCustomAssets).toStrictEqual([customToken]); + + controller.destroy(); + }); + + it('skips non-EVM and malformed custom assets when building includeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const solanaToken = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; + const malformed = 'not-a-caip-asset' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ customAssets: [solanaToken, malformed] }), + ); + + // No EVM custom asset on a requested chain -> includeAssetIds omitted. + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + undefined, + ); + + controller.destroy(); + }); + + it('ignores malformed unprocessed include asset ids', async () => { + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + unprocessedIncludeAssetIds: ['not-a-caip-asset'], + }); + + const response = await controller.fetch( + createDataRequest({ customAssets: [customToken] }), + ); + + // The malformed unprocessed id cannot be parsed, so it is dropped from + // both axes (no error, no asset-axis entry). + expect(response.errors).toBeUndefined(); + expect(response.unprocessedCustomAssets).toBeUndefined(); + + controller.destroy(); + }); + + it('passes EVM hidden assets on requested chains to v6 as excludeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const hiddenToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ excludeAssetIds: [hiddenToken] }), + ); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { excludeAssetIds: [hiddenToken] }, + undefined, + ); + + controller.destroy(); + }); + + it('omits hidden assets that are not on a requested chain from excludeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + // Hidden asset on Polygon while only Mainnet is being fetched. + const polygonToken = + 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ + chainIds: [CHAIN_MAINNET], + excludeAssetIds: [polygonToken], + }), + ); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + undefined, + ); + + controller.destroy(); + }); + + it('skips non-EVM and malformed hidden assets when building excludeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const solanaToken = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; + const malformed = 'not-a-caip-asset' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ excludeAssetIds: [solanaToken, malformed] }), + ); + + // No EVM hidden asset on a requested chain -> excludeAssetIds omitted. + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + undefined, + undefined, + ); + + controller.destroy(); + }); + + it('lets a pinned asset win when it also appears in the hidden list', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const token = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ + customAssets: [token], + excludeAssetIds: [token], + }), + ); + + // The asset is pinned, so it is included and never excluded. + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [token] }, + undefined, + ); + + controller.destroy(); + }); + + it('sends both includeAssetIds and excludeAssetIds when pins and hidden assets differ', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const pinned = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const hidden = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; + + await controller.fetch( + createDataRequest({ + customAssets: [pinned], + excludeAssetIds: [hidden], + }), + ); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [pinned], excludeAssetIds: [hidden] }, + undefined, + ); + + controller.destroy(); + }); }); it('fetch marks every requested chain as errored when the call exceeds the configured timeout', async () => { @@ -977,6 +1293,21 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + it('middleware forwards full updateMode from v6 fetches', async () => { + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }); + + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext(); + + await controller.assetsMiddleware(context, next); + + expect(context.response.updateMode).toBe('full'); + + controller.destroy(); + }); + it('middleware skips Accounts API when balance is not requested', async () => { const { controller, apiClient } = await setupController({ balances: [ diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 31e55cdfa72..3409cccbb38 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -7,6 +7,7 @@ import type { import { isCaipChainId, KnownCaipNamespace, + parseCaipAssetType, toCaipChainId, } from '@metamask/utils'; @@ -49,9 +50,9 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // Allowed actions that AccountsApiDataSource can call. Balances are fetched via // ApiPlatformClient directly (no BackendApiClient actions needed); the messenger -// is used to read the Accounts API v6 balances feature flag and to subscribe to -// `RemoteFeatureFlagController:stateChange` (see constructor) so migration flag -// changes refresh the active chains. +// is used to subscribe to `RemoteFeatureFlagController:stateChange` (see +// constructor) so migration flag changes refresh the active chains, and to +// read those flags when listing active chains. export type AccountsApiDataSourceAllowedActions = RemoteFeatureFlagControllerGetStateAction; @@ -95,7 +96,7 @@ export type AccountsApiDataSourceConfig = { export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { /** * The AssetsController messenger (shared by all data sources). Used to read - * the `assetsAccountsApiV6` remote feature flag. + * Snaps → AssetsController migration flags and subscribe to flag changes. */ messenger: AssetsControllerMessenger; /** ApiPlatformClient for API calls with caching */ @@ -106,6 +107,12 @@ export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { chains: ChainId[], previousChains: ChainId[], ) => void; + /** + * Whether to use the Accounts API v6 balances endpoint (and `includeAssetIds`). + * Injected by AssetsController so the flag is read in one place. Defaults to + * `false` (v5). Read on demand, not cached. + */ + isBalanceV6Enabled?: () => boolean; state?: Partial; }; @@ -219,6 +226,9 @@ export class AccountsApiDataSource extends AbstractDataSource< /** Shared AssetsController messenger, used to read remote feature flags. */ readonly #messenger: AssetsControllerMessenger; + /** Injected by AssetsController; `true` when the v6 balances endpoint should be used. */ + readonly #isBalanceV6Enabled: () => boolean; + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; @@ -240,6 +250,8 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#tokenDetectionEnabled = options.tokenDetectionEnabled ?? ((): boolean => true); this.#messenger = options.messenger; + this.#isBalanceV6Enabled = + options.isBalanceV6Enabled ?? ((): boolean => false); this.#apiClient = options.queryApiClient; // The Snaps → AssetsController migration flags gate which migration networks @@ -277,35 +289,6 @@ export class AccountsApiDataSource extends AbstractDataSource< } } - /** - * Whether the Accounts API v6 balances endpoint is enabled, read from the - * RemoteFeatureFlagController (`assetsAccountsApiV6`). Read on demand (per - * fetch) rather than cached so the value can be toggled at runtime, and behaves - * the same across clients (extension, mobile). Defaults to `false` when the - * flag is unset or the controller is unavailable. - * - * The flag is a LaunchDarkly JSON variation shaped `{ value: boolean }` (same - * shape as `backendWebSocketConnection`), so the nested `value` is read rather - * than treating the flag as a plain boolean. - * - * @returns `true` when the v6 balances endpoint should be used. - */ - #isBalanceV6Enabled(): boolean { - try { - const { remoteFeatureFlags } = this.#messenger.call( - 'RemoteFeatureFlagController:getState', - ); - const flag = remoteFeatureFlags?.assetsAccountsApiV6; - return ( - typeof flag === 'object' && - flag !== null && - Boolean((flag as { value?: unknown }).value) - ); - } catch { - return false; - } - } - // ============================================================================ // INITIALIZATION // ============================================================================ @@ -435,11 +418,63 @@ export class AccountsApiDataSource extends AbstractDataSource< } : undefined; - // Feature-flagged: v6 endpoint with a fallback to legacy v5. The flag is - // read here (not cached) so a runtime toggle can revert v6 -> v5. - const { unprocessedNetworks, assetsBalance } = this.#isBalanceV6Enabled() - ? await this.#fetchV6Balances(accountIds, fetchOptions, request) - : await this.#fetchV5Balances(accountIds, fetchOptions, request); + const isV6 = this.#isBalanceV6Enabled(); + let fetchResult: { + unprocessedNetworks: string[]; + unprocessedIncludeAssetIds: string[]; + assetsBalance: Record>; + }; + + if (isV6) { + // User-pinned assets on the fetched chains, sent to v6 as + // `includeAssetIds` so the backend returns them even at zero balance. + const includeAssetIds = this.#getIncludeAssetIds( + request, + chainsToFetch, + ); + + // User-hidden assets on the fetched chains, sent to v6 as + // `excludeAssetIds`. A pin wins over a hide. + const excludeAssetIds = this.#getExcludeAssetIds( + request, + chainsToFetch, + includeAssetIds, + ); + + fetchResult = await this.#fetchV6Balances( + accountIds, + fetchOptions, + request, + includeAssetIds, + excludeAssetIds, + ); + + const validUnprocessedAssetIds = + fetchResult.unprocessedIncludeAssetIds.filter((assetId) => { + try { + parseCaipAssetType(assetId as Caip19AssetId); + return true; + } catch { + return false; + } + }); + if (validUnprocessedAssetIds.length > 0) { + response.unprocessedCustomAssets = [ + ...(response.unprocessedCustomAssets ?? []), + ...(validUnprocessedAssetIds as Caip19AssetId[]), + ]; + } + response.updateMode = 'full'; + } else { + fetchResult = await this.#fetchV5Balances( + accountIds, + fetchOptions, + request, + ); + response.updateMode = 'merge'; + } + + const { unprocessedNetworks, assetsBalance } = fetchResult; // Handle unprocessed networks - these will be passed to next middleware if (unprocessedNetworks.length > 0) { @@ -454,7 +489,6 @@ export class AccountsApiDataSource extends AbstractDataSource< } response.assetsBalance = assetsBalance; - response.updateMode = 'merge'; } catch (error) { log('Fetch FAILED', { error, chains: chainsToFetch }); @@ -482,6 +516,87 @@ export class AccountsApiDataSource extends AbstractDataSource< return response; } + /** + * Collect the pinned EVM assets on the fetched chains to send to the v6 + * endpoint as `includeAssetIds`; malformed IDs are skipped. + * + * @param request - The data request (carries `customAssets`). + * @param chainsToFetch - Chains being requested this fetch. + * @returns Deduplicated asset IDs, or `undefined` when none. + */ + #getIncludeAssetIds( + request: DataRequest, + chainsToFetch: ChainId[], + ): Caip19AssetId[] | undefined { + if (!request.customAssets || request.customAssets.length === 0) { + return undefined; + } + + const chainsToFetchSet = new Set(chainsToFetch); + const includeAssetIds = new Set(); + + for (const assetId of request.customAssets) { + let chainId: ChainId; + try { + chainId = parseCaipAssetType(assetId).chainId; + } catch { + continue; + } + if ( + chainId.startsWith(`${KnownCaipNamespace.Eip155}:`) && + chainsToFetchSet.has(chainId) + ) { + includeAssetIds.add(assetId); + } + } + + return includeAssetIds.size > 0 ? [...includeAssetIds] : undefined; + } + + /** + * Collect the hidden EVM assets on the fetched chains to send to the v6 + * endpoint as `excludeAssetIds`; malformed IDs are skipped and pinned + * assets are left out (a pin wins). + * + * @param request - The data request (carries `excludeAssetIds`). + * @param chainsToFetch - Chains being requested this fetch. + * @param includeAssetIds - Pinned asset IDs that must not be excluded. + * @returns Deduplicated asset IDs, or `undefined` when none. + */ + #getExcludeAssetIds( + request: DataRequest, + chainsToFetch: ChainId[], + includeAssetIds: Caip19AssetId[] | undefined, + ): Caip19AssetId[] | undefined { + if (!request.excludeAssetIds || request.excludeAssetIds.length === 0) { + return undefined; + } + + const chainsToFetchSet = new Set(chainsToFetch); + const includeSet = new Set(includeAssetIds ?? []); + const excludeAssetIds = new Set(); + + for (const assetId of request.excludeAssetIds) { + if (includeSet.has(assetId)) { + continue; + } + let chainId: ChainId; + try { + chainId = parseCaipAssetType(assetId).chainId; + } catch { + continue; + } + if ( + chainId.startsWith(`${KnownCaipNamespace.Eip155}:`) && + chainsToFetchSet.has(chainId) + ) { + excludeAssetIds.add(assetId); + } + } + + return excludeAssetIds.size > 0 ? [...excludeAssetIds] : undefined; + } + /** * Fetch balances from the legacy v5 endpoint and process them. * @@ -498,6 +613,7 @@ export class AccountsApiDataSource extends AbstractDataSource< request: DataRequest, ): Promise<{ unprocessedNetworks: string[]; + unprocessedIncludeAssetIds: string[]; assetsBalance: Record>; }> { const apiResponse = await fetchWithTimeout( @@ -517,6 +633,8 @@ export class AccountsApiDataSource extends AbstractDataSource< return { unprocessedNetworks: apiResponse.unprocessedNetworks, + // v5 has no `includeAssetIds` support. + unprocessedIncludeAssetIds: [], assetsBalance, }; } @@ -527,7 +645,10 @@ export class AccountsApiDataSource extends AbstractDataSource< * @param accountIds - CAIP-10 account IDs to fetch balances for. * @param fetchOptions - Cache/fetch options (e.g. force update settings). * @param request - The original data request containing accounts to map. - * @returns Unprocessed networks and processed asset balances by account. + * @param includeAssetIds - Pinned asset IDs the backend must always return. + * @param excludeAssetIds - Hidden asset IDs the backend must drop. + * @returns Unprocessed networks, unprocessed pinned assets, and processed + * asset balances by account. */ async #fetchV6Balances( accountIds: string[], @@ -535,15 +656,26 @@ export class AccountsApiDataSource extends AbstractDataSource< | { staleTime: number; gcTime: number; bypassServerCache?: boolean } | undefined, request: DataRequest, + includeAssetIds: Caip19AssetId[] | undefined, + excludeAssetIds: Caip19AssetId[] | undefined, ): Promise<{ unprocessedNetworks: string[]; + unprocessedIncludeAssetIds: string[]; assetsBalance: Record>; }> { + const params = + includeAssetIds || excludeAssetIds + ? { + ...(includeAssetIds && { includeAssetIds }), + ...(excludeAssetIds && { excludeAssetIds }), + } + : undefined; + const apiResponse = await fetchWithTimeout( () => this.#apiClient.accounts.fetchV6MultiAccountBalances( accountIds, - undefined, + params, fetchOptions, ), this.#fetchTimeoutMs, @@ -556,6 +688,7 @@ export class AccountsApiDataSource extends AbstractDataSource< return { unprocessedNetworks: apiResponse.unprocessedNetworks, + unprocessedIncludeAssetIds: apiResponse.unprocessedIncludeAssetIds, assetsBalance, }; } @@ -753,6 +886,32 @@ export class AccountsApiDataSource extends AbstractDataSource< } } + if (response.updateMode === 'full') { + context.response = { + ...context.response, + updateMode: 'full', + }; + } else if ( + response.updateMode === 'merge' && + context.response.updateMode !== 'full' + ) { + context.response = { + ...context.response, + updateMode: 'merge', + }; + } + + // Forward the asset-axis signal so the RPC fallback recovers these pins. + if ( + response.unprocessedCustomAssets && + response.unprocessedCustomAssets.length > 0 + ) { + context.response.unprocessedCustomAssets = [ + ...(context.response.unprocessedCustomAssets ?? []), + ...response.unprocessedCustomAssets, + ]; + } + // Determine successfully handled chains (exclude unprocessed/error chains) const unprocessedChains = new Set(Object.keys(response.errors ?? {})); successfullyHandledChains = request.chainIds.filter( @@ -798,6 +957,36 @@ export class AccountsApiDataSource extends AbstractDataSource< // SUBSCRIBE // ============================================================================ + /** + * Claim EVM pins on assigned chains (sent to v6 as `includeAssetIds`). + * v5 has no `includeAssetIds`, so with the v6 flag off nothing is claimed + * and pins fall through to RPC. + * + * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. + * @param assignedChains - Chains assigned to this source in the handoff. + * @returns The claimed subset of `customAssets`. + */ + claimCustomAssets( + customAssets: Caip19AssetId[], + assignedChains: ChainId[], + ): Caip19AssetId[] { + if (!this.#isBalanceV6Enabled()) { + return []; + } + const assigned = new Set(assignedChains); + return customAssets.filter((assetId) => { + try { + const parsed = parseCaipAssetType(assetId); + return ( + parsed.chain.namespace === KnownCaipNamespace.Eip155 && + assigned.has(parsed.chainId) + ); + } catch { + return false; + } + }); + } + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { const { request, subscriptionId, isUpdate, skipInitialFetch } = subscriptionRequest; diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 0fbdf5e315d..800d26a7613 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -832,28 +832,38 @@ describe('RpcDataSource', () => { .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue(createBalanceFetchResult()); - await withController(async ({ controller }) => { - const request = createDataRequest({ - customAssets: [customAssetId], - }); - await controller.fetch(request); - - expect(fetchSpy).toHaveBeenCalledWith( - MOCK_CHAIN_ID_HEX, - MOCK_ACCOUNT_ID, - MOCK_ADDRESS, - [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - expect.objectContaining({ - assetId: customAssetId, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, }), - ], - ); - }); + }, + }, + async ({ controller }) => { + const request = createDataRequest({ + customAssets: [customAssetId], + }); + await controller.fetch(request); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: customAssetId, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], + ); + }, + ); fetchSpy.mockRestore(); }); @@ -868,28 +878,128 @@ describe('RpcDataSource', () => { .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue(createBalanceFetchResult()); - await withController(async ({ controller }) => { - const request = createDataRequest({ - customAssets: [matchingAsset, otherChainAsset], - }); - await controller.fetch(request); + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { + [MOCK_ACCOUNT_ID]: [matchingAsset, otherChainAsset], + }, + }), + }, + }, + async ({ controller }) => { + const request = createDataRequest({ + customAssets: [matchingAsset, otherChainAsset], + }); + await controller.fetch(request); - expect(fetchSpy).toHaveBeenCalledWith( - MOCK_CHAIN_ID_HEX, - MOCK_ACCOUNT_ID, - MOCK_ADDRESS, - [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - expect.objectContaining({ - assetId: matchingAsset, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: matchingAsset, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], + ); + }, + ); + + fetchSpy.mockRestore(); + }); + + it('does not fetch custom assets the fetching account has not pinned', async () => { + const customAssetId = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + options: { isBalanceV6Enabled: (): boolean => true }, + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { 'other-account-id': [customAssetId] }, }), - ], - ); - }); + }, + }, + async ({ controller }) => { + const request = createDataRequest({ + customAssets: [customAssetId], + }); + await controller.fetch(request); + + // The request's customAssets list is flat; ownership comes from + // state. MOCK_ACCOUNT_ID did not pin the asset, so its fetch + // carries only the native entry. + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + ], + ); + }, + ); + + fetchSpy.mockRestore(); + }); + + it('fetches request custom assets on v5 even when another account owns the pin', async () => { + const customAssetId = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { 'other-account-id': [customAssetId] }, + }), + }, + }, + async ({ controller }) => { + const request = createDataRequest({ + customAssets: [customAssetId], + }); + await controller.fetch(request); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: customAssetId, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], + ); + }, + ); fetchSpy.mockRestore(); }); @@ -1381,6 +1491,211 @@ describe('RpcDataSource', () => { expect(detectionStopSpy).toHaveBeenCalled(); }); }); + + it('starts an asset-scoped poll (explicit assetIds) for pinned assets on chains not assigned to RPC', async () => { + const balanceStartSpy = jest.spyOn( + BalanceFetcher.prototype, + 'startPolling', + ); + const detectionStartSpy = jest.spyOn( + TokenDetector.prototype, + 'startPolling', + ); + const customAssetId = + `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + + await withController( + { + options: { isBalanceV6Enabled: (): boolean => true }, + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, + }), + }, + }, + async ({ controller }) => { + // The chain axis assigned nothing to RPC (chainIds is empty — e.g. + // the websocket claimed the chain), but RPC claimed the pinned asset. + await controller.subscribe({ + request: createDataRequest({ + chainIds: [], + customAssets: [customAssetId], + }), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate: jest.fn(), + }); + + expect(balanceStartSpy).toHaveBeenCalledWith({ + chainId: MOCK_CHAIN_ID_HEX, + accountId: MOCK_ACCOUNT_ID, + accountAddress: MOCK_ADDRESS, + assetIds: [customAssetId], + }); + // No regular chain coverage — detection must not run. + expect(detectionStartSpy).not.toHaveBeenCalled(); + await controller.unsubscribe('test-sub'); + }, + ); + }); + + it('does not start an asset-scoped poll for accounts without pinned assets on the supplemental chain', async () => { + const balanceStartSpy = jest.spyOn( + BalanceFetcher.prototype, + 'startPolling', + ); + const customAssetId = + `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { 'other-account-id': [customAssetId] }, + }), + }, + }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest({ + chainIds: [], + customAssets: [customAssetId], + }), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate: jest.fn(), + }); + + expect(balanceStartSpy).not.toHaveBeenCalled(); + await controller.unsubscribe('test-sub'); + }, + ); + }); + + it('does not start an asset-scoped poll for chains already covered by regular polling', async () => { + const balanceStartSpy = jest.spyOn( + BalanceFetcher.prototype, + 'startPolling', + ); + const customAssetId = + `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, + }), + }, + }, + async ({ controller }) => { + // Chain assigned to RPC: the regular poll already includes + // state.customAssets, so no supplemental poll must start. + await controller.subscribe({ + request: createDataRequest({ + customAssets: [customAssetId], + }), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate: jest.fn(), + }); + + expect(balanceStartSpy).toHaveBeenCalledTimes(1); + expect(balanceStartSpy).toHaveBeenCalledWith({ + chainId: MOCK_CHAIN_ID_HEX, + accountId: MOCK_ACCOUNT_ID, + accountAddress: MOCK_ADDRESS, + }); + await controller.unsubscribe('test-sub'); + }, + ); + }); + + it('unsubscribe stops asset-scoped polling', async () => { + const balanceStopSpy = jest.spyOn( + BalanceFetcher.prototype, + 'stopPollingByPollingToken', + ); + const customAssetId = + `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + + await withController( + { + options: { isBalanceV6Enabled: (): boolean => true }, + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, + }), + }, + }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest({ + chainIds: [], + customAssets: [customAssetId], + }), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate: jest.fn(), + }); + await controller.unsubscribe('test-sub'); + expect(balanceStopSpy).toHaveBeenCalled(); + }, + ); + }); + }); + + describe('claimCustomAssets', () => { + const availableChainAsset = + `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + const unavailableChainAsset = + 'eip155:999/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId; + const nonEvmAsset = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFW' as Caip19AssetId; + + it('claims EVM assets on active chains even when the chain was not assigned to RPC', async () => { + await withController(async ({ controller }) => { + expect( + controller.claimCustomAssets([availableChainAsset], []), + ).toStrictEqual([availableChainAsset]); + }); + }); + + it('does not claim assets on chains RPC cannot serve, non-EVM assets, or malformed IDs', async () => { + await withController(async ({ controller }) => { + expect( + controller.claimCustomAssets( + [ + unavailableChainAsset, + nonEvmAsset, + 'not-a-caip-asset' as Caip19AssetId, + ], + [], + ), + ).toStrictEqual([]); + }); + }); + + it('falls back to assigned chains when network state has not been applied yet', async () => { + const networkState = createMockNetworkState(NetworkStatus.Degraded); + await withController({ networkState }, async ({ controller }) => { + // eslint-disable-next-line n/no-sync -- testing sync API used by AssetsController + expect(controller.getActiveChainsSync()).toStrictEqual([]); + expect( + controller.claimCustomAssets( + [availableChainAsset], + [MOCK_CHAIN_ID_CAIP], + ), + ).toStrictEqual([availableChainAsset]); + expect( + controller.claimCustomAssets([availableChainAsset], []), + ).toStrictEqual([]); + }); + }); }); describe('unsubscribe', () => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 93f8cb5b6ae..2b9cd12d752 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -15,11 +15,11 @@ import type { import { isStrictHexString, isCaipChainId, + KnownCaipNamespace, parseCaipAssetType, parseCaipChainId, hexToNumber, toCaipChainId, - KnownCaipNamespace, } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { BigNumber as BigNumberJS } from 'bignumber.js'; @@ -145,6 +145,11 @@ export type RpcDataSourceOptions = { /** Returns the asset type ('native' | 'erc20' | 'spl') for the given CAIP-19 asset ID */ getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; + /** + * Whether Accounts API v6 pin filtering and asset-scoped polls are enabled. + * Injected by AssetsController; defaults to v5 when omitted. + */ + isBalanceV6Enabled?: () => boolean; }; /** @@ -224,6 +229,8 @@ export class RpcDataSource extends AbstractDataSource< readonly #isOnboarded: () => boolean; + readonly #isBalanceV6Enabled: () => boolean; + /** Currently active chains */ #activeChains: ChainId[] = []; @@ -261,6 +268,8 @@ export class RpcDataSource extends AbstractDataSource< this.#useExternalService = options.useExternalService ?? ((): boolean => true); this.#isOnboarded = options.isOnboarded ?? ((): boolean => true); + this.#isBalanceV6Enabled = + options.isBalanceV6Enabled ?? ((): boolean => false); const balanceInterval = options.balanceInterval ?? DEFAULT_BALANCE_INTERVAL; const detectionInterval = @@ -1015,6 +1024,12 @@ export class RpcDataSource extends AbstractDataSource< const assetsInfo: Record = {}; const failedChains: ChainId[] = []; + // request.customAssets is flat. v6 resolves pin ownership from state so + // each account only fetches its own pins; v5 applies the flat list as on main. + const customAssetsByAccount = request.customAssets + ? this.#getCustomAssetsByAccount() + : {}; + // Fetch balances for each account and its supported chains (pre-computed in request) for (const { account, @@ -1042,29 +1057,23 @@ export class RpcDataSource extends AbstractDataSource< if (request.customAssets) { const existingMetadata = this.#getExistingAssetsMetadata(); - - for (const assetId of request.customAssets) { - try { - const parsed = parseCaipAssetType(assetId); - const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; - if ( - assetChainId === chainId && - this.#getAssetType(assetId) === 'erc20' - ) { - const tokenAddress = - parsed.assetReference.toLowerCase() as Address; - const normalizedId = normalizeAssetId(assetId); - const decimals = existingMetadata[normalizedId]?.decimals; - - assetsToFetch.push({ - assetId, - address: tokenAddress, - decimals, - }); - } - } catch { - // Skip unparseable asset IDs - } + if (this.#isBalanceV6Enabled()) { + this.#appendRequestCustomErc20sV6( + assetsToFetch, + request.customAssets, + chainId, + existingMetadata, + new Set( + (customAssetsByAccount[accountId] ?? []).map(normalizeAssetId), + ), + ); + } else { + this.#appendRequestCustomErc20sV5( + assetsToFetch, + request.customAssets, + chainId, + existingMetadata, + ); } } @@ -1370,6 +1379,37 @@ export class RpcDataSource extends AbstractDataSource< }; } + /** + * RPC is the terminal claimer on the asset axis: it claims every EVM pin it + * has a provider for, even on chains claimed by higher-priority sources. + * Pins outside the regular RPC assignment get an asset-scoped poll (see + * `subscribe`). + * + * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. + * @param assignedChains - Chains assigned to RPC; availability fallback + * before network state is applied. + * @returns The claimed subset of `customAssets`. + */ + claimCustomAssets( + customAssets: Caip19AssetId[], + assignedChains: ChainId[], + ): Caip19AssetId[] { + const available = new Set( + this.#activeChains.length > 0 ? this.#activeChains : assignedChains, + ); + return customAssets.filter((assetId) => { + try { + const parsed = parseCaipAssetType(assetId); + return ( + parsed.chain.namespace === KnownCaipNamespace.Eip155 && + available.has(parsed.chainId) + ); + } catch { + return false; + } + }); + } + /** * Subscribe to updates for the given request. * Starts polling through BalanceFetcher and TokenDetector. @@ -1393,15 +1433,23 @@ export class RpcDataSource extends AbstractDataSource< ) : request.chainIds; + // Pins claimed on chains outside the regular RPC assignment get an + // asset-scoped poll below. + const supplementalChains = this.#getSupplementalCustomAssetChains( + request, + chainsToSubscribe, + ); + log('Subscribe requested', { subscriptionId, isUpdate, accounts: request.accountsWithSupportedChains.map((a) => a.account.id), chainsToSubscribe, + supplementalChains, activeChainsFallback: this.#activeChains.length === 0, }); - if (chainsToSubscribe.length === 0) { + if (chainsToSubscribe.length === 0 && supplementalChains.length === 0) { log('No active chains to subscribe'); return; } @@ -1453,8 +1501,6 @@ export class RpcDataSource extends AbstractDataSource< const balanceToken = this.#balanceFetcher.startPolling(balanceInput); balancePollingTokens.push(balanceToken); - // Token detection is only relevant for "regular" subscriptions — - // a customAssetsOnly subscription should never run detection. if ( request.customAssetsOnly !== true && this.#tokenDetectionEnabled() && @@ -1472,6 +1518,53 @@ export class RpcDataSource extends AbstractDataSource< } } + // Asset-scoped polls on chains another source claimed: poll ONLY the + // claimed pins to avoid double-polling tracked balances. Pin changes + // re-run the subscription pass, which rebuilds these polls. + // Asset-scoped polls on chains another source claimed (v6 only). + if (this.#isBalanceV6Enabled() && supplementalChains.length > 0) { + const supplemental = new Set(supplementalChains); + const claimedAssetsByChain = new Map(); + for (const assetId of request.customAssets ?? []) { + try { + const { chainId } = parseCaipAssetType(assetId); + if (supplemental.has(chainId)) { + const chainAssets = claimedAssetsByChain.get(chainId) ?? []; + chainAssets.push(assetId); + claimedAssetsByChain.set(chainId, chainAssets); + } + } catch { + // Skip unparseable asset IDs + } + } + // request.customAssets is flat; ownership comes from controller state. + const customAssetsByAccount = this.#getCustomAssetsByAccount(); + for (const { account } of request.accountsWithSupportedChains) { + const pinned = new Set(customAssetsByAccount[account.id] ?? []); + if (pinned.size === 0) { + continue; + } + for (const [chainId, chainAssets] of claimedAssetsByChain) { + // Sorted so the polling input (the dedupe key) is deterministic. + const assetIds = chainAssets + .filter((assetId) => pinned.has(assetId)) + .sort(); + if (assetIds.length === 0) { + continue; + } + const balanceInput: BalancePollingInput = { + chainId: caipChainIdToHex(chainId), + accountId: account.id, + accountAddress: account.address as Address, + assetIds, + }; + balancePollingTokens.push( + this.#balanceFetcher.startPolling(balanceInput), + ); + } + } + } + // Store subscription data const accounts = request.accountsWithSupportedChains.map( (entry) => entry.account, @@ -1515,6 +1608,143 @@ export class RpcDataSource extends AbstractDataSource< } } + /** + * Chains needing a supplemental asset-scoped poll: chains of pins not + * covered by the regular RPC polling. Only EVM chains RPC can serve; + * malformed IDs are skipped. + * + * @param request - The subscription's data request (carries `customAssets`). + * @param chainsToSubscribe - Chains covered by the regular polling loop. + * @returns Chains requiring an asset-scoped poll. + */ + #getSupplementalCustomAssetChains( + request: DataRequest, + chainsToSubscribe: ChainId[], + ): ChainId[] { + if (!request.customAssets || request.customAssets.length === 0) { + return []; + } + + const covered = new Set(chainsToSubscribe); + const chains = new Set(); + + for (const assetId of request.customAssets) { + let parsed: ReturnType; + try { + parsed = parseCaipAssetType(assetId); + } catch { + continue; + } + const { chainId } = parsed; + if ( + parsed.chain.namespace === KnownCaipNamespace.Eip155 && + !covered.has(chainId) && + (this.#activeChains.length === 0 || + this.#activeChains.includes(chainId)) + ) { + chains.add(chainId); + } + } + + return [...chains]; + } + + /** + * v5: include every `request.customAssets` ERC-20 on this chain (main behavior). + * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default. + * + * @param assetsToFetch - Native/custom entries for this account-chain fetch. + * @param customAssets - Flat pin list from the data request. + * @param chainId - Chain being fetched. + * @param existingMetadata - Metadata already in AssetsController state. + */ + #appendRequestCustomErc20sV5( + assetsToFetch: AssetFetchEntry[], + customAssets: Caip19AssetId[], + chainId: ChainId, + existingMetadata: Record, + ): void { + for (const assetId of customAssets) { + try { + const parsed = parseCaipAssetType(assetId); + const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + if ( + assetChainId === chainId && + this.#getAssetType(assetId) === 'erc20' + ) { + const tokenAddress = parsed.assetReference.toLowerCase() as Address; + const normalizedId = normalizeAssetId(assetId); + const decimals = existingMetadata[normalizedId]?.decimals; + + assetsToFetch.push({ + assetId, + address: tokenAddress, + decimals, + }); + } + } catch { + // Skip unparseable asset IDs + } + } + } + + /** + * v6: include only pins owned by this account on this chain. + * + * @param assetsToFetch - Native/custom entries for this account-chain fetch. + * @param customAssets - Flat pin list from the data request. + * @param chainId - Chain being fetched. + * @param existingMetadata - Metadata already in AssetsController state. + * @param pinnedByAccount - This account's pins from controller state. + */ + #appendRequestCustomErc20sV6( + assetsToFetch: AssetFetchEntry[], + customAssets: Caip19AssetId[], + chainId: ChainId, + existingMetadata: Record, + pinnedByAccount: Set, + ): void { + for (const assetId of customAssets) { + try { + const parsed = parseCaipAssetType(assetId); + const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + const normalizedId = normalizeAssetId(assetId); + if ( + assetChainId === chainId && + pinnedByAccount.has(normalizedId) && + this.#getAssetType(assetId) === 'erc20' + ) { + const tokenAddress = parsed.assetReference.toLowerCase() as Address; + const decimals = existingMetadata[normalizedId]?.decimals; + + assetsToFetch.push({ + assetId, + address: tokenAddress, + decimals, + }); + } + } catch { + // Skip unparseable asset IDs + } + } + } + + /** + * Get per-account pins from AssetsController state — the request's flat + * `customAssets` list carries no account association. + * + * @returns Record of account ID to pinned CAIP-19 asset IDs. + */ + #getCustomAssetsByAccount(): Record { + try { + const state = this.#messenger.call('AssetsController:getState'); + return (state.customAssets ?? {}) as Record; + } catch (error) { + log('Failed to get customAssets from state', { error }); + return {}; + } + } + /** * Get existing assets metadata from AssetsController state. * Used to include metadata for ERC20 tokens when returning balance updates. diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index 795b6f92b9b..4c10eba6aa9 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -248,12 +248,7 @@ describe('BalanceFetcher', () => { ); }); - it('polls custom assets even when they have no entry in assetsBalance yet', async () => { - // Regression: custom assets must be polled by RPC because RPC is the - // sole balance fetcher for them. Previously #getAssetsToFetch only - // looked at state.assetsBalance, so a freshly added custom asset - // (no balance row yet, e.g. zero balance or first fetch failed) - // would never be polled. + it('includes state.customAssets on the regular poll even without an assetsBalance row', async () => { const mockState: AssetsBalanceState = { assetsBalance: { [TEST_ACCOUNT_ID]: { @@ -283,12 +278,7 @@ describe('BalanceFetcher', () => { true, '1000000000000000000', ), - createMockBalanceResponse( - TEST_TOKEN_1.toLowerCase() as Address, - TEST_ACCOUNT, - true, - '500', - ), + createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, true, '0'), ]); const input: BalancePollingInput = { @@ -299,9 +289,6 @@ describe('BalanceFetcher', () => { await controller._executePoll(input); - // The multicall batch should include both the native asset and - // the custom ERC-20 token, even though the custom token has no - // entry in assetsBalance. const [, batchedRequests] = mockMulticallClient.batchBalanceOf.mock.calls[0]; const requestedTokens = ( @@ -310,16 +297,17 @@ describe('BalanceFetcher', () => { .map((req) => req.tokenAddress.toLowerCase()) .sort(); expect(requestedTokens).toStrictEqual( - [ZERO_ADDRESS.toLowerCase(), TEST_TOKEN_1.toLowerCase()].sort(), + [ZERO_ADDRESS, TEST_TOKEN_1] + .map((addr) => addr.toLowerCase()) + .sort(), ); }, ); }); - it('in customAssetsOnly mode skips state.assetsBalance and only fetches state.customAssets', async () => { - // The supplemental subscription path: another data source covers the - // chain for regular balances, but RPC must still poll the user's - // customAssets. We must NOT also poll the regular tracked balances. + it('with explicit assetIds fetches exactly those assets and ignores tracked state', async () => { + // Asset-scoped poll path: RPC claimed the pins on a chain another + // source owns — it must NOT also poll the tracked balances. const mockState: AssetsBalanceState = { assetsBalance: { [TEST_ACCOUNT_ID]: { @@ -354,7 +342,7 @@ describe('BalanceFetcher', () => { chainId: MAINNET_CHAIN_ID, accountId: TEST_ACCOUNT_ID, accountAddress: TEST_ACCOUNT, - customAssetsOnly: true, + assetIds: [TOKEN_1_ASSET_ID], }; await controller._executePoll(input); @@ -366,8 +354,8 @@ describe('BalanceFetcher', () => { ) .map((req) => req.tokenAddress.toLowerCase()) .sort(); - // ONLY the custom token — not the native and not TOKEN_2 from - // assetsBalance. + // ONLY the pinned asset — not the native and not TOKEN_2 from + // assetsBalance, even though both are tracked in state. expect(requestedTokens).toStrictEqual([TEST_TOKEN_1.toLowerCase()]); }, ); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 44d8af36fa1..b9320a16d41 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -45,11 +45,16 @@ export type BalancePollingInput = { accountId: AccountId; /** Account address */ accountAddress: Address; + /** + * When present, fetch exactly these assets instead of the tracked + * `assetsBalance` entries (Accounts API v6 asset-scoped RPC poll). Must be + * deterministically ordered — the polling input is the dedupe key. + */ + assetIds?: CaipAssetType[]; /** * When true, only fetch balances for entries in `state.customAssets`, * skipping `state.assetsBalance`. Used by the supplemental RPC - * subscription on chains that another data source is already covering - * for regular balance refreshes. + * subscription on the Accounts API v5 path. */ customAssetsOnly?: boolean; }; @@ -121,6 +126,7 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { - const assets = this.#getAssetsToFetch(chainId, accountId, customAssetsOnly); + const assets = this.#getAssetsToFetch( + chainId, + accountId, + assetIds, + customAssetsOnly, + ); return this.fetchBalancesForAssets( chainId, diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts index af114458eaf..85f99ef54e2 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts @@ -55,8 +55,9 @@ export type AssetsBalanceState = { assetsBalance: Record>; /** * User-added custom assets per account: accountId -> CAIP-19 asset IDs. - * Used to ensure RPC polling refreshes custom assets even when they have - * no entry in `assetsBalance` yet (e.g. zero balance, first fetch failed). + * Not read by asset-scoped polls (`BalancePollingInput.assetIds`). Regular + * polls still include these so v5 supplemental/`customAssetsOnly` refreshes + * pins that have no `assetsBalance` row yet. */ customAssets?: Record; }; diff --git a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts index 8b6fceed491..d38fe358aad 100644 --- a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts +++ b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts @@ -53,6 +53,17 @@ export function mergeDataResponses(responses: DataResponse[]): DataResponse { ...response.errors, }; } + if ( + response.unprocessedCustomAssets && + response.unprocessedCustomAssets.length > 0 + ) { + merged.unprocessedCustomAssets = [ + ...new Set([ + ...(merged.unprocessedCustomAssets ?? []), + ...response.unprocessedCustomAssets, + ]), + ]; + } if (response.detectedAssets) { merged.detectedAssets = { ...(merged.detectedAssets ?? {}), diff --git a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts index 6677f31e5cd..c8161574813 100644 --- a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts +++ b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts @@ -15,14 +15,10 @@ const MOCK_ACCOUNT_ID = 'mock-account-id'; const MOCK_ASSET_MAINNET = 'eip155:1/slip44:60' as Caip19AssetId; const MOCK_ASSET_POLYGON = 'eip155:137/slip44:966' as Caip19AssetId; const MOCK_ASSET_BSC = 'eip155:56/slip44:714' as Caip19AssetId; -const MOCK_ERC20_MAINNET = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; -const MOCK_ERC20_POLYGON = - 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; -const MOCK_STAKING_ASSET_MAINNET = - 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df' as Caip19AssetId; -const MOCK_NON_EVM_ASSET = - 'bip122:000000000019d6689c085ae165831e93/slip44:0' as Caip19AssetId; +const MOCK_TOKEN_POLYGON = + 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; +const MOCK_TOKEN_MAINNET = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; function createMockAccount(id: string = MOCK_ACCOUNT_ID): InternalAccount { return { @@ -100,7 +96,10 @@ describe('RpcFallbackMiddleware', () => { it('passes through when there are no errors in the response', async () => { const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext(createDataRequest(['eip155:1']), { assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '1' } }, @@ -121,7 +120,10 @@ describe('RpcFallbackMiddleware', () => { }, }; const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext(createDataRequest(['eip155:1', 'eip155:137']), { assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '1' } }, @@ -144,7 +146,10 @@ describe('RpcFallbackMiddleware', () => { }, }; const { source } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext(createDataRequest(['eip155:1', 'eip155:137']), { assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '1' } }, @@ -169,7 +174,10 @@ describe('RpcFallbackMiddleware', () => { }, }; const { source } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext(createDataRequest(['eip155:137']), { errors: { 'eip155:137': 'Fetch failed: oops' }, }); @@ -183,7 +191,10 @@ describe('RpcFallbackMiddleware', () => { it('keeps errors for chains RPC could not recover', async () => { const { source } = createMockRpcSource({}); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext(createDataRequest(['eip155:137']), { errors: { 'eip155:137': 'Fetch failed: oops' }, }); @@ -203,7 +214,10 @@ describe('RpcFallbackMiddleware', () => { // would still have its error cleared. The check must look at what RPC // actually returned. const { source } = createMockRpcSource({}); // RPC fails — empty response - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext(createDataRequest(['eip155:137']), { assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_POLYGON]: { amount: '7' } }, @@ -222,7 +236,10 @@ describe('RpcFallbackMiddleware', () => { it('does not run for non-balance data types', async () => { const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext( { ...createDataRequest(['eip155:1']), @@ -248,7 +265,10 @@ describe('RpcFallbackMiddleware', () => { }, }; const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); const ctx = createContext( createDataRequest(['eip155:1', 'eip155:137', 'eip155:56']), { @@ -270,406 +290,126 @@ describe('RpcFallbackMiddleware', () => { expect(finalCtx.response.errors).toStrictEqual({}); }); - describe('assets tracked in state that the response left empty', () => { - it('passes through when tracked assets all have a positive balance in the response', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '12' } }, - }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(ctx); - }); - - it('matches response asset IDs case-insensitively', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ERC20_MAINNET.toLowerCase()]: { amount: '12' }, - }, - }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw).not.toHaveBeenCalled(); + it('recovers unprocessedCustomAssets with an RPC call scoped to just those assets (via customAssets), not a whole-chain fetch', async () => { + const rpcResponse: DataResponse = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_TOKEN_POLYGON]: { amount: '3' } }, + }, + }; + const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, }); - - it('fetches tracked assets the response omitted via RPC', async () => { - const rpcResponse: DataResponse = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '42' } }, - }, - }; - const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '1' } }, - }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_MAINNET]: { amount: '1' }, - [MOCK_ERC20_MAINNET]: { amount: '1000' }, - }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw).toHaveBeenCalledTimes(1); - const [rpcCtx] = rpcMw.mock.calls[0]; - expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:1']); - expect(rpcCtx.request.customAssets).toStrictEqual([MOCK_ERC20_MAINNET]); - expect( - next.mock.calls[0][0].response.assetsBalance[MOCK_ACCOUNT_ID], - ).toStrictEqual({ - [MOCK_ASSET_MAINNET]: { amount: '1' }, - [MOCK_ERC20_MAINNET]: { amount: '42' }, - }); + const ctx = createContext(createDataRequest(['eip155:1', 'eip155:137']), { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '1' } }, + }, + unprocessedCustomAssets: [MOCK_TOKEN_POLYGON], }); + const next = jest.fn(async (innerCtx) => innerCtx); - it('fetches tracked assets the response reports as zero via RPC', async () => { - const rpcResponse: DataResponse = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '9' } }, - }, - }; - const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '0' } }, - }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); + await mw.assetsMiddleware(ctx, next); - expect(rpcMw).toHaveBeenCalledTimes(1); - expect( - next.mock.calls[0][0].response.assetsBalance[MOCK_ACCOUNT_ID][ - MOCK_ERC20_MAINNET - ], - ).toStrictEqual({ amount: '9' }); - }); + expect(rpcMw).toHaveBeenCalledTimes(1); + const [rpcCtx] = rpcMw.mock.calls[0]; + expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:137']); + expect(rpcCtx.request.customAssets).toStrictEqual([MOCK_TOKEN_POLYGON]); - it('fetches custom assets from state that the response left empty', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - {}, - { customAssets: { [MOCK_ACCOUNT_ID]: [MOCK_ERC20_MAINNET] } }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw.mock.calls[0][0].request.customAssets).toStrictEqual([ - MOCK_ERC20_MAINNET, - ]); + const finalCtx = next.mock.calls[0][0]; + expect(finalCtx.response.assetsBalance[MOCK_ACCOUNT_ID]).toStrictEqual({ + [MOCK_ASSET_MAINNET]: { amount: '1' }, + [MOCK_TOKEN_POLYGON]: { amount: '3' }, }); + // Recovered — removed from the asset axis. + expect(finalCtx.response.unprocessedCustomAssets).toBeUndefined(); + }); - it('keeps custom assets already on the request', async () => { - const otherCustom = - 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - { ...createDataRequest(['eip155:1']), customAssets: [otherCustom] }, - {}, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw.mock.calls[0][0].request.customAssets).toStrictEqual([ - otherCustom, - MOCK_ERC20_MAINNET, - ]); + it('skips asset-scoped recovery for assets whose chain was already retried on the chain axis', async () => { + // The chain-axis fetch (native + custom assets) already covers the token, so + // there must be no second, asset-scoped RPC call for the same chain. + const rpcResponse: DataResponse = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_TOKEN_POLYGON]: { amount: '3' } }, + }, + }; + const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, }); - - it('skips staking contract assets', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - {}, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_STAKING_ASSET_MAINNET]: { amount: '1000' }, - }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw).not.toHaveBeenCalled(); + const ctx = createContext(createDataRequest(['eip155:137']), { + errors: { 'eip155:137': 'Unprocessed networks' }, + unprocessedCustomAssets: [MOCK_TOKEN_POLYGON], }); + const next = jest.fn(async (innerCtx) => innerCtx); - it('skips non-EVM tracked assets', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - {}, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_NON_EVM_ASSET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw).not.toHaveBeenCalled(); - }); + await mw.assetsMiddleware(ctx, next); - it('skips tracked assets on chains outside the request', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1'], ['eip155:1', 'eip155:137']), - {}, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_POLYGON]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); + // Only the chain-axis call — it uses the original request (no customAssets + // override to the unresolved subset). + expect(rpcMw).toHaveBeenCalledTimes(1); + const [rpcCtx] = rpcMw.mock.calls[0]; + expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:137']); + expect(rpcCtx.request.customAssets).toBeUndefined(); - await mw.assetsMiddleware(ctx, next); + const finalCtx = next.mock.calls[0][0]; + expect(finalCtx.response.errors).toStrictEqual({}); + expect(finalCtx.response.unprocessedCustomAssets).toBeUndefined(); + }); - expect(rpcMw).not.toHaveBeenCalled(); + it('keeps unprocessedCustomAssets that RPC could not recover', async () => { + const { source } = createMockRpcSource({}); // RPC returns nothing + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, }); - - it('skips tracked assets on chains the account does not support', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1', 'eip155:137'], ['eip155:1']), - {}, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_POLYGON]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw).not.toHaveBeenCalled(); + const ctx = createContext(createDataRequest(['eip155:137']), { + unprocessedCustomAssets: [MOCK_TOKEN_POLYGON], }); + const next = jest.fn(async (innerCtx) => innerCtx); - it('fetches both errored chains and chains of stale tracked assets', async () => { - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1', 'eip155:137']), - { - errors: { 'eip155:137': 'Unprocessed by Accounts API' }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); + await mw.assetsMiddleware(ctx, next); - expect(new Set(rpcMw.mock.calls[0][0].request.chainIds)).toStrictEqual( - new Set(['eip155:137', 'eip155:1']), - ); - }); + const finalCtx = next.mock.calls[0][0]; + expect(finalCtx.response.unprocessedCustomAssets).toStrictEqual([ + MOCK_TOKEN_POLYGON, + ]); + }); - it('discards failed RPC results for stale-asset chains so upstream balances survive', async () => { - // Regression: RpcDataSource writes a native `0` stub for chains it fails - // on. For a chain fetched only because of a stale tracked asset (the - // upstream source succeeded on it), merging that stub would overwrite - // the correct native amount and, with replaceCoveredChainBalances, wipe - // the chain's token slice from state. - const rpcFailureResponse: DataResponse = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '0' } }, - }, - errors: { 'eip155:1': 'Fetch failed: provider down' }, - }; - const { source } = createMockRpcSource(rpcFailureResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1']), - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '5' } }, - }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_MAINNET]: { amount: '5' }, - [MOCK_ERC20_MAINNET]: { amount: '1000' }, - }, - }, + it('recovers both errored chains and unprocessed assets in separate RPC calls', async () => { + const rpcResponse: DataResponse = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_BSC]: { amount: '9' }, + [MOCK_TOKEN_MAINNET]: { amount: '4' }, }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - const finalCtx = next.mock.calls[0][0]; - expect(finalCtx.response.assetsBalance[MOCK_ACCOUNT_ID]).toStrictEqual({ - [MOCK_ASSET_MAINNET]: { amount: '5' }, - }); - expect(finalCtx.response.errors?.['eip155:1']).toBeUndefined(); + }, + }; + const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, }); - - it('drops failure stubs but keeps the error for chains already errored upstream', async () => { - // The stub must not count as a "recovered" balance either — the chain - // stays errored so the slow pipeline retries it. - const rpcFailureResponse: DataResponse = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_POLYGON]: { amount: '0' } }, - }, - errors: { 'eip155:137': 'Fetch failed: provider down' }, - }; - const { source } = createMockRpcSource(rpcFailureResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext(createDataRequest(['eip155:137']), { - errors: { 'eip155:137': 'Unprocessed by Accounts API' }, - }); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - const finalCtx = next.mock.calls[0][0]; - expect( - finalCtx.response.assetsBalance?.[MOCK_ACCOUNT_ID], - ).toBeUndefined(); - expect(finalCtx.response.errors?.['eip155:137']).toBe( - 'Fetch failed: provider down', - ); + const ctx = createContext(createDataRequest(['eip155:1', 'eip155:56']), { + errors: { 'eip155:56': 'Fetch failed' }, + unprocessedCustomAssets: [MOCK_TOKEN_MAINNET], }); + const next = jest.fn(async (innerCtx) => innerCtx); - it('merges recovered errored chains while discarding a failed stale-asset chain', async () => { - const rpcResponse: DataResponse = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_POLYGON]: { amount: '7' }, - [MOCK_ASSET_MAINNET]: { amount: '0' }, - }, - }, - errors: { 'eip155:1': 'Fetch failed: provider down' }, - }; - const { source } = createMockRpcSource(rpcResponse); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const ctx = createContext( - createDataRequest(['eip155:1', 'eip155:137']), - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '5' } }, - }, - errors: { 'eip155:137': 'Unprocessed by Accounts API' }, - }, - { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - }, - }, - ); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); + await mw.assetsMiddleware(ctx, next); - const finalCtx = next.mock.calls[0][0]; - expect(finalCtx.response.assetsBalance[MOCK_ACCOUNT_ID]).toStrictEqual({ - [MOCK_ASSET_MAINNET]: { amount: '5' }, - [MOCK_ASSET_POLYGON]: { amount: '7' }, - }); - expect(finalCtx.response.errors).toStrictEqual({}); - }); + // One call for the errored chain (whole chain), one scoped call for the pin. + expect(rpcMw).toHaveBeenCalledTimes(2); + const chainCall = rpcMw.mock.calls[0][0]; + expect(chainCall.request.chainIds).toStrictEqual(['eip155:56']); + expect(chainCall.request.customAssets).toBeUndefined(); + const assetCall = rpcMw.mock.calls[1][0]; + expect(assetCall.request.chainIds).toStrictEqual(['eip155:1']); + expect(assetCall.request.customAssets).toStrictEqual([MOCK_TOKEN_MAINNET]); - it('deduplicates stale assets shared by several accounts', async () => { - const secondAccountId = 'second-account-id'; - const stateBalances = { - [MOCK_ACCOUNT_ID]: { [MOCK_ERC20_MAINNET]: { amount: '1000' } }, - [secondAccountId]: { [MOCK_ERC20_MAINNET]: { amount: '5' } }, - }; - const { source, middleware: rpcMw } = createMockRpcSource(); - const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); - const request = { - ...createDataRequest(['eip155:1']), - accountsWithSupportedChains: [ - { - account: createMockAccount(), - supportedChains: ['eip155:1'] as ChainId[], - }, - { - account: createMockAccount(secondAccountId), - supportedChains: ['eip155:1'] as ChainId[], - }, - ], - }; - const ctx = createContext(request, {}, { assetsBalance: stateBalances }); - const next = jest.fn(async (innerCtx) => innerCtx); - - await mw.assetsMiddleware(ctx, next); - - expect(rpcMw.mock.calls[0][0].request.customAssets).toStrictEqual([ - MOCK_ERC20_MAINNET, - ]); - }); + const finalCtx = next.mock.calls[0][0]; + expect(finalCtx.response.errors).toStrictEqual({}); + expect(finalCtx.response.unprocessedCustomAssets).toBeUndefined(); }); }); diff --git a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts index 32377f1d1f2..e9b94773d04 100644 --- a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts +++ b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts @@ -9,9 +9,12 @@ import type { Caip19AssetId, ChainId, Context, + DataRequest, DataResponse, Middleware, + NextFunction, } from '../types.js'; +import { normalizeAssetId } from '../utils/index.js'; import { mergeDataResponses } from './ParallelMiddleware.js'; const CONTROLLER_NAME = 'RpcFallbackMiddleware'; @@ -21,33 +24,35 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); export type RpcFallbackMiddlewareOptions = { /** The RPC data source to use as a fallback. */ rpcDataSource: AssetsDataSource; + /** + * When true, recover `unprocessedCustomAssets` (Accounts API v6). When false, + * re-read stale tracked assets the v5 API omitted (legacy path). + */ + isBalanceV6Enabled?: () => boolean; }; +const noopNext = async (ctx: Context): Promise => ctx; + /** - * RpcFallbackMiddleware re-reads balances from the RPC data source in two - * cases: + * RpcFallbackMiddleware recovers what upstream sources left outstanding on RPC. * - * 1. Chains present in `response.errors` (network error, unprocessedNetworks, - * timeout, …). Successful RPC results are merged into the response and - * their entries are cleared from `response.errors`. - * 2. EVM assets tracked in state (`state.assetsBalance` or - * `state.customAssets`) that this balance response left empty. The Accounts - * API omits tokens it does not index, and a returned `0` is - * indistinguishable from "not indexed", so the `merge` state update would - * otherwise keep a stale amount forever. They are passed to RPC as - * `customAssets` so the balance fetcher includes them in its multicall. + * Accounts API v5: chains in `response.errors`, plus EVM assets tracked in + * state that this response left empty (the API omits tokens it does not index). * - * Place this immediately after `createParallelBalanceMiddleware` in the fast - * pipeline, after `CustomAssetGraduationMiddleware` so the custom assets RPC - * carries never trigger graduation. + * Accounts API v6: chains in `response.errors`, plus pins in + * `response.unprocessedCustomAssets`. */ export class RpcFallbackMiddleware { readonly name = CONTROLLER_NAME; readonly #rpcDataSource: AssetsDataSource; + readonly #isBalanceV6Enabled: () => boolean; + constructor(options: RpcFallbackMiddlewareOptions) { this.#rpcDataSource = options.rpcDataSource; + this.#isBalanceV6Enabled = + options.isBalanceV6Enabled ?? ((): boolean => false); } getName(): string { @@ -56,117 +61,239 @@ export class RpcFallbackMiddleware { get assetsMiddleware(): Middleware { return forDataTypes(['balance'], async (ctx, next) => { - const erroredChains = new Set( - Object.keys(ctx.response.errors ?? {}) as ChainId[], - ); - const staleAssets = collectStaleTrackedAssets(ctx); - - const chainsToFetch = [ - ...new Set([ - ...ctx.request.chainIds.filter((id) => erroredChains.has(id)), - // Already restricted to requested chains. Their chain may not be - // errored: the Accounts API can answer for a chain while omitting a - // token it does not index. - ...staleAssets.map((assetId) => assetId.split('/')[0] as ChainId), - ]), - ]; - - if (chainsToFetch.length === 0) { - return next(ctx); + if (this.#isBalanceV6Enabled()) { + return this.#recoverV6(ctx, next); } + return this.#recoverV5(ctx, next); + }); + } - log('Re-reading balances on RPC', { - erroredChains: [...erroredChains], - staleAssets, - chains: chainsToFetch, - }); - - const filteredRequest = { - ...ctx.request, - chainIds: chainsToFetch, - customAssets: [ - ...new Set([...(ctx.request.customAssets ?? []), ...staleAssets]), - ], - }; - - const noopNext = async (inner: typeof ctx): Promise => inner; - const rpcResult = await this.#rpcDataSource.assetsMiddleware( - { - ...ctx, - request: filteredRequest, - response: {}, - }, - noopNext, - ); + async #recoverV6(ctx: Context, next: NextFunction): Promise { + const erroredChains = new Set( + Object.keys(ctx.response.errors ?? {}) as ChainId[], + ); + const unprocessedCustomAssets = [ + ...new Set(ctx.response.unprocessedCustomAssets ?? []), + ]; - // A chain RPC itself failed on contributed nothing trustworthy: its - // balances are failure stubs (native 0) that would overwrite correct - // upstream amounts and, with replaceCoveredChainBalances, wipe the - // chain's token slice from state. Drop them before merging — this also - // keeps failed chains from counting as "recovered" below. - const rpcFailedChains = new Set( - Object.keys(rpcResult.response.errors ?? {}) as ChainId[], - ); - const rpcAssetsBalance = filterOutChainBalances( - rpcResult.response.assetsBalance, - rpcFailedChains, - ); + if (erroredChains.size === 0 && unprocessedCustomAssets.length === 0) { + return next(ctx); + } + + let merged: DataResponse = ctx.response; - // RPC errors are kept only for chains that were already errored - // upstream. For chains fetched solely for stale tracked assets the - // upstream response succeeded and stays authoritative — the stale asset - // keeps its previous amount and is retried on the next pass. - const rpcErrors = Object.fromEntries( - Object.entries(rpcResult.response.errors ?? {}).filter(([chainId]) => - erroredChains.has(chainId as ChainId), - ), + if (erroredChains.size > 0) { + merged = await this.#recoverErroredChains(ctx, merged, erroredChains); + } + + const assetsToRecover = unprocessedCustomAssets.filter( + (assetId) => !erroredChains.has(chainIdOfAsset(assetId)), + ); + if (assetsToRecover.length > 0) { + merged = await this.#recoverUnprocessedAssets( + ctx, + merged, + assetsToRecover, ); + } + + merged = clearRecoveredAssetIds(merged); + + return next({ ...ctx, response: merged }); + } + + async #recoverV5(ctx: Context, next: NextFunction): Promise { + const erroredChains = new Set( + Object.keys(ctx.response.errors ?? {}) as ChainId[], + ); + const staleAssets = collectStaleTrackedAssets(ctx); + + const chainsToFetch = [ + ...new Set([ + ...ctx.request.chainIds.filter((id) => erroredChains.has(id)), + ...staleAssets.map((assetId) => assetId.split('/')[0] as ChainId), + ]), + ]; + + if (chainsToFetch.length === 0) { + return next(ctx); + } + + log('Re-reading balances on RPC', { + erroredChains: [...erroredChains], + staleAssets, + chains: chainsToFetch, + }); + + const filteredRequest = { + ...ctx.request, + chainIds: chainsToFetch, + customAssets: [ + ...new Set([...(ctx.request.customAssets ?? []), ...staleAssets]), + ], + }; + + const rpcResult = await this.#rpcDataSource.assetsMiddleware( + { + ...ctx, + request: filteredRequest, + response: {}, + }, + noopNext, + ); + + const rpcFailedChains = new Set( + Object.keys(rpcResult.response.errors ?? {}) as ChainId[], + ); + const rpcAssetsBalance = filterOutChainBalances( + rpcResult.response.assetsBalance, + rpcFailedChains, + ); + + const rpcErrors = Object.fromEntries( + Object.entries(rpcResult.response.errors ?? {}).filter(([chainId]) => + erroredChains.has(chainId as ChainId), + ), + ); + + const merged: DataResponse = mergeDataResponses([ + ctx.response, + { + ...rpcResult.response, + assetsBalance: rpcAssetsBalance, + errors: rpcErrors, + }, + ]); - const merged: DataResponse = mergeDataResponses([ - ctx.response, - { - ...rpcResult.response, - assetsBalance: rpcAssetsBalance, - errors: rpcErrors, - }, - ]); - - // Clear errors only for chains RPC actually recovered a balance for. - // We must inspect the (filtered) RPC balances — NOT merged — because - // merged also contains balances from the upstream sources (AccountsApi / - // Websocket / Staked). If those sources returned partial data for - // a chain that they also flagged as errored (e.g. via - // unprocessedNetworks), and RPC then failed for that same chain, - // looking at merged would incorrectly mark the error as recovered. - if (merged.errors && rpcAssetsBalance) { - const chainsRecoveredByRpc = new Set(); - for (const accountBalances of Object.values(rpcAssetsBalance)) { - for (const assetId of Object.keys(accountBalances)) { - chainsRecoveredByRpc.add(assetId.split('/')[0]); - } + if (merged.errors && rpcAssetsBalance) { + const chainsRecoveredByRpc = new Set(); + for (const accountBalances of Object.values(rpcAssetsBalance)) { + for (const assetId of Object.keys(accountBalances)) { + chainsRecoveredByRpc.add(assetId.split('/')[0]); } - for (const chainId of erroredChains) { - if (chainsRecoveredByRpc.has(chainId)) { - delete merged.errors[chainId]; - } + } + for (const chainId of erroredChains) { + if (chainsRecoveredByRpc.has(chainId)) { + delete merged.errors[chainId]; } } + } - return next({ ...ctx, response: merged }); + return next({ ...ctx, response: merged }); + } + + async #recoverErroredChains( + ctx: Context, + currentResponse: DataResponse, + erroredChains: Set, + ): Promise { + log('Retrying failed chains on RPC', { chains: [...erroredChains] }); + + const chainRequest: DataRequest = { + ...ctx.request, + chainIds: ctx.request.chainIds.filter((id) => erroredChains.has(id)), + }; + const rpcResult = await this.#rpcDataSource.assetsMiddleware( + { ...ctx, request: chainRequest, response: {} }, + noopNext, + ); + + const rpcFailedChains = new Set( + Object.keys(rpcResult.response.errors ?? {}) as ChainId[], + ); + const rpcAssetsBalance = filterOutChainBalances( + rpcResult.response.assetsBalance, + rpcFailedChains, + ); + + const merged = mergeDataResponses([ + currentResponse, + { + ...rpcResult.response, + assetsBalance: rpcAssetsBalance, + }, + ]); + + if (merged.errors && rpcAssetsBalance) { + const chainsRecoveredByRpc = new Set(); + for (const accountBalances of Object.values(rpcAssetsBalance)) { + for (const assetId of Object.keys(accountBalances)) { + chainsRecoveredByRpc.add(assetId.split('/')[0]); + } + } + for (const chainId of erroredChains) { + if (chainsRecoveredByRpc.has(chainId)) { + delete merged.errors[chainId]; + } + } + } + + return merged; + } + + async #recoverUnprocessedAssets( + ctx: Context, + currentResponse: DataResponse, + assetsToRecover: Caip19AssetId[], + ): Promise { + const assetChains = [ + ...new Set(assetsToRecover.map((assetId) => chainIdOfAsset(assetId))), + ]; + + log('Recovering unprocessed pinned assets on RPC', { + assetIds: assetsToRecover, }); + + const assetRequest: DataRequest = { + ...ctx.request, + chainIds: assetChains, + customAssets: assetsToRecover, + }; + const rpcResult = await this.#rpcDataSource.assetsMiddleware( + { ...ctx, request: assetRequest, response: {} }, + noopNext, + ); + + return mergeDataResponses([currentResponse, rpcResult.response]); } } -/** - * Remove all balances that belong to the given chains. - * - * Used to discard results for chains the RPC source itself failed on, whose - * entries are failure stubs (native `0`) rather than real readings. - * - * @param assetsBalance - Balances by account from the RPC response. - * @param chainIds - Chains whose balances should be dropped. - * @returns The filtered balance map, without accounts left empty. - */ +function chainIdOfAsset(assetId: Caip19AssetId): ChainId { + return assetId.split('/')[0] as ChainId; +} + +function clearRecoveredAssetIds(response: DataResponse): DataResponse { + if ( + !response.unprocessedCustomAssets || + response.unprocessedCustomAssets.length === 0 + ) { + return response; + } + + const recovered = new Set(); + for (const accountBalances of Object.values(response.assetsBalance ?? {})) { + for (const assetId of Object.keys(accountBalances)) { + recovered.add(normalizeAssetId(assetId as Caip19AssetId)); + } + } + + const stillUnprocessed = response.unprocessedCustomAssets.filter( + (assetId) => !recovered.has(normalizeAssetId(assetId)), + ); + + if (stillUnprocessed.length === response.unprocessedCustomAssets.length) { + return response; + } + + const next = { ...response }; + if (stillUnprocessed.length === 0) { + delete next.unprocessedCustomAssets; + } else { + next.unprocessedCustomAssets = stillUnprocessed; + } + return next; +} + function filterOutChainBalances( assetsBalance: DataResponse['assetsBalance'], chainIds: Set, @@ -189,17 +316,6 @@ function filterOutChainBalances( return filtered; } -/** - * EVM assets tracked in state that this balance response left empty and RPC - * should re-read. - * - * Limited to chains both requested and supported by the owning account: - * RpcDataSource fetches per account and skips chains outside its supported - * set, so anything else would be queued and then silently dropped. - * - * @param ctx - Pipeline context. - * @returns Asset IDs to hand to the RPC data source. - */ function collectStaleTrackedAssets(ctx: Context): Caip19AssetId[] { const { assetsBalance: stateAssetsBalance, customAssets: stateCustomAssets } = ctx.getAssetsState(); @@ -221,8 +337,6 @@ function collectStaleTrackedAssets(ctx: Context): Caip19AssetId[] { for (const assetId of trackedAssetIds) { if ( isEvmAssetOnChains(assetId, chainsForAccount) && - // Staked vault balances belong to StakedBalanceDataSource; an RPC - // ERC-20 read of the share token would clobber them. !isStakingContractAssetId(assetId) && isBalanceEmpty(ctx.response.assetsBalance?.[accountId], assetId) ) { @@ -234,16 +348,6 @@ function collectStaleTrackedAssets(ctx: Context): Caip19AssetId[] { return [...staleAssets]; } -/** - * Whether a balance response carries no positive amount for an asset. A - * returned `0` cannot be distinguished from "not indexed", so both count as - * empty. Asset IDs are matched case-insensitively: state keys ERC-20 assets by - * checksummed address, while some data sources return them lower-cased. - * - * @param balances - Balance map for a single account from the response. - * @param assetId - Asset ID to check. - * @returns True when the response holds no positive amount for the asset. - */ function isBalanceEmpty( balances: Record | undefined, assetId: Caip19AssetId, @@ -258,13 +362,6 @@ function isBalanceEmpty( return !(Number(amount) > 0); } -/** - * Whether an asset is an EVM asset on one of the given chains. - * - * @param assetId - CAIP-19 asset ID. - * @param chainIds - Chains to match against. - * @returns True for EVM assets whose chain is in `chainIds`. - */ function isEvmAssetOnChains( assetId: Caip19AssetId, chainIds: ChainId[], diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 501220b6e5f..4fce55fef55 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -342,9 +342,15 @@ export type DataRequest = { * When true, the data source should poll only the user's `customAssets` * for the requested chains and skip refreshing the regular tracked * balances. Used by the AssetsController to issue a supplemental RPC - * subscription on chains that another data source is already covering. + * subscription on chains that another data source is already covering + * (Accounts API v5 path). Ignored when Accounts API v6 is enabled. */ customAssetsOnly?: boolean; + /** + * User-hidden CAIP-19 asset IDs, sent to the Accounts API v6 endpoint as + * `excludeAssetIds` so they are dropped from the response. + */ + excludeAssetIds?: Caip19AssetId[]; /** Force fresh fetch, bypass cache */ forceUpdate?: boolean; /** @@ -371,8 +377,14 @@ export type DataResponse = { assetsPrice?: Record; /** Balance data per account */ assetsBalance?: Record>; - /** Errors encountered, keyed by chain ID */ + /** Errors encountered, keyed by chain ID (chain-axis fallback + telemetry) */ errors?: Record; + /** + * Pinned asset IDs the source could not resolve (asset-axis fallback). + * Unlike `errors` the chain succeeded — only these assets still need a + * downstream (RPC) fetch. + */ + unprocessedCustomAssets?: Caip19AssetId[]; /** Detected assets (assets that do not have metadata) */ detectedAssets?: Record; /** @@ -384,8 +396,8 @@ export type DataResponse = { * When set with `updateMode: 'merge'`, balances on chains present in * `assetsBalance` replace the prior chain slice (stale tokens on those chains * are dropped). Custom assets on covered chains are preserved. Used for - * `getAssets({ forceUpdate: true })` so unlock/startup reflects the API snapshot - * without switching to `updateMode: 'full'`. + * Accounts API v5 `getAssets({ forceUpdate: true })`. Accounts API v6 uses + * `updateMode: 'full'` instead. */ replaceCoveredChainBalances?: boolean; }; @@ -393,20 +405,18 @@ export type DataResponse = { /** * Type of {@link DataResponse.updateMode}: how the controller applies the response to state. * - * - **full**: Response is the full set for the scope. Assets in state but not in the - * response are cleared (except custom assets). Use for initial fetch or full refresh. - * - **merge**: By default only assets present in the response are updated; - * nothing is removed. When {@link DataResponse.replaceCoveredChainBalances} - * is true, balances on chains present in the response replace the prior chain - * slice (stale tokens on those chains are dropped; custom assets preserved). - * Metadata and prices from the response are applied. Use for event-driven updates. - * - **update**: Balance-only overlay — incoming balance amounts are patched in place; - * existing balances, metadata, and prices are never removed or overwritten. - * Missing metadata and prices from the response are seeded so RPC-only chains - * can render on first fetch. Use for force refresh when the API may return a - * partial chain snapshot. - */ -export type AssetsUpdateMode = 'full' | 'merge' | 'update'; + * - **full**: Response is the full set for covered chains (those present in + * `assetsBalance`). Assets in state on those chains but not in the response + * are cleared, including custom assets (Accounts API v6 returns pins via + * `includeAssetIds`). The only exception is {@link DataResponse.unprocessedCustomAssets} + * that RPC fallback also could not recover — those prior balances are kept + * until a later successful fetch. Use for Accounts API v6 snapshots. + * - **merge**: Only assets present in the response are updated; nothing is + * removed, unless {@link DataResponse.replaceCoveredChainBalances} is set + * (Accounts API v5 force refresh). Metadata and prices from the response + * are applied. Use for event-driven updates and Accounts API v5. + */ +export type AssetsUpdateMode = 'full' | 'merge'; // ============================================================================ // DATA SOURCE <-> CONTROLLER (DIRECT CALLS, NO MESSENGER PER SOURCE) From 9887e84f978143b8e06be37e6a5a44efc913d94d Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Fri, 4 Sep 2026 10:40:47 +0200 Subject: [PATCH 02/30] add rpcFallbackMiddleware in v6 --- packages/assets-controller/CHANGELOG.md | 1 + .../architecture/15.0.0/CHANGES.md | 161 ++++++++++++++++++ .../src/AssetsController.test.ts | 19 +-- .../assets-controller/src/AssetsController.ts | 28 +-- .../assets-controller/tsconfig.build.json | 2 +- 5 files changed, 187 insertions(+), 24 deletions(-) create mode 100644 packages/assets-controller/architecture/15.0.0/CHANGES.md diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 14054f7c3dc..8d74e8d623d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - When `assetsAccountsApiV6` is enabled, Accounts API fetch and middleware report `updateMode: 'full'` so assets on chains in the snapshot (including custom assets returned via `includeAssetIds`) are replaced; pins left in `unprocessedCustomAssets` after RPC fallback keep their prior balances. The v5 path keeps `updateMode: 'merge'` with `replaceCoveredChainBalances` so custom assets are preserved ([#9651](https://github.com/MetaMask/core/pull/9651)) +- When `assetsAccountsApiV6` is enabled, the `getAssets` force-update fast pipeline runs `RpcFallbackMiddleware` after Accounts API so `unprocessedIncludeAssetIds` are recovered on RPC before state is committed (same as poll enrichment). Chains that still have unresolved pins are also kept on the slow RPC lane. - **BREAKING:** Remove the unused `updateMode` option from `AssetsController.getAssets`. Apply mode comes only from `DataResponse.updateMode` (data sources). Remove `'update'` from `AssetsUpdateMode` — it was never produced; use `'merge'` to overlay or `'full'` to replace a covered chain slice ([#9651](https://github.com/MetaMask/core/pull/9651)) ## [16.0.0] diff --git a/packages/assets-controller/architecture/15.0.0/CHANGES.md b/packages/assets-controller/architecture/15.0.0/CHANGES.md new file mode 100644 index 00000000000..a186261a77a --- /dev/null +++ b/packages/assets-controller/architecture/15.0.0/CHANGES.md @@ -0,0 +1,161 @@ +# AssetsController 15.0.0 - architecture change and AccountsAPI v5/v6 paths + +This branch keeps the previous balance behavior and the new Accounts API v6 +behavior side by side behind the `assetsAccountsApiV6` remote feature flag. + +- Flag off, missing, or unreadable: use the legacy **v5** path. +- `assetsAccountsApiV6: { value: true }`: use the new **v6** path. + +The flag is read only in `AssetsController.#isBalanceV6Enabled()` and injected +into `AccountsApiDataSource`, `RpcFallbackMiddleware`, and `RpcDataSource`. + +## Architectural change + +The controller now has two explicit orchestration paths instead of one mixed +flow with many conditional branches: + +- **v5 path** preserves the old behavior so rollout-off matches existing + production semantics. +- **v6 path** isolates the new behavior so rollout-on can be evaluated and the + old path can be deleted cleanly later. + +This split exists in three main places: + +1. Force-update orchestration from `getAssets(..., { forceUpdate: true })` +2. Balance subscription setup +3. Balance update enrichment and merge + +## Path overview + +```mermaid +flowchart TD + A[getAssets forceUpdate / subscribe / handleAssetsUpdate] --> F{assetsAccountsApiV6} + F -->|false| V5[v5 path] + F -->|true| V6[v6 path] +``` + +## Force-update path + +Both paths share the same broad shape: run a fast lane, commit state, then run +the slower background fetch. + +```mermaid +flowchart TB + subgraph request ["Request shaping"] + R5["v5 request
every pinned asset for requested accounts
unscoped
no excludeAssetIds"] + R6["v6 request
pins scoped to requested chains
optional customAssets override
hidden assets as excludeAssetIds"] + end + + subgraph v5fast ["v5 fast lane"] + direction TB + V5P["Accounts API v5 || Staked"] + V5G[CustomAssetGraduation] + V5F[RpcFallback] + V5D[Detection] + V5T["Token || Price"] + V5P --> V5G --> V5F --> V5D --> V5T + V5S["State update
merge + replaceCoveredChainBalances"] + V5T --> V5S + end + + subgraph v6fast ["v6 fast lane"] + direction TB + V6P["Accounts API v6 || Staked
includeAssetIds / excludeAssetIds"] + V6F["RpcFallback
errored chains + unprocessedIncludeAssetIds"] + V6D[Detection] + V6T["Token || Price"] + V6P --> V6F --> V6D --> V6T + V6S["State update
Accounts API drives updateMode full"] + V6T --> V6S + end + + subgraph bg ["Background lane"] + B1["Snap || RPC"] + B2[Detection] + B3["Token || Price"] + B4["State update
merge"] + B1 --> B2 --> B3 --> B4 + end + + R5 --> v5fast --> bg + R6 --> v6fast --> bg +``` + +When basic functionality is off, both fast lanes reduce to `Staked -> Detection` +and the background lane is RPC only. + +## Subscribe path + +```mermaid +flowchart LR + subgraph v5sub ["v5 subscribe"] + S5A[Chain handoff by source priority] + S5B["RPC customAssetsOnly supplement
for pins on chains another source owns"] + S5A --> S5B + end + + subgraph v6sub ["v6 subscribe"] + S6A[Chain handoff by source priority] + S6B["claimCustomAssets per source
Accounts API claims EVM pins as includeAssetIds"] + S6C["RPC asset-scoped polls
for unclaimed claimed pins"] + S6A --> S6B --> S6C + end +``` + +`AccountsApiDataSource.claimCustomAssets()` returns `[]` on v5. + +## Update enrichment path + +```mermaid +flowchart TB + subgraph v5update ["v5 handleAssetsUpdate"] + U5G["CustomAssetGraduation
for Accounts API / AccountActivity"] + U5D[Detection] + U5T["Token || Price"] + U5S["State update
merge; honor replaceCoveredChainBalances"] + U5G --> U5D --> U5T --> U5S + end + + subgraph v6update ["v6 handleAssetsUpdate"] + U6F["RpcFallback when basic on
errored chains + unprocessedCustomAssets"] + U6D[Detection] + U6T["Token || Price"] + U6S["State update
full when Accounts API marks it full"] + U6F --> U6D --> U6T --> U6S + end +``` + +v5 does not run `RpcFallbackMiddleware` on this subscribe/update path. v6 does +not run `CustomAssetGraduationMiddleware`. + +## Behavioral intent + +| Concern | v5 | v6 | +| ------------------------- | ------------------------------------------ | ------------------------------------------------- | +| Accounts API endpoint | `fetchV5MultiAccountBalances` | `fetchV6MultiAccountBalances` | +| Accounts API update mode | `merge` | `full` | +| Covered-chain merge | Preserve old behavior | Replace covered chain slice | +| Custom asset preservation | Keep custom + staked pins in v5 merge path | Keep `unprocessedCustomAssets` until RPC resolves | +| Hidden assets | Not sent to v5 endpoint | Sent as `excludeAssetIds` | +| RPC token fetch | Flat `request.customAssets` on the chain | Only pins owned by that account | + +## Code map + +| Concern | v5 implementation | v6 implementation | +| ------------------------ | ----------------------------------------- | ----------------------------------------- | +| Force-update request | `#buildForceUpdateRequestV5` | `#buildForceUpdateRequestV6` | +| Force-update pipeline | `#forceUpdateAssetsV5`, `#runFastFetchV5` | `#forceUpdateAssetsV6`, `#runFastFetchV6` | +| Subscribe | `#subscribeAssetsBalanceV5` | `#subscribeAssetsBalanceV6` | +| Update enrichment | `#handleAssetsUpdateV5` | `#handleAssetsUpdateV6` | +| Balance merge | `mergeAccountBalancesV5` | `mergeAccountBalancesV6` | +| Accounts API fetch | `#fetchV5Balances` | `#fetchV6Balances` | +| RPC fallback | `#recoverV5` | `#recoverV6` | +| RPC custom ERC-20 append | `#appendRequestCustomErc20sV5` | `#appendRequestCustomErc20sV6` | + +## Deletion plan after rollout + +Once v6 is accepted as the only behavior: + +1. Remove the v5 methods and `!this.#isBalanceV6Enabled()` branches. +2. Keep the v6 methods as the only orchestration path. +3. Remove transitional docs/tables that compare v5 and v6. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 1e19332fcdd..421fa6a8b91 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1801,7 +1801,7 @@ describe('AssetsController', () => { ); }); - it('routes chains carrying unprocessed pinned assets (unprocessedCustomAssets) to the slow-pipeline RPC fetch', async () => { + it('falls back to RPC on the force-update fast path for unprocessed include asset ids', async () => { const customToken = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; @@ -1826,9 +1826,9 @@ describe('AssetsController', () => { }, } as unknown as ApiPlatformClient; - const rpcRequestChainIds: ChainId[][] = []; + const rpcRequestCustomAssets: (Caip19AssetId[] | undefined)[] = []; const rpcMiddleware = jest.fn(async (ctx, next) => { - rpcRequestChainIds.push(ctx.request.chainIds); + rpcRequestCustomAssets.push(ctx.request.customAssets); return next(ctx); }); const rpcMiddlewareGetter = jest @@ -1850,22 +1850,21 @@ describe('AssetsController', () => { await controller.addCustomAsset(MOCK_ACCOUNT_ID, customToken); + rpcMiddleware.mockClear(); await controller.getAssets([createMockInternalAccount()], { chainIds: ['eip155:1'], forceUpdate: true, }); - - // Slow pipeline is fire-and-forget; let it run. - await flushPromises(); }, ); - // The chain of the unresolved pin (eip155:1) — a chain AccountsApi - // handled and did NOT flag as errored — is still routed to RPC in the - // slow pipeline so the pin gets fetched. + // RpcFallbackMiddleware runs in the awaited fast lane, scoped to the + // pins Accounts API listed in unprocessedIncludeAssetIds. expect(rpcMiddleware).toHaveBeenCalled(); expect( - rpcRequestChainIds.some((chains) => chains.includes('eip155:1')), + rpcRequestCustomAssets.some((customAssets) => + customAssets?.includes(customToken), + ), ).toBe(true); rpcMiddlewareGetter.mockRestore(); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 5917800101c..603ce2c7cfc 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1889,6 +1889,7 @@ export class AssetsController extends BaseController< this.#accountsApiDataSource, this.#stakedBalanceDataSource, ]), + this.#rpcFallbackMiddleware, this.#detectionMiddleware, createParallelMiddleware([ this.#tokenDataSource, @@ -2612,19 +2613,20 @@ export class AssetsController extends BaseController< * @returns `true` when the v6 remote flag is on. */ #isBalanceV6Enabled(): boolean { - try { - const { remoteFeatureFlags } = this.messenger.call( - 'RemoteFeatureFlagController:getState', - ); - const flag = remoteFeatureFlags?.assetsAccountsApiV6; - return ( - typeof flag === 'object' && - flag !== null && - Boolean((flag as { value?: unknown }).value) - ); - } catch { - return false; - } + return true; + // try { + // const { remoteFeatureFlags } = this.messenger.call( + // 'RemoteFeatureFlagController:getState', + // ); + // const flag = remoteFeatureFlags?.assetsAccountsApiV6; + // return ( + // typeof flag === 'object' && + // flag !== null && + // Boolean((flag as { value?: unknown }).value) + // ); + // } catch { + // return false; + // } } // ============================================================================ diff --git a/packages/assets-controller/tsconfig.build.json b/packages/assets-controller/tsconfig.build.json index a5960f7760f..73b031aff4d 100644 --- a/packages/assets-controller/tsconfig.build.json +++ b/packages/assets-controller/tsconfig.build.json @@ -61,5 +61,5 @@ } ], "include": ["../../types", "./src"], - "exclude": ["**/*.test.ts", "**/__fixtures__/"] + "exclude": ["**/*.test.ts", "**/__fixtures__/", "./architecture"] } From e1241d66a1b6a806e8c9e8c200eac84fe7a7ad42 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 7 Sep 2026 22:51:57 +0200 Subject: [PATCH 03/30] Not fetching hidden tokens --- packages/assets-controller/CHANGELOG.md | 1 + .../src/AssetsController.test.ts | 134 +++++++++++++++++- .../assets-controller/src/AssetsController.ts | 77 +++++++++- .../AccountsApiDataSource.test.ts | 6 +- .../src/data-sources/AccountsApiDataSource.ts | 33 ++--- 5 files changed, 224 insertions(+), 27 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8d74e8d623d..613695c8347 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A hide now wins over a pin when an asset is both in `customAssets` and marked `hidden` in `assetPreferences`. Hidden pins are dropped from the `customAssets`/`includeAssetIds` attached to `getAssets` force updates, balance subscriptions, and the supplemental RPC custom-asset poll, so a hidden token is no longer fetched. `hideAsset` still leaves the pin in `customAssets` to record that the token was imported, and `unhideAsset` restores it on the next subscription ([#9651](https://github.com/MetaMask/core/pull/9651)) - When `assetsAccountsApiV6` is enabled, Accounts API fetch and middleware report `updateMode: 'full'` so assets on chains in the snapshot (including custom assets returned via `includeAssetIds`) are replaced; pins left in `unprocessedCustomAssets` after RPC fallback keep their prior balances. The v5 path keeps `updateMode: 'merge'` with `replaceCoveredChainBalances` so custom assets are preserved ([#9651](https://github.com/MetaMask/core/pull/9651)) - When `assetsAccountsApiV6` is enabled, the `getAssets` force-update fast pipeline runs `RpcFallbackMiddleware` after Accounts API so `unprocessedIncludeAssetIds` are recovered on RPC before state is committed (same as poll enrichment). Chains that still have unresolved pins are also kept on the slow RPC lane. - **BREAKING:** Remove the unused `updateMode` option from `AssetsController.getAssets`. Apply mode comes only from `DataResponse.updateMode` (data sources). Remove `'update'` from `AssetsUpdateMode` — it was never produced; use `'merge'` to overlay or `'full'` to replace a covered chain slice ([#9651](https://github.com/MetaMask/core/pull/9651)) diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 421fa6a8b91..9f905cb50d7 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -27,7 +27,10 @@ import type { PriceDataSourceConfig } from './data-sources/PriceDataSource.js'; import { PriceDataSource } from './data-sources/PriceDataSource.js'; import { RpcDataSource } from './data-sources/RpcDataSource.js'; import { TokenDataSource } from './data-sources/TokenDataSource.js'; -import { buildDefaultAssetsInfo } from './defaults.js'; +import { + buildDefaultAssetsInfo, + getDefaultTrackedAssetsForChain, +} from './defaults.js'; import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata.js'; import type { Caip19AssetId, @@ -102,6 +105,10 @@ const MOCK_ASSET_ID = const MOCK_ASSET_ID_LOWERCASE = 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId; const MOCK_NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId; +/** mUSD on mainnet — a controller-managed default tracked asset. */ +const [MOCK_DEFAULT_TRACKED_ASSET_ID] = getDefaultTrackedAssetsForChain( + 'eip155:1' as ChainId, +); /** * Activate asset tracking by marking the UI open, the keyring unlocked, and @@ -1464,6 +1471,67 @@ describe('AssetsController', () => { ); }); + it('stops forwarding a pinned custom asset as includeAssetIds once it is hidden', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + await withController( + { + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + }, + async ({ controller }) => { + await flushPromises(); + + await controller.addCustomAsset(MOCK_ACCOUNT_ID, customToken); + controller.hideAsset(customToken); + + // The pin is preserved so the import is remembered. + expect(controller.getCustomAssets(MOCK_ACCOUNT_ID)).toContain( + customToken, + ); + + fetchV6MultiAccountBalances.mockClear(); + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + + expect(fetchV6MultiAccountBalances).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + excludeAssetIds: expect.arrayContaining([customToken]), + }), + expect.anything(), + ); + for (const [, params] of fetchV6MultiAccountBalances.mock.calls) { + expect(params?.includeAssetIds ?? []).not.toContain(customToken); + } + }, + ); + }); + it('includes every pinned asset on the v5 force-update request, including other chains', async () => { const mainnetToken = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; @@ -3092,6 +3160,70 @@ describe('AssetsController', () => { }); }); + it('keeps default tracked assets at zero when a full update omits them', async () => { + // Regression: a force refresh (e.g. "Refresh list") replaces the chain + // slice, so mUSD was dropped from the token list for accounts holding + // none. Default tracked assets are re-asserted just like natives. + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '0' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_DEFAULT_TRACKED_ASSET_ID + ], + ).toStrictEqual({ amount: '0' }); + }); + }); + + it('does not overwrite a default tracked asset balance returned by a full update', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '5' }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '7' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_DEFAULT_TRACKED_ASSET_ID + ], + ).toStrictEqual({ amount: '7' }); + }); + }); + it('preserves unprocessed custom assets that RPC fallback could not recover', async () => { const initialState: Partial = { assetsBalance: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 603ce2c7cfc..2d2020e50fd 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -109,6 +109,7 @@ import { DEFAULT_TRACKED_ASSETS_BY_CHAIN, buildDefaultAssetsInfo, getDefaultAssetMetadata, + getDefaultTrackedAssetsForChain, } from './defaults.js'; import { AssetsDataSourceError } from './errors.js'; import { projectLogger, createModuleLogger } from './logger.js'; @@ -1760,7 +1761,7 @@ export class AssetsController extends BaseController< ): DataRequest { const customAssets: Caip19AssetId[] = []; for (const account of accounts) { - customAssets.push(...this.getCustomAssets(account.id)); + customAssets.push(...this.#getVisibleCustomAssets(account.id)); } return this.#buildDataRequest(accounts, chainIds, { @@ -1803,7 +1804,8 @@ export class AssetsController extends BaseController< try { const normalizedAssetId = normalizeAssetId(assetId); if ( - requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) + requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) && + !this.state.assetPreferences[normalizedAssetId]?.hidden ) { customAssetsSet.add(normalizedAssetId); } @@ -2606,6 +2608,21 @@ export class AssetsController extends BaseController< return hidden; } + /** + * An account's pins minus the ones the user has hidden. A hide wins over a + * pin on requests, so a hidden asset is never fetched; the pin stays in + * `customAssets` to record that the token was imported, and unhiding it + * restores the pin on the next subscription. + * + * @param accountId - The account whose pins should be collected. + * @returns The account's pinned asset IDs that are not hidden. + */ + #getVisibleCustomAssets(accountId: AccountId): Caip19AssetId[] { + return this.getCustomAssets(accountId).filter( + (assetId) => !this.state.assetPreferences[assetId]?.hidden, + ); + } + /** * Whether Accounts API v6 (and the v6 custom-asset path) is enabled. * Injected into AccountsApiDataSource and RpcFallbackMiddleware. @@ -2907,6 +2924,25 @@ export class AssetsController extends BaseController< ); } + /** + * Returns the controller-managed default tracked asset IDs (e.g. mUSD) for + * the chains this account supports (account scopes ∩ enabled chains). + * Non-EVM accounts resolve to an empty list because every chain in the + * defaults registry is EVM today. + * + * @param account - The account (scopes determine which chains apply). + * @returns Array of default tracked asset IDs across the supported chains. + */ + #getDefaultTrackedAssetIdsForAccount( + account: InternalAccount, + ): Caip19AssetId[] { + const ids: Caip19AssetId[] = []; + for (const chainId of this.#getEnabledChainsForAccount(account)) { + ids.push(...getDefaultTrackedAssetsForChain(chainId)); + } + return ids; + } + /** * Chains for the post-commit slow pipeline (Snap + RPC). Excludes chains the * fast Accounts API path already handled without error so stale RPC data cannot @@ -3185,6 +3221,22 @@ export class AssetsController extends BaseController< } } + // Default tracked assets (mUSD) are controller-managed and, like + // natives, must render at zero balance. An authoritative + // chain-slice replace omits them whenever the account holds none, + // so re-assert them here — otherwise a force refresh (e.g. the + // "Refresh list" action) would drop mUSD from the token list. + const defaultTrackedAssetIdsForAccount = account + ? this.#getDefaultTrackedAssetIdsForAccount(account) + : []; + for (const defaultAssetId of defaultTrackedAssetIdsForAccount) { + if ( + !Object.prototype.hasOwnProperty.call(effective, defaultAssetId) + ) { + effective[defaultAssetId] = { amount: '0' } as AssetBalance; + } + } + for (const [assetId, balance] of Object.entries(effective)) { const previousBalance = previousBalances[ assetId as Caip19AssetId @@ -3205,11 +3257,14 @@ export class AssetsController extends BaseController< ); effective[assetId] = { ...balance, amount: newAmount }; const oldAmount = previousBalance?.amount; - const isNewDefaultNativeZero = + const isNewSeededZero = oldAmount === undefined && newAmount === '0' && - nativeAssetIdsForAccount.includes(assetId as Caip19AssetId); - if (oldAmount !== newAmount && !isNewDefaultNativeZero) { + (nativeAssetIdsForAccount.includes(assetId as Caip19AssetId) || + defaultTrackedAssetIdsForAccount.includes( + assetId as Caip19AssetId, + )); + if (oldAmount !== newAmount && !isNewSeededZero) { changedBalances.push({ accountId, assetId, @@ -3677,7 +3732,7 @@ export class AssetsController extends BaseController< const remainingChains = new Set(chainToAccounts.keys()); const remainingCustomAssets = new Set(); for (const account of accounts) { - for (const assetId of this.getCustomAssets(account.id)) { + for (const assetId of this.#getVisibleCustomAssets(account.id)) { try { if (remainingChains.has(parseCaipAssetType(assetId).chainId)) { remainingCustomAssets.add(assetId); @@ -3777,9 +3832,17 @@ export class AssetsController extends BaseController< const rpc = this.#rpcDataSource; const supplementalKey = `ds:${rpc.getName()}:custom`; + const visibleCustomAssetsByAccount: Record = {}; + for (const account of accounts) { + const visibleCustomAssets = this.#getVisibleCustomAssets(account.id); + if (visibleCustomAssets.length > 0) { + visibleCustomAssetsByAccount[account.id] = visibleCustomAssets; + } + } + const decision = pickRpcCustomAssetsSupplement({ accountIds: accounts.map((account) => account.id), - customAssetsByAccount: this.state.customAssets, + customAssetsByAccount: visibleCustomAssetsByAccount, rpcAssignedChains, rpcAvailableChains: new Set(rpc.getActiveChainsSync()), enabledChains: new Set(chainToAccounts.keys()), diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 7776e216372..bddff6e62d6 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -1161,7 +1161,7 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('lets a pinned asset win when it also appears in the hidden list', async () => { + it('lets a hidden asset win when it also appears in the pinned list', async () => { const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, }); @@ -1176,12 +1176,12 @@ describe('AccountsApiDataSource', () => { }), ); - // The asset is pinned, so it is included and never excluded. + // The asset is hidden, so it is excluded and never included. expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - { includeAssetIds: [token] }, + { excludeAssetIds: [token] }, undefined, ); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 3409cccbb38..c44bc96e2be 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -426,19 +426,20 @@ export class AccountsApiDataSource extends AbstractDataSource< }; if (isV6) { - // User-pinned assets on the fetched chains, sent to v6 as - // `includeAssetIds` so the backend returns them even at zero balance. - const includeAssetIds = this.#getIncludeAssetIds( + // User-hidden assets on the fetched chains, sent to v6 as + // `excludeAssetIds`. + const excludeAssetIds = this.#getExcludeAssetIds( request, chainsToFetch, ); - // User-hidden assets on the fetched chains, sent to v6 as - // `excludeAssetIds`. A pin wins over a hide. - const excludeAssetIds = this.#getExcludeAssetIds( + // User-pinned assets on the fetched chains, sent to v6 as + // `includeAssetIds` so the backend returns them even at zero balance. + // A hide wins over a pin, so hidden assets are never fetched. + const includeAssetIds = this.#getIncludeAssetIds( request, chainsToFetch, - includeAssetIds, + excludeAssetIds, ); fetchResult = await this.#fetchV6Balances( @@ -518,24 +519,31 @@ export class AccountsApiDataSource extends AbstractDataSource< /** * Collect the pinned EVM assets on the fetched chains to send to the v6 - * endpoint as `includeAssetIds`; malformed IDs are skipped. + * endpoint as `includeAssetIds`; malformed IDs are skipped and hidden + * assets are left out (a hide wins). * * @param request - The data request (carries `customAssets`). * @param chainsToFetch - Chains being requested this fetch. + * @param excludeAssetIds - Hidden asset IDs that must not be included. * @returns Deduplicated asset IDs, or `undefined` when none. */ #getIncludeAssetIds( request: DataRequest, chainsToFetch: ChainId[], + excludeAssetIds: Caip19AssetId[] | undefined, ): Caip19AssetId[] | undefined { if (!request.customAssets || request.customAssets.length === 0) { return undefined; } const chainsToFetchSet = new Set(chainsToFetch); + const excludeSet = new Set(excludeAssetIds ?? []); const includeAssetIds = new Set(); for (const assetId of request.customAssets) { + if (excludeSet.has(assetId)) { + continue; + } let chainId: ChainId; try { chainId = parseCaipAssetType(assetId).chainId; @@ -555,31 +563,24 @@ export class AccountsApiDataSource extends AbstractDataSource< /** * Collect the hidden EVM assets on the fetched chains to send to the v6 - * endpoint as `excludeAssetIds`; malformed IDs are skipped and pinned - * assets are left out (a pin wins). + * endpoint as `excludeAssetIds`; malformed IDs are skipped. * * @param request - The data request (carries `excludeAssetIds`). * @param chainsToFetch - Chains being requested this fetch. - * @param includeAssetIds - Pinned asset IDs that must not be excluded. * @returns Deduplicated asset IDs, or `undefined` when none. */ #getExcludeAssetIds( request: DataRequest, chainsToFetch: ChainId[], - includeAssetIds: Caip19AssetId[] | undefined, ): Caip19AssetId[] | undefined { if (!request.excludeAssetIds || request.excludeAssetIds.length === 0) { return undefined; } const chainsToFetchSet = new Set(chainsToFetch); - const includeSet = new Set(includeAssetIds ?? []); const excludeAssetIds = new Set(); for (const assetId of request.excludeAssetIds) { - if (includeSet.has(assetId)) { - continue; - } let chainId: ChainId; try { chainId = parseCaipAssetType(assetId).chainId; From ff6e6f34aff391e63366e088984bc86cc8d173c4 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 7 Sep 2026 23:16:07 +0200 Subject: [PATCH 04/30] fix bypassServerCache --- packages/assets-controller/CHANGELOG.md | 4 + .../architecture/v1}/CHANGES.md | 2 +- .../src/AssetsController.test.ts | 73 ++++++++++++++---- .../assets-controller/src/AssetsController.ts | 30 ++++---- .../AccountsApiDataSource.test.ts | 74 +++++++------------ 5 files changed, 106 insertions(+), 77 deletions(-) rename packages/assets-controller/{architecture/15.0.0 => docs/architecture/v1}/CHANGES.md (98%) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 613695c8347..ef0a06ad9fb 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - When `assetsAccountsApiV6` is enabled, the `getAssets` force-update fast pipeline runs `RpcFallbackMiddleware` after Accounts API so `unprocessedIncludeAssetIds` are recovered on RPC before state is committed (same as poll enrichment). Chains that still have unresolved pins are also kept on the slow RPC lane. - **BREAKING:** Remove the unused `updateMode` option from `AssetsController.getAssets`. Apply mode comes only from `DataResponse.updateMode` (data sources). Remove `'update'` from `AssetsUpdateMode` — it was never produced; use `'merge'` to overlay or `'full'` to replace a covered chain slice ([#9651](https://github.com/MetaMask/core/pull/9651)) +### Fixed + +- Treat `assetsAccountsApiV6` as enabled when it is `true` ([#9651](https://github.com/MetaMask/core/pull/9651)) + ## [16.0.0] ### Changed diff --git a/packages/assets-controller/architecture/15.0.0/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md similarity index 98% rename from packages/assets-controller/architecture/15.0.0/CHANGES.md rename to packages/assets-controller/docs/architecture/v1/CHANGES.md index a186261a77a..71dd3253075 100644 --- a/packages/assets-controller/architecture/15.0.0/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -4,7 +4,7 @@ This branch keeps the previous balance behavior and the new Accounts API v6 behavior side by side behind the `assetsAccountsApiV6` remote feature flag. - Flag off, missing, or unreadable: use the legacy **v5** path. -- `assetsAccountsApiV6: { value: true }`: use the new **v6** path. +- `assetsAccountsApiV6: true`: use the new **v6** path. The flag is read only in `AssetsController.#isBalanceV6Enabled()` and injected into `AccountsApiDataSource`, `RpcFallbackMiddleware`, and `RpcDataSource`. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 9f905cb50d7..29ff8dc4d33 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -733,7 +733,7 @@ describe('AssetsController', () => { await withController( { - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); @@ -1404,7 +1404,7 @@ describe('AssetsController', () => { await withController( { queryApiClient, - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await flushPromises(); @@ -1448,7 +1448,7 @@ describe('AssetsController', () => { await withController( { queryApiClient, - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await flushPromises(); @@ -1499,7 +1499,7 @@ describe('AssetsController', () => { await withController( { queryApiClient, - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await flushPromises(); @@ -1595,7 +1595,7 @@ describe('AssetsController', () => { await withController( { - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await controller.addCustomAsset(MOCK_ACCOUNT_ID, mainnetToken); @@ -1641,7 +1641,7 @@ describe('AssetsController', () => { await withController( { - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await controller.addCustomAsset(MOCK_ACCOUNT_ID, pinnedToken); @@ -1684,7 +1684,7 @@ describe('AssetsController', () => { await withController( { - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await controller.getAssets([createMockInternalAccount()], { @@ -1722,7 +1722,7 @@ describe('AssetsController', () => { await withController( { - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await controller.getAssets([createMockInternalAccount()], { @@ -1769,7 +1769,7 @@ describe('AssetsController', () => { await withController( { queryApiClient, - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await flushPromises(); @@ -1792,6 +1792,51 @@ describe('AssetsController', () => { ); }); + it('forwards bypassServerCache on Accounts API v6 force updates', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + await withController( + { + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + await flushPromises(); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + bypassServerCache: true, + }); + + expect(fetchV6MultiAccountBalances).toHaveBeenCalledWith( + expect.any(Array), + undefined, + expect.objectContaining({ bypassServerCache: true }), + ); + }, + ); + }); + describe('pipeline splitting', () => { it('returns from getAssets before background pipelines complete', async () => { // Spy on handleAssetsUpdate to count how many times state is written. @@ -1911,7 +1956,7 @@ describe('AssetsController', () => { await withController( { queryApiClient, - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { await flushPromises(); @@ -2442,7 +2487,7 @@ describe('AssetsController', () => { }; await withController( - { remoteFeatureFlags: { assetsAccountsApiV6: { value: true } } }, + { remoteFeatureFlags: { assetsAccountsApiV6: true } }, async ({ controller }) => { rpcMiddlewareGetter.mockClear(); @@ -2483,7 +2528,7 @@ describe('AssetsController', () => { }; await withController( - { remoteFeatureFlags: { assetsAccountsApiV6: { value: true } } }, + { remoteFeatureFlags: { assetsAccountsApiV6: true } }, async ({ controller }) => { rpcMiddlewareGetter.mockClear(); @@ -2714,7 +2759,7 @@ describe('AssetsController', () => { .mockResolvedValue(undefined); await withController( - { remoteFeatureFlags: { assetsAccountsApiV6: { value: true } } }, + { remoteFeatureFlags: { assetsAccountsApiV6: true } }, async ({ controller }) => { await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); @@ -3896,7 +3941,7 @@ describe('AssetsController', () => { { clientControllerState: { isUiOpen: true }, queryApiClient, - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, state: { assetsBalance: { [MOCK_ACCOUNT_ID]: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 2d2020e50fd..94f788b0dad 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1746,6 +1746,7 @@ export class AssetsController extends BaseController< * @param requestOptions.assetTypes - Asset types to fetch. * @param requestOptions.dataTypes - Data types to fetch. * @param requestOptions.forceUpdate - Always `true` to bypass caches. + * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches. * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for. * @returns The v5 data request. */ @@ -1756,6 +1757,7 @@ export class AssetsController extends BaseController< assetTypes: AssetType[]; dataTypes: DataType[]; forceUpdate: true; + bypassServerCache?: boolean; assetsForPriceUpdate?: Caip19AssetId[]; }, ): DataRequest { @@ -1780,6 +1782,7 @@ export class AssetsController extends BaseController< * @param requestOptions.assetTypes - Asset types to fetch. * @param requestOptions.dataTypes - Data types to fetch. * @param requestOptions.forceUpdate - Always `true` to bypass caches. + * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches. * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for. * @param requestOptions.customAssetsOverride - Pinned assets to use instead of the stored custom assets. * @returns The v6 data request. @@ -1791,6 +1794,7 @@ export class AssetsController extends BaseController< assetTypes: AssetType[]; dataTypes: DataType[]; forceUpdate: true; + bypassServerCache?: boolean; assetsForPriceUpdate?: Caip19AssetId[]; customAssetsOverride?: Caip19AssetId[]; }, @@ -1820,6 +1824,7 @@ export class AssetsController extends BaseController< assetTypes: requestOptions.assetTypes, dataTypes: requestOptions.dataTypes, forceUpdate: requestOptions.forceUpdate, + bypassServerCache: requestOptions.bypassServerCache, assetsForPriceUpdate: requestOptions.assetsForPriceUpdate, customAssets: customAssets.length > 0 ? customAssets : undefined, excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, @@ -2627,23 +2632,20 @@ export class AssetsController extends BaseController< * Whether Accounts API v6 (and the v6 custom-asset path) is enabled. * Injected into AccountsApiDataSource and RpcFallbackMiddleware. * + * `RemoteFeatureFlagController` resolves a threshold-scoped flag to the + * selected group's `value`, so processed state is a boolean. + * * @returns `true` when the v6 remote flag is on. */ #isBalanceV6Enabled(): boolean { - return true; - // try { - // const { remoteFeatureFlags } = this.messenger.call( - // 'RemoteFeatureFlagController:getState', - // ); - // const flag = remoteFeatureFlags?.assetsAccountsApiV6; - // return ( - // typeof flag === 'object' && - // flag !== null && - // Boolean((flag as { value?: unknown }).value) - // ); - // } catch { - // return false; - // } + try { + const { remoteFeatureFlags } = this.messenger.call( + 'RemoteFeatureFlagController:getState', + ); + return remoteFeatureFlags?.assetsAccountsApiV6 === true; + } catch { + return false; + } } // ============================================================================ diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index bddff6e62d6..932abb0dc28 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -37,12 +37,7 @@ const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; function isBalanceV6EnabledFromFlags( remoteFeatureFlags: Record, ): boolean { - const flag = remoteFeatureFlags.assetsAccountsApiV6; - return ( - typeof flag === 'object' && - flag !== null && - Boolean((flag as { value?: unknown }).value) - ); + return remoteFeatureFlags.assetsAccountsApiV6 === true; } type MockApiClient = { @@ -674,7 +669,7 @@ describe('AccountsApiDataSource', () => { it('claims EVM assets on assigned chains when the v6 flag is enabled', async () => { const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); expect( @@ -694,7 +689,7 @@ describe('AccountsApiDataSource', () => { it('claims nothing when the v6 flag is disabled (v5 has no includeAssetIds support)', async () => { const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: false } }, + remoteFeatureFlags: { assetsAccountsApiV6: false }, }); expect( @@ -725,7 +720,7 @@ describe('AccountsApiDataSource', () => { it('uses the v6 endpoint when the assetsAccountsApiV6 remote flag is enabled', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); await controller.fetch(createDataRequest()); @@ -742,7 +737,7 @@ describe('AccountsApiDataSource', () => { it('sets updateMode to full for v6 fetches', async () => { const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const response = await controller.fetch(createDataRequest()); @@ -754,24 +749,7 @@ describe('AccountsApiDataSource', () => { it('uses the v5 endpoint when the assetsAccountsApiV6 remote flag is disabled', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: false } }, - }); - - await controller.fetch(createDataRequest()); - - expect( - apiClient.accounts.fetchV5MultiAccountBalances, - ).toHaveBeenCalledTimes(1); - expect( - apiClient.accounts.fetchV6MultiAccountBalances, - ).not.toHaveBeenCalled(); - - controller.destroy(); - }); - - it('uses the v5 endpoint when the flag is a plain boolean instead of the JSON value shape', async () => { - const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: true }, + remoteFeatureFlags: { assetsAccountsApiV6: false }, }); await controller.fetch(createDataRequest()); @@ -788,7 +766,7 @@ describe('AccountsApiDataSource', () => { it('calls the v6 endpoint without extra params when enabled', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); await controller.fetch(createDataRequest()); @@ -809,7 +787,7 @@ describe('AccountsApiDataSource', () => { it('reads the flag per fetch so it can revert to v5 at runtime', async () => { const remoteFeatureFlags = { - assetsAccountsApiV6: { value: true }, + assetsAccountsApiV6: true, }; const { controller, apiClient } = await setupController({ remoteFeatureFlags, @@ -820,7 +798,7 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledTimes(1); - remoteFeatureFlags.assetsAccountsApiV6 = { value: false }; + remoteFeatureFlags.assetsAccountsApiV6 = false; await controller.fetch(createDataRequest()); expect( apiClient.accounts.fetchV5MultiAccountBalances, @@ -832,7 +810,7 @@ describe('AccountsApiDataSource', () => { it('processes v6 token balances grouped by account', async () => { const accountId = `eip155:1:${MOCK_ADDRESS}`; const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, v6Balances: [ createMockV6BalanceItem( accountId, @@ -857,7 +835,7 @@ describe('AccountsApiDataSource', () => { it('ignores v6 defi positions', async () => { const accountId = `eip155:1:${MOCK_ADDRESS}`; const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, v6Balances: [ createMockV6BalanceItem( accountId, @@ -889,7 +867,7 @@ describe('AccountsApiDataSource', () => { const stakingAssetId = 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df'; const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, v6Balances: [ createMockV6BalanceItem( accountId, @@ -919,7 +897,7 @@ describe('AccountsApiDataSource', () => { it('marks v6 unprocessed networks as errors', async () => { const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedNetworks: ['eip155:1'], }); @@ -934,7 +912,7 @@ describe('AccountsApiDataSource', () => { it('handles v6 API errors', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); apiClient.accounts.fetchV6MultiAccountBalances.mockRejectedValueOnce( @@ -950,7 +928,7 @@ describe('AccountsApiDataSource', () => { it('passes EVM custom assets on requested chains to v6 as includeAssetIds', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const customToken = @@ -973,7 +951,7 @@ describe('AccountsApiDataSource', () => { it('omits custom assets that are not on a requested chain from includeAssetIds', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); // Custom asset on Polygon while only Mainnet is being fetched. @@ -1003,7 +981,7 @@ describe('AccountsApiDataSource', () => { 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedIncludeAssetIds: [customToken], }); @@ -1024,7 +1002,7 @@ describe('AccountsApiDataSource', () => { 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedIncludeAssetIds: [ customToken, 'not-a-caip-asset' as Caip19AssetId, @@ -1042,7 +1020,7 @@ describe('AccountsApiDataSource', () => { it('skips non-EVM and malformed custom assets when building includeAssetIds', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const solanaToken = @@ -1070,7 +1048,7 @@ describe('AccountsApiDataSource', () => { 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedIncludeAssetIds: ['not-a-caip-asset'], }); @@ -1088,7 +1066,7 @@ describe('AccountsApiDataSource', () => { it('passes EVM hidden assets on requested chains to v6 as excludeAssetIds', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const hiddenToken = @@ -1111,7 +1089,7 @@ describe('AccountsApiDataSource', () => { it('omits hidden assets that are not on a requested chain from excludeAssetIds', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); // Hidden asset on Polygon while only Mainnet is being fetched. @@ -1138,7 +1116,7 @@ describe('AccountsApiDataSource', () => { it('skips non-EVM and malformed hidden assets when building excludeAssetIds', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const solanaToken = @@ -1163,7 +1141,7 @@ describe('AccountsApiDataSource', () => { it('lets a hidden asset win when it also appears in the pinned list', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const token = @@ -1190,7 +1168,7 @@ describe('AccountsApiDataSource', () => { it('sends both includeAssetIds and excludeAssetIds when pins and hidden assets differ', async () => { const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const pinned = @@ -1295,7 +1273,7 @@ describe('AccountsApiDataSource', () => { it('middleware forwards full updateMode from v6 fetches', async () => { const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: { value: true } }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, }); const next = jest.fn().mockResolvedValue(undefined); From f67925e6c45efa1a8bf6a07e05f4cd54d06a9179 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 16 Sep 2026 23:05:15 +0200 Subject: [PATCH 05/30] update test threshold --- packages/assets-controller/jest.config.cjs | 2 +- .../src/data-sources/AccountActivityDataSource.ts | 3 --- .../src/data-sources/evm-rpc-services/types/state.ts | 5 ++--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/assets-controller/jest.config.cjs b/packages/assets-controller/jest.config.cjs index 795b4b105c0..1782d6db10e 100644 --- a/packages/assets-controller/jest.config.cjs +++ b/packages/assets-controller/jest.config.cjs @@ -18,7 +18,7 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 82.17, - functions: 90.06, + functions: 90.01, lines: 90.56, statements: 90.56, }, diff --git a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts index 80230efa359..99516d72690 100644 --- a/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountActivityDataSource.ts @@ -251,9 +251,6 @@ export class AccountActivityDataSource extends AbstractDataSource< // SUBSCRIBE / UNSUBSCRIBE // ============================================================================ - // Pins on active chains are claimed via the inherited `claimCustomAssets`: - // the activity stream pushes all activity for the address, pins included. - /** * AADS is event-driven and chain-agnostic: it never participates in the * controller's subscribe/unsubscribe handoff. Incoming `balanceUpdated` diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts index 85f99ef54e2..af114458eaf 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts @@ -55,9 +55,8 @@ export type AssetsBalanceState = { assetsBalance: Record>; /** * User-added custom assets per account: accountId -> CAIP-19 asset IDs. - * Not read by asset-scoped polls (`BalancePollingInput.assetIds`). Regular - * polls still include these so v5 supplemental/`customAssetsOnly` refreshes - * pins that have no `assetsBalance` row yet. + * Used to ensure RPC polling refreshes custom assets even when they have + * no entry in `assetsBalance` yet (e.g. zero balance, first fetch failed). */ customAssets?: Record; }; From 4d37b2f76b0c9fa5454f484f7abeb4c3b8ec0d52 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 21 Sep 2026 14:48:50 +0200 Subject: [PATCH 06/30] clean code --- .../docs/architecture/v1/CHANGES.md | 204 ++++++++---------- .../src/AssetsController.test.ts | 61 ++++++ .../assets-controller/src/AssetsController.ts | 31 +-- ...c-spam-token-filtering.integration.test.ts | 2 +- .../pipeline/buildFastFetchSources.test.ts | 30 ++- .../src/pipeline/buildFastFetchSources.ts | 13 +- 6 files changed, 206 insertions(+), 135 deletions(-) diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index 71dd3253075..385b9d0aeb6 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -1,161 +1,143 @@ -# AssetsController 15.0.0 - architecture change and AccountsAPI v5/v6 paths +# AssetsController 15.0.0 - Accounts API v5 and v6 side by side -This branch keeps the previous balance behavior and the new Accounts API v6 -behavior side by side behind the `assetsAccountsApiV6` remote feature flag. +## What this change is about -- Flag off, missing, or unreadable: use the legacy **v5** path. -- `assetsAccountsApiV6: true`: use the new **v6** path. +The AssetsController collects token balances, metadata, and prices for every +account and chain, and stores the result for the UI to render. -The flag is read only in `AssetsController.#isBalanceV6Enabled()` and injected -into `AccountsApiDataSource`, `RpcFallbackMiddleware`, and `RpcDataSource`. +This release adds support for a new version of the Accounts API (**v6**) while +keeping the current one (**v5**) fully intact. Which one runs is decided at +runtime by the `assetsAccountsApiV6` remote feature flag: -## Architectural change +- flag off, missing, or unreadable: the legacy **v5** path (today's production + behavior) +- `assetsAccountsApiV6: true`: the new **v6** path -The controller now has two explicit orchestration paths instead of one mixed -flow with many conditional branches: +The flag is read in exactly one place, `AssetsController.#isBalanceV6Enabled()`, +and passed down to `AccountsApiDataSource`, `RpcFallbackMiddleware`, and +`RpcDataSource`. -- **v5 path** preserves the old behavior so rollout-off matches existing - production semantics. -- **v6 path** isolates the new behavior so rollout-on can be evaluated and the - old path can be deleted cleanly later. +## Vocabulary used below -This split exists in three main places: +| Term | Meaning | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Pinned asset (custom asset) | A token the user added manually (Manage Token flow). We always want to fetch it, even when the API does not return it on its own. | +| Hidden asset | A token the user chose to hide, so it should not be fetched or shown. | +| Data source | Where balances come from: the Accounts API, RPC nodes, a Snap, or staking. | +| Middleware | A step that enriches or repairs data after it is fetched (token detection, prices, metadata, RPC fallback). | +| Fast lane | The first, quick round of fetching. Its result is written to state right away so the UI can render. | +| Background lane | The slower sources that run afterwards; their results are merged in. | +| Update mode `merge` vs `full` | `merge` keeps assets already in state that the response did not mention. `full` replaces the whole slice the source covers. | +| Basic functionality | The user setting that turns off network calls to MetaMask services. | -1. Force-update orchestration from `getAssets(..., { forceUpdate: true })` -2. Balance subscription setup -3. Balance update enrichment and merge +## Why there are two paths -## Path overview +Rather than scattering flag checks through one shared flow, the controller now +has two named flows: + +- the **v5 path** preserves the old behavior exactly, so turning the flag off + matches production +- the **v6 path** isolates the new behavior, so it can be evaluated on its own + and the old path can later be deleted in one clean sweep + +The split shows up in three places: a forced refresh, setting up live updates, +and handling an update once it arrives. ```mermaid -flowchart TD - A[getAssets forceUpdate / subscribe / handleAssetsUpdate] --> F{assetsAccountsApiV6} - F -->|false| V5[v5 path] - F -->|true| V6[v6 path] +flowchart LR + A["Refresh · Subscribe · Incoming update"] --> F{assetsAccountsApiV6} + F -->|false| V5["v5 path"] + F -->|true| V6["v6 path"] ``` -## Force-update path +## 1. Forced refresh -Both paths share the same broad shape: run a fast lane, commit state, then run -the slower background fetch. +Triggered by `getAssets(..., { forceUpdate: true })`. Both paths have the same +shape: build a request, run the fast lane, write state, then run the background +lane and merge its results. ```mermaid flowchart TB - subgraph request ["Request shaping"] - R5["v5 request
every pinned asset for requested accounts
unscoped
no excludeAssetIds"] - R6["v6 request
pins scoped to requested chains
optional customAssets override
hidden assets as excludeAssetIds"] - end - subgraph v5fast ["v5 fast lane"] direction TB - V5P["Accounts API v5 || Staked"] - V5G[CustomAssetGraduation] - V5F[RpcFallback] - V5D[Detection] - V5T["Token || Price"] - V5P --> V5G --> V5F --> V5D --> V5T - V5S["State update
merge + replaceCoveredChainBalances"] - V5T --> V5S + V5P["Accounts API v5 + Staked"] --> V5G[CustomAssetGraduation] --> V5F[RpcFallback] --> V5D[Detection] --> V5T["Token + Price"] --> V5S["State: merge, replaceCoveredChainBalances"] end - subgraph v6fast ["v6 fast lane"] direction TB - V6P["Accounts API v6 || Staked
includeAssetIds / excludeAssetIds"] - V6F["RpcFallback
errored chains + unprocessedIncludeAssetIds"] - V6D[Detection] - V6T["Token || Price"] - V6P --> V6F --> V6D --> V6T - V6S["State update
Accounts API drives updateMode full"] - V6T --> V6S + V6P["Accounts API v6 + Staked
includeAssetIds / excludeAssetIds"] --> V6F["RpcFallback
errored chains + unprocessedIncludeAssetIds"] --> V6D[Detection] --> V6T["Token + Price"] --> V6S["State: full where the Accounts API has coverage"] end - - subgraph bg ["Background lane"] - B1["Snap || RPC"] - B2[Detection] - B3["Token || Price"] - B4["State update
merge"] - B1 --> B2 --> B3 --> B4 + subgraph bg ["Background lane (same for both)"] + direction TB + B1["Snap + RPC"] --> B2[Detection] --> B3["Token + Price"] --> B4["State: merge"] end - - R5 --> v5fast --> bg - R6 --> v6fast --> bg + v5fast --> bg + v6fast --> bg ``` -When basic functionality is off, both fast lanes reduce to `Staked -> Detection` -and the background lane is RPC only. +The requests differ as well: -## Subscribe path +- **v5** asks for every pinned asset of the requested accounts, without scoping + them to chains, and does not mention hidden assets. +- **v6** scopes pinned assets to the requested chains (or uses the explicit + `customAssets` override) and sends hidden assets as `excludeAssetIds`. -```mermaid -flowchart LR - subgraph v5sub ["v5 subscribe"] - S5A[Chain handoff by source priority] - S5B["RPC customAssetsOnly supplement
for pins on chains another source owns"] - S5A --> S5B - end +When basic functionality is off, both fast lanes shrink to +`Staked -> Detection`. The background lane is RPC only in both paths. - subgraph v6sub ["v6 subscribe"] - S6A[Chain handoff by source priority] - S6B["claimCustomAssets per source
Accounts API claims EVM pins as includeAssetIds"] - S6C["RPC asset-scoped polls
for unclaimed claimed pins"] - S6A --> S6B --> S6C - end -``` +## 2. Setting up live updates (subscribe) -`AccountsApiDataSource.claimCustomAssets()` returns `[]` on v5. +Both paths first hand each chain to the data source with the highest priority +for it. They differ in how pinned assets on those chains are covered: -## Update enrichment path +- **v5** adds a separate RPC poll (`customAssetsOnly`) for pins that sit on a + chain another source already owns. +- **v6** asks each source which pins it can take, via `claimCustomAssets()` - + the Accounts API claims EVM pins and sends them as `includeAssetIds`. RPC then + polls only the pins nobody claimed. -```mermaid -flowchart TB - subgraph v5update ["v5 handleAssetsUpdate"] - U5G["CustomAssetGraduation
for Accounts API / AccountActivity"] - U5D[Detection] - U5T["Token || Price"] - U5S["State update
merge; honor replaceCoveredChainBalances"] - U5G --> U5D --> U5T --> U5S - end +On v5, `AccountsApiDataSource.claimCustomAssets()` simply returns `[]`. - subgraph v6update ["v6 handleAssetsUpdate"] - U6F["RpcFallback when basic on
errored chains + unprocessedCustomAssets"] - U6D[Detection] - U6T["Token || Price"] - U6S["State update
full when Accounts API marks it full"] - U6F --> U6D --> U6T --> U6S - end -``` +## 3. Handling an incoming update + +- **v5** (`#handleAssetsUpdateV5`): CustomAssetGraduation (for the Accounts API + and AccountActivity) -> Detection -> Token + Price -> state written with + `merge`, honoring `replaceCoveredChainBalances`. +- **v6** (`#handleAssetsUpdateV6`): RpcFallback when basic functionality is on + (for errored chains and `unprocessedCustomAssets`) -> Detection -> Token + + Price -> state written as `full` when the Accounts API marks it so. -v5 does not run `RpcFallbackMiddleware` on this subscribe/update path. v6 does -not run `CustomAssetGraduationMiddleware`. +In other words: v5 never runs `RpcFallbackMiddleware` here, and v6 never runs +`CustomAssetGraduationMiddleware` at all. -## Behavioral intent +## Behavior differences at a glance -| Concern | v5 | v6 | -| ------------------------- | ------------------------------------------ | ------------------------------------------------- | -| Accounts API endpoint | `fetchV5MultiAccountBalances` | `fetchV6MultiAccountBalances` | -| Accounts API update mode | `merge` | `full` | -| Covered-chain merge | Preserve old behavior | Replace covered chain slice | -| Custom asset preservation | Keep custom + staked pins in v5 merge path | Keep `unprocessedCustomAssets` until RPC resolves | -| Hidden assets | Not sent to v5 endpoint | Sent as `excludeAssetIds` | -| RPC token fetch | Flat `request.customAssets` on the chain | Only pins owned by that account | +| Concern | v5 | v6 | +| ------------------------- | ------------------------------------------- | ------------------------------------------------------ | +| Accounts API endpoint | `fetchV5MultiAccountBalances` | `fetchV6MultiAccountBalances` | +| Accounts API update mode | `merge` | `full` | +| Covered-chain merge | Preserve old behavior | Replace the covered chain slice | +| Pinned asset preservation | Keep custom + staked pins in the merge path | Keep `unprocessedCustomAssets` until RPC resolves them | +| Hidden assets | Not sent to the endpoint | Sent as `excludeAssetIds` | +| RPC token fetch | Flat `request.customAssets` for the chain | Only the pins owned by that account | -## Code map +## Where the code lives -| Concern | v5 implementation | v6 implementation | +| Concern | v5 | v6 | | ------------------------ | ----------------------------------------- | ----------------------------------------- | | Force-update request | `#buildForceUpdateRequestV5` | `#buildForceUpdateRequestV6` | | Force-update pipeline | `#forceUpdateAssetsV5`, `#runFastFetchV5` | `#forceUpdateAssetsV6`, `#runFastFetchV6` | +| Fast lane composition | `buildFastFetchSources` (graduation on) | `buildFastFetchSources` (graduation off) | | Subscribe | `#subscribeAssetsBalanceV5` | `#subscribeAssetsBalanceV6` | -| Update enrichment | `#handleAssetsUpdateV5` | `#handleAssetsUpdateV6` | +| Update handling | `#handleAssetsUpdateV5` | `#handleAssetsUpdateV6` | | Balance merge | `mergeAccountBalancesV5` | `mergeAccountBalancesV6` | | Accounts API fetch | `#fetchV5Balances` | `#fetchV6Balances` | | RPC fallback | `#recoverV5` | `#recoverV6` | | RPC custom ERC-20 append | `#appendRequestCustomErc20sV5` | `#appendRequestCustomErc20sV6` | -## Deletion plan after rollout +## Deleting v5 after rollout Once v6 is accepted as the only behavior: -1. Remove the v5 methods and `!this.#isBalanceV6Enabled()` branches. -2. Keep the v6 methods as the only orchestration path. -3. Remove transitional docs/tables that compare v5 and v6. +1. Remove the v5 methods and every `!this.#isBalanceV6Enabled()` branch. +2. Keep the v6 methods as the single orchestration path. +3. Remove this document's v5/v6 comparison tables. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index fd4a0d624f4..7e0bc8e52d9 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -2016,6 +2016,67 @@ describe('AssetsController', () => { tokenMiddlewareGetter.mockRestore(); priceMiddlewareGetter.mockRestore(); }); + + it('does not call Accounts API v6 on force update when isBasicFunctionality is false', async () => { + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); + const tokenMiddlewareGetter = jest.spyOn( + TokenDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) as unknown as jest.SpyInstance; + const priceMiddlewareGetter = jest.spyOn( + PriceDataSource.prototype, + 'assetsMiddleware', + // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. + 'get', + ) as unknown as jest.SpyInstance; + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; + + await withController( + { + isBasicFunctionality: () => false, + queryApiClient, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + tokenMiddlewareGetter.mockClear(); + priceMiddlewareGetter.mockClear(); + fetchV6MultiAccountBalances.mockClear(); + + await controller.getAssets([createMockInternalAccount()], { + chainIds: ['eip155:1'], + forceUpdate: true, + }); + await flushPromises(); + }, + ); + + expect(fetchV6MultiAccountBalances).not.toHaveBeenCalled(); + expect(tokenMiddlewareGetter).not.toHaveBeenCalled(); + expect(priceMiddlewareGetter).not.toHaveBeenCalled(); + + tokenMiddlewareGetter.mockRestore(); + priceMiddlewareGetter.mockRestore(); + }); }); it('filters by chainIds option', async () => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 281c00c4262..d448283112a 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1734,7 +1734,7 @@ export class AssetsController extends BaseController< tokenDataSource: this.#tokenDataSource, priceDataSource: this.#priceDataSource, }, - { isBasicFunctionality }, + { isBasicFunctionality, includeCustomAssetGraduation: true }, ); const fastResponse = await this.#runFastFetchV5({ @@ -1768,20 +1768,21 @@ export class AssetsController extends BaseController< pipelineTrace?: TraceCallback; }): Promise { const isBasicFunctionality = this.#isBasicFunctionality(); - const fastSources = isBasicFunctionality - ? [ - createParallelBalanceMiddleware([ - this.#accountsApiDataSource, - this.#stakedBalanceDataSource, - ]), - this.#rpcFallbackMiddleware, - this.#detectionMiddleware, - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : [this.#stakedBalanceDataSource, this.#detectionMiddleware]; + const fastSources = buildFastFetchSources( + { + accountsApiDataSource: this.#accountsApiDataSource, + stakedBalanceDataSource: this.#stakedBalanceDataSource, + customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware, + rpcFallbackMiddleware: this.#rpcFallbackMiddleware, + detectionMiddleware: this.#detectionMiddleware, + tokenDataSource: this.#tokenDataSource, + priceDataSource: this.#priceDataSource, + }, + { + isBasicFunctionality, + includeCustomAssetGraduation: false, + }, + ); const fastResponse = await this.#runFastFetchV6({ accounts, diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts index e1e0f6b1a87..b9a0093ab41 100644 --- a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts @@ -165,7 +165,7 @@ async function runPipeline( tokenDataSource, priceDataSource, }, - { isBasicFunctionality: true }, + { isBasicFunctionality: true, includeCustomAssetGraduation: true }, ); const { response } = await executeAssetsPipeline({ diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts index 632cc6c616f..05477263fc0 100644 --- a/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts @@ -37,6 +37,7 @@ describe('buildFastFetchSources', () => { title: 'orders the lane balances → graduation → rpc fallback → detection → enrichment', isBasicFunctionality: true, + includeCustomAssetGraduation: true, expected: [ 'ParallelBalanceMiddleware', 'CustomAssetGraduationMiddleware', @@ -45,17 +46,34 @@ describe('buildFastFetchSources', () => { 'ParallelMiddleware', ], }, + { + title: 'drops graduation from the lane', + isBasicFunctionality: true, + // The Accounts API v6 lane resolves pins through `includeAssetIds`. + includeCustomAssetGraduation: false, + expected: [ + 'ParallelBalanceMiddleware', + 'RpcFallbackMiddleware', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, { title: 'runs only the staking balance and detection', isBasicFunctionality: false, + includeCustomAssetGraduation: true, // No network-backed source may run when the user has opted out. expected: ['StakedBalanceDataSource', 'DetectionMiddleware'], }, - ])('$title', ({ isBasicFunctionality, expected }) => { - const sources = buildFastFetchSources(buildSources(), { - isBasicFunctionality, - }); + ])( + '$title', + ({ isBasicFunctionality, includeCustomAssetGraduation, expected }) => { + const sources = buildFastFetchSources(buildSources(), { + isBasicFunctionality, + includeCustomAssetGraduation, + }); - expect(sources.map((source) => source.getName())).toStrictEqual(expected); - }); + expect(sources.map((source) => source.getName())).toStrictEqual(expected); + }, + ); }); diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.ts index b81db374d23..dc70075d189 100644 --- a/packages/assets-controller/src/pipeline/buildFastFetchSources.ts +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.ts @@ -40,11 +40,18 @@ export type FastFetchSources = { * @param options - Lane options. * @param options.isBasicFunctionality - When false, only the staking balance and * detection run; no network-backed source is used. + * @param options.includeCustomAssetGraduation - `true` on the Accounts API v5 + * lane. The v6 lane never graduates custom assets: it sends the pins to the + * endpoint as `includeAssetIds` and keeps the ones it could not resolve as + * `unprocessedCustomAssets`. * @returns The composed source list, ready for `executeAssetsPipeline`. */ export function buildFastFetchSources( sources: FastFetchSources, - options: { isBasicFunctionality: boolean }, + options: { + isBasicFunctionality: boolean; + includeCustomAssetGraduation: boolean; + }, ): AssetsDataSource[] { const { accountsApiDataSource, @@ -65,7 +72,9 @@ export function buildFastFetchSources( accountsApiDataSource, stakedBalanceDataSource, ]), - customAssetGraduationMiddleware, + ...(options.includeCustomAssetGraduation + ? [customAssetGraduationMiddleware] + : []), rpcFallbackMiddleware, detectionMiddleware, createParallelMiddleware([tokenDataSource, priceDataSource]), From 7626b4f6935f5a513755d74b4a694aa9b72fea99 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 21 Sep 2026 15:48:34 +0200 Subject: [PATCH 07/30] clean code --- .../docs/architecture/v1/CHANGES.md | 5 +- .../src/data-sources/RpcDataSource.test.ts | 187 +++--------------- .../src/data-sources/RpcDataSource.ts | 117 +---------- 3 files changed, 34 insertions(+), 275 deletions(-) diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index 385b9d0aeb6..1d163127ddf 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -92,8 +92,9 @@ for it. They differ in how pinned assets on those chains are covered: - **v5** adds a separate RPC poll (`customAssetsOnly`) for pins that sit on a chain another source already owns. - **v6** asks each source which pins it can take, via `claimCustomAssets()` - - the Accounts API claims EVM pins and sends them as `includeAssetIds`. RPC then - polls only the pins nobody claimed. + the Accounts API claims EVM pins and sends them as `includeAssetIds`. RPC + claims leftover EVM pins only on chains assigned to it. Pins the API could + not resolve are recovered by `RpcFallbackMiddleware` on that update. On v5, `AccountsApiDataSource.claimCustomAssets()` simply returns `[]`. diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 8186dce9d80..1b0862aacfa 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1495,55 +1495,7 @@ describe('RpcDataSource', () => { }); }); - it('starts an asset-scoped poll (explicit assetIds) for pinned assets on chains not assigned to RPC', async () => { - const balanceStartSpy = jest.spyOn( - BalanceFetcher.prototype, - 'startPolling', - ); - const detectionStartSpy = jest.spyOn( - TokenDetector.prototype, - 'startPolling', - ); - const customAssetId = - `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; - - await withController( - { - options: { isBalanceV6Enabled: (): boolean => true }, - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, - }), - }, - }, - async ({ controller }) => { - // The chain axis assigned nothing to RPC (chainIds is empty — e.g. - // the websocket claimed the chain), but RPC claimed the pinned asset. - await controller.subscribe({ - request: createDataRequest({ - chainIds: [], - customAssets: [customAssetId], - }), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), - }); - - expect(balanceStartSpy).toHaveBeenCalledWith({ - chainId: MOCK_CHAIN_ID_HEX, - accountId: MOCK_ACCOUNT_ID, - accountAddress: MOCK_ADDRESS, - assetIds: [customAssetId], - }); - // No regular chain coverage — detection must not run. - expect(detectionStartSpy).not.toHaveBeenCalled(); - await controller.unsubscribe('test-sub'); - }, - ); - }); - - it('does not start an asset-scoped poll for accounts without pinned assets on the supplemental chain', async () => { + it('does not poll when no chains are assigned even if customAssets are present', async () => { const balanceStartSpy = jest.spyOn( BalanceFetcher.prototype, 'startPolling', @@ -1551,104 +1503,20 @@ describe('RpcDataSource', () => { const customAssetId = `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - customAssets: { 'other-account-id': [customAssetId] }, - }), - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest({ - chainIds: [], - customAssets: [customAssetId], - }), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), - }); - - expect(balanceStartSpy).not.toHaveBeenCalled(); - await controller.unsubscribe('test-sub'); - }, - ); - }); - - it('does not start an asset-scoped poll for chains already covered by regular polling', async () => { - const balanceStartSpy = jest.spyOn( - BalanceFetcher.prototype, - 'startPolling', - ); - const customAssetId = - `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; - - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, - }), - }, - }, - async ({ controller }) => { - // Chain assigned to RPC: the regular poll already includes - // state.customAssets, so no supplemental poll must start. - await controller.subscribe({ - request: createDataRequest({ - customAssets: [customAssetId], - }), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), - }); - - expect(balanceStartSpy).toHaveBeenCalledTimes(1); - expect(balanceStartSpy).toHaveBeenCalledWith({ - chainId: MOCK_CHAIN_ID_HEX, - accountId: MOCK_ACCOUNT_ID, - accountAddress: MOCK_ADDRESS, - }); - await controller.unsubscribe('test-sub'); - }, - ); - }); - - it('unsubscribe stops asset-scoped polling', async () => { - const balanceStopSpy = jest.spyOn( - BalanceFetcher.prototype, - 'stopPollingByPollingToken', - ); - const customAssetId = - `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; + await withController(async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest({ + chainIds: [], + customAssets: [customAssetId], + }), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate: jest.fn(), + }); - await withController( - { - options: { isBalanceV6Enabled: (): boolean => true }, - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, - }), - }, - }, - async ({ controller }) => { - await controller.subscribe({ - request: createDataRequest({ - chainIds: [], - customAssets: [customAssetId], - }), - subscriptionId: 'test-sub', - isUpdate: false, - onAssetsUpdate: jest.fn(), - }); - await controller.unsubscribe('test-sub'); - expect(balanceStopSpy).toHaveBeenCalled(); - }, - ); + expect(balanceStartSpy).not.toHaveBeenCalled(); + await controller.unsubscribe('test-sub'); + }); }); }); @@ -1660,15 +1528,21 @@ describe('RpcDataSource', () => { const nonEvmAsset = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFW' as Caip19AssetId; - it('claims EVM assets on active chains even when the chain was not assigned to RPC', async () => { + it('claims EVM assets only on chains assigned to RPC', async () => { await withController(async ({ controller }) => { expect( controller.claimCustomAssets([availableChainAsset], []), + ).toStrictEqual([]); + expect( + controller.claimCustomAssets( + [availableChainAsset], + [MOCK_CHAIN_ID_CAIP], + ), ).toStrictEqual([availableChainAsset]); }); }); - it('does not claim assets on chains RPC cannot serve, non-EVM assets, or malformed IDs', async () => { + it('does not claim assets on unassigned chains, non-EVM assets, or malformed IDs', async () => { await withController(async ({ controller }) => { expect( controller.claimCustomAssets( @@ -1677,25 +1551,8 @@ describe('RpcDataSource', () => { nonEvmAsset, 'not-a-caip-asset' as Caip19AssetId, ], - [], - ), - ).toStrictEqual([]); - }); - }); - - it('falls back to assigned chains when network state has not been applied yet', async () => { - const networkState = createMockNetworkState(NetworkStatus.Degraded); - await withController({ networkState }, async ({ controller }) => { - // eslint-disable-next-line n/no-sync -- testing sync API used by AssetsController - expect(controller.getActiveChainsSync()).toStrictEqual([]); - expect( - controller.claimCustomAssets( - [availableChainAsset], [MOCK_CHAIN_ID_CAIP], ), - ).toStrictEqual([availableChainAsset]); - expect( - controller.claimCustomAssets([availableChainAsset], []), ).toStrictEqual([]); }); }); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 2b9cd12d752..926dbcd193e 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -146,7 +146,7 @@ export type RpcDataSourceOptions = { /** Returns the asset type ('native' | 'erc20' | 'spl') for the given CAIP-19 asset ID */ getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; /** - * Whether Accounts API v6 pin filtering and asset-scoped polls are enabled. + * Whether Accounts API v6 pin filtering is enabled. * Injected by AssetsController; defaults to v5 when omitted. */ isBalanceV6Enabled?: () => boolean; @@ -1380,29 +1380,26 @@ export class RpcDataSource extends AbstractDataSource< } /** - * RPC is the terminal claimer on the asset axis: it claims every EVM pin it - * has a provider for, even on chains claimed by higher-priority sources. - * Pins outside the regular RPC assignment get an asset-scoped poll (see - * `subscribe`). + * Claim leftover EVM pins on chains assigned to RPC. Pins on chains owned by + * Account Activity or the Accounts API stay with those sources (v6 + * `includeAssetIds` / websocket). Unresolved pins on those chains are + * recovered by `RpcFallbackMiddleware`, not a second RPC subscription. * * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. - * @param assignedChains - Chains assigned to RPC; availability fallback - * before network state is applied. + * @param assignedChains - Chains assigned to RPC in this handoff. * @returns The claimed subset of `customAssets`. */ claimCustomAssets( customAssets: Caip19AssetId[], assignedChains: ChainId[], ): Caip19AssetId[] { - const available = new Set( - this.#activeChains.length > 0 ? this.#activeChains : assignedChains, - ); + const assigned = new Set(assignedChains); return customAssets.filter((assetId) => { try { const parsed = parseCaipAssetType(assetId); return ( parsed.chain.namespace === KnownCaipNamespace.Eip155 && - available.has(parsed.chainId) + assigned.has(parsed.chainId) ); } catch { return false; @@ -1433,23 +1430,15 @@ export class RpcDataSource extends AbstractDataSource< ) : request.chainIds; - // Pins claimed on chains outside the regular RPC assignment get an - // asset-scoped poll below. - const supplementalChains = this.#getSupplementalCustomAssetChains( - request, - chainsToSubscribe, - ); - log('Subscribe requested', { subscriptionId, isUpdate, accounts: request.accountsWithSupportedChains.map((a) => a.account.id), chainsToSubscribe, - supplementalChains, activeChainsFallback: this.#activeChains.length === 0, }); - if (chainsToSubscribe.length === 0 && supplementalChains.length === 0) { + if (chainsToSubscribe.length === 0) { log('No active chains to subscribe'); return; } @@ -1518,53 +1507,6 @@ export class RpcDataSource extends AbstractDataSource< } } - // Asset-scoped polls on chains another source claimed: poll ONLY the - // claimed pins to avoid double-polling tracked balances. Pin changes - // re-run the subscription pass, which rebuilds these polls. - // Asset-scoped polls on chains another source claimed (v6 only). - if (this.#isBalanceV6Enabled() && supplementalChains.length > 0) { - const supplemental = new Set(supplementalChains); - const claimedAssetsByChain = new Map(); - for (const assetId of request.customAssets ?? []) { - try { - const { chainId } = parseCaipAssetType(assetId); - if (supplemental.has(chainId)) { - const chainAssets = claimedAssetsByChain.get(chainId) ?? []; - chainAssets.push(assetId); - claimedAssetsByChain.set(chainId, chainAssets); - } - } catch { - // Skip unparseable asset IDs - } - } - // request.customAssets is flat; ownership comes from controller state. - const customAssetsByAccount = this.#getCustomAssetsByAccount(); - for (const { account } of request.accountsWithSupportedChains) { - const pinned = new Set(customAssetsByAccount[account.id] ?? []); - if (pinned.size === 0) { - continue; - } - for (const [chainId, chainAssets] of claimedAssetsByChain) { - // Sorted so the polling input (the dedupe key) is deterministic. - const assetIds = chainAssets - .filter((assetId) => pinned.has(assetId)) - .sort(); - if (assetIds.length === 0) { - continue; - } - const balanceInput: BalancePollingInput = { - chainId: caipChainIdToHex(chainId), - accountId: account.id, - accountAddress: account.address as Address, - assetIds, - }; - balancePollingTokens.push( - this.#balanceFetcher.startPolling(balanceInput), - ); - } - } - } - // Store subscription data const accounts = request.accountsWithSupportedChains.map( (entry) => entry.account, @@ -1608,47 +1550,6 @@ export class RpcDataSource extends AbstractDataSource< } } - /** - * Chains needing a supplemental asset-scoped poll: chains of pins not - * covered by the regular RPC polling. Only EVM chains RPC can serve; - * malformed IDs are skipped. - * - * @param request - The subscription's data request (carries `customAssets`). - * @param chainsToSubscribe - Chains covered by the regular polling loop. - * @returns Chains requiring an asset-scoped poll. - */ - #getSupplementalCustomAssetChains( - request: DataRequest, - chainsToSubscribe: ChainId[], - ): ChainId[] { - if (!request.customAssets || request.customAssets.length === 0) { - return []; - } - - const covered = new Set(chainsToSubscribe); - const chains = new Set(); - - for (const assetId of request.customAssets) { - let parsed: ReturnType; - try { - parsed = parseCaipAssetType(assetId); - } catch { - continue; - } - const { chainId } = parsed; - if ( - parsed.chain.namespace === KnownCaipNamespace.Eip155 && - !covered.has(chainId) && - (this.#activeChains.length === 0 || - this.#activeChains.includes(chainId)) - ) { - chains.add(chainId); - } - } - - return [...chains]; - } - /** * v5: include every `request.customAssets` ERC-20 on this chain (main behavior). * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default. From e1ee0030c910a6afb0f9920ce59573d76ecb893c Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 21 Sep 2026 16:14:22 +0200 Subject: [PATCH 08/30] clean code --- packages/assets-controller/CHANGELOG.md | 2 +- .../docs/architecture/v1/CHANGES.md | 6 +- .../assets-controller/src/AssetsController.ts | 1 - .../src/data-sources/RpcDataSource.test.ts | 88 --------------- .../src/data-sources/RpcDataSource.ts | 104 ++---------------- 5 files changed, 13 insertions(+), 188 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 59710e15960..51a665eff2a 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add an optional `customAssets` option to `AssetsController.getAssets`, used only when `assetsAccountsApiV6` is enabled. It overrides which pinned assets are attached to the fetch (sent as `includeAssetIds` and to RPC). When the flag is off, `getAssets` still attaches every pin of the requested accounts, unscoped, matching the previous v5 request ([#9651](https://github.com/MetaMask/core/pull/9651)) -- Add an optional `isBalanceV6Enabled` getter to `AccountsApiDataSource` and `RpcDataSource`. `AssetsController` injects it so the `assetsAccountsApiV6` flag is read in one place; both default to v5 when the getter is omitted([#9651](https://github.com/MetaMask/core/pull/9651)) +- Add an optional `isBalanceV6Enabled` getter to `AccountsApiDataSource`. `AssetsController` injects it so the `assetsAccountsApiV6` flag is read in one place; it defaults to v5 when the getter is omitted ([#9651](https://github.com/MetaMask/core/pull/9651)) ### Changed diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index 1d163127ddf..9dd1644f431 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -14,8 +14,7 @@ runtime by the `assetsAccountsApiV6` remote feature flag: - `assetsAccountsApiV6: true`: the new **v6** path The flag is read in exactly one place, `AssetsController.#isBalanceV6Enabled()`, -and passed down to `AccountsApiDataSource`, `RpcFallbackMiddleware`, and -`RpcDataSource`. +and passed down to `AccountsApiDataSource` and `RpcFallbackMiddleware`. ## Vocabulary used below @@ -119,7 +118,7 @@ In other words: v5 never runs `RpcFallbackMiddleware` here, and v6 never runs | Covered-chain merge | Preserve old behavior | Replace the covered chain slice | | Pinned asset preservation | Keep custom + staked pins in the merge path | Keep `unprocessedCustomAssets` until RPC resolves them | | Hidden assets | Not sent to the endpoint | Sent as `excludeAssetIds` | -| RPC token fetch | Flat `request.customAssets` for the chain | Only the pins owned by that account | +| RPC token fetch | Flat `request.customAssets` for the chain | Same: one EVM account per request | ## Where the code lives @@ -133,7 +132,6 @@ In other words: v5 never runs `RpcFallbackMiddleware` here, and v6 never runs | Balance merge | `mergeAccountBalancesV5` | `mergeAccountBalancesV6` | | Accounts API fetch | `#fetchV5Balances` | `#fetchV6Balances` | | RPC fallback | `#recoverV5` | `#recoverV6` | -| RPC custom ERC-20 append | `#appendRequestCustomErc20sV5` | `#appendRequestCustomErc20sV6` | ## Deleting v5 after rollout diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index d448283112a..569d4169b51 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1046,7 +1046,6 @@ export class AssetsController extends BaseController< isOnboarded: rpcConfig.isOnboarded ?? isOnboarded, getAssetType: (assetId: Caip19AssetId): 'native' | 'erc20' | 'spl' => this.#getAssetType(assetId), - isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), }); this.#stakedBalanceDataSource = new StakedBalanceDataSource({ messenger: this.messenger, diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 1b0862aacfa..5c49872fed6 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -918,94 +918,6 @@ describe('RpcDataSource', () => { fetchSpy.mockRestore(); }); - - it('does not fetch custom assets the fetching account has not pinned', async () => { - const customAssetId = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - const fetchSpy = jest - .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') - .mockResolvedValue(createBalanceFetchResult()); - - await withController( - { - options: { isBalanceV6Enabled: (): boolean => true }, - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - customAssets: { 'other-account-id': [customAssetId] }, - }), - }, - }, - async ({ controller }) => { - const request = createDataRequest({ - customAssets: [customAssetId], - }); - await controller.fetch(request); - - // The request's customAssets list is flat; ownership comes from - // state. MOCK_ACCOUNT_ID did not pin the asset, so its fetch - // carries only the native entry. - expect(fetchSpy).toHaveBeenCalledWith( - MOCK_CHAIN_ID_HEX, - MOCK_ACCOUNT_ID, - MOCK_ADDRESS, - [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - ], - ); - }, - ); - - fetchSpy.mockRestore(); - }); - - it('fetches request custom assets on v5 even when another account owns the pin', async () => { - const customAssetId = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - const fetchSpy = jest - .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') - .mockResolvedValue(createBalanceFetchResult()); - - await withController( - { - actionHandlerOverrides: { - 'AssetsController:getState': () => ({ - ...getDefaultAssetsControllerState(), - customAssets: { 'other-account-id': [customAssetId] }, - }), - }, - }, - async ({ controller }) => { - const request = createDataRequest({ - customAssets: [customAssetId], - }); - await controller.fetch(request); - - expect(fetchSpy).toHaveBeenCalledWith( - MOCK_CHAIN_ID_HEX, - MOCK_ACCOUNT_ID, - MOCK_ADDRESS, - [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - expect.objectContaining({ - assetId: customAssetId, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - }), - ], - ); - }, - ); - - fetchSpy.mockRestore(); - }); }); describe('detectTokens', () => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 926dbcd193e..626cfd55174 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -145,11 +145,6 @@ export type RpcDataSourceOptions = { /** Returns the asset type ('native' | 'erc20' | 'spl') for the given CAIP-19 asset ID */ getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; - /** - * Whether Accounts API v6 pin filtering is enabled. - * Injected by AssetsController; defaults to v5 when omitted. - */ - isBalanceV6Enabled?: () => boolean; }; /** @@ -229,8 +224,6 @@ export class RpcDataSource extends AbstractDataSource< readonly #isOnboarded: () => boolean; - readonly #isBalanceV6Enabled: () => boolean; - /** Currently active chains */ #activeChains: ChainId[] = []; @@ -268,8 +261,6 @@ export class RpcDataSource extends AbstractDataSource< this.#useExternalService = options.useExternalService ?? ((): boolean => true); this.#isOnboarded = options.isOnboarded ?? ((): boolean => true); - this.#isBalanceV6Enabled = - options.isBalanceV6Enabled ?? ((): boolean => false); const balanceInterval = options.balanceInterval ?? DEFAULT_BALANCE_INTERVAL; const detectionInterval = @@ -1024,12 +1015,6 @@ export class RpcDataSource extends AbstractDataSource< const assetsInfo: Record = {}; const failedChains: ChainId[] = []; - // request.customAssets is flat. v6 resolves pin ownership from state so - // each account only fetches its own pins; v5 applies the flat list as on main. - const customAssetsByAccount = request.customAssets - ? this.#getCustomAssetsByAccount() - : {}; - // Fetch balances for each account and its supported chains (pre-computed in request) for (const { account, @@ -1056,25 +1041,12 @@ export class RpcDataSource extends AbstractDataSource< } if (request.customAssets) { - const existingMetadata = this.#getExistingAssetsMetadata(); - if (this.#isBalanceV6Enabled()) { - this.#appendRequestCustomErc20sV6( - assetsToFetch, - request.customAssets, - chainId, - existingMetadata, - new Set( - (customAssetsByAccount[accountId] ?? []).map(normalizeAssetId), - ), - ); - } else { - this.#appendRequestCustomErc20sV5( - assetsToFetch, - request.customAssets, - chainId, - existingMetadata, - ); - } + this.#appendRequestCustomErc20s( + assetsToFetch, + request.customAssets, + chainId, + this.#getExistingAssetsMetadata(), + ); } try { @@ -1551,15 +1523,16 @@ export class RpcDataSource extends AbstractDataSource< } /** - * v5: include every `request.customAssets` ERC-20 on this chain (main behavior). - * Delete with the rest of the v5 path when `assetsAccountsApiV6` is the default. + * Include every `request.customAssets` ERC-20 on this chain. The selected + * group has at most one EVM account, so there is no per-account ownership + * filter. * * @param assetsToFetch - Native/custom entries for this account-chain fetch. * @param customAssets - Flat pin list from the data request. * @param chainId - Chain being fetched. * @param existingMetadata - Metadata already in AssetsController state. */ - #appendRequestCustomErc20sV5( + #appendRequestCustomErc20s( assetsToFetch: AssetFetchEntry[], customAssets: Caip19AssetId[], chainId: ChainId, @@ -1589,63 +1562,6 @@ export class RpcDataSource extends AbstractDataSource< } } - /** - * v6: include only pins owned by this account on this chain. - * - * @param assetsToFetch - Native/custom entries for this account-chain fetch. - * @param customAssets - Flat pin list from the data request. - * @param chainId - Chain being fetched. - * @param existingMetadata - Metadata already in AssetsController state. - * @param pinnedByAccount - This account's pins from controller state. - */ - #appendRequestCustomErc20sV6( - assetsToFetch: AssetFetchEntry[], - customAssets: Caip19AssetId[], - chainId: ChainId, - existingMetadata: Record, - pinnedByAccount: Set, - ): void { - for (const assetId of customAssets) { - try { - const parsed = parseCaipAssetType(assetId); - const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; - const normalizedId = normalizeAssetId(assetId); - if ( - assetChainId === chainId && - pinnedByAccount.has(normalizedId) && - this.#getAssetType(assetId) === 'erc20' - ) { - const tokenAddress = parsed.assetReference.toLowerCase() as Address; - const decimals = existingMetadata[normalizedId]?.decimals; - - assetsToFetch.push({ - assetId, - address: tokenAddress, - decimals, - }); - } - } catch { - // Skip unparseable asset IDs - } - } - } - - /** - * Get per-account pins from AssetsController state — the request's flat - * `customAssets` list carries no account association. - * - * @returns Record of account ID to pinned CAIP-19 asset IDs. - */ - #getCustomAssetsByAccount(): Record { - try { - const state = this.#messenger.call('AssetsController:getState'); - return (state.customAssets ?? {}) as Record; - } catch (error) { - log('Failed to get customAssets from state', { error }); - return {}; - } - } - /** * Get existing assets metadata from AssetsController state. * Used to include metadata for ERC20 tokens when returning balance updates. From 9d2a9bdf5ba1b932967fb0d4162f0cedb32688a2 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 21 Sep 2026 16:42:00 +0200 Subject: [PATCH 09/30] clean code --- packages/assets-controller/CHANGELOG.md | 8 +- .../docs/architecture/v1/CHANGES.md | 38 +- .../src/AssetsController.test.ts | 66 ++-- .../assets-controller/src/AssetsController.ts | 262 ++------------ .../data-sources/AbstractDataSource.test.ts | 39 +- .../src/data-sources/AbstractDataSource.ts | 28 -- .../AccountsApiDataSource.test.ts | 332 ++++++++++-------- .../src/data-sources/AccountsApiDataSource.ts | 161 ++++----- .../src/data-sources/RpcDataSource.test.ts | 38 -- .../src/data-sources/RpcDataSource.ts | 28 -- .../services/BalanceFetcher.test.ts | 34 +- .../services/BalanceFetcher.ts | 53 +-- ...c-spam-token-filtering.integration.test.ts | 3 +- packages/assets-controller/src/types.ts | 11 +- .../assets-controller/tsconfig.build.json | 2 +- 15 files changed, 395 insertions(+), 708 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 51a665eff2a..a0661e5a91e 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,14 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add an optional `customAssets` option to `AssetsController.getAssets`, used only when `assetsAccountsApiV6` is enabled. It overrides which pinned assets are attached to the fetch (sent as `includeAssetIds` and to RPC). When the flag is off, `getAssets` still attaches every pin of the requested accounts, unscoped, matching the previous v5 request ([#9651](https://github.com/MetaMask/core/pull/9651)) -- Add an optional `isBalanceV6Enabled` getter to `AccountsApiDataSource`. `AssetsController` injects it so the `assetsAccountsApiV6` flag is read in one place; it defaults to v5 when the getter is omitted ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Add a required `getAssetsState` getter to `AccountsApiDataSource`. `AssetsController` injects `() => this.state` so v6 include/exclude and token-detection filtering read controller state without copying it onto subscribe or fetch requests ### Changed -- **BREAKING:** Remove the unused `updateMode` option from `AssetsController.getAssets`. Apply mode comes only from `DataResponse.updateMode` (data sources). Remove `'update'` from `AssetsUpdateMode` — it was never produced; use `'merge'` to overlay or `'full'` to replace a covered chain slice ([#9651](https://github.com/MetaMask/core/pull/9651)) -- A hide now wins over a pin when an asset is both in `customAssets` and marked `hidden` in `assetPreferences`. Hidden pins are dropped from the `customAssets`/`includeAssetIds` attached to `getAssets` force updates, balance subscriptions, and the supplemental RPC custom-asset poll, so a hidden token is no longer fetched. `hideAsset` still leaves the pin in `customAssets` to record that the token was imported, and `unhideAsset` restores it on the next subscription ([#9651](https://github.com/MetaMask/core/pull/9651)) +- **BREAKING:** Remove `excludeAssetIds` from `DataRequest`. Accounts API v6 reads hidden assets from controller state (`assetPreferences`) when building the v6 `excludeAssetIds` query param. `request.customAssets` remains only as a scoped fetch override (`getAssets({ customAssets })`, `addCustomAsset`, RPC fallback), not a copy of every pin. +- When `assetsAccountsApiV6` is enabled, a hide wins over a pin: Accounts API v6 sends hidden assets as `excludeAssetIds` and skips them in `includeAssetIds`. The v5 path is unchanged (`getAssets` force updates and the RPC custom-asset supplement still attach every pin, including hidden ones). `hideAsset` still leaves the pin in `customAssets` to record that the token was imported ([#9651](https://github.com/MetaMask/core/pull/9651)) - When `assetsAccountsApiV6` is enabled, Accounts API fetch and middleware report `updateMode: 'full'` so assets on chains in the snapshot (including custom assets returned via `includeAssetIds`) are replaced; pins left in `unprocessedCustomAssets` after RPC fallback keep their prior balances. The v5 path keeps `updateMode: 'merge'` with `replaceCoveredChainBalances` so custom assets are preserved ([#9651](https://github.com/MetaMask/core/pull/9651)) -- When `assetsAccountsApiV6` is enabled, the `getAssets` force-update fast pipeline runs `RpcFallbackMiddleware` after Accounts API so `unprocessedIncludeAssetIds` are recovered on RPC before state is committed (same as poll enrichment). Chains that still have unresolved pins are also kept on the slow RPC lane. +- When `assetsAccountsApiV6` is enabled, Accounts API does not filter unknown tokens via `tokenDetectionEnabled`. The v6 snapshot is applied in full; v5 still drops tokens not already in state when detection is off - Bump `@metamask/transaction-controller` from `^70.0.1` to `^70.1.0` ([#10262](https://github.com/MetaMask/core/pull/10262)) ### Fixed diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index 9dd1644f431..9aa306e5b54 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -77,8 +77,10 @@ The requests differ as well: - **v5** asks for every pinned asset of the requested accounts, without scoping them to chains, and does not mention hidden assets. -- **v6** scopes pinned assets to the requested chains (or uses the explicit - `customAssets` override) and sends hidden assets as `excludeAssetIds`. +- **v6** does not copy pins or hides onto the request. The Accounts API + reads them from state (`includeAssetIds` / `excludeAssetIds`). A + `customAssets` override still scopes a one-shot fetch (new pin, RPC + fallback). When basic functionality is off, both fast lanes shrink to `Staked -> Detection`. The background lane is RPC only in both paths. @@ -90,12 +92,11 @@ for it. They differ in how pinned assets on those chains are covered: - **v5** adds a separate RPC poll (`customAssetsOnly`) for pins that sit on a chain another source already owns. -- **v6** asks each source which pins it can take, via `claimCustomAssets()` - - the Accounts API claims EVM pins and sends them as `includeAssetIds`. RPC - claims leftover EVM pins only on chains assigned to it. Pins the API could - not resolve are recovered by `RpcFallbackMiddleware` on that update. - -On v5, `AccountsApiDataSource.claimCustomAssets()` simply returns `[]`. +- **v6** subscribe only assigns accounts and chains. The Accounts API reads + pins and hides from state and sends them as `includeAssetIds` / + `excludeAssetIds`. RPC polls `customAssets` from state on its assigned + chains. Account Activity ignores pins/hides. Pins the API could not resolve + are recovered by `RpcFallbackMiddleware` on that update. ## 3. Handling an incoming update @@ -118,20 +119,21 @@ In other words: v5 never runs `RpcFallbackMiddleware` here, and v6 never runs | Covered-chain merge | Preserve old behavior | Replace the covered chain slice | | Pinned asset preservation | Keep custom + staked pins in the merge path | Keep `unprocessedCustomAssets` until RPC resolves them | | Hidden assets | Not sent to the endpoint | Sent as `excludeAssetIds` | +| Token detection filter | Drop unknown tokens when detection is off | Not applied; v6 snapshot is kept in full | | RPC token fetch | Flat `request.customAssets` for the chain | Same: one EVM account per request | ## Where the code lives -| Concern | v5 | v6 | -| ------------------------ | ----------------------------------------- | ----------------------------------------- | -| Force-update request | `#buildForceUpdateRequestV5` | `#buildForceUpdateRequestV6` | -| Force-update pipeline | `#forceUpdateAssetsV5`, `#runFastFetchV5` | `#forceUpdateAssetsV6`, `#runFastFetchV6` | -| Fast lane composition | `buildFastFetchSources` (graduation on) | `buildFastFetchSources` (graduation off) | -| Subscribe | `#subscribeAssetsBalanceV5` | `#subscribeAssetsBalanceV6` | -| Update handling | `#handleAssetsUpdateV5` | `#handleAssetsUpdateV6` | -| Balance merge | `mergeAccountBalancesV5` | `mergeAccountBalancesV6` | -| Accounts API fetch | `#fetchV5Balances` | `#fetchV6Balances` | -| RPC fallback | `#recoverV5` | `#recoverV6` | +| Concern | v5 | v6 | +| --------------------- | ------------------------------------------ | --------------------------------------------------------------------- | +| Force-update request | `#buildForceUpdateRequestV5` | `#buildForceUpdateRequestV6` | +| Force-update pipeline | `#forceUpdateAssetsV5`, `#runFastFetchV5` | `#forceUpdateAssetsV6`, `#runFastFetchV6` | +| Fast lane composition | `buildFastFetchSources` (graduation on) | `buildFastFetchSources` (graduation off) | +| Subscribe | `#subscribeAssetsBalance` + RPC supplement | `#subscribeAssetsBalance`; Accounts API v6 include/exclude from state | +| Update handling | `#handleAssetsUpdateV5` | `#handleAssetsUpdateV6` | +| Balance merge | `mergeAccountBalancesV5` | `mergeAccountBalancesV6` | +| Accounts API fetch | `#fetchV5Balances` | `#fetchV6Balances` | +| RPC fallback | `#recoverV5` | `#recoverV6` | ## Deleting v5 after rollout diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 7e0bc8e52d9..6e464bd582d 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -1573,35 +1573,44 @@ describe('AssetsController', () => { middlewareGetter.mockRestore(); }); - it('scopes the custom assets on the request to the requested chains', async () => { + it('includes only pins on the requested chains as includeAssetIds', async () => { const mainnetToken = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const polygonToken = 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; - const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; - const accountsApiMiddleware = jest.fn(async (ctx, next) => { - capturedCustomAssets.push(ctx.request.customAssets); - return next(ctx); + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], }); - const middlewareGetter = jest - .spyOn( - AccountsApiDataSource.prototype, - 'assetsMiddleware', - // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. - 'get', - ) - .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1, 137], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; await withController( { + queryApiClient, remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { + await flushPromises(); + await controller.addCustomAsset(MOCK_ACCOUNT_ID, mainnetToken); await controller.addCustomAsset(MOCK_ACCOUNT_ID, polygonToken); - capturedCustomAssets.length = 0; + fetchV6MultiAccountBalances.mockClear(); await controller.getAssets([createMockInternalAccount()], { chainIds: ['eip155:1'], forceUpdate: true, @@ -1609,14 +1618,11 @@ describe('AssetsController', () => { }, ); - // Pins on chains outside the request are dropped when the request is - // built — every data source would only ignore them at fetch time. - expect(capturedCustomAssets.length).toBeGreaterThan(0); - for (const customAssets of capturedCustomAssets) { - expect(customAssets).toStrictEqual([mainnetToken]); + expect(fetchV6MultiAccountBalances).toHaveBeenCalled(); + for (const [, params] of fetchV6MultiAccountBalances.mock.calls) { + expect(params?.includeAssetIds ?? []).toContain(mainnetToken); + expect(params?.includeAssetIds ?? []).not.toContain(polygonToken); } - - middlewareGetter.mockRestore(); }); it('uses the customAssets option instead of state-pinned assets when provided', async () => { @@ -2806,9 +2812,9 @@ describe('AssetsController', () => { }); describe('two-axis subscription handoff (chains + custom assets)', () => { - it('claims pinned assets on account-activity-claimed chains instead of letting them fall through', async () => { - // Account activity claims eip155:1; its stream covers pins, so they - // stay with its subscription instead of falling through to a poller. + it('does not attach pinned assets to the account-activity subscription', async () => { + // Account activity owns eip155:1. Pins stay in controller state; the + // websocket does not take include/exclude lists. jest .spyOn(AccountActivityDataSource.prototype, 'getActiveChainsSync') .mockReturnValue(['eip155:1' as ChainId]); @@ -2826,17 +2832,15 @@ describe('AssetsController', () => { const wsRequest = wsSubscribeSpy.mock.calls.at(-1)?.[0].request; expect(wsRequest?.chainIds).toStrictEqual(['eip155:1']); - expect(wsRequest?.customAssets).toStrictEqual([MOCK_ASSET_ID]); + expect(wsRequest?.customAssets).toBeUndefined(); - // Nothing was left for lower-priority sources to claim. expect(rpcSubscribeSpy).not.toHaveBeenCalled(); }, ); }); - it('does not create an RPC subscription for pinned assets no source can claim', async () => { - // Account activity is not active on the pin's chain, so the pin falls - // through the whole handoff... + it('does not create an RPC subscription for pinned assets on unassigned chains', async () => { + // Account activity is not active on the pin's chain... jest .spyOn(AccountActivityDataSource.prototype, 'getActiveChainsSync') .mockReturnValue([]); @@ -2849,7 +2853,7 @@ describe('AssetsController', () => { await withController(async ({ controller }) => { // ...and RPC has no provider for the chain (no networks configured in - // the mocked NetworkController), so its real claim returns nothing. + // the mocked NetworkController), so it is assigned no chains. await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); expect(rpcSubscribeSpy).not.toHaveBeenCalled(); @@ -3269,7 +3273,7 @@ describe('AssetsController', () => { it('keeps default tracked assets at zero when a full update omits them', async () => { // Regression: a force refresh (e.g. "Refresh list") replaces the chain // slice, so mUSD was dropped from the token list for accounts holding - // none. Default tracked assets are re-asserted just like natives. + // none. mergeAccountBalancesV6 re-asserts default tracked assets. const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 569d4169b51..1c6a58de179 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -607,8 +607,8 @@ function normalizeResponse(response: DataResponse): DataResponse { function mergeAccountBalancesV5( previousBalances: Record, accountBalances: Record, - customAssetIds: Caip19AssetId[] = [], - replaceCoveredChains = false, + customAssetIds: Caip19AssetId[], + replaceCoveredChains: boolean, ): Record { if (!replaceCoveredChains) { return { ...previousBalances, ...accountBalances }; @@ -629,8 +629,8 @@ function mergeAccountBalancesV5( for (const customId of customAssetIds) { if (!Object.prototype.hasOwnProperty.call(next, customId)) { - const previous = previousBalances[customId]; - next[customId] = previous ?? ({ amount: '0' } as AssetBalance); + const prev = previousBalances[customId]; + next[customId] = prev ?? ({ amount: '0' } as AssetBalance); } } @@ -692,6 +692,21 @@ function mergeAccountBalancesV6( next[assetId] = balance; } } + // Default tracked assets (mUSD) are controller-managed and must render at + // zero when the account holds none. The v6 snapshot omits them in that + // case, so re-assert them on covered chains — otherwise a force refresh + // would drop them from the token list. + // TODO: seed defaults as visible pins in `customAssets` instead (skip if + // already present or `assetPreferences[assetId].hidden`), then drop this + // merge special case. + for (const chainId of coveredChains) { + for (const assetId of getDefaultTrackedAssetsForChain(chainId as ChainId)) { + if (!Object.prototype.hasOwnProperty.call(next, assetId)) { + next[assetId] = + previousBalances[assetId] ?? ({ amount: '0' } as AssetBalance); + } + } + } return next; } @@ -1024,6 +1039,7 @@ export class AssetsController extends BaseController< onActiveChainsUpdated: this.#onActiveChainsUpdated, ...accountsApiDataSourceConfig, isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), + getAssetsState: (): AssetsControllerStateInternal => this.state, }); this.#snapDataSource = new SnapDataSource({ messenger: this.messenger, @@ -1643,7 +1659,7 @@ export class AssetsController extends BaseController< ): DataRequest { const customAssets: Caip19AssetId[] = []; for (const account of accounts) { - customAssets.push(...this.#getVisibleCustomAssets(account.id)); + customAssets.push(...this.getCustomAssets(account.id)); } return this.#buildDataRequest(accounts, chainIds, { @@ -1653,18 +1669,18 @@ export class AssetsController extends BaseController< } /** - * v6 force-update request: chain-scoped pins (or `customAssets` override) - * plus hidden assets as `excludeAssetIds`. + * v6 force-update request. Pins and hides are read from state by the + * Accounts API unless `customAssetsOverride` scopes this fetch. * * @param accounts - Accounts in this fetch. * @param chainIds - Chains in this fetch. - * @param requestOptions - Shared force-update request fields and optional pin override. + * @param requestOptions - Shared force-update request fields and optional pin scope. * @param requestOptions.assetTypes - Asset types to fetch. * @param requestOptions.dataTypes - Data types to fetch. * @param requestOptions.forceUpdate - Always `true` to bypass caches. * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches. * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for. - * @param requestOptions.customAssetsOverride - Pinned assets to use instead of the stored custom assets. + * @param requestOptions.customAssetsOverride - When set, fetch only these pins. * @returns The v6 data request. */ #buildForceUpdateRequestV6( @@ -1680,11 +1696,8 @@ export class AssetsController extends BaseController< }, ): DataRequest { const requestedChains = new Set(chainIds); - const candidateCustomAssets = - requestOptions.customAssetsOverride ?? - accounts.flatMap((account) => this.getCustomAssets(account.id)); const customAssetsSet = new Set(); - for (const assetId of candidateCustomAssets) { + for (const assetId of requestOptions.customAssetsOverride ?? []) { try { const normalizedAssetId = normalizeAssetId(assetId); if ( @@ -1698,7 +1711,6 @@ export class AssetsController extends BaseController< } } const customAssets = [...customAssetsSet]; - const hiddenAssets = this.#getHiddenAssetIds(); return this.#buildDataRequest(accounts, chainIds, { assetTypes: requestOptions.assetTypes, @@ -1707,7 +1719,6 @@ export class AssetsController extends BaseController< bypassServerCache: requestOptions.bypassServerCache, assetsForPriceUpdate: requestOptions.assetsForPriceUpdate, customAssets: customAssets.length > 0 ? customAssets : undefined, - excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, }); } @@ -2473,39 +2484,6 @@ export class AssetsController extends BaseController< this.#subscribeAssets(); } - /** - * Collect globally hidden asset IDs (from `assetPreferences`), forwarded on - * data requests as `excludeAssetIds`. - * - * @returns The CAIP-19 asset IDs the user has hidden. - */ - #getHiddenAssetIds(): Caip19AssetId[] { - const hidden: Caip19AssetId[] = []; - for (const [assetId, prefs] of Object.entries( - this.state.assetPreferences, - )) { - if (prefs.hidden) { - hidden.push(assetId as Caip19AssetId); - } - } - return hidden; - } - - /** - * An account's pins minus the ones the user has hidden. A hide wins over a - * pin on requests, so a hidden asset is never fetched; the pin stays in - * `customAssets` to record that the token was imported, and unhiding it - * restores the pin on the next subscription. - * - * @param accountId - The account whose pins should be collected. - * @returns The account's pinned asset IDs that are not hidden. - */ - #getVisibleCustomAssets(accountId: AccountId): Caip19AssetId[] { - return this.getCustomAssets(accountId).filter( - (assetId) => !this.state.assetPreferences[assetId]?.hidden, - ); - } - /** * Whether Accounts API v6 (and the v6 custom-asset path) is enabled. * Injected into AccountsApiDataSource and RpcFallbackMiddleware. @@ -2804,25 +2782,6 @@ export class AssetsController extends BaseController< ); } - /** - * Returns the controller-managed default tracked asset IDs (e.g. mUSD) for - * the chains this account supports (account scopes ∩ enabled chains). - * Non-EVM accounts resolve to an empty list because every chain in the - * defaults registry is EVM today. - * - * @param account - The account (scopes determine which chains apply). - * @returns Array of default tracked asset IDs across the supported chains. - */ - #getDefaultTrackedAssetIdsForAccount( - account: InternalAccount, - ): Caip19AssetId[] { - const ids: Caip19AssetId[] = []; - for (const chainId of this.#getEnabledChainsForAccount(account)) { - ids.push(...getDefaultTrackedAssetsForChain(chainId)); - } - return ids; - } - /** * Chains for the post-commit slow pipeline (Snap + RPC). Excludes chains the * fast Accounts API path already handled without error so stale RPC data cannot @@ -3106,22 +3065,6 @@ export class AssetsController extends BaseController< } } - // Default tracked assets (mUSD) are controller-managed and, like - // natives, must render at zero balance. An authoritative - // chain-slice replace omits them whenever the account holds none, - // so re-assert them here — otherwise a force refresh (e.g. the - // "Refresh list" action) would drop mUSD from the token list. - const defaultTrackedAssetIdsForAccount = account - ? this.#getDefaultTrackedAssetIdsForAccount(account) - : []; - for (const defaultAssetId of defaultTrackedAssetIdsForAccount) { - if ( - !Object.prototype.hasOwnProperty.call(effective, defaultAssetId) - ) { - effective[defaultAssetId] = { amount: '0' } as AssetBalance; - } - } - for (const [assetId, balance] of Object.entries(effective)) { const previousBalance = previousBalances[ assetId as Caip19AssetId @@ -3152,9 +3095,8 @@ export class AssetsController extends BaseController< oldAmount === undefined && newAmount === '0' && (nativeAssetIdsForAccount.includes(assetId as Caip19AssetId) || - defaultTrackedAssetIdsForAccount.includes( - assetId as Caip19AssetId, - )); + getDefaultAssetMetadata(assetId as Caip19AssetId) !== + undefined); if (oldAmount !== newAmount && !isNewSeededZero) { changedBalances.push({ accountId, @@ -3533,12 +3475,11 @@ export class AssetsController extends BaseController< * Strategy to minimize data source calls: * 1. Collect all chains to subscribe based on enabled networks * 2. Map chains to accounts based on their scopes - * 3. Split by data source (priority order) - each source gets ONE - * subscription, claiming chains AND pinned assets (`claimCustomAssets`); - * unclaimed assets fall through to lower-priority sources. - * - * This ensures we make minimal subscriptions to each data source while covering - * all accounts, chains, and pinned assets. + * 3. Split by data source (priority order) — each source gets one + * subscription for the chains it was assigned (accounts + chains only). + * v6 sources read pins/hides from state themselves (Accounts API sends + * `includeAssetIds` / `excludeAssetIds`). v5 uses a separate RPC + * `customAssetsOnly` supplement for pins on chains another source owns. * * @param accounts - Accounts to subscribe balance updates for. * @param chainIds - Chain IDs to subscribe for. @@ -3549,18 +3490,6 @@ export class AssetsController extends BaseController< accounts: InternalAccount[], chainIds: ChainId[], options?: { skipInitialFetch?: boolean }, - ): void { - if (this.#isBalanceV6Enabled()) { - this.#subscribeAssetsBalanceV6(accounts, chainIds, options); - return; - } - this.#subscribeAssetsBalanceV5(accounts, chainIds, options); - } - - #subscribeAssetsBalanceV5( - accounts: InternalAccount[], - chainIds: ChainId[], - options?: { skipInitialFetch?: boolean }, ): void { const chainToAccounts = this.#buildChainToAccountsMap( accounts, @@ -3609,104 +3538,12 @@ export class AssetsController extends BaseController< } } - this.#subscribeRpcCustomAssetsSupplement( - accounts, - chainToAccounts, - rpcAssignedChains, - ); - } - - #subscribeAssetsBalanceV6( - accounts: InternalAccount[], - chainIds: ChainId[], - options?: { skipInitialFetch?: boolean }, - ): void { - const chainToAccounts = this.#buildChainToAccountsMap( - accounts, - new Set(chainIds), - ); - const remainingChains = new Set(chainToAccounts.keys()); - const remainingCustomAssets = new Set(); - for (const account of accounts) { - for (const assetId of this.#getVisibleCustomAssets(account.id)) { - try { - if (remainingChains.has(parseCaipAssetType(assetId).chainId)) { - remainingCustomAssets.add(assetId); - } - } catch { - // Skip unparseable asset IDs - } - } - } - const balanceDataSources = this.#isBasicFunctionality() - ? this.#allBalanceDataSources - : [this.#rpcDataSource]; - - for (const source of balanceDataSources) { - const availableChains = new Set(source.getActiveChainsSync()); - const assignedChains: ChainId[] = []; - - for (const chainId of remainingChains) { - if (availableChains.has(chainId)) { - assignedChains.push(chainId); - remainingChains.delete(chainId); - } - } - - const claimedAssets = source.claimCustomAssets( - [...remainingCustomAssets], - assignedChains, + if (!this.#isBalanceV6Enabled()) { + this.#subscribeRpcCustomAssetsSupplement( + accounts, + chainToAccounts, + rpcAssignedChains, ); - for (const assetId of claimedAssets) { - remainingCustomAssets.delete(assetId); - } - if (assignedChains.length === 0 && claimedAssets.length === 0) { - this.#unsubscribeDataSource(source); - continue; - } - - const claimedAssetsSet = new Set(claimedAssets); - const seenIds = new Set(); - const accountsForSource = assignedChains - .flatMap((chainId) => chainToAccounts.get(chainId) ?? []) - .filter((account) => { - if (seenIds.has(account.id)) { - return false; - } - seenIds.add(account.id); - return true; - }); - for (const account of accounts) { - if ( - !seenIds.has(account.id) && - this.getCustomAssets(account.id).some((assetId) => - claimedAssetsSet.has(assetId), - ) - ) { - seenIds.add(account.id); - accountsForSource.push(account); - } - } - - if (accountsForSource.length > 0) { - const hiddenAssets = this.#getHiddenAssetIds(); - this.#subscribeDataSource(source, accountsForSource, assignedChains, { - customAssets: claimedAssets, - excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, - ...(options?.skipInitialFetch && - source === this.#accountsApiDataSource - ? { skipInitialFetch: true } - : {}), - }); - } else { - this.#unsubscribeDataSource(source); - } - } - - if (remainingCustomAssets.size > 0) { - log('Custom assets unclaimed by any data source', { - assetIds: [...remainingCustomAssets], - }); } } @@ -3728,17 +3565,9 @@ export class AssetsController extends BaseController< const rpc = this.#rpcDataSource; const supplementalKey = `ds:${rpc.getName()}:custom`; - const visibleCustomAssetsByAccount: Record = {}; - for (const account of accounts) { - const visibleCustomAssets = this.#getVisibleCustomAssets(account.id); - if (visibleCustomAssets.length > 0) { - visibleCustomAssetsByAccount[account.id] = visibleCustomAssets; - } - } - const decision = pickRpcCustomAssetsSupplement({ accountIds: accounts.map((account) => account.id), - customAssetsByAccount: visibleCustomAssetsByAccount, + customAssetsByAccount: this.state.customAssets, rpcAssignedChains, rpcAvailableChains: new Set(rpc.getActiveChainsSync()), enabledChains: new Set(chainToAccounts.keys()), @@ -3847,12 +3676,8 @@ export class AssetsController extends BaseController< * @param chains - Array of chain IDs to subscribe for. * @param options - Optional subscription overrides. * @param options.subscriptionKey - Custom subscription key (default: `ds:`). - * @param options.customAssets - Pinned assets this source claimed - * (`claimCustomAssets`), forwarded on the poll request (Accounts API v6). * @param options.customAssetsOnly - When true, only poll customAssets for these * chains (Accounts API v5 supplemental RPC subscription). - * @param options.excludeAssetIds - Hidden assets forwarded on the poll request - * (Accounts API v6). * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch. */ #subscribeDataSource( @@ -3861,8 +3686,6 @@ export class AssetsController extends BaseController< chains: ChainId[], options: { subscriptionKey?: string; - customAssets?: Caip19AssetId[]; - excludeAssetIds?: Caip19AssetId[]; customAssetsOnly?: boolean; skipInitialFetch?: boolean; } = {}, @@ -3871,7 +3694,6 @@ export class AssetsController extends BaseController< const subscriptionKey = options.subscriptionKey ?? `ds:${sourceId}`; const existingSubscription = this.#activeSubscriptions.get(subscriptionKey); const isUpdate = existingSubscription !== undefined; - const customAssets = options.customAssets ?? []; log('Subscribe to data source', { sourceId, @@ -3879,7 +3701,6 @@ export class AssetsController extends BaseController< isUpdate, accountCount: accounts.length, chainCount: chains.length, - customAssetCount: customAssets.length, customAssetsOnly: options.customAssetsOnly === true, skipInitialFetch: options.skipInitialFetch === true, }); @@ -3889,11 +3710,6 @@ export class AssetsController extends BaseController< assetTypes: ['fungible'], dataTypes: ['balance'], updateInterval: this.#defaultUpdateInterval, - customAssets: customAssets.length > 0 ? customAssets : undefined, - excludeAssetIds: - options.excludeAssetIds && options.excludeAssetIds.length > 0 - ? options.excludeAssetIds - : undefined, ...(options.customAssetsOnly === true ? { customAssetsOnly: true } : {}), diff --git a/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts b/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts index b4e11723bf6..98c5262e8e6 100644 --- a/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AbstractDataSource.test.ts @@ -1,4 +1,4 @@ -import type { Caip19AssetId, ChainId } from '../types.js'; +import type { ChainId } from '../types.js'; import type { ActiveSubscription, DataSourceState, @@ -113,43 +113,6 @@ describe('AbstractDataSource', () => { expect(dataSource.getSubscriptions().size).toBe(0); }); - describe('claimCustomAssets', () => { - it('claims assets on assigned chains by default and skips others', () => { - const { dataSource } = setupDataSource(); - - const assignedChainAsset = - `${CHAIN_MAINNET}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; - const unassignedChainAsset = - `${CHAIN_POLYGON}/erc20:0x2222222222222222222222222222222222222222` as Caip19AssetId; - - expect( - dataSource.claimCustomAssets( - [assignedChainAsset, unassignedChainAsset], - [CHAIN_MAINNET], - ), - ).toStrictEqual([assignedChainAsset]); - }); - - it('skips malformed asset IDs and claims nothing without assigned chains', () => { - const { dataSource } = setupDataSource(); - - expect( - dataSource.claimCustomAssets( - ['not-a-caip-asset' as Caip19AssetId], - [CHAIN_MAINNET], - ), - ).toStrictEqual([]); - expect( - dataSource.claimCustomAssets( - [ - `${CHAIN_MAINNET}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId, - ], - [], - ), - ).toStrictEqual([]); - }); - }); - it.each([ { chains: [], expected: [] }, { chains: [CHAIN_MAINNET], expected: [CHAIN_MAINNET] }, diff --git a/packages/assets-controller/src/data-sources/AbstractDataSource.ts b/packages/assets-controller/src/data-sources/AbstractDataSource.ts index be690a116e3..6023b18dfb2 100644 --- a/packages/assets-controller/src/data-sources/AbstractDataSource.ts +++ b/packages/assets-controller/src/data-sources/AbstractDataSource.ts @@ -1,7 +1,4 @@ -import { parseCaipAssetType } from '@metamask/utils'; - import type { - Caip19AssetId, ChainId, DataRequest, DataResponse, @@ -120,31 +117,6 @@ export abstract class AbstractDataSource< return this.state.activeChains; } - /** - * Claim the pinned assets this source commits to serving. Called during the - * subscription handoff; claimed assets are not offered to lower-priority - * sources. Assets a source cannot resolve at fetch time are released via - * `DataResponse.unprocessedCustomAssets`. Default: claim assets on this - * source's assigned chains. - * - * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. - * @param assignedChains - Chains assigned to this source in the handoff. - * @returns The claimed subset of `customAssets`. - */ - claimCustomAssets( - customAssets: Caip19AssetId[], - assignedChains: ChainId[], - ): Caip19AssetId[] { - const assigned = new Set(assignedChains); - return customAssets.filter((assetId) => { - try { - return assigned.has(parseCaipAssetType(assetId).chainId); - } catch { - return false; - } - }); - } - /** * Subscribe to updates for the given request. */ diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 97265f35f37..8c6ce69588b 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -144,6 +144,20 @@ function createDataRequest( }; } +function createAssetsState( + overrides: Partial = {}, +): AssetsControllerStateInternal { + return { + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + selectedCurrency: 'usd', + ...overrides, + }; +} + function createMiddlewareContext(overrides?: Partial): Context { return { request: createDataRequest(), @@ -171,6 +185,8 @@ async function setupController( fetchTimeoutMs?: number; v6Balances?: V6BalanceItem[]; remoteFeatureFlags?: Record; + getAssetsState?: () => AssetsControllerStateInternal; + tokenDetectionEnabled?: () => boolean; } = {}, ): Promise { const { @@ -182,6 +198,8 @@ async function setupController( fetchTimeoutMs, v6Balances = [], remoteFeatureFlags = {}, + getAssetsState = (): AssetsControllerStateInternal => createAssetsState(), + tokenDetectionEnabled, } = options; const rootMessenger = new Messenger({ @@ -235,7 +253,9 @@ async function setupController( activeChainsUpdateHandler(dataSourceName, chains, previousChains), isBalanceV6Enabled: (): boolean => isBalanceV6EnabledFromFlags(remoteFeatureFlags), + getAssetsState, ...(fetchTimeoutMs === undefined ? {} : { fetchTimeoutMs }), + ...(tokenDetectionEnabled === undefined ? {} : { tokenDetectionEnabled }), }); // Wait for async initialization @@ -792,48 +812,6 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - describe('claimCustomAssets', () => { - const assignedChainAsset = - 'eip155:1/erc20:0x1111111111111111111111111111111111111111'; - const unassignedChainAsset = - 'eip155:137/erc20:0x2222222222222222222222222222222222222222'; - const nonEvmAsset = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFW'; - - it('claims EVM assets on assigned chains when the v6 flag is enabled', async () => { - const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }); - - expect( - controller.claimCustomAssets( - [ - assignedChainAsset, - unassignedChainAsset, - nonEvmAsset, - 'not-a-caip-asset', - ] as Caip19AssetId[], - ['eip155:1' as ChainId], - ), - ).toStrictEqual([assignedChainAsset]); - - controller.destroy(); - }); - - it('claims nothing when the v6 flag is disabled (v5 has no includeAssetIds support)', async () => { - const { controller } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: false }, - }); - - expect( - controller.claimCustomAssets([assignedChainAsset] as Caip19AssetId[], [ - 'eip155:1' as ChainId, - ]), - ).toStrictEqual([]); - - controller.destroy(); - }); - }); - describe('assetsAccountsApiV6 feature flag', () => { it('uses the v5 endpoint by default', async () => { const { controller, apiClient } = await setupController(); @@ -1277,16 +1255,17 @@ describe('AccountsApiDataSource', () => { }); it('passes EVM hidden assets on requested chains to v6 as excludeAssetIds', async () => { + const hiddenToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + assetPreferences: { [hiddenToken]: { hidden: true } }, + }), }); - const hiddenToken = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - await controller.fetch( - createDataRequest({ excludeAssetIds: [hiddenToken] }), - ); + await controller.fetch(createDataRequest()); expect( apiClient.accounts.fetchV6MultiAccountBalances, @@ -1300,20 +1279,17 @@ describe('AccountsApiDataSource', () => { }); it('omits hidden assets that are not on a requested chain from excludeAssetIds', async () => { + const polygonToken = + 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + assetPreferences: { [polygonToken]: { hidden: true } }, + }), }); - // Hidden asset on Polygon while only Mainnet is being fetched. - const polygonToken = - 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - await controller.fetch( - createDataRequest({ - chainIds: [CHAIN_MAINNET], - excludeAssetIds: [polygonToken], - }), - ); + await controller.fetch(createDataRequest({ chainIds: [CHAIN_MAINNET] })); expect( apiClient.accounts.fetchV6MultiAccountBalances, @@ -1327,19 +1303,22 @@ describe('AccountsApiDataSource', () => { }); it('skips non-EVM and malformed hidden assets when building excludeAssetIds', async () => { - const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }); - const solanaToken = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; const malformed = 'not-a-caip-asset' as Caip19AssetId; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + assetPreferences: { + [solanaToken]: { hidden: true }, + [malformed]: { hidden: true }, + }, + }), + }); - await controller.fetch( - createDataRequest({ excludeAssetIds: [solanaToken, malformed] }), - ); + await controller.fetch(createDataRequest()); - // No EVM hidden asset on a requested chain -> excludeAssetIds omitted. expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( @@ -1351,22 +1330,44 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('lets a hidden asset win when it also appears in the pinned list', async () => { + it('reads visible pins from controller state as includeAssetIds when the request has none', async () => { + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [customToken] }, + }), }); + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [customToken] }, + undefined, + ); + + controller.destroy(); + }); + + it('does not include a state pin that is also hidden', async () => { const token = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [token] }, + assetPreferences: { [token]: { hidden: true } }, + }), + }); - await controller.fetch( - createDataRequest({ - customAssets: [token], - excludeAssetIds: [token], - }), - ); + await controller.fetch(createDataRequest()); - // The asset is hidden, so it is excluded and never included. expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( @@ -1379,21 +1380,20 @@ describe('AccountsApiDataSource', () => { }); it('sends both includeAssetIds and excludeAssetIds when pins and hidden assets differ', async () => { - const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }); - const pinned = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const hidden = 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [pinned] }, + assetPreferences: { [hidden]: { hidden: true } }, + }), + }); - await controller.fetch( - createDataRequest({ - customAssets: [pinned], - excludeAssetIds: [hidden], - }), - ); + await controller.fetch(createDataRequest()); expect( apiClient.accounts.fetchV6MultiAccountBalances, @@ -1679,6 +1679,8 @@ describe('AccountsApiDataSource', () => { balances?: V5BalanceItem[]; unprocessedNetworks?: string[]; tokenDetectionEnabled?: boolean; + getAssetsState?: () => AssetsControllerStateInternal; + remoteFeatureFlags?: Record; } = {}, ): Promise { const { @@ -1686,6 +1688,9 @@ describe('AccountsApiDataSource', () => { balances = [], unprocessedNetworks = [], tokenDetectionEnabled, + getAssetsState = (): AssetsControllerStateInternal => + createAssetsState(), + remoteFeatureFlags = {}, } = options; const rootMessenger = new Messenger< @@ -1711,7 +1716,7 @@ describe('AccountsApiDataSource', () => { registerActionHandler: (a: string, h: () => unknown) => void; } ).registerActionHandler('RemoteFeatureFlagController:getState', () => ({ - remoteFeatureFlags: {}, + remoteFeatureFlags, cacheTimestamp: 0, })); @@ -1737,6 +1742,9 @@ describe('AccountsApiDataSource', () => { apiClient as unknown as AccountsApiDataSourceOptions['queryApiClient'], onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => activeChainsUpdateHandler(dataSourceName, chains, previousChains), + isBalanceV6Enabled: (): boolean => + isBalanceV6EnabledFromFlags(remoteFeatureFlags), + getAssetsState, }; if (tokenDetectionEnabled !== undefined) { @@ -1786,17 +1794,6 @@ describe('AccountsApiDataSource', () => { request: createDataRequest(), isUpdate: false, onAssetsUpdate: assetsUpdateHandler, - getAssetsState: () => ({ - assetsInfo: {}, - assetsBalance: { - [ACCOUNT_ID]: { - [KNOWN_ASSET]: { amount: '500' }, - }, - }, - assetsPrice: {}, - customAssets: {}, - assetPreferences: {}, - }), }); expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); @@ -1825,6 +1822,14 @@ describe('AccountsApiDataSource', () => { '2000', ), ], + getAssetsState: () => + createAssetsState({ + assetsBalance: { + [ACCOUNT_ID]: { + [KNOWN_ASSET]: { amount: '500' }, + }, + }, + }), }); await controller.subscribe({ @@ -1832,17 +1837,6 @@ describe('AccountsApiDataSource', () => { request: createDataRequest(), isUpdate: false, onAssetsUpdate: assetsUpdateHandler, - getAssetsState: () => ({ - assetsInfo: {}, - assetsBalance: { - [ACCOUNT_ID]: { - [KNOWN_ASSET]: { amount: '500' }, - }, - }, - assetsPrice: {}, - customAssets: {}, - assetPreferences: {}, - }), }); expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); @@ -1867,6 +1861,7 @@ describe('AccountsApiDataSource', () => { '2000', ), ], + getAssetsState: () => createAssetsState(), }); await controller.subscribe({ @@ -1874,13 +1869,6 @@ describe('AccountsApiDataSource', () => { request: createDataRequest(), isUpdate: false, onAssetsUpdate: assetsUpdateHandler, - getAssetsState: () => ({ - assetsInfo: {}, - assetsBalance: {}, - assetsPrice: {}, - customAssets: {}, - assetPreferences: {}, - }), }); expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); @@ -1906,25 +1894,14 @@ describe('AccountsApiDataSource', () => { '2000', ), ], - }); - - // Set up state accessor via subscribe (middleware uses the stored getAssetsState) - await controller.subscribe({ - subscriptionId: 'sub-setup', - request: createDataRequest(), - isUpdate: false, - onAssetsUpdate: jest.fn(), - getAssetsState: () => ({ - assetsInfo: {}, - assetsBalance: { - [ACCOUNT_ID]: { - [KNOWN_ASSET]: { amount: '500' }, + getAssetsState: () => + createAssetsState({ + assetsBalance: { + [ACCOUNT_ID]: { + [KNOWN_ASSET]: { amount: '500' }, + }, }, - }, - assetsPrice: {}, - customAssets: {}, - assetPreferences: {}, - }), + }), }); const middleware = controller.assetsMiddleware; @@ -1955,40 +1932,93 @@ describe('AccountsApiDataSource', () => { '1000000000000000000', ), ], + getAssetsState: () => createAssetsState(), }); - // Simulate new account: subscribe with empty state so #getAssetsState is set. - // Filter will then remove all API balance data (no assets in state). - await controller.subscribe({ - subscriptionId: 'sub-setup', + const nextFn = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ request: createDataRequest({ chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], }), - isUpdate: false, - onAssetsUpdate: jest.fn(), - getAssetsState: () => ({ - assetsInfo: {}, - assetsBalance: {}, - assetsPrice: {}, - customAssets: {}, - assetPreferences: {}, + }); + + await controller.assetsMiddleware(context, nextFn); + + // All chains must still be passed to next middleware so RPC can fetch native balances + expect(nextFn).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], + }), }), + ); + + controller.destroy(); + }); + + it('does not filter unknown tokens on the v6 path when tokenDetectionEnabled is false', async () => { + const known = createMockV6BalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + KNOWN_ASSET, + '1000', + ); + const unknown = createMockV6BalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + UNKNOWN_ASSET, + '2000', + ); + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + tokenDetectionEnabled: (): boolean => false, + v6Balances: [known, unknown], + getAssetsState: () => + createAssetsState({ + assetsBalance: { + [ACCOUNT_ID]: { + [KNOWN_ASSET]: { amount: '500' }, + }, + }, + }), + }); + + const response = await controller.fetch(createDataRequest()); + const accountBalances = response.assetsBalance?.[ACCOUNT_ID] ?? {}; + expect(Object.keys(accountBalances)).toHaveLength(2); + expect(accountBalances[KNOWN_ASSET as Caip19AssetId]).toBeDefined(); + expect(accountBalances[UNKNOWN_ASSET as Caip19AssetId]).toBeDefined(); + + controller.destroy(); + }); + + it('claims handled chains on v6 when tokenDetectionEnabled is false', async () => { + const { controller } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + tokenDetectionEnabled: (): boolean => false, + v6Balances: [ + createMockV6BalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + 'eip155:1/slip44:60', + '1000000000000000000', + 'token', + 'native', + ), + ], + getAssetsState: () => createAssetsState(), }); const nextFn = jest.fn().mockResolvedValue(undefined); const context = createMiddlewareContext({ request: createDataRequest({ - chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], + chainIds: [CHAIN_MAINNET], }), }); await controller.assetsMiddleware(context, nextFn); - // All chains must still be passed to next middleware so RPC can fetch native balances expect(nextFn).toHaveBeenCalledWith( expect.objectContaining({ request: expect.objectContaining({ - chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], + chainIds: [], }), }), ); @@ -2012,13 +2042,7 @@ describe('filterResponseToKnownAssets', () => { function buildState( balances: Record>, ): AssetsControllerStateInternal { - return { - assetsInfo: {}, - assetsBalance: balances, - assetsPrice: {}, - customAssets: {}, - assetPreferences: {}, - }; + return createAssetsState({ assetsBalance: balances }); } it('returns response unchanged when assetsBalance is undefined', () => { diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 74f44d92f13..370010cd916 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -114,6 +114,11 @@ export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { * `false` (v5). Read on demand, not cached. */ isBalanceV6Enabled?: () => boolean; + /** + * Current AssetsController state. Used for v6 include/exclude asset IDs and + * for filtering when token detection is off. + */ + getAssetsState: () => AssetsControllerStateInternal; state?: Partial; }; @@ -262,15 +267,14 @@ export class AccountsApiDataSource extends AbstractDataSource< /** Injected by AssetsController; `true` when the v6 balances endpoint should be used. */ readonly #isBalanceV6Enabled: () => boolean; + readonly #getAssetsState: () => AssetsControllerStateInternal; + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; /** Chains refresh timer */ #chainsRefreshTimer: ReturnType | null = null; - /** State accessor from subscriptions (for filtering when tokenDetectionEnabled is false) */ - #getAssetsState?: () => AssetsControllerStateInternal; - constructor(options: AccountsApiDataSourceOptions) { super(CONTROLLER_NAME, { ...defaultState, @@ -285,6 +289,7 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#isBalanceV6Enabled = options.isBalanceV6Enabled ?? ((): boolean => false); + this.#getAssetsState = options.getAssetsState; this.#apiClient = options.queryApiClient; // The Snaps → AssetsController migration flags gate which migration networks @@ -461,10 +466,7 @@ export class AccountsApiDataSource extends AbstractDataSource< if (isV6) { // User-hidden assets on the fetched chains, sent to v6 as // `excludeAssetIds`. - const excludeAssetIds = this.#getExcludeAssetIds( - request, - chainsToFetch, - ); + const excludeAssetIds = this.#getExcludeAssetIds(chainsToFetch); // User-pinned assets on the fetched chains, sent to v6 as // `includeAssetIds` so the backend returns them even at zero balance. @@ -542,8 +544,9 @@ export class AccountsApiDataSource extends AbstractDataSource< } } - // When token detection is disabled, filter out tokens not already in state - if (!this.#tokenDetectionEnabled() && this.#getAssetsState) { + // v5: when token detection is off, drop tokens not already in state. + // v6: the snapshot is authoritative (`updateMode: 'full'`); do not filter. + if (!this.#isBalanceV6Enabled() && !this.#tokenDetectionEnabled()) { response = filterResponseToKnownAssets(response, this.#getAssetsState()); } @@ -555,7 +558,10 @@ export class AccountsApiDataSource extends AbstractDataSource< * endpoint as `includeAssetIds`; malformed IDs are skipped and hidden * assets are left out (a hide wins). * - * @param request - The data request (carries `customAssets`). + * Prefers `request.customAssets` when the caller scoped the fetch (e.g. + * `addCustomAsset`). Otherwise reads visible pins from controller state. + * + * @param request - The data request (optional `customAssets` scope). * @param chainsToFetch - Chains being requested this fetch. * @param excludeAssetIds - Hidden asset IDs that must not be included. * @returns Deduplicated asset IDs, or `undefined` when none. @@ -565,55 +571,68 @@ export class AccountsApiDataSource extends AbstractDataSource< chainsToFetch: ChainId[], excludeAssetIds: Caip19AssetId[] | undefined, ): Caip19AssetId[] | undefined { - if (!request.customAssets || request.customAssets.length === 0) { - return undefined; - } - - const chainsToFetchSet = new Set(chainsToFetch); - const excludeSet = new Set(excludeAssetIds ?? []); - const includeAssetIds = new Set(); - - for (const assetId of request.customAssets) { - if (excludeSet.has(assetId)) { - continue; - } - let chainId: ChainId; - try { - chainId = parseCaipAssetType(assetId).chainId; - } catch { - continue; - } - if ( - chainId.startsWith(`${KnownCaipNamespace.Eip155}:`) && - chainsToFetchSet.has(chainId) - ) { - includeAssetIds.add(assetId); - } - } - - return includeAssetIds.size > 0 ? [...includeAssetIds] : undefined; + const candidates = + request.customAssets && request.customAssets.length > 0 + ? request.customAssets + : this.#getVisibleCustomAssetsFromState(request); + return this.#filterEvmAssetsOnChains( + candidates, + chainsToFetch, + new Set(excludeAssetIds ?? []), + ); } /** - * Collect the hidden EVM assets on the fetched chains to send to the v6 - * endpoint as `excludeAssetIds`; malformed IDs are skipped. + * Collect hidden EVM assets on the fetched chains from controller state + * to send as `excludeAssetIds`. * - * @param request - The data request (carries `excludeAssetIds`). * @param chainsToFetch - Chains being requested this fetch. * @returns Deduplicated asset IDs, or `undefined` when none. */ - #getExcludeAssetIds( - request: DataRequest, - chainsToFetch: ChainId[], - ): Caip19AssetId[] | undefined { - if (!request.excludeAssetIds || request.excludeAssetIds.length === 0) { - return undefined; + #getExcludeAssetIds(chainsToFetch: ChainId[]): Caip19AssetId[] | undefined { + return this.#filterEvmAssetsOnChains( + this.#getHiddenAssetIdsFromState(), + chainsToFetch, + ); + } + + #getVisibleCustomAssetsFromState(request: DataRequest): Caip19AssetId[] { + const state = this.#getAssetsState(); + const visible: Caip19AssetId[] = []; + for (const { account } of request.accountsWithSupportedChains) { + for (const assetId of state.customAssets[account.id] ?? []) { + if (!state.assetPreferences[assetId]?.hidden) { + visible.push(assetId); + } + } } + return visible; + } + #getHiddenAssetIdsFromState(): Caip19AssetId[] { + const hidden: Caip19AssetId[] = []; + for (const [assetId, prefs] of Object.entries( + this.#getAssetsState().assetPreferences, + )) { + if (prefs.hidden) { + hidden.push(assetId as Caip19AssetId); + } + } + return hidden; + } + + #filterEvmAssetsOnChains( + assetIds: Caip19AssetId[], + chainsToFetch: ChainId[], + excludeSet?: Set, + ): Caip19AssetId[] | undefined { const chainsToFetchSet = new Set(chainsToFetch); - const excludeAssetIds = new Set(); + const filtered = new Set(); - for (const assetId of request.excludeAssetIds) { + for (const assetId of assetIds) { + if (excludeSet?.has(assetId)) { + continue; + } let chainId: ChainId; try { chainId = parseCaipAssetType(assetId).chainId; @@ -624,11 +643,11 @@ export class AccountsApiDataSource extends AbstractDataSource< chainId.startsWith(`${KnownCaipNamespace.Eip155}:`) && chainsToFetchSet.has(chainId) ) { - excludeAssetIds.add(assetId); + filtered.add(assetId); } } - return excludeAssetIds.size > 0 ? [...excludeAssetIds] : undefined; + return filtered.size > 0 ? [...filtered] : undefined; } /** @@ -956,10 +975,11 @@ export class AccountsApiDataSource extends AbstractDataSource< (chainId) => !unprocessedChains.has(chainId), ); - // When token detection is off and we filtered out all balance data (e.g. new - // account with empty state), do not claim any chain as handled so that RPC - // middleware can still process them and fetch native balances (ETH, MATIC, etc.). + // v5: when token detection is off and we filtered out all balance data + // (e.g. new account with empty state), do not claim any chain so RPC + // can still fetch native balances. v6 keeps API coverage as-is. if ( + !this.#isBalanceV6Enabled() && !this.#tokenDetectionEnabled() && (!response.assetsBalance || Object.keys(response.assetsBalance).length === 0) @@ -995,45 +1015,10 @@ export class AccountsApiDataSource extends AbstractDataSource< // SUBSCRIBE // ============================================================================ - /** - * Claim EVM pins on assigned chains (sent to v6 as `includeAssetIds`). - * v5 has no `includeAssetIds`, so with the v6 flag off nothing is claimed - * and pins fall through to RPC. - * - * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. - * @param assignedChains - Chains assigned to this source in the handoff. - * @returns The claimed subset of `customAssets`. - */ - claimCustomAssets( - customAssets: Caip19AssetId[], - assignedChains: ChainId[], - ): Caip19AssetId[] { - if (!this.#isBalanceV6Enabled()) { - return []; - } - const assigned = new Set(assignedChains); - return customAssets.filter((assetId) => { - try { - const parsed = parseCaipAssetType(assetId); - return ( - parsed.chain.namespace === KnownCaipNamespace.Eip155 && - assigned.has(parsed.chainId) - ); - } catch { - return false; - } - }); - } - async subscribe(subscriptionRequest: SubscriptionRequest): Promise { const { request, subscriptionId, isUpdate, skipInitialFetch } = subscriptionRequest; - // Store state accessor for filtering when tokenDetectionEnabled is false - if (subscriptionRequest.getAssetsState) { - this.#getAssetsState = subscriptionRequest.getAssetsState; - } - // Try all requested chains - API will handle unsupported ones via unprocessedNetworks const chainsToSubscribe = request.chainIds; diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 5c49872fed6..74063c8d6d2 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1432,44 +1432,6 @@ describe('RpcDataSource', () => { }); }); - describe('claimCustomAssets', () => { - const availableChainAsset = - `${MOCK_CHAIN_ID_CAIP}/erc20:0x1111111111111111111111111111111111111111` as Caip19AssetId; - const unavailableChainAsset = - 'eip155:999/erc20:0x2222222222222222222222222222222222222222' as Caip19AssetId; - const nonEvmAsset = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFW' as Caip19AssetId; - - it('claims EVM assets only on chains assigned to RPC', async () => { - await withController(async ({ controller }) => { - expect( - controller.claimCustomAssets([availableChainAsset], []), - ).toStrictEqual([]); - expect( - controller.claimCustomAssets( - [availableChainAsset], - [MOCK_CHAIN_ID_CAIP], - ), - ).toStrictEqual([availableChainAsset]); - }); - }); - - it('does not claim assets on unassigned chains, non-EVM assets, or malformed IDs', async () => { - await withController(async ({ controller }) => { - expect( - controller.claimCustomAssets( - [ - unavailableChainAsset, - nonEvmAsset, - 'not-a-caip-asset' as Caip19AssetId, - ], - [MOCK_CHAIN_ID_CAIP], - ), - ).toStrictEqual([]); - }); - }); - }); - describe('unsubscribe', () => { it('removes subscription', async () => { await withController(async ({ controller }) => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 626cfd55174..26cb0bb1b6e 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -1351,34 +1351,6 @@ export class RpcDataSource extends AbstractDataSource< }; } - /** - * Claim leftover EVM pins on chains assigned to RPC. Pins on chains owned by - * Account Activity or the Accounts API stay with those sources (v6 - * `includeAssetIds` / websocket). Unresolved pins on those chains are - * recovered by `RpcFallbackMiddleware`, not a second RPC subscription. - * - * @param customAssets - Candidate CAIP-19 asset IDs still unclaimed. - * @param assignedChains - Chains assigned to RPC in this handoff. - * @returns The claimed subset of `customAssets`. - */ - claimCustomAssets( - customAssets: Caip19AssetId[], - assignedChains: ChainId[], - ): Caip19AssetId[] { - const assigned = new Set(assignedChains); - return customAssets.filter((assetId) => { - try { - const parsed = parseCaipAssetType(assetId); - return ( - parsed.chain.namespace === KnownCaipNamespace.Eip155 && - assigned.has(parsed.chainId) - ); - } catch { - return false; - } - }); - } - /** * Subscribe to updates for the given request. * Starts polling through BalanceFetcher and TokenDetector. diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index 4c10eba6aa9..795b6f92b9b 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -248,7 +248,12 @@ describe('BalanceFetcher', () => { ); }); - it('includes state.customAssets on the regular poll even without an assetsBalance row', async () => { + it('polls custom assets even when they have no entry in assetsBalance yet', async () => { + // Regression: custom assets must be polled by RPC because RPC is the + // sole balance fetcher for them. Previously #getAssetsToFetch only + // looked at state.assetsBalance, so a freshly added custom asset + // (no balance row yet, e.g. zero balance or first fetch failed) + // would never be polled. const mockState: AssetsBalanceState = { assetsBalance: { [TEST_ACCOUNT_ID]: { @@ -278,7 +283,12 @@ describe('BalanceFetcher', () => { true, '1000000000000000000', ), - createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, true, '0'), + createMockBalanceResponse( + TEST_TOKEN_1.toLowerCase() as Address, + TEST_ACCOUNT, + true, + '500', + ), ]); const input: BalancePollingInput = { @@ -289,6 +299,9 @@ describe('BalanceFetcher', () => { await controller._executePoll(input); + // The multicall batch should include both the native asset and + // the custom ERC-20 token, even though the custom token has no + // entry in assetsBalance. const [, batchedRequests] = mockMulticallClient.batchBalanceOf.mock.calls[0]; const requestedTokens = ( @@ -297,17 +310,16 @@ describe('BalanceFetcher', () => { .map((req) => req.tokenAddress.toLowerCase()) .sort(); expect(requestedTokens).toStrictEqual( - [ZERO_ADDRESS, TEST_TOKEN_1] - .map((addr) => addr.toLowerCase()) - .sort(), + [ZERO_ADDRESS.toLowerCase(), TEST_TOKEN_1.toLowerCase()].sort(), ); }, ); }); - it('with explicit assetIds fetches exactly those assets and ignores tracked state', async () => { - // Asset-scoped poll path: RPC claimed the pins on a chain another - // source owns — it must NOT also poll the tracked balances. + it('in customAssetsOnly mode skips state.assetsBalance and only fetches state.customAssets', async () => { + // The supplemental subscription path: another data source covers the + // chain for regular balances, but RPC must still poll the user's + // customAssets. We must NOT also poll the regular tracked balances. const mockState: AssetsBalanceState = { assetsBalance: { [TEST_ACCOUNT_ID]: { @@ -342,7 +354,7 @@ describe('BalanceFetcher', () => { chainId: MAINNET_CHAIN_ID, accountId: TEST_ACCOUNT_ID, accountAddress: TEST_ACCOUNT, - assetIds: [TOKEN_1_ASSET_ID], + customAssetsOnly: true, }; await controller._executePoll(input); @@ -354,8 +366,8 @@ describe('BalanceFetcher', () => { ) .map((req) => req.tokenAddress.toLowerCase()) .sort(); - // ONLY the pinned asset — not the native and not TOKEN_2 from - // assetsBalance, even though both are tracked in state. + // ONLY the custom token — not the native and not TOKEN_2 from + // assetsBalance. expect(requestedTokens).toStrictEqual([TEST_TOKEN_1.toLowerCase()]); }, ); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index b9320a16d41..44d8af36fa1 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -45,16 +45,11 @@ export type BalancePollingInput = { accountId: AccountId; /** Account address */ accountAddress: Address; - /** - * When present, fetch exactly these assets instead of the tracked - * `assetsBalance` entries (Accounts API v6 asset-scoped RPC poll). Must be - * deterministically ordered — the polling input is the dedupe key. - */ - assetIds?: CaipAssetType[]; /** * When true, only fetch balances for entries in `state.customAssets`, * skipping `state.assetsBalance`. Used by the supplemental RPC - * subscription on the Accounts API v5 path. + * subscription on chains that another data source is already covering + * for regular balance refreshes. */ customAssetsOnly?: boolean; }; @@ -126,7 +121,6 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { - const assets = this.#getAssetsToFetch( - chainId, - accountId, - assetIds, - customAssetsOnly, - ); + const assets = this.#getAssetsToFetch(chainId, accountId, customAssetsOnly); return this.fetchBalancesForAssets( chainId, diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts index b9a0093ab41..b46f80a6746 100644 --- a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts @@ -104,6 +104,7 @@ async function runPipeline( messenger: assetsControllerMessenger, queryApiClient, onActiveChainsUpdated: jest.fn(), + getAssetsState: (): AssetsControllerStateInternal => state, }); const stakedBalanceDataSource = new StakedBalanceDataSource({ @@ -171,7 +172,7 @@ async function runPipeline( const { response } = await executeAssetsPipeline({ sources, request, - getAssetsState: () => state, + getAssetsState: (): AssetsControllerStateInternal => state, }); accountsApiDataSource.destroy(); diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 9f293379788..8d715d6dc35 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -342,7 +342,11 @@ export type DataRequest = { assetTypes?: AssetType[]; /** Which data to fetch */ dataTypes: DataType[]; - /** Specific CAIP-19 asset IDs */ + /** + * Optional scoped CAIP-19 asset IDs for this fetch (not the full pin list). + * Used by `getAssets({ customAssets })`, `addCustomAsset`, and RPC fallback + * recovery. When omitted, v6 sources read pins from controller state. + */ customAssets?: Caip19AssetId[]; /** * When true, the data source should poll only the user's `customAssets` @@ -352,11 +356,6 @@ export type DataRequest = { * (Accounts API v5 path). Ignored when Accounts API v6 is enabled. */ customAssetsOnly?: boolean; - /** - * User-hidden CAIP-19 asset IDs, sent to the Accounts API v6 endpoint as - * `excludeAssetIds` so they are dropped from the response. - */ - excludeAssetIds?: Caip19AssetId[]; /** Force fresh fetch, bypass cache */ forceUpdate?: boolean; /** diff --git a/packages/assets-controller/tsconfig.build.json b/packages/assets-controller/tsconfig.build.json index 1493c629174..d3ca7fb59b9 100644 --- a/packages/assets-controller/tsconfig.build.json +++ b/packages/assets-controller/tsconfig.build.json @@ -67,5 +67,5 @@ } ], "include": ["../../types", "./src"], - "exclude": ["**/*.test.ts", "**/__fixtures__/", "./architecture"] + "exclude": ["**/*.test.ts", "**/__fixtures__/"] } From db287901b82d8fa42286f3ee1ff3d8e1af866fb5 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 21 Sep 2026 23:30:06 +0200 Subject: [PATCH 10/30] update docs --- .../docs/architecture/v1/CHANGES.md | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index 9aa306e5b54..377052aa256 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -1,4 +1,4 @@ -# AssetsController 15.0.0 - Accounts API v5 and v6 side by side +# AssetsController - Accounts API v5 and v6 side by side ## What this change is about @@ -90,6 +90,40 @@ When basic functionality is off, both fast lanes shrink to Both paths first hand each chain to the data source with the highest priority for it. They differ in how pinned assets on those chains are covered: +```mermaid +flowchart TB + subgraph shared ["Both paths"] + H["Assign each chain to the highest-priority source that supports it"] + end + + subgraph v5sub ["v5"] + direction TB + V5A["Subscribe each source with accounts + assigned chains"] + V5WS["Account Activity
updateMode merge"] + V5API["Accounts API
updateMode merge"] + V5RPC["RPC
updateMode merge"] + V5R["Extra RPC poll: customAssetsOnly
pins on chains another source already owns
updateMode merge"] + V5A --> V5WS + V5A --> V5API + V5A --> V5RPC + V5A --> V5R + end + + subgraph v6sub ["v6"] + direction TB + V6A["Subscribe each source with accounts + assigned chains only"] + V6WS["Account Activity
ignores pins/hides
updateMode merge"] + V6API["Accounts API
includeAssetIds / excludeAssetIds from state
updateMode full"] + V6RPC["RPC
polls customAssets from state on assigned chains
updateMode merge"] + V6A --> V6WS + V6A --> V6API + V6A --> V6RPC + end + + shared --> v5sub + shared --> v6sub +``` + - **v5** adds a separate RPC poll (`customAssetsOnly`) for pins that sit on a chain another source already owns. - **v6** subscribe only assigns accounts and chains. The Accounts API reads @@ -100,6 +134,18 @@ for it. They differ in how pinned assets on those chains are covered: ## 3. Handling an incoming update +```mermaid +flowchart TB + subgraph v5upd ["v5 · #handleAssetsUpdateV5"] + direction TB + V5I["Incoming update"] --> V5G["CustomAssetGraduation
Accounts API + Account Activity"] --> V5D[Detection] --> V5T["Token + Price"] --> V5S["State: merge
replaceCoveredChainBalances"] + end + subgraph v6upd ["v6 · #handleAssetsUpdateV6"] + direction TB + V6I["Incoming update"] --> V6F["RpcFallback when basic functionality is on
errored chains + unprocessedCustomAssets"] --> V6D[Detection] --> V6T["Token + Price"] --> V6S["State: full when Accounts API
merge otherwise"] + end +``` + - **v5** (`#handleAssetsUpdateV5`): CustomAssetGraduation (for the Accounts API and AccountActivity) -> Detection -> Token + Price -> state written with `merge`, honoring `replaceCoveredChainBalances`. From d46fe5245f267f6925d50c20e6b71ddda3e0e2d7 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Tue, 22 Sep 2026 11:06:28 +0200 Subject: [PATCH 11/30] update changelog --- packages/assets-controller/CHANGELOG.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a0661e5a91e..a2ee53ae712 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,20 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add an optional `customAssets` option to `AssetsController.getAssets`, used only when `assetsAccountsApiV6` is enabled. It overrides which pinned assets are attached to the fetch (sent as `includeAssetIds` and to RPC). When the flag is off, `getAssets` still attaches every pin of the requested accounts, unscoped, matching the previous v5 request ([#9651](https://github.com/MetaMask/core/pull/9651)) -- Add a required `getAssetsState` getter to `AccountsApiDataSource`. `AssetsController` injects `() => this.state` so v6 include/exclude and token-detection filtering read controller state without copying it onto subscribe or fetch requests +- Add optional `customAssets` to `AssetsController.getAssets`, to scope a fetch to specific asset IDs (Accounts API v6 only) ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Add optional `unprocessedCustomAssets` to `DataResponse`, listing pinned asset IDs a source could not resolve so `RpcFallbackMiddleware` can recover them ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Add optional `isBalanceV6Enabled` to `AccountsApiDataSourceOptions` and `RpcFallbackMiddlewareOptions`, so the `assetsAccountsApiV6` flag is read once by `AssetsController` and injected ([#9651](https://github.com/MetaMask/core/pull/9651)) ### Changed -- **BREAKING:** Remove `excludeAssetIds` from `DataRequest`. Accounts API v6 reads hidden assets from controller state (`assetPreferences`) when building the v6 `excludeAssetIds` query param. `request.customAssets` remains only as a scoped fetch override (`getAssets({ customAssets })`, `addCustomAsset`, RPC fallback), not a copy of every pin. -- When `assetsAccountsApiV6` is enabled, a hide wins over a pin: Accounts API v6 sends hidden assets as `excludeAssetIds` and skips them in `includeAssetIds`. The v5 path is unchanged (`getAssets` force updates and the RPC custom-asset supplement still attach every pin, including hidden ones). `hideAsset` still leaves the pin in `customAssets` to record that the token was imported ([#9651](https://github.com/MetaMask/core/pull/9651)) -- When `assetsAccountsApiV6` is enabled, Accounts API fetch and middleware report `updateMode: 'full'` so assets on chains in the snapshot (including custom assets returned via `includeAssetIds`) are replaced; pins left in `unprocessedCustomAssets` after RPC fallback keep their prior balances. The v5 path keeps `updateMode: 'merge'` with `replaceCoveredChainBalances` so custom assets are preserved ([#9651](https://github.com/MetaMask/core/pull/9651)) -- When `assetsAccountsApiV6` is enabled, Accounts API does not filter unknown tokens via `tokenDetectionEnabled`. The v6 snapshot is applied in full; v5 still drops tokens not already in state when detection is off +- **BREAKING:** Remove `'update'` from `AssetsUpdateMode`; use `'full'` (Accounts API v6 snapshot) or `'merge'` ([#9651](https://github.com/MetaMask/core/pull/9651)) +- **BREAKING:** Remove the `updateMode` option from `AssetsController.getAssets`; the data source now sets it on its response ([#9651](https://github.com/MetaMask/core/pull/9651)) +- **BREAKING:** Require `getAssetsState` in `AccountsApiDataSourceOptions` ([#9651](https://github.com/MetaMask/core/pull/9651)) + - Pass `() => this.state` from `AssetsController` +- When `assetsAccountsApiV6` is enabled, Accounts API v6 reads pins and hides from state and sends them as `includeAssetIds` / `excludeAssetIds`, then applies the response with `updateMode: 'full'` ([#9651](https://github.com/MetaMask/core/pull/9651)) +- `hideAsset` and `unhideAsset` now re-evaluate subscriptions, so live polls start and stop excluding the asset immediately ([#9651](https://github.com/MetaMask/core/pull/9651)) - Bump `@metamask/transaction-controller` from `^70.0.1` to `^70.1.0` ([#10262](https://github.com/MetaMask/core/pull/10262)) ### Fixed -- Treat `assetsAccountsApiV6` as enabled when it is `true` ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Treat the `assetsAccountsApiV6` remote feature flag as enabled when it is `true`, instead of reading a nested `{ value }` object ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Keep default tracked assets (mUSD) at a zero balance when an Accounts API v6 `full` update omits them, so a force refresh no longer drops them from the token list ([#9651](https://github.com/MetaMask/core/pull/9651)) - Skip `#updateState` assignments for metadata, balances, and prices that are deep-equal to what's already in state, so Immer no longer emits a no-op `stateChange` (and a full state persist) on every poll that repeats unchanged data ([#10260](https://github.com/MetaMask/core/pull/10260)) - `TokenDataSource` spam filtering now removes filtered assets from `assetsBalance` and `detectedAssets` using case-insensitive asset ID matching (previously only `assetsInfo` was matched case-insensitively), so spam tokens whose IDs arrive in a different case than state no longer survive in the pipeline response and persist to state ([#10172](https://github.com/MetaMask/core/pull/10172)) From a4b51f14541cc53e89f5a40fe41ba4a8aa874f6a Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Tue, 22 Sep 2026 15:58:42 +0200 Subject: [PATCH 12/30] update integration test --- ...c-spam-token-filtering.integration.test.ts | 81 +++++++++++---- .../bsc-spam-token/api-responses/index.ts | 46 ++++++++- ...c-spam-token-filtering.integration.test.ts | 99 ++++++++++++++++++- 3 files changed, 202 insertions(+), 24 deletions(-) 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 66048972372..07ab7e58fb8 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 @@ -21,6 +21,7 @@ import { createTestApiClient } from './__fixtures__/mockTokenApi.js'; import { waitFor, waitUntilStable } from './__fixtures__/test-utils.js'; import { AssetsController } from './AssetsController.js'; import type { AssetsControllerState } from './AssetsController.js'; +import type { AssetsControllerStateInternal } from './types.js'; /** * Integration coverage for `AssetsController` against the BNB Chain wallet @@ -28,9 +29,9 @@ import type { AssetsControllerState } from './AssetsController.js'; * * Boots the real controller, answers the same captured APIs as * `buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts`, and - * asserts CDOGE never lands in persisted state. - * - * Integration Expectation - CDOGE is correctly filtered out of controller state. + * asserts CDOGE never lands in persisted state — unless the user imported it + * as a custom asset, in which case it must survive (see the custom-asset + * suite below). */ type StateSurface = { @@ -63,14 +64,17 @@ async function withController( { state = buildEmptyAssetsState(), queryApiClient = createTestApiClient(), + remoteFeatureFlags = {}, }: { state?: Partial; queryApiClient?: ApiPlatformClient; + remoteFeatureFlags?: Record; }, fn: WithControllerCallback, ): Promise { const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ - registerCustomRootActions: registerBscSpamControllerActions, + registerCustomRootActions: (messenger) => + registerBscSpamControllerActions(messenger, { remoteFeatureFlags }), }); const controller = new AssetsController({ @@ -90,25 +94,31 @@ async function withController( async function fetchWallet( state: Partial = buildEmptyAssetsState(), + remoteFeatureFlags: Record = {}, ): Promise { const { accountsSupportedNetworks } = mockBscSpamApis(); - return await withController({ state }, async ({ controller }) => { - // wait for `AccountsApiDataSource` to ask `/v2/supportedNetworks` to indicate the fast-lane is ready - await waitFor(() => expect(accountsSupportedNetworks.isDone()).toBe(true)); + return await withController( + { state, remoteFeatureFlags }, + async ({ controller }) => { + // wait for `AccountsApiDataSource` to ask `/v2/supportedNetworks` to indicate the fast-lane is ready + await waitFor(() => + expect(accountsSupportedNetworks.isDone()).toBe(true), + ); - await controller.getAssets([buildBscSpamAccount()], { - chainIds: [BSC_CHAIN_ID], - forceUpdate: true, - }); + await controller.getAssets([buildBscSpamAccount()], { + chainIds: [BSC_CHAIN_ID], + forceUpdate: true, + }); - // `getAssets` awaits the fast lane only; the slow lane is fire-and-forget - // and can still be writing. Let state settle so the assertions about CDOGE - // being absent cannot pass just because nothing has landed yet. - await waitUntilStable(() => controller.state); + // `getAssets` awaits the fast lane only; the slow lane is fire-and-forget + // and can still be writing. Let state settle so the assertions about CDOGE + // being absent cannot pass just because nothing has landed yet. + await waitUntilStable(() => controller.state); - return controller.state; - }); + return controller.state; + }, + ); } const WALLET_PASSES = [ @@ -168,3 +178,40 @@ describe('AssetsController: BNB Chain spam token (CDOGE)', () => { }); }); }); + +describe('AssetsController: BNB Chain spam token (CDOGE) imported as a custom asset', () => { + afterEach(() => { + cleanAll(); + }); + + function buildCustomAssetWalletState(): AssetsControllerStateInternal { + return buildEmptyAssetsState({ + customAssets: { [BSC_SPAM_ACCOUNT_ID]: [CDOGE_ASSET_ID_CHECKSUM] }, + assetsBalance: { + [BSC_SPAM_ACCOUNT_ID]: { + [CDOGE_ASSET_ID_CHECKSUM]: { amount: '0' }, + }, + }, + assetsInfo: { + [CDOGE_ASSET_ID_CHECKSUM]: { + type: 'erc20', + symbol: 'CDOGE', + name: '$$$DOGECHAIN', + decimals: 9, + }, + }, + }); + } + + it('keeps the imported token in customAssets, balances and metadata', async () => { + const state = await fetchWallet(buildCustomAssetWalletState(), { + assetsAccountsApiV6: true, + }); + + expect(state.customAssets[BSC_SPAM_ACCOUNT_ID]).toContain( + CDOGE_ASSET_ID_CHECKSUM, + ); + expect(BALANCES.lookUp(state, CDOGE_ASSET_ID_LOWERCASE)).toBeDefined(); + expect(METADATA.lookUp(state, CDOGE_ASSET_ID_LOWERCASE)).toBeDefined(); + }); +}); diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts index 66476c232ec..90b4dd7319c 100644 --- a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts @@ -62,13 +62,16 @@ function mockAccountsSupportedNetworks(): nock.Scope { * Intercept `GET {ACCOUNTS}/v5/multiaccount/balances` with the wallet's * captured 38 holdings. * + * @param omitAssetIds - Asset IDs (any casing) to drop from the captured + * balances, simulating a token the Accounts API does not index. * @returns The nock scope and the account IDs each request asked about. */ -function mockV5MultiAccountBalances(): { +function mockV5MultiAccountBalances(omitAssetIds: string[] = []): { scope: nock.Scope; requestedAccountIds: string[][]; } { const requestedAccountIds: string[][] = []; + const omitted = new Set(omitAssetIds.map((assetId) => assetId.toLowerCase())); const scope = nock(API_URLS.ACCOUNTS) .persist() @@ -78,12 +81,39 @@ function mockV5MultiAccountBalances(): { requestedAccountIds.push( readListParam(uri, 'accountIds', API_URLS.ACCOUNTS), ); - return v5MultiAccountBalances; + const balances = v5MultiAccountBalances.balances.filter( + (entry) => !omitted.has(entry.assetId.toLowerCase()), + ); + return { ...v5MultiAccountBalances, count: balances.length, balances }; }); return { scope, requestedAccountIds }; } +/** + * Intercept `GET {ACCOUNTS}/v6/multiaccount/balances` with the wallet's + * captured token balances. + * + * @param omitAssetIds - Asset IDs (any casing) to drop from the captured + * balances. + * @returns The nock scope. + */ +function mockV6MultiAccountBalances(omitAssetIds: string[] = []): nock.Scope { + const omitted = new Set(omitAssetIds.map((assetId) => assetId.toLowerCase())); + + return nock(API_URLS.ACCOUNTS) + .persist() + .get('/v6/multiaccount/balances') + .query(true) + .reply(200, { + balances: v5MultiAccountBalances.balances.filter( + (entry) => !omitted.has(entry.assetId.toLowerCase()), + ), + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + }); +} + /** * Intercept `GET {TOKENS}/v2/supportedNetworks`. `eip155:56` is in the captured * `fullSupport` list, so BNB Chain assets genuinely reach the occurrence filter @@ -190,9 +220,16 @@ function mockV3SpotPrices(): BatchRecordingMock { * All interceptors persist, so batch composition and cache misses cannot make a * test fail for want of an interceptor. * + * @param options - Options for shaping the intercepted responses. + * @param options.omitBalanceAssetIds - Asset IDs (any casing) to drop from + * the captured balances, simulating tokens the Accounts API does not index. * @returns The recording mocks, and other utils */ -export function mockBscSpamApis(): { +export function mockBscSpamApis({ + omitBalanceAssetIds = [], +}: { + omitBalanceAssetIds?: string[]; +} = {}): { accountsSupportedNetworks: nock.Scope; balances: { requestedAccountIds: string[][] }; assets: BatchRecordingMock; @@ -204,7 +241,8 @@ export function mockBscSpamApis(): { mockPricesSupportedNetworks(); mockChainIdNetwork(); - const balances = mockV5MultiAccountBalances(); + const balances = mockV5MultiAccountBalances(omitBalanceAssetIds); + mockV6MultiAccountBalances(omitBalanceAssetIds); const assets = mockV3Assets(); const prices = mockV3SpotPrices(); diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts index b46f80a6746..c336d73129e 100644 --- a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts @@ -12,6 +12,7 @@ import { BNB_ASSET_ID, BSC_CHAIN_ID, BSC_SPAM_ACCOUNT_ID, + CDOGE_ASSET_ID_CHECKSUM, CDOGE_ASSET_ID_LOWERCASE, } from '../__fixtures__/bsc-spam-token/wallet.js'; import { createMockMessengers } from '../__fixtures__/MockAssetControllerMessenger.js'; @@ -27,7 +28,9 @@ import { RpcFallbackMiddleware } from '../middlewares/RpcFallbackMiddleware.js'; import type { AccountId, AssetsControllerStateInternal, + AssetsDataSource, Caip19AssetId, + Context, DataRequest, DataResponse, } from '../types.js'; @@ -78,6 +81,15 @@ const DETECTED_ASSETS: ResponseSurface = { async function runPipeline( state: AssetsControllerStateInternal, + { + rpcDataSource: rpcOverride, + omitBalanceAssetIds = [], + includeCustomAssetGraduation = true, + }: { + rpcDataSource?: AssetsDataSource; + omitBalanceAssetIds?: string[]; + includeCustomAssetGraduation?: boolean; + } = {}, ): Promise { const { assetsControllerMessenger } = createMockMessengers({ registerCustomRootActions: (rootMessenger) => { @@ -136,7 +148,7 @@ async function runPipeline( getSelectedCurrency: (): 'usd' => 'usd', }); - mockBscSpamApis(); + mockBscSpamApis({ omitBalanceAssetIds }); await accountsApiDataSource.refreshActiveChains(); @@ -161,12 +173,14 @@ async function runPipeline( ); }, }), - rpcFallbackMiddleware: new RpcFallbackMiddleware({ rpcDataSource }), + rpcFallbackMiddleware: new RpcFallbackMiddleware({ + rpcDataSource: rpcOverride ?? rpcDataSource, + }), detectionMiddleware: new DetectionMiddleware(), tokenDataSource, priceDataSource, }, - { isBasicFunctionality: true, includeCustomAssetGraduation: true }, + { isBasicFunctionality: true, includeCustomAssetGraduation }, ); const { response } = await executeAssetsPipeline({ @@ -240,3 +254,82 @@ describe('assets pipeline: BNB Chain spam token (CDOGE)', () => { }); }); }); + +describe('assets pipeline: BNB Chain spam token (CDOGE) imported as a custom asset', () => { + afterEach(() => { + cleanAll(); + }); + + const createRecordingRpcSource = ( + balances: Record, + ): { source: AssetsDataSource; requests: DataRequest[] } => { + const requests: DataRequest[] = []; + const source: AssetsDataSource = { + getName: () => 'RpcDataSource', + assetsMiddleware: async (ctx): Promise => { + requests.push(ctx.request); + return { + ...ctx, + response: { + assetsBalance: { [BSC_SPAM_ACCOUNT_ID]: balances }, + }, + }; + }, + }; + return { source, requests }; + }; + + it.each([BALANCES, METADATA, DETECTED_ASSETS])( + '$surface - keeps the imported token despite a positive API balance and low occurrences', + async ({ lookUp }) => { + const response = await runPipeline( + buildEmptyAssetsState({ + customAssets: { [BSC_SPAM_ACCOUNT_ID]: [CDOGE_ASSET_ID_CHECKSUM] }, + }), + { includeCustomAssetGraduation: false }, + ); + + expect(lookUp(response, CDOGE_ASSET_ID_LOWERCASE)).toBeDefined(); + }, + ); + + it('does not re-read the custom asset on RPC when the Accounts API reports its balance', async () => { + const { source, requests } = createRecordingRpcSource({}); + + const response = await runPipeline( + buildEmptyAssetsState({ + customAssets: { [BSC_SPAM_ACCOUNT_ID]: [CDOGE_ASSET_ID_CHECKSUM] }, + }), + { + rpcDataSource: source, + includeCustomAssetGraduation: false, + }, + ); + + expect(requests).toHaveLength(0); + expect(BALANCES.lookUp(response, CDOGE_ASSET_ID_LOWERCASE)).toBeDefined(); + }); + + it('re-reads the custom asset on RPC when the Accounts API omits it', async () => { + const { source, requests } = createRecordingRpcSource({ + [CDOGE_ASSET_ID_LOWERCASE]: { amount: '4321' }, + }); + + const response = await runPipeline( + buildEmptyAssetsState({ + customAssets: { [BSC_SPAM_ACCOUNT_ID]: [CDOGE_ASSET_ID_CHECKSUM] }, + }), + { + rpcDataSource: source, + omitBalanceAssetIds: [CDOGE_ASSET_ID_LOWERCASE], + includeCustomAssetGraduation: false, + }, + ); + + expect(requests).toHaveLength(1); + expect(requests[0]?.customAssets ?? []).toContain(CDOGE_ASSET_ID_CHECKSUM); + expect(BALANCES.lookUp(response, CDOGE_ASSET_ID_LOWERCASE)).toMatchObject({ + amount: '4321', + }); + }); +}); From 6d7ad3374cfb0fcc26c8c13338ed4fc667f09dc6 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 23 Sep 2026 00:02:01 +0200 Subject: [PATCH 13/30] fix RPCFallback v6 --- .../middlewares/RpcFallbackMiddleware.test.ts | 94 +++++++-- .../src/middlewares/RpcFallbackMiddleware.ts | 186 +++++++++--------- 2 files changed, 176 insertions(+), 104 deletions(-) diff --git a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts index c8161574813..2ad04dc5ead 100644 --- a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts +++ b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts @@ -53,6 +53,7 @@ function createDataRequest( type StateOverrides = { assetsBalance?: Record>; customAssets?: Record; + assetPreferences?: Record; }; function createContext( @@ -67,6 +68,7 @@ function createContext( assetsInfo: {}, assetsBalance: stateOverrides.assetsBalance ?? {}, customAssets: stateOverrides.customAssets ?? {}, + assetPreferences: stateOverrides.assetPreferences ?? {}, assetsPrice: {}, } as AssetsControllerStateInternal), }; @@ -290,6 +292,74 @@ describe('RpcFallbackMiddleware', () => { expect(finalCtx.response.errors).toStrictEqual({}); }); + it('merges API unprocessed pins with every tracked asset on the errored chain in one RPC call', async () => { + const rpcResponse: DataResponse = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_POLYGON]: { amount: '10' }, + [MOCK_TOKEN_POLYGON]: { amount: '50' }, + }, + }, + }; + const { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); + const ctx = createContext( + createDataRequest(['eip155:1', 'eip155:137']), + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_MAINNET]: { amount: '1.2' } }, + }, + errors: { 'eip155:137': 'Unprocessed by Accounts API' }, + unprocessedCustomAssets: [MOCK_TOKEN_POLYGON], + }, + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_POLYGON]: { amount: '10' }, + [MOCK_TOKEN_POLYGON]: { amount: '50' }, + }, + }, + customAssets: { [MOCK_ACCOUNT_ID]: [MOCK_TOKEN_POLYGON] }, + }, + ); + const next = jest.fn(async (innerCtx) => innerCtx); + + await mw.assetsMiddleware(ctx, next); + + expect(rpcMw).toHaveBeenCalledTimes(1); + const [rpcCtx] = rpcMw.mock.calls[0]; + expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:137']); + expect(new Set(rpcCtx.request.customAssets)).toStrictEqual( + new Set([MOCK_ASSET_POLYGON, MOCK_TOKEN_POLYGON]), + ); + }); + + it('leaves a hidden asset out of the errored-chain retry', async () => { + const { source, middleware: rpcMw } = createMockRpcSource({}); + const mw = new RpcFallbackMiddleware({ + rpcDataSource: source, + isBalanceV6Enabled: (): boolean => true, + }); + const ctx = createContext( + createDataRequest(['eip155:137']), + { errors: { 'eip155:137': 'Fetch failed' } }, + { + customAssets: { [MOCK_ACCOUNT_ID]: [MOCK_TOKEN_POLYGON] }, + assetPreferences: { [MOCK_TOKEN_POLYGON]: { hidden: true } }, + }, + ); + const next = jest.fn(async (innerCtx) => innerCtx); + + await mw.assetsMiddleware(ctx, next); + + const [rpcCtx] = rpcMw.mock.calls[0]; + expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:137']); + expect(rpcCtx.request.customAssets).toBeUndefined(); + }); + it('recovers unprocessedCustomAssets with an RPC call scoped to just those assets (via customAssets), not a whole-chain fetch', async () => { const rpcResponse: DataResponse = { assetsBalance: { @@ -325,9 +395,7 @@ describe('RpcFallbackMiddleware', () => { expect(finalCtx.response.unprocessedCustomAssets).toBeUndefined(); }); - it('skips asset-scoped recovery for assets whose chain was already retried on the chain axis', async () => { - // The chain-axis fetch (native + custom assets) already covers the token, so - // there must be no second, asset-scoped RPC call for the same chain. + it('puts an API unprocessed pin on the same RPC request as the errored chain, without a second call', async () => { const rpcResponse: DataResponse = { assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_TOKEN_POLYGON]: { amount: '3' } }, @@ -346,12 +414,10 @@ describe('RpcFallbackMiddleware', () => { await mw.assetsMiddleware(ctx, next); - // Only the chain-axis call — it uses the original request (no customAssets - // override to the unresolved subset). expect(rpcMw).toHaveBeenCalledTimes(1); const [rpcCtx] = rpcMw.mock.calls[0]; expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:137']); - expect(rpcCtx.request.customAssets).toBeUndefined(); + expect(rpcCtx.request.customAssets).toStrictEqual([MOCK_TOKEN_POLYGON]); const finalCtx = next.mock.calls[0][0]; expect(finalCtx.response.errors).toStrictEqual({}); @@ -377,7 +443,7 @@ describe('RpcFallbackMiddleware', () => { ]); }); - it('recovers both errored chains and unprocessed assets in separate RPC calls', async () => { + it('recovers an errored chain and an unprocessed pin on another chain in one RPC call', async () => { const rpcResponse: DataResponse = { assetsBalance: { [MOCK_ACCOUNT_ID]: { @@ -399,14 +465,12 @@ describe('RpcFallbackMiddleware', () => { await mw.assetsMiddleware(ctx, next); - // One call for the errored chain (whole chain), one scoped call for the pin. - expect(rpcMw).toHaveBeenCalledTimes(2); - const chainCall = rpcMw.mock.calls[0][0]; - expect(chainCall.request.chainIds).toStrictEqual(['eip155:56']); - expect(chainCall.request.customAssets).toBeUndefined(); - const assetCall = rpcMw.mock.calls[1][0]; - expect(assetCall.request.chainIds).toStrictEqual(['eip155:1']); - expect(assetCall.request.customAssets).toStrictEqual([MOCK_TOKEN_MAINNET]); + expect(rpcMw).toHaveBeenCalledTimes(1); + const [rpcCtx] = rpcMw.mock.calls[0]; + expect(new Set(rpcCtx.request.chainIds)).toStrictEqual( + new Set(['eip155:56', 'eip155:1']), + ); + expect(rpcCtx.request.customAssets).toStrictEqual([MOCK_TOKEN_MAINNET]); const finalCtx = next.mock.calls[0][0]; expect(finalCtx.response.errors).toStrictEqual({}); diff --git a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts index e9b94773d04..ec1c9f91f4a 100644 --- a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts +++ b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts @@ -39,8 +39,10 @@ const noopNext = async (ctx: Context): Promise => ctx; * Accounts API v5: chains in `response.errors`, plus EVM assets tracked in * state that this response left empty (the API omits tokens it does not index). * - * Accounts API v6: chains in `response.errors`, plus pins in - * `response.unprocessedCustomAssets`. + * Accounts API v6: one RPC retry. `customAssets` is the union of + * `response.unprocessedCustomAssets` (trust the API) and every tracked + * asset on chains in `response.errors` (so a `full` update cannot drop + * imported or previously detected tokens on a failed chain). */ export class RpcFallbackMiddleware { readonly name = CONTROLLER_NAME; @@ -72,29 +74,65 @@ export class RpcFallbackMiddleware { const erroredChains = new Set( Object.keys(ctx.response.errors ?? {}) as ChainId[], ); - const unprocessedCustomAssets = [ + const unprocessedAssetsPins = [ ...new Set(ctx.response.unprocessedCustomAssets ?? []), ]; + const assetsOnErroredChains = collectTrackedAssetsOnChains( + ctx, + erroredChains, + ); + const customAssets = [ + ...new Set([...unprocessedAssetsPins, ...assetsOnErroredChains]), + ]; + const chainsToFetch = [ + ...new Set([ + ...ctx.request.chainIds.filter((id) => erroredChains.has(id)), + ...customAssets.map((assetId) => chainIdOfAsset(assetId)), + ]), + ]; - if (erroredChains.size === 0 && unprocessedCustomAssets.length === 0) { + if (chainsToFetch.length === 0) { return next(ctx); } - let merged: DataResponse = ctx.response; - - if (erroredChains.size > 0) { - merged = await this.#recoverErroredChains(ctx, merged, erroredChains); - } + const rpcRequest: DataRequest = { + ...ctx.request, + chainIds: chainsToFetch, + ...(customAssets.length > 0 ? { customAssets } : {}), + }; + const rpcResult = await this.#rpcDataSource.assetsMiddleware( + { ...ctx, request: rpcRequest, response: {} }, + noopNext, + ); - const assetsToRecover = unprocessedCustomAssets.filter( - (assetId) => !erroredChains.has(chainIdOfAsset(assetId)), + const rpcFailedChains = new Set( + Object.keys(rpcResult.response.errors ?? {}) as ChainId[], + ); + const rpcAssetsBalance = filterOutChainBalances( + rpcResult.response.assetsBalance, + rpcFailedChains, ); - if (assetsToRecover.length > 0) { - merged = await this.#recoverUnprocessedAssets( - ctx, - merged, - assetsToRecover, - ); + + let merged = mergeDataResponses([ + ctx.response, + { + ...rpcResult.response, + assetsBalance: rpcAssetsBalance, + }, + ]); + + if (merged.errors && rpcAssetsBalance) { + const chainsRecoveredByRpc = new Set(); + for (const accountBalances of Object.values(rpcAssetsBalance)) { + for (const assetId of Object.keys(accountBalances)) { + chainsRecoveredByRpc.add(assetId.split('/')[0]); + } + } + for (const chainId of erroredChains) { + if (chainsRecoveredByRpc.has(chainId)) { + delete merged.errors[chainId]; + } + } } merged = clearRecoveredAssetIds(merged); @@ -181,85 +219,55 @@ export class RpcFallbackMiddleware { return next({ ...ctx, response: merged }); } +} - async #recoverErroredChains( - ctx: Context, - currentResponse: DataResponse, - erroredChains: Set, - ): Promise { - log('Retrying failed chains on RPC', { chains: [...erroredChains] }); - - const chainRequest: DataRequest = { - ...ctx.request, - chainIds: ctx.request.chainIds.filter((id) => erroredChains.has(id)), - }; - const rpcResult = await this.#rpcDataSource.assetsMiddleware( - { ...ctx, request: chainRequest, response: {} }, - noopNext, - ); - - const rpcFailedChains = new Set( - Object.keys(rpcResult.response.errors ?? {}) as ChainId[], - ); - const rpcAssetsBalance = filterOutChainBalances( - rpcResult.response.assetsBalance, - rpcFailedChains, - ); +function chainIdOfAsset(assetId: Caip19AssetId): ChainId { + return assetId.split('/')[0] as ChainId; +} - const merged = mergeDataResponses([ - currentResponse, - { - ...rpcResult.response, - assetsBalance: rpcAssetsBalance, - }, - ]); +/** + * Every EVM asset already in state on `chains` — balances and pins — so an RPC + * retry of a failed chain re-reads the whole slice, not natives only. Hidden + * assets are left out, matching the `excludeAssetIds` the Accounts API is sent. + * Staking vaults belong to `StakedBalanceDataSource` and are skipped. + * + * @param ctx - Pipeline context, for request accounts and assets state. + * @param chains - Chains being recovered on RPC. + * @returns Deduplicated asset IDs on those chains. + */ +function collectTrackedAssetsOnChains( + ctx: Context, + chains: Set, +): Caip19AssetId[] { + if (chains.size === 0) { + return []; + } - if (merged.errors && rpcAssetsBalance) { - const chainsRecoveredByRpc = new Set(); - for (const accountBalances of Object.values(rpcAssetsBalance)) { - for (const assetId of Object.keys(accountBalances)) { - chainsRecoveredByRpc.add(assetId.split('/')[0]); - } - } - for (const chainId of erroredChains) { - if (chainsRecoveredByRpc.has(chainId)) { - delete merged.errors[chainId]; - } + const { + assetsBalance, + customAssets: statePins, + assetPreferences, + } = ctx.getAssetsState(); + const tracked = new Set(); + + for (const { account } of ctx.request.accountsWithSupportedChains) { + const balanceIds = Object.keys( + assetsBalance[account.id] ?? {}, + ) as Caip19AssetId[]; + const pinIds = statePins?.[account.id] ?? []; + for (const assetId of [...balanceIds, ...pinIds]) { + if ( + chains.has(chainIdOfAsset(assetId)) && + assetId.startsWith(`${KnownCaipNamespace.Eip155}:`) && + !isStakingContractAssetId(assetId) && + !assetPreferences?.[normalizeAssetId(assetId)]?.hidden + ) { + tracked.add(assetId); } } - - return merged; - } - - async #recoverUnprocessedAssets( - ctx: Context, - currentResponse: DataResponse, - assetsToRecover: Caip19AssetId[], - ): Promise { - const assetChains = [ - ...new Set(assetsToRecover.map((assetId) => chainIdOfAsset(assetId))), - ]; - - log('Recovering unprocessed pinned assets on RPC', { - assetIds: assetsToRecover, - }); - - const assetRequest: DataRequest = { - ...ctx.request, - chainIds: assetChains, - customAssets: assetsToRecover, - }; - const rpcResult = await this.#rpcDataSource.assetsMiddleware( - { ...ctx, request: assetRequest, response: {} }, - noopNext, - ); - - return mergeDataResponses([currentResponse, rpcResult.response]); } -} -function chainIdOfAsset(assetId: Caip19AssetId): ChainId { - return assetId.split('/')[0] as ChainId; + return [...tracked]; } function clearRecoveredAssetIds(response: DataResponse): DataResponse { From 2a52b799b4ee822168b516c1e7c7b7bbc92a166c Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 23 Sep 2026 00:32:22 +0200 Subject: [PATCH 14/30] fix v6 force update skips RPC custom tokens --- packages/assets-controller/CHANGELOG.md | 3 +- .../assets-controller/src/AssetsController.ts | 1 + .../src/data-sources/RpcDataSource.test.ts | 179 +++++++++++++++++- .../src/data-sources/RpcDataSource.ts | 75 +++++--- ...c-spam-token-filtering.integration.test.ts | 1 + 5 files changed, 227 insertions(+), 32 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a2ee53ae712..c3e68cd27be 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `'update'` from `AssetsUpdateMode`; use `'full'` (Accounts API v6 snapshot) or `'merge'` ([#9651](https://github.com/MetaMask/core/pull/9651)) - **BREAKING:** Remove the `updateMode` option from `AssetsController.getAssets`; the data source now sets it on its response ([#9651](https://github.com/MetaMask/core/pull/9651)) -- **BREAKING:** Require `getAssetsState` in `AccountsApiDataSourceOptions` ([#9651](https://github.com/MetaMask/core/pull/9651)) +- **BREAKING:** Require `getAssetsState` in `AccountsApiDataSourceOptions` and `RpcDataSourceOptions` ([#9651](https://github.com/MetaMask/core/pull/9651)) - Pass `() => this.state` from `AssetsController` - When `assetsAccountsApiV6` is enabled, Accounts API v6 reads pins and hides from state and sends them as `includeAssetIds` / `excludeAssetIds`, then applies the response with `updateMode: 'full'` ([#9651](https://github.com/MetaMask/core/pull/9651)) - `hideAsset` and `unhideAsset` now re-evaluate subscriptions, so live polls start and stop excluding the asset immediately ([#9651](https://github.com/MetaMask/core/pull/9651)) @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `RpcDataSource.fetch` now falls back to the account's visible `customAssets` in state when a request carries no `customAssets` scope, matching `AccountsApiDataSource`, so a force refresh picks up pinned tokens on RPC-only chains and when basic functionality is off instead of leaving them until the next poll ([#9651](https://github.com/MetaMask/core/pull/9651)) - Treat the `assetsAccountsApiV6` remote feature flag as enabled when it is `true`, instead of reading a nested `{ value }` object ([#9651](https://github.com/MetaMask/core/pull/9651)) - Keep default tracked assets (mUSD) at a zero balance when an Accounts API v6 `full` update omits them, so a force refresh no longer drops them from the token list ([#9651](https://github.com/MetaMask/core/pull/9651)) - Skip `#updateState` assignments for metadata, balances, and prices that are deep-equal to what's already in state, so Immer no longer emits a no-op `stateChange` (and a full state persist) on every poll that repeats unchanged data ([#10260](https://github.com/MetaMask/core/pull/10260)) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 1c6a58de179..91b7883c438 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1049,6 +1049,7 @@ export class AssetsController extends BaseController< }); this.#rpcDataSource = new RpcDataSource({ messenger: this.messenger, + getAssetsState: (): AssetsControllerStateInternal => this.state, onActiveChainsUpdated: this.#onActiveChainsUpdated, getNativeAssetForChain: (chainId: ChainId): Caip19AssetId => this.#getNativeAssetMap()[chainId] ?? diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 74063c8d6d2..74474c71531 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -246,6 +246,19 @@ async function withController( return isNative ? 'native' : 'erc20'; }, ...options, + getAssetsState: + options.getAssetsState ?? + ((): AssetsControllerState => { + try { + return ( + (actionHandlerOverrides?.['AssetsController:getState']?.() as + | AssetsControllerState + | undefined) ?? getDefaultAssetsControllerState() + ); + } catch { + return getDefaultAssetsControllerState(); + } + }), }); try { @@ -296,6 +309,7 @@ describe('createRpcDataSource', () => { const { assetsControllerMessenger } = createMockMessengers(); const source = createRpcDataSource({ messenger: assetsControllerMessenger, + getAssetsState: getDefaultAssetsControllerState, onActiveChainsUpdated: jest.fn(), getNativeAssetForChain: jest.fn(), getAssetType: jest.fn(), @@ -811,7 +825,7 @@ describe('RpcDataSource', () => { ); }); - it('handles AssetsController:getState throw in metadata lookup', async () => { + it('handles AssetsController:getState throw without failing fetch metadata', async () => { await withController( { actionHandlerOverrides: { @@ -918,6 +932,168 @@ describe('RpcDataSource', () => { fetchSpy.mockRestore(); }); + + it('fetches the account custom assets in state when the request carries none', async () => { + const pinnedAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [pinnedAsset] }, + }), + }, + }, + async ({ controller }) => { + await controller.fetch(createDataRequest()); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: pinnedAsset, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], + ); + }, + ); + + fetchSpy.mockRestore(); + }); + + it('fetches only the request custom assets when the request is scoped', async () => { + const requestedAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const otherPinnedAsset = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { + [MOCK_ACCOUNT_ID]: [requestedAsset, otherPinnedAsset], + }, + }), + }, + }, + async ({ controller }) => { + // A scoped fetch (e.g. addCustomAsset) must not widen into the + // account's other pins — the subscription poll covers those. + await controller.fetch( + createDataRequest({ customAssets: [requestedAsset] }), + ); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ assetId: requestedAsset }), + ], + ); + }, + ); + + fetchSpy.mockRestore(); + }); + + it('skips hidden custom assets from state', async () => { + const hiddenAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [hiddenAsset] }, + assetPreferences: { [hiddenAsset]: { hidden: true } }, + }), + }, + }, + async ({ controller }) => { + await controller.fetch(createDataRequest()); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + ], + ); + }, + ); + + fetchSpy.mockRestore(); + }); + + it('does not fetch custom assets belonging to another account', async () => { + const otherAccountAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { 'other-account-id': [otherAccountAsset] }, + }), + }, + }, + async ({ controller }) => { + await controller.fetch(createDataRequest()); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + ], + ); + }, + ); + + fetchSpy.mockRestore(); + }); }); describe('detectTokens', () => { @@ -2095,6 +2271,7 @@ describe('RpcDataSource', () => { }); const controller = new RpcDataSource({ messenger: assetsControllerMessenger, + getAssetsState: getDefaultAssetsControllerState, onActiveChainsUpdated: jest.fn(), getNativeAssetForChain: jest.fn(), getAssetType: jest.fn().mockReturnValue('erc20'), diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 26cb0bb1b6e..c6d9e7fa7be 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -34,6 +34,7 @@ import type { Caip19AssetId, AssetBalance, AssetMetadata, + AssetsControllerStateInternal, DataRequest, DataResponse, Middleware, @@ -117,6 +118,11 @@ export type RpcDataSourceConfig = { export type RpcDataSourceOptions = { /** The AssetsController messenger (shared by all data sources). */ messenger: AssetsControllerMessenger; + /** + * Current AssetsController state. Used to include the account's visible + * `customAssets` on fetch, and to read metadata for converting balances. + */ + getAssetsState: () => AssetsControllerStateInternal; /** Called when active chains are updated. Pass dataSourceName so the controller knows the source. */ onActiveChainsUpdated: ( dataSourceName: string, @@ -208,6 +214,8 @@ export class RpcDataSource extends AbstractDataSource< > { readonly #messenger: AssetsControllerMessenger; + readonly #getAssetsState: () => AssetsControllerStateInternal; + readonly #onActiveChainsUpdated: ( dataSourceName: string, chains: ChainId[], @@ -252,6 +260,7 @@ export class RpcDataSource extends AbstractDataSource< constructor(options: RpcDataSourceOptions) { super(CONTROLLER_NAME, { activeChains: [] }); this.#messenger = options.messenger; + this.#getAssetsState = options.getAssetsState; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; this.#getNativeAssetForChain = options.getNativeAssetForChain; this.#getAssetType = options.getAssetType; @@ -1040,14 +1049,12 @@ export class RpcDataSource extends AbstractDataSource< assetsToFetch.push({ assetId: nativeAssetId, address: ZERO_ADDRESS }); } - if (request.customAssets) { - this.#appendRequestCustomErc20s( - assetsToFetch, - request.customAssets, - chainId, - this.#getExistingAssetsMetadata(), - ); - } + this.#appendCustomErc20s( + assetsToFetch, + request.customAssets, + accountId, + chainId, + ); try { const result = await this.#balanceFetcher.fetchBalancesForAssets( @@ -1495,37 +1502,51 @@ export class RpcDataSource extends AbstractDataSource< } /** - * Include every `request.customAssets` ERC-20 on this chain. The selected - * group has at most one EVM account, so there is no per-account ownership - * filter. + * Append the ERC-20 pins this account-chain fetch must cover. + * + * Prefers `request.customAssets` when the caller scoped the fetch (e.g. + * `addCustomAsset`). Otherwise reads the + * account's visible pins from state: RPC is their sole balance fetcher, and + * `fetch` builds its own entry list rather than going through + * `BalanceFetcher`'s state read (which polling uses), so an unscoped request + * would otherwise leave them stale until the next poll. * * @param assetsToFetch - Native/custom entries for this account-chain fetch. - * @param customAssets - Flat pin list from the data request. + * @param requestCustomAssets - Flat pin list from the data request, if scoped. + * @param accountId - Account being fetched. * @param chainId - Chain being fetched. - * @param existingMetadata - Metadata already in AssetsController state. */ - #appendRequestCustomErc20s( + #appendCustomErc20s( assetsToFetch: AssetFetchEntry[], - customAssets: Caip19AssetId[], + requestCustomAssets: Caip19AssetId[] | undefined, + accountId: string, chainId: ChainId, - existingMetadata: Record, ): void { - for (const assetId of customAssets) { + const { + assetsInfo = {}, + customAssets = {}, + assetPreferences = {}, + } = this.#getAssetsState(); + const candidates = + requestCustomAssets && requestCustomAssets.length > 0 + ? requestCustomAssets + : (customAssets[accountId] ?? []).filter( + (assetId) => !assetPreferences[normalizeAssetId(assetId)]?.hidden, + ); + + for (const assetId of candidates) { try { const parsed = parseCaipAssetType(assetId); const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + const normalizedId = normalizeAssetId(assetId); if ( assetChainId === chainId && this.#getAssetType(assetId) === 'erc20' ) { - const tokenAddress = parsed.assetReference.toLowerCase() as Address; - const normalizedId = normalizeAssetId(assetId); - const decimals = existingMetadata[normalizedId]?.decimals; - assetsToFetch.push({ assetId, - address: tokenAddress, - decimals, + address: parsed.assetReference.toLowerCase() as Address, + decimals: assetsInfo[normalizedId]?.decimals, }); } } catch { @@ -1541,13 +1562,7 @@ export class RpcDataSource extends AbstractDataSource< * @returns Record of asset IDs to their metadata. */ #getExistingAssetsMetadata(): Record { - try { - const state = this.#messenger.call('AssetsController:getState'); - return state.assetsInfo ?? {}; - } catch (error) { - log('Failed to get existing assets metadata', { error }); - return {}; - } + return this.#getAssetsState().assetsInfo ?? {}; } /** diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts index c336d73129e..e7c4ca85639 100644 --- a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts @@ -126,6 +126,7 @@ async function runPipeline( const rpcDataSource = new RpcDataSource({ messenger: assetsControllerMessenger, + getAssetsState: (): AssetsControllerStateInternal => state, onActiveChainsUpdated: jest.fn(), getNativeAssetForChain: (): Caip19AssetId => BNB_ASSET_ID, getAssetType: (assetId): 'native' | 'erc20' => From 1e64d9d58dcc0ab0aac1146ef5167a6b8ebddc75 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 23 Sep 2026 00:37:56 +0200 Subject: [PATCH 15/30] fix Detection middleware runs twice on v5 --- packages/assets-controller/src/AssetsController.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 91b7883c438..16b55917ffe 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -4191,7 +4191,6 @@ export class AssetsController extends BaseController< ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []), this.#detectionMiddleware, ]; - enrichmentSources.push(this.#detectionMiddleware); if (this.#isBasicFunctionality()) { enrichmentSources.push( createParallelMiddleware([ From 317ae9ad36a41f652856d05a17bf3144685d839b Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 23 Sep 2026 10:25:01 +0200 Subject: [PATCH 16/30] fix Scoped fetch drops other custom tokens --- packages/assets-controller/CHANGELOG.md | 2 +- .../docs/architecture/v1/CHANGES.md | 2 +- .../src/AssetsController.test.ts | 174 +++--------------- .../assets-controller/src/AssetsController.ts | 94 ++-------- .../src/data-sources/AccountsApiDataSource.ts | 4 +- .../src/data-sources/RpcDataSource.test.ts | 3 +- .../src/data-sources/RpcDataSource.ts | 4 +- packages/assets-controller/src/types.ts | 4 +- 8 files changed, 52 insertions(+), 235 deletions(-) diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index c3e68cd27be..3ae925b9724 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `customAssets` to `AssetsController.getAssets`, to scope a fetch to specific asset IDs (Accounts API v6 only) ([#9651](https://github.com/MetaMask/core/pull/9651)) - Add optional `unprocessedCustomAssets` to `DataResponse`, listing pinned asset IDs a source could not resolve so `RpcFallbackMiddleware` can recover them ([#9651](https://github.com/MetaMask/core/pull/9651)) - Add optional `isBalanceV6Enabled` to `AccountsApiDataSourceOptions` and `RpcFallbackMiddlewareOptions`, so the `assetsAccountsApiV6` flag is read once by `AssetsController` and injected ([#9651](https://github.com/MetaMask/core/pull/9651)) @@ -25,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `addCustomAsset` on the Accounts API v6 path now force-fetches the token's chain with all visible pins, instead of scoping `customAssets` to the new token, so a `full` snapshot no longer drops other pinned balances until the next poll - `RpcDataSource.fetch` now falls back to the account's visible `customAssets` in state when a request carries no `customAssets` scope, matching `AccountsApiDataSource`, so a force refresh picks up pinned tokens on RPC-only chains and when basic functionality is off instead of leaving them until the next poll ([#9651](https://github.com/MetaMask/core/pull/9651)) - Treat the `assetsAccountsApiV6` remote feature flag as enabled when it is `true`, instead of reading a nested `{ value }` object ([#9651](https://github.com/MetaMask/core/pull/9651)) - Keep default tracked assets (mUSD) at a zero balance when an Accounts API v6 `full` update omits them, so a force refresh no longer drops them from the token list ([#9651](https://github.com/MetaMask/core/pull/9651)) diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index 377052aa256..c036d5b1878 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -172,7 +172,7 @@ In other words: v5 never runs `RpcFallbackMiddleware` here, and v6 never runs | Concern | v5 | v6 | | --------------------- | ------------------------------------------ | --------------------------------------------------------------------- | -| Force-update request | `#buildForceUpdateRequestV5` | `#buildForceUpdateRequestV6` | +| Force-update request | `#buildForceUpdateRequestV5` | `#buildDataRequest` (pins read from state) | | Force-update pipeline | `#forceUpdateAssetsV5`, `#runFastFetchV5` | `#forceUpdateAssetsV6`, `#runFastFetchV6` | | Fast lane composition | `buildFastFetchSources` (graduation on) | `buildFastFetchSources` (graduation off) | | Subscribe | `#subscribeAssetsBalance` + RPC supplement | `#subscribeAssetsBalance`; Accounts API v6 include/exclude from state | diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 6e464bd582d..b539c4070dc 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -712,45 +712,53 @@ describe('AssetsController', () => { }); }); - it('fetches only the newly added asset instead of every pinned asset', async () => { + it('force-fetches the asset chain with every pinned token as includeAssetIds', async () => { // Use a valid checksummed address (DAI token address) const secondAssetId = 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; - const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; - const accountsApiMiddleware = jest.fn(async (ctx, next) => { - capturedCustomAssets.push(ctx.request.customAssets); - return next(ctx); + const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ + accounts: [], + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], }); - const middlewareGetter = jest - .spyOn( - AccountsApiDataSource.prototype, - 'assetsMiddleware', - // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. - 'get', - ) - .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV6MultiAccountBalances, + fetchV5MultiAccountBalances: jest.fn().mockResolvedValue({ + balances: [], + unprocessedNetworks: [], + }), + }, + } as unknown as ApiPlatformClient; await withController( { + queryApiClient, remoteFeatureFlags: { assetsAccountsApiV6: true }, }, async ({ controller }) => { - await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + await flushPromises(); - capturedCustomAssets.length = 0; + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + fetchV6MultiAccountBalances.mockClear(); await controller.addCustomAsset(MOCK_ACCOUNT_ID, secondAssetId); }, ); - // The fetch triggered by adding the second pin must not re-request the - // first pin — the subscription refresh covers it on the next poll. - expect(capturedCustomAssets.length).toBeGreaterThan(0); - for (const customAssets of capturedCustomAssets) { - expect(customAssets).toStrictEqual([secondAssetId]); + // The v6 snapshot is applied as `updateMode: 'full'`, so every pin on + // the chain must be requested — otherwise other custom tokens drop. + expect(fetchV6MultiAccountBalances).toHaveBeenCalled(); + for (const [, params] of fetchV6MultiAccountBalances.mock.calls) { + expect(params?.includeAssetIds ?? []).toStrictEqual( + expect.arrayContaining([MOCK_ASSET_ID, secondAssetId]), + ); } - - middlewareGetter.mockRestore(); }); it('does not overwrite an existing balance when re-adding a custom asset', async () => { @@ -1625,128 +1633,6 @@ describe('AssetsController', () => { } }); - it('uses the customAssets option instead of state-pinned assets when provided', async () => { - const pinnedToken = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - const requestedToken = - 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; - - const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; - const accountsApiMiddleware = jest.fn(async (ctx, next) => { - capturedCustomAssets.push(ctx.request.customAssets); - return next(ctx); - }); - const middlewareGetter = jest - .spyOn( - AccountsApiDataSource.prototype, - 'assetsMiddleware', - // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. - 'get', - ) - .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; - - await withController( - { - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }, - async ({ controller }) => { - await controller.addCustomAsset(MOCK_ACCOUNT_ID, pinnedToken); - await controller.addCustomAsset(MOCK_ACCOUNT_ID, requestedToken); - - capturedCustomAssets.length = 0; - await controller.getAssets([createMockInternalAccount()], { - chainIds: ['eip155:1'], - forceUpdate: true, - customAssets: [requestedToken], - }); - }, - ); - - expect(capturedCustomAssets.length).toBeGreaterThan(0); - for (const customAssets of capturedCustomAssets) { - expect(customAssets).toStrictEqual([requestedToken]); - } - - middlewareGetter.mockRestore(); - }); - - it('scopes the customAssets option to the requested chains and drops invalid IDs', async () => { - const polygonToken = - 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; - - const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; - const accountsApiMiddleware = jest.fn(async (ctx, next) => { - capturedCustomAssets.push(ctx.request.customAssets); - return next(ctx); - }); - const middlewareGetter = jest - .spyOn( - AccountsApiDataSource.prototype, - 'assetsMiddleware', - // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. - 'get', - ) - .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; - - await withController( - { - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }, - async ({ controller }) => { - await controller.getAssets([createMockInternalAccount()], { - chainIds: ['eip155:1'], - forceUpdate: true, - customAssets: [polygonToken, 'not-a-caip-id' as Caip19AssetId], - }); - }, - ); - - // The off-chain pin and the unparseable ID are both dropped, leaving no - // custom assets on the request. - expect(capturedCustomAssets.length).toBeGreaterThan(0); - for (const customAssets of capturedCustomAssets) { - expect(customAssets).toBeUndefined(); - } - - middlewareGetter.mockRestore(); - }); - - it('normalizes asset IDs passed via the customAssets option', async () => { - const capturedCustomAssets: (Caip19AssetId[] | undefined)[] = []; - const accountsApiMiddleware = jest.fn(async (ctx, next) => { - capturedCustomAssets.push(ctx.request.customAssets); - return next(ctx); - }); - const middlewareGetter = jest - .spyOn( - AccountsApiDataSource.prototype, - 'assetsMiddleware', - // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. - 'get', - ) - .mockReturnValue(accountsApiMiddleware) as unknown as jest.SpyInstance; - - await withController( - { - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }, - async ({ controller }) => { - await controller.getAssets([createMockInternalAccount()], { - chainIds: ['eip155:1'], - forceUpdate: true, - customAssets: [MOCK_ASSET_ID_LOWERCASE], - }); - }, - ); - - expect(capturedCustomAssets.length).toBeGreaterThan(0); - for (const customAssets of capturedCustomAssets) { - expect(customAssets).toStrictEqual([MOCK_ASSET_ID]); - } - - middlewareGetter.mockRestore(); - }); - it('forwards user-hidden assets to the Accounts API v6 endpoint as excludeAssetIds', async () => { const fetchV6MultiAccountBalances = jest.fn().mockResolvedValue({ accounts: [], diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 16b55917ffe..b615ede647d 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1577,12 +1577,6 @@ export class AssetsController extends BaseController< bypassServerCache?: boolean; dataTypes?: DataType[]; assetsForPriceUpdate?: Caip19AssetId[]; - /** - * v6 only: pinned assets to attach instead of every pin in state. - * Ignored when `assetsAccountsApiV6` is off. Entries outside the - * requested chains are dropped. - */ - customAssets?: Caip19AssetId[]; }, ): Promise>> { const chainIds = options?.chainIds ?? [...this.#enabledChains]; @@ -1609,10 +1603,7 @@ export class AssetsController extends BaseController< await this.#forceUpdateAssetsV6({ accounts, chainIds, - request: this.#buildForceUpdateRequestV6(accounts, chainIds, { - ...requestOptions, - customAssetsOverride: options?.customAssets, - }), + request: this.#buildDataRequest(accounts, chainIds, requestOptions), pipelineTrace, }); } else { @@ -1669,60 +1660,6 @@ export class AssetsController extends BaseController< }); } - /** - * v6 force-update request. Pins and hides are read from state by the - * Accounts API unless `customAssetsOverride` scopes this fetch. - * - * @param accounts - Accounts in this fetch. - * @param chainIds - Chains in this fetch. - * @param requestOptions - Shared force-update request fields and optional pin scope. - * @param requestOptions.assetTypes - Asset types to fetch. - * @param requestOptions.dataTypes - Data types to fetch. - * @param requestOptions.forceUpdate - Always `true` to bypass caches. - * @param requestOptions.bypassServerCache - Also bypass server-side HTTP caches. - * @param requestOptions.assetsForPriceUpdate - Assets to refresh prices for. - * @param requestOptions.customAssetsOverride - When set, fetch only these pins. - * @returns The v6 data request. - */ - #buildForceUpdateRequestV6( - accounts: InternalAccount[], - chainIds: ChainId[], - requestOptions: { - assetTypes: AssetType[]; - dataTypes: DataType[]; - forceUpdate: true; - bypassServerCache?: boolean; - assetsForPriceUpdate?: Caip19AssetId[]; - customAssetsOverride?: Caip19AssetId[]; - }, - ): DataRequest { - const requestedChains = new Set(chainIds); - const customAssetsSet = new Set(); - for (const assetId of requestOptions.customAssetsOverride ?? []) { - try { - const normalizedAssetId = normalizeAssetId(assetId); - if ( - requestedChains.has(parseCaipAssetType(normalizedAssetId).chainId) && - !this.state.assetPreferences[normalizedAssetId]?.hidden - ) { - customAssetsSet.add(normalizedAssetId); - } - } catch { - // Skip unparseable asset IDs - } - } - const customAssets = [...customAssetsSet]; - - return this.#buildDataRequest(accounts, chainIds, { - assetTypes: requestOptions.assetTypes, - dataTypes: requestOptions.dataTypes, - forceUpdate: requestOptions.forceUpdate, - bypassServerCache: requestOptions.bypassServerCache, - assetsForPriceUpdate: requestOptions.assetsForPriceUpdate, - customAssets: customAssets.length > 0 ? customAssets : undefined, - }); - } - async #forceUpdateAssetsV5({ accounts, chainIds, @@ -2295,7 +2232,8 @@ export class AssetsController extends BaseController< /** * Add a custom asset for an account. * Custom assets are included in subscription and fetch operations. - * Adding a custom asset also unhides it if it was previously hidden. + * Adding a custom asset also unhides it if it was previously hidden, + * and force-fetches that asset's chain (including every visible pin). * * When `pendingMetadata` is provided (e.g. from the extension's pending-tokens * flow), the token metadata is persisted immediately into `assetsInfo` so the @@ -2374,22 +2312,16 @@ export class AssetsController extends BaseController< const account = this.#getSelectedAccounts().find((a) => a.id === accountId); if (account) { const chainId = extractChainId(normalizedAssetId); - if (this.#isBalanceV6Enabled()) { - await this.getAssets([account], { - chainIds: [chainId], - dataTypes: ['balance', 'metadata', 'price'], - assetTypes: ['fungible'], - forceUpdate: true, - customAssets: [normalizedAssetId], - }); - } else { - await this.getAssets([account], { - chainIds: [chainId], - dataTypes: ['balance', 'metadata', 'price'], - assetTypes: ['fungible'], - forceUpdate: true, - }); - } + // Same force-update on both paths: the token's chain, every pin from + // state. v5 already did this (merge + all pins on the request). v6 + // must too, because it applies `updateMode: 'full'` and reads pins + // as `includeAssetIds` — a single-token override would wipe others. + await this.getAssets([account], { + chainIds: [chainId], + dataTypes: ['balance', 'metadata', 'price'], + assetTypes: ['fungible'], + forceUpdate: true, + }); } // Re-evaluate subscriptions so polls pick up the new pin. diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 370010cd916..8baad6acafb 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -558,8 +558,8 @@ export class AccountsApiDataSource extends AbstractDataSource< * endpoint as `includeAssetIds`; malformed IDs are skipped and hidden * assets are left out (a hide wins). * - * Prefers `request.customAssets` when the caller scoped the fetch (e.g. - * `addCustomAsset`). Otherwise reads visible pins from controller state. + * Prefers `request.customAssets` when the caller scoped the fetch. + * Otherwise reads visible pins from controller state. * * @param request - The data request (optional `customAssets` scope). * @param chainsToFetch - Chains being requested this fetch. diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 74474c71531..cee00c32fba 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -996,8 +996,7 @@ describe('RpcDataSource', () => { }, }, async ({ controller }) => { - // A scoped fetch (e.g. addCustomAsset) must not widen into the - // account's other pins — the subscription poll covers those. + // A scoped fetch must not widen into the account's other pins. await controller.fetch( createDataRequest({ customAssets: [requestedAsset] }), ); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index c6d9e7fa7be..0b45a7a6c6c 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -1504,8 +1504,8 @@ export class RpcDataSource extends AbstractDataSource< /** * Append the ERC-20 pins this account-chain fetch must cover. * - * Prefers `request.customAssets` when the caller scoped the fetch (e.g. - * `addCustomAsset`). Otherwise reads the + * Prefers `request.customAssets` when the caller scoped the fetch. + * Otherwise reads the * account's visible pins from state: RPC is their sole balance fetcher, and * `fetch` builds its own entry list rather than going through * `BalanceFetcher`'s state read (which polling uses), so an unscoped request diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 8d715d6dc35..2bc2e7e8948 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -344,8 +344,8 @@ export type DataRequest = { dataTypes: DataType[]; /** * Optional scoped CAIP-19 asset IDs for this fetch (not the full pin list). - * Used by `getAssets({ customAssets })`, `addCustomAsset`, and RPC fallback - * recovery. When omitted, v6 sources read pins from controller state. + * Used by RPC fallback recovery and the v5 force-update pin list. When + * omitted, v6 sources read pins from controller state. */ customAssets?: Caip19AssetId[]; /** From a72bafcba0240781318149e9a8cf1b1be5052340 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 23 Sep 2026 16:32:26 +0200 Subject: [PATCH 17/30] full snapshot updates --- packages/assets-controller/CHANGELOG.md | 17 +- .../docs/architecture/v1/CHANGES.md | 227 ++++-- .../src/AssetsController.test.ts | 528 ++++++++++--- .../assets-controller/src/AssetsController.ts | 272 ++++--- .../AccountsApiDataSource.test.ts | 412 ++++++++-- .../src/data-sources/AccountsApiDataSource.ts | 562 +++++++------- .../src/data-sources/RpcDataSource.test.ts | 399 ++++++++-- .../src/data-sources/RpcDataSource.ts | 630 +++++++++++---- .../src/data-sources/SnapDataSource.test.ts | 110 ++- .../src/data-sources/SnapDataSource.ts | 152 ++++ .../services/BalanceFetcher.test.ts | 129 +++- .../services/BalanceFetcher.ts | 85 +- .../src/middlewares/ParallelMiddleware.ts | 11 - .../middlewares/RpcFallbackMiddleware.test.ts | 724 +++++++++++++----- .../src/middlewares/RpcFallbackMiddleware.ts | 198 ++--- ...c-spam-token-filtering.integration.test.ts | 16 + .../src/pipeline/buildFastFetchSources.ts | 4 +- packages/assets-controller/src/types.ts | 18 +- .../src/utils/assetVisibility.test.ts | 159 ++++ .../src/utils/assetVisibility.ts | 123 +++ .../src/utils/filterFailedChainBalances.ts | 38 + 21 files changed, 3684 insertions(+), 1130 deletions(-) create mode 100644 packages/assets-controller/src/utils/assetVisibility.test.ts create mode 100644 packages/assets-controller/src/utils/assetVisibility.ts create mode 100644 packages/assets-controller/src/utils/filterFailedChainBalances.ts diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 3ae925b9724..3719c56fef9 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `unprocessedCustomAssets` to `DataResponse`, listing pinned asset IDs a source could not resolve so `RpcFallbackMiddleware` can recover them ([#9651](https://github.com/MetaMask/core/pull/9651)) -- Add optional `isBalanceV6Enabled` to `AccountsApiDataSourceOptions` and `RpcFallbackMiddlewareOptions`, so the `assetsAccountsApiV6` flag is read once by `AssetsController` and injected ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Add optional `isBalanceV6Enabled` to `AccountsApiDataSourceOptions`, `RpcDataSourceOptions`, and `RpcFallbackMiddlewareOptions`, so the `assetsAccountsApiV6` flag is read once by `AssetsController` and injected ([#9651](https://github.com/MetaMask/core/pull/9651)) ### Changed @@ -18,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove the `updateMode` option from `AssetsController.getAssets`; the data source now sets it on its response ([#9651](https://github.com/MetaMask/core/pull/9651)) - **BREAKING:** Require `getAssetsState` in `AccountsApiDataSourceOptions` and `RpcDataSourceOptions` ([#9651](https://github.com/MetaMask/core/pull/9651)) - Pass `() => this.state` from `AssetsController` +- **BREAKING:** Require `getAssetVisibility` in `AccountsApiDataSourceOptions`, `SnapDataSourceOptions`, and `RpcDataSourceOptions` + - Pass the shared native/pin/default/hidden visibility resolver from `AssetsController` +- **BREAKING:** Require `isBalanceV6Enabled` in `SnapDataSourceOptions` - When `assetsAccountsApiV6` is enabled, Accounts API v6 reads pins and hides from state and sends them as `includeAssetIds` / `excludeAssetIds`, then applies the response with `updateMode: 'full'` ([#9651](https://github.com/MetaMask/core/pull/9651)) - `hideAsset` and `unhideAsset` now re-evaluate subscriptions, so live polls start and stop excluding the asset immediately ([#9651](https://github.com/MetaMask/core/pull/9651)) - Bump `@metamask/transaction-controller` from `^70.0.1` to `^70.1.0` ([#10262](https://github.com/MetaMask/core/pull/10262)) @@ -25,9 +27,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `addCustomAsset` on the Accounts API v6 path now force-fetches the token's chain with all visible pins, instead of scoping `customAssets` to the new token, so a `full` snapshot no longer drops other pinned balances until the next poll -- `RpcDataSource.fetch` now falls back to the account's visible `customAssets` in state when a request carries no `customAssets` scope, matching `AccountsApiDataSource`, so a force refresh picks up pinned tokens on RPC-only chains and when basic functionality is off instead of leaving them until the next poll ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Accounts API v6 RPC fallback retries failed chains in full (native, existing balances, pins, and default tracked assets from state) instead of scoping `request.customAssets` to individual tokens +- Accounts API v6 `includeAssetIds` is built from natives, visible pins, and default tracked assets for the requested chains (not detected ERC-20 balances or a scoped `request.customAssets` list), so `updateMode: 'full'` can replace the chain slice +- On the Accounts API v6 path, the Accounts API, Snap, RPC fallback, and RPC polling share the same native/pin/default/hidden visibility policy; hidden assets are not fetched but their existing balances remain in state ([#9651](https://github.com/MetaMask/core/pull/9651)) +- `RpcDataSource.fetch` on the Accounts API v6 path builds its asset list from the shared visibility policy (native, visible pins, default tracked assets) instead of the request's `customAssets` scope, so a force refresh picks up pinned tokens on RPC-only chains and when basic functionality is off instead of leaving them until the next poll ([#9651](https://github.com/MetaMask/core/pull/9651)) +- `RpcDataSource.fetch` and RPC subscribe balance updates stamp `updateMode: 'full'` on the Accounts API v6 path, so successful RPC chain snapshots replace state the same way as Accounts API v6 ([#9651](https://github.com/MetaMask/core/pull/9651)) +- `SnapDataSource.fetch` and subscription initial fetches stamp `updateMode: 'full'` on the Accounts API v6 path and add missing visible assets at zero, so non-EVM pins survive authoritative snapshots; Snap balance events remain merge deltas +- Treat a failed RPC `balanceOf` or an asset with unknown decimals as a chain failure on the Accounts API v6 path: discard that chain's snapshot and leave existing balances unchanged - Treat the `assetsAccountsApiV6` remote feature flag as enabled when it is `true`, instead of reading a nested `{ value }` object ([#9651](https://github.com/MetaMask/core/pull/9651)) -- Keep default tracked assets (mUSD) at a zero balance when an Accounts API v6 `full` update omits them, so a force refresh no longer drops them from the token list ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Apply an Accounts API v6 `full` update as the complete asset list for the chains it covers, only carrying over assets the source was not asked about (hidden assets and staking positions); natives, pins, and default tracked assets such as mUSD survive a force refresh because v6 requests them as `includeAssetIds` ([#9651](https://github.com/MetaMask/core/pull/9651)) +- Apply Accounts API and RPC balance responses atomically per chain on the Accounts API v6 path: a chain that fails, or that v6 could not answer for every requested `includeAssetIds`, is reported in `errors`, contributes no balances, and leaves its existing state unchanged until the RPC fallback recovers it ([#9651](https://github.com/MetaMask/core/pull/9651)) - Skip `#updateState` assignments for metadata, balances, and prices that are deep-equal to what's already in state, so Immer no longer emits a no-op `stateChange` (and a full state persist) on every poll that repeats unchanged data ([#10260](https://github.com/MetaMask/core/pull/10260)) - `TokenDataSource` spam filtering now removes filtered assets from `assetsBalance` and `detectedAssets` using case-insensitive asset ID matching (previously only `assetsInfo` was matched case-insensitively), so spam tokens whose IDs arrive in a different case than state no longer survive in the pipeline response and persist to state ([#10172](https://github.com/MetaMask/core/pull/10172)) diff --git a/packages/assets-controller/docs/architecture/v1/CHANGES.md b/packages/assets-controller/docs/architecture/v1/CHANGES.md index c036d5b1878..426adaa70c1 100644 --- a/packages/assets-controller/docs/architecture/v1/CHANGES.md +++ b/packages/assets-controller/docs/architecture/v1/CHANGES.md @@ -1,12 +1,25 @@ -# AssetsController - Accounts API v5 and v6 side by side +# Managing the visible asset set across Accounts API v5 and v6 ## What this change is about The AssetsController collects token balances, metadata, and prices for every -account and chain, and stores the result for the UI to render. - -This release adds support for a new version of the Accounts API (**v6**) while -keeping the current one (**v5**) fully intact. Which one runs is decided at +account and chain, and stores the result for the UI to render. The hard part is +not fetching a balance — it is agreeing on **which tokens the user should see**, +and keeping that set intact as several sources write into the same state. + +The two Accounts API versions answer that question differently: + +- **v5**: the API decides what to return. The client cannot ask for a specific + token, so anything the API does not index (a pin, mUSD at zero) is patched in + afterwards — by RPC re-reads, a supplemental custom-asset poll, and custom + asset graduation. Responses are overlaid onto state (`merge`), so nothing is + ever removed and a stale token can linger. +- **v6**: the client declares the visible set up front as `includeAssetIds` / + `excludeAssetIds`, and the answer is authoritative for the chains it covers + (`full`). That removes the patch-ups, but it means every visible asset must + appear in the snapshot — otherwise the replace would drop it. + +Both paths ship together and stay fully separate. Which one runs is decided at runtime by the `assetsAccountsApiV6` remote feature flag: - flag off, missing, or unreadable: the legacy **v5** path (today's production @@ -14,7 +27,8 @@ runtime by the `assetsAccountsApiV6` remote feature flag: - `assetsAccountsApiV6: true`: the new **v6** path The flag is read in exactly one place, `AssetsController.#isBalanceV6Enabled()`, -and passed down to `AccountsApiDataSource` and `RpcFallbackMiddleware`. +and injected into `AccountsApiDataSource`, `SnapDataSource`, `RpcDataSource` +(and through it `BalanceFetcher`), and `RpcFallbackMiddleware`. ## Vocabulary used below @@ -22,11 +36,14 @@ and passed down to `AccountsApiDataSource` and `RpcFallbackMiddleware`. | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | Pinned asset (custom asset) | A token the user added manually (Manage Token flow). We always want to fetch it, even when the API does not return it on its own. | | Hidden asset | A token the user chose to hide, so it should not be fetched or shown. | +| Default tracked asset | A token the client always tracks on a chain even at zero (for example mUSD). | +| Visible asset | Native, pin, or default tracked asset that is not hidden. Staking vault IDs are not visible; a dedicated source owns them. | | Data source | Where balances come from: the Accounts API, RPC nodes, a Snap, or staking. | | Middleware | A step that enriches or repairs data after it is fetched (token detection, prices, metadata, RPC fallback). | | Fast lane | The first, quick round of fetching. Its result is written to state right away so the UI can render. | | Background lane | The slower sources that run afterwards; their results are merged in. | | Update mode `merge` vs `full` | `merge` keeps assets already in state that the response did not mention. `full` replaces the whole slice the source covers. | +| Failed chain | A chain the source could not answer in full. Its balances are dropped from the response and existing state is left unchanged. | | Basic functionality | The user setting that turns off network calls to MetaMask services. | ## Why there are two paths @@ -49,46 +66,80 @@ flowchart LR F -->|true| V6["v6 path"] ``` +## Shared visibility (v6) + +v6 sources do not take pins or hides from the request. They all call +`getAssetVisibility(accountIds, chainIds)` (`utils/assetVisibility.ts`), which +reads controller state and returns: + +- `visibleAssetIds`: natives (except chains with no native, e.g. Tempo), pins + for those accounts, and default tracked assets, minus hidden and staking IDs +- `hiddenAssetIds`: hidden preferences on those chains + +Accounts API sends those lists as `includeAssetIds` / `excludeAssetIds`. RPC +and Snap fetch the visible set and skip hidden IDs. Because a `full` snapshot +must mention every visible asset (at zero when unheld), Snap pads missing +visible IDs to `{ amount: '0' }`. RPC builds the same list from state, so a +successful chain is already complete. + +`hideAsset` / `unhideAsset` and `addCustomAsset` / `removeCustomAsset` +re-run `#subscribeAssets` so the next poll sees the new lists. +`addCustomAsset` also force-fetches that token's chain (every pin from state, +not only the new token) so a `full` snapshot cannot wipe the other pins. + ## 1. Forced refresh Triggered by `getAssets(..., { forceUpdate: true })`. Both paths have the same shape: build a request, run the fast lane, write state, then run the background -lane and merge its results. +lane. ```mermaid flowchart TB subgraph v5fast ["v5 fast lane"] direction TB - V5P["Accounts API v5 + Staked"] --> V5G[CustomAssetGraduation] --> V5F[RpcFallback] --> V5D[Detection] --> V5T["Token + Price"] --> V5S["State: merge, replaceCoveredChainBalances"] + V5P["Accounts API v5 + Staked"] --> V5G[CustomAssetGraduation] --> V5F[RpcFallback] --> V5D[Detection] --> V5T["Token + Price"] --> V5S["State: merge + replaceCoveredChainBalances"] end subgraph v6fast ["v6 fast lane"] direction TB - V6P["Accounts API v6 + Staked
includeAssetIds / excludeAssetIds"] --> V6F["RpcFallback
errored chains + unprocessedIncludeAssetIds"] --> V6D[Detection] --> V6T["Token + Price"] --> V6S["State: full where the Accounts API has coverage"] + V6P["Accounts API v6 + Staked
includeAssetIds / excludeAssetIds"] --> V6F["RpcFallback
errored chains"] --> V6D[Detection] --> V6T["Token + Price"] --> V6S["State: full for covered chains"] + end + subgraph bgv5 ["v5 background lane"] + direction TB + B5["Snap + RPC
updateMode merge"] --> B5D[Detection] --> B5T["Token + Price"] --> B5S["State: merge"] end - subgraph bg ["Background lane (same for both)"] + subgraph bgv6 ["v6 background lane"] direction TB - B1["Snap + RPC"] --> B2[Detection] --> B3["Token + Price"] --> B4["State: merge"] + B6["Snap + RPC
updateMode full"] --> B6D[Detection] --> B6T["Token + Price"] --> B6S["State: full for covered chains"] end - v5fast --> bg - v6fast --> bg + v5fast --> bgv5 + v6fast --> bgv6 ``` The requests differ as well: -- **v5** asks for every pinned asset of the requested accounts, without scoping - them to chains, and does not mention hidden assets. -- **v6** does not copy pins or hides onto the request. The Accounts API - reads them from state (`includeAssetIds` / `excludeAssetIds`). A - `customAssets` override still scopes a one-shot fetch (new pin, RPC - fallback). +- **v5** puts every pinned asset of the requested accounts on + `request.customAssets`, without scoping them to chains, and does not mention + hidden assets. RPC fallback may add stale tracked assets to that list. Fast + fetch then writes state as `merge` plus `replaceCoveredChainBalances`, which + restores custom assets omitted by the API. +- **v6** does not copy pins or hides onto the request. The Accounts API reads + visibility from state. Detected ERC-20 balances come back with the chain, so + `updateMode: 'full'` is a complete snapshot for successful chains. A chain + that is unprocessed, or that did not resolve every `includeAssetId`, is put + in `errors` and contributes no balances. RPC fallback retries those **chains** + in full from state and stamps `full`. Snap `#fetchV6` stamps `full` after + zero-filling missing visible assets. Background Snap+RPC both stamp `full`; + `mergeDataResponses` promotes `full` if any source did. When basic functionality is off, both fast lanes shrink to -`Staked -> Detection`. The background lane is RPC only in both paths. +`Staked -> Detection`. The background lane is RPC only in both paths +(`merge` on v5, `full` on v6). ## 2. Setting up live updates (subscribe) Both paths first hand each chain to the data source with the highest priority -for it. They differ in how pinned assets on those chains are covered: +for it (Accounts API, then Snap, then RPC, plus Account Activity and staking +on the chains they support). ```mermaid flowchart TB @@ -99,12 +150,14 @@ flowchart TB subgraph v5sub ["v5"] direction TB V5A["Subscribe each source with accounts + assigned chains"] - V5WS["Account Activity
updateMode merge"] - V5API["Accounts API
updateMode merge"] - V5RPC["RPC
updateMode merge"] - V5R["Extra RPC poll: customAssetsOnly
pins on chains another source already owns
updateMode merge"] + V5WS["Account Activity · merge"] + V5API["Accounts API · merge"] + V5SNAP["Snap · merge"] + V5RPC["RPC · merge"] + V5R["Extra RPC poll: customAssetsOnly
pins on chains another source already owns · merge"] V5A --> V5WS V5A --> V5API + V5A --> V5SNAP V5A --> V5RPC V5A --> V5R end @@ -112,11 +165,13 @@ flowchart TB subgraph v6sub ["v6"] direction TB V6A["Subscribe each source with accounts + assigned chains only"] - V6WS["Account Activity
ignores pins/hides
updateMode merge"] - V6API["Accounts API
includeAssetIds / excludeAssetIds from state
updateMode full"] - V6RPC["RPC
polls customAssets from state on assigned chains
updateMode merge"] + V6WS["Account Activity · event deltas · merge"] + V6API["Accounts API · visibility from state · full"] + V6SNAP["Snap · visible assets from state · full snapshot
balance events stay merge"] + V6RPC["RPC · visible assets from state · full"] V6A --> V6WS V6A --> V6API + V6A --> V6SNAP V6A --> V6RPC end @@ -126,11 +181,11 @@ flowchart TB - **v5** adds a separate RPC poll (`customAssetsOnly`) for pins that sit on a chain another source already owns. -- **v6** subscribe only assigns accounts and chains. The Accounts API reads - pins and hides from state and sends them as `includeAssetIds` / - `excludeAssetIds`. RPC polls `customAssets` from state on its assigned - chains. Account Activity ignores pins/hides. Pins the API could not resolve - are recovered by `RpcFallbackMiddleware` on that update. +- **v6** subscribe only assigns accounts and chains. There is no + `customAssetsOnly` supplement: RPC already polls the visible set on the + chains it was assigned, and Accounts API / Snap cover pins on their chains + through visibility. Account Activity events, RPC token detection, staked + balance updates, and Snap `accountBalancesUpdated` events stay `merge`. ## 3. Handling an incoming update @@ -138,53 +193,113 @@ flowchart TB flowchart TB subgraph v5upd ["v5 · #handleAssetsUpdateV5"] direction TB - V5I["Incoming update"] --> V5G["CustomAssetGraduation
Accounts API + Account Activity"] --> V5D[Detection] --> V5T["Token + Price"] --> V5S["State: merge
replaceCoveredChainBalances"] + V5I["Incoming update"] --> V5G["CustomAssetGraduation
Accounts API + Account Activity"] --> V5R["RpcFallback when source is Accounts API"] --> V5D[Detection] --> V5T["Token + Price"] --> V5S["State: applyV5
merge, honor replaceCoveredChainBalances"] end subgraph v6upd ["v6 · #handleAssetsUpdateV6"] direction TB - V6I["Incoming update"] --> V6F["RpcFallback when basic functionality is on
errored chains + unprocessedCustomAssets"] --> V6D[Detection] --> V6T["Token + Price"] --> V6S["State: full when Accounts API
merge otherwise"] + V6I["Incoming update"] --> V6F["RpcFallback when basic functionality is on
errored chains"] --> V6D[Detection] --> V6T["Token + Price"] --> V6S["State: applyV6
full replace or merge overlay"] end ``` -- **v5** (`#handleAssetsUpdateV5`): CustomAssetGraduation (for the Accounts API - and AccountActivity) -> Detection -> Token + Price -> state written with - `merge`, honoring `replaceCoveredChainBalances`. -- **v6** (`#handleAssetsUpdateV6`): RpcFallback when basic functionality is on - (for errored chains and `unprocessedCustomAssets`) -> Detection -> Token + - Price -> state written as `full` when the Accounts API marks it so. +Both paths still run the Account Activity occurrence-floor filter when basic +functionality is on. + +- **v5** (`#handleAssetsUpdateV5`): CustomAssetGraduation (Accounts API and + Account Activity) → RpcFallback when the source is Accounts API → Detection → + Token + Price → `applyV5AccountBalanceUpdate` (`effectiveAccountBalancesV5`). +- **v6** (`#handleAssetsUpdateV6`): never CustomAssetGraduation. RpcFallback + when basic functionality is on (failed chains; native + visible assets from + state, still `full`) → Detection → Token + Price → + `applyV6AccountBalanceUpdate` (`effectiveAccountBalancesV6`). + +`#updateState` picks the writer from the flag, not from `updateMode`: + +- flag off → v5 always +- flag on → v6 always. Inside v6, `updateMode: 'merge'` overlays + `{ ...previous, ...incoming }`; `updateMode: 'full'` replaces each covered + chain slice. Assets the source was never asked about survive a `full` + replace via `isUnreported` (hidden tokens, and staking vaults until Accounts + API returns ETH staked balances). + +## Failed chains + +A v6 source either contributes a complete chain snapshot or nothing for that +chain: + +- Accounts API: unprocessed networks and unresolved `includeAssetIds` go in + `errors` +- RPC: a failed `balanceOf` or unknown decimals fails the whole chain (v5 + still overlays the tokens that succeeded) +- Snap: a snap that does not own the account is skipped; remaining chains stay + on the request for the next middleware +- `filterFailedChainBalances` strips balances on failed chains before state is + written, so a partial chain cannot cover (and wipe) that slice -In other words: v5 never runs `RpcFallbackMiddleware` here, and v6 never runs -`CustomAssetGraduationMiddleware` at all. +RpcFallback then retries only `errors` keys. ## Behavior differences at a glance -| Concern | v5 | v6 | -| ------------------------- | ------------------------------------------- | ------------------------------------------------------ | -| Accounts API endpoint | `fetchV5MultiAccountBalances` | `fetchV6MultiAccountBalances` | -| Accounts API update mode | `merge` | `full` | -| Covered-chain merge | Preserve old behavior | Replace the covered chain slice | -| Pinned asset preservation | Keep custom + staked pins in the merge path | Keep `unprocessedCustomAssets` until RPC resolves them | -| Hidden assets | Not sent to the endpoint | Sent as `excludeAssetIds` | -| Token detection filter | Drop unknown tokens when detection is off | Not applied; v6 snapshot is kept in full | -| RPC token fetch | Flat `request.customAssets` for the chain | Same: one EVM account per request | +| Concern | v5 | v6 | +| ------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Accounts API endpoint | `fetchV5MultiAccountBalances` | `fetchV6MultiAccountBalances` | +| Fetch update mode | `merge` for Accounts API, Snap, and RPC | `full` for Accounts API, Snap, and RPC | +| Subscribe update mode | `merge` for every source | `full` for Accounts API, Snap snapshots, and RPC; `merge` for events (Snap, Account Activity, staking, detection) | +| State writer | `effectiveAccountBalancesV5` | `effectiveAccountBalancesV6(updateMode)` | +| Covered-chain replace | `replaceCoveredChainBalances` on force refresh; restore custom + staked | `full` replaces the covered slice; keep hidden + staking via `isUnreported` | +| Pinned assets | `request.customAssets` + merge restore | Visibility → `includeAssetIds` / Snap zero-fill / RPC fetch list | +| Hidden assets | Not sent to the endpoint | `excludeAssetIds`; skipped by RPC and Snap; prior balance kept on `full` | +| Default tracked assets | Survive only if already in state or returned | Always in visibility, so a `full` refresh keeps them at zero when unheld | +| Token detection filter | Drop unknown tokens when detection is off | Not applied; v6 snapshot is kept in full | +| RPC token list | Request `customAssets` + tracked balances | Visible natives, pins, and default tracked from state | +| RPC pin supplement | Extra `customAssetsOnly` poll | None | +| Custom-asset graduation | Fast lane and live Accounts API / Account Activity | Never | +| Failed chain | Overlay whatever tokens succeeded | Drop the chain's balances; leave existing state until RPC recovers it | + +v6 `updateMode` by source: + +| Source | Fetch / force refresh | Subscribe / live updates | +| ---------------- | --------------------- | ------------------------------------------------------------- | +| Accounts API | `full` | `full` (polls `fetch`) | +| Snap | `full` | `full` initial fetch; balance events stay `merge` | +| RPC | `full` | `full` (balance poll and tx refresh); detection stays `merge` | +| Account Activity | n/a (event-only) | `merge` | +| Staked balances | `merge` | `merge` | + +Fast-lane Accounts API `full` merged with staking `merge` still stamps `full` +(`mergeDataResponses` promotes it). Staking rows that are present in that +merged payload are written with the snapshot; if they are omitted, `isUnreported` +keeps the prior vault balance. ## Where the code lives | Concern | v5 | v6 | | --------------------- | ------------------------------------------ | --------------------------------------------------------------------- | +| Flag | `#isBalanceV6Enabled` (shared) | injected into Accounts API, Snap, RPC, BalanceFetcher, RpcFallback | +| Visibility | n/a (request `customAssets`) | `getAssetVisibility` / `#getAssetVisibility` | | Force-update request | `#buildForceUpdateRequestV5` | `#buildDataRequest` (pins read from state) | | Force-update pipeline | `#forceUpdateAssetsV5`, `#runFastFetchV5` | `#forceUpdateAssetsV6`, `#runFastFetchV6` | | Fast lane composition | `buildFastFetchSources` (graduation on) | `buildFastFetchSources` (graduation off) | -| Subscribe | `#subscribeAssetsBalance` + RPC supplement | `#subscribeAssetsBalance`; Accounts API v6 include/exclude from state | +| Subscribe | `#subscribeAssetsBalance` + RPC supplement | `#subscribeAssetsBalance` only | | Update handling | `#handleAssetsUpdateV5` | `#handleAssetsUpdateV6` | -| Balance merge | `mergeAccountBalancesV5` | `mergeAccountBalancesV6` | +| State apply | `applyV5AccountBalanceUpdate` | `applyV6AccountBalanceUpdate` | +| Balance merge | `effectiveAccountBalancesV5` | `effectiveAccountBalancesV6` | | Accounts API fetch | `#fetchV5Balances` | `#fetchV6Balances` | +| Snap fetch | `#fetchV5` | `#fetchV6` | +| RPC fetch | `#fetchV5` | `#fetchV6` | +| RPC fetch list | `#getAssetsToFetchV5` | `#getAssetsToFetchV6` | | RPC fallback | `#recoverV5` | `#recoverV6` | +| Failed-chain filter | `filterFailedChainBalances` (shared) | same helper; v6 uses it to keep snapshots atomic | ## Deleting v5 after rollout Once v6 is accepted as the only behavior: -1. Remove the v5 methods and every `!this.#isBalanceV6Enabled()` branch. -2. Keep the v6 methods as the single orchestration path. +1. Remove the v5 methods (`#fetchV5`, `#fetchV5Balances`, `#recoverV5`, + `#forceUpdateAssetsV5`, `#handleAssetsUpdateV5`, + `applyV5AccountBalanceUpdate`, `effectiveAccountBalancesV5`, + `#subscribeRpcCustomAssetsSupplement`) and every + `!this.#isBalanceV6Enabled()` branch. +2. Keep the v6 methods as the single orchestration path. Drop + `isStakingContractAssetId` from `isUnreported` when Accounts API returns + ETH staked balances. 3. Remove this document's v5/v6 comparison tables. diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index b539c4070dc..c448ab6ecc7 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -821,6 +821,99 @@ describe('AssetsController', () => { }); }); + describe('custom asset graduation', () => { + const SOLANA_ASSET_ID = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; + + it('graduates an EVM custom asset when AccountsApiDataSource reports a balance for it', async () => { + await withController(async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain( + MOCK_ASSET_ID, + ); + + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1000000' }, + }, + }, + }, + 'AccountsApiDataSource', + ); + + expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toBeUndefined(); + }); + }); + + it('graduates an EVM custom asset when AccountActivityDataSource reports a balance for it', async () => { + await withController(async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1000000' }, + }, + }, + }, + 'AccountActivityDataSource', + ); + + expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toBeUndefined(); + }); + }); + + it('does not graduate when RpcDataSource reports a balance for a custom asset', async () => { + await withController(async ({ controller }) => { + await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1000000' }, + }, + }, + }, + 'RpcDataSource', + ); + + expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain( + MOCK_ASSET_ID, + ); + }); + }); + + it('does not graduate a non-EVM (Solana) custom asset', async () => { + await withController( + { + state: { + customAssets: { [MOCK_ACCOUNT_ID]: [SOLANA_ASSET_ID] }, + }, + }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [SOLANA_ASSET_ID]: { amount: '1000000' }, + }, + }, + }, + 'AccountsApiDataSource', + ); + + expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain( + SOLANA_ASSET_ID, + ); + }, + ); + }); + }); + describe('getCustomAssets', () => { it('returns empty array for account with no custom assets', async () => { await withController(({ controller }) => { @@ -1722,7 +1815,11 @@ describe('AssetsController', () => { expect(fetchV6MultiAccountBalances).toHaveBeenCalledWith( expect.any(Array), - undefined, + expect.objectContaining({ + includeAssetIds: expect.arrayContaining([ + MOCK_DEFAULT_TRACKED_ASSET_ID, + ]), + }), expect.objectContaining({ bypassServerCache: true }), ); }, @@ -1831,9 +1928,15 @@ describe('AssetsController', () => { }, } as unknown as ApiPlatformClient; - const rpcRequestCustomAssets: (Caip19AssetId[] | undefined)[] = []; + const rpcRequests: { + chainIds: ChainId[]; + customAssets: Caip19AssetId[] | undefined; + }[] = []; const rpcMiddleware = jest.fn(async (ctx, next) => { - rpcRequestCustomAssets.push(ctx.request.customAssets); + rpcRequests.push({ + chainIds: ctx.request.chainIds, + customAssets: ctx.request.customAssets, + }); return next(ctx); }); const rpcMiddlewareGetter = jest @@ -1863,13 +1966,14 @@ describe('AssetsController', () => { }, ); - // RpcFallbackMiddleware runs in the awaited fast lane, scoped to the - // pins Accounts API listed in unprocessedIncludeAssetIds. + // RpcFallbackMiddleware retries the pin's chain; RPC reads native, + // balances, and pins from state rather than a scoped customAssets list. expect(rpcMiddleware).toHaveBeenCalled(); expect( - rpcRequestCustomAssets.some((customAssets) => - customAssets?.includes(customToken), - ), + rpcRequests.some(({ chainIds }) => chainIds.includes('eip155:1')), + ).toBe(true); + expect( + rpcRequests.every(({ customAssets }) => customAssets === undefined), ).toBe(true); rpcMiddlewareGetter.mockRestore(); @@ -2462,47 +2566,6 @@ describe('AssetsController', () => { rpcMiddlewareGetter.mockRestore(); }); - it('falls back to RPC for pinned assets a subscription update reported as unprocessed (unprocessedCustomAssets)', async () => { - const rpcMiddlewareGetter = jest.spyOn( - RpcDataSource.prototype, - 'assetsMiddleware', - // @ts-expect-error -- Jest supports `get` for accessor spies; `Spyable` typings omit prototype getters. - 'get', - ) as unknown as jest.SpyInstance; - - const customToken = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - const request: DataRequest = { - accountsWithSupportedChains: [], - chainIds: ['eip155:1'], - dataTypes: ['balance'], - customAssets: [customToken], - }; - - await withController( - { remoteFeatureFlags: { assetsAccountsApiV6: true } }, - async ({ controller }) => { - rpcMiddlewareGetter.mockClear(); - - await controller.handleAssetsUpdate( - { - assetsBalance: {}, - unprocessedCustomAssets: [customToken], - }, - 'AccountsApiDataSource', - request, - ); - }, - ); - - // The asset-axis signal also pulls the RPC data source middleware for an - // asset-scoped recovery. - expect(rpcMiddlewareGetter).toHaveBeenCalled(); - - rpcMiddlewareGetter.mockRestore(); - }); - it('does not run the RPC fallback when a subscription update has no errored chains', async () => { const rpcMiddlewareGetter = jest.spyOn( RpcDataSource.prototype, @@ -3119,7 +3182,7 @@ describe('AssetsController', () => { }); }); - it('replaces covered-chain balances in full mode, including custom assets', async () => { + it('drops pins a full update omits, since v6 requests them as includeAssetIds', async () => { const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { @@ -3132,136 +3195,202 @@ describe('AssetsController', () => { }, }; - await withController({ state: initialState }, async ({ controller }) => { - await controller.handleAssetsUpdate( - { - updateMode: 'full', - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + await withController( + { + state: initialState, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, }, }, - }, - 'TestSource', - ); + 'TestSource', + ); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], - ).toBeUndefined(); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ - MOCK_NATIVE_ASSET_ID - ], - ).toStrictEqual({ amount: '2' }); - }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '2' }); + }, + ); }); - it('keeps default tracked assets at zero when a full update omits them', async () => { - // Regression: a force refresh (e.g. "Refresh list") replaces the chain - // slice, so mUSD was dropped from the token list for accounts holding - // none. mergeAccountBalancesV6 re-asserts default tracked assets. + it('retains hidden balances without re-fetching or displaying them in full mode', async () => { const initialState: Partial = { + assetsInfo: { + [MOCK_ASSET_ID]: { + type: 'erc20', + symbol: 'TEST', + name: 'Hidden Test Token', + decimals: 18, + }, + }, assetsBalance: { [MOCK_ACCOUNT_ID]: { - [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '0' }, + [MOCK_ASSET_ID]: { amount: '1' }, [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, }, }, + customAssets: { + [MOCK_ACCOUNT_ID]: [MOCK_ASSET_ID], + }, + assetPreferences: { + [MOCK_ASSET_ID]: { hidden: true }, + }, }; - await withController({ state: initialState }, async ({ controller }) => { - await controller.handleAssetsUpdate( - { - updateMode: 'full', - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + await withController( + { + state: initialState, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, }, }, - }, - 'TestSource', - ); + 'TestSource', + ); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ - MOCK_DEFAULT_TRACKED_ASSET_ID - ], - ).toStrictEqual({ amount: '0' }); - }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ amount: '1' }); + expect( + controller.getAccountAssetByID(MOCK_ACCOUNT_ID, MOCK_ASSET_ID), + ).toBeUndefined(); + }, + ); }); - it('does not overwrite a default tracked asset balance returned by a full update', async () => { + it('keeps default tracked assets a full update returns at zero', async () => { + // A force refresh (e.g. "Refresh list") replaces the chain slice, so + // mUSD only survives for accounts holding none because v6 requests it + // as an includeAssetId and the response carries it back at zero. const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { - [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '5' }, + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '0' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, }, }, }; - await withController({ state: initialState }, async ({ controller }) => { - await controller.handleAssetsUpdate( - { - updateMode: 'full', - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '7' }, + await withController( + { + state: initialState, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '0' }, + }, }, }, - }, - 'TestSource', - ); + 'TestSource', + ); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ - MOCK_DEFAULT_TRACKED_ASSET_ID - ], - ).toStrictEqual({ amount: '7' }); - }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_DEFAULT_TRACKED_ASSET_ID + ], + ).toStrictEqual({ amount: '0' }); + }, + ); }); - it('preserves unprocessed custom assets that RPC fallback could not recover', async () => { + it('does not overwrite a default tracked asset balance returned by a full update', async () => { const initialState: Partial = { assetsBalance: { [MOCK_ACCOUNT_ID]: { - [MOCK_ASSET_ID]: { amount: '1' }, - [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '5' }, }, }, - customAssets: { - [MOCK_ACCOUNT_ID]: [MOCK_ASSET_ID], - }, }; await withController( - { state: initialState, isBasicFunctionality: () => false }, + { + state: initialState, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, async ({ controller }) => { await controller.handleAssetsUpdate( { updateMode: 'full', - unprocessedCustomAssets: [MOCK_ASSET_ID], assetsBalance: { [MOCK_ACCOUNT_ID]: { - [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + [MOCK_DEFAULT_TRACKED_ASSET_ID]: { amount: '7' }, }, }, }, 'TestSource', ); - expect( - controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], - ).toStrictEqual({ amount: '1' }); expect( controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ - MOCK_NATIVE_ASSET_ID + MOCK_DEFAULT_TRACKED_ASSET_ID ], - ).toStrictEqual({ amount: '2' }); + ).toStrictEqual({ amount: '7' }); }, ); }); - it('preserves existing staked balances when a full update omits them', async () => { + it('replaces covered-chain balances in merge mode when replaceCoveredChainBalances is set', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + }, + }, + }; + + await withController({ state: initialState }, async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'merge', + replaceCoveredChainBalances: true, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '2' }); + }); + }); + + + it('preserves existing staked balances when replaceCoveredChainBalances omits them', async () => { const stakingAssetId = 'eip155:1/erc20:0x4FEF9D741011476750A243aC70b9789a63dd47Df' as Caip19AssetId; const initialState: Partial = { @@ -3277,7 +3406,8 @@ describe('AssetsController', () => { await withController({ state: initialState }, async ({ controller }) => { await controller.handleAssetsUpdate( { - updateMode: 'full', + updateMode: 'merge', + replaceCoveredChainBalances: true, assetsBalance: { [MOCK_ACCOUNT_ID]: { [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, @@ -3301,6 +3431,53 @@ describe('AssetsController', () => { }); }); + + it('preserves existing staked balances when a full update omits them', async () => { + const stakingAssetId = + 'eip155:1/erc20:0x4FEF9D741011476750A243aC70b9789a63dd47Df' as Caip19AssetId; + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '1' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + [stakingAssetId]: { amount: '1.5' }, + }, + }, + }; + + await withController( + { + state: initialState, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'full', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '2' }, + }, + }, + }, + 'AccountsApiDataSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '2' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[stakingAssetId], + ).toStrictEqual({ amount: '1.5' }); + }, + ); + }); + it('replaces state when full update has authoritative data', async () => { const initialState: Partial = { assetsBalance: { @@ -3370,6 +3547,46 @@ describe('AssetsController', () => { }); }); + it('overlays balances without removing tokens when v6 merge mode is used', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '6.185173' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.000390285791392' }, + }, + }, + }; + + await withController( + { + state: initialState, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, + async ({ controller }) => { + await controller.handleAssetsUpdate( + { + updateMode: 'merge', + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_NATIVE_ASSET_ID]: { amount: '0.000389261286724' }, + }, + }, + }, + 'TestSource', + ); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toStrictEqual({ amount: '6.185173' }); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '0.000389261286724' }); + }, + ); + }); + it('seeds missing metadata in merge mode for RPC-only chains', async () => { const avaxNative = 'eip155:43114/slip44:9005' as Caip19AssetId; @@ -4212,6 +4429,69 @@ describe('AssetsController', () => { ); }); + it('replaces pre-lock balances on unlock via merge with covered-chain replacement', async () => { + const fetchV5MultiAccountBalances = jest.fn().mockResolvedValue({ + balances: [ + { + accountId: 'eip155:1:0x1234567890123456789012345678901234567890', + assetId: MOCK_NATIVE_ASSET_ID, + balance: '2', + }, + ], + unprocessedNetworks: [], + }); + + const queryApiClient = { + ...createMockQueryApiClient(), + accounts: { + fetchV2SupportedNetworks: jest.fn().mockResolvedValue({ + fullSupport: [1], + partialSupport: [], + }), + fetchV5MultiAccountBalances, + }, + } as unknown as ApiPlatformClient; + + await withController( + { + clientControllerState: { isUiOpen: true }, + queryApiClient, + state: { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [MOCK_ASSET_ID]: { amount: '100' }, + [MOCK_NATIVE_ASSET_ID]: { amount: '0.5' }, + }, + }, + }, + }, + async ({ controller, messenger }) => { + ( + messenger as unknown as { + publish: (topic: string, payload?: unknown) => void; + } + ).publish('ClientController:stateChanged', { isUiOpen: true }); + messenger.publish('KeyringController:unlock'); + (messenger.publish as CallableFunction)( + 'AccountTreeController:initialized', + {}, + ); + + await flushPromises(); + + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID], + ).toBeUndefined(); + expect( + controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[ + MOCK_NATIVE_ASSET_ID + ], + ).toStrictEqual({ amount: '2' }); + }, + ); + }); + + it('invokes first-init fetch trace only once per session until lock', async () => { const traceMock = jest .fn() diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index b615ede647d..d2d7de60293 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -109,7 +109,6 @@ import { DEFAULT_TRACKED_ASSETS_BY_CHAIN, buildDefaultAssetsInfo, getDefaultAssetMetadata, - getDefaultTrackedAssetsForChain, } from './defaults.js'; import { projectLogger, createModuleLogger } from './logger.js'; import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware.js'; @@ -150,11 +149,14 @@ import type { SubscriptionResponse, Asset, } from './types.js'; +import type { AssetVisibility } from './utils/assetVisibility.js'; +import { getAssetVisibility } from './utils/assetVisibility.js'; import { ZERO_ADDRESS } from './utils/constants.js'; import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement.js'; import { normalizeAmountString, normalizeAssetId, + safeNormalizeAssetId, formatExchangeRatesForBridge, formatStateForTransactionPay, buildNativeAssetsFromConstant, @@ -581,20 +583,11 @@ function normalizeResponse(response: DataResponse): DataResponse { normalized.replaceCoveredChainBalances = true; } - if ( - response.unprocessedCustomAssets && - response.unprocessedCustomAssets.length > 0 - ) { - normalized.unprocessedCustomAssets = response.unprocessedCustomAssets.map( - (assetId) => normalizeAssetId(assetId), - ); - } - return normalized; } /** - * Merge account balances from a data-source response into prior state. + * Compute the effective account balances from a data-source response and prior state. * * @param previousBalances - Balances already in state for this account. * @param accountBalances - Balances from the incoming response. @@ -602,9 +595,9 @@ function normalizeResponse(response: DataResponse): DataResponse { * on the Accounts API v5 path (`replaceCoveredChainBalances`). * @param replaceCoveredChains - When true (v5 force refresh), drop prior balances * on chains present in the response before applying it, then restore custom assets. - * @returns The merged balance map for the account. + * @returns The effective balance map for the account. */ -function mergeAccountBalancesV5( +function effectiveAccountBalancesV5( previousBalances: Record, accountBalances: Record, customAssetIds: Caip19AssetId[], @@ -650,63 +643,49 @@ function mergeAccountBalancesV5( } /** - * Apply an Accounts API v6 authoritative chain snapshot. + * Compute the effective account balances for an Accounts API v6 update. + * + * `merge` overlays the incoming balances on prior state (Account Activity, + * Snap events, staking). `full` replaces each covered chain slice. v6 asks + * for natives, pins, and default tracked assets through `includeAssetIds` + * and either returns them (at zero when the account holds none) or names + * them in `unprocessedIncludeAssetIds`. Only assets the source was never + * asked about survive a `full` replace — see `isUnreported`. * * @param previousBalances - Balances already in state for this account. * @param accountBalances - Balances from the incoming response. - * @param unprocessedCustomAssets - Pins that v6 and RPC could not resolve. - * @returns The merged balance map for the account. + * @param isUnreported - Whether a prior asset was outside the source's scope, + * so its absence from the response does not mean the balance is gone. + * The staking-contract branch is temporary: drop `isStakingContractAssetId` + * once Accounts API returns ETH staked balances in the v6 snapshot. Hidden + * assets still need this callback after that. + * @param updateMode - `'merge'` overlays incoming balances; `'full'` replaces + * each covered chain slice except unreported assets. + * @returns The effective balance map for the account. */ -function mergeAccountBalancesV6( +function effectiveAccountBalancesV6( previousBalances: Record, accountBalances: Record, - unprocessedCustomAssets: Caip19AssetId[] = [], + isUnreported: (assetId: Caip19AssetId) => boolean, + updateMode: AssetsUpdateMode, ): Record { + if (updateMode === 'merge') { + return { ...previousBalances, ...accountBalances }; + } + const coveredChains = new Set( Object.keys(accountBalances).map((assetId) => assetId.split('/')[0]), ); const next: Record = {}; - for (const [assetId, balance] of Object.entries(previousBalances)) { - if (!coveredChains.has(assetId.split('/')[0])) { - next[assetId] = balance; - } - } - Object.assign(next, accountBalances); - - const unprocessedSet = new Set( - unprocessedCustomAssets.map((assetId) => normalizeAssetId(assetId)), - ); - for (const [assetId, balance] of Object.entries(previousBalances)) { - if ( - unprocessedSet.has(normalizeAssetId(assetId as Caip19AssetId)) && - !Object.prototype.hasOwnProperty.call(next, assetId) - ) { - next[assetId] = balance; - } - } for (const [assetId, balance] of Object.entries(previousBalances)) { if ( - isStakingContractAssetId(assetId) && - !Object.prototype.hasOwnProperty.call(next, assetId) + !coveredChains.has(assetId.split('/')[0]) || + isUnreported(assetId as Caip19AssetId) ) { next[assetId] = balance; } } - // Default tracked assets (mUSD) are controller-managed and must render at - // zero when the account holds none. The v6 snapshot omits them in that - // case, so re-assert them on covered chains — otherwise a force refresh - // would drop them from the token list. - // TODO: seed defaults as visible pins in `customAssets` instead (skip if - // already present or `assetPreferences[assetId].hidden`), then drop this - // merge special case. - for (const chainId of coveredChains) { - for (const assetId of getDefaultTrackedAssetsForChain(chainId as ChainId)) { - if (!Object.prototype.hasOwnProperty.call(next, assetId)) { - next[assetId] = - previousBalances[assetId] ?? ({ amount: '0' } as AssetBalance); - } - } - } + Object.assign(next, accountBalances); return next; } @@ -1040,20 +1019,24 @@ export class AssetsController extends BaseController< ...accountsApiDataSourceConfig, isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), getAssetsState: (): AssetsControllerStateInternal => this.state, + getAssetVisibility: this.#getAssetVisibility.bind(this), }); this.#snapDataSource = new SnapDataSource({ messenger: this.messenger, onActiveChainsUpdated: this.#onActiveChainsUpdated, onAssetsUpdate: (response): Promise => this.handleAssetsUpdate(response, 'SnapDataSource'), + isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), + getAssetVisibility: this.#getAssetVisibility.bind(this), }); this.#rpcDataSource = new RpcDataSource({ messenger: this.messenger, getAssetsState: (): AssetsControllerStateInternal => this.state, + isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), + getAssetVisibility: this.#getAssetVisibility.bind(this), onActiveChainsUpdated: this.#onActiveChainsUpdated, getNativeAssetForChain: (chainId: ChainId): Caip19AssetId => - this.#getNativeAssetMap()[chainId] ?? - `${chainId}/erc20:${ZERO_ADDRESS}`, + this.#getNativeAssetForChain(chainId), // Share the API platform's TanStack Query client so the RPC token // detector caches/dedupes its top-token-list fetches alongside the rest // of the package's API calls. Caller-provided rpcConfig.queryClient @@ -1895,7 +1878,7 @@ export class AssetsController extends BaseController< parentContext, trace: pipelineTrace, }); - await this.#updateState({ ...response, updateMode: 'merge' }); + await this.#updateState(response); }, }).catch((error) => log('Background pipeline failed', { error })); } @@ -2618,6 +2601,34 @@ export class AssetsController extends BaseController< ); } + /** + * Canonical native CAIP-19 ID for a chain. Uses the native asset map when + * the chain is registered; otherwise falls back to the zero-address ERC-20 + * encoding used for EVM natives without a SLIP-44 id. + * + * @param chainId - CAIP-2 chain ID. + * @returns The native asset ID for the chain. + */ + #getNativeAssetForChain(chainId: ChainId): Caip19AssetId { + return ( + this.#getNativeAssetMap()[chainId] ?? + (`${chainId}/erc20:${ZERO_ADDRESS}` as Caip19AssetId) + ); + } + + #getAssetVisibility( + accountIds: AccountId[], + chainIds: ChainId[], + ): AssetVisibility { + return getAssetVisibility({ + state: this.state, + accountIds, + chainIds, + getNativeAssetForChain: (chainId) => + this.#getNativeAssetForChain(chainId), + }); + } + /** * Checks whether the given CAIP-19 asset ID represents a native asset * according to the cached native asset map, the asset namespace, and the asset reference. @@ -2732,21 +2743,10 @@ export class AssetsController extends BaseController< this.#accountsApiDataSource.getActiveChainsSync(), ); - // Chains whose pins went unresolved (`unprocessedCustomAssets`): route - // them to the slow pipeline so RPC fetches the pins. - const unprocessedCustomAssetChains = new Set( - (fastResponse.unprocessedCustomAssets ?? []).map( - (assetId) => assetId.split('/')[0] as ChainId, - ), - ); - return chainIds.filter((chainId) => { if (fastResponse.errors?.[chainId]) { return true; } - if (unprocessedCustomAssetChains.has(chainId)) { - return true; - } if (!accountsApiChains.has(chainId)) { return true; } @@ -2870,6 +2870,7 @@ export class AssetsController extends BaseController< async #updateState(response: DataResponse): Promise { const normalizedResponse = normalizeResponse(response); const mode: AssetsUpdateMode = normalizedResponse.updateMode ?? 'merge'; + const isBalanceV6Enabled = this.#isBalanceV6Enabled(); const releaseLock = await this.#controllerMutex.acquire(); @@ -2957,30 +2958,26 @@ export class AssetsController extends BaseController< } if (normalizedResponse.assetsBalance) { - for (const [accountId, accountBalances] of Object.entries( - normalizedResponse.assetsBalance, - )) { - const previousBalances = - previousState.assetsBalance[accountId] ?? {}; - + const applyV5AccountBalanceUpdate = ( + accountId: string, + accountBalances: Record, + previousBalances: Record, + ): void => { const customAssetIds = (state.customAssets as Record)[ accountId ] ?? []; - const effective = - mode === 'full' - ? mergeAccountBalancesV6( - previousBalances, - accountBalances, - normalizedResponse.unprocessedCustomAssets ?? [], - ) - : mergeAccountBalancesV5( - previousBalances, - accountBalances, - customAssetIds, - normalizedResponse.replaceCoveredChainBalances === true, - ); + const replaceCoveredChains = + mode === 'full' || + normalizedResponse.replaceCoveredChainBalances === true; + + const effectiveAccountBalances = effectiveAccountBalancesV5( + previousBalances, + accountBalances, + customAssetIds, + replaceCoveredChains, + ); // Ensure native tokens have an entry (0 if missing) for chains this account supports const account = this.#getSelectedAccounts().find( @@ -2991,14 +2988,19 @@ export class AssetsController extends BaseController< : this.#getNativeAssetIdsForEnabledChains(); for (const nativeAssetId of nativeAssetIdsForAccount) { if ( - !Object.prototype.hasOwnProperty.call(effective, nativeAssetId) + !Object.prototype.hasOwnProperty.call( + effectiveAccountBalances, + nativeAssetId, + ) ) { - effective[nativeAssetId] = + effectiveAccountBalances[nativeAssetId] = getDefaultNativeAssetBalance(nativeAssetId); } } - for (const [assetId, balance] of Object.entries(effective)) { + for (const [assetId, balance] of Object.entries( + effectiveAccountBalances, + )) { const previousBalance = previousBalances[ assetId as Caip19AssetId ] as AssetBalance | undefined; @@ -3019,7 +3021,7 @@ export class AssetsController extends BaseController< const newMetadata = (balance as FungibleAssetBalance).metadata ?? (previousBalance as FungibleAssetBalance | undefined)?.metadata; - effective[assetId] = { + effectiveAccountBalances[assetId] = { amount: newAmount, ...(newMetadata === undefined ? {} : { metadata: newMetadata }), }; @@ -3040,8 +3042,89 @@ export class AssetsController extends BaseController< } } - if (!isEqual(previousBalances, effective)) { - balances[accountId] = effective; + if (!isEqual(previousBalances, effectiveAccountBalances)) { + balances[accountId] = effectiveAccountBalances; + } + }; + + // Delete with the rest of the v6 path if `assetsAccountsApiV6` is + // rolled back; v5 keeps using `applyV5AccountBalanceUpdate`. + const applyV6AccountBalanceUpdate = ( + accountId: string, + accountBalances: Record, + previousBalances: Record, + ): void => { + // Hidden assets go out as `excludeAssetIds` and staked positions + // are owned by StakedBalanceDataSource, so neither is in scope of + // a `full` snapshot. Remove `isStakingContractAssetId` from + // `isUnreported` when Accounts API returns ETH staked balances. + const effectiveAccountBalances = effectiveAccountBalancesV6( + previousBalances, + accountBalances, + (assetId) => + state.assetPreferences[safeNormalizeAssetId(assetId)] + ?.hidden === true || isStakingContractAssetId(assetId), + mode, + ); + + for (const [assetId, balance] of Object.entries( + effectiveAccountBalances, + )) { + const previousBalance = previousBalances[ + assetId as Caip19AssetId + ] as AssetBalance | undefined; + const assetDecimals = ( + metadata[assetId] as { decimals?: number } | undefined + )?.decimals; + const newAmount = normalizeAmountString( + (balance as { amount: unknown }).amount, + assetDecimals, + ); + const newMetadata = + (balance as FungibleAssetBalance).metadata ?? + (previousBalance as FungibleAssetBalance | undefined)?.metadata; + effectiveAccountBalances[assetId] = { + amount: newAmount, + ...(newMetadata === undefined ? {} : { metadata: newMetadata }), + }; + const oldAmount = previousBalance?.amount; + // A v6 snapshot reports assets the account holds none of (its + // `includeAssetIds`) at zero. Publishing those as a change would + // emit `0 -> 0`, since a missing previous amount reads as '0'. + const isNewZero = oldAmount === undefined && newAmount === '0'; + if (oldAmount !== newAmount && !isNewZero) { + changedBalances.push({ + accountId, + assetId, + oldAmount, + newAmount, + }); + } + } + + if (!isEqual(previousBalances, effectiveAccountBalances)) { + balances[accountId] = effectiveAccountBalances; + } + }; + + for (const [accountId, accountBalances] of Object.entries( + normalizedResponse.assetsBalance, + )) { + const previousBalances = + previousState.assetsBalance[accountId] ?? {}; + + if (isBalanceV6Enabled) { + applyV6AccountBalanceUpdate( + accountId, + accountBalances, + previousBalances, + ); + } else { + applyV5AccountBalanceUpdate( + accountId, + accountBalances, + previousBalances, + ); } } } @@ -3410,8 +3493,9 @@ export class AssetsController extends BaseController< * 2. Map chains to accounts based on their scopes * 3. Split by data source (priority order) — each source gets one * subscription for the chains it was assigned (accounts + chains only). - * v6 sources read pins/hides from state themselves (Accounts API sends - * `includeAssetIds` / `excludeAssetIds`). v5 uses a separate RPC + * v6 sources read pins/hides/defaults from state themselves (Accounts API + * sends `includeAssetIds` / `excludeAssetIds`; RPC polls native, balances, + * pins, and default tracked assets). v5 uses a separate RPC * `customAssetsOnly` supplement for pins on chains another source owns. * * @param accounts - Accounts to subscribe balance updates for. diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 8c6ce69588b..21c665e3bc5 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -4,6 +4,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; +import { getDefaultTrackedAssetsForChain } from '../defaults.js'; import type { ChainId, Caip19AssetId, @@ -11,6 +12,10 @@ import type { Context, AssetsControllerStateInternal, } from '../types.js'; +import type { AssetVisibility } from '../utils/assetVisibility.js'; +import { getAssetVisibility } from '../utils/assetVisibility.js'; +import { ZERO_ADDRESS } from '../utils/constants.js'; +import { NATIVE_ASSETS } from '../utils/native-assets.js'; import { SNAPS_ASSETS_MIGRATION_FLAG_KEYS, SnapsAssetsMigrationStage, @@ -33,6 +38,13 @@ const CHAIN_MAINNET = 'eip155:1' as ChainId; const CHAIN_POLYGON = 'eip155:137' as ChainId; const CHAIN_ARBITRUM = 'eip155:42161' as ChainId; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; +const [MAINNET_MUSD] = getDefaultTrackedAssetsForChain(CHAIN_MAINNET); +const MAINNET_NATIVE = 'eip155:1/slip44:60' as Caip19AssetId; +const POLYGON_NATIVE = 'eip155:137/slip44:966' as Caip19AssetId; + +function getNativeAssetForChain(chainId: ChainId): Caip19AssetId { + return NATIVE_ASSETS[chainId] ?? `${chainId}/erc20:${ZERO_ADDRESS}`; +} function isBalanceV6EnabledFromFlags( remoteFeatureFlags: Record, @@ -254,6 +266,13 @@ async function setupController( isBalanceV6Enabled: (): boolean => isBalanceV6EnabledFromFlags(remoteFeatureFlags), getAssetsState, + getAssetVisibility: (accountIds, chainIds): AssetVisibility => + getAssetVisibility({ + state: getAssetsState(), + accountIds, + chainIds, + getNativeAssetForChain, + }), ...(fetchTimeoutMs === undefined ? {} : { fetchTimeoutMs }), ...(tokenDetectionEnabled === undefined ? {} : { tokenDetectionEnabled }), }); @@ -874,7 +893,7 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('calls the v6 endpoint without extra params when enabled', async () => { + it('calls the v6 endpoint with default tracked assets as includeAssetIds when enabled', async () => { const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, }); @@ -885,7 +904,7 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - undefined, + { includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD] }, undefined, ); expect( @@ -1085,17 +1104,38 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('marks v6 unprocessed networks as errors', async () => { + it('omits v6 balances from unprocessed networks', async () => { const { controller } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedNetworks: ['eip155:1'], + v6Balances: [ + createMockV6BalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + MAINNET_NATIVE, + '1000000000000000000', + 'token', + 'native', + ), + createMockV6BalanceItem( + `eip155:137:${MOCK_ADDRESS}`, + 'eip155:137/slip44:966', + '2000000000000000000', + 'token', + 'native', + ), + ], }); - const response = await controller.fetch(createDataRequest()); + const response = await controller.fetch( + createDataRequest({ chainIds: [CHAIN_MAINNET, CHAIN_POLYGON] }), + ); expect(response.errors?.[CHAIN_MAINNET]).toBe( 'Unprocessed by Accounts API', ); + expect(response.assetsBalance?.['mock-account-id']).toStrictEqual({ + 'eip155:137/slip44:966': { amount: '2000000000000000000' }, + }); controller.destroy(); }); @@ -1117,22 +1157,25 @@ describe('AccountsApiDataSource', () => { }); it('passes EVM custom assets on requested chains to v6 as includeAssetIds', async () => { + const customToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [customToken] }, + }), }); - const customToken = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - await controller.fetch( - createDataRequest({ customAssets: [customToken] }), - ); + await controller.fetch(createDataRequest()); expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - { includeAssetIds: [customToken] }, + { + includeAssetIds: expect.arrayContaining([customToken, MAINNET_MUSD]), + }, undefined, ); @@ -1140,93 +1183,109 @@ describe('AccountsApiDataSource', () => { }); it('omits custom assets that are not on a requested chain from includeAssetIds', async () => { + const polygonToken = + 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller, apiClient } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [polygonToken] }, + }), }); - // Custom asset on Polygon while only Mainnet is being fetched. - const polygonToken = - 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - - await controller.fetch( - createDataRequest({ - chainIds: [CHAIN_MAINNET], - customAssets: [polygonToken], - }), - ); + await controller.fetch(createDataRequest({ chainIds: [CHAIN_MAINNET] })); expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - undefined, + { includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD] }, undefined, ); controller.destroy(); }); - it('surfaces unprocessed include asset ids on the asset axis (unprocessedCustomAssets) without flagging the chain as errored', async () => { + it('errors a chain that came back without every requested include asset id', async () => { const customToken = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedIncludeAssetIds: [customToken], + v6Balances: [ + createMockV6BalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + MAINNET_NATIVE, + '1000000000000000000', + 'token', + 'native', + ), + ], }); - const response = await controller.fetch( - createDataRequest({ customAssets: [customToken] }), - ); + const response = await controller.fetch(createDataRequest()); - // The chain itself succeeded — only the specific pinned asset is - // outstanding, so it goes on the asset axis, not `errors`. - expect(response.errors?.[CHAIN_MAINNET]).toBeUndefined(); - expect(response.unprocessedCustomAssets).toStrictEqual([customToken]); + // A snapshot missing one of its `includeAssetIds` is not a full + // snapshot, so the chain fails and its partial balances are dropped. + expect(response.errors?.[CHAIN_MAINNET]).toBe( + 'Unresolved includeAssetIds', + ); + expect(response.assetsBalance).toBeUndefined(); controller.destroy(); }); - it('omits unparseable unprocessed include asset ids from unprocessedCustomAssets', async () => { + it('errors only the chain of the unresolved include asset id', async () => { const customToken = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; const { controller } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, - unprocessedIncludeAssetIds: [ - customToken, - 'not-a-caip-asset' as Caip19AssetId, + unprocessedIncludeAssetIds: [customToken], + v6Balances: [ + createMockV6BalanceItem( + `eip155:137:${MOCK_ADDRESS}`, + POLYGON_NATIVE, + '5000000000000000000', + 'token', + 'native', + ), ], }); const response = await controller.fetch( - createDataRequest({ customAssets: [customToken] }), + createDataRequest({ chainIds: [CHAIN_MAINNET, CHAIN_POLYGON] }), ); - expect(response.unprocessedCustomAssets).toStrictEqual([customToken]); + expect(response.errors?.[CHAIN_POLYGON]).toBeUndefined(); + expect( + response.assetsBalance?.['mock-account-id']?.[POLYGON_NATIVE], + ).toStrictEqual({ amount: '5000000000000000000' }); controller.destroy(); }); - it('skips non-EVM and malformed custom assets when building includeAssetIds', async () => { - const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: true }, - }); - + it('omits pins not on a requested chain from includeAssetIds', async () => { const solanaToken = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; const malformed = 'not-a-caip-asset' as Caip19AssetId; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [solanaToken, malformed] }, + }), + }); - await controller.fetch( - createDataRequest({ customAssets: [solanaToken, malformed] }), - ); + await controller.fetch(createDataRequest()); - // No EVM custom asset on a requested chain -> includeAssetIds omitted. + // No custom asset on a requested chain — still include defaults. expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - undefined, + { includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD] }, undefined, ); @@ -1234,17 +1293,12 @@ describe('AccountsApiDataSource', () => { }); it('ignores malformed unprocessed include asset ids', async () => { - const customToken = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - const { controller } = await setupController({ remoteFeatureFlags: { assetsAccountsApiV6: true }, unprocessedIncludeAssetIds: ['not-a-caip-asset'], }); - const response = await controller.fetch( - createDataRequest({ customAssets: [customToken] }), - ); + const response = await controller.fetch(createDataRequest()); // The malformed unprocessed id cannot be parsed, so it is dropped from // both axes (no error, no asset-axis entry). @@ -1271,7 +1325,10 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - { excludeAssetIds: [hiddenToken] }, + { + includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD], + excludeAssetIds: [hiddenToken], + }, undefined, ); @@ -1295,14 +1352,14 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - undefined, + { includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD] }, undefined, ); controller.destroy(); }); - it('skips non-EVM and malformed hidden assets when building excludeAssetIds', async () => { + it('omits hidden assets not on a requested chain from excludeAssetIds', async () => { const solanaToken = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; const malformed = 'not-a-caip-asset' as Caip19AssetId; @@ -1323,37 +1380,172 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD] }, undefined, + ); + + controller.destroy(); + }); + + it('passes Solana pins on requested Solana chains to v6 as includeAssetIds', async () => { + const solanaChain = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; + const solanaNative = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; + const solanaToken = + `${solanaChain}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` as Caip19AssetId; + const { controller, apiClient } = await setupController({ + supportedChains: [1, solanaChain as unknown as number], + remoteFeatureFlags: { + assetsAccountsApiV6: true, + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [solanaToken] }, + }), + }); + + await controller.fetch(createDataRequest({ chainIds: [solanaChain] })); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`${solanaChain}:${MOCK_ADDRESS}`], + { includeAssetIds: [solanaNative, solanaToken] }, undefined, ); controller.destroy(); }); - it('reads visible pins from controller state as includeAssetIds when the request has none', async () => { - const customToken = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + it('passes hidden Solana assets on requested Solana chains to v6 as excludeAssetIds', async () => { + const solanaChain = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; + const solanaNative = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; + const solanaToken = + `${solanaChain}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` as Caip19AssetId; const { controller, apiClient } = await setupController({ - remoteFeatureFlags: { assetsAccountsApiV6: true }, + supportedChains: [1, solanaChain as unknown as number], + remoteFeatureFlags: { + assetsAccountsApiV6: true, + [SNAPS_ASSETS_MIGRATION_FLAG_KEYS.solana]: { + stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback, + }, + }, getAssetsState: () => createAssetsState({ - customAssets: { 'mock-account-id': [customToken] }, + assetPreferences: { [solanaToken]: { hidden: true } }, }), }); - await controller.fetch(createDataRequest()); + await controller.fetch(createDataRequest({ chainIds: [solanaChain] })); expect( apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( - [`eip155:1:${MOCK_ADDRESS}`], - { includeAssetIds: [customToken] }, + [`${solanaChain}:${MOCK_ADDRESS}`], + { + includeAssetIds: [solanaNative], + excludeAssetIds: [solanaToken], + }, undefined, ); controller.destroy(); }); + it('reads natives, visible pins, and default tracked assets as includeAssetIds, not detected ERC-20 balances', async () => { + const pinnedToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const trackedToken = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + const native = 'eip155:1/slip44:60' as Caip19AssetId; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [pinnedToken] }, + assetsBalance: { + 'mock-account-id': { + [native]: { amount: '1' }, + [trackedToken]: { amount: '2' }, + }, + }, + }), + }); + + await controller.fetch(createDataRequest()); + + const [, params] = + apiClient.accounts.fetchV6MultiAccountBalances.mock.calls[0]; + expect(params?.includeAssetIds ?? []).toStrictEqual( + expect.arrayContaining([pinnedToken, native, MAINNET_MUSD]), + ); + expect(params?.includeAssetIds ?? []).not.toContain(trackedToken); + + controller.destroy(); + }); + + it('includes map-encoded erc20 natives in includeAssetIds, not synthetic slip44 ids', async () => { + const gnosisNative = + 'eip155:100/erc20:0x0000000000000000000000000000000000000000' as Caip19AssetId; + const syntheticSlip44 = 'eip155:1/slip44:bandwidth' as Caip19AssetId; + const gnosisChain = 'eip155:100' as ChainId; + const { controller, apiClient } = await setupController({ + supportedChains: [1, 100], + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + assetsBalance: { + 'mock-account-id': { + [gnosisNative]: { amount: '1' }, + [syntheticSlip44]: { amount: '1' }, + }, + }, + }), + }); + + await controller.fetch( + createDataRequest({ chainIds: [CHAIN_MAINNET, gnosisChain] }), + ); + + const [, params] = + apiClient.accounts.fetchV6MultiAccountBalances.mock.calls[0]; + expect(params?.includeAssetIds ?? []).toContain(gnosisNative); + expect(params?.includeAssetIds ?? []).not.toContain(syntheticSlip44); + + controller.destroy(); + }); + + it('ignores request.customAssets when building v6 includeAssetIds', async () => { + const statePin = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const requestOnlyToken = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [statePin] }, + }), + }); + + await controller.fetch( + createDataRequest({ customAssets: [requestOnlyToken] }), + ); + + const [, params] = + apiClient.accounts.fetchV6MultiAccountBalances.mock.calls[0]; + expect(params?.includeAssetIds).toStrictEqual( + expect.arrayContaining([statePin, MAINNET_MUSD]), + ); + expect(params?.includeAssetIds).not.toContain(requestOnlyToken); + + controller.destroy(); + }); + it('does not include a state pin that is also hidden', async () => { const token = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; @@ -1372,7 +1564,37 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - { excludeAssetIds: [token] }, + { + includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD], + excludeAssetIds: [token], + }, + undefined, + ); + + controller.destroy(); + }); + + it('excludes hidden native and default tracked assets from includeAssetIds', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + assetPreferences: { + [MAINNET_NATIVE]: { hidden: true }, + [MAINNET_MUSD]: { hidden: true }, + }, + }), + }); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { + excludeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD], + }, undefined, ); @@ -1399,12 +1621,65 @@ describe('AccountsApiDataSource', () => { apiClient.accounts.fetchV6MultiAccountBalances, ).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], - { includeAssetIds: [pinned], excludeAssetIds: [hidden] }, + { + includeAssetIds: expect.arrayContaining([pinned, MAINNET_MUSD]), + excludeAssetIds: [hidden], + }, undefined, ); controller.destroy(); }); + + it('includes default tracked assets as includeAssetIds even with no pins or native in state', async () => { + const { controller, apiClient } = await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }); + + await controller.fetch(createDataRequest()); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { includeAssetIds: [MAINNET_NATIVE, MAINNET_MUSD] }, + undefined, + ); + + controller.destroy(); + }); + + it('sends the same includeAssetIds on subscribe polls as on fetch', async () => { + const pinnedToken = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const { controller, apiClient, assetsUpdateHandler } = + await setupController({ + remoteFeatureFlags: { assetsAccountsApiV6: true }, + getAssetsState: () => + createAssetsState({ + customAssets: { 'mock-account-id': [pinnedToken] }, + }), + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: assetsUpdateHandler, + }); + + expect( + apiClient.accounts.fetchV6MultiAccountBalances, + ).toHaveBeenCalledWith( + [`eip155:1:${MOCK_ADDRESS}`], + { + includeAssetIds: expect.arrayContaining([pinnedToken, MAINNET_MUSD]), + }, + { staleTime: 0, gcTime: 0 }, + ); + + controller.destroy(); + }); }); it('fetch marks every requested chain as errored when the call exceeds the configured timeout', async () => { @@ -1745,6 +2020,13 @@ describe('AccountsApiDataSource', () => { isBalanceV6Enabled: (): boolean => isBalanceV6EnabledFromFlags(remoteFeatureFlags), getAssetsState, + getAssetVisibility: (accountIds, chainIds): AssetVisibility => + getAssetVisibility({ + state: getAssetsState(), + accountIds, + chainIds, + getNativeAssetForChain, + }), }; if (tokenDetectionEnabled !== undefined) { diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 8baad6acafb..1c8e7bbaf30 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -23,6 +23,8 @@ import type { Middleware, AssetsControllerStateInternal, } from '../types.js'; +import type { GetAssetVisibility } from '../utils/assetVisibility.js'; +import { filterFailedChainBalances } from '../utils/filterFailedChainBalances.js'; import { fetchWithTimeout, normalizeAssetId } from '../utils/index.js'; import { getMigrationStages, @@ -119,6 +121,8 @@ export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { * for filtering when token detection is off. */ getAssetsState: () => AssetsControllerStateInternal; + /** Returns shared visible/hidden assets for an account/chain scope. */ + getAssetVisibility: GetAssetVisibility; state?: Partial; }; @@ -269,6 +273,8 @@ export class AccountsApiDataSource extends AbstractDataSource< readonly #getAssetsState: () => AssetsControllerStateInternal; + readonly #getAssetVisibility: GetAssetVisibility; + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; @@ -290,6 +296,7 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#isBalanceV6Enabled = options.isBalanceV6Enabled ?? ((): boolean => false); this.#getAssetsState = options.getAssetsState; + this.#getAssetVisibility = options.getAssetVisibility; this.#apiClient = options.queryApiClient; // The Snaps → AssetsController migration flags gate which migration networks @@ -410,107 +417,43 @@ export class AccountsApiDataSource extends AbstractDataSource< // ============================================================================ async fetch(request: DataRequest): Promise { - let response: DataResponse = {}; + if (this.#isBalanceV6Enabled()) { + return this.#fetchV6(request); + } - // Filter to only chains supported by Accounts API - const supportedChains = new Set(this.state.activeChains); - const chainsToFetch = request.chainIds.filter((chainId) => - supportedChains.has(chainId), - ); + return this.#fetchV5(request); + } + + /** + * v5 fetch. Unchanged from the pre-v6 handler; delete the v6 sibling first + * if `assetsAccountsApiV6` is rolled back. + * + * @param request - The data request. + * @returns Balances stamped `merge`, plus errors for chains the API could + * not process. + */ + async #fetchV5(request: DataRequest): Promise { + let response: DataResponse = {}; + const chainsToFetch = this.#getChainsToFetch(request); if (chainsToFetch.length === 0) { - // Mark unsupported chains as errors so they pass to next middleware - for (const chainId of request.chainIds) { - if (!supportedChains.has(chainId)) { - response.errors = response.errors ?? {}; - response.errors[chainId] = 'Chain not supported by Accounts API'; - } - } + this.#markUnsupportedChains(response, request); return response; } try { - // Build CAIP-10 account IDs (e.g., "eip155:1:0x1234...") - // Use pre-computed supportedChains per account from the request - const accountIds = request.accountsWithSupportedChains.flatMap( - ({ account, supportedChains: accountChains }) => - chainsToFetch - .filter((chainId) => accountChains.includes(chainId)) - .map((chainId) => `${chainId}:${account.address}`), - ); + const accountIds = this.#buildAccountIds(request, chainsToFetch); // Skip API call if no valid account-chain combinations if (accountIds.length === 0) { return response; } - const fetchOptions = - request.forceUpdate || request.bypassServerCache - ? { - staleTime: 0, - gcTime: 0, - // Also defeats the API's server-side cache (via a random - // bypassServerCache query param) so a post-transaction refresh cannot - // be answered with a pre-transaction snapshot. - ...(request.bypassServerCache ? { bypassServerCache: true } : {}), - } - : undefined; - - const isV6 = this.#isBalanceV6Enabled(); - let fetchResult: { - unprocessedNetworks: string[]; - unprocessedIncludeAssetIds: string[]; - assetsBalance: Record>; - }; - - if (isV6) { - // User-hidden assets on the fetched chains, sent to v6 as - // `excludeAssetIds`. - const excludeAssetIds = this.#getExcludeAssetIds(chainsToFetch); - - // User-pinned assets on the fetched chains, sent to v6 as - // `includeAssetIds` so the backend returns them even at zero balance. - // A hide wins over a pin, so hidden assets are never fetched. - const includeAssetIds = this.#getIncludeAssetIds( - request, - chainsToFetch, - excludeAssetIds, - ); - - fetchResult = await this.#fetchV6Balances( - accountIds, - fetchOptions, - request, - includeAssetIds, - excludeAssetIds, - ); - - const validUnprocessedAssetIds = - fetchResult.unprocessedIncludeAssetIds.filter((assetId) => { - try { - parseCaipAssetType(assetId as Caip19AssetId); - return true; - } catch { - return false; - } - }); - if (validUnprocessedAssetIds.length > 0) { - response.unprocessedCustomAssets = [ - ...(response.unprocessedCustomAssets ?? []), - ...(validUnprocessedAssetIds as Caip19AssetId[]), - ]; - } - response.updateMode = 'full'; - } else { - fetchResult = await this.#fetchV5Balances( - accountIds, - fetchOptions, - request, - ); - response.updateMode = 'merge'; - } - - const { unprocessedNetworks, assetsBalance } = fetchResult; + const { unprocessedNetworks, assetsBalance } = await this.#fetchV5Balances( + accountIds, + this.#buildFetchOptions(request), + request, + ); // Handle unprocessed networks - these will be passed to next middleware if (unprocessedNetworks.length > 0) { @@ -525,129 +468,191 @@ export class AccountsApiDataSource extends AbstractDataSource< } response.assetsBalance = assetsBalance; + response.updateMode = 'merge'; } catch (error) { - log('Fetch FAILED', { error, chains: chainsToFetch }); + this.#markFetchFailure(response, chainsToFetch, error); + } - // On error, mark all chains as errors so they can be handled by next middleware - response.errors = response.errors ?? {}; - for (const chainId of chainsToFetch) { - response.errors[chainId] = - `Fetch failed: ${error instanceof Error ? error.message : String(error)}`; - } + this.#markUnsupportedChains(response, request); + + // When token detection is disabled, filter out tokens not already in state + if (!this.#tokenDetectionEnabled()) { + response = filterResponseToKnownAssets(response, this.#getAssetsState()); } - // Mark unsupported chains as errors so they pass to next middleware - for (const chainId of request.chainIds) { - if (!supportedChains.has(chainId)) { + return response; + } + + /** + * v6 fetch. Reads visibility from state, sends it as `includeAssetIds` / + * `excludeAssetIds`, and stamps `full`. A chain the API left unprocessed, or + * answered without every requested `includeAssetId`, is reported in `errors` + * and contributes no balances, so the RPC fallback can recover it. + * + * @param request - The data request. + * @returns An authoritative snapshot for the chains that succeeded. + */ + async #fetchV6(request: DataRequest): Promise { + const response: DataResponse = {}; + const chainsToFetch = this.#getChainsToFetch(request); + + if (chainsToFetch.length === 0) { + this.#markUnsupportedChains(response, request); + return response; + } + + try { + const accountIds = this.#buildAccountIds(request, chainsToFetch); + + if (accountIds.length === 0) { + return response; + } + + const { visibleAssetIds, hiddenAssetIds } = this.#getAssetVisibility( + request.accountsWithSupportedChains.map(({ account }) => account.id), + chainsToFetch, + ); + + const { unprocessedNetworks, unprocessedIncludeAssetIds, assetsBalance } = + await this.#fetchV6Balances( + accountIds, + this.#buildFetchOptions(request), + request, + visibleAssetIds.length > 0 ? visibleAssetIds : undefined, + hiddenAssetIds.length > 0 ? hiddenAssetIds : undefined, + ); + response.updateMode = 'full'; + + const unprocessedChainIds = unprocessedNetworks.map(caipChainIdToChainId); + // Chains answered without every requested `includeAssetIds`. The + // snapshot is incomplete, so the chain counts as failed rather than + // silently dropping the assets it left out. + const incompleteChainIds = this.#getChainIdsForAssetIds( + unprocessedIncludeAssetIds, + ); + const failedChainIds = new Set([ + ...unprocessedChainIds, + ...incompleteChainIds, + ]); + + // Errors hand these chains to the next middleware (the RPC fallback). + if (failedChainIds.size > 0) { response.errors = response.errors ?? {}; - response.errors[chainId] = 'Chain not supported by Accounts API'; + for (const chainId of unprocessedChainIds) { + response.errors[chainId] = 'Unprocessed by Accounts API'; + } + for (const chainId of incompleteChainIds) { + response.errors[chainId] ??= 'Unresolved includeAssetIds'; + } } - } - // v5: when token detection is off, drop tokens not already in state. - // v6: the snapshot is authoritative (`updateMode: 'full'`); do not filter. - if (!this.#isBalanceV6Enabled() && !this.#tokenDetectionEnabled()) { - response = filterResponseToKnownAssets(response, this.#getAssetsState()); + // A failed chain contributes nothing, so its balances stay as they are. + response.assetsBalance = filterFailedChainBalances( + assetsBalance, + failedChainIds, + ); + } catch (error) { + this.#markFetchFailure(response, chainsToFetch, error); } + this.#markUnsupportedChains(response, request); + return response; } /** - * Collect the pinned EVM assets on the fetched chains to send to the v6 - * endpoint as `includeAssetIds`; malformed IDs are skipped and hidden - * assets are left out (a hide wins). + * Requested chains the Accounts API currently supports. * - * Prefers `request.customAssets` when the caller scoped the fetch. - * Otherwise reads visible pins from controller state. + * @param request - The data request being fetched. + * @returns The subset of `request.chainIds` this source can answer. + */ + #getChainsToFetch(request: DataRequest): ChainId[] { + const supportedChains = new Set(this.state.activeChains); + return request.chainIds.filter((chainId) => supportedChains.has(chainId)); + } + + /** + * Mark unsupported chains as errors so they pass to next middleware. * - * @param request - The data request (optional `customAssets` scope). - * @param chainsToFetch - Chains being requested this fetch. - * @param excludeAssetIds - Hidden asset IDs that must not be included. - * @returns Deduplicated asset IDs, or `undefined` when none. + * @param response - Response being built; mutated in place. + * @param request - The data request being fetched. */ - #getIncludeAssetIds( - request: DataRequest, - chainsToFetch: ChainId[], - excludeAssetIds: Caip19AssetId[] | undefined, - ): Caip19AssetId[] | undefined { - const candidates = - request.customAssets && request.customAssets.length > 0 - ? request.customAssets - : this.#getVisibleCustomAssetsFromState(request); - return this.#filterEvmAssetsOnChains( - candidates, - chainsToFetch, - new Set(excludeAssetIds ?? []), - ); + #markUnsupportedChains(response: DataResponse, request: DataRequest): void { + const supportedChains = new Set(this.state.activeChains); + for (const chainId of request.chainIds) { + if (!supportedChains.has(chainId)) { + response.errors = response.errors ?? {}; + response.errors[chainId] = 'Chain not supported by Accounts API'; + } + } } /** - * Collect hidden EVM assets on the fetched chains from controller state - * to send as `excludeAssetIds`. + * Build CAIP-10 account IDs (e.g., "eip155:1:0x1234...") from the + * pre-computed supportedChains per account on the request. * - * @param chainsToFetch - Chains being requested this fetch. - * @returns Deduplicated asset IDs, or `undefined` when none. + * @param request - The data request being fetched. + * @param chainsToFetch - Chains this source will ask the API for. + * @returns One CAIP-10 ID per account-chain combination. */ - #getExcludeAssetIds(chainsToFetch: ChainId[]): Caip19AssetId[] | undefined { - return this.#filterEvmAssetsOnChains( - this.#getHiddenAssetIdsFromState(), - chainsToFetch, + #buildAccountIds(request: DataRequest, chainsToFetch: ChainId[]): string[] { + return request.accountsWithSupportedChains.flatMap( + ({ account, supportedChains: accountChains }) => + chainsToFetch + .filter((chainId) => accountChains.includes(chainId)) + .map((chainId) => `${chainId}:${account.address}`), ); } - #getVisibleCustomAssetsFromState(request: DataRequest): Caip19AssetId[] { - const state = this.#getAssetsState(); - const visible: Caip19AssetId[] = []; - for (const { account } of request.accountsWithSupportedChains) { - for (const assetId of state.customAssets[account.id] ?? []) { - if (!state.assetPreferences[assetId]?.hidden) { - visible.push(assetId); - } - } + #buildFetchOptions( + request: DataRequest, + ): { staleTime: number; gcTime: number; bypassServerCache?: boolean } | undefined { + if (!request.forceUpdate && !request.bypassServerCache) { + return undefined; } - return visible; - } - #getHiddenAssetIdsFromState(): Caip19AssetId[] { - const hidden: Caip19AssetId[] = []; - for (const [assetId, prefs] of Object.entries( - this.#getAssetsState().assetPreferences, - )) { - if (prefs.hidden) { - hidden.push(assetId as Caip19AssetId); - } - } - return hidden; + return { + staleTime: 0, + gcTime: 0, + // Also defeats the API's server-side cache (via a random + // bypassServerCache query param) so a post-transaction refresh cannot + // be answered with a pre-transaction snapshot. + ...(request.bypassServerCache ? { bypassServerCache: true } : {}), + }; } - #filterEvmAssetsOnChains( - assetIds: Caip19AssetId[], + /** + * On error, mark all chains as errors so the next middleware handles them. + * + * @param response - Response being built; mutated in place. + * @param chainsToFetch - Chains the failed request covered. + * @param error - The thrown error. + */ + #markFetchFailure( + response: DataResponse, chainsToFetch: ChainId[], - excludeSet?: Set, - ): Caip19AssetId[] | undefined { - const chainsToFetchSet = new Set(chainsToFetch); - const filtered = new Set(); + error: unknown, + ): void { + log('Fetch FAILED', { error, chains: chainsToFetch }); + + response.errors = response.errors ?? {}; + for (const chainId of chainsToFetch) { + response.errors[chainId] = + `Fetch failed: ${error instanceof Error ? error.message : String(error)}`; + } + } + #getChainIdsForAssetIds(assetIds: string[]): Set { + const chainIds = new Set(); for (const assetId of assetIds) { - if (excludeSet?.has(assetId)) { - continue; - } - let chainId: ChainId; try { - chainId = parseCaipAssetType(assetId).chainId; + chainIds.add(parseCaipAssetType(assetId as Caip19AssetId).chainId); } catch { - continue; - } - if ( - chainId.startsWith(`${KnownCaipNamespace.Eip155}:`) && - chainsToFetchSet.has(chainId) - ) { - filtered.add(assetId); + // An unparseable ID cannot be attributed to a chain, so it cannot + // invalidate one either. } } - - return filtered.size > 0 ? [...filtered] : undefined; + return chainIds; } /** @@ -912,104 +917,145 @@ export class AccountsApiDataSource extends AbstractDataSource< * @returns The middleware function for the assets pipeline. */ get assetsMiddleware(): Middleware { - return async (context, next) => { - const { request } = context; + if (this.#isBalanceV6Enabled()) { + return this.#assetsMiddlewareV6; + } - // Price/metadata-only requests must not hit the Accounts API. - if (!request.dataTypes.includes('balance')) { - return next(context); - } + return this.#assetsMiddlewareV5; + } - // If no chains requested, skip to next middleware - if (request.chainIds.length === 0) { - return next(context); - } + readonly #assetsMiddlewareV5: Middleware = async (context, next) => { + const { request } = context; - let successfullyHandledChains: ChainId[] = []; + // Price/metadata-only requests must not hit the Accounts API. + if (!request.dataTypes.includes('balance')) { + return next(context); + } - try { - const response = await this.fetch(request); - - // Merge response into context - if (response.assetsBalance) { - context.response.assetsBalance ??= {}; - for (const [accountId, accountBalances] of Object.entries( - response.assetsBalance, - )) { - context.response.assetsBalance[accountId] = { - ...context.response.assetsBalance[accountId], - ...accountBalances, - }; - } - } + // If no chains requested, skip to next middleware + if (request.chainIds.length === 0) { + return next(context); + } - if (response.updateMode === 'full') { - context.response = { - ...context.response, - updateMode: 'full', - }; - } else if ( - response.updateMode === 'merge' && - context.response.updateMode !== 'full' - ) { - context.response = { - ...context.response, - updateMode: 'merge', + let successfullyHandledChains: ChainId[] = []; + + try { + const response = await this.fetch(request); + + // Merge response into context + if (response.assetsBalance) { + context.response.assetsBalance ??= {}; + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance, + )) { + context.response.assetsBalance[accountId] = { + ...context.response.assetsBalance[accountId], + ...accountBalances, }; } + } - // Forward the asset-axis signal so the RPC fallback recovers these pins. - if ( - response.unprocessedCustomAssets && - response.unprocessedCustomAssets.length > 0 - ) { - context.response.unprocessedCustomAssets = [ - ...(context.response.unprocessedCustomAssets ?? []), - ...response.unprocessedCustomAssets, - ]; - } + // Determine successfully handled chains (exclude unprocessed/error chains) + const unprocessedChains = new Set(Object.keys(response.errors ?? {})); + successfullyHandledChains = request.chainIds.filter( + (chainId) => !unprocessedChains.has(chainId), + ); - // Determine successfully handled chains (exclude unprocessed/error chains) - const unprocessedChains = new Set(Object.keys(response.errors ?? {})); - successfullyHandledChains = request.chainIds.filter( - (chainId) => !unprocessedChains.has(chainId), - ); + // When token detection is off and we filtered out all balance data (e.g. new + // account with empty state), do not claim any chain as handled so that RPC + // middleware can still process them and fetch native balances (ETH, MATIC, etc.). + if ( + !this.#tokenDetectionEnabled() && + (!response.assetsBalance || + Object.keys(response.assetsBalance).length === 0) + ) { + successfullyHandledChains = []; + } + } catch (error) { + log('Middleware fetch failed', { error }); + successfullyHandledChains = []; + } - // v5: when token detection is off and we filtered out all balance data - // (e.g. new account with empty state), do not claim any chain so RPC - // can still fetch native balances. v6 keeps API coverage as-is. - if ( - !this.#isBalanceV6Enabled() && - !this.#tokenDetectionEnabled() && - (!response.assetsBalance || - Object.keys(response.assetsBalance).length === 0) - ) { - successfullyHandledChains = []; + // Remove successfully handled chains from request for next middleware + if (successfullyHandledChains.length > 0) { + const remainingChains = request.chainIds.filter( + (chainId) => !successfullyHandledChains.includes(chainId), + ); + + return next({ + ...context, + request: { + ...request, + chainIds: remainingChains, + }, + }); + } + + // No chains handled - pass context unchanged + return next(context); + }; + + readonly #assetsMiddlewareV6: Middleware = async (context, next) => { + const { request } = context; + + // Price/metadata-only requests must not hit the Accounts API. + if (!request.dataTypes.includes('balance')) { + return next(context); + } + + // If no chains requested, skip to next middleware + if (request.chainIds.length === 0) { + return next(context); + } + + let remainingChains = request.chainIds; + + try { + const response = await this.fetch(request); + + // Merge response into context + if (response.assetsBalance) { + context.response.assetsBalance ??= {}; + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance, + )) { + context.response.assetsBalance[accountId] = { + ...context.response.assetsBalance[accountId], + ...accountBalances, + }; } - } catch (error) { - log('Middleware fetch failed', { error }); - successfullyHandledChains = []; } - // Remove successfully handled chains from request for next middleware - if (successfullyHandledChains.length > 0) { - const remainingChains = request.chainIds.filter( - (chainId) => !successfullyHandledChains.includes(chainId), - ); + context.response = { + ...context.response, + updateMode: 'full', + }; - return next({ - ...context, - request: { - ...request, - chainIds: remainingChains, - }, - }); + const errors = response.errors ?? {}; + remainingChains = request.chainIds.filter((chainId) => errors[chainId]); + + // Remaining chains are exactly the failed ones; RPC fallback retries them. + if (remainingChains.length > 0) { + context.response.errors = { + ...context.response.errors, + ...Object.fromEntries( + remainingChains.map((chainId) => [chainId, errors[chainId]]), + ), + }; } + } catch (error) { + log('Middleware fetch failed', { error }); + remainingChains = request.chainIds; + } - // No chains handled - pass context unchanged - return next(context); - }; - } + return next({ + ...context, + request: { + ...request, + chainIds: remainingChains, + }, + }); + }; // ============================================================================ // SUBSCRIBE diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index cee00c32fba..7a957bdd76c 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -16,7 +16,10 @@ import type { AssetsControllerMessenger, AssetsControllerState, } from '../AssetsController.js'; +import { getDefaultTrackedAssetsForChain } from '../defaults.js'; import type { Caip19AssetId, ChainId, DataRequest, Context } from '../types.js'; +import type { AssetVisibility } from '../utils/assetVisibility.js'; +import { getAssetVisibility } from '../utils/assetVisibility.js'; import { normalizeAssetId } from '../utils/index.js'; import { BalanceFetcher, TokenDetector } from './evm-rpc-services/index.js'; import type { @@ -36,6 +39,7 @@ const MOCK_CHAIN_ID_HEX = '0x1'; const MOCK_CHAIN_ID_CAIP = 'eip155:1' as ChainId; const MOCK_ACCOUNT_ID = 'mock-account-id'; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; +const [MAINNET_MUSD] = getDefaultTrackedAssetsForChain(MOCK_CHAIN_ID_CAIP); type EthereumProvider = { request: jest.Mock }; function createBalanceFetchResult( @@ -231,6 +235,19 @@ async function withController( const defaultNativeAssetMap: Record = { [MOCK_CHAIN_ID_CAIP]: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, }; + const getAssetsState = + options.getAssetsState ?? + ((): AssetsControllerState => { + try { + return ( + (actionHandlerOverrides?.['AssetsController:getState']?.() as + | AssetsControllerState + | undefined) ?? getDefaultAssetsControllerState() + ); + } catch { + return getDefaultAssetsControllerState(); + } + }); const onActiveChainsUpdated = options.onActiveChainsUpdated ?? jest.fn(); const controller = new RpcDataSource({ @@ -245,20 +262,15 @@ async function withController( ) || assetId.includes('/slip44:'); return isNative ? 'native' : 'erc20'; }, - ...options, - getAssetsState: - options.getAssetsState ?? - ((): AssetsControllerState => { - try { - return ( - (actionHandlerOverrides?.['AssetsController:getState']?.() as - | AssetsControllerState - | undefined) ?? getDefaultAssetsControllerState() - ); - } catch { - return getDefaultAssetsControllerState(); - } + getAssetVisibility: (accountIds, chainIds): AssetVisibility => + getAssetVisibility({ + state: getAssetsState(), + accountIds, + chainIds, + getNativeAssetForChain: (chainId) => defaultNativeAssetMap[chainId], }), + ...options, + getAssetsState, }); try { @@ -313,6 +325,7 @@ describe('createRpcDataSource', () => { onActiveChainsUpdated: jest.fn(), getNativeAssetForChain: jest.fn(), getAssetType: jest.fn(), + getAssetVisibility: jest.fn(), }); expect(source).toBeInstanceOf(RpcDataSource); source.destroy(); @@ -507,6 +520,7 @@ describe('RpcDataSource', () => { const response = await controller.fetch(createDataRequest()); expect(response).toBeDefined(); expect(response.assetsBalance).toBeDefined(); + expect(response.updateMode).toBe('merge'); }); }); @@ -657,6 +671,40 @@ describe('RpcDataSource', () => { }); }); + it('treats a failed balanceOf as a chain failure on the v6 path', async () => { + const nativeAssetId = 'eip155:1/slip44:60' as Caip19AssetId; + await withController( + { options: { isBalanceV6Enabled: (): boolean => true } }, + async ({ controller }) => { + jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue( + createBalanceFetchResult({ + balances: [ + { + assetId: nativeAssetId, + accountId: MOCK_ACCOUNT_ID, + chainId: MOCK_CHAIN_ID_HEX, + balance: '1000000000000000000', + formattedBalance: '1', + decimals: 18, + timestamp: Date.now(), + }, + ], + failedAddresses: [ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Address, + ], + }), + ); + const response = await controller.fetch(createDataRequest()); + expect(response.errors?.[MOCK_CHAIN_ID_CAIP]).toBe( + 'RPC fetch failed', + ); + expect(response.assetsBalance).toBeUndefined(); + }, + ); + }); + it('initializes assetsBalance[accountId] with no native in catch when first fetch for account throws on native skip chain', async () => { await withController(async ({ controller }) => { jest @@ -849,18 +897,56 @@ describe('RpcDataSource', () => { .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue(createBalanceFetchResult()); + await withController(async ({ controller }) => { + const request = createDataRequest({ + customAssets: [customAssetId], + }); + await controller.fetch(request); + + expect(fetchSpy).toHaveBeenCalledWith( + MOCK_CHAIN_ID_HEX, + MOCK_ACCOUNT_ID, + MOCK_ADDRESS, + [ + { + assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + address: '0x0000000000000000000000000000000000000000', + }, + expect.objectContaining({ + assetId: customAssetId, + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + }), + ], + ); + }); + + fetchSpy.mockRestore(); + }); + + it('filters custom assets to only include tokens on the fetched chain', async () => { + const matchingAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const otherChainAsset = + 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + await withController( { actionHandlerOverrides: { 'AssetsController:getState': () => ({ ...getDefaultAssetsControllerState(), - customAssets: { [MOCK_ACCOUNT_ID]: [customAssetId] }, + customAssets: { + [MOCK_ACCOUNT_ID]: [matchingAsset, otherChainAsset], + }, }), }, }, async ({ controller }) => { const request = createDataRequest({ - customAssets: [customAssetId], + customAssets: [matchingAsset, otherChainAsset], }); await controller.fetch(request); @@ -874,7 +960,7 @@ describe('RpcDataSource', () => { address: '0x0000000000000000000000000000000000000000', }, expect.objectContaining({ - assetId: customAssetId, + assetId: matchingAsset, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', }), ], @@ -885,11 +971,11 @@ describe('RpcDataSource', () => { fetchSpy.mockRestore(); }); - it('filters custom assets to only include tokens on the fetched chain', async () => { - const matchingAsset = + it('fetches existing balances, pins, and default tracked assets from state on the v6 path when the request carries no customAssets', async () => { + const pinnedAsset = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - const otherChainAsset = - 'eip155:137/erc20:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Caip19AssetId; + const trackedBalanceAsset = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; const fetchSpy = jest .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') @@ -897,56 +983,71 @@ describe('RpcDataSource', () => { await withController( { + options: { isBalanceV6Enabled: (): boolean => true }, actionHandlerOverrides: { 'AssetsController:getState': () => ({ ...getDefaultAssetsControllerState(), - customAssets: { - [MOCK_ACCOUNT_ID]: [matchingAsset, otherChainAsset], + customAssets: { [MOCK_ACCOUNT_ID]: [pinnedAsset] }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [trackedBalanceAsset]: { amount: '1' }, + }, }, }), }, }, async ({ controller }) => { - const request = createDataRequest({ - customAssets: [matchingAsset, otherChainAsset], - }); - await controller.fetch(request); + const response = await controller.fetch(createDataRequest()); expect(fetchSpy).toHaveBeenCalledWith( MOCK_CHAIN_ID_HEX, MOCK_ACCOUNT_ID, MOCK_ADDRESS, - [ + expect.arrayContaining([ { assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, address: '0x0000000000000000000000000000000000000000', }, - expect.objectContaining({ - assetId: matchingAsset, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - }), - ], + expect.objectContaining({ assetId: pinnedAsset }), + expect.objectContaining({ assetId: trackedBalanceAsset }), + expect.objectContaining({ assetId: MAINNET_MUSD }), + ]), ); + expect(response.updateMode).toBe('full'); }, ); fetchSpy.mockRestore(); }); - it('fetches the account custom assets in state when the request carries none', async () => { + it('does not fetch hidden native, pinned, default, or existing assets on the v6 path', async () => { + const nativeAsset = `${MOCK_CHAIN_ID_CAIP}/slip44:60` as Caip19AssetId; const pinnedAsset = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - + const trackedBalanceAsset = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; const fetchSpy = jest .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') .mockResolvedValue(createBalanceFetchResult()); await withController( { + options: { isBalanceV6Enabled: (): boolean => true }, actionHandlerOverrides: { 'AssetsController:getState': () => ({ ...getDefaultAssetsControllerState(), customAssets: { [MOCK_ACCOUNT_ID]: [pinnedAsset] }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [trackedBalanceAsset]: { amount: '1' }, + }, + }, + assetPreferences: { + [nativeAsset]: { hidden: true }, + [pinnedAsset]: { hidden: true }, + [trackedBalanceAsset]: { hidden: true }, + [MAINNET_MUSD]: { hidden: true }, + }, }), }, }, @@ -957,16 +1058,7 @@ describe('RpcDataSource', () => { MOCK_CHAIN_ID_HEX, MOCK_ACCOUNT_ID, MOCK_ADDRESS, - [ - { - assetId: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - address: '0x0000000000000000000000000000000000000000', - }, - expect.objectContaining({ - assetId: pinnedAsset, - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - }), - ], + [], ); }, ); @@ -974,6 +1066,46 @@ describe('RpcDataSource', () => { fetchSpy.mockRestore(); }); + it('fetches only native on the v5 path when the request carries no customAssets', async () => { + const pinnedAsset = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; + const trackedBalanceAsset = + 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId; + + const fetchSpy = jest + .spyOn(BalanceFetcher.prototype, 'fetchBalancesForAssets') + .mockResolvedValue(createBalanceFetchResult()); + + await withController( + { + actionHandlerOverrides: { + 'AssetsController:getState': () => ({ + ...getDefaultAssetsControllerState(), + customAssets: { [MOCK_ACCOUNT_ID]: [pinnedAsset] }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + [trackedBalanceAsset]: { amount: '1' }, + }, + }, + }), + }, + }, + async ({ controller }) => { + await controller.fetch(createDataRequest()); + + // v5 reads ERC-20s from the scoped request only; state pins and + // tracked balances are polled by the balance fetcher instead. + const assets = fetchSpy.mock.calls[0][3] as { assetId: string }[]; + const assetIds = assets.map((entry) => entry.assetId); + expect(assetIds).toStrictEqual([`${MOCK_CHAIN_ID_CAIP}/slip44:60`]); + expect(assetIds).not.toContain(pinnedAsset); + expect(assetIds).not.toContain(trackedBalanceAsset); + }, + ); + + fetchSpy.mockRestore(); + }); + it('fetches only the request custom assets when the request is scoped', async () => { const requestedAsset = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; @@ -1387,6 +1519,24 @@ describe('RpcDataSource', () => { expect(next).toHaveBeenCalledWith(context); }); }); + + it('forwards full updateMode on the v6 path', async () => { + await withController( + { options: { isBalanceV6Enabled: (): boolean => true } }, + async ({ controller }) => { + const context: Context = { + request: createDataRequest(), + response: {}, + getAssetsState: jest.fn(), + }; + const next = jest + .fn() + .mockImplementation((ctx: Context) => Promise.resolve(ctx)); + await controller.assetsMiddleware(context, next); + expect(context.response.updateMode).toBe('full'); + }, + ); + }); }); describe('subscribe', () => { @@ -1724,6 +1874,122 @@ describe('RpcDataSource', () => { ).toStrictEqual({ amount: '1' }); }); + it('stamps full updateMode on the v6 poll path', async () => { + let balanceUpdateCallback: + | ((result: BalanceFetchResult) => void | Promise) + | null = null; + jest + .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') + .mockImplementation(function (this: BalanceFetcher, callback) { + balanceUpdateCallback = callback; + }); + + const onAssetsUpdate = jest.fn(); + await withController( + { options: { isBalanceV6Enabled: (): boolean => true } }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + await balanceUpdateCallback?.( + createBalanceFetchResult({ + balances: [ + { + assetId: 'eip155:1/slip44:60' as Caip19AssetId, + balance: '1000000000000000000', + } as BalanceFetchResult['balances'][0], + ], + }), + ); + }, + ); + + expect(onAssetsUpdate).toHaveBeenCalledWith( + expect.objectContaining({ updateMode: 'full' }), + expect.any(Object), + ); + }); + + it('skips the v6 poll snapshot when a balanceOf failed', async () => { + let balanceUpdateCallback: + | ((result: BalanceFetchResult) => void | Promise) + | null = null; + jest + .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') + .mockImplementation(function (this: BalanceFetcher, callback) { + balanceUpdateCallback = callback; + }); + + const onAssetsUpdate = jest.fn(); + await withController( + { options: { isBalanceV6Enabled: (): boolean => true } }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + await balanceUpdateCallback?.( + createBalanceFetchResult({ + balances: [ + { + assetId: 'eip155:1/slip44:60' as Caip19AssetId, + balance: '1000000000000000000', + } as BalanceFetchResult['balances'][0], + ], + failedAddresses: [ + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Address, + ], + }), + ); + }, + ); + + expect(onAssetsUpdate).not.toHaveBeenCalled(); + }); + + it('skips the v6 poll snapshot when an asset has unknown decimals', async () => { + const erc20AssetId = + 'eip155:1/erc20:0xAbc0000000000000000000000000000000000001' as Caip19AssetId; + let balanceUpdateCallback: + | ((result: BalanceFetchResult) => void | Promise) + | null = null; + jest + .spyOn(BalanceFetcher.prototype, 'setOnBalanceUpdate') + .mockImplementation(function (this: BalanceFetcher, callback) { + balanceUpdateCallback = callback; + }); + + const onAssetsUpdate = jest.fn(); + await withController( + { options: { isBalanceV6Enabled: (): boolean => true } }, + async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + await balanceUpdateCallback?.( + createBalanceFetchResult({ + balances: [ + { + assetId: erc20AssetId, + balance: '0', + } as BalanceFetchResult['balances'][0], + ], + }), + ); + }, + ); + + expect(onAssetsUpdate).not.toHaveBeenCalled(); + }); + it('omits unknown ERC-20 from assetsInfo when not in existing state', async () => { const tokenAddress = '0xAbc0000000000000000000000000000000000001'; const erc20AssetId = `eip155:1/erc20:${tokenAddress}` as Caip19AssetId; @@ -2192,6 +2458,50 @@ describe('RpcDataSource', () => { } }); + it('refreshes balance with full mode on the v6 path when transaction confirmed', async () => { + const onAssetsUpdate = jest.fn().mockResolvedValue(undefined); + const fetchSpy = jest + .spyOn(RpcDataSource.prototype, 'fetch') + .mockResolvedValue({ + assetsBalance: { + [MOCK_ACCOUNT_ID]: { + 'eip155:1/slip44:60': { amount: '2' }, + }, + }, + updateMode: 'full', + }); + + try { + await withController( + { options: { isBalanceV6Enabled: (): boolean => true } }, + async ({ controller, rootMessenger }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + }); + + rootMessenger.publish( + 'TransactionController:transactionConfirmed', + { + chainId: MOCK_CHAIN_ID_HEX, + txParams: { from: MOCK_ADDRESS }, + } as unknown as TransactionMeta, + ); + await new Promise(process.nextTick); + + expect(onAssetsUpdate).toHaveBeenCalledWith( + expect.objectContaining({ updateMode: 'full' }), + expect.objectContaining({ dataTypes: ['balance'] }), + ); + }, + ); + } finally { + fetchSpy.mockRestore(); + } + }); + it('does not refresh when transaction confirmed has no chainId', async () => { await withController(async ({ rootMessenger }) => { rootMessenger.publish( @@ -2274,6 +2584,7 @@ describe('RpcDataSource', () => { onActiveChainsUpdated: jest.fn(), getNativeAssetForChain: jest.fn(), getAssetType: jest.fn().mockReturnValue('erc20'), + getAssetVisibility: jest.fn(), }); controller.destroy(); expect(controller).toBeDefined(); diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index 0b45a7a6c6c..6974e87fc68 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -39,7 +39,9 @@ import type { DataResponse, Middleware, } from '../types.js'; +import type { GetAssetVisibility } from '../utils/assetVisibility.js'; import { ZERO_ADDRESS } from '../utils/constants.js'; +import { filterFailedChainBalances } from '../utils/filterFailedChainBalances.js'; import { normalizeAssetId } from '../utils/index.js'; import { AbstractDataSource } from './AbstractDataSource.js'; import type { @@ -48,6 +50,7 @@ import type { } from './AbstractDataSource.js'; import { BalanceFetcher, + isStakingContractAssetId, MulticallClient, TokenDetector, TokensApiClient, @@ -151,6 +154,14 @@ export type RpcDataSourceOptions = { /** Returns the asset type ('native' | 'erc20' | 'spl') for the given CAIP-19 asset ID */ getAssetType: (assetId: Caip19AssetId) => 'native' | 'erc20' | 'spl'; + /** + * Whether Accounts API v6 is enabled. Unscoped fetches then include native, + * existing balances, pins, and default tracked assets. v5 unscoped fetches + * stay pins-from-state only. Omitted means v5. + */ + isBalanceV6Enabled?: () => boolean; + /** Returns shared visible/hidden assets for an account/chain scope. */ + getAssetVisibility: GetAssetVisibility; }; /** @@ -257,6 +268,10 @@ export class RpcDataSource extends AbstractDataSource< assetId: Caip19AssetId, ) => 'native' | 'erc20' | 'spl'; + readonly #isBalanceV6Enabled: () => boolean; + + readonly #getAssetVisibility: GetAssetVisibility; + constructor(options: RpcDataSourceOptions) { super(CONTROLLER_NAME, { activeChains: [] }); this.#messenger = options.messenger; @@ -270,6 +285,9 @@ export class RpcDataSource extends AbstractDataSource< this.#useExternalService = options.useExternalService ?? ((): boolean => true); this.#isOnboarded = options.isOnboarded ?? ((): boolean => true); + this.#isBalanceV6Enabled = + options.isBalanceV6Enabled ?? ((): boolean => false); + this.#getAssetVisibility = options.getAssetVisibility; const balanceInterval = options.balanceInterval ?? DEFAULT_BALANCE_INTERVAL; const detectionInterval = @@ -332,6 +350,8 @@ export class RpcDataSource extends AbstractDataSource< const nativeId = this.#getNativeAssetForChain(chainId); return nativeId?.toLowerCase() === assetId.toLowerCase(); }, + isBalanceV6Enabled: (): boolean => this.#isBalanceV6Enabled(), + getAssetVisibility: this.#getAssetVisibility, }, ); // Polling controller awaits this callback; rejections must not become unhandled. @@ -502,6 +522,21 @@ export class RpcDataSource extends AbstractDataSource< * @param result - The balance fetch result. */ async #handleBalanceUpdate(result: BalanceFetchResult): Promise { + if (this.#isBalanceV6Enabled()) { + await this.#handleBalanceUpdateV6(result); + return; + } + + await this.#handleBalanceUpdateV5(result); + } + + /** + * v5 poll path. Unchanged from the pre-v6 handler; delete the v6 sibling + * first if `assetsAccountsApiV6` is rolled back. + * + * @param result - The balance fetch result. + */ + async #handleBalanceUpdateV5(result: BalanceFetchResult): Promise { const newBalances: Record = {}; // Convert hex chain ID to CAIP-2 format @@ -570,6 +605,94 @@ export class RpcDataSource extends AbstractDataSource< } } + /** + * v6 poll path: a complete chain snapshot, or nothing. Delete with the rest + * of the v6 path if `assetsAccountsApiV6` is rolled back. + * + * @param result - The balance fetch result. + */ + async #handleBalanceUpdateV6(result: BalanceFetchResult): Promise { + if (result.failedAddresses.length > 0) { + log('Skipping incomplete v6 poll snapshot', { + accountId: result.accountId, + chainId: result.chainId, + failedAddresses: result.failedAddresses, + }); + return; + } + + const newBalances: Record = {}; + + // Convert hex chain ID to CAIP-2 format + const chainIdDecimal = parseInt(result.chainId, 16); + const caipChainId = `eip155:${chainIdDecimal}` as ChainId; + + // Normalize asset IDs from BalanceFetcher (lowercase) to checksummed form + const normalizedBalances = result.balances.map((b) => ({ + ...b, + assetId: normalizeAssetId(b.assetId), + })); + + // Collect metadata for all balances + const assetsInfo = this.#collectMetadataForBalances( + normalizedBalances, + caipChainId, + ); + + // Convert balances to human-readable format. + // Unknown decimals mean the snapshot is incomplete: drop the chain + // instead of publishing a partial `full` result. + const existingMetadata = this.#getExistingAssetsMetadata(); + for (const balance of normalizedBalances) { + const stateMetadata = existingMetadata[balance.assetId]; + const pipelineMetadata = assetsInfo[balance.assetId]; + const decimals = this.#pickValidDecimals(stateMetadata, pipelineMetadata); + + if (decimals === undefined) { + log('Skipping incomplete v6 poll snapshot', { + accountId: result.accountId, + chainId: result.chainId, + assetId: balance.assetId, + }); + return; + } + + const humanReadableAmount = this.#convertToHumanReadable( + balance.balance, + decimals, + ); + + newBalances[balance.assetId] = { + amount: humanReadableAmount, + }; + } + + const response: DataResponse = { + assetsBalance: { + [result.accountId]: newBalances, + }, + assetsInfo, + updateMode: 'full', + }; + + const request: DataRequest = { + accountsWithSupportedChains: [], + chainIds: [caipChainId], + dataTypes: ['balance'], + }; + + log('Balance update response', { + accountId: result.accountId, + newBalanceCount: Object.keys(newBalances).length, + }); + + for (const subscription of this.#activeSubscriptions.values()) { + subscription.onAssetsUpdate(response, request)?.catch((error) => { + log('Failed to update assets', { error }); + }); + } + } + /** * Handle detection update from TokenDetector. * @@ -999,22 +1122,106 @@ export class RpcDataSource extends AbstractDataSource< log('Skipping fetch - onboarding not complete'); return {}; } + if (this.#isBalanceV6Enabled()) { + return this.#fetchV6(request); + } + return this.#fetchV5(request); + } - const response: DataResponse = {}; + /** + * v5 fetch. Overlays tokens that succeed, stamps `merge`. Delete with the + * rest of the v5 path if `assetsAccountsApiV6` is rolled back. + * + * @param request - The data request. + * @returns Balance and metadata for the requested chains. + */ + async #fetchV5(request: DataRequest): Promise { + const chainsToFetch = this.#getChainsToFetch(request); + if (chainsToFetch.length === 0) { + log('No active chains to fetch'); + return {}; + } - const chainsToFetch = request.chainIds.filter((chainId) => - this.#activeChains.includes(chainId), - ); + const assetsBalance: Record< + string, + Record + > = {}; + const assetsInfo: Record = {}; + const failedChains: ChainId[] = []; - log('Fetch requested', { - accounts: request.accountsWithSupportedChains.map((a) => a.account.id), - requestedChains: request.chainIds, + for (const { + account, + supportedChains, + } of request.accountsWithSupportedChains) { + const chainsForAccount = chainsToFetch.filter((chain) => + supportedChains.includes(chain), + ); + if (chainsForAccount.length === 0) { + continue; + } + + const { address, id: accountId } = account; + + for (const chainId of chainsForAccount) { + const hexChainId = caipChainIdToHex(chainId); + const nativeAssetId = this.#getNativeAssetForChain(chainId); + const shouldSkipNative = shouldSkipNativeForCaipChainId(chainId); + const assetsToFetch = this.#getAssetsToFetchV5( + request, + chainId, + shouldSkipNative, + ); + + try { + const result = await this.#balanceFetcher.fetchBalancesForAssets( + hexChainId, + accountId, + address as Address, + assetsToFetch, + ); + await this.#ingestFetchedBalances( + result, + accountId, + chainId, + assetsBalance, + assetsInfo, + ); + } catch (error) { + this.#recordFetchChainFailure({ + address, + chainId, + error, + accountId, + nativeAssetId, + shouldSkipNative, + assetsBalance, + assetsInfo, + failedChains, + }); + } + } + } + + return this.#completeFetchResponse({ + assetsBalance, + assetsInfo, + failedChains, chainsToFetch, + updateMode: 'merge', }); + } + /** + * v6 fetch. Visibility-scoped snapshot, atomic per chain, stamps `full`. + * + * @param request - The data request. + * @returns Balance and metadata for successful chains only. + */ + async #fetchV6(request: DataRequest): Promise { + const chainsToFetch = this.#getChainsToFetch(request); if (chainsToFetch.length === 0) { log('No active chains to fetch'); - return response; + return {}; } const assetsBalance: Record< @@ -1024,7 +1231,6 @@ export class RpcDataSource extends AbstractDataSource< const assetsInfo: Record = {}; const failedChains: ChainId[] = []; - // Fetch balances for each account and its supported chains (pre-computed in request) for (const { account, supportedChains, @@ -1041,116 +1247,185 @@ export class RpcDataSource extends AbstractDataSource< for (const chainId of chainsForAccount) { const hexChainId = caipChainIdToHex(chainId); const nativeAssetId = this.#getNativeAssetForChain(chainId); - const shouldSkipNative = shouldSkipNativeForCaipChainId(chainId); - const assetsToFetch: AssetFetchEntry[] = []; - if (!shouldSkipNative) { - // Build a single AssetFetchEntry[] for native + custom ERC-20s - assetsToFetch.push({ assetId: nativeAssetId, address: ZERO_ADDRESS }); - } - - this.#appendCustomErc20s( - assetsToFetch, - request.customAssets, - accountId, - chainId, - ); try { const result = await this.#balanceFetcher.fetchBalancesForAssets( hexChainId, accountId, address as Address, - assetsToFetch, + this.#getAssetsToFetchV6(accountId, chainId), ); - if (!assetsBalance[accountId]) { - assetsBalance[accountId] = {}; + // A failed `balanceOf` means we do not have the full list, so drop + // this chain instead of applying a partial result. + if (result.failedAddresses.length > 0) { + if (!failedChains.includes(chainId)) { + failedChains.push(chainId); + } + continue; } - // Normalize asset IDs from BalanceFetcher (which uses lowercase - // addresses) to checksummed form so they match assetsInfo state keys. - const normalizedBalances = result.balances.map((b) => ({ - ...b, - assetId: normalizeAssetId(b.assetId), - })); - - // Collect metadata for all balances - const balanceMetadata = this.#collectMetadataForBalances( - normalizedBalances, + await this.#ingestFetchedBalances( + result, + accountId, chainId, + assetsBalance, + assetsInfo, ); - Object.assign(assetsInfo, balanceMetadata); - - // Convert balances to human-readable format using decimals from - // assetsInfo state (which includes pendingMetadata from addCustomAsset). - // Resolution: state → pipeline metadata → RPC `decimals()`; omit balance if still unknown. - const existingMetadata = this.#getExistingAssetsMetadata(); - for (const balance of normalizedBalances) { - const stateMetadata = existingMetadata[balance.assetId]; - const pipelineMetadata = assetsInfo[balance.assetId]; - let decimals: number | undefined = this.#pickValidDecimals( - stateMetadata, - pipelineMetadata, - ); - - if (decimals === undefined) { - const parsed = parseCaipAssetType(balance.assetId); - if (this.#getAssetType(balance.assetId) === 'erc20') { - decimals = await this.#fetchDecimalsViaRpc( - chainId, - parsed.assetReference, - ); - } - } + } catch (error) { + this.#recordFetchChainFailure({ + address, + chainId, + error, + accountId, + nativeAssetId, + shouldSkipNative, + assetsBalance, + assetsInfo, + failedChains, + }); + } + } + } - if (decimals === undefined) { - continue; - } + return this.#completeFetchResponse({ + // A failed chain contributes nothing, so its balances stay as they are. + assetsBalance: filterFailedChainBalances( + assetsBalance, + new Set(failedChains), + ), + assetsInfo, + failedChains, + chainsToFetch, + updateMode: 'full', + }); + } - const humanReadableAmount = this.#convertToHumanReadable( - balance.balance, - decimals, - ); + #getChainsToFetch(request: DataRequest): ChainId[] { + const chainsToFetch = request.chainIds.filter((chainId) => + this.#activeChains.includes(chainId), + ); - assetsBalance[accountId][balance.assetId] = { - amount: humanReadableAmount, - }; - } - } catch (error) { - log('Failed to fetch balance', { address, chainId, error }); + log('Fetch requested', { + accounts: request.accountsWithSupportedChains.map((a) => a.account.id), + requestedChains: request.chainIds, + chainsToFetch, + }); - if (!assetsBalance[accountId]) { - assetsBalance[accountId] = {}; - } + return chainsToFetch; + } - if (!shouldSkipNative) { - assetsBalance[accountId][nativeAssetId] = { amount: '0' }; - } - // On error, emit a stub only when no valid metadata exists in state - // yet. Re-emitting existing metadata would overwrite richer entries - // (e.g. image/description added by AccountsAPI) with a simpler stub. - const existingNativeMeta = - this.#getExistingAssetsMetadata()[nativeAssetId]; - if (!this.#hasValidDecimals(existingNativeMeta)) { - const chainStatus = this.#chainStatuses[chainId]; - if (chainStatus) { - assetsInfo[nativeAssetId] = { - type: 'native', - symbol: chainStatus.nativeCurrency, - name: chainStatus.nativeCurrency, - decimals: 18, - }; - } - } + async #ingestFetchedBalances( + result: BalanceFetchResult, + accountId: string, + chainId: ChainId, + assetsBalance: Record>, + assetsInfo: Record, + ): Promise { + assetsBalance[accountId] ??= {}; - if (!failedChains.includes(chainId)) { - failedChains.push(chainId); - } + const normalizedBalances = result.balances.map((balance) => ({ + ...balance, + assetId: normalizeAssetId(balance.assetId), + })); + + Object.assign( + assetsInfo, + this.#collectMetadataForBalances(normalizedBalances, chainId), + ); + + const existingMetadata = this.#getExistingAssetsMetadata(); + for (const balance of normalizedBalances) { + const stateMetadata = existingMetadata[balance.assetId]; + const pipelineMetadata = assetsInfo[balance.assetId]; + let decimals: number | undefined = this.#pickValidDecimals( + stateMetadata, + pipelineMetadata, + ); + + if (decimals === undefined) { + const parsed = parseCaipAssetType(balance.assetId); + if (this.#getAssetType(balance.assetId) === 'erc20') { + decimals = await this.#fetchDecimalsViaRpc( + chainId, + parsed.assetReference, + ); } } + + if (decimals === undefined) { + continue; + } + + assetsBalance[accountId][balance.assetId] = { + amount: this.#convertToHumanReadable(balance.balance, decimals), + }; + } + } + + #recordFetchChainFailure({ + address, + chainId, + error, + accountId, + nativeAssetId, + shouldSkipNative, + assetsBalance, + assetsInfo, + failedChains, + }: { + address: string; + chainId: ChainId; + error: unknown; + accountId: string; + nativeAssetId: Caip19AssetId; + shouldSkipNative: boolean; + assetsBalance: Record>; + assetsInfo: Record; + failedChains: ChainId[]; + }): void { + log('Failed to fetch balance', { address, chainId, error }); + + assetsBalance[accountId] ??= {}; + + if (!shouldSkipNative) { + assetsBalance[accountId][nativeAssetId] = { amount: '0' }; + } + const existingNativeMeta = + this.#getExistingAssetsMetadata()[nativeAssetId]; + if (!this.#hasValidDecimals(existingNativeMeta)) { + const chainStatus = this.#chainStatuses[chainId]; + if (chainStatus) { + assetsInfo[nativeAssetId] = { + type: 'native', + symbol: chainStatus.nativeCurrency, + name: chainStatus.nativeCurrency, + decimals: 18, + }; + } } + if (!failedChains.includes(chainId)) { + failedChains.push(chainId); + } + } + + #completeFetchResponse({ + assetsBalance, + assetsInfo, + failedChains, + chainsToFetch, + updateMode, + }: { + assetsBalance: DataResponse['assetsBalance']; + assetsInfo: Record; + failedChains: ChainId[]; + chainsToFetch: ChainId[]; + updateMode: DataResponse['updateMode']; + }): DataResponse { + const response: DataResponse = { updateMode }; + if (failedChains.length > 0) { log('Fetch PARTIAL - some chains failed', { successChains: chainsToFetch.filter( @@ -1166,19 +1441,16 @@ export class RpcDataSource extends AbstractDataSource< } else { log('Fetch SUCCESS', { chains: chainsToFetch, - accountCount: Object.keys(assetsBalance).length, + accountCount: Object.keys(assetsBalance ?? {}).length, }); } response.assetsBalance = assetsBalance; - // Include metadata for native tokens if we have any if (Object.keys(assetsInfo).length > 0) { response.assetsInfo = assetsInfo; } - response.updateMode = 'merge'; - return response; } @@ -1335,6 +1607,13 @@ export class RpcDataSource extends AbstractDataSource< }; } + if (response.updateMode) { + context.response = { + ...context.response, + updateMode: response.updateMode, + }; + } + const failedChains = new Set(Object.keys(response.errors ?? {})); successfullyHandledChains = supportedChains.filter( (chainId) => !failedChains.has(chainId), @@ -1502,57 +1781,140 @@ export class RpcDataSource extends AbstractDataSource< } /** - * Append the ERC-20 pins this account-chain fetch must cover. + * v5 entries for one account-chain fetch: native plus the ERC-20s the + * caller scoped via `request.customAssets`. Unchanged from the pre-v6 + * behavior; delete with the rest of the v5 path. * - * Prefers `request.customAssets` when the caller scoped the fetch. - * Otherwise reads the - * account's visible pins from state: RPC is their sole balance fetcher, and - * `fetch` builds its own entry list rather than going through - * `BalanceFetcher`'s state read (which polling uses), so an unscoped request - * would otherwise leave them stale until the next poll. + * @param request - The data request being fetched. + * @param chainId - Chain being fetched. + * @param shouldSkipNative - When true, omit the native entry. + * @returns Entries to hand to the balance fetcher. + */ + #getAssetsToFetchV5( + request: DataRequest, + chainId: ChainId, + shouldSkipNative: boolean, + ): AssetFetchEntry[] { + const assetsToFetch: AssetFetchEntry[] = []; + + if (!shouldSkipNative) { + // Build a single AssetFetchEntry[] for native + custom ERC-20s + assetsToFetch.push({ + assetId: this.#getNativeAssetForChain(chainId), + address: ZERO_ADDRESS, + }); + } + + if (request.customAssets) { + const existingMetadata = this.#getExistingAssetsMetadata(); + + for (const assetId of request.customAssets) { + try { + const parsed = parseCaipAssetType(assetId); + const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + if ( + assetChainId === chainId && + this.#getAssetType(assetId) === 'erc20' + ) { + const tokenAddress = parsed.assetReference.toLowerCase() as Address; + const normalizedId = normalizeAssetId(assetId); + const decimals = existingMetadata[normalizedId]?.decimals; + + assetsToFetch.push({ + assetId, + address: tokenAddress, + decimals, + }); + } + } catch { + // Skip unparseable asset IDs + } + } + } + + return assetsToFetch; + } + + /** + * v6 entries for one account-chain fetch: the shared visible set (native, + * pins, default tracked) plus already-tracked ERC-20s that are not hidden. + * Chains with no native token omit native from `visibleAssetIds`. Hide is + * only applied to `assetsBalance` rows. The request is never scoped via + * `customAssets` on v6. * - * @param assetsToFetch - Native/custom entries for this account-chain fetch. - * @param requestCustomAssets - Flat pin list from the data request, if scoped. * @param accountId - Account being fetched. * @param chainId - Chain being fetched. + * @returns Entries to hand to the balance fetcher. */ - #appendCustomErc20s( - assetsToFetch: AssetFetchEntry[], - requestCustomAssets: Caip19AssetId[] | undefined, - accountId: string, - chainId: ChainId, - ): void { - const { - assetsInfo = {}, - customAssets = {}, - assetPreferences = {}, - } = this.#getAssetsState(); - const candidates = - requestCustomAssets && requestCustomAssets.length > 0 - ? requestCustomAssets - : (customAssets[accountId] ?? []).filter( - (assetId) => !assetPreferences[normalizeAssetId(assetId)]?.hidden, - ); + #getAssetsToFetchV6(accountId: string, chainId: ChainId): AssetFetchEntry[] { + const { assetsInfo = {}, assetsBalance = {} } = this.#getAssetsState(); + const { visibleAssetIds, hiddenAssetIds } = this.#getAssetVisibility( + [accountId], + [chainId], + ); + const hidden = new Set(hiddenAssetIds); + const assetsToFetch: AssetFetchEntry[] = []; + const seen = new Set(); + + // Fetch visible assets + for (const assetId of visibleAssetIds) { + try { + if (this.#getAssetType(assetId) === 'native') { + assetsToFetch.push({ assetId, address: ZERO_ADDRESS }); + continue; + } + + const parsed = parseCaipAssetType(assetId); + const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + if ( + assetChainId !== chainId || + this.#getAssetType(assetId) !== 'erc20' || + seen.has(assetId) + ) { + continue; + } - for (const assetId of candidates) { + seen.add(assetId); + assetsToFetch.push({ + assetId, + address: parsed.assetReference.toLowerCase() as Address, + decimals: assetsInfo[assetId]?.decimals, + }); + } catch { + // Skip unparseable asset IDs + } + } + + // Fetch already-tracked ERC-20s that are not hidden + for (const assetId of Object.keys( + assetsBalance[accountId] ?? {}, + ) as Caip19AssetId[]) { try { const parsed = parseCaipAssetType(assetId); const assetChainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; const normalizedId = normalizeAssetId(assetId); if ( - assetChainId === chainId && - this.#getAssetType(assetId) === 'erc20' + hidden.has(normalizedId) || + isStakingContractAssetId(assetId) || + assetChainId !== chainId || + this.#getAssetType(assetId) !== 'erc20' || + seen.has(normalizedId) ) { - assetsToFetch.push({ - assetId, - address: parsed.assetReference.toLowerCase() as Address, - decimals: assetsInfo[normalizedId]?.decimals, - }); + continue; } + + seen.add(normalizedId); + assetsToFetch.push({ + assetId, + address: parsed.assetReference.toLowerCase() as Address, + decimals: assetsInfo[normalizedId]?.decimals, + }); } catch { // Skip unparseable asset IDs } } + + return assetsToFetch; } /** diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts index beb9bfcedf5..16a2aa2745b 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts @@ -10,6 +10,7 @@ import type { import type { AssetsControllerMessenger } from '../AssetsController.js'; import type { ChainId, DataRequest, Context, Caip19AssetId } from '../types.js'; +import type { GetAssetVisibility } from '../utils/assetVisibility.js'; import type { SnapDataSourceOptions, AccountBalancesUpdatedEventPayload, @@ -43,6 +44,8 @@ type RootMessenger = Messenger; const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; const MOCK_SOL_ASSET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as Caip19AssetId; +const MOCK_SOL_PIN = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:So11111111111111111111111111111111111111112' as Caip19AssetId; const MOCK_STELLAR_ASSET = 'stellar:pubnet/slip44:148' as Caip19AssetId; const MOCK_BTC_ASSET = 'bip122:000000000019d6689c085ae165831e93/slip44:0' as Caip19AssetId; @@ -179,9 +182,20 @@ function setupController( accountAssets?: string[]; balances?: Record; configuredNetworks?: ChainId[]; + isBalanceV6Enabled?: () => boolean; + getAssetVisibility?: GetAssetVisibility; } = {}, ): SetupResult { - const { installedSnaps = {}, accountAssets = [], balances = {} } = options; + const { + installedSnaps = {}, + accountAssets = [], + balances = {}, + isBalanceV6Enabled = (): boolean => false, + getAssetVisibility = (): ReturnType => ({ + visibleAssetIds: [], + hiddenAssetIds: [], + }), + } = options; const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -254,6 +268,8 @@ function setupController( messenger: controllerMessenger as unknown as AssetsControllerMessenger, onActiveChainsUpdated: activeChainsUpdateHandler, onAssetsUpdate: assetsUpdateHandler, + isBalanceV6Enabled, + getAssetVisibility, }; const controller = new SnapDataSource(controllerOptions); @@ -526,6 +542,44 @@ describe('SnapDataSource', () => { cleanup(); }); + it('returns a full v6 snapshot with visible assets missing from the snap set to zero', async () => { + const getAssetVisibility = jest.fn().mockReturnValue({ + visibleAssetIds: [MOCK_SOL_ASSET, MOCK_SOL_PIN], + hiddenAssetIds: [], + }); + const { controller, cleanup } = setupController({ + installedSnaps: { + [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, + }, + accountAssets: [MOCK_SOL_ASSET], + balances: { + [MOCK_SOL_ASSET]: { amount: '1000000000', unit: 'SOL' }, + }, + isBalanceV6Enabled: () => true, + getAssetVisibility, + }); + await new Promise(process.nextTick); + + const response = await controller.fetch(createDataRequest()); + + expect(getAssetVisibility).toHaveBeenCalledWith( + ['mock-account-id'], + [SOLANA_MAINNET], + ); + expect(response).toStrictEqual({ + assetsBalance: { + 'mock-account-id': { + [MOCK_SOL_ASSET]: { amount: '1000000000' }, + [MOCK_SOL_PIN]: { amount: '0' }, + }, + }, + assetsInfo: {}, + updateMode: 'full', + }); + + cleanup(); + }); + it('fetch includes balance metadata when provided by the snap', async () => { const balanceMetadata = { spendable: '900000000', @@ -631,8 +685,10 @@ describe('SnapDataSource', () => { installedSnaps: { [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, }, + isBalanceV6Enabled: () => true, }); await new Promise(process.nextTick); + assetsUpdateHandler.mockClear(); triggerBalancesUpdated({ balances: { @@ -644,15 +700,15 @@ describe('SnapDataSource', () => { await new Promise(process.nextTick); - expect(assetsUpdateHandler).toHaveBeenCalledWith( - expect.objectContaining({ - assetsBalance: { - 'account-1': { - [MOCK_SOL_ASSET]: { amount: '1000000000' }, - }, + expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); + expect(assetsUpdateHandler).toHaveBeenCalledWith({ + updateMode: 'merge', + assetsBalance: { + 'account-1': { + [MOCK_SOL_ASSET]: { amount: '1000000000' }, }, - }), - ); + }, + }); cleanup(); }); @@ -948,6 +1004,37 @@ describe('SnapDataSource', () => { cleanup(); }); + it('middleware forwards full update mode for v6 snapshots', async () => { + const { controller, cleanup } = setupController({ + installedSnaps: { + [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, + }, + accountAssets: [MOCK_SOL_ASSET], + balances: { + [MOCK_SOL_ASSET]: { amount: '1000000000', unit: 'SOL' }, + }, + isBalanceV6Enabled: () => true, + getAssetVisibility: () => ({ + visibleAssetIds: [MOCK_SOL_ASSET], + hiddenAssetIds: [], + }), + }); + await new Promise(process.nextTick); + + const next = jest.fn().mockImplementation(async (context) => context); + const context = createMiddlewareContext(); + + await controller.assetsMiddleware(context, next); + + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + response: expect.objectContaining({ updateMode: 'full' }), + }), + ); + + cleanup(); + }); + it('middleware removes handled chains from next request', async () => { const { controller, cleanup } = setupController({ installedSnaps: { @@ -1072,6 +1159,11 @@ describe('SnapDataSource', () => { messenger: controllerMessenger as unknown as AssetsControllerMessenger, onActiveChainsUpdated: jest.fn(), onAssetsUpdate: jest.fn(), + isBalanceV6Enabled: () => false, + getAssetVisibility: () => ({ + visibleAssetIds: [], + hiddenAssetIds: [], + }), }); await new Promise(process.nextTick); diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index 6e4dd8b6a4b..76bc2b5fb08 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -27,6 +27,7 @@ import type { DataResponse, Middleware, } from '../types.js'; +import type { GetAssetVisibility } from '../utils/assetVisibility.js'; import { AbstractDataSource } from './AbstractDataSource.js'; import type { DataSourceState, @@ -173,6 +174,10 @@ export type SnapDataSourceOptions = { * controller currently has an active subscription for the chain. */ onAssetsUpdate: (response: DataResponse) => void | Promise; + /** Whether authoritative v6 balance snapshots are enabled. */ + isBalanceV6Enabled: () => boolean; + /** Resolves native, pinned, default, and hidden assets for a request scope. */ + getAssetVisibility: GetAssetVisibility; /** Configured networks to support (defaults to all snap networks) */ configuredNetworks?: ChainId[]; /** Default polling interval in ms for subscriptions */ @@ -217,6 +222,10 @@ export class SnapDataSource extends AbstractDataSource< readonly #onAssetsUpdate: (response: DataResponse) => void | Promise; + readonly #isBalanceV6Enabled: () => boolean; + + readonly #getAssetVisibility: GetAssetVisibility; + /** Bound handler for snap keyring balance updates, stored for cleanup */ readonly #handleSnapBalancesUpdatedBound: ( payload: AccountBalancesUpdatedEventPayload, @@ -236,6 +245,8 @@ export class SnapDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; this.#onAssetsUpdate = options.onAssetsUpdate; + this.#isBalanceV6Enabled = options.isBalanceV6Enabled; + this.#getAssetVisibility = options.getAssetVisibility; // Bind handlers for cleanup in destroy() this.#handleSnapBalancesUpdatedBound = this.#handleSnapBalancesUpdated.bind( @@ -453,6 +464,22 @@ export class SnapDataSource extends AbstractDataSource< if (!request?.chainIds?.length) { return {}; } + + if (this.#isBalanceV6Enabled()) { + return this.#fetchV6(request); + } + + return this.#fetchV5(request); + } + + /** + * v5 fetch. Unchanged from the pre-v6 handler; delete the v6 sibling first + * if `assetsAccountsApiV6` is rolled back. + * + * @param request - The data request. + * @returns Overlay (`merge`) balances from the snap keyring. + */ + async #fetchV5(request: DataRequest): Promise { if (!request?.accountsWithSupportedChains?.length) { return { assetsBalance: {}, assetsInfo: {}, updateMode: 'merge' }; } @@ -519,6 +546,125 @@ export class SnapDataSource extends AbstractDataSource< return results; } + /** + * v6 fetch: a complete snapshot of the requested account-chain slices. + * The snap only reports assets the account holds, so every other visible + * asset (native, pin, default tracked) is added at zero — otherwise a + * `full` replace would drop pins the Accounts API cannot resolve yet. + * Hidden assets are left out entirely. Delete with the rest of the v6 path + * if `assetsAccountsApiV6` is rolled back. + * + * @param request - The data request. + * @returns Authoritative (`full`) balances for the requested slices. + */ + async #fetchV6(request: DataRequest): Promise { + if (!request?.accountsWithSupportedChains?.length) { + return { assetsBalance: {}, assetsInfo: {}, updateMode: 'full' }; + } + + const results: DataResponse = { + assetsBalance: {}, + assetsInfo: {}, + updateMode: 'full', + }; + + // Fetch balances for each account using its snap ID from metadata + for (const { account } of request.accountsWithSupportedChains) { + // Skip accounts without snap metadata (non-snap accounts) + const snapId = account.metadata.snap?.id; + if (!snapId) { + continue; + } + + // Skip accounts whose snap doesn't support any of the requested chains + const supportedChainIds = request.chainIds.filter( + (chainId) => this.state.chainToSnap[chainId] === snapId, + ); + if (supportedChainIds.length === 0) { + continue; + } + + const accountId = account.id; + try { + const client = this.#getKeyringClient(snapId); + const { visibleAssetIds, hiddenAssetIds } = this.#getAssetVisibility( + [accountId], + supportedChainIds, + ); + + // Step 1: Get the list of assets for this account + const accountAssets = await client.listAccountAssets(accountId); + const assetsToFetch = this.#selectAssetsToFetchV6( + accountAssets ?? [], + supportedChainIds, + hiddenAssetIds, + ); + + // Step 2: Get balances for those specific assets + const balances: Record = assetsToFetch.length + ? await client.getAccountBalances(accountId, assetsToFetch) + : {}; + + // Transform keyring response to DataResponse format + const accountBalances: Record = {}; + if (balances && typeof balances === 'object') { + for (const [assetId, balance] of Object.entries(balances)) { + accountBalances[assetId] = { + amount: balance.amount, + ...(balance.metadata ? { metadata: balance.metadata } : {}), + }; + } + } + + // Step 3: Complete the snapshot so the replace cannot drop assets the + // snap does not report a holding for. + for (const assetId of visibleAssetIds) { + accountBalances[assetId] ??= { amount: '0' }; + } + + if (results.assetsBalance) { + results.assetsBalance[accountId] = accountBalances; + } + } catch { + // Expected when account doesn't belong to this snap + } + } + + return results; + } + + /** + * Narrow the snap's asset list to the requested chains, dropping assets the + * user hid so they are neither fetched nor written back to state. + * + * @param accountAssets - Asset IDs the snap listed for the account. + * @param supportedChainIds - Chains this snap was asked about. + * @param hiddenAssetIds - Asset IDs the user hid in this scope. + * @returns Asset IDs to request balances for. + */ + #selectAssetsToFetchV6( + accountAssets: string[], + supportedChainIds: ChainId[], + hiddenAssetIds: Caip19AssetId[], + ): CaipAssetType[] { + const supportedChains = new Set(supportedChainIds); + const hidden = new Set( + hiddenAssetIds.map((assetId) => assetId.toLowerCase()), + ); + + return accountAssets.filter((assetId): assetId is CaipAssetType => { + try { + return ( + supportedChains.has(extractChainFromAssetId(assetId)) && + !hidden.has(assetId.toLowerCase()) + ); + } catch { + // Skip unparseable asset IDs + return false; + } + }); + } + // ============================================================================ // MIDDLEWARE // ============================================================================ @@ -581,6 +727,12 @@ export class SnapDataSource extends AbstractDataSource< ...response.assetsPrice, }; } + if (response.updateMode) { + context.response = { + ...context.response, + updateMode: response.updateMode, + }; + } // Determine successfully handled chains (exclude errors) const failedChains = new Set(Object.keys(response.errors ?? {})); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts index 795b6f92b9b..852598bbb47 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.test.ts @@ -1,5 +1,7 @@ import type { CaipAssetType } from '@metamask/utils'; +import { getDefaultTrackedAssetsForChain } from '../../../defaults.js'; +import type { ChainId as AssetsChainId } from '../../../types.js'; import type { MulticallClient } from '../clients/index.js'; import type { Address, @@ -103,7 +105,13 @@ function createMockBalanceResponse( // ============================================================================= type WithControllerOptions = { - config?: BalanceFetcherConfig; + config?: Omit< + BalanceFetcherConfig, + 'getAssetVisibility' | 'isBalanceV6Enabled' + > & + Partial< + Pick + >; assetsBalanceState?: AssetsBalanceState; }; @@ -129,13 +137,21 @@ async function withController( config = { isNativeAsset: (): boolean => false }, assetsBalanceState, } = options; + const resolvedConfig: BalanceFetcherConfig = { + isBalanceV6Enabled: () => false, + getAssetVisibility: () => ({ + visibleAssetIds: [], + hiddenAssetIds: [], + }), + ...config, + }; const mockMulticallClient = createMockMulticallClient(); const mockMessenger = createMockMessenger(assetsBalanceState); const controller = new BalanceFetcher( mockMulticallClient, mockMessenger, - config, + resolvedConfig, ); try { @@ -316,6 +332,115 @@ describe('BalanceFetcher', () => { ); }); + it('on the v6 path polls default tracked assets even when they have no assetsBalance entry', async () => { + const [mainnetMusd] = getDefaultTrackedAssetsForChain( + 'eip155:1' as AssetsChainId, + ); + const musdAddress = mainnetMusd.split(':').at(-1)?.toLowerCase() ?? ''; + + const mockState: AssetsBalanceState = { + assetsBalance: { + [TEST_ACCOUNT_ID]: { + [NATIVE_ETH_ASSET_ID]: { amount: '0' }, + }, + }, + }; + + await withController( + { + assetsBalanceState: mockState, + config: { + isNativeAsset: (id: CaipAssetType) => id === NATIVE_ETH_ASSET_ID, + isBalanceV6Enabled: (): boolean => true, + getAssetVisibility: () => ({ + visibleAssetIds: [NATIVE_ETH_ASSET_ID, mainnetMusd], + hiddenAssetIds: [], + }), + }, + }, + async ({ controller, mockMulticallClient }) => { + controller.setOnBalanceUpdate(jest.fn()); + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + ZERO_ADDRESS, + TEST_ACCOUNT, + true, + '1000000000000000000', + ), + createMockBalanceResponse( + musdAddress as Address, + TEST_ACCOUNT, + true, + '0', + ), + ]); + + await controller._executePoll({ + chainId: MAINNET_CHAIN_ID, + accountId: TEST_ACCOUNT_ID, + accountAddress: TEST_ACCOUNT, + }); + + const [, batchedRequests] = + mockMulticallClient.batchBalanceOf.mock.calls[0]; + const requestedTokens = ( + batchedRequests as { tokenAddress: string }[] + ).map((req) => req.tokenAddress.toLowerCase()); + expect(requestedTokens).toContain(ZERO_ADDRESS.toLowerCase()); + expect(requestedTokens).toContain(musdAddress); + }, + ); + }); + + it('on the v6 path stops polling a tracked asset the user hid', async () => { + const mockState: AssetsBalanceState = { + assetsBalance: { + [TEST_ACCOUNT_ID]: { + [NATIVE_ETH_ASSET_ID]: { amount: '1' }, + [TOKEN_1_ASSET_ID]: { amount: '500' }, + }, + }, + }; + + await withController( + { + assetsBalanceState: mockState, + config: { + isNativeAsset: (id: CaipAssetType) => id === NATIVE_ETH_ASSET_ID, + isBalanceV6Enabled: (): boolean => true, + getAssetVisibility: () => ({ + visibleAssetIds: [NATIVE_ETH_ASSET_ID], + hiddenAssetIds: [TOKEN_1_ASSET_ID], + }), + }, + }, + async ({ controller, mockMulticallClient }) => { + controller.setOnBalanceUpdate(jest.fn()); + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + ZERO_ADDRESS, + TEST_ACCOUNT, + true, + '1000000000000000000', + ), + ]); + + await controller._executePoll({ + chainId: MAINNET_CHAIN_ID, + accountId: TEST_ACCOUNT_ID, + accountAddress: TEST_ACCOUNT, + }); + + const [, batchedRequests] = + mockMulticallClient.batchBalanceOf.mock.calls[0]; + const requestedTokens = ( + batchedRequests as { tokenAddress: string }[] + ).map((req) => req.tokenAddress.toLowerCase()); + expect(requestedTokens).toStrictEqual([ZERO_ADDRESS.toLowerCase()]); + }, + ); + }); + it('in customAssetsOnly mode skips state.assetsBalance and only fetches state.customAssets', async () => { // The supplemental subscription path: another data source covers the // chain for regular balances, but RPC must still poll the user's diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts index 44d8af36fa1..b3386c7860b 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/BalanceFetcher.ts @@ -1,6 +1,8 @@ import { StaticIntervalPollingControllerOnly } from '@metamask/polling-controller'; import { parseCaipAssetType } from '@metamask/utils'; +import type { ChainId as Caip2ChainId } from '../../../types.js'; +import type { GetAssetVisibility } from '../../../utils/assetVisibility.js'; import { ZERO_ADDRESS } from '../../../utils/constants.js'; import type { MulticallClient } from '../clients/index.js'; import type { @@ -33,6 +35,13 @@ export type BalanceFetcherConfig = { pollingInterval?: number; /** Determines whether a CAIP-19 asset ID represents a native asset. */ isNativeAsset: (assetId: CaipAssetType) => boolean; + /** + * When true, polls default tracked assets (e.g. mUSD) in addition to + * `assetsBalance` and `customAssets`. v5 omits this and stays pins + balances. + */ + isBalanceV6Enabled: () => boolean; + /** Returns shared visible/hidden assets for an account/chain scope. */ + getAssetVisibility: GetAssetVisibility; }; /** @@ -76,11 +85,21 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly + Omit< + BalanceFetcherConfig, + | 'pollingInterval' + | 'isNativeAsset' + | 'isBalanceV6Enabled' + | 'getAssetVisibility' + > >; readonly #isNativeAsset: (assetId: CaipAssetType) => boolean; + readonly #isBalanceV6Enabled: () => boolean; + + readonly #getAssetVisibility: GetAssetVisibility; + #onBalanceUpdate: OnBalanceUpdateCallback | undefined; constructor( @@ -96,6 +115,8 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly