diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index d1175291c6a..8f07c67ece7 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `AccountsApiDataSource` now forwards user-pinned custom assets to the Accounts API v6 balances endpoint as `includeAssetIds`, so the backend returns those assets even at a zero balance. Pinned EVM assets the backend could not resolve are reported back as `unprocessedIncludeAssetIds` and surfaced on the new asset-axis signal `DataResponse.unprocessedCustomAssets` (the chain itself is no longer marked as errored) ([#9600](https://github.com/MetaMask/core/pull/9600)) +- The data-source protocol now separates chain-level and asset-level fallback. `DataResponse` gains `unprocessedCustomAssets` (specific requested asset IDs a source could not resolve, distinct from chain-level `errors`). `RpcFallbackMiddleware` recovers `unprocessedCustomAssets` by issuing an RPC request scoped to just those assets (passed as `customAssets`), instead of re-fetching every pin on the chain ([#9600](https://github.com/MetaMask/core/pull/9600)) +- `AssetsController.getAssets` (force-update) no longer runs `RpcFallbackMiddleware` on the fast path. Chains an upstream source leaves outstanding are now recovered by RPC in the background slow pipeline instead: `#getSlowPipelineChainIds` includes both errored chains (e.g. `unprocessedNetworks`) and the chains of any `unprocessedCustomAssets` (pinned assets the backend could not resolve), so RPC fetches them off the fast path ([#9600](https://github.com/MetaMask/core/pull/9600)) +- `AssetsController` subscription/poll balance updates now run the RPC fallback before enrichment (when basic functionality is on), so anything an upstream source leaves outstanding is fetched from RPC during polling: errored chains (e.g. `unprocessedNetworks`) via a full-chain fetch, and pinned assets the backend could not resolve (`unprocessedCustomAssets`) via an asset-scoped fetch. (The poll path has no slow pipeline, so it uses `RpcFallbackMiddleware` directly.) ([#9600](https://github.com/MetaMask/core/pull/9600)) +- `AccountsApiDataSource` now forwards user-hidden assets (from `assetPreferences`) to the Accounts API v6 balances endpoint as `excludeAssetIds`, so the backend drops them from the response even when detected or carrying a non-zero balance. A pinned asset always wins over a hidden one, so any overlap with `includeAssetIds` is dropped from `excludeAssetIds` ([#9600](https://github.com/MetaMask/core/pull/9600)) + +### Removed + +- **BREAKING:** Remove `CustomAssetGraduationMiddleware` (and `CustomAssetGraduationMiddlewareOptions`) from the public API. User-pinned custom assets are now treated as "display no matter what" and are no longer auto-removed when a balance is detected ([#9600](https://github.com/MetaMask/core/pull/9600)) +- **BREAKING:** Remove the `customAssetsOnly` field from `DataRequest`. The dedicated RPC "custom assets only" supplemental subscription is removed now that the v6 endpoint returns pinned custom assets directly ([#9600](https://github.com/MetaMask/core/pull/9600)) + ## [11.1.1] ### Changed diff --git a/packages/assets-controller/jest.config.js b/packages/assets-controller/jest.config.js index 111146cdabf..d8268eb0503 100644 --- a/packages/assets-controller/jest.config.js +++ b/packages/assets-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 80.2, - functions: 88.9, - lines: 89.57, - statements: 89.47, + branches: 79.9, + functions: 88.8, + lines: 89.5, + statements: 89.4, }, }, }); diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 6916fd97a9b..ae69e933469 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -21,6 +21,7 @@ import type { import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource'; import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; +import { RpcDataSource } from './data-sources/RpcDataSource'; import { TokenDataSource } from './data-sources/TokenDataSource'; import { buildDefaultAssetsInfo } from './defaults'; import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata'; @@ -712,99 +713,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 BackendWebsocketDataSource 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' }, - }, - }, - }, - 'BackendWebsocketDataSource', - ); - - 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 }) => { @@ -1092,6 +1000,108 @@ 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('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. @@ -1169,6 +1179,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({ + accounts: [], + 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, @@ -1547,6 +1627,113 @@ 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(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(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', () => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 0899544f963..7355004e807 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -100,7 +100,6 @@ import { } from './defaults'; import { AssetsDataSourceError } from './errors'; import { projectLogger, createModuleLogger } from './logger'; -import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware'; import { DetectionMiddleware } from './middlewares/DetectionMiddleware'; import { createParallelBalanceMiddleware, @@ -146,7 +145,6 @@ import type { TransactionPayLegacyFormat, } from './utils'; import { ZERO_ADDRESS } from './utils/constants'; -import { pickRpcCustomAssetsSupplement } from './utils/customAssetsRpcSupplement'; import { processAccountActivityBalanceUpdates } from './utils/processAccountActivityBalanceUpdates'; const NATIVE_ASSETS_QUERY_KEY = ['nativeAssets']; @@ -816,8 +814,6 @@ export class AssetsController extends BaseController< readonly #detectionMiddleware: DetectionMiddleware; - readonly #customAssetGraduationMiddleware: CustomAssetGraduationMiddleware; - readonly #rpcFallbackMiddleware: RpcFallbackMiddleware; readonly #tokenDataSource: TokenDataSource; @@ -948,19 +944,6 @@ export class AssetsController extends BaseController< ...priceDataSourceConfig, }); this.#detectionMiddleware = new DetectionMiddleware(); - this.#customAssetGraduationMiddleware = new CustomAssetGraduationMiddleware( - { - getSelectedAccountId: (): AccountId | undefined => { - try { - return this.#getSelectedAccounts()[0]?.id; - } catch { - return undefined; - } - }, - removeCustomAsset: (accountId, assetId): void => - this.removeCustomAsset(accountId, assetId), - }, - ); this.#rpcFallbackMiddleware = new RpcFallbackMiddleware({ rpcDataSource: this.#rpcDataSource, }); @@ -1633,12 +1616,15 @@ export class AssetsController extends BaseController< customAssets.push(...accountCustomAssets); } + const hiddenAssets = this.#getHiddenAssetIds(); + if (options?.forceUpdate) { const startTime = performance.now(); const request = this.#buildDataRequest(accounts, chainIds, { assetTypes, dataTypes, customAssets: customAssets.length > 0 ? customAssets : undefined, + excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, forceUpdate: true, assetsForPriceUpdate: options?.assetsForPriceUpdate, }); @@ -1646,6 +1632,10 @@ export class AssetsController extends BaseController< // 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. + // Anything an upstream source leaves outstanding — chains it flagged as + // errored, and pinned assets the backend could not resolve + // (`unprocessedCustomAssets`) — is recovered by RPC in the slow pipeline below + // (see `#getSlowPipelineChainIds`), keeping RPC off the fast path. // // Fast/slow pipelines use merge so partial API snapshots cannot wipe // tokens missing from the response (e.g. USDC when only native balance @@ -1656,11 +1646,6 @@ export class AssetsController extends BaseController< 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, @@ -1682,6 +1667,11 @@ export class AssetsController extends BaseController< // commits to state. Their balances are merged together before detection. // Token + price enrichment matches the pre-split behavior: only when basic // functionality is on (RPC-only mode must not call token/price APIs). + // This is also where the fast path's RPC fallback now happens: errored + // chains and the chains of any `unprocessedCustomAssets` (pinned assets the + // backend could not resolve) are included so RPC fetches them. The slow + // request carries `customAssets`, so a whole-chain RPC fetch resolves the + // pinned assets on those chains. const slowPipelineChainIds = this.#getSlowPipelineChainIds( chainIds, response, @@ -1993,8 +1983,8 @@ export class AssetsController extends BaseController< }); } - // 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 customAsset (sent to + // AccountsApi as `includeAssetIds`, fetched by RPC on chains it owns). this.#subscribeAssets(); } @@ -2022,8 +2012,8 @@ 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 the removed customAsset is dropped from + // subsequent polls (AccountsApi `includeAssetIds` and RPC). this.#subscribeAssets(); } @@ -2059,6 +2049,10 @@ export class AssetsController extends BaseController< } state.assetPreferences[normalizedAssetId].hidden = true; }); + + // Re-evaluate subscriptions so subsequent polls send the newly hidden asset + // as `excludeAssetIds` and the Accounts API drops it from the response. + this.#subscribeAssets(); } /** @@ -2080,6 +2074,27 @@ export class AssetsController extends BaseController< } } }); + + // Re-evaluate subscriptions so the un-hidden asset is dropped from + // `excludeAssetIds` on subsequent polls. + this.#subscribeAssets(); + } + + /** + * Collect all globally hidden asset IDs (from `assetPreferences`). Forwarded + * on data requests as `excludeAssetIds` so the Accounts API removes them from + * the response even when detected or carrying a non-zero balance. + * + * @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; } // ============================================================================ @@ -2374,10 +2389,23 @@ export class AssetsController extends BaseController< this.#accountsApiDataSource.getActiveChainsSync(), ); + // Chains carrying pinned assets the backend could not resolve + // (`unprocessedCustomAssets`). The chain itself succeeded on the fast path, but + // the pin still needs a fresh RPC fetch — route it to the slow pipeline so + // RPC resolves it (via `customAssets` in the slow request). + 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; } @@ -2963,16 +2991,11 @@ 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:`. Split on `:` and + // pick the source-name segment. 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); } } @@ -3040,8 +3063,6 @@ export class AssetsController extends BaseController< ? this.#allBalanceDataSources : [this.#rpcDataSource]; - let rpcAssignedChains: Set = new Set(); - for (const source of balanceDataSources) { const availableChains = new Set(source.getActiveChainsSync()); const assignedChains: ChainId[] = []; @@ -3058,10 +3079,6 @@ export class AssetsController extends BaseController< continue; } - if (source === this.#rpcDataSource) { - rpcAssignedChains = new Set(assignedChains); - } - const seenIds = new Set(); const accountsForSource = assignedChains .flatMap((chainId) => chainToAccounts.get(chainId) ?? []) @@ -3076,66 +3093,6 @@ export class AssetsController extends BaseController< this.#subscribeDataSource(source, accountsForSource, assignedChains); } } - - // 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, - rpcAssignedChains, - ); - } - - /** - * Guarantee that customAssets are **always** polled by RPC, even when - * AccountsApi or the websocket 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. - * - * @param accounts - Accounts to consider for customAssets. - * @param chainToAccounts - Map of chain → accounts (built by caller). - * @param rpcAssignedChains - Chains RPC was assigned in the regular handoff. - */ - #subscribeRpcCustomAssetsSupplement( - accounts: InternalAccount[], - chainToAccounts: Map, - rpcAssignedChains: Set, - ): void { - const rpc = this.#rpcDataSource; - const supplementalKey = `ds:${rpc.getName()}:custom`; - - const decision = pickRpcCustomAssetsSupplement({ - accountIds: accounts.map((account) => account.id), - customAssetsByAccount: this.state.customAssets, - rpcAssignedChains, - rpcAvailableChains: new Set(rpc.getActiveChainsSync()), - enabledChains: new Set(chainToAccounts.keys()), - }); - - if (decision.chains.length === 0) { - this.#unsubscribeBySubscriptionKey(rpc, supplementalKey); - return; - } - - const supplementalAccounts = accounts.filter((account) => - decision.accountIds.has(account.id), - ); - if (supplementalAccounts.length === 0) { - this.#unsubscribeBySubscriptionKey(rpc, supplementalKey); - return; - } - - this.#subscribeDataSource(rpc, supplementalAccounts, decision.chains, { - subscriptionKey: supplementalKey, - customAssetsOnly: true, - }); } /** @@ -3222,13 +3179,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. */ #subscribeDataSource( source: AbstractDataSource, accounts: InternalAccount[], chains: ChainId[], - options: { subscriptionKey?: string; customAssetsOnly?: boolean } = {}, + options: { subscriptionKey?: string } = {}, ): void { const sourceId = source.getName(); const subscriptionKey = options.subscriptionKey ?? `ds:${sourceId}`; @@ -3241,17 +3197,26 @@ export class AssetsController extends BaseController< isUpdate, accountCount: accounts.length, chainCount: chains.length, - customAssetsOnly: options.customAssetsOnly === true, }); + // User-pinned custom assets for the subscribed accounts, forwarded on the + // poll request so AccountsApi can send them as `includeAssetIds`. + const customAssets: Caip19AssetId[] = []; + for (const account of accounts) { + customAssets.push(...this.getCustomAssets(account.id)); + } + + // Globally hidden assets, forwarded so AccountsApi can send them as + // `excludeAssetIds` and drop them from the response. + const hiddenAssets = this.#getHiddenAssetIds(); + const subscribeReq: SubscriptionRequest = { request: this.#buildDataRequest(accounts, chains, { assetTypes: ['fungible'], dataTypes: ['balance'], updateInterval: this.#defaultUpdateInterval, - ...(options.customAssetsOnly === true - ? { customAssetsOnly: true } - : {}), + customAssets: customAssets.length > 0 ? customAssets : undefined, + excludeAssetIds: hiddenAssets.length > 0 ? hiddenAssets : undefined, }), subscriptionId: subscriptionKey, isUpdate, @@ -3680,20 +3645,19 @@ export class AssetsController extends BaseController< ), }; - // Graduate custom assets only when AccountsAPI / Websocket 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 shouldGraduateCustomAssets = - sourceId === 'AccountsApiDataSource' || - sourceId === 'BackendWebsocketDataSource' || - sourceId === 'AccountActivityService'; - - const enrichmentSources: AssetsDataSource[] = [ - ...(shouldGraduateCustomAssets - ? [this.#customAssetGraduationMiddleware] - : []), - this.#detectionMiddleware, - ]; + // When basic functionality is on, recover what the upstream source left + // outstanding on RPC before enrichment: errored chains (e.g. + // `unprocessedNetworks`) via a full-chain fetch, and pinned assets the + // backend could not resolve (AccountsApi `unprocessedIncludeAssetIds`, + // surfaced as `unprocessedCustomAssets`) via an asset-scoped fetch. This mirrors + // the force-update pipeline so poll/subscription updates fall back to RPC + // too. In RPC-only mode (basic functionality off) the poll already uses RPC, + // so there is nothing to fall back to. + const enrichmentSources: AssetsDataSource[] = []; + if (this.#isBasicFunctionality()) { + enrichmentSources.push(this.#rpcFallbackMiddleware); + } + enrichmentSources.push(this.#detectionMiddleware); if (this.#isBasicFunctionality()) { enrichmentSources.push( createParallelMiddleware([ diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index f01829bf159..b4e1979a1ab 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -66,6 +66,7 @@ function createMockApiClient( balances: V5BalanceItem[] = [], unprocessedNetworks: string[] = [], v6Accounts: V6AccountBalancesEntry[] = [], + unprocessedIncludeAssetIds: string[] = [], ): MockApiClient { return { accounts: { @@ -80,7 +81,7 @@ function createMockApiClient( fetchV6MultiAccountBalances: jest.fn().mockResolvedValue({ accounts: v6Accounts, unprocessedNetworks, - unprocessedIncludeAssetIds: [], + unprocessedIncludeAssetIds, }), }, }; @@ -141,6 +142,7 @@ async function setupController( supportedChains?: number[]; balances?: V5BalanceItem[]; unprocessedNetworks?: string[]; + unprocessedIncludeAssetIds?: string[]; fetchTimeoutMs?: number; v6Accounts?: V6AccountBalancesEntry[]; remoteFeatureFlags?: Record; @@ -150,6 +152,7 @@ async function setupController( supportedChains = [1, 137], balances = [], unprocessedNetworks = [], + unprocessedIncludeAssetIds = [], fetchTimeoutMs, v6Accounts = [], remoteFeatureFlags, @@ -197,6 +200,7 @@ async function setupController( balances, unprocessedNetworks, v6Accounts, + unprocessedIncludeAssetIds, ); const controller = new AccountsApiDataSource({ @@ -638,7 +642,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 } }, }); @@ -654,7 +658,236 @@ describe('AccountsApiDataSource', () => { 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([`eip155:1:${MOCK_ADDRESS}`], undefined, undefined); + + 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, ); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 6ac6167b0e4..6e03792343b 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 { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote import { isCaipChainId, KnownCaipNamespace, + parseCaipAssetType, toCaipChainId, } from '@metamask/utils'; @@ -379,11 +380,34 @@ export class AccountsApiDataSource extends AbstractDataSource< ? { staleTime: 0, gcTime: 0 } : undefined; + // User-pinned custom assets ("display no matter what") on the chains + // being fetched. Sent to the v6 endpoint as `includeAssetIds` so the + // backend returns them even at a zero balance (see APIPLAT-2499). + const includeAssetIds = this.#getIncludeAssetIds(request, chainsToFetch); + + // User-hidden assets on the chains being fetched. Sent to the v6 endpoint + // as `excludeAssetIds` so they are dropped from the response even when + // detected or carrying a non-zero balance. A pinned asset always wins + // over a hidden one (adding a custom asset unhides it), so any overlap + // with `includeAssetIds` is removed here defensively. + const excludeAssetIds = this.#getExcludeAssetIds( + request, + chainsToFetch, + includeAssetIds, + ); + // 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 { unprocessedNetworks, unprocessedIncludeAssetIds, assetsBalance } = + this.#isBalanceV6Enabled() + ? await this.#fetchV6Balances( + accountIds, + fetchOptions, + request, + includeAssetIds, + excludeAssetIds, + ) + : await this.#fetchV5Balances(accountIds, fetchOptions, request); // Handle unprocessed networks - these will be passed to next middleware if (unprocessedNetworks.length > 0) { @@ -397,6 +421,28 @@ export class AccountsApiDataSource extends AbstractDataSource< } } + // Pinned assets the backend could not resolve (RPC down, contract does + // not exist, no `balanceOf`, etc.). Surface them on the asset-axis signal + // (`unprocessedCustomAssets`) — NOT `errors` — because the chain itself + // succeeded. The RPC fallback fetches just these specific assets without + // re-fetching the whole chain, and a fabricated zero balance is avoided. + const validUnprocessedAssetIds = unprocessedIncludeAssetIds.filter( + (assetId) => { + try { + parseCaipAssetType(assetId); + return true; + } catch { + return false; + } + }, + ); + if (validUnprocessedAssetIds.length > 0) { + response.unprocessedCustomAssets = [ + ...(response.unprocessedCustomAssets ?? []), + ...(validUnprocessedAssetIds as Caip19AssetId[]), + ]; + } + response.assetsBalance = assetsBalance; response.updateMode = 'merge'; } catch (error) { @@ -426,6 +472,89 @@ export class AccountsApiDataSource extends AbstractDataSource< return response; } + /** + * Collect the user-pinned custom assets that should be sent to the v6 + * endpoint as `includeAssetIds`. Only EVM (`eip155:`) assets on chains that + * are actually being fetched are included; malformed IDs are skipped. + * + * @param request - The original data request (carries `customAssets`). + * @param chainsToFetch - Chains supported and being requested this fetch. + * @returns Deduplicated CAIP-19 asset IDs to pin, 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 user-hidden assets that should be sent to the v6 endpoint as + * `excludeAssetIds`. Only EVM (`eip155:`) assets on chains that are actually + * being fetched are included; malformed IDs are skipped. Any asset that is + * also pinned (`includeAssetIds`) is left out so a pin always wins. + * + * @param request - The original data request (carries `excludeAssetIds`). + * @param chainsToFetch - Chains supported and being requested this fetch. + * @param includeAssetIds - Pinned asset IDs that must not be excluded. + * @returns Deduplicated CAIP-19 asset IDs to exclude, 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. * @@ -440,6 +569,7 @@ export class AccountsApiDataSource extends AbstractDataSource< request: DataRequest, ): Promise<{ unprocessedNetworks: string[]; + unprocessedIncludeAssetIds: string[]; assetsBalance: Record>; }> { const apiResponse = await fetchWithTimeout( @@ -459,6 +589,8 @@ export class AccountsApiDataSource extends AbstractDataSource< return { unprocessedNetworks: apiResponse.unprocessedNetworks, + // v5 has no `includeAssetIds` support. + unprocessedIncludeAssetIds: [], assetsBalance, }; } @@ -469,21 +601,37 @@ 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 - User-pinned CAIP-19 asset IDs the backend must + * always return (even at zero balance). + * @param excludeAssetIds - User-hidden CAIP-19 asset IDs the backend must + * drop from the response (even at a non-zero balance). + * @returns Unprocessed networks, unprocessed pinned assets, and processed + * asset balances by account. */ async #fetchV6Balances( accountIds: string[], fetchOptions: { staleTime: number; gcTime: number } | 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, @@ -496,6 +644,7 @@ export class AccountsApiDataSource extends AbstractDataSource< return { unprocessedNetworks: apiResponse.unprocessedNetworks, + unprocessedIncludeAssetIds: apiResponse.unprocessedIncludeAssetIds, assetsBalance, }; } @@ -678,6 +827,18 @@ export class AccountsApiDataSource extends AbstractDataSource< } } + // Forward the asset-axis signal so the RPC fallback can recover pinned + // assets the backend could not resolve, without re-fetching the chain. + 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( diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index c2ff9044d13..ea3ad9aefb1 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -881,6 +881,7 @@ describe('RpcDataSource', () => { 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 d28af6e3fda..e6ca7fb7557 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -1419,20 +1419,11 @@ export class RpcDataSource extends AbstractDataSource< chainId: hexChainId, accountId, accountAddress: address as Address, - ...(request.customAssetsOnly === true - ? { customAssetsOnly: true } - : {}), }; 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() && - this.#useExternalService() - ) { + if (this.#tokenDetectionEnabled() && this.#useExternalService()) { const detectionInput: DetectionPollingInput = { chainId: hexChainId, accountId, 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 d4476a988e3..c169df5ffc7 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 @@ -316,63 +316,6 @@ describe('BalanceFetcher', () => { ); }); - 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]: { - [NATIVE_ETH_ASSET_ID]: { amount: '0' }, - [TOKEN_2_ASSET_ID]: { amount: '0' }, - }, - }, - customAssets: { - [TEST_ACCOUNT_ID]: [TOKEN_1_ASSET_ID], - }, - }; - - await withController( - { - assetsBalanceState: mockState, - config: { - isNativeAsset: (id: CaipAssetType) => id === NATIVE_ETH_ASSET_ID, - }, - }, - async ({ controller, mockMulticallClient }) => { - controller.setOnBalanceUpdate(jest.fn()); - mockMulticallClient.batchBalanceOf.mockResolvedValue([ - createMockBalanceResponse( - TEST_TOKEN_1.toLowerCase() as Address, - TEST_ACCOUNT, - true, - '500', - ), - ]); - - const input: BalancePollingInput = { - chainId: MAINNET_CHAIN_ID, - accountId: TEST_ACCOUNT_ID, - accountAddress: TEST_ACCOUNT, - customAssetsOnly: true, - }; - - await controller._executePoll(input); - - const [, batchedRequests] = - mockMulticallClient.batchBalanceOf.mock.calls[0]; - const requestedTokens = ( - batchedRequests as { tokenAddress: string }[] - ) - .map((req) => req.tokenAddress.toLowerCase()) - .sort(); - // ONLY the custom token — not the native and not TOKEN_2 from - // assetsBalance. - expect(requestedTokens).toStrictEqual([TEST_TOKEN_1.toLowerCase()]); - }, - ); - }); - it('does not call callback when balances are empty', async () => { await withController(async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); 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 43c9b16736c..c4531307488 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,13 +45,6 @@ export type BalancePollingInput = { accountId: AccountId; /** Account address */ accountAddress: Address; - /** - * 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. - */ - customAssetsOnly?: boolean; }; /** @@ -121,7 +114,6 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly 0) { @@ -135,13 +127,11 @@ export class BalanceFetcher extends StaticIntervalPollingControllerOnly { - const assets = this.#getAssetsToFetch(chainId, accountId, customAssetsOnly); + const assets = this.#getAssetsToFetch(chainId, accountId); return this.fetchBalancesForAssets( chainId, diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 609b0a1b79e..b268282e75b 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -159,15 +159,8 @@ export type { } from './data-sources'; // Middlewares -export { - CustomAssetGraduationMiddleware, - DetectionMiddleware, - RpcFallbackMiddleware, -} from './middlewares'; -export type { - CustomAssetGraduationMiddlewareOptions, - RpcFallbackMiddlewareOptions, -} from './middlewares'; +export { DetectionMiddleware, RpcFallbackMiddleware } from './middlewares'; +export type { RpcFallbackMiddlewareOptions } from './middlewares'; // Utilities export { diff --git a/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.test.ts b/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.test.ts deleted file mode 100644 index 02331161e93..00000000000 --- a/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import type { - AssetsControllerStateInternal, - Caip19AssetId, - Context, - DataRequest, -} from '../types'; -import { CustomAssetGraduationMiddleware } from './CustomAssetGraduationMiddleware'; - -const MOCK_ACCOUNT_ID = 'mock-account-id'; -const OTHER_ACCOUNT_ID = 'other-account-id'; - -// Checksummed addresses — customAssets state stores normalized IDs. -const EVM_CUSTOM_ASSET = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; -const EVM_OTHER_ASSET = - 'eip155:137/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; -const SOLANA_CUSTOM_ASSET = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as Caip19AssetId; -const BTC_CUSTOM_ASSET = - 'bip122:000000000019d6689c085ae165831e93/slip44:0' as Caip19AssetId; - -function createMockAccount(id = MOCK_ACCOUNT_ID): InternalAccount { - return { - id, - address: '0x1234567890123456789012345678901234567890', - options: {}, - methods: [], - type: 'eip155:eoa', - scopes: ['eip155:0'], - metadata: { - name: 'Test Account', - keyring: { type: 'HD Key Tree' }, - importTime: 0, - lastSelected: 0, - }, - } as InternalAccount; -} - -function createDataRequest(overrides?: Partial): DataRequest { - const chainIds = overrides?.chainIds ?? ['eip155:1']; - const accounts = [createMockAccount()]; - return { - chainIds, - accountsWithSupportedChains: accounts.map((a) => ({ - account: a, - supportedChains: chainIds, - })), - dataTypes: ['balance'], - ...overrides, - } as DataRequest; -} - -function createAssetsState( - customAssets: Record = {}, -): AssetsControllerStateInternal { - return { - assetsInfo: {}, - assetsBalance: {}, - assetsPrice: {}, - customAssets, - assetPreferences: {}, - } as AssetsControllerStateInternal; -} - -function createContext( - overrides?: Partial, - customAssets: Record = {}, -): Context { - return { - request: createDataRequest(), - response: {}, - getAssetsState: jest.fn().mockReturnValue(createAssetsState(customAssets)), - ...overrides, - }; -} - -function setup( - customAssets: Record = {}, - selectedAccountId: string | undefined = MOCK_ACCOUNT_ID, -): { - middleware: CustomAssetGraduationMiddleware; - context: Context; - removeCustomAsset: jest.Mock; - getSelectedAccountId: jest.Mock; -} { - const removeCustomAsset = jest.fn(); - const getSelectedAccountId = jest.fn().mockReturnValue(selectedAccountId); - const middleware = new CustomAssetGraduationMiddleware({ - getSelectedAccountId, - removeCustomAsset, - }); - const context = createContext({}, customAssets); - return { middleware, context, removeCustomAsset, getSelectedAccountId }; -} - -describe('CustomAssetGraduationMiddleware', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('initializes with correct name', () => { - const { middleware } = setup(); - expect(middleware.name).toBe('CustomAssetGraduationMiddleware'); - expect(middleware.getName()).toBe('CustomAssetGraduationMiddleware'); - }); - - it('exposes an assetsMiddleware function', () => { - const { middleware } = setup(); - expect(typeof middleware.assetsMiddleware).toBe('function'); - }); - - it('graduates an EVM custom asset that was returned in the balance response', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(next).toHaveBeenCalledWith(context); - expect(removeCustomAsset).toHaveBeenCalledTimes(1); - expect(removeCustomAsset).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - EVM_CUSTOM_ASSET, - ); - }); - - it('graduates only the returned subset of custom assets', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET, EVM_OTHER_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).toHaveBeenCalledTimes(1); - expect(removeCustomAsset).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - EVM_CUSTOM_ASSET, - ); - }); - - it('does not graduate when AccountsAPI returns a zero balance', async () => { - // The API may include zero entries for tokens it indexes but the user - // no longer holds. Keeping them in customAssets ensures RPC keeps - // polling so a future incoming transfer is reflected immediately. - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '0' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('does not graduate when the decimal amount is zero', async () => { - // Both AccountsApi and BackendWebsocketDataSource emit human-readable - // decimal strings, so "0.0" must be treated the same as "0". - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '0.0' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('graduates when AccountsAPI returns a small positive decimal balance (V5 format)', async () => { - // V5 returns amounts already divided by decimals, e.g. WETH: - // { balance: "0.283549083429656057", decimals: 18 }. The middleware - // must recognise these as positive without any further unit handling. - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '0.283549083429656057' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).toHaveBeenCalledTimes(1); - expect(removeCustomAsset).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - EVM_CUSTOM_ASSET, - ); - }); - - it('graduates only custom assets whose balance is positive', async () => { - // Mixed response: one asset has zero balance, another has a real - // balance. Only the latter should graduate. - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET, EVM_OTHER_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '0' }, - [EVM_OTHER_ASSET]: { amount: '500' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).toHaveBeenCalledTimes(1); - expect(removeCustomAsset).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - EVM_OTHER_ASSET, - ); - }); - - it('does not graduate when the balance amount is malformed', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: 'not-a-number' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('does not graduate non-EVM (Solana) custom assets', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [SOLANA_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [SOLANA_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('does not graduate non-EVM (BTC) custom assets', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [BTC_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [BTC_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('only graduates assets for the selected account', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - [OTHER_ACCOUNT_ID]: [EVM_OTHER_ASSET], - }); - context.response = { - assetsBalance: { - [OTHER_ACCOUNT_ID]: { - [EVM_OTHER_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('is a no-op when the selected account has no custom assets', async () => { - const { middleware, context, removeCustomAsset } = setup({}); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('is a no-op when the response has no balances for the selected account', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: {}, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('is a no-op when the response is empty', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = {}; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('is a no-op when there is no selected account', async () => { - const { middleware, context, removeCustomAsset, getSelectedAccountId } = - setup({ [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET] }); - getSelectedAccountId.mockReturnValue(undefined); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('does not graduate non-custom EVM assets that appear in the response', async () => { - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_OTHER_ASSET]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('does not run for non-balance data types', async () => { - const { middleware, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - const context = createContext( - { - request: createDataRequest({ dataTypes: ['metadata'] }), - response: { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }, - }, - { [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET] }, - ); - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(context); - }); - - it('graduates a custom asset when the response uses a non-checksummed (lowercase) address', async () => { - // Regression: BackendWebsocketDataSource does not normalize asset IDs, - // so balances may arrive with lowercase addresses while customAssets - // state stores the checksummed form. Graduation must be robust to that. - const checksummedCustomAsset = - 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; - const lowercaseFromWebsocket = - 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId; - - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [checksummedCustomAsset], - }); - context.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [lowercaseFromWebsocket]: { amount: '1000' }, - }, - }, - }; - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).toHaveBeenCalledTimes(1); - // Removal must use the canonical (checksummed) form stored in state. - expect(removeCustomAsset).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - checksummedCustomAsset, - ); - }); - - it('does not graduate when the matching balance is added by downstream middleware (e.g. RPC fallback)', async () => { - // Regression test: the graduation middleware must inspect the response - // BEFORE calling next() so that balances merged in by later middleware - // (notably the RPC fallback, which intentionally fetches custom assets) - // do not trigger graduation. See PR description for the resilience work. - const { middleware, context, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - // No balance from upstream sources — AccountsApi did not return it. - context.response = { assetsBalance: { [MOCK_ACCOUNT_ID]: {} } }; - // The downstream middleware (RPC fallback) populates the asset balance. - const next = jest.fn().mockImplementation(async (ctx) => { - ctx.response = { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }; - return ctx; - }); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).not.toHaveBeenCalled(); - }); - - it('runs when dataTypes includes balance among others', async () => { - const { middleware, removeCustomAsset } = setup({ - [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET], - }); - const context = createContext( - { - request: createDataRequest({ dataTypes: ['balance', 'metadata'] }), - response: { - assetsBalance: { - [MOCK_ACCOUNT_ID]: { - [EVM_CUSTOM_ASSET]: { amount: '1000' }, - }, - }, - }, - }, - { [MOCK_ACCOUNT_ID]: [EVM_CUSTOM_ASSET] }, - ); - const next = jest.fn().mockImplementation((ctx) => Promise.resolve(ctx)); - - await middleware.assetsMiddleware(context, next); - - expect(removeCustomAsset).toHaveBeenCalledWith( - MOCK_ACCOUNT_ID, - EVM_CUSTOM_ASSET, - ); - }); -}); diff --git a/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts b/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts deleted file mode 100644 index 3fec59bcce5..00000000000 --- a/packages/assets-controller/src/middlewares/CustomAssetGraduationMiddleware.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { KnownCaipNamespace } from '@metamask/utils'; - -import { projectLogger, createModuleLogger } from '../logger'; -import { forDataTypes } from '../types'; -import type { - AccountId, - AssetBalance, - Caip19AssetId, - Middleware, -} from '../types'; -import { normalizeAssetId } from '../utils'; - -const CONTROLLER_NAME = 'CustomAssetGraduationMiddleware'; - -const log = createModuleLogger(projectLogger, CONTROLLER_NAME); - -export type CustomAssetGraduationMiddlewareOptions = { - getSelectedAccountId: () => AccountId | undefined; - removeCustomAsset: (accountId: AccountId, assetId: Caip19AssetId) => void; -}; - -/** - * CustomAssetGraduationMiddleware removes EVM assets from `customAssets` when - * an upstream balance source (AccountsAPI / Websocket) reports a non-zero - * balance for them. Once a detector sees the asset with a real balance, it - * no longer needs to be tracked as "custom" — the regular detection flow - * will keep it fresh. - * - * Rules: - * - Only the selected account's custom assets are considered. Switching the - * selected account triggers a fresh fetch, which re-runs graduation - * against the new account's balances. - * - Only EVM (CAIP-2 namespace `eip155`) assets graduate. Non-EVM custom - * assets (Solana, BTC, Tron, etc. — served by Snap data sources) are left - * alone. - * - Only positive balances graduate. A zero balance from AccountsAPI means - * the API knows about the token but the user does not currently hold it; - * keeping it in `customAssets` ensures RPC keeps polling so a future - * incoming transfer is reflected promptly. - */ -export class CustomAssetGraduationMiddleware { - readonly name = CONTROLLER_NAME; - - readonly #getSelectedAccountId: () => AccountId | undefined; - - readonly #removeCustomAsset: ( - accountId: AccountId, - assetId: Caip19AssetId, - ) => void; - - constructor(options: CustomAssetGraduationMiddlewareOptions) { - this.#getSelectedAccountId = options.getSelectedAccountId; - this.#removeCustomAsset = options.removeCustomAsset; - } - - getName(): string { - return this.name; - } - - get assetsMiddleware(): Middleware { - return forDataTypes(['balance'], async (ctx, next) => { - // Inspect the response BEFORE calling next() so we only consider - // balances populated by upstream middleware (AccountsApi / Websocket / - // Staked). This middleware is positioned in the pipeline before the - // RPC fallback — RPC intentionally carries custom assets and must - // never trigger graduation. - const accountId = this.#getSelectedAccountId(); - if (!accountId) { - return next(ctx); - } - - const state = ctx.getAssetsState(); - const customForAccount = state.customAssets?.[accountId] ?? []; - if (customForAccount.length === 0) { - return next(ctx); - } - - const returnedBalances = ctx.response.assetsBalance?.[accountId] ?? {}; - const returnedAssetIds = Object.keys(returnedBalances) as Caip19AssetId[]; - if (returnedAssetIds.length === 0) { - return next(ctx); - } - - // customAssets state is stored with checksummed/normalized asset IDs. - // AccountsApiDataSource normalizes its response IDs, but - // BackendWebsocketDataSource does not — so we normalize the response - // side here to make the comparison robust to lower-case addresses - // delivered over the websocket. - const customSet = new Set(customForAccount); - for (const rawAssetId of returnedAssetIds) { - if (!isEvmAssetId(rawAssetId)) { - continue; - } - if (!hasPositiveBalance(returnedBalances[rawAssetId])) { - continue; - } - const normalizedAssetId = safeNormalize(rawAssetId); - if (!customSet.has(normalizedAssetId)) { - continue; - } - log('Graduating custom asset', { - accountId, - assetId: normalizedAssetId, - }); - this.#removeCustomAsset(accountId, normalizedAssetId); - } - - return next(ctx); - }); - } -} - -/** - * Check whether a CAIP-19 asset ID belongs to an EVM chain. - * - * @param assetId - The CAIP-19 asset ID to inspect. - * @returns `true` when the asset's chain namespace is `eip155`. - */ -function isEvmAssetId(assetId: Caip19AssetId): boolean { - // CAIP-19 format: :/: - // The chain namespace is always the segment before the first colon. - const namespace = assetId.split(':')[0]; - return namespace === KnownCaipNamespace.Eip155; -} - -/** - * Normalize a CAIP-19 asset ID, returning the original on failure. Some - * malformed IDs (e.g. an asset reference that fails address checksumming) - * make `normalizeAssetId` throw — in that case we fall back to the raw ID - * so the graduation pass can still proceed for other assets. - * - * @param assetId - The CAIP-19 asset ID to normalize. - * @returns The normalized ID, or the original on failure. - */ -function safeNormalize(assetId: Caip19AssetId): Caip19AssetId { - try { - return normalizeAssetId(assetId); - } catch { - return assetId; - } -} - -/** - * Whether a balance entry reports a strictly positive amount. AccountsAPI - * may return zero for tokens it indexes but the user no longer holds; we - * treat those as non-graduating so RPC keeps polling and surfaces any - * future incoming transfer immediately. - * - * `AssetBalance.amount` is already a human-readable decimal string from - * both AccountsApi (e.g. "0.283549083429656057") and the websocket data - * source (which divides by `decimals` before emitting), so a `Number()` - * sign check is safe: `NaN`, `undefined`, empty strings, and zero all - * fail the comparison. - * - * @param balance - The balance entry from the response. - * @returns `true` when the balance amount represents a value greater than 0. - */ -function hasPositiveBalance(balance: AssetBalance | undefined): boolean { - return Number(balance?.amount) > 0; -} diff --git a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts index 7d67c83308e..18d53d8548e 100644 --- a/packages/assets-controller/src/middlewares/ParallelMiddleware.ts +++ b/packages/assets-controller/src/middlewares/ParallelMiddleware.ts @@ -53,6 +53,14 @@ 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 bcd82f65d19..ec62400ae85 100644 --- a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts +++ b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.test.ts @@ -14,6 +14,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_TOKEN_POLYGON = + 'eip155:137/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Caip19AssetId; +const MOCK_TOKEN_MAINNET = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7' as Caip19AssetId; function createMockAccount(): InternalAccount { return { @@ -246,4 +250,118 @@ describe('RpcFallbackMiddleware', () => { const finalCtx = next.mock.calls[0][0]; expect(finalCtx.response.errors).toStrictEqual({}); }); + + 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 }); + 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); + + await mw.assetsMiddleware(ctx, next); + + 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]); + + 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('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 }); + const ctx = createContext(createDataRequest(['eip155:137']), { + errors: { 'eip155:137': 'Unprocessed networks' }, + unprocessedCustomAssets: [MOCK_TOKEN_POLYGON], + }); + const next = jest.fn(async (innerCtx) => innerCtx); + + 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(); + + const finalCtx = next.mock.calls[0][0]; + expect(finalCtx.response.errors).toStrictEqual({}); + expect(finalCtx.response.unprocessedCustomAssets).toBeUndefined(); + }); + + it('keeps unprocessedCustomAssets that RPC could not recover', async () => { + const { source } = createMockRpcSource({}); // RPC returns nothing + const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const ctx = createContext(createDataRequest(['eip155:137']), { + unprocessedCustomAssets: [MOCK_TOKEN_POLYGON], + }); + const next = jest.fn(async (innerCtx) => innerCtx); + + await mw.assetsMiddleware(ctx, next); + + const finalCtx = next.mock.calls[0][0]; + expect(finalCtx.response.unprocessedCustomAssets).toStrictEqual([ + MOCK_TOKEN_POLYGON, + ]); + }); + + 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 { source, middleware: rpcMw } = createMockRpcSource(rpcResponse); + const mw = new RpcFallbackMiddleware({ rpcDataSource: source }); + const ctx = createContext( + createDataRequest(['eip155:1', 'eip155:56']), + { + errors: { 'eip155:56': 'Fetch failed' }, + unprocessedCustomAssets: [MOCK_TOKEN_MAINNET], + }, + ); + const next = jest.fn(async (innerCtx) => innerCtx); + + 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]); + + 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 b91da7490a3..cdde2754ebd 100644 --- a/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts +++ b/packages/assets-controller/src/middlewares/RpcFallbackMiddleware.ts @@ -2,10 +2,14 @@ import { projectLogger, createModuleLogger } from '../logger'; import { forDataTypes } from '../types'; import type { AssetsDataSource, + Caip19AssetId, ChainId, + Context, + DataRequest, DataResponse, Middleware, } from '../types'; +import { normalizeAssetId } from '../utils'; import { mergeDataResponses } from './ParallelMiddleware'; const CONTROLLER_NAME = 'RpcFallbackMiddleware'; @@ -17,15 +21,26 @@ export type RpcFallbackMiddlewareOptions = { rpcDataSource: AssetsDataSource; }; +const noopNext = async (ctx: Context): Promise => ctx; + /** - * RpcFallbackMiddleware retries chains that failed upstream on the RPC data - * source. Any chain present in `response.errors` (network error, - * unprocessedNetworks, timeout, …) is handed off to RPC with the request - * filtered to just those chains. Successful RPC results are merged into the - * response and their entries are cleared from `response.errors`. + * RpcFallbackMiddleware recovers what upstream sources left outstanding on the + * RPC data source, along two axes: + * + * - **Chain axis:** any chain present in `response.errors` (network error, + * unprocessedNetworks, timeout, …) is re-fetched in full (native + custom + * assets). Recovered chains are cleared from `response.errors`. + * - **Asset axis:** any pinned asset in `response.unprocessedCustomAssets` (e.g. + * AccountsApi `unprocessedIncludeAssetIds` the backend could not resolve) is + * re-fetched with an RPC request scoped to just those assets (passed as + * `customAssets`), rather than re-fetching every pin on the chain. Recovered + * assets are removed from `response.unprocessedCustomAssets`. Assets whose chain + * was already retried on the chain axis are skipped (that fetch already + * covers them). * * Place this immediately after `createParallelBalanceMiddleware` in the fast - * pipeline. + * pipeline, and in the subscription enrichment pipeline so poll updates recover + * too. */ export class RpcFallbackMiddleware { readonly name = CONTROLLER_NAME; @@ -45,57 +60,158 @@ export class RpcFallbackMiddleware { const erroredChains = new Set( Object.keys(ctx.response.errors ?? {}) as ChainId[], ); - if (erroredChains.size === 0) { + const unprocessedCustomAssets = [ + ...new Set(ctx.response.unprocessedCustomAssets ?? []), + ]; + + if (erroredChains.size === 0 && unprocessedCustomAssets.length === 0) { return next(ctx); } - log('Retrying failed chains on RPC', { - chains: [...erroredChains], - }); - - const filteredRequest = { - ...ctx.request, - chainIds: ctx.request.chainIds.filter((id) => erroredChains.has(id)), - }; - - const noopNext = async (inner: typeof ctx): Promise => inner; - const rpcResult = await this.#rpcDataSource.assetsMiddleware( - { - ...ctx, - request: filteredRequest, - response: {}, - }, - noopNext, + let merged: DataResponse = ctx.response; + + // Chain axis: retry whole errored chains on RPC. + if (erroredChains.size > 0) { + merged = await this.#recoverErroredChains(ctx, merged, erroredChains); + } + + // Asset axis: recover specific pinned assets the backend could not + // resolve. Skip assets whose chain was already retried above — that + // full-chain fetch already includes custom assets. + const assetsToRecover = unprocessedCustomAssets.filter( + (assetId) => !erroredChains.has(chainIdOfAsset(assetId)), ); + if (assetsToRecover.length > 0) { + merged = await this.#recoverUnprocessedAssets( + ctx, + merged, + assetsToRecover, + ); + } + + // Drop asset-axis entries we now have a balance for so downstream sources + // do not try them again. + merged = clearRecoveredAssetIds(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 merged = mergeDataResponses([currentResponse, rpcResult.response]); - const merged: DataResponse = mergeDataResponses([ - ctx.response, - rpcResult.response, - ]); - - // Clear errors only for chains RPC actually recovered a balance for. - // We must inspect rpcResult.response — 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. - const rpcAssetsBalance = rpcResult.response.assetsBalance; - 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]); - } + // Clear errors only for chains RPC actually recovered a balance for. + // We must inspect rpcResult.response — NOT merged — because merged also + // contains balances from the upstream sources (AccountsApi / Websocket / + // Staked). If those returned partial data for a chain they also flagged as + // errored, and RPC then failed for that same chain, looking at merged would + // incorrectly mark the error as recovered. + const rpcAssetsBalance = rpcResult.response.assetsBalance; + 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 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, }); + + // Scope the RPC fetch to just the unresolved pins by overriding + // `customAssets`. RpcDataSource fetches native + these on each chain in a + // single multicall, so this recovers the pins without re-fetching every + // other pin on the chain. + 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]); + } +} + +/** + * Extract the CAIP-2 chain ID from a CAIP-19 asset ID. + * + * @param assetId - The CAIP-19 asset ID. + * @returns The CAIP-2 chain ID portion. + */ +function chainIdOfAsset(assetId: Caip19AssetId): ChainId { + return assetId.split('/')[0] as ChainId; +} + +/** + * Remove entries from `unprocessedCustomAssets` that now have a balance in the + * response (recovered by RPC or already covered by a retried chain). + * + * @param response - The merged data response. + * @returns The response with recovered assets pruned from `unprocessedCustomAssets`. + */ +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; } diff --git a/packages/assets-controller/src/middlewares/index.ts b/packages/assets-controller/src/middlewares/index.ts index 15a26613b99..688716946b8 100644 --- a/packages/assets-controller/src/middlewares/index.ts +++ b/packages/assets-controller/src/middlewares/index.ts @@ -1,5 +1,3 @@ -export { CustomAssetGraduationMiddleware } from './CustomAssetGraduationMiddleware'; -export type { CustomAssetGraduationMiddlewareOptions } from './CustomAssetGraduationMiddleware'; export { DetectionMiddleware } from './DetectionMiddleware'; export { RpcFallbackMiddleware } from './RpcFallbackMiddleware'; export type { RpcFallbackMiddlewareOptions } from './RpcFallbackMiddleware'; diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 2b2b940a713..73cdf1eb594 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -339,12 +339,11 @@ export type DataRequest = { /** Specific CAIP-19 asset IDs */ customAssets?: Caip19AssetId[]; /** - * 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. + * User-hidden CAIP-19 asset IDs (from `assetPreferences`). Sent to the + * Accounts API v6 endpoint as `excludeAssetIds` so hidden assets are removed + * from the response even when detected or carrying a non-zero balance. */ - customAssetsOnly?: boolean; + excludeAssetIds?: Caip19AssetId[]; /** Force fresh fetch, bypass cache */ forceUpdate?: boolean; /** Hint for polling interval (ms) - used by data sources that implement polling */ @@ -363,8 +362,16 @@ 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; + /** + * Explicitly requested asset IDs the source could not resolve (asset-axis + * fallback). Distinct from `errors`: the chain itself succeeded, but these + * specific pinned assets (e.g. AccountsApi `unprocessedIncludeAssetIds`) still + * need to be fetched by a downstream source (RPC) without re-fetching the + * whole chain. + */ + unprocessedCustomAssets?: Caip19AssetId[]; /** Detected assets (assets that do not have metadata) */ detectedAssets?: Record; /** diff --git a/packages/assets-controller/src/utils/customAssetsRpcSupplement.test.ts b/packages/assets-controller/src/utils/customAssetsRpcSupplement.test.ts deleted file mode 100644 index c7bfe631747..00000000000 --- a/packages/assets-controller/src/utils/customAssetsRpcSupplement.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { Caip19AssetId, ChainId } from '../types'; -import { pickRpcCustomAssetsSupplement } from './customAssetsRpcSupplement'; - -const MAINNET = 'eip155:1' as ChainId; -const POLYGON = 'eip155:137' as ChainId; -const OPTIMISM = 'eip155:10' as ChainId; -const SOLANA = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; - -const WETH_MAINNET = - 'eip155:1/erc20:0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' as Caip19AssetId; -const USDC_MAINNET = - 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Caip19AssetId; -const USDC_POLYGON = - 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359' as Caip19AssetId; -const TOKEN_OPTIMISM = - 'eip155:10/erc20:0x4200000000000000000000000000000000000042' as Caip19AssetId; -const SOLANA_TOKEN = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB' as Caip19AssetId; - -const ACCOUNT_A = 'account-a'; -const ACCOUNT_B = 'account-b'; - -describe('pickRpcCustomAssetsSupplement', () => { - describe('the core invariant — customAssets must always be fetched by RPC', () => { - it('picks a chain claimed by AccountsApi/Websocket as long as the user has a customAsset there', () => { - // Mainnet has been claimed by another data source (AccountsApi or - // Websocket), so it is NOT in `rpcAssignedChains`. The user has a - // customAsset on mainnet — RPC must run a supplemental sub. - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET, POLYGON]), - enabledChains: new Set([MAINNET, POLYGON]), - }); - - expect(result.chains).toStrictEqual([MAINNET]); - expect([...result.accountIds]).toStrictEqual([ACCOUNT_A]); - }); - - it('picks every chain that has a customAsset across all selected accounts', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A, ACCOUNT_B], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET, USDC_POLYGON], - [ACCOUNT_B]: [TOKEN_OPTIMISM], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET, POLYGON, OPTIMISM]), - enabledChains: new Set([MAINNET, POLYGON, OPTIMISM]), - }); - - expect(new Set(result.chains)).toStrictEqual( - new Set([MAINNET, POLYGON, OPTIMISM]), - ); - expect(result.accountIds).toStrictEqual(new Set([ACCOUNT_A, ACCOUNT_B])); - }); - - it('deduplicates a chain when multiple accounts have customAssets there', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A, ACCOUNT_B], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET], - [ACCOUNT_B]: [USDC_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([MAINNET]); - expect(result.accountIds).toStrictEqual(new Set([ACCOUNT_A, ACCOUNT_B])); - }); - }); - - describe('skip rules', () => { - it('skips a chain that the regular RPC subscription already covers', () => { - // The regular RPC sub already fetches customAssets for `MAINNET` - // (see BalanceFetcher#getAssetsToFetch), so a supplemental sub would - // double-poll. Skip. - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set([MAINNET]), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([]); - }); - - it('skips a chain RPC cannot serve (no NetworkController config for it)', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set(), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([]); - }); - - it('skips a chain that is currently disabled', () => { - // The chain is RPC-supported and not RPC-assigned, but the user has - // disabled it — there is no UI surface that shows its balances, so - // polling would waste resources. - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set(), - }); - - expect(result.chains).toStrictEqual([]); - }); - - it('skips an account with no customAssets', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A, ACCOUNT_B], - customAssetsByAccount: { - [ACCOUNT_A]: [], - [ACCOUNT_B]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([MAINNET]); - expect(result.accountIds).toStrictEqual(new Set([ACCOUNT_B])); - }); - - it('skips an account that is not in the selected accountIds list', () => { - // Even though state.customAssets has entries for ACCOUNT_B, the caller - // only selected ACCOUNT_A. ACCOUNT_B's customAssets are ignored. - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [], - [ACCOUNT_B]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([]); - expect(result.accountIds).toStrictEqual(new Set()); - }); - - it('skips a malformed CAIP-19 asset ID without throwing', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: ['not-a-caip-19-id' as Caip19AssetId, WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([MAINNET]); - }); - }); - - describe('chain-namespace coverage', () => { - it('picks non-EVM chains too — the helper is namespace-agnostic', () => { - // The graduation middleware only graduates EVM customAssets, but the - // supplemental RPC fetch is conceptually independent: any chain RPC - // can serve and the user has imported a token on, gets supplemented. - // Solana is a hypothetical future case; today RPC reports it inactive - // so this test exists to document the invariant rather than gate - // production behavior. - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [SOLANA_TOKEN], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([SOLANA]), - enabledChains: new Set([SOLANA]), - }); - - expect(result.chains).toStrictEqual([SOLANA]); - }); - }); - - describe('mixed scenarios that exercise multiple rules at once', () => { - it('picks one chain and skips another for the same account', () => { - // ACCOUNT_A holds: - // - WETH_MAINNET → RPC supplement picks it (claimed by API/WS). - // - USDC_POLYGON → regular RPC sub already covers it. Skip. - // - TOKEN_OPTIMISM → RPC doesn't support optimism here. Skip. - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET, USDC_POLYGON, TOKEN_OPTIMISM], - }, - rpcAssignedChains: new Set([POLYGON]), - rpcAvailableChains: new Set([MAINNET, POLYGON]), - enabledChains: new Set([MAINNET, POLYGON, OPTIMISM]), - }); - - expect(result.chains).toStrictEqual([MAINNET]); - expect(result.accountIds).toStrictEqual(new Set([ACCOUNT_A])); - }); - - it('returns empty when every customAsset is filtered out', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [ACCOUNT_A], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET, USDC_POLYGON], - }, - rpcAssignedChains: new Set([MAINNET, POLYGON]), - rpcAvailableChains: new Set([MAINNET, POLYGON]), - enabledChains: new Set([MAINNET, POLYGON]), - }); - - expect(result.chains).toStrictEqual([]); - // The account is still listed (the controller's empty-chains - // early-return prevents any subscription anyway). - expect(result.accountIds).toStrictEqual(new Set([ACCOUNT_A])); - }); - - it('returns empty when no accounts are passed in', () => { - const result = pickRpcCustomAssetsSupplement({ - accountIds: [], - customAssetsByAccount: { - [ACCOUNT_A]: [WETH_MAINNET], - }, - rpcAssignedChains: new Set(), - rpcAvailableChains: new Set([MAINNET]), - enabledChains: new Set([MAINNET]), - }); - - expect(result.chains).toStrictEqual([]); - expect(result.accountIds).toStrictEqual(new Set()); - }); - }); -}); diff --git a/packages/assets-controller/src/utils/customAssetsRpcSupplement.ts b/packages/assets-controller/src/utils/customAssetsRpcSupplement.ts deleted file mode 100644 index 3e202535bc5..00000000000 --- a/packages/assets-controller/src/utils/customAssetsRpcSupplement.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { parseCaipAssetType } from '@metamask/utils'; - -import type { Caip19AssetId, ChainId } from '../types'; - -/** - * Inputs needed to decide whether `RpcDataSource` should run a supplemental - * `customAssetsOnly` subscription. All inputs are pre-computed by the caller - * (controller) so this helper is fully pure and deterministic. - */ -export type RpcCustomAssetsSupplementInput = { - /** Selected account IDs being considered for subscription. */ - accountIds: string[]; - /** `state.customAssets` slice — accountId → CAIP-19 asset IDs. */ - customAssetsByAccount: Record; - /** Chains the regular RPC subscription already covers. */ - rpcAssignedChains: Set; - /** Chains the RPC data source can serve (its current activeChains). */ - rpcAvailableChains: Set; - /** - * Chains that are currently enabled for at least one selected account - * (i.e. keys of the controller's chainToAccounts map). - */ - enabledChains: Set; -}; - -/** - * The decision output: which chains and accounts need a supplemental - * `customAssetsOnly` RPC subscription. - */ -export type RpcCustomAssetsSupplementResult = { - /** Chains that need a supplemental customAssetsOnly RPC subscription. */ - chains: ChainId[]; - /** Accounts that own at least one customAsset on a supplemental chain. */ - accountIds: Set; -}; - -/** - * Decide which chains require a supplemental `customAssetsOnly` RPC - * subscription so that user-imported customAssets are **always** polled by - * RPC — even when AccountsApi or the websocket data source has already - * claimed the chain in the regular handoff. - * - * RPC is the sole balance fetcher for customAssets: - * - AccountsApi indexes a curated token list and may return zero (or no - * entry at all) for a user-imported token, even on supported chains. - * - The websocket data source is push-only: it relays balance deltas from - * transactions, so a user who never transacts won't see balance updates. - * - * To guarantee freshness, we subscribe RPC in `customAssetsOnly` mode on - * any chain that: - * 1. has at least one selected account with a customAsset there; - * 2. is supported by the RPC data source; - * 3. is **not** already covered by the regular RPC subscription - * (`rpcAssignedChains`) — otherwise customAssets are picked up there; - * 4. is currently enabled (in `chainToAccounts`); polling a disabled - * chain wastes resources because the user can't see its balances. - * - * Malformed asset IDs are silently skipped — they can't be parsed into a - * chain anyway and shouldn't crash the subscription pipeline. - * - * @param input - Decision inputs. - * @returns Chains and account IDs that require supplemental subscription. - */ -export function pickRpcCustomAssetsSupplement( - input: RpcCustomAssetsSupplementInput, -): RpcCustomAssetsSupplementResult { - const { - accountIds, - customAssetsByAccount, - rpcAssignedChains, - rpcAvailableChains, - enabledChains, - } = input; - - const chains = new Set(); - const supplementalAccountIds = new Set(); - - for (const accountId of accountIds) { - const customForAccount = customAssetsByAccount[accountId]; - if (!customForAccount || customForAccount.length === 0) { - continue; - } - // Mirror the controller's prior behavior: an account with any customAsset - // (even if all are filtered out below) is added to the candidate set. - // The empty-chains early-return in the caller still prevents a useless - // subscription, so this preserves observable behavior. - supplementalAccountIds.add(accountId); - for (const assetId of customForAccount) { - let chainId: ChainId; - try { - chainId = parseCaipAssetType(assetId).chainId; - } catch { - continue; - } - if (rpcAssignedChains.has(chainId)) { - continue; - } - if (!rpcAvailableChains.has(chainId)) { - continue; - } - if (!enabledChains.has(chainId)) { - continue; - } - chains.add(chainId); - } - } - - return { - chains: [...chains], - accountIds: supplementalAccountIds, - }; -}