From 589b3113b9ad570647fbc882cc6eb287b505b6b0 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 25 Sep 2026 10:28:11 +0100 Subject: [PATCH 1/8] Add websocket price-update integration tests for AssetsController Extract the websocket-update pipeline construction into `buildWsUpdateSources` (mirroring `buildFastFetchSources` for the fast pipeline) so the post-websocket-event enrichment pass can be tested in isolation from `AssetsController.handleAssetsUpdate`. Cover the behavior from #10410 at both layers: - `buildWsUpdateSources.price-updates.integration.test.ts` drives the real `AccountActivityDataSource` + `executeAssetsPipeline` against recorded API responses, asserting detected/existing assets receive Tokens API metadata and Price API spot prices in the same pass. - `AssetsController.ws-price-updates.integration.test.ts` runs the full controller on a closed lifecycle, publishing `AccountActivityService:balanceUpdated` events and asserting state balances, metadata, and spot prices land from the same pipeline pass. Fixtures are captured from the live APIs into `__fixtures__/ws-price-updates/` via `captureWsApiResponses.ts`, mirroring the `scam-token-cleanup` fixture layout. --- .../assets-controller/src/AssetsController.ts | 67 +-- ...oller.ws-price-updates.integration.test.ts | 345 ++++++++++++++++ .../accounts-api/v2-supportedNetworks.ts | 25 ++ .../ws-price-updates/api-responses/index.ts | 219 ++++++++++ .../price-api/v2-supportedNetworks.ts | 173 ++++++++ .../api-responses/price-api/v3-spot-prices.ts | 48 +++ .../token-api/suggestedOccurrenceFloors.ts | 14 + .../tokens-api/v2-supportedNetworks.ts | 69 ++++ .../api-responses/tokens-api/v3-assets.ts | 62 +++ .../ws-price-updates/captureWsApiResponses.ts | 151 +++++++ .../ws-price-updates/messenger.ts | 150 +++++++ .../__fixtures__/ws-price-updates/wallet.ts | 30 ++ .../__fixtures__/ws-price-updates/wsEvents.ts | 85 ++++ .../__fixtures__/ws-price-updates/wsWallet.ts | 70 ++++ ...eSources.price-updates.integration.test.ts | 382 ++++++++++++++++++ .../src/pipeline/buildWsUpdateSources.test.ts | 73 ++++ .../src/pipeline/buildWsUpdateSources.ts | 68 ++++ .../assets-controller/src/pipeline/index.ts | 2 + 18 files changed, 2000 insertions(+), 33 deletions(-) create mode 100644 packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/accounts-api/v2-supportedNetworks.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v2-supportedNetworks.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v3-spot-prices.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/token-api/suggestedOccurrenceFloors.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v2-supportedNetworks.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v3-assets.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts create mode 100644 packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts create mode 100644 packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts create mode 100644 packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts create mode 100644 packages/assets-controller/src/pipeline/buildWsUpdateSources.ts diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 7dd889483f5..8807da7b53f 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -126,6 +126,7 @@ import { } from './migrations/healAssetsInfoMetadata.js'; import { buildFastFetchSources, + buildWsUpdateSources, executeAssetsPipeline, } from './pipeline/index.js'; import type { @@ -3812,42 +3813,42 @@ export class AssetsController extends BaseController< // 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 === 'AccountActivityDataSource'; - - // Websocket updates can carry brand-new spam airdrops: enrich them - // with Token API occurrences and drop below-floor tokens BEFORE - // detection, so spam is never detected, enriched, priced or persisted. - const shouldFilterOccurrences = - sourceId === 'AccountActivityDataSource' && - this.#isBasicFunctionality(); - const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; - const enrichmentSources: AssetsDataSource[] = [ - ...(shouldGraduateCustomAssets - ? [this.#customAssetGraduationMiddleware] - : []), - ...(shouldFilterOccurrences - ? [ + + // Websocket updates run their own composed lane (graduation → + // occurrence filtering → detection → enrichment) so it can be built and + // driven without booting the controller; see `buildWsUpdateSources`. + const enrichmentSources: AssetsDataSource[] = + sourceId === 'AccountActivityDataSource' + ? buildWsUpdateSources( { - getName: () => 'OccurrenceFloorFilter', - assetsMiddleware: - this.#tokenDataSource.occurrenceFilterMiddleware, + customAssetGraduationMiddleware: + this.#customAssetGraduationMiddleware, + detectionMiddleware: this.#detectionMiddleware, + tokenDataSource: this.#tokenDataSource, + priceDataSource: this.#priceDataSource, }, - ] - : []), - ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []), - this.#detectionMiddleware, - ]; - if (this.#isBasicFunctionality()) { - enrichmentSources.push( - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ); - } + { isBasicFunctionality: this.#isBasicFunctionality() }, + ) + : [ + // AccountsApiDataSource: graduate custom assets before the RPC + // fallback; Snap and RPC updates go straight to detection. + ...(shouldRunRpcFallback + ? [ + this.#customAssetGraduationMiddleware, + this.#rpcFallbackMiddleware, + ] + : []), + this.#detectionMiddleware, + ...(this.#isBasicFunctionality() + ? [ + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ] + : []), + ]; const { response: enrichedResponse } = await this.#executeMiddlewares({ sources: enrichmentSources, diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts new file mode 100644 index 00000000000..178c8dbfa1e --- /dev/null +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -0,0 +1,345 @@ +/** + * Integration coverage for `AssetsController` websocket price updates — the + * behavior fixed by the "prices after websocket balance updates" change: + * after a websocket balance update, the Token and Price APIs are invoked so + * existing and newly-seen tokens get metadata and spot prices in the same + * pipeline pass. + * + * Boots the real controller against the same captured APIs as + * `buildWsUpdateSources.price-updates.integration.test.ts`, keeps the wallet + * lifecycle closed so only the websocket event drives the Token and Price + * APIs, and asserts what lands in persisted controller state. + */ + +import type { ApiPlatformClient } from '@metamask/core-backend'; +import { cleanAll } from 'nock'; + +import { createMockMessengers } from './__fixtures__/MockAssetControllerMessenger.js'; +import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js'; +import { createTestApiClient } from './__fixtures__/mockTokenApi.js'; +import { waitFor, waitUntilStable } from './__fixtures__/test-utils.js'; +import { mockWsApis } from './__fixtures__/ws-price-updates/api-responses/index.js'; +import { registerWsControllerActions } from './__fixtures__/ws-price-updates/messenger.js'; +import { + ETH_ASSET_ID, + USDC_ASSET_ID_CHECKSUM, + USDC_ASSET_ID_LOWERCASE, + WS_ACCOUNT_ID, +} from './__fixtures__/ws-price-updates/wallet.js'; +import { + buildEthBalanceUpdatedEvent, + buildUsdcBalanceUpdatedEvent, + ETH_WS_AMOUNT, + USDC_WS_AMOUNT, +} from './__fixtures__/ws-price-updates/wsEvents.js'; +import { + buildEmptyAssetsState, + getIgnoringCase, +} from './__fixtures__/ws-price-updates/wsWallet.js'; +import { AssetsController } from './AssetsController.js'; +import type { AssetsControllerState } from './AssetsController.js'; + +type WithControllerCallback = (args: { + controller: AssetsController; + messenger: MockRootMessenger; +}) => Promise; + +async function withController( + { + state, + queryApiClient = createTestApiClient(), + }: { + state: Partial; + queryApiClient?: ApiPlatformClient; + }, + fn: WithControllerCallback, +): Promise { + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: registerWsControllerActions, + }); + + const controller = new AssetsController({ + messenger: assetsControllerMessenger, + state, + queryApiClient, + isBasicFunctionality: (): boolean => true, + }); + + try { + return await fn({ controller, messenger: rootMessenger }); + } finally { + controller.destroy(); + queryApiClient.clear(); + } +} + +/** + * Boot the controller (lifecycle closed: UI shut, keyring locked, account + * tree uninitialized, so the subscribe lanes stay dormant and only the + * websocket event can drive the Token and Price APIs), deliver a websocket + * balance event, and let state settle. + * + * @param options - The run options. + * @param options.state - The state to boot with. + * @param options.event - The websocket event to deliver. + * @returns The settled controller state, the asset IDs the Token API was + * asked about, and the asset IDs the Price API was asked about. + */ +async function runWsEvent({ + state, + event, +}: { + state: Partial; + event: ReturnType; +}): Promise<{ + state: AssetsControllerState; + assetBatches: string[][]; + priceBatches: string[][]; +}> { + cleanAll(); + const { accountsSupportedNetworks, assets, prices } = mockWsApis(); + const queryApiClient = createTestApiClient(); + + const controllerState = await withController( + { state, queryApiClient }, + async ({ controller, messenger }) => { + // Wait for boot (`AccountsApiDataSource` reading the supported networks + // manifest) so the assertions below cannot pass on a wallet that never + // finished starting. + await waitFor(() => + expect(accountsSupportedNetworks.isDone()).toBe(true), + ); + + messenger.publish('AccountActivityService:balanceUpdated', event); + + await waitFor(() => { + // The event's balances must have landed before anything is asserted. + for (const update of event.updates) { + const landed = getIgnoringCase( + controller.state.assetsBalance[WS_ACCOUNT_ID] ?? {}, + update.asset.type, + ); + if (landed === undefined) { + throw new Error('Websocket balances have not landed yet'); + } + } + }); + + await waitUntilStable(() => controller.state); + + return controller.state; + }, + ); + + return { + state: controllerState, + assetBatches: assets.requestedBatches, + priceBatches: prices.requestedBatches, + }; +} + +describe('AssetsController: websocket price updates', () => { + afterEach(() => { + cleanAll(); + }); + + describe('brand-new holdings: ETH held and USDC acquired in one websocket event', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildEmptyAssetsState(), + event: { + address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', + chain: 'eip155:1', + updates: [ + ...buildEthBalanceUpdatedEvent().updates, + ...buildUsdcBalanceUpdatedEvent().updates, + ], + }, + }); + }); + + it('persists both websocket balances', () => { + const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; + + expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ + amount: USDC_WS_AMOUNT, + }); + }); + + it('persists metadata for both holdings from the captured Token API', () => { + const eth = getIgnoringCase(result.state.assetsInfo, ETH_ASSET_ID) as { + name: string; + symbol: string; + decimals: number; + }; + const usdc = getIgnoringCase( + result.state.assetsInfo, + USDC_ASSET_ID_LOWERCASE, + ) as { name: string; symbol: string; decimals: number }; + + expect(eth).toMatchObject({ + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }); + expect(usdc).toMatchObject({ + name: 'USDC', + symbol: 'USDC', + decimals: 6, + }); + }); + + it('prices both holdings from the captured Price API in the same pass', () => { + const ethPrice = getIgnoringCase( + result.state.assetsPrice, + ETH_ASSET_ID, + ) as { price: number }; + const usdcPrice = getIgnoringCase( + result.state.assetsPrice, + USDC_ASSET_ID_LOWERCASE, + ) as { price: number }; + + expect(ethPrice).toBeDefined(); + expect(ethPrice.price).toBeGreaterThan(0); + expect(usdcPrice).toBeDefined(); + // The captured USDC price is pegged around one dollar. + expect(usdcPrice.price).toBeGreaterThan(0.9); + expect(usdcPrice.price).toBeLessThan(1.1); + }); + + it('invoked the Price API for both holdings', () => { + const askedAbout = new Set( + result.priceBatches.flat().map((assetId) => assetId.toLowerCase()), + ); + expect(askedAbout).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + + it('invoked the Token API for the new token and the native asset', () => { + const askedAbout = new Set( + result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), + ); + expect(askedAbout).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + }); + + describe('held-but-unpriced native asset: ETH in state without a price', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildEmptyAssetsState({ + assetsBalance: { + [WS_ACCOUNT_ID]: { [ETH_ASSET_ID]: { amount: '1' } }, + }, + assetsInfo: { + [ETH_ASSET_ID]: { + type: 'native', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44:60.png', + }, + }, + }), + event: buildEthBalanceUpdatedEvent(), + }); + }); + + it('persists the refreshed websocket balance', () => { + const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; + expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + }); + + it('prices the held-but-unpriced asset from the captured Price API', () => { + const ethPrice = getIgnoringCase( + result.state.assetsPrice, + ETH_ASSET_ID, + ) as { price: number }; + expect(ethPrice).toBeDefined(); + expect(ethPrice.price).toBeGreaterThan(0); + }); + + it('invoked the Price API for the held-but-unpriced asset', () => { + const askedAbout = new Set( + result.priceBatches.flat().map((assetId) => assetId.toLowerCase()), + ); + expect(askedAbout).toStrictEqual(new Set([ETH_ASSET_ID])); + }); + }); + + describe('already-priced token: USDC in state with balance, metadata and price', () => { + const SEEDED_USDC_PRICE = { + assetPriceType: 'fungible' as const, + price: 1, + usdPrice: 1, + pricePercentChange1d: 0.01, + lastUpdated: 1_756_100_000_000, + }; + + let result: Awaited>; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildEmptyAssetsState({ + assetsBalance: { + [WS_ACCOUNT_ID]: { + [USDC_ASSET_ID_CHECKSUM]: { amount: '5' }, + }, + }, + assetsInfo: { + [USDC_ASSET_ID_CHECKSUM]: { + type: 'erc20', + name: 'USDC', + symbol: 'USDC', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + }, + }, + assetsPrice: { + [USDC_ASSET_ID_CHECKSUM]: SEEDED_USDC_PRICE, + }, + }), + event: buildUsdcBalanceUpdatedEvent(), + }); + }); + + it('persists the refreshed websocket balance', () => { + const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; + expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ + amount: USDC_WS_AMOUNT, + }); + }); + + it('keeps the seeded price untouched', () => { + expect( + getIgnoringCase(result.state.assetsPrice, USDC_ASSET_ID_LOWERCASE), + ).toStrictEqual(SEEDED_USDC_PRICE); + }); + + it('did not invoke the Price API for the priced token', () => { + expect(result.priceBatches).toStrictEqual([]); + }); + + it('did not refetch metadata for the enriched token', () => { + // Native metadata is refreshed every pass, so the Token API is still + // asked about the native asset — but not about USDC. + const askedAbout = new Set( + result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), + ); + expect(askedAbout).toStrictEqual(new Set([ETH_ASSET_ID])); + }); + }); +}); diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/accounts-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/accounts-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..c6cea1c3195 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/accounts-api/v2-supportedNetworks.ts @@ -0,0 +1,25 @@ +const accountsV2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:137', + 'eip155:56', + 'eip155:1329', + 'eip155:43114', + 'eip155:59144', + 'eip155:8453', + 'eip155:10', + 'eip155:42161', + 'eip155:143', + 'eip155:999', + 'eip155:4663', + 'eip155:5042', + 'eip155:50', + ], + partialSupport: [ + 'tron:728126428', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'stellar:pubnet', + ], +} as const; + +export default accountsV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts new file mode 100644 index 00000000000..9e0379a1750 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts @@ -0,0 +1,219 @@ +import { API_URLS } from '@metamask/core-backend'; +import type { V3AssetResponse } from '@metamask/core-backend'; +import type { Json } from '@metamask/utils'; +import nock from 'nock'; + +import accountsV2SupportedNetworks from './accounts-api/v2-supportedNetworks.js'; +import pricesV2SupportedNetworks from './price-api/v2-supportedNetworks.js'; +import v3SpotPrices from './price-api/v3-spot-prices.js'; +import suggestedOccurrenceFloors from './token-api/suggestedOccurrenceFloors.js'; +import tokensV2SupportedNetworks from './tokens-api/v2-supportedNetworks.js'; +import v3Assets from './tokens-api/v3-assets.js'; + +/** Captured `/v3/assets` entries, keyed by lower-cased CAIP-19 asset ID. */ +const V3_ASSETS_BY_LOWER_ID = v3Assets as unknown as Record< + string, + V3AssetResponse +>; + +/** Captured `/v3/spot-prices` entries, keyed by lower-cased CAIP-19 asset ID. */ +const V3_SPOT_PRICES_BY_LOWER_ID = v3SpotPrices as unknown as Record< + string, + Json +>; + +/** A batched interceptor plus a log of what it was asked for. */ +type BatchRecordingMock = { + scope: nock.Scope; + /** The asset IDs each intercepted request asked about, in request order. */ + requestedBatches: string[][]; +}; + +/** + * Intercept `GET https://chainid.network/chains.json`, which + * `AssetsController` fetches on boot to fill native-asset gaps. + * + * @returns The nock scope. + */ +function mockChainIdNetwork(): nock.Scope { + return nock('https://chainid.network') + .persist() + .get('/chains.json') + .reply(200, []); +} + +/** + * Intercept `GET {ACCOUNTS}/v2/supportedNetworks`, which + * `AccountsApiDataSource` reads on boot to decide which chains it claims. + * + * @returns The nock scope. + */ +function mockAccountsSupportedNetworks(): nock.Scope { + return nock(API_URLS.ACCOUNTS) + .persist() + .get('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/v2/supportedNetworks') + .reply(200, accountsV2SupportedNetworks); +} + +/** + * Intercept `GET {TOKENS}/v2/supportedNetworks`. `eip155:1` is in the captured + * `fullSupport` list, so Mainnet assets genuinely reach the Token and Price + * data sources rather than being skipped as unsupported. + * + * @returns The nock scope. + */ +function mockTokensSupportedNetworks(): nock.Scope { + return nock(API_URLS.TOKENS) + .persist() + .get('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/v2/supportedNetworks') + .reply(200, tokensV2SupportedNetworks); +} + +/** + * Intercept `GET {TOKEN}/v1/suggestedOccurrenceFloors`. The capture has `1` + * (Mainnet) at three, so a websocket-first USDC sighting must beat that floor + * on its own captured occurrences to survive. + * + * @returns The nock scope. + */ +function mockSuggestedOccurrenceFloors(): nock.Scope { + return nock(API_URLS.TOKEN) + .persist() + .get('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/v1/suggestedOccurrenceFloors') + .reply(200, suggestedOccurrenceFloors); +} + +/** + * Intercept `GET {TOKENS}/v3/assets`, answering each batch from the captured + * per-asset entries and preserving the API's lower-case `assetId` echo. Assets + * the API does not carry are answered as empty stubs, as it does for tokens on + * chains it does not index. + * + * @returns The nock scope and the asset IDs each request asked about. + */ +function mockV3Assets(): BatchRecordingMock { + const requestedBatches: string[][] = []; + + const scope = nock(API_URLS.TOKENS) + .persist() + .get('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/v3/assets') + .query(true) + .reply(200, (uri: string) => { + const assetIds = readListParam(uri, 'assetIds', API_URLS.TOKENS); + requestedBatches.push(assetIds); + return assetIds.map((assetId) => lookupAsset(assetId)); + }); + + return { scope, requestedBatches }; +} + +/** + * Intercept `GET {PRICES}/v2/supportedNetworks`, which `PriceDataSource` reads + * before fetching. + * + * @returns The nock scope. + */ +function mockPricesSupportedNetworks(): nock.Scope { + return nock(API_URLS.PRICES) + .persist() + .get('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/v2/supportedNetworks') + .reply(200, pricesV2SupportedNetworks); +} + +/** + * Intercept `GET {PRICES}/v3/spot-prices`, answering from the captured prices + * and keying the response lower-case as the live API does. Assets with no + * captured price are simply absent, as they are upstream. + * + * @returns The nock scope and the asset IDs each request asked about. + */ +function mockV3SpotPrices(): BatchRecordingMock { + const requestedBatches: string[][] = []; + + const scope = nock(API_URLS.PRICES) + .persist() + .get('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/v3/spot-prices') + .query(true) + .reply(200, (uri: string) => { + const assetIds = readListParam(uri, 'assetIds', API_URLS.PRICES); + requestedBatches.push(assetIds); + + const prices: Record = {}; + for (const assetId of assetIds) { + const lowerId = assetId.toLowerCase(); + const captured = V3_SPOT_PRICES_BY_LOWER_ID[lowerId]; + if (captured !== undefined) { + prices[lowerId] = captured; + } + } + return prices; + }); + + return { scope, requestedBatches }; +} + +/** + * Register every interceptor the websocket update pass needs for this wallet: + * the Token API occurrence floors, Tokens API supported networks and assets, + * the Price API supported networks and spot prices, plus the Accounts API + * supported networks and `chainid.network` manifest `AssetsController` reads + * on boot. + * + * All interceptors persist, so batch composition and cache misses cannot make a + * test fail for want of an interceptor. The Tokens and Price batched + * interceptors record what they were asked about, so tests can assert the + * websocket pass actually invoked them. + * + * @returns The recording mocks. + */ +export function mockWsApis(): { + accountsSupportedNetworks: nock.Scope; + assets: BatchRecordingMock; + prices: BatchRecordingMock; +} { + const accountsSupportedNetworks = mockAccountsSupportedNetworks(); + mockTokensSupportedNetworks(); + mockSuggestedOccurrenceFloors(); + mockPricesSupportedNetworks(); + mockChainIdNetwork(); + + const assets = mockV3Assets(); + const prices = mockV3SpotPrices(); + + return { accountsSupportedNetworks, assets, prices }; +} + +/** + * Read a comma-separated query parameter back off an intercepted request URI. + * + * @param uri - The intercepted request URI, path and query. + * @param param - The query parameter name. + * @param base - Base URL, so the relative URI can be parsed. + * @returns The parameter's values. + */ +function readListParam(uri: string, param: string, base: string): string[] { + const value = new URL(uri, base).searchParams.get(param); + return value ? value.split(',') : []; +} + +/** + * Look up the captured `/v3/assets` entry for an asset, answering an empty stub + * for tokens the API does not carry. + * + * @param assetId - The CAIP-19 asset ID, as requested (any casing). + * @returns The captured entry, or an empty stub. + */ +function lookupAsset(assetId: string): V3AssetResponse { + const captured = V3_ASSETS_BY_LOWER_ID[assetId.toLowerCase()]; + if (captured) { + return captured; + } + return { + symbol: '', + name: '', + decimals: null, + address: assetId.split(':').pop() ?? assetId, + type: 'erc20', + assetId: assetId.toLowerCase(), + } as unknown as V3AssetResponse; +} diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..19ca2947e2c --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v2-supportedNetworks.ts @@ -0,0 +1,173 @@ +const priceV2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:10', + 'eip155:56', + 'eip155:100', + 'eip155:137', + 'eip155:250', + 'eip155:324', + 'eip155:8453', + 'eip155:42161', + 'eip155:43114', + 'eip155:59144', + ], + partialSupport: { + spotPricesV2: [ + 'eip155:25', + 'eip155:30', + 'eip155:42', + 'eip155:50', + 'eip155:57', + 'eip155:66', + 'eip155:70', + 'eip155:82', + 'eip155:88', + 'eip155:106', + 'eip155:122', + 'eip155:128', + 'eip155:143', + 'eip155:146', + 'eip155:196', + 'eip155:232', + 'eip155:252', + 'eip155:288', + 'eip155:321', + 'eip155:336', + 'eip155:361', + 'eip155:714', + 'eip155:747', + 'eip155:988', + 'eip155:999', + 'eip155:1071', + 'eip155:1088', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:1776', + 'eip155:1868', + 'eip155:2342', + 'eip155:2525', + 'eip155:2741', + 'eip155:4217', + 'eip155:4326', + 'eip155:4663', + 'eip155:5000', + 'eip155:5042', + 'eip155:7000', + 'eip155:9745', + 'eip155:10000', + 'eip155:16507', + 'eip155:33139', + 'eip155:41923', + 'eip155:42220', + 'eip155:42262', + 'eip155:42431', + 'eip155:42793', + 'eip155:43111', + 'eip155:57073', + 'eip155:60808', + 'eip155:68414', + 'eip155:73115', + 'eip155:80094', + 'eip155:81457', + 'eip155:88888', + 'eip155:97741', + 'eip155:98866', + 'eip155:167000', + 'eip155:333999', + 'eip155:534352', + 'eip155:747474', + 'eip155:984122', + 'eip155:1440000', + 'eip155:1313161554', + 'eip155:1666600000', + 'eip155:130', + 'eip155:16661', + 'eip155:204', + 'eip155:5031', + ], + spotPricesV3: [ + 'eip155:25', + 'eip155:30', + 'eip155:42', + 'eip155:50', + 'eip155:57', + 'eip155:66', + 'eip155:70', + 'eip155:82', + 'eip155:88', + 'eip155:106', + 'eip155:122', + 'eip155:128', + 'eip155:143', + 'eip155:146', + 'eip155:196', + 'eip155:232', + 'eip155:252', + 'eip155:288', + 'eip155:321', + 'eip155:336', + 'eip155:361', + 'eip155:714', + 'eip155:747', + 'eip155:988', + 'eip155:999', + 'eip155:1071', + 'eip155:1088', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:1776', + 'eip155:1868', + 'eip155:2342', + 'eip155:2525', + 'eip155:2741', + 'eip155:4217', + 'eip155:4326', + 'eip155:4663', + 'eip155:5000', + 'eip155:5042', + 'eip155:7000', + 'eip155:9745', + 'eip155:10000', + 'eip155:16507', + 'eip155:33139', + 'eip155:41923', + 'eip155:42220', + 'eip155:42262', + 'eip155:42431', + 'eip155:42793', + 'eip155:43111', + 'eip155:57073', + 'eip155:60808', + 'eip155:68414', + 'eip155:73115', + 'eip155:80094', + 'eip155:81457', + 'eip155:88888', + 'eip155:97741', + 'eip155:98866', + 'eip155:167000', + 'eip155:333999', + 'eip155:534352', + 'eip155:747474', + 'eip155:984122', + 'eip155:1440000', + 'eip155:1313161554', + 'eip155:1666600000', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'bip122:000000000019d6689c085ae165831e93', + 'tron:728126428', + 'stellar:pubnet', + 'eip155:130', + 'eip155:16661', + 'eip155:204', + 'eip155:5031', + ], + }, +} as const; + +export default priceV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v3-spot-prices.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v3-spot-prices.ts new file mode 100644 index 00000000000..e61e4a69e8f --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/price-api/v3-spot-prices.ts @@ -0,0 +1,48 @@ +const v3SpotPrices = { + 'eip155:1/slip44:60': { + id: 'eip155:1/slip44:60', + price: 2695.143083654188, + marketCap: 328968823909, + allTimeHigh: 4946.05, + allTimeLow: 0.432979, + totalVolume: 14154333653, + high1d: 2700.84, + low1d: 2634.74, + circulatingSupply: 122078905.3531288, + dilutedMarketCap: 328968823909, + marketCapPercentChange1d: 1.31673, + priceChange1d: 34.86, + pricePercentChange1h: 0.7096, + pricePercentChange1d: 1.54, + pricePercentChange7d: 8.1116, + pricePercentChange14d: 9.0722, + pricePercentChange30d: 9.2978, + pricePercentChange200d: 35.4966, + pricePercentChange1y: -32.9048, + liquidity: 402736978.704722, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + price: 0.999893, + marketCap: 75417765889, + allTimeHigh: 1.043, + allTimeLow: 0.877647, + totalVolume: 19196419238, + high1d: 1.001, + low1d: 0.99942, + circulatingSupply: 75425993902.40373, + dilutedMarketCap: 75448676623, + marketCapPercentChange1d: 0.08344, + priceChange1d: 0.00010448, + pricePercentChange1h: 0.0055, + pricePercentChange1d: 0.01135, + pricePercentChange7d: 0.0303, + pricePercentChange14d: 0.0085, + pricePercentChange30d: -0.0033, + pricePercentChange200d: 0.0143, + pricePercentChange1y: 0.014, + liquidity: 2593057210.707836, + }, +} as const; + +export default v3SpotPrices; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/token-api/suggestedOccurrenceFloors.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/token-api/suggestedOccurrenceFloors.ts new file mode 100644 index 00000000000..42acc025150 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/token-api/suggestedOccurrenceFloors.ts @@ -0,0 +1,14 @@ +const suggestedOccurrenceFloors = { + '1': 3, + '143': 1, + '204': 1, + '232': 1, + '690': 1, + '1329': 1, + '4663': 1, + '10143': 1, + '59144': 1, + '98866': 1, +} as const; + +export default suggestedOccurrenceFloors; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..5005720331d --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v2-supportedNetworks.ts @@ -0,0 +1,69 @@ +const tokensV2SupportedNetworks = { + fullSupport: [ + 'eip155:1', + 'eip155:10', + 'eip155:25', + 'eip155:56', + 'eip155:100', + 'eip155:137', + 'eip155:143', + 'eip155:250', + 'eip155:324', + 'eip155:1101', + 'eip155:1284', + 'eip155:1285', + 'eip155:1329', + 'eip155:8453', + 'eip155:42161', + 'eip155:42220', + 'eip155:43114', + 'eip155:59144', + 'eip155:1313161554', + 'eip155:1666600000', + 'eip155:11297108109', + 'eip155:13371', + 'eip155:534352', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + 'tron:728126428', + 'stellar:pubnet', + 'eip155:698', + 'eip155:16507', + 'eip155:41923', + 'eip155:747474', + 'eip155:80094', + 'eip155:33139', + 'eip155:2741', + 'eip155:1868', + 'eip155:166', + 'eip155:1440000', + 'eip155:252', + 'eip155:43111', + 'eip155:50', + 'eip155:42', + 'eip155:9745', + 'eip155:999', + 'eip155:1776', + 'eip155:4326', + 'eip155:196', + 'eip155:68414', + 'eip155:42793', + 'eip155:60808', + 'eip155:30', + 'bip122:000000000019d6689c085ae165831e93', + 'eip155:88888', + 'eip155:988', + 'eip155:42431', + 'eip155:4217', + 'eip155:5000', + 'eip155:5042', + 'eip155:4663', + 'eip155:5031', + 'eip155:16661', + 'eip155:130', + 'eip155:204', + 'eip155:81457', + ], + partialSupport: ['solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'], +} as const; + +export default tokensV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v3-assets.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v3-assets.ts new file mode 100644 index 00000000000..963ec61010c --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/tokens-api/v3-assets.ts @@ -0,0 +1,62 @@ +const v3Assets = { + 'eip155:1/slip44:60': { + aggregators: [], + assetId: 'eip155:1/slip44:60', + decimals: 18, + description: { + en: 'Ethereum is a global, open-source platform for decentralized applications. In other words, the vision is to create a world computer that anyone can build applications in a decentralized manner; while all states and data are distributed and publicly accessible. Ethereum supports smart contracts in which developers can write code in order to program digital value. Examples of decentralized apps (dapps) that are built on Ethereum includes tokens, non-fungible tokens, decentralized finance apps, lending protocol, decentralized exchanges, and much more.On Ethereum, all transactions and smart contract executions require a small fee to be paid. This fee is called Gas. In technical terms, Gas refers to the unit of measure on the amount of computational effort required to execute an operation or a smart contract. The more complex the execution operation is, the more gas is required to fulfill that operation. Gas fees are paid entirely in Ether (ETH), which is the native coin of the blockchain. The price of gas can fluctuate from time to time depending on the network demand.', + ko: '이더리움(Ethereum/ETH)은 블록체인 기술에 기반한 클라우드 컴퓨팅 플랫폼 또는 프로그래밍 언어이다. 비탈릭 부테린이 개발하였다.비탈릭 부테린은 가상화폐인 비트코인에 사용된 핵심 기술인 블록체인(blockchain)에 화폐 거래 기록뿐 아니라 계약서 등의 추가 정보를 기록할 수 있다는 점에 착안하여, 전 세계 수많은 사용자들이 보유하고 있는 컴퓨팅 자원을 활용해 분산 네트워크를 구성하고, 이 플랫폼을 이용하여 SNS, 이메일, 전자투표 등 다양한 정보를 기록하는 시스템을 창안했다. 이더리움은 C++, 자바, 파이썬, GO 등 주요 프로그래밍 언어를 지원한다.이더리움을 사물 인터넷(IoT)에 적용하면 기계 간 금융 거래도 가능해진다. 예를 들어 고장난 청소로봇이 정비로봇에 돈을 내고 정비를 받고, 청소로봇은 돈을 벌기 위해 정비로봇의 집을 청소하는 것도 가능해진다.', + zh: 'Ethereum(以太坊)是一个平台和一种编程语言,使开发人员能够建立和发布下一代分布式应用。Ethereum 是使用甲醚作为燃料,以激励其网络的第一个图灵完备cryptocurrency。Ethereum(以太坊) 是由Vitalik Buterin的创建。该项目于2014年8月获得了美国1800万$比特币的价值及其crowdsale期间。在2016年,Ethereum(以太坊)的价格上涨超过50倍。', + ja: 'イーサリアム (Ethereum, ETH)・プロジェクトにより開発が進められている、分散型アプリケーション(DApps)やスマート・コントラクトを構築するためのプラットフォームの名称、及び関連するオープンソース・ソフトウェア・プロジェクトの総称である。イーサリアムでは、イーサリアム・ネットワークと呼ばれるP2Pのネットワーク上でスマート・コントラクトの履行履歴をブロックチェーンに記録していく。またイーサリアムは、スマート・コントラクトを記述するチューリング完全なプログラミング言語を持ち、ネットワーク参加者はこのネットワーク上のブロックチェーンに任意のDAppsやスマート・コントラクトを記述しそれを実行することが可能になる。ネットワーク参加者が「Ether」と呼ばれるイーサリアム内部通貨の報酬を目当てに、採掘と呼ばれるブロックチェーンへのスマート・コントラクトの履行結果の記録を行うことで、その正統性を保証していく。このような仕組みにより特定の中央管理組織に依拠せず、P2P全体を実行環境としてプログラムの実行とその結果を共有することが可能になった。', + }, + erc20Permit: false, + honeypotStatus: {}, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + name: 'Ethereum', + occurrences: 1, + symbol: 'ETH', + isContractVerified: false, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + aggregators: [ + 'metamask', + 'liFi', + 'oneInch', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'trustWallet', + 'bancor', + ], + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + description: { + en: 'USDC is a fully collateralized US dollar stablecoin. USDC is the bridge between dollars and trading on cryptocurrency exchanges. The technology behind CENTRE makes it possible to exchange value between people, businesses and financial institutions just like email between mail services and texts between SMS providers. We believe by removing artificial economic borders, we can create a more inclusive global economy.', + }, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + labels: ['stable_coin', 'badges:v1:stablecoin'], + name: 'USDC', + occurrences: 10, + storage: { + balance: 9, + approval: 10, + }, + symbol: 'USDC', + isContractVerified: true, + }, +} as const; + +export default v3Assets; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts new file mode 100644 index 00000000000..f384d4b9463 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts @@ -0,0 +1,151 @@ +import { API_URLS } from '@metamask/core-backend'; +import { writeFile } from '@metamask/utils/node'; + +/** + * Captures the API responses the websocket-update pipeline asks for, verbatim, + * into this directory. Re-run with: + * + * ```sh + * node --experimental-strip-types packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts + * ``` + * + * The captures are what the AccountActivity (websocket) update pass requests: + * occurrence floors (spam filtering of websocket airdrops), Tokens API metadata + * for the assets the event surfaced, and Price API spot prices — the same pass + * `AssetsController.handleAssetsUpdate` runs after a + * `AccountActivityService:balanceUpdated` event, plus the supported-networks + * manifests each API serves. + * + * The asset IDs mirror `../wallet.ts` (kept inline so the script runs with + * plain `node --experimental-strip-types`, which does not remap `.js` imports). + */ + +const OUT_DIR = `${import.meta.dirname}/api-responses`; + +/** + * The assets the example websocket event reports balances for, in the + * checksummed form `AccountActivityDataSource` normalization emits. + */ +const WS_EVENT_ASSET_IDS = [ + 'eip155:1/slip44:60', + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', +] as const; + +const V3_ASSETS_QUERY = { + includeIconUrl: 'true', + includeMarketData: 'true', + includeMetadata: 'true', + includeLabels: 'true', + includeRwaData: 'true', + includeAggregators: 'true', + includeOccurrences: 'true', +} as const; + +const V3_SPOT_PRICES_QUERY = { + vsCurrency: 'usd', + includeMarketData: 'true', + cacheOnly: 'false', +} as const; + +/** + * Fetch and parse JSON from the given URL, throwing on HTTP failures. + * + * @param url - The URL to fetch. + * @returns The parsed response body. + */ +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${url}`); + } + return response.json(); +} + +/** + * Write a captured response as a fixture module. + * + * @param relativePath - The fixture path, relative to `api-responses/`. + * @param constName - The exported constant's name. + * @param data - The captured response body. + */ +async function writeFixture( + relativePath: string, + constName: string, + data: unknown, +): Promise { + await writeFile( + `${OUT_DIR}/${relativePath}`, + `const ${constName} = ${JSON.stringify(data, null, 2)} as const;\n\nexport default ${constName};\n`, + ); +} + +/** + * Capture a service's `/v2/supportedNetworks` manifest. + * + * @param service - The service whose manifest to capture. + */ +async function captureSupportedNetworks( + service: 'accounts' | 'tokens' | 'price', +): Promise { + const baseUrl = + API_URLS[service === 'price' ? 'PRICES' : service.toUpperCase()]; + const networks = await fetchJson(`${baseUrl}/v2/supportedNetworks`); + await writeFixture( + `${service}-api/v2-supportedNetworks.ts`, + `${service}V2SupportedNetworks`, + networks, + ); +} + +/** + * Capture every fixture the websocket-update tests replay. + */ +async function main(): Promise { + await captureSupportedNetworks('accounts'); + await captureSupportedNetworks('tokens'); + await captureSupportedNetworks('price'); + + const floors = await fetchJson( + `${API_URLS.TOKEN}/v1/suggestedOccurrenceFloors`, + ); + await writeFixture( + 'token-api/suggestedOccurrenceFloors.ts', + 'suggestedOccurrenceFloors', + floors, + ); + + const assetsParams = new URLSearchParams({ + ...V3_ASSETS_QUERY, + assetIds: WS_EVENT_ASSET_IDS.join(','), + }); + const assets = (await fetchJson( + `${API_URLS.TOKENS}/v3/assets?${assetsParams.toString()}`, + )) as { assetId?: string }[]; + // Keyed by the lower-cased asset ID the API echoes back, matching + // `__fixtures__/bsc-spam-token/api-responses/index.ts`. + const assetsByLowerId: Record = {}; + for (const entry of assets) { + if (entry.assetId) { + assetsByLowerId[entry.assetId.toLowerCase()] = entry; + } + } + await writeFixture('tokens-api/v3-assets.ts', 'v3Assets', assetsByLowerId); + + const pricesParams = new URLSearchParams({ + ...V3_SPOT_PRICES_QUERY, + assetIds: WS_EVENT_ASSET_IDS.join(','), + }); + const spotPrices = (await fetchJson( + `${API_URLS.PRICES}/v3/spot-prices?${pricesParams.toString()}`, + )) as Record; + await writeFixture('price-api/v3-spot-prices.ts', 'v3SpotPrices', spotPrices); + + console.log( + `Captured ${Object.keys(assetsByLowerId).length} assets and ${Object.keys(spotPrices).length} spot prices.`, + ); +} + +main().catch((error) => { + console.error(error); + throw error; +}); diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts new file mode 100644 index 00000000000..0ad5f8a1834 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts @@ -0,0 +1,150 @@ +import { MockInternalProvider } from '@metamask/eth-json-rpc-provider'; +import type { NetworkState } from '@metamask/network-controller'; +import { + getDefaultNetworkControllerState, + NetworkClientType, + NetworkStatus, + RpcEndpointType, +} from '@metamask/network-controller'; + +import { + registerAccountMocks, + registerWalletLifecycleMocks, +} from '../MockAssetControllerMessenger.js'; +import type { + MockRootMessenger, + RegisterWalletLifecycleMocksOptions, +} from '../MockAssetControllerMessenger.js'; +import { + ETH_ASSET_ID, + MAINNET_CHAIN_ID, + MAINNET_CHAIN_ID_HEX, + MAINNET_NETWORK_CLIENT_ID, + MAINNET_RPC_URL, +} from './wallet.js'; +import { buildWsAccount } from './wsWallet.js'; + +/** + * RPC provider that answers the cheap probes `RpcDataSource` makes without + * inventing token balances. Persist the stubs so a slow-lane fallback cannot + * fail the suite for want of a matching request. + * + * @returns The mock provider. + */ +function createMainnetMockProvider(): MockInternalProvider { + return new MockInternalProvider({ + stubs: [ + { method: 'eth_chainId', result: MAINNET_CHAIN_ID_HEX }, + { method: 'eth_call', result: '0x' }, + { method: 'eth_getBalance', result: '0x' }, + { method: 'eth_blockNumber', result: '0x' }, + ].map(({ method, result }) => ({ + request: { method }, + response: { result }, + discardAfterMatching: false, + })), + }); +} + +/** + * NetworkController state with Ethereum Mainnet selected and enabled. + * + * @returns The network state. + */ +function buildMainnetNetworkState(): NetworkState { + return { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: MAINNET_NETWORK_CLIENT_ID, + networkConfigurationsByChainId: { + [MAINNET_CHAIN_ID_HEX]: { + chainId: MAINNET_CHAIN_ID_HEX, + name: 'Ethereum Mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: MAINNET_NETWORK_CLIENT_ID, + url: MAINNET_RPC_URL, + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + }, + }, + networksMetadata: { + [MAINNET_NETWORK_CLIENT_ID]: { + status: NetworkStatus.Available, + EIPS: {}, + }, + }, + }; +} + +/** + * Register the NetworkController / NetworkEnablement / ConfigRegistry handlers + * the websocket price-update fixtures need. Shared by the pipeline and + * controller integration tests. + * + * @param rootMessenger - The root messenger to register handlers on. + */ +export function registerMainnetNetwork(rootMessenger: MockRootMessenger): void { + const provider = createMainnetMockProvider(); + const networkState = buildMainnetNetworkState(); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => networkState, + ); + + // `RpcDataSource` only reads `provider` off the client, so the + // configuration is here to keep the shape honest rather than to be used. + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => + ({ + configuration: { + type: NetworkClientType.Custom, + chainId: MAINNET_CHAIN_ID_HEX, + rpcUrl: MAINNET_RPC_URL, + ticker: 'ETH', + failoverRpcUrls: [], + }, + provider, + }) as never, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => ({ + enabledNetworkMap: { eip155: { [MAINNET_CHAIN_ID_HEX]: true } }, + nativeAssetIdentifiers: { [MAINNET_CHAIN_ID]: ETH_ASSET_ID }, + }), + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + () => undefined, + ); +} + +type RegisterWsControllerActionsOptions = RegisterWalletLifecycleMocksOptions; + +/** + * Register every external action `AssetsController` needs to boot the + * websocket price-update wallet: the account, lifecycle, feature flags, and + * the network handlers from {@link registerMainnetNetwork}. + * + * @param rootMessenger - The root messenger to register handlers on. + * @param opts - Lifecycle / flag overrides. + */ +export function registerWsControllerActions( + rootMessenger: MockRootMessenger, + opts: RegisterWsControllerActionsOptions = {}, +): void { + registerWalletLifecycleMocks(rootMessenger, opts); + registerAccountMocks(rootMessenger, { + accounts: [buildWsAccount()], + }); + registerMainnetNetwork(rootMessenger); +} diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts new file mode 100644 index 00000000000..0247099a00f --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts @@ -0,0 +1,30 @@ +/** Ethereum Mainnet, served by every captured API in this folder. */ +export const MAINNET_CHAIN_ID = 'eip155:1' as const; + +/** Example wallet, as the Account Activity websocket reports its address (lower case). */ +export const WS_WALLET_ADDRESS = '0x742d35cc6634c0532925a3b844bc454e4438f44e'; + +/** `InternalAccount.id` (a UUID), not the address — see `AccountId`. */ +export const WS_ACCOUNT_ID = 'c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f'; + +/** Native ETH, the wallet's pre-existing holding. */ +export const ETH_ASSET_ID = `${MAINNET_CHAIN_ID}/slip44:60` as const; + +/** USDC, first seen by the wallet on the websocket (lower case, as the WS sends it). */ +export const USDC_ADDRESS_LOWERCASE = + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + +/** Checksummed USDC, the normalized form state and the pipeline key assets by. */ +export const USDC_ADDRESS_CHECKSUM = + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + +export const USDC_ASSET_ID_LOWERCASE = + `${MAINNET_CHAIN_ID}/erc20:${USDC_ADDRESS_LOWERCASE}` as const; + +export const USDC_ASSET_ID_CHECKSUM = + `${MAINNET_CHAIN_ID}/erc20:${USDC_ADDRESS_CHECKSUM}` as const; + +// RPC mocks +export const MAINNET_CHAIN_ID_HEX = '0x1' as const; +export const MAINNET_NETWORK_CLIENT_ID = 'mainnet' as const; +export const MAINNET_RPC_URL = 'https://mainnet-rpc.test'; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts new file mode 100644 index 00000000000..c0dfa9c9109 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts @@ -0,0 +1,85 @@ +import type { BalanceUpdate } from '@metamask/core-backend'; + +import { + ETH_ASSET_ID, + MAINNET_CHAIN_ID, + USDC_ASSET_ID_LOWERCASE, + WS_WALLET_ADDRESS, +} from './wallet.js'; + +/** Payload of an `AccountActivityService:balanceUpdated` event. */ +export type BalanceUpdatedEventPayload = { + address: string; + chain: string; + updates: BalanceUpdate[]; +}; + +/** + * Hex quantities the websocket reports, as a transaction-driven wallet would + * (AADS converts `0x…` amounts to human-readable form before the pipeline). + */ +export const WS_ETH_WEI_HEX = '0x1bc16d674ec80000'; // 2 ETH +export const WS_USDC_BASE_UNITS_HEX = '0x989680'; // 10 USDC (6 decimals) + +/** Human-readable amounts AADS derives from the hex quantities above. */ +export const ETH_WS_AMOUNT = '2'; +export const USDC_WS_AMOUNT = '10'; + +/** + * Build an `AccountActivityService:balanceUpdated` event payload updating the + * native ETH balance — the wallet's pre-existing holding. + * + * @param options - Overrides for the balance amount. + * @param options.amount - The balance amount to report, in wei hex. + * @returns The event payload. + */ +export function buildEthBalanceUpdatedEvent(options?: { + amount?: string; +}): BalanceUpdatedEventPayload { + return { + address: WS_WALLET_ADDRESS, + chain: MAINNET_CHAIN_ID, + updates: [ + { + asset: { + fungible: true, + type: ETH_ASSET_ID, + unit: 'ETH', + decimals: 18, + }, + postBalance: { amount: options?.amount ?? WS_ETH_WEI_HEX }, + transfers: [], + }, + ], + }; +} + +/** + * Build an `AccountActivityService:balanceUpdated` event payload for USDC — + * a token the wallet never held before, first surfaced by the websocket + * (lower-case address, as the websocket sends ERC-20s). + * + * @param options - Overrides for the balance amount. + * @param options.amount - The balance amount to report, in base-unit hex. + * @returns The event payload. + */ +export function buildUsdcBalanceUpdatedEvent(options?: { + amount?: string; +}): BalanceUpdatedEventPayload { + return { + address: WS_WALLET_ADDRESS, + chain: MAINNET_CHAIN_ID, + updates: [ + { + asset: { + fungible: true, + type: USDC_ASSET_ID_LOWERCASE, + unit: 'USDC', + decimals: 6, + }, + postBalance: { amount: options?.amount ?? WS_USDC_BASE_UNITS_HEX }, + transfers: [], + }, + ], + }; +} diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts new file mode 100644 index 00000000000..b1f99eaf0f8 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts @@ -0,0 +1,70 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { AssetsControllerStateInternal } from '../../types.js'; +import { + MAINNET_CHAIN_ID, + WS_ACCOUNT_ID, + WS_WALLET_ADDRESS, +} from './wallet.js'; + +/** + * Build the wallet's `InternalAccount`. + * + * Scoped to Ethereum Mainnet only, so `accountsWithSupportedChains` resolves + * to the one chain the websocket events report balances for. + * + * @param overrides - Fields to override on the account. + * @returns The internal account. + */ +export function buildWsAccount( + overrides?: Partial, +): InternalAccount { + return { + id: WS_ACCOUNT_ID, + address: WS_WALLET_ADDRESS, + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: [MAINNET_CHAIN_ID], + metadata: { + name: 'WS Price Wallet', + keyring: { type: 'HD Key Tree' }, + importTime: 1_756_100_000_000, + lastSelected: 1_756_200_000_000, + }, + ...overrides, + } as InternalAccount; +} + +/** + * A fresh wallet: no balances, metadata, prices or custom assets yet, so the + * first websocket event surfaces holdings that are all brand new to state. + * + * @param overrides - State slices to override (e.g. seed the ETH holding of a + * prior pass). + * @returns The starting state. + */ +export function buildEmptyAssetsState( + overrides?: Partial, +): AssetsControllerStateInternal { + return { + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + selectedCurrency: 'usd', + ...overrides, + }; +} + +export function getIgnoringCase( + record: Record, + assetId: string, +): unknown { + const lowerId = assetId.toLowerCase(); + const match = Object.keys(record).find( + (key) => key.toLowerCase() === lowerId, + ); + return match === undefined ? undefined : record[match]; +} diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts new file mode 100644 index 00000000000..bd7acbb0207 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -0,0 +1,382 @@ +import type { Caip19AssetId } from '@metamask/utils'; +import { parseCaipAssetType } from '@metamask/utils'; +import { cleanAll } from 'nock'; + +import { + createMockMessengers, + registerAccountMocks, +} from '../__fixtures__/MockAssetControllerMessenger.js'; +import { createTestApiClient } from '../__fixtures__/mockTokenApi.js'; +import { waitFor } from '../__fixtures__/test-utils.js'; +import { mockWsApis } from '../__fixtures__/ws-price-updates/api-responses/index.js'; +import { registerMainnetNetwork } from '../__fixtures__/ws-price-updates/messenger.js'; +import { + ETH_ASSET_ID, + USDC_ASSET_ID_CHECKSUM, + USDC_ASSET_ID_LOWERCASE, + WS_ACCOUNT_ID, +} from '../__fixtures__/ws-price-updates/wallet.js'; +import { + buildEthBalanceUpdatedEvent, + buildUsdcBalanceUpdatedEvent, + ETH_WS_AMOUNT, + USDC_WS_AMOUNT, +} from '../__fixtures__/ws-price-updates/wsEvents.js'; +import { + buildEmptyAssetsState, + buildWsAccount, + getIgnoringCase, +} from '../__fixtures__/ws-price-updates/wsWallet.js'; +import { AccountActivityDataSource } from '../data-sources/AccountActivityDataSource.js'; +import { PriceDataSource } from '../data-sources/PriceDataSource.js'; +import { TokenDataSource } from '../data-sources/TokenDataSource.js'; +import { CustomAssetGraduationMiddleware } from '../middlewares/CustomAssetGraduationMiddleware.js'; +import { DetectionMiddleware } from '../middlewares/DetectionMiddleware.js'; +import type { AccountId, DataRequest, DataResponse } from '../types.js'; +import { buildWsUpdateSources } from './buildWsUpdateSources.js'; +import { executeAssetsPipeline } from './executeAssetsPipeline.js'; + +/** + * Run one websocket update pass over the wallet — everything but the + * controller: the real `AccountActivityDataSource` receives a + * `AccountActivityService:balanceUpdated` event, and its update flows through + * the composed websocket lane (`buildWsUpdateSources`) via + * `executeAssetsPipeline`, against the given state. + * + * @param options - The pass inputs. + * @param options.state - The state the pass sees. + * @param options.event - The websocket event to deliver. + * @returns The enriched response, the request the detection middleware + * mutated, and the asset IDs the Token and Price APIs were asked about. + */ +async function runWsUpdatePass({ + state, + event, +}: { + state: ReturnType; + event: ReturnType; +}): Promise<{ + response: DataResponse; + request: DataRequest; + priceBatches: string[][]; + assetBatches: string[][]; +}> { + cleanAll(); + const { assets, prices } = mockWsApis(); + + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger) => { + registerAccountMocks(messenger, { accounts: [buildWsAccount()] }); + registerMainnetNetwork(messenger); + }, + }); + + const queryApiClient = createTestApiClient(); + + const tokenDataSource = new TokenDataSource(assetsControllerMessenger, { + queryApiClient, + getNativeAssetIds: (): string[] => [ETH_ASSET_ID], + getAssetType: (assetId): 'native' | 'erc20' => + parseCaipAssetType(assetId).assetNamespace === 'erc20' + ? 'erc20' + : 'native', + }); + + const priceDataSource = new PriceDataSource({ + queryApiClient, + getSelectedCurrency: (): 'usd' => 'usd', + }); + + const sources = buildWsUpdateSources( + { + customAssetGraduationMiddleware: new CustomAssetGraduationMiddleware({ + getSelectedAccountId: (): AccountId => WS_ACCOUNT_ID, + removeCustomAsset: (): void => { + throw new Error( + 'Websocket pass should not graduate custom assets away!', + ); + }, + }), + detectionMiddleware: new DetectionMiddleware(), + tokenDataSource, + priceDataSource, + }, + { isBasicFunctionality: true }, + ); + + let captured: { response: DataResponse; request: DataRequest } | undefined; + const accountActivityDataSource = new AccountActivityDataSource({ + messenger: assetsControllerMessenger, + onActiveChainsUpdated: jest.fn(), + onAssetsUpdate: async ( + response: DataResponse, + request: DataRequest, + ): Promise => { + const { response: enriched } = await executeAssetsPipeline({ + sources, + request, + initialResponse: response, + getAssetsState: () => state, + }); + captured = { response: enriched, request }; + }, + }); + + rootMessenger.publish('AccountActivityService:balanceUpdated', event); + + await waitFor(() => { + if (!captured) { + throw new Error('Websocket update pass has not completed yet'); + } + }); + + accountActivityDataSource.destroy(); + queryApiClient.clear(); + + return { + response: captured.response, + request: captured.request, + priceBatches: prices.requestedBatches, + assetBatches: assets.requestedBatches, + }; +} + +/** + * Asset IDs an API was asked about, lower-cased, across all batches. + * + * @param batches - The recorded request batches. + * @returns The lower-cased asset IDs asked about. + */ +function askedAbout(batches: string[][]): Set { + return new Set(batches.flat().map((assetId) => assetId.toLowerCase())); +} + +describe('websocket update pipeline: prices for surfaced holdings', () => { + afterEach(() => { + cleanAll(); + }); + + describe('brand-new holdings: ETH held and USDC acquired in one websocket event', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEmptyAssetsState(), + event: { + address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', + chain: 'eip155:1', + updates: [ + ...buildEthBalanceUpdatedEvent().updates, + ...buildUsdcBalanceUpdatedEvent().updates, + ], + }, + }); + }); + + it('detects both holdings as new', () => { + const detected = result.response.detectedAssets?.[ + WS_ACCOUNT_ID + ] as Caip19AssetId[]; + expect(detected?.map((id) => id.toLowerCase())).toStrictEqual( + expect.arrayContaining([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + + it('enriches both holdings with captured metadata in the same pass', () => { + const eth = getIgnoringCase( + result.response.assetsInfo ?? {}, + ETH_ASSET_ID, + ) as { name: string; symbol: string; decimals: number }; + const usdc = getIgnoringCase( + result.response.assetsInfo ?? {}, + USDC_ASSET_ID_LOWERCASE, + ) as { name: string; symbol: string; decimals: number }; + + expect(eth).toMatchObject({ + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }); + expect(usdc).toMatchObject({ + name: 'USDC', + symbol: 'USDC', + decimals: 6, + }); + }); + + it('carries the websocket balances through', () => { + const balances = result.response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}; + + expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ + amount: USDC_WS_AMOUNT, + }); + }); + + it('queues both holdings for a price update', () => { + // The detection middleware mutates the request so the price data source + // prices the holdings it surfaced. + const queued = + result.request.assetsForPriceUpdate?.map((id) => id.toLowerCase()) ?? + []; + expect(queued).toStrictEqual( + expect.arrayContaining([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + + it('invokes the price API for both holdings in the same pass', () => { + expect(askedAbout(result.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + + it('prices both holdings from the captured spot prices', () => { + const ethPrice = getIgnoringCase( + result.response.assetsPrice ?? {}, + ETH_ASSET_ID, + ) as { price: number }; + const usdcPrice = getIgnoringCase( + result.response.assetsPrice ?? {}, + USDC_ASSET_ID_LOWERCASE, + ) as { price: number }; + + expect(ethPrice).toBeDefined(); + expect(ethPrice.price).toBeGreaterThan(0); + expect(usdcPrice).toBeDefined(); + // The captured USDC price is pegged around one dollar. + expect(usdcPrice.price).toBeGreaterThan(0.9); + expect(usdcPrice.price).toBeLessThan(1.1); + }); + + it('asks the Token API for the new token and the native asset', () => { + // The occurrence filter consults occurrences for the new ERC-20 before + // detection, and metadata enrichment covers the native asset every pass. + expect(askedAbout(result.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + }); + + describe('held-but-unpriced native asset: ETH in state without a price', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEmptyAssetsState({ + assetsBalance: { + [WS_ACCOUNT_ID]: { [ETH_ASSET_ID]: { amount: '1' } }, + }, + assetsInfo: { + [ETH_ASSET_ID]: { + type: 'native', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44:60.png', + }, + }, + }), + event: buildEthBalanceUpdatedEvent(), + }); + }); + + it('does not re-detect the holding', () => { + expect(result.response.detectedAssets?.[WS_ACCOUNT_ID]).toBeUndefined(); + }); + + it('queues the held-but-unpriced asset for a price update', () => { + const queued = + result.request.assetsForPriceUpdate?.map((id) => id.toLowerCase()) ?? + []; + expect(queued).toStrictEqual([ETH_ASSET_ID]); + }); + + it('invokes the price API for the held-but-unpriced asset', () => { + expect(askedAbout(result.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), + ); + }); + + it('prices the held asset from the captured spot price', () => { + const ethPrice = getIgnoringCase( + result.response.assetsPrice ?? {}, + ETH_ASSET_ID, + ) as { price: number }; + expect(ethPrice).toBeDefined(); + expect(ethPrice.price).toBeGreaterThan(0); + }); + + it('carries the refreshed websocket balance', () => { + const balances = result.response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}; + expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + }); + }); + + describe('already-priced token: USDC in state with balance, metadata and price', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEmptyAssetsState({ + assetsBalance: { + [WS_ACCOUNT_ID]: { + [USDC_ASSET_ID_CHECKSUM]: { amount: '5' }, + }, + }, + assetsInfo: { + [USDC_ASSET_ID_CHECKSUM]: { + type: 'erc20', + name: 'USDC', + symbol: 'USDC', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + }, + }, + assetsPrice: { + [USDC_ASSET_ID_CHECKSUM]: { + assetPriceType: 'fungible', + price: 1, + usdPrice: 1, + pricePercentChange1d: 0.01, + lastUpdated: 1_756_100_000_000, + }, + }, + }), + event: buildUsdcBalanceUpdatedEvent(), + }); + }); + + it('does not re-detect the token', () => { + expect(result.response.detectedAssets?.[WS_ACCOUNT_ID]).toBeUndefined(); + }); + + it('does not queue the priced token for another price update', () => { + expect(result.request.assetsForPriceUpdate ?? []).toStrictEqual([]); + }); + + it('does not invoke the price API', () => { + expect(result.priceBatches).toStrictEqual([]); + }); + + it('does not refetch metadata for the enriched token, only the native asset', () => { + // Native metadata is refreshed every pass; the fully enriched token is + // left alone. + expect(askedAbout(result.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), + ); + }); + + it('carries the websocket balance refresh', () => { + const balances = result.response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}; + expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ + amount: USDC_WS_AMOUNT, + }); + }); + }); +}); diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts new file mode 100644 index 00000000000..a898ddb8184 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts @@ -0,0 +1,73 @@ +import type { AssetsDataSource, Middleware } from '../types.js'; +import { buildWsUpdateSources } from './buildWsUpdateSources.js'; +import type { WsUpdateSources } from './buildWsUpdateSources.js'; + +function stubSource(name: string): AssetsDataSource { + return { + getName: () => name, + assetsMiddleware: (async (ctx) => ctx) as Middleware, + }; +} + +function stubTokenSource(name: string): WsUpdateSources['tokenDataSource'] { + return { + ...stubSource(name), + occurrenceFilterMiddleware: (async (ctx) => ctx) as Middleware, + }; +} + +function buildSources(): WsUpdateSources { + return { + customAssetGraduationMiddleware: stubSource( + 'CustomAssetGraduationMiddleware', + ), + detectionMiddleware: stubSource('DetectionMiddleware'), + tokenDataSource: stubTokenSource('TokenDataSource'), + priceDataSource: stubSource('PriceDataSource'), + }; +} + +describe('buildWsUpdateSources', () => { + it.each([ + { + title: + 'orders the lane graduation → occurrence filter → detection → enrichment', + isBasicFunctionality: true, + expected: [ + 'CustomAssetGraduationMiddleware', + 'OccurrenceFloorFilter', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, + { + title: 'runs only the graduation and detection', + isBasicFunctionality: false, + // No network-backed source may run when the user has opted out. + expected: ['CustomAssetGraduationMiddleware', 'DetectionMiddleware'], + }, + ])('$title', ({ isBasicFunctionality, expected }) => { + const sources = buildWsUpdateSources(buildSources(), { + isBasicFunctionality, + }); + + expect(sources.map((source) => source.getName())).toStrictEqual(expected); + }); + + it('derives the occurrence filter from the token data source', () => { + const tokenDataSource = stubTokenSource('TokenDataSource'); + const middlewareSpy = jest.fn(tokenDataSource.occurrenceFilterMiddleware); + tokenDataSource.occurrenceFilterMiddleware = middlewareSpy; + + const sources = buildWsUpdateSources( + { ...buildSources(), tokenDataSource }, + { isBasicFunctionality: true }, + ); + + const occurrenceFloorFilter = sources.find( + (source) => source.getName() === 'OccurrenceFloorFilter', + ); + expect(occurrenceFloorFilter).toBeDefined(); + expect(occurrenceFloorFilter?.assetsMiddleware).toBe(middlewareSpy); + }); +}); diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts new file mode 100644 index 00000000000..53c3d353a57 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts @@ -0,0 +1,68 @@ +import { createParallelMiddleware } from '../middlewares/ParallelMiddleware.js'; +import type { AssetsDataSource, Middleware } from '../types.js'; + +/** + * The sources the websocket update lane composes, in the roles the lane + * assigns them. + * + * `AssetsController` constructs all of these unconditionally, so the lane can + * assume every role is filled. `isBasicFunctionality` decides which of them + * actually run, not which are supplied. + */ +export type WsUpdateSources = { + customAssetGraduationMiddleware: AssetsDataSource; + detectionMiddleware: AssetsDataSource; + /** Also supplies the occurrence-filter middleware used for websocket airdrops. */ + tokenDataSource: AssetsDataSource & { + readonly occurrenceFilterMiddleware: Middleware; + }; + priceDataSource: AssetsDataSource; +}; + +/** + * Compose the websocket (AccountActivity) update lane: custom-asset graduation → + * occurrence filtering → detection → token metadata and prices in parallel. + * + * Extracted from `AssetsController#handleAssetsUpdate` so the lane can be + * composed and driven without booting the controller (mirroring + * `buildFastFetchSources` for the fast fetch lane). + * + * Ordering carries two invariants: + * - Occurrence filtering runs BEFORE detection, so a websocket airdrop below + * its chain's occurrence floor is never detected, enriched, priced or + * persisted. + * - Detection runs before token and price enrichment, which both read + * `response.detectedAssets`. + * + * @param sources - The sources to place into the lane. + * @param options - Lane options. + * @param options.isBasicFunctionality - When false, only graduation and + * detection run; no network-backed source is used. + * @returns The composed source list, ready for `executeAssetsPipeline`. + */ +export function buildWsUpdateSources( + sources: WsUpdateSources, + options: { isBasicFunctionality: boolean }, +): AssetsDataSource[] { + const { + customAssetGraduationMiddleware, + detectionMiddleware, + tokenDataSource, + priceDataSource, + } = sources; + + const occurrenceFloorFilter: AssetsDataSource = { + getName: (): string => 'OccurrenceFloorFilter', + assetsMiddleware: tokenDataSource.occurrenceFilterMiddleware, + }; + + const pipeline: AssetsDataSource[] = [ + customAssetGraduationMiddleware, + ...(options.isBasicFunctionality ? [occurrenceFloorFilter] : []), + detectionMiddleware, + ]; + if (options.isBasicFunctionality) { + pipeline.push(createParallelMiddleware([tokenDataSource, priceDataSource])); + } + return pipeline; +} diff --git a/packages/assets-controller/src/pipeline/index.ts b/packages/assets-controller/src/pipeline/index.ts index 5b128bd82b5..7635eab5d14 100644 --- a/packages/assets-controller/src/pipeline/index.ts +++ b/packages/assets-controller/src/pipeline/index.ts @@ -1,4 +1,6 @@ export { buildFastFetchSources } from './buildFastFetchSources.js'; export type { FastFetchSources } from './buildFastFetchSources.js'; +export { buildWsUpdateSources } from './buildWsUpdateSources.js'; +export type { WsUpdateSources } from './buildWsUpdateSources.js'; export { executeAssetsPipeline } from './executeAssetsPipeline.js'; export type { ExecuteAssetsPipelineParams } from './executeAssetsPipeline.js'; From ea3a8fffa7316022dd1e5812a662f11f64e1d86e Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 25 Sep 2026 16:19:57 +0100 Subject: [PATCH 2/8] Trim comments to house style in the WS price-update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the surrounding code's comment density: drop the inline rationale comments from `#handleAssetsUpdateV5` (the lane ordering rationale lives in `buildWsUpdateSources`' JSDoc, mirroring `buildFastFetchSources`) and cut the capture script's multi-paragraph header (with its shell example) down to a two-line summary — operational details stay in the PR description. --- .../assets-controller/src/AssetsController.ts | 6 ------ .../ws-price-updates/captureWsApiResponses.ts | 19 +++---------------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index f493da0272e..73d40c25e6b 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -4227,10 +4227,6 @@ export class AssetsController extends BaseController< const updateStart = performance.now(); const pipelineRequest = this.#getUpdatePipelineRequest(request); const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; - - // Websocket updates run their own composed lane (graduation → - // occurrence filtering → detection → enrichment) so it can be built and - // driven without booting the controller; see `buildWsUpdateSources`. const enrichmentSources: AssetsDataSource[] = sourceId === 'AccountActivityDataSource' ? buildWsUpdateSources( @@ -4244,8 +4240,6 @@ export class AssetsController extends BaseController< { isBasicFunctionality: this.#isBasicFunctionality() }, ) : [ - // AccountsApiDataSource: graduate custom assets before the RPC - // fallback; Snap and RPC updates go straight to detection. ...(shouldRunRpcFallback ? [ this.#customAssetGraduationMiddleware, diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts index 9d8f95113f2..4b27d89e183 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts @@ -2,22 +2,9 @@ import { API_URLS } from '@metamask/core-backend'; import { writeFile } from '@metamask/utils/node'; /** - * Captures the API responses the websocket-update pipeline asks for, verbatim, - * into this directory. Re-run with: - * - * ```sh - * node --experimental-strip-types packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts - * ``` - * - * The captures are what the AccountActivity (websocket) update pass requests: - * occurrence floors (spam filtering of websocket airdrops), Tokens API metadata - * for the assets the event surfaced, and Price API spot prices — the same pass - * `AssetsController.handleAssetsUpdate` runs after a - * `AccountActivityService:balanceUpdated` event, plus the supported-networks - * manifests each API serves. - * - * The asset IDs mirror `../wallet.ts` (kept inline so the script runs with - * plain `node --experimental-strip-types`, which does not remap `.js` imports). + * Capture the API responses the websocket-update pipeline asks for, verbatim, + * into this directory. Re-run with + * `node --experimental-strip-types `. */ const OUT_DIR = `${import.meta.dirname}/api-responses`; From 398c588b609ec40a4315bee9308a1faedabe16f1 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 28 Sep 2026 11:21:40 +0100 Subject: [PATCH 3/8] Serve both update generations from the websocket lane builder Route `#handleAssetsUpdateV6`'s websocket lane through `buildWsUpdateSources`, mirroring how `buildFastFetchSources` serves the v5 and v6 fast-fetch lanes: the builder now takes `includeCustomAssetGraduation` (true on the v5 lane; the v6 lane never graduates custom assets and instead runs the RPC fallback ahead of detection) and the v6 handler's inline occurrence-filter/RPC-fallback construction is gone. Cover both generations: unit rows for the v6 lane shapes, a v6 pass in the pipeline integration test (RPC fallback passthrough on a successful response), and a controller-level v6 scenario booted with `assetsAccountsApiV6: true` via the fixture's remote feature flags. --- .../assets-controller/src/AssetsController.ts | 54 +++++----- ...oller.ws-price-updates.integration.test.ts | 100 +++++++++++++++++- ...eSources.price-updates.integration.test.ts | 71 ++++++++++++- .../src/pipeline/buildWsUpdateSources.test.ts | 44 ++++++-- .../src/pipeline/buildWsUpdateSources.ts | 47 +++++--- 5 files changed, 261 insertions(+), 55 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 73d40c25e6b..28c9eac587b 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -4226,6 +4226,7 @@ export class AssetsController extends BaseController< fn: async (parentContext) => { const updateStart = performance.now(); const pipelineRequest = this.#getUpdatePipelineRequest(request); + const isBasicFunctionality = this.#isBasicFunctionality(); const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; const enrichmentSources: AssetsDataSource[] = sourceId === 'AccountActivityDataSource' @@ -4233,11 +4234,12 @@ export class AssetsController extends BaseController< { customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware, + rpcFallbackMiddleware: this.#rpcFallbackMiddleware, detectionMiddleware: this.#detectionMiddleware, tokenDataSource: this.#tokenDataSource, priceDataSource: this.#priceDataSource, }, - { isBasicFunctionality: this.#isBasicFunctionality() }, + { isBasicFunctionality, includeCustomAssetGraduation: true }, ) : [ ...(shouldRunRpcFallback @@ -4247,7 +4249,7 @@ export class AssetsController extends BaseController< ] : []), this.#detectionMiddleware, - ...(this.#isBasicFunctionality() + ...(isBasicFunctionality ? [ createParallelMiddleware([ this.#tokenDataSource, @@ -4296,32 +4298,32 @@ export class AssetsController extends BaseController< fn: async (parentContext) => { const updateStart = performance.now(); const pipelineRequest = this.#getUpdatePipelineRequest(request); - const shouldFilterOccurrences = - sourceId === 'AccountActivityDataSource' && - this.#isBasicFunctionality(); - const enrichmentSources: AssetsDataSource[] = [ - ...(shouldFilterOccurrences - ? [ + const isBasicFunctionality = this.#isBasicFunctionality(); + const enrichmentSources: AssetsDataSource[] = + sourceId === 'AccountActivityDataSource' + ? buildWsUpdateSources( { - getName: () => 'OccurrenceFloorFilter', - assetsMiddleware: - this.#tokenDataSource.occurrenceFilterMiddleware, + customAssetGraduationMiddleware: + this.#customAssetGraduationMiddleware, + rpcFallbackMiddleware: this.#rpcFallbackMiddleware, + detectionMiddleware: this.#detectionMiddleware, + tokenDataSource: this.#tokenDataSource, + priceDataSource: this.#priceDataSource, }, - ] - : []), - ]; - if (this.#isBasicFunctionality()) { - enrichmentSources.push(this.#rpcFallbackMiddleware); - } - enrichmentSources.push(this.#detectionMiddleware); - if (this.#isBasicFunctionality()) { - enrichmentSources.push( - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ); - } + { isBasicFunctionality, includeCustomAssetGraduation: false }, + ) + : [ + ...(isBasicFunctionality ? [this.#rpcFallbackMiddleware] : []), + this.#detectionMiddleware, + ...(isBasicFunctionality + ? [ + createParallelMiddleware([ + this.#tokenDataSource, + this.#priceDataSource, + ]), + ] + : []), + ]; const { response: enrichedResponse } = await this.#executeMiddlewares({ sources: enrichmentSources, diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts index 178c8dbfa1e..7141f788045 100644 --- a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -12,6 +12,7 @@ */ import type { ApiPlatformClient } from '@metamask/core-backend'; +import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { cleanAll } from 'nock'; import { createMockMessengers } from './__fixtures__/MockAssetControllerMessenger.js'; @@ -48,14 +49,17 @@ async function withController( { state, queryApiClient = createTestApiClient(), + remoteFeatureFlags, }: { state: Partial; queryApiClient?: ApiPlatformClient; + remoteFeatureFlags?: FeatureFlags; }, fn: WithControllerCallback, ): Promise { const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ - registerCustomRootActions: registerWsControllerActions, + registerCustomRootActions: (messenger: MockRootMessenger): void => + registerWsControllerActions(messenger, { remoteFeatureFlags }), }); const controller = new AssetsController({ @@ -82,15 +86,19 @@ async function withController( * @param options - The run options. * @param options.state - The state to boot with. * @param options.event - The websocket event to deliver. + * @param options.remoteFeatureFlags - Remote feature flags to boot with + * (`assetsAccountsApiV6: true` switches to the v6 update lane). * @returns The settled controller state, the asset IDs the Token API was * asked about, and the asset IDs the Price API was asked about. */ async function runWsEvent({ state, event, + remoteFeatureFlags, }: { state: Partial; event: ReturnType; + remoteFeatureFlags?: FeatureFlags; }): Promise<{ state: AssetsControllerState; assetBatches: string[][]; @@ -101,7 +109,7 @@ async function runWsEvent({ const queryApiClient = createTestApiClient(); const controllerState = await withController( - { state, queryApiClient }, + { state, queryApiClient, remoteFeatureFlags }, async ({ controller, messenger }) => { // Wait for boot (`AccountsApiDataSource` reading the supported networks // manifest) so the assertions below cannot pass on a wallet that never @@ -231,6 +239,94 @@ describe('AssetsController: websocket price updates', () => { }); }); + describe('v6 remote flag on: the same websocket event through the v6 update lane', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildEmptyAssetsState(), + event: { + address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', + chain: 'eip155:1', + updates: [ + ...buildEthBalanceUpdatedEvent().updates, + ...buildUsdcBalanceUpdatedEvent().updates, + ], + }, + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }); + }); + + it('persists both websocket balances', () => { + const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; + + expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ + amount: USDC_WS_AMOUNT, + }); + }); + + it('persists metadata for both holdings from the captured Token API', () => { + const eth = getIgnoringCase(result.state.assetsInfo, ETH_ASSET_ID) as { + name: string; + symbol: string; + decimals: number; + }; + const usdc = getIgnoringCase( + result.state.assetsInfo, + USDC_ASSET_ID_LOWERCASE, + ) as { name: string; symbol: string; decimals: number }; + + expect(eth).toMatchObject({ + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }); + expect(usdc).toMatchObject({ + name: 'USDC', + symbol: 'USDC', + decimals: 6, + }); + }); + + it('prices both holdings from the captured Price API in the same pass', () => { + const ethPrice = getIgnoringCase( + result.state.assetsPrice, + ETH_ASSET_ID, + ) as { price: number }; + const usdcPrice = getIgnoringCase( + result.state.assetsPrice, + USDC_ASSET_ID_LOWERCASE, + ) as { price: number }; + + expect(ethPrice).toBeDefined(); + expect(ethPrice.price).toBeGreaterThan(0); + expect(usdcPrice).toBeDefined(); + expect(usdcPrice.price).toBeGreaterThan(0.9); + expect(usdcPrice.price).toBeLessThan(1.1); + }); + + it('invoked the Price API for both holdings', () => { + const askedAbout = new Set( + result.priceBatches.flat().map((assetId) => assetId.toLowerCase()), + ); + expect(askedAbout).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + + it('invoked the Token API for the new token and the native asset', () => { + const askedAbout = new Set( + result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), + ); + expect(askedAbout).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + }); + describe('held-but-unpriced native asset: ETH in state without a price', () => { let result: Awaited>; diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts index 43d1e8d93bf..9c81c681f47 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -35,6 +35,7 @@ import type { AccountId, AssetsControllerState, Caip19AssetId, + Context, DataRequest, DataResponse, } from '../types.js'; @@ -51,20 +52,25 @@ import { executeAssetsPipeline } from './executeAssetsPipeline.js'; * @param options - The pass inputs. * @param options.state - The state the pass sees. * @param options.event - The websocket event to deliver. + * @param options.lane - The update lane to compose (`v5` by default, `v6` + * runs the RPC fallback instead of graduation). * @returns The enriched response, the request the detection middleware * mutated, and the asset IDs the Token and Price APIs were asked about. */ async function runWsUpdatePass({ state, event, + lane = 'v5', }: { state: ReturnType; event: ReturnType; + lane?: 'v5' | 'v6'; }): Promise<{ response: DataResponse; request: DataRequest; priceBatches: string[][]; assetBatches: string[][]; + rpcFallbackRequests: DataRequest[]; }> { cleanAll(); const { assets, prices } = mockWsApis(); @@ -77,6 +83,7 @@ async function runWsUpdatePass({ }); const queryApiClient = createTestApiClient(); + const rpcFallbackRequests: DataRequest[] = []; const tokenDataSource = new TokenDataSource(assetsControllerMessenger, { queryApiClient, @@ -105,13 +112,26 @@ async function runWsUpdatePass({ }, getAssetsState: (): AssetsControllerState => state, }), + rpcFallbackMiddleware: { + getName: (): string => 'RpcFallbackMiddleware', + assetsMiddleware: async ( + ctx: Context, + next: (context: Context) => Promise, + ): Promise => { + rpcFallbackRequests.push(ctx.request); + return next(ctx); + }, + }, detectionMiddleware: new DetectionMiddleware({ getAssetsState: (): AssetsControllerState => state, }), tokenDataSource, priceDataSource, }, - { isBasicFunctionality: true }, + { + isBasicFunctionality: true, + includeCustomAssetGraduation: lane === 'v5', + }, ); let captured: { response: DataResponse; request: DataRequest } | undefined; @@ -147,6 +167,7 @@ async function runWsUpdatePass({ request: captured.request, priceBatches: prices.requestedBatches, assetBatches: assets.requestedBatches, + rpcFallbackRequests, }; } @@ -268,6 +289,54 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); }); + describe('v6 lane: the same websocket event through the v6 composition', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEmptyAssetsState(), + event: { + address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', + chain: 'eip155:1', + updates: [ + ...buildEthBalanceUpdatedEvent().updates, + ...buildUsdcBalanceUpdatedEvent().updates, + ], + }, + lane: 'v6', + }); + }); + + it('enriches and prices both surfaced holdings like the v5 lane', () => { + expect(askedAbout(result.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + expect(askedAbout(result.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + + const ethPrice = getIgnoringCase( + result.response.assetsPrice ?? {}, + ETH_ASSET_ID, + ) as { price: number }; + const usdcPrice = getIgnoringCase( + result.response.assetsPrice ?? {}, + USDC_ASSET_ID_LOWERCASE, + ) as { price: number }; + expect(ethPrice.price).toBeGreaterThan(0); + expect(usdcPrice.price).toBeGreaterThan(0.9); + }); + + it('runs the RPC fallback once on the balance pass', () => { + // The v6 lane replaces graduation with an RPC fallback ahead of + // detection; a response without errors leaves it a passthrough. + expect(result.rpcFallbackRequests).toHaveLength(1); + expect(result.rpcFallbackRequests[0]?.dataTypes).toStrictEqual( + expect.arrayContaining(['balance']), + ); + }); + }); + describe('held-but-unpriced native asset: ETH in state without a price', () => { let result: Awaited>; diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts index 8405a634cce..c96ebca1693 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts @@ -21,6 +21,7 @@ function buildSources(): WsUpdateSources { customAssetGraduationMiddleware: stubSource( 'CustomAssetGraduationMiddleware', ), + rpcFallbackMiddleware: stubSource('RpcFallbackMiddleware'), detectionMiddleware: stubSource('DetectionMiddleware'), tokenDataSource: stubTokenSource('TokenDataSource'), priceDataSource: stubSource('PriceDataSource'), @@ -30,8 +31,8 @@ function buildSources(): WsUpdateSources { describe('buildWsUpdateSources', () => { it.each([ { - title: - 'orders the lane graduation → occurrence filter → detection → enrichment', + title: 'v5 lane: graduation → occurrence filter → detection → enrichment', + includeCustomAssetGraduation: true, isBasicFunctionality: true, expected: [ 'CustomAssetGraduationMiddleware', @@ -41,18 +42,41 @@ describe('buildWsUpdateSources', () => { ], }, { - title: 'runs only the graduation and detection', + title: 'v5 lane: runs only the graduation and detection', + includeCustomAssetGraduation: true, isBasicFunctionality: false, // No network-backed source may run when the user has opted out. expected: ['CustomAssetGraduationMiddleware', 'DetectionMiddleware'], }, - ])('$title', ({ isBasicFunctionality, expected }) => { - const sources = buildWsUpdateSources(buildSources(), { - isBasicFunctionality, - }); + { + title: + 'v6 lane: occurrence filter → RPC fallback → detection → enrichment', + includeCustomAssetGraduation: false, + isBasicFunctionality: true, + expected: [ + 'OccurrenceFloorFilter', + 'RpcFallbackMiddleware', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, + { + title: 'v6 lane: runs only the detection', + includeCustomAssetGraduation: false, + isBasicFunctionality: false, + expected: ['DetectionMiddleware'], + }, + ])( + '$title', + ({ includeCustomAssetGraduation, isBasicFunctionality, expected }) => { + const sources = buildWsUpdateSources(buildSources(), { + isBasicFunctionality, + includeCustomAssetGraduation, + }); - expect(sources.map((source) => source.getName())).toStrictEqual(expected); - }); + expect(sources.map((source) => source.getName())).toStrictEqual(expected); + }, + ); it('derives the occurrence filter from the token data source', () => { const tokenDataSource = stubTokenSource('TokenDataSource'); @@ -61,7 +85,7 @@ describe('buildWsUpdateSources', () => { const sources = buildWsUpdateSources( { ...buildSources(), tokenDataSource }, - { isBasicFunctionality: true }, + { isBasicFunctionality: true, includeCustomAssetGraduation: true }, ); const occurrenceFloorFilter = sources.find( diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts index 53c3d353a57..1d284fdbb5f 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts @@ -11,6 +11,7 @@ import type { AssetsDataSource, Middleware } from '../types.js'; */ export type WsUpdateSources = { customAssetGraduationMiddleware: AssetsDataSource; + rpcFallbackMiddleware: AssetsDataSource; detectionMiddleware: AssetsDataSource; /** Also supplies the occurrence-filter middleware used for websocket airdrops. */ tokenDataSource: AssetsDataSource & { @@ -20,32 +21,42 @@ export type WsUpdateSources = { }; /** - * Compose the websocket (AccountActivity) update lane: custom-asset graduation → - * occurrence filtering → detection → token metadata and prices in parallel. + * Compose the websocket update lane: custom-asset graduation (v5 only) → + * occurrence filtering → RPC fallback (v6 only) → detection → token metadata + * and prices in parallel. * - * Extracted from `AssetsController#handleAssetsUpdate` so the lane can be - * composed and driven without booting the controller (mirroring - * `buildFastFetchSources` for the fast fetch lane). + * Extracted from `AssetsController#handleAssetsUpdateV5` and + * `#handleAssetsUpdateV6` so the lane can be composed and driven without + * booting the controller (mirroring `buildFastFetchSources` for the fast + * fetch lane). * * Ordering carries two invariants: - * - Occurrence filtering runs BEFORE detection, so a websocket airdrop below - * its chain's occurrence floor is never detected, enriched, priced or - * persisted. + * - Occurrence filtering runs BEFORE the RPC fallback and detection, so a + * websocket airdrop below its chain's occurrence floor is never detected, + * enriched, priced or persisted. * - Detection runs before token and price enrichment, which both read * `response.detectedAssets`. * * @param sources - The sources to place into the lane. * @param options - Lane options. - * @param options.isBasicFunctionality - When false, only graduation and + * @param options.isBasicFunctionality - When false, only graduation (v5) and * detection run; no network-backed source is used. + * @param options.includeCustomAssetGraduation - `true` on the v5 update lane. + * The v6 lane never graduates custom assets — it sends the pins to the + * endpoint as `includeAssetIds` — and instead runs the RPC fallback before + * detection. * @returns The composed source list, ready for `executeAssetsPipeline`. */ export function buildWsUpdateSources( sources: WsUpdateSources, - options: { isBasicFunctionality: boolean }, + options: { + isBasicFunctionality: boolean; + includeCustomAssetGraduation: boolean; + }, ): AssetsDataSource[] { const { customAssetGraduationMiddleware, + rpcFallbackMiddleware, detectionMiddleware, tokenDataSource, priceDataSource, @@ -56,13 +67,17 @@ export function buildWsUpdateSources( assetsMiddleware: tokenDataSource.occurrenceFilterMiddleware, }; - const pipeline: AssetsDataSource[] = [ - customAssetGraduationMiddleware, + return [ + ...(options.includeCustomAssetGraduation + ? [customAssetGraduationMiddleware] + : []), ...(options.isBasicFunctionality ? [occurrenceFloorFilter] : []), + ...(!options.includeCustomAssetGraduation && options.isBasicFunctionality + ? [rpcFallbackMiddleware] + : []), detectionMiddleware, + ...(options.isBasicFunctionality + ? [createParallelMiddleware([tokenDataSource, priceDataSource])] + : []), ]; - if (options.isBasicFunctionality) { - pipeline.push(createParallelMiddleware([tokenDataSource, priceDataSource])); - } - return pipeline; } From bf77d66e78e9229b7e4be86a14f02c3d5eee0308 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 28 Sep 2026 11:37:24 +0100 Subject: [PATCH 4/8] Trim comments and JSDoc in the websocket price-update tests Keep one-line summaries and only the required @param/@returns tags; drop the rationale paragraphs, scenario explanations that repeat the test titles, and multi-sentence constant/fixture docs. --- ...oller.ws-price-updates.integration.test.ts | 26 +++---------- .../ws-price-updates/api-responses/index.ts | 37 +++++-------------- .../ws-price-updates/captureWsApiResponses.ts | 8 +--- .../ws-price-updates/messenger.ts | 16 +++----- .../__fixtures__/ws-price-updates/wallet.ts | 12 +++--- .../__fixtures__/ws-price-updates/wsEvents.ts | 15 +++----- .../__fixtures__/ws-price-updates/wsWallet.ts | 10 ++--- ...eSources.price-updates.integration.test.ts | 18 ++------- .../src/pipeline/buildWsUpdateSources.test.ts | 1 - .../src/pipeline/buildWsUpdateSources.ts | 35 +++--------------- 10 files changed, 46 insertions(+), 132 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts index 7141f788045..eed30793e03 100644 --- a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -1,14 +1,7 @@ /** - * Integration coverage for `AssetsController` websocket price updates — the - * behavior fixed by the "prices after websocket balance updates" change: - * after a websocket balance update, the Token and Price APIs are invoked so - * existing and newly-seen tokens get metadata and spot prices in the same - * pipeline pass. - * - * Boots the real controller against the same captured APIs as - * `buildWsUpdateSources.price-updates.integration.test.ts`, keeps the wallet - * lifecycle closed so only the websocket event drives the Token and Price - * APIs, and asserts what lands in persisted controller state. + * Integration coverage for `AssetsController` websocket price updates: after + * a websocket balance update, the Token and Price APIs are invoked so existing + * and newly-seen tokens get metadata and spot prices in the same pipeline pass. */ import type { ApiPlatformClient } from '@metamask/core-backend'; @@ -78,10 +71,8 @@ async function withController( } /** - * Boot the controller (lifecycle closed: UI shut, keyring locked, account - * tree uninitialized, so the subscribe lanes stay dormant and only the - * websocket event can drive the Token and Price APIs), deliver a websocket - * balance event, and let state settle. + * Boot the controller (lifecycle closed), deliver a websocket balance event, + * and let state settle. * * @param options - The run options. * @param options.state - The state to boot with. @@ -111,9 +102,7 @@ async function runWsEvent({ const controllerState = await withController( { state, queryApiClient, remoteFeatureFlags }, async ({ controller, messenger }) => { - // Wait for boot (`AccountsApiDataSource` reading the supported networks - // manifest) so the assertions below cannot pass on a wallet that never - // finished starting. + // Wait for boot so the assertions cannot pass on a half-started wallet. await waitFor(() => expect(accountsSupportedNetworks.isDone()).toBe(true), ); @@ -121,7 +110,6 @@ async function runWsEvent({ messenger.publish('AccountActivityService:balanceUpdated', event); await waitFor(() => { - // The event's balances must have landed before anything is asserted. for (const update of event.updates) { const landed = getIgnoringCase( controller.state.assetsBalance[WS_ACCOUNT_ID] ?? {}, @@ -430,8 +418,6 @@ describe('AssetsController: websocket price updates', () => { }); it('did not refetch metadata for the enriched token', () => { - // Native metadata is refreshed every pass, so the Token API is still - // asked about the native asset — but not about USDC. const askedAbout = new Set( result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), ); diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts index 9e0379a1750..34cf482ca96 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts @@ -30,8 +30,7 @@ type BatchRecordingMock = { }; /** - * Intercept `GET https://chainid.network/chains.json`, which - * `AssetsController` fetches on boot to fill native-asset gaps. + * Intercept `GET https://chainid.network/chains.json`. * * @returns The nock scope. */ @@ -43,8 +42,7 @@ function mockChainIdNetwork(): nock.Scope { } /** - * Intercept `GET {ACCOUNTS}/v2/supportedNetworks`, which - * `AccountsApiDataSource` reads on boot to decide which chains it claims. + * Intercept `GET {ACCOUNTS}/v2/supportedNetworks`. * * @returns The nock scope. */ @@ -56,9 +54,7 @@ function mockAccountsSupportedNetworks(): nock.Scope { } /** - * Intercept `GET {TOKENS}/v2/supportedNetworks`. `eip155:1` is in the captured - * `fullSupport` list, so Mainnet assets genuinely reach the Token and Price - * data sources rather than being skipped as unsupported. + * Intercept `GET {TOKENS}/v2/supportedNetworks`. * * @returns The nock scope. */ @@ -70,9 +66,7 @@ function mockTokensSupportedNetworks(): nock.Scope { } /** - * Intercept `GET {TOKEN}/v1/suggestedOccurrenceFloors`. The capture has `1` - * (Mainnet) at three, so a websocket-first USDC sighting must beat that floor - * on its own captured occurrences to survive. + * Intercept `GET {TOKEN}/v1/suggestedOccurrenceFloors`. * * @returns The nock scope. */ @@ -85,9 +79,7 @@ function mockSuggestedOccurrenceFloors(): nock.Scope { /** * Intercept `GET {TOKENS}/v3/assets`, answering each batch from the captured - * per-asset entries and preserving the API's lower-case `assetId` echo. Assets - * the API does not carry are answered as empty stubs, as it does for tokens on - * chains it does not index. + * per-asset entries with the API's lower-case `assetId` echo. * * @returns The nock scope and the asset IDs each request asked about. */ @@ -108,8 +100,7 @@ function mockV3Assets(): BatchRecordingMock { } /** - * Intercept `GET {PRICES}/v2/supportedNetworks`, which `PriceDataSource` reads - * before fetching. + * Intercept `GET {PRICES}/v2/supportedNetworks`. * * @returns The nock scope. */ @@ -122,8 +113,7 @@ function mockPricesSupportedNetworks(): nock.Scope { /** * Intercept `GET {PRICES}/v3/spot-prices`, answering from the captured prices - * and keying the response lower-case as the live API does. Assets with no - * captured price are simply absent, as they are upstream. + * with the API's lower-case keying. * * @returns The nock scope and the asset IDs each request asked about. */ @@ -153,16 +143,9 @@ function mockV3SpotPrices(): BatchRecordingMock { } /** - * Register every interceptor the websocket update pass needs for this wallet: - * the Token API occurrence floors, Tokens API supported networks and assets, - * the Price API supported networks and spot prices, plus the Accounts API - * supported networks and `chainid.network` manifest `AssetsController` reads - * on boot. - * - * All interceptors persist, so batch composition and cache misses cannot make a - * test fail for want of an interceptor. The Tokens and Price batched - * interceptors record what they were asked about, so tests can assert the - * websocket pass actually invoked them. + * Register every interceptor the websocket update pass needs for this + * wallet. The batched Tokens and Price interceptors record what they were + * asked about. * * @returns The recording mocks. */ diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts index 4b27d89e183..559e79bb664 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts @@ -9,10 +9,7 @@ import { writeFile } from '@metamask/utils/node'; const OUT_DIR = `${import.meta.dirname}/api-responses`; -/** - * The assets the example websocket event reports balances for, in the - * checksummed form `AccountActivityDataSource` normalization emits. - */ +/** The assets the example websocket event reports balances for, checksummed. */ const WS_EVENT_ASSET_IDS = [ 'eip155:1/slip44:60', 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', @@ -115,8 +112,7 @@ async function main(): Promise { const assets = (await fetchJson( `${API_URLS.TOKENS}/v3/assets?${assetsParams.toString()}`, )) as { assetId?: string }[]; - // Keyed by the lower-cased asset ID the API echoes back, matching - // `__fixtures__/bsc-spam-token/api-responses/index.ts`. + // Keyed by the lower-cased asset ID the API echoes back. const assetsByLowerId: Record = {}; for (const entry of assets) { if (entry.assetId) { diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts index 0ad5f8a1834..5b3a094be53 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts @@ -25,9 +25,7 @@ import { import { buildWsAccount } from './wsWallet.js'; /** - * RPC provider that answers the cheap probes `RpcDataSource` makes without - * inventing token balances. Persist the stubs so a slow-lane fallback cannot - * fail the suite for want of a matching request. + * The RPC provider, stubbing the probes `RpcDataSource` makes. * * @returns The mock provider. */ @@ -82,9 +80,7 @@ function buildMainnetNetworkState(): NetworkState { } /** - * Register the NetworkController / NetworkEnablement / ConfigRegistry handlers - * the websocket price-update fixtures need. Shared by the pipeline and - * controller integration tests. + * Register the network handlers the websocket price-update fixtures need. * * @param rootMessenger - The root messenger to register handlers on. */ @@ -97,8 +93,7 @@ export function registerMainnetNetwork(rootMessenger: MockRootMessenger): void { () => networkState, ); - // `RpcDataSource` only reads `provider` off the client, so the - // configuration is here to keep the shape honest rather than to be used. + // Only `provider` is read; the configuration keeps the client shape honest. rootMessenger.registerActionHandler( 'NetworkController:getNetworkClientById', () => @@ -131,9 +126,8 @@ export function registerMainnetNetwork(rootMessenger: MockRootMessenger): void { type RegisterWsControllerActionsOptions = RegisterWalletLifecycleMocksOptions; /** - * Register every external action `AssetsController` needs to boot the - * websocket price-update wallet: the account, lifecycle, feature flags, and - * the network handlers from {@link registerMainnetNetwork}. + * Register every external action `AssetsController` needs to boot the wallet, + * plus the network handlers from {@link registerMainnetNetwork}. * * @param rootMessenger - The root messenger to register handlers on. * @param opts - Lifecycle / flag overrides. diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts index 0247099a00f..1f83f05b129 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts @@ -1,20 +1,20 @@ -/** Ethereum Mainnet, served by every captured API in this folder. */ +/** Ethereum Mainnet. */ export const MAINNET_CHAIN_ID = 'eip155:1' as const; -/** Example wallet, as the Account Activity websocket reports its address (lower case). */ +/** The wallet address, lower case as the websocket reports it. */ export const WS_WALLET_ADDRESS = '0x742d35cc6634c0532925a3b844bc454e4438f44e'; -/** `InternalAccount.id` (a UUID), not the address — see `AccountId`. */ +/** `InternalAccount.id` (a UUID), not the address. */ export const WS_ACCOUNT_ID = 'c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f'; -/** Native ETH, the wallet's pre-existing holding. */ +/** Native ETH. */ export const ETH_ASSET_ID = `${MAINNET_CHAIN_ID}/slip44:60` as const; -/** USDC, first seen by the wallet on the websocket (lower case, as the WS sends it). */ +/** USDC, lower case as the websocket reports it. */ export const USDC_ADDRESS_LOWERCASE = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; -/** Checksummed USDC, the normalized form state and the pipeline key assets by. */ +/** Checksummed USDC, as state keys assets. */ export const USDC_ADDRESS_CHECKSUM = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts index c0dfa9c9109..b0cfee7259a 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts @@ -14,20 +14,16 @@ export type BalanceUpdatedEventPayload = { updates: BalanceUpdate[]; }; -/** - * Hex quantities the websocket reports, as a transaction-driven wallet would - * (AADS converts `0x…` amounts to human-readable form before the pipeline). - */ +/** Hex quantities, as the websocket reports them. */ export const WS_ETH_WEI_HEX = '0x1bc16d674ec80000'; // 2 ETH export const WS_USDC_BASE_UNITS_HEX = '0x989680'; // 10 USDC (6 decimals) -/** Human-readable amounts AADS derives from the hex quantities above. */ +/** Human-readable amounts, as AADS derives them from the hex quantities. */ export const ETH_WS_AMOUNT = '2'; export const USDC_WS_AMOUNT = '10'; /** - * Build an `AccountActivityService:balanceUpdated` event payload updating the - * native ETH balance — the wallet's pre-existing holding. + * Build a `balanceUpdated` payload for the native ETH balance. * * @param options - Overrides for the balance amount. * @param options.amount - The balance amount to report, in wei hex. @@ -55,9 +51,8 @@ export function buildEthBalanceUpdatedEvent(options?: { } /** - * Build an `AccountActivityService:balanceUpdated` event payload for USDC — - * a token the wallet never held before, first surfaced by the websocket - * (lower-case address, as the websocket sends ERC-20s). + * Build a `balanceUpdated` payload for USDC, a token the wallet never held + * before. * * @param options - Overrides for the balance amount. * @param options.amount - The balance amount to report, in base-unit hex. diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts index 54de99055e2..cb2d03c3f96 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts @@ -10,9 +10,6 @@ import { /** * Build the wallet's `InternalAccount`. * - * Scoped to Ethereum Mainnet only, so `accountsWithSupportedChains` resolves - * to the one chain the websocket events report balances for. - * * @param overrides - Fields to override on the account. * @returns The internal account. */ @@ -37,11 +34,10 @@ export function buildWsAccount( } /** - * A fresh wallet: no balances, metadata, prices or custom assets yet, so the - * first websocket event surfaces holdings that are all brand new to state. + * A fresh wallet's state, empty of balances, metadata, prices and custom + * assets. * - * @param overrides - State slices to override (e.g. seed the ETH holding of a - * prior pass). + * @param overrides - State slices to override. * @returns The starting state. */ export function buildEmptyAssetsState( diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts index 9c81c681f47..e0372b8ef16 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -43,17 +43,12 @@ import { buildWsUpdateSources } from './buildWsUpdateSources.js'; import { executeAssetsPipeline } from './executeAssetsPipeline.js'; /** - * Run one websocket update pass over the wallet — everything but the - * controller: the real `AccountActivityDataSource` receives a - * `AccountActivityService:balanceUpdated` event, and its update flows through - * the composed websocket lane (`buildWsUpdateSources`) via - * `executeAssetsPipeline`, against the given state. + * Run one websocket update pass through the composed lane. * * @param options - The pass inputs. * @param options.state - The state the pass sees. * @param options.event - The websocket event to deliver. - * @param options.lane - The update lane to compose (`v5` by default, `v6` - * runs the RPC fallback instead of graduation). + * @param options.lane - The update lane to compose (`v5` by default). * @returns The enriched response, the request the detection middleware * mutated, and the asset IDs the Token and Price APIs were asked about. */ @@ -246,8 +241,6 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('queues both holdings for a price update', () => { - // The detection middleware mutates the request so the price data source - // prices the holdings it surfaced. const queued = result.request.assetsForPriceUpdate?.map((id) => id.toLowerCase()) ?? []; @@ -281,8 +274,6 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('asks the Token API for the new token and the native asset', () => { - // The occurrence filter consults occurrences for the new ERC-20 before - // detection, and metadata enrichment covers the native asset every pass. expect(askedAbout(result.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), ); @@ -328,8 +319,7 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('runs the RPC fallback once on the balance pass', () => { - // The v6 lane replaces graduation with an RPC fallback ahead of - // detection; a response without errors leaves it a passthrough. + // A response without errors leaves the fallback a passthrough. expect(result.rpcFallbackRequests).toHaveLength(1); expect(result.rpcFallbackRequests[0]?.dataTypes).toStrictEqual( expect.arrayContaining(['balance']), @@ -443,8 +433,6 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('does not refetch metadata for the enriched token, only the native asset', () => { - // Native metadata is refreshed every pass; the fully enriched token is - // left alone. expect(askedAbout(result.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts index c96ebca1693..77cbf6b0ac6 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts @@ -45,7 +45,6 @@ describe('buildWsUpdateSources', () => { title: 'v5 lane: runs only the graduation and detection', includeCustomAssetGraduation: true, isBasicFunctionality: false, - // No network-backed source may run when the user has opted out. expected: ['CustomAssetGraduationMiddleware', 'DetectionMiddleware'], }, { diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts index 1d284fdbb5f..4e620ff2c2e 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts @@ -1,14 +1,7 @@ import { createParallelMiddleware } from '../middlewares/ParallelMiddleware.js'; import type { AssetsDataSource, Middleware } from '../types.js'; -/** - * The sources the websocket update lane composes, in the roles the lane - * assigns them. - * - * `AssetsController` constructs all of these unconditionally, so the lane can - * assume every role is filled. `isBasicFunctionality` decides which of them - * actually run, not which are supplied. - */ +/** The sources the websocket update lane composes. */ export type WsUpdateSources = { customAssetGraduationMiddleware: AssetsDataSource; rpcFallbackMiddleware: AssetsDataSource; @@ -21,30 +14,14 @@ export type WsUpdateSources = { }; /** - * Compose the websocket update lane: custom-asset graduation (v5 only) → - * occurrence filtering → RPC fallback (v6 only) → detection → token metadata - * and prices in parallel. - * - * Extracted from `AssetsController#handleAssetsUpdateV5` and - * `#handleAssetsUpdateV6` so the lane can be composed and driven without - * booting the controller (mirroring `buildFastFetchSources` for the fast - * fetch lane). - * - * Ordering carries two invariants: - * - Occurrence filtering runs BEFORE the RPC fallback and detection, so a - * websocket airdrop below its chain's occurrence floor is never detected, - * enriched, priced or persisted. - * - Detection runs before token and price enrichment, which both read - * `response.detectedAssets`. + * Compose the websocket update lane. * * @param sources - The sources to place into the lane. * @param options - Lane options. - * @param options.isBasicFunctionality - When false, only graduation (v5) and - * detection run; no network-backed source is used. - * @param options.includeCustomAssetGraduation - `true` on the v5 update lane. - * The v6 lane never graduates custom assets — it sends the pins to the - * endpoint as `includeAssetIds` — and instead runs the RPC fallback before - * detection. + * @param options.isBasicFunctionality - When false, no network-backed source + * runs. + * @param options.includeCustomAssetGraduation - `true` on the v5 lane; the + * v6 lane runs the RPC fallback instead of graduation. * @returns The composed source list, ready for `executeAssetsPipeline`. */ export function buildWsUpdateSources( From d4f66d5f526fe671f2a88daf1d338ecefe7a054e Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 28 Sep 2026 11:42:04 +0100 Subject: [PATCH 5/8] Extract the standard update lane into buildUpdateSources Route both update handlers' non-websocket lanes through a sibling builder of `buildWsUpdateSources`: the gating booleans (`includeCustomAssetGraduation`, `includeRpcFallback`) stay at the call sites, so each handler's exact lane composition is preserved. Unit tests cover the five lane shapes. --- .../assets-controller/src/AssetsController.ts | 62 +++++++------ .../src/pipeline/buildUpdateSources.test.ts | 93 +++++++++++++++++++ .../src/pipeline/buildUpdateSources.ts | 49 ++++++++++ .../assets-controller/src/pipeline/index.ts | 2 + 4 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 packages/assets-controller/src/pipeline/buildUpdateSources.test.ts create mode 100644 packages/assets-controller/src/pipeline/buildUpdateSources.ts diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 28c9eac587b..05601020f42 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -126,6 +126,7 @@ import { } from './migrations/healAssetsInfoMetadata.js'; import { buildFastFetchSources, + buildUpdateSources, buildWsUpdateSources, executeAssetsPipeline, } from './pipeline/index.js'; @@ -4227,7 +4228,7 @@ export class AssetsController extends BaseController< const updateStart = performance.now(); const pipelineRequest = this.#getUpdatePipelineRequest(request); const isBasicFunctionality = this.#isBasicFunctionality(); - const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; + const isAccountsApiUpdate = sourceId === 'AccountsApiDataSource'; const enrichmentSources: AssetsDataSource[] = sourceId === 'AccountActivityDataSource' ? buildWsUpdateSources( @@ -4241,23 +4242,21 @@ export class AssetsController extends BaseController< }, { isBasicFunctionality, includeCustomAssetGraduation: true }, ) - : [ - ...(shouldRunRpcFallback - ? [ - this.#customAssetGraduationMiddleware, - this.#rpcFallbackMiddleware, - ] - : []), - this.#detectionMiddleware, - ...(isBasicFunctionality - ? [ - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : []), - ]; + : buildUpdateSources( + { + customAssetGraduationMiddleware: + this.#customAssetGraduationMiddleware, + rpcFallbackMiddleware: this.#rpcFallbackMiddleware, + detectionMiddleware: this.#detectionMiddleware, + tokenDataSource: this.#tokenDataSource, + priceDataSource: this.#priceDataSource, + }, + { + isBasicFunctionality, + includeCustomAssetGraduation: isAccountsApiUpdate, + includeRpcFallback: isAccountsApiUpdate, + }, + ); const { response: enrichedResponse } = await this.#executeMiddlewares({ sources: enrichmentSources, @@ -4312,18 +4311,21 @@ export class AssetsController extends BaseController< }, { isBasicFunctionality, includeCustomAssetGraduation: false }, ) - : [ - ...(isBasicFunctionality ? [this.#rpcFallbackMiddleware] : []), - this.#detectionMiddleware, - ...(isBasicFunctionality - ? [ - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : []), - ]; + : buildUpdateSources( + { + customAssetGraduationMiddleware: + this.#customAssetGraduationMiddleware, + rpcFallbackMiddleware: this.#rpcFallbackMiddleware, + detectionMiddleware: this.#detectionMiddleware, + tokenDataSource: this.#tokenDataSource, + priceDataSource: this.#priceDataSource, + }, + { + isBasicFunctionality, + includeCustomAssetGraduation: false, + includeRpcFallback: isBasicFunctionality, + }, + ); const { response: enrichedResponse } = await this.#executeMiddlewares({ sources: enrichmentSources, diff --git a/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts b/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts new file mode 100644 index 00000000000..5912050c030 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts @@ -0,0 +1,93 @@ +import type { AssetsDataSource } from '../types.js'; +import { buildUpdateSources } from './buildUpdateSources.js'; +import type { UpdateSources } from './buildUpdateSources.js'; + +function stubSource(name: string): AssetsDataSource { + return { + getName: () => name, + assetsMiddleware: async (ctx) => ctx, + }; +} + +function buildSources(): UpdateSources { + return { + customAssetGraduationMiddleware: stubSource( + 'CustomAssetGraduationMiddleware', + ), + rpcFallbackMiddleware: stubSource('RpcFallbackMiddleware'), + detectionMiddleware: stubSource('DetectionMiddleware'), + tokenDataSource: stubSource('TokenDataSource'), + priceDataSource: stubSource('PriceDataSource'), + }; +} + +describe('buildUpdateSources', () => { + it.each([ + { + title: 'v5 AccountsApi lane: graduation → RPC fallback → detection → enrichment', + includeCustomAssetGraduation: true, + includeRpcFallback: true, + isBasicFunctionality: true, + expected: [ + 'CustomAssetGraduationMiddleware', + 'RpcFallbackMiddleware', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, + { + title: 'v5 AccountsApi lane: graduation → RPC fallback → detection', + includeCustomAssetGraduation: true, + includeRpcFallback: true, + isBasicFunctionality: false, + expected: [ + 'CustomAssetGraduationMiddleware', + 'RpcFallbackMiddleware', + 'DetectionMiddleware', + ], + }, + { + title: 'v5 Snap/RPC lane: detection → enrichment', + includeCustomAssetGraduation: false, + includeRpcFallback: false, + isBasicFunctionality: true, + expected: ['DetectionMiddleware', 'ParallelMiddleware'], + }, + { + title: 'v6 lane: RPC fallback → detection → enrichment', + includeCustomAssetGraduation: false, + includeRpcFallback: true, + isBasicFunctionality: true, + expected: [ + 'RpcFallbackMiddleware', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, + { + title: 'v6 lane: detection only', + includeCustomAssetGraduation: false, + includeRpcFallback: false, + isBasicFunctionality: false, + expected: ['DetectionMiddleware'], + }, + ])( + '$title', + ({ + includeCustomAssetGraduation, + includeRpcFallback, + isBasicFunctionality, + expected, + }) => { + const sources = buildUpdateSources(buildSources(), { + isBasicFunctionality, + includeCustomAssetGraduation, + includeRpcFallback, + }); + + expect(sources.map((source) => source.getName())).toStrictEqual( + expected, + ); + }, + ); +}); diff --git a/packages/assets-controller/src/pipeline/buildUpdateSources.ts b/packages/assets-controller/src/pipeline/buildUpdateSources.ts new file mode 100644 index 00000000000..c7ef1d9cf3d --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildUpdateSources.ts @@ -0,0 +1,49 @@ +import { createParallelMiddleware } from '../middlewares/ParallelMiddleware.js'; +import type { AssetsDataSource } from '../types.js'; + +/** The sources the standard update lane composes. */ +export type UpdateSources = { + customAssetGraduationMiddleware: AssetsDataSource; + rpcFallbackMiddleware: AssetsDataSource; + detectionMiddleware: AssetsDataSource; + tokenDataSource: AssetsDataSource; + priceDataSource: AssetsDataSource; +}; + +/** + * Compose the update lane for the non-websocket sources. + * + * @param sources - The sources to place into the lane. + * @param options - Lane options. + * @param options.isBasicFunctionality - When false, no enrichment runs. + * @param options.includeCustomAssetGraduation - When to include graduation. + * @param options.includeRpcFallback - When to include the RPC fallback. + * @returns The composed source list, ready for `executeAssetsPipeline`. + */ +export function buildUpdateSources( + sources: UpdateSources, + options: { + isBasicFunctionality: boolean; + includeCustomAssetGraduation: boolean; + includeRpcFallback: boolean; + }, +): AssetsDataSource[] { + const { + customAssetGraduationMiddleware, + rpcFallbackMiddleware, + detectionMiddleware, + tokenDataSource, + priceDataSource, + } = sources; + + return [ + ...(options.includeCustomAssetGraduation + ? [customAssetGraduationMiddleware] + : []), + ...(options.includeRpcFallback ? [rpcFallbackMiddleware] : []), + detectionMiddleware, + ...(options.isBasicFunctionality + ? [createParallelMiddleware([tokenDataSource, priceDataSource])] + : []), + ]; +} diff --git a/packages/assets-controller/src/pipeline/index.ts b/packages/assets-controller/src/pipeline/index.ts index 7635eab5d14..05367f7e3cd 100644 --- a/packages/assets-controller/src/pipeline/index.ts +++ b/packages/assets-controller/src/pipeline/index.ts @@ -1,5 +1,7 @@ export { buildFastFetchSources } from './buildFastFetchSources.js'; export type { FastFetchSources } from './buildFastFetchSources.js'; +export { buildUpdateSources } from './buildUpdateSources.js'; +export type { UpdateSources } from './buildUpdateSources.js'; export { buildWsUpdateSources } from './buildWsUpdateSources.js'; export type { WsUpdateSources } from './buildWsUpdateSources.js'; export { executeAssetsPipeline } from './executeAssetsPipeline.js'; From cbe6afb52d4d57549fb1553013e2a814f105676a Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 28 Sep 2026 12:22:01 +0100 Subject: [PATCH 6/8] Restyle the websocket price-update integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the bsc-spam-token integration-test format: an "Integration Expectation" header, response-surface tables driving `it.each('$surface - …')` assertions, `describe.each` rows sharing the v5/v6 lane its, shared fixture builders for the combined event and the scenario states, and `askedAbout`/`priceOf` helpers instead of inline set-building and type casts. --- ...oller.ws-price-updates.integration.test.ts | 391 +++++++---------- .../__fixtures__/ws-price-updates/wsEvents.ts | 16 + .../__fixtures__/ws-price-updates/wsWallet.ts | 60 +++ .../src/pipeline/buildUpdateSources.test.ts | 7 +- ...eSources.price-updates.integration.test.ts | 410 +++++++++--------- 5 files changed, 426 insertions(+), 458 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts index eed30793e03..4f37f1a7432 100644 --- a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -1,9 +1,3 @@ -/** - * Integration coverage for `AssetsController` websocket price updates: after - * a websocket balance update, the Token and Price APIs are invoked so existing - * and newly-seen tokens get metadata and spot prices in the same pipeline pass. - */ - import type { ApiPlatformClient } from '@metamask/core-backend'; import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { cleanAll } from 'nock'; @@ -16,11 +10,12 @@ import { mockWsApis } from './__fixtures__/ws-price-updates/api-responses/index. import { registerWsControllerActions } from './__fixtures__/ws-price-updates/messenger.js'; import { ETH_ASSET_ID, - USDC_ASSET_ID_CHECKSUM, USDC_ASSET_ID_LOWERCASE, WS_ACCOUNT_ID, } from './__fixtures__/ws-price-updates/wallet.js'; +import type { BalanceUpdatedEventPayload } from './__fixtures__/ws-price-updates/wsEvents.js'; import { + buildEthAndUsdcBalanceUpdatedEvent, buildEthBalanceUpdatedEvent, buildUsdcBalanceUpdatedEvent, ETH_WS_AMOUNT, @@ -28,11 +23,84 @@ import { } from './__fixtures__/ws-price-updates/wsEvents.js'; import { buildEmptyAssetsState, + buildEthHeldUnpricedState, + buildUsdcHeldAndPricedState, getIgnoringCase, + SEEDED_USDC_PRICE, } from './__fixtures__/ws-price-updates/wsWallet.js'; import { AssetsController } from './AssetsController.js'; import type { AssetsControllerState } from './AssetsController.js'; +/** + * Integration coverage for `AssetsController` websocket price updates + * against the Mainnet wallet that acquires ETH and USDC over the websocket. + * + * Boots the real controller, with its lifecycle closed, against realistic + * APIs. + * + * Integration Expectation - surfaced holdings get metadata and spot prices in + * the same pass as the balance update. + */ + +type StateSurface = { + surface: string; + lookUp: (state: AssetsControllerState, assetId: string) => unknown; +}; + +const BALANCES: StateSurface = { + surface: 'balances', + lookUp: (state, assetId) => + getIgnoringCase(state.assetsBalance[WS_ACCOUNT_ID] ?? {}, assetId), +}; + +const METADATA: StateSurface = { + surface: 'metadata', + lookUp: (state, assetId) => getIgnoringCase(state.assetsInfo, assetId), +}; + +const PRICES: StateSurface = { + surface: 'prices', + lookUp: (state, assetId) => getIgnoringCase(state.assetsPrice, assetId), +}; + +/** The update lanes the remote feature flags switch the controller between. */ +const UPDATE_LANES: { name: string; remoteFeatureFlags?: FeatureFlags }[] = [ + { name: 'the v5 update lane' }, + { + name: 'the v6 update lane', + remoteFeatureFlags: { assetsAccountsApiV6: true }, + }, +]; + +/** A settled websocket event run. */ +type WsEventResult = { + state: AssetsControllerState; + assetBatches: string[][]; + priceBatches: string[][]; +}; + +/** + * Read the price the run persisted for an asset. + * + * @param state - The settled controller state. + * @param assetId - The CAIP-19 asset ID. + * @returns The persisted price. + */ +function priceOf(state: AssetsControllerState, assetId: string): number { + const price = PRICES.lookUp(state, assetId) as { price: number }; + return price.price; +} + +/** + * Asset IDs an API was asked about, lower-cased, across all batches. + * + * @param batches - The recorded request batches. + * @returns The lower-cased asset IDs asked about. + */ +function askedAbout(batches: string[][]): Set { + return new Set(batches.flat().map((assetId) => assetId.toLowerCase())); +} + type WithControllerCallback = (args: { controller: AssetsController; messenger: MockRootMessenger; @@ -77,10 +145,9 @@ async function withController( * @param options - The run options. * @param options.state - The state to boot with. * @param options.event - The websocket event to deliver. - * @param options.remoteFeatureFlags - Remote feature flags to boot with - * (`assetsAccountsApiV6: true` switches to the v6 update lane). - * @returns The settled controller state, the asset IDs the Token API was - * asked about, and the asset IDs the Price API was asked about. + * @param options.remoteFeatureFlags - Flags to boot with + * (`assetsAccountsApiV6: true` selects the v6 update lane). + * @returns The event result. */ async function runWsEvent({ state, @@ -88,13 +155,9 @@ async function runWsEvent({ remoteFeatureFlags, }: { state: Partial; - event: ReturnType; + event: BalanceUpdatedEventPayload; remoteFeatureFlags?: FeatureFlags; -}): Promise<{ - state: AssetsControllerState; - assetBatches: string[][]; - priceBatches: string[][]; -}> { +}): Promise { cleanAll(); const { accountsSupportedNetworks, assets, prices } = mockWsApis(); const queryApiClient = createTestApiClient(); @@ -139,277 +202,118 @@ describe('AssetsController: websocket price updates', () => { cleanAll(); }); - describe('brand-new holdings: ETH held and USDC acquired in one websocket event', () => { - let result: Awaited>; - - beforeAll(async () => { - result = await runWsEvent({ - state: buildEmptyAssetsState(), - event: { - address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', - chain: 'eip155:1', - updates: [ - ...buildEthBalanceUpdatedEvent().updates, - ...buildUsdcBalanceUpdatedEvent().updates, - ], - }, + describe.each(UPDATE_LANES)( + '$name: brand-new holdings: ETH held and USDC acquired in one websocket event', + ({ remoteFeatureFlags }) => { + let result: WsEventResult; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildEmptyAssetsState(), + event: buildEthAndUsdcBalanceUpdatedEvent(), + remoteFeatureFlags, + }); }); - }); - it('persists both websocket balances', () => { - const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; + it.each([BALANCES, METADATA, PRICES])( + '$surface - persisted for the surfaced holdings after one pass', + ({ lookUp }) => { + expect(lookUp(result.state, ETH_ASSET_ID)).toBeDefined(); + expect(lookUp(result.state, USDC_ASSET_ID_LOWERCASE)).toBeDefined(); + }, + ); - expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ - amount: ETH_WS_AMOUNT, - }); - expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ - amount: USDC_WS_AMOUNT, + it('persists the websocket balances', () => { + expect(BALANCES.lookUp(result.state, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + expect( + BALANCES.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), + ).toStrictEqual({ amount: USDC_WS_AMOUNT }); }); - }); - it('persists metadata for both holdings from the captured Token API', () => { - const eth = getIgnoringCase(result.state.assetsInfo, ETH_ASSET_ID) as { - name: string; - symbol: string; - decimals: number; - }; - const usdc = getIgnoringCase( - result.state.assetsInfo, - USDC_ASSET_ID_LOWERCASE, - ) as { name: string; symbol: string; decimals: number }; - - expect(eth).toMatchObject({ - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - }); - expect(usdc).toMatchObject({ - name: 'USDC', - symbol: 'USDC', - decimals: 6, + it('persists metadata for both holdings from the captured Token API', () => { + expect(METADATA.lookUp(result.state, ETH_ASSET_ID)).toMatchObject({ + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }); + expect( + METADATA.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), + ).toMatchObject({ name: 'USDC', symbol: 'USDC', decimals: 6 }); }); - }); - - it('prices both holdings from the captured Price API in the same pass', () => { - const ethPrice = getIgnoringCase( - result.state.assetsPrice, - ETH_ASSET_ID, - ) as { price: number }; - const usdcPrice = getIgnoringCase( - result.state.assetsPrice, - USDC_ASSET_ID_LOWERCASE, - ) as { price: number }; - - expect(ethPrice).toBeDefined(); - expect(ethPrice.price).toBeGreaterThan(0); - expect(usdcPrice).toBeDefined(); - // The captured USDC price is pegged around one dollar. - expect(usdcPrice.price).toBeGreaterThan(0.9); - expect(usdcPrice.price).toBeLessThan(1.1); - }); - - it('invoked the Price API for both holdings', () => { - const askedAbout = new Set( - result.priceBatches.flat().map((assetId) => assetId.toLowerCase()), - ); - expect(askedAbout).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); - - it('invoked the Token API for the new token and the native asset', () => { - const askedAbout = new Set( - result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), - ); - expect(askedAbout).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); - }); - - describe('v6 remote flag on: the same websocket event through the v6 update lane', () => { - let result: Awaited>; - beforeAll(async () => { - result = await runWsEvent({ - state: buildEmptyAssetsState(), - event: { - address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', - chain: 'eip155:1', - updates: [ - ...buildEthBalanceUpdatedEvent().updates, - ...buildUsdcBalanceUpdatedEvent().updates, - ], - }, - remoteFeatureFlags: { assetsAccountsApiV6: true }, + it('prices both holdings from the captured Price API in the same pass', () => { + expect(priceOf(result.state, ETH_ASSET_ID)).toBeGreaterThan(0); + // The captured USDC price is pegged around one dollar. + expect(priceOf(result.state, USDC_ASSET_ID_LOWERCASE)).toBeGreaterThan( + 0.9, + ); + expect(priceOf(result.state, USDC_ASSET_ID_LOWERCASE)).toBeLessThan( + 1.1, + ); }); - }); - - it('persists both websocket balances', () => { - const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; - expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ - amount: ETH_WS_AMOUNT, - }); - expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ - amount: USDC_WS_AMOUNT, + it('invoked the Price API for both holdings', () => { + expect(askedAbout(result.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); }); - }); - it('persists metadata for both holdings from the captured Token API', () => { - const eth = getIgnoringCase(result.state.assetsInfo, ETH_ASSET_ID) as { - name: string; - symbol: string; - decimals: number; - }; - const usdc = getIgnoringCase( - result.state.assetsInfo, - USDC_ASSET_ID_LOWERCASE, - ) as { name: string; symbol: string; decimals: number }; - - expect(eth).toMatchObject({ - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - }); - expect(usdc).toMatchObject({ - name: 'USDC', - symbol: 'USDC', - decimals: 6, + it('invoked the Token API for the new token and the native asset', () => { + expect(askedAbout(result.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); }); - }); - - it('prices both holdings from the captured Price API in the same pass', () => { - const ethPrice = getIgnoringCase( - result.state.assetsPrice, - ETH_ASSET_ID, - ) as { price: number }; - const usdcPrice = getIgnoringCase( - result.state.assetsPrice, - USDC_ASSET_ID_LOWERCASE, - ) as { price: number }; - - expect(ethPrice).toBeDefined(); - expect(ethPrice.price).toBeGreaterThan(0); - expect(usdcPrice).toBeDefined(); - expect(usdcPrice.price).toBeGreaterThan(0.9); - expect(usdcPrice.price).toBeLessThan(1.1); - }); - - it('invoked the Price API for both holdings', () => { - const askedAbout = new Set( - result.priceBatches.flat().map((assetId) => assetId.toLowerCase()), - ); - expect(askedAbout).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); - - it('invoked the Token API for the new token and the native asset', () => { - const askedAbout = new Set( - result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), - ); - expect(askedAbout).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); - }); + }, + ); describe('held-but-unpriced native asset: ETH in state without a price', () => { - let result: Awaited>; + let result: WsEventResult; beforeAll(async () => { result = await runWsEvent({ - state: buildEmptyAssetsState({ - assetsBalance: { - [WS_ACCOUNT_ID]: { [ETH_ASSET_ID]: { amount: '1' } }, - }, - assetsInfo: { - [ETH_ASSET_ID]: { - type: 'native', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - image: - 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44:60.png', - }, - }, - }), + state: buildEthHeldUnpricedState(), event: buildEthBalanceUpdatedEvent(), }); }); it('persists the refreshed websocket balance', () => { - const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; - expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + expect(BALANCES.lookUp(result.state, ETH_ASSET_ID)).toStrictEqual({ amount: ETH_WS_AMOUNT, }); }); it('prices the held-but-unpriced asset from the captured Price API', () => { - const ethPrice = getIgnoringCase( - result.state.assetsPrice, - ETH_ASSET_ID, - ) as { price: number }; - expect(ethPrice).toBeDefined(); - expect(ethPrice.price).toBeGreaterThan(0); + expect(priceOf(result.state, ETH_ASSET_ID)).toBeGreaterThan(0); }); it('invoked the Price API for the held-but-unpriced asset', () => { - const askedAbout = new Set( - result.priceBatches.flat().map((assetId) => assetId.toLowerCase()), + expect(askedAbout(result.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), ); - expect(askedAbout).toStrictEqual(new Set([ETH_ASSET_ID])); }); }); describe('already-priced token: USDC in state with balance, metadata and price', () => { - const SEEDED_USDC_PRICE = { - assetPriceType: 'fungible' as const, - price: 1, - usdPrice: 1, - pricePercentChange1d: 0.01, - lastUpdated: 1_756_100_000_000, - }; - - let result: Awaited>; + let result: WsEventResult; beforeAll(async () => { result = await runWsEvent({ - state: buildEmptyAssetsState({ - assetsBalance: { - [WS_ACCOUNT_ID]: { - [USDC_ASSET_ID_CHECKSUM]: { amount: '5' }, - }, - }, - assetsInfo: { - [USDC_ASSET_ID_CHECKSUM]: { - type: 'erc20', - name: 'USDC', - symbol: 'USDC', - decimals: 6, - image: - 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', - }, - }, - assetsPrice: { - [USDC_ASSET_ID_CHECKSUM]: SEEDED_USDC_PRICE, - }, - }), + state: buildUsdcHeldAndPricedState(), event: buildUsdcBalanceUpdatedEvent(), }); }); it('persists the refreshed websocket balance', () => { - const balances = result.state.assetsBalance[WS_ACCOUNT_ID] ?? {}; - expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ - amount: USDC_WS_AMOUNT, - }); + expect( + BALANCES.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), + ).toStrictEqual({ amount: USDC_WS_AMOUNT }); }); it('keeps the seeded price untouched', () => { expect( - getIgnoringCase(result.state.assetsPrice, USDC_ASSET_ID_LOWERCASE), + PRICES.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), ).toStrictEqual(SEEDED_USDC_PRICE); }); @@ -418,10 +322,9 @@ describe('AssetsController: websocket price updates', () => { }); it('did not refetch metadata for the enriched token', () => { - const askedAbout = new Set( - result.assetBatches.flat().map((assetId) => assetId.toLowerCase()), + expect(askedAbout(result.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), ); - expect(askedAbout).toStrictEqual(new Set([ETH_ASSET_ID])); }); }); }); diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts index b0cfee7259a..63b6489de89 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts @@ -78,3 +78,19 @@ export function buildUsdcBalanceUpdatedEvent(options?: { ], }; } + +/** + * Build a `balanceUpdated` payload reporting both the ETH and USDC balances. + * + * @returns The event payload. + */ +export function buildEthAndUsdcBalanceUpdatedEvent(): BalanceUpdatedEventPayload { + return { + address: WS_WALLET_ADDRESS, + chain: MAINNET_CHAIN_ID, + updates: [ + ...buildEthBalanceUpdatedEvent().updates, + ...buildUsdcBalanceUpdatedEvent().updates, + ], + }; +} diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts index cb2d03c3f96..6ea29995cd9 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts @@ -2,7 +2,9 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { AssetsControllerState } from '../../types.js'; import { + ETH_ASSET_ID, MAINNET_CHAIN_ID, + USDC_ASSET_ID_CHECKSUM, WS_ACCOUNT_ID, WS_WALLET_ADDRESS, } from './wallet.js'; @@ -64,3 +66,61 @@ export function getIgnoringCase( ); return match === undefined ? undefined : record[match]; } + +/** A seeded USDC spot price, as a wallet that has already priced USDC holds it. */ +export const SEEDED_USDC_PRICE = { + assetPriceType: 'fungible' as const, + price: 1, + usdPrice: 1, + pricePercentChange1d: 0.01, + lastUpdated: 1_756_100_000_000, +}; + +/** + * State holding ETH with metadata but no price yet. + * + * @returns The held-but-unpriced state. + */ +export function buildEthHeldUnpricedState(): AssetsControllerState { + return buildEmptyAssetsState({ + assetsBalance: { + [WS_ACCOUNT_ID]: { [ETH_ASSET_ID]: { amount: '1' } }, + }, + assetsInfo: { + [ETH_ASSET_ID]: { + type: 'native', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44:60.png', + }, + }, + }); +} + +/** + * State holding USDC with balance, metadata and an already-seeded price. + * + * @returns The already-priced state. + */ +export function buildUsdcHeldAndPricedState(): AssetsControllerState { + return buildEmptyAssetsState({ + assetsBalance: { + [WS_ACCOUNT_ID]: { [USDC_ASSET_ID_CHECKSUM]: { amount: '5' } }, + }, + assetsInfo: { + [USDC_ASSET_ID_CHECKSUM]: { + type: 'erc20', + name: 'USDC', + symbol: 'USDC', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + }, + }, + assetsPrice: { + [USDC_ASSET_ID_CHECKSUM]: SEEDED_USDC_PRICE, + }, + }); +} diff --git a/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts b/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts index 5912050c030..4bf5248c390 100644 --- a/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts +++ b/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts @@ -24,7 +24,8 @@ function buildSources(): UpdateSources { describe('buildUpdateSources', () => { it.each([ { - title: 'v5 AccountsApi lane: graduation → RPC fallback → detection → enrichment', + title: + 'v5 AccountsApi lane: graduation → RPC fallback → detection → enrichment', includeCustomAssetGraduation: true, includeRpcFallback: true, isBasicFunctionality: true, @@ -85,9 +86,7 @@ describe('buildUpdateSources', () => { includeRpcFallback, }); - expect(sources.map((source) => source.getName())).toStrictEqual( - expected, - ); + expect(sources.map((source) => source.getName())).toStrictEqual(expected); }, ); }); diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts index e0372b8ef16..9195800f584 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -11,11 +11,12 @@ import { mockWsApis } from '../__fixtures__/ws-price-updates/api-responses/index import { registerMainnetNetwork } from '../__fixtures__/ws-price-updates/messenger.js'; import { ETH_ASSET_ID, - USDC_ASSET_ID_CHECKSUM, USDC_ASSET_ID_LOWERCASE, WS_ACCOUNT_ID, } from '../__fixtures__/ws-price-updates/wallet.js'; +import type { BalanceUpdatedEventPayload } from '../__fixtures__/ws-price-updates/wsEvents.js'; import { + buildEthAndUsdcBalanceUpdatedEvent, buildEthBalanceUpdatedEvent, buildUsdcBalanceUpdatedEvent, ETH_WS_AMOUNT, @@ -23,6 +24,8 @@ import { } from '../__fixtures__/ws-price-updates/wsEvents.js'; import { buildEmptyAssetsState, + buildEthHeldUnpricedState, + buildUsdcHeldAndPricedState, buildWsAccount, getIgnoringCase, } from '../__fixtures__/ws-price-updates/wsWallet.js'; @@ -34,7 +37,7 @@ import { DetectionMiddleware } from '../middlewares/DetectionMiddleware.js'; import type { AccountId, AssetsControllerState, - Caip19AssetId, + AssetsDataSource, Context, DataRequest, DataResponse, @@ -42,6 +45,109 @@ import type { import { buildWsUpdateSources } from './buildWsUpdateSources.js'; import { executeAssetsPipeline } from './executeAssetsPipeline.js'; +/** + * Integration coverage for the websocket update lane against the Mainnet + * wallet that acquires ETH and USDC over the websocket. + * + * Executes the real websocket-update pipeline against realistic APIs. + * + * Integration Expectation - surfaced holdings are detected, enriched and + * priced in the same pass. + */ + +type UpdateLane = 'v5' | 'v6'; + +type ResponseSurface = { + surface: string; + lookUp: (response: DataResponse, assetId: string) => unknown; +}; + +const BALANCES: ResponseSurface = { + surface: 'balances', + lookUp: (response, assetId) => + getIgnoringCase(response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}, assetId), +}; + +const METADATA: ResponseSurface = { + surface: 'metadata', + lookUp: (response, assetId) => + getIgnoringCase(response.assetsInfo ?? {}, assetId), +}; + +const PRICES: ResponseSurface = { + surface: 'prices', + lookUp: (response, assetId) => + getIgnoringCase(response.assetsPrice ?? {}, assetId), +}; + +const DETECTED_ASSETS: ResponseSurface = { + surface: 'detected assets', + lookUp: (response, assetId) => + Object.values(response.detectedAssets ?? {}) + .flat() + .find((detectedId) => detectedId.toLowerCase() === assetId.toLowerCase()), +}; + +/** A completed websocket update pass. */ +type WsUpdatePassResult = { + response: DataResponse; + request: DataRequest; + priceBatches: string[][]; + assetBatches: string[][]; + rpcFallbackRequests: DataRequest[]; +}; + +/** The update lanes the controller composes. */ +const LANES: { name: string; lane: UpdateLane }[] = [ + { name: 'the v5 lane', lane: 'v5' }, + { name: 'the v6 lane', lane: 'v6' }, +]; + +/** + * Read the price the pass recorded for an asset. + * + * @param response - The pass response. + * @param assetId - The CAIP-19 asset ID. + * @returns The recorded price. + */ +function priceOf(response: DataResponse, assetId: string): number { + const price = PRICES.lookUp(response, assetId) as { price: number }; + return price.price; +} + +/** + * Asset IDs an API was asked about, lower-cased, across all batches. + * + * @param batches - The recorded request batches. + * @returns The lower-cased asset IDs asked about. + */ +function askedAbout(batches: string[][]): Set { + return new Set(batches.flat().map((assetId) => assetId.toLowerCase())); +} + +/** + * Build an RPC fallback middleware that records the requests it sees. + * + * @returns The middleware and its recorded requests. + */ +function createRecordingRpcFallback(): { + middleware: AssetsDataSource; + requests: DataRequest[]; +} { + const requests: DataRequest[] = []; + const middleware: AssetsDataSource = { + getName: (): string => 'RpcFallbackMiddleware', + assetsMiddleware: async ( + ctx: Context, + next: (context: Context) => Promise, + ): Promise => { + requests.push(ctx.request); + return next(ctx); + }, + }; + return { middleware, requests }; +} + /** * Run one websocket update pass through the composed lane. * @@ -49,8 +155,7 @@ import { executeAssetsPipeline } from './executeAssetsPipeline.js'; * @param options.state - The state the pass sees. * @param options.event - The websocket event to deliver. * @param options.lane - The update lane to compose (`v5` by default). - * @returns The enriched response, the request the detection middleware - * mutated, and the asset IDs the Token and Price APIs were asked about. + * @returns The pass result. */ async function runWsUpdatePass({ state, @@ -58,15 +163,9 @@ async function runWsUpdatePass({ lane = 'v5', }: { state: ReturnType; - event: ReturnType; - lane?: 'v5' | 'v6'; -}): Promise<{ - response: DataResponse; - request: DataRequest; - priceBatches: string[][]; - assetBatches: string[][]; - rpcFallbackRequests: DataRequest[]; -}> { + event: BalanceUpdatedEventPayload; + lane?: UpdateLane; +}): Promise { cleanAll(); const { assets, prices } = mockWsApis(); @@ -78,7 +177,8 @@ async function runWsUpdatePass({ }); const queryApiClient = createTestApiClient(); - const rpcFallbackRequests: DataRequest[] = []; + const { middleware: rpcFallbackMiddleware, requests: rpcFallbackRequests } = + createRecordingRpcFallback(); const tokenDataSource = new TokenDataSource(assetsControllerMessenger, { queryApiClient, @@ -107,16 +207,7 @@ async function runWsUpdatePass({ }, getAssetsState: (): AssetsControllerState => state, }), - rpcFallbackMiddleware: { - getName: (): string => 'RpcFallbackMiddleware', - assetsMiddleware: async ( - ctx: Context, - next: (context: Context) => Promise, - ): Promise => { - rpcFallbackRequests.push(ctx.request); - return next(ctx); - }, - }, + rpcFallbackMiddleware, detectionMiddleware: new DetectionMiddleware({ getAssetsState: (): AssetsControllerState => state, }), @@ -166,158 +257,99 @@ async function runWsUpdatePass({ }; } -/** - * Asset IDs an API was asked about, lower-cased, across all batches. - * - * @param batches - The recorded request batches. - * @returns The lower-cased asset IDs asked about. - */ -function askedAbout(batches: string[][]): Set { - return new Set(batches.flat().map((assetId) => assetId.toLowerCase())); -} - describe('websocket update pipeline: prices for surfaced holdings', () => { afterEach(() => { cleanAll(); }); - describe('brand-new holdings: ETH held and USDC acquired in one websocket event', () => { - let result: Awaited>; - - beforeAll(async () => { - result = await runWsUpdatePass({ - state: buildEmptyAssetsState(), - event: { - address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', - chain: 'eip155:1', - updates: [ - ...buildEthBalanceUpdatedEvent().updates, - ...buildUsdcBalanceUpdatedEvent().updates, - ], - }, + describe.each(LANES)( + '$name: brand-new holdings: ETH held and USDC acquired in one websocket event', + ({ lane }) => { + let result: WsUpdatePassResult; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEmptyAssetsState(), + event: buildEthAndUsdcBalanceUpdatedEvent(), + lane, + }); }); - }); - it('detects both holdings as new', () => { - const detected = result.response.detectedAssets?.[ - WS_ACCOUNT_ID - ] as Caip19AssetId[]; - expect(detected?.map((id) => id.toLowerCase())).toStrictEqual( - expect.arrayContaining([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + it.each([BALANCES, METADATA, PRICES, DETECTED_ASSETS])( + '$surface - present for the surfaced holdings after one pass', + ({ lookUp }) => { + expect(lookUp(result.response, ETH_ASSET_ID)).toBeDefined(); + expect( + lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toBeDefined(); + }, ); - }); - it('enriches both holdings with captured metadata in the same pass', () => { - const eth = getIgnoringCase( - result.response.assetsInfo ?? {}, - ETH_ASSET_ID, - ) as { name: string; symbol: string; decimals: number }; - const usdc = getIgnoringCase( - result.response.assetsInfo ?? {}, - USDC_ASSET_ID_LOWERCASE, - ) as { name: string; symbol: string; decimals: number }; - - expect(eth).toMatchObject({ - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, + it('carries the websocket balances through', () => { + expect(BALANCES.lookUp(result.response, ETH_ASSET_ID)).toStrictEqual({ + amount: ETH_WS_AMOUNT, + }); + expect( + BALANCES.lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toStrictEqual({ amount: USDC_WS_AMOUNT }); }); - expect(usdc).toMatchObject({ - name: 'USDC', - symbol: 'USDC', - decimals: 6, - }); - }); - - it('carries the websocket balances through', () => { - const balances = result.response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}; - expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ - amount: ETH_WS_AMOUNT, - }); - expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ - amount: USDC_WS_AMOUNT, + it('enriches both holdings with the captured metadata', () => { + expect(METADATA.lookUp(result.response, ETH_ASSET_ID)).toMatchObject({ + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }); + expect( + METADATA.lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toMatchObject({ name: 'USDC', symbol: 'USDC', decimals: 6 }); }); - }); - it('queues both holdings for a price update', () => { - const queued = - result.request.assetsForPriceUpdate?.map((id) => id.toLowerCase()) ?? - []; - expect(queued).toStrictEqual( - expect.arrayContaining([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); + it('prices both holdings from the captured spot prices', () => { + expect(priceOf(result.response, ETH_ASSET_ID)).toBeGreaterThan(0); + // The captured USDC price is pegged around one dollar. + expect( + priceOf(result.response, USDC_ASSET_ID_LOWERCASE), + ).toBeGreaterThan(0.9); + expect(priceOf(result.response, USDC_ASSET_ID_LOWERCASE)).toBeLessThan( + 1.1, + ); + }); - it('invokes the price API for both holdings in the same pass', () => { - expect(askedAbout(result.priceBatches)).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); + it('queues both surfaced holdings for a price update', () => { + const queued = + result.request.assetsForPriceUpdate?.map((id) => id.toLowerCase()) ?? + []; + expect(queued).toStrictEqual( + expect.arrayContaining([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); - it('prices both holdings from the captured spot prices', () => { - const ethPrice = getIgnoringCase( - result.response.assetsPrice ?? {}, - ETH_ASSET_ID, - ) as { price: number }; - const usdcPrice = getIgnoringCase( - result.response.assetsPrice ?? {}, - USDC_ASSET_ID_LOWERCASE, - ) as { price: number }; - - expect(ethPrice).toBeDefined(); - expect(ethPrice.price).toBeGreaterThan(0); - expect(usdcPrice).toBeDefined(); - // The captured USDC price is pegged around one dollar. - expect(usdcPrice.price).toBeGreaterThan(0.9); - expect(usdcPrice.price).toBeLessThan(1.1); - }); + it('invokes the Price API for both surfaced holdings in the same pass', () => { + expect(askedAbout(result.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); - it('asks the Token API for the new token and the native asset', () => { - expect(askedAbout(result.assetBatches)).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - }); - }); + it('invokes the Token API for the new token and the native asset', () => { + expect(askedAbout(result.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + }, + ); - describe('v6 lane: the same websocket event through the v6 composition', () => { - let result: Awaited>; + describe('v6 lane: the RPC fallback on the balance pass', () => { + let result: WsUpdatePassResult; beforeAll(async () => { result = await runWsUpdatePass({ state: buildEmptyAssetsState(), - event: { - address: '0x742d35cc6634c0532925a3b844bc454e4438f44e', - chain: 'eip155:1', - updates: [ - ...buildEthBalanceUpdatedEvent().updates, - ...buildUsdcBalanceUpdatedEvent().updates, - ], - }, + event: buildEthAndUsdcBalanceUpdatedEvent(), lane: 'v6', }); }); - it('enriches and prices both surfaced holdings like the v5 lane', () => { - expect(askedAbout(result.assetBatches)).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - expect(askedAbout(result.priceBatches)).toStrictEqual( - new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), - ); - - const ethPrice = getIgnoringCase( - result.response.assetsPrice ?? {}, - ETH_ASSET_ID, - ) as { price: number }; - const usdcPrice = getIgnoringCase( - result.response.assetsPrice ?? {}, - USDC_ASSET_ID_LOWERCASE, - ) as { price: number }; - expect(ethPrice.price).toBeGreaterThan(0); - expect(usdcPrice.price).toBeGreaterThan(0.9); - }); - it('runs the RPC fallback once on the balance pass', () => { // A response without errors leaves the fallback a passthrough. expect(result.rpcFallbackRequests).toHaveLength(1); @@ -328,31 +360,19 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); describe('held-but-unpriced native asset: ETH in state without a price', () => { - let result: Awaited>; + let result: WsUpdatePassResult; beforeAll(async () => { result = await runWsUpdatePass({ - state: buildEmptyAssetsState({ - assetsBalance: { - [WS_ACCOUNT_ID]: { [ETH_ASSET_ID]: { amount: '1' } }, - }, - assetsInfo: { - [ETH_ASSET_ID]: { - type: 'native', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - image: - 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44:60.png', - }, - }, - }), + state: buildEthHeldUnpricedState(), event: buildEthBalanceUpdatedEvent(), }); }); it('does not re-detect the holding', () => { - expect(result.response.detectedAssets?.[WS_ACCOUNT_ID]).toBeUndefined(); + expect( + DETECTED_ASSETS.lookUp(result.response, ETH_ASSET_ID), + ).toBeUndefined(); }); it('queues the held-but-unpriced asset for a price update', () => { @@ -362,73 +382,44 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { expect(queued).toStrictEqual([ETH_ASSET_ID]); }); - it('invokes the price API for the held-but-unpriced asset', () => { + it('invokes the Price API for the held-but-unpriced asset', () => { expect(askedAbout(result.priceBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); }); it('prices the held asset from the captured spot price', () => { - const ethPrice = getIgnoringCase( - result.response.assetsPrice ?? {}, - ETH_ASSET_ID, - ) as { price: number }; - expect(ethPrice).toBeDefined(); - expect(ethPrice.price).toBeGreaterThan(0); + expect(priceOf(result.response, ETH_ASSET_ID)).toBeGreaterThan(0); }); it('carries the refreshed websocket balance', () => { - const balances = result.response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}; - expect(getIgnoringCase(balances, ETH_ASSET_ID)).toStrictEqual({ + expect(BALANCES.lookUp(result.response, ETH_ASSET_ID)).toStrictEqual({ amount: ETH_WS_AMOUNT, }); }); }); describe('already-priced token: USDC in state with balance, metadata and price', () => { - let result: Awaited>; + let result: WsUpdatePassResult; beforeAll(async () => { result = await runWsUpdatePass({ - state: buildEmptyAssetsState({ - assetsBalance: { - [WS_ACCOUNT_ID]: { - [USDC_ASSET_ID_CHECKSUM]: { amount: '5' }, - }, - }, - assetsInfo: { - [USDC_ASSET_ID_CHECKSUM]: { - type: 'erc20', - name: 'USDC', - symbol: 'USDC', - decimals: 6, - image: - 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', - }, - }, - assetsPrice: { - [USDC_ASSET_ID_CHECKSUM]: { - assetPriceType: 'fungible', - price: 1, - usdPrice: 1, - pricePercentChange1d: 0.01, - lastUpdated: 1_756_100_000_000, - }, - }, - }), + state: buildUsdcHeldAndPricedState(), event: buildUsdcBalanceUpdatedEvent(), }); }); it('does not re-detect the token', () => { - expect(result.response.detectedAssets?.[WS_ACCOUNT_ID]).toBeUndefined(); + expect( + DETECTED_ASSETS.lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toBeUndefined(); }); it('does not queue the priced token for another price update', () => { expect(result.request.assetsForPriceUpdate ?? []).toStrictEqual([]); }); - it('does not invoke the price API', () => { + it('does not invoke the Price API', () => { expect(result.priceBatches).toStrictEqual([]); }); @@ -439,10 +430,9 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('carries the websocket balance refresh', () => { - const balances = result.response.assetsBalance?.[WS_ACCOUNT_ID] ?? {}; - expect(getIgnoringCase(balances, USDC_ASSET_ID_LOWERCASE)).toStrictEqual({ - amount: USDC_WS_AMOUNT, - }); + expect( + BALANCES.lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toStrictEqual({ amount: USDC_WS_AMOUNT }); }); }); }); From f0a24bdc4f227b204e2ed68d9764e869e51d126e Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 28 Sep 2026 13:03:11 +0100 Subject: [PATCH 7/8] Pin exact captured prices and group the recorded mocks in the run results Assert the captured spot prices exactly (ETH 2688.8502994319642, USDC 0.999966) instead of numeric ranges, and nest each run's recorded mock output under `mocks` (`priceAPI`, `tokenAPI`, `rpc`) so the pipeline/controller results read apart from what the mocks observed. --- ...oller.ws-price-updates.integration.test.ts | 62 +++++++++-------- .../__fixtures__/ws-price-updates/wallet.ts | 6 ++ ...eSources.price-updates.integration.test.ts | 68 ++++++++++--------- 3 files changed, 73 insertions(+), 63 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts index 4f37f1a7432..c3852ef4528 100644 --- a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -10,7 +10,9 @@ import { mockWsApis } from './__fixtures__/ws-price-updates/api-responses/index. import { registerWsControllerActions } from './__fixtures__/ws-price-updates/messenger.js'; import { ETH_ASSET_ID, + ETH_SPOT_PRICE, USDC_ASSET_ID_LOWERCASE, + USDC_SPOT_PRICE, WS_ACCOUNT_ID, } from './__fixtures__/ws-price-updates/wallet.js'; import type { BalanceUpdatedEventPayload } from './__fixtures__/ws-price-updates/wsEvents.js'; @@ -75,22 +77,12 @@ const UPDATE_LANES: { name: string; remoteFeatureFlags?: FeatureFlags }[] = [ /** A settled websocket event run. */ type WsEventResult = { state: AssetsControllerState; - assetBatches: string[][]; - priceBatches: string[][]; + mocks: { + priceAPI: { priceBatches: string[][] }; + tokenAPI: { assetBatches: string[][] }; + }; }; -/** - * Read the price the run persisted for an asset. - * - * @param state - The settled controller state. - * @param assetId - The CAIP-19 asset ID. - * @returns The persisted price. - */ -function priceOf(state: AssetsControllerState, assetId: string): number { - const price = PRICES.lookUp(state, assetId) as { price: number }; - return price.price; -} - /** * Asset IDs an API was asked about, lower-cased, across all batches. * @@ -192,8 +184,10 @@ async function runWsEvent({ return { state: controllerState, - assetBatches: assets.requestedBatches, - priceBatches: prices.requestedBatches, + mocks: { + priceAPI: { priceBatches: prices.requestedBatches }, + tokenAPI: { assetBatches: assets.requestedBatches }, + }, }; } @@ -244,24 +238,28 @@ describe('AssetsController: websocket price updates', () => { }); it('prices both holdings from the captured Price API in the same pass', () => { - expect(priceOf(result.state, ETH_ASSET_ID)).toBeGreaterThan(0); - // The captured USDC price is pegged around one dollar. - expect(priceOf(result.state, USDC_ASSET_ID_LOWERCASE)).toBeGreaterThan( - 0.9, - ); - expect(priceOf(result.state, USDC_ASSET_ID_LOWERCASE)).toBeLessThan( - 1.1, - ); + expect(PRICES.lookUp(result.state, ETH_ASSET_ID)).toMatchObject({ + assetPriceType: 'fungible', + price: ETH_SPOT_PRICE, + usdPrice: ETH_SPOT_PRICE, + }); + expect( + PRICES.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), + ).toMatchObject({ + assetPriceType: 'fungible', + price: USDC_SPOT_PRICE, + usdPrice: USDC_SPOT_PRICE, + }); }); it('invoked the Price API for both holdings', () => { - expect(askedAbout(result.priceBatches)).toStrictEqual( + expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), ); }); it('invoked the Token API for the new token and the native asset', () => { - expect(askedAbout(result.assetBatches)).toStrictEqual( + expect(askedAbout(result.mocks.tokenAPI.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), ); }); @@ -285,11 +283,15 @@ describe('AssetsController: websocket price updates', () => { }); it('prices the held-but-unpriced asset from the captured Price API', () => { - expect(priceOf(result.state, ETH_ASSET_ID)).toBeGreaterThan(0); + expect(PRICES.lookUp(result.state, ETH_ASSET_ID)).toMatchObject({ + assetPriceType: 'fungible', + price: ETH_SPOT_PRICE, + usdPrice: ETH_SPOT_PRICE, + }); }); it('invoked the Price API for the held-but-unpriced asset', () => { - expect(askedAbout(result.priceBatches)).toStrictEqual( + expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); }); @@ -318,11 +320,11 @@ describe('AssetsController: websocket price updates', () => { }); it('did not invoke the Price API for the priced token', () => { - expect(result.priceBatches).toStrictEqual([]); + expect(result.mocks.priceAPI.priceBatches).toStrictEqual([]); }); it('did not refetch metadata for the enriched token', () => { - expect(askedAbout(result.assetBatches)).toStrictEqual( + expect(askedAbout(result.mocks.tokenAPI.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); }); diff --git a/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts index 1f83f05b129..1e801fa919b 100644 --- a/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts @@ -24,6 +24,12 @@ export const USDC_ASSET_ID_LOWERCASE = export const USDC_ASSET_ID_CHECKSUM = `${MAINNET_CHAIN_ID}/erc20:${USDC_ADDRESS_CHECKSUM}` as const; +/** The ETH spot price the Price API reports. */ +export const ETH_SPOT_PRICE = 2688.8502994319642; + +/** The USDC spot price the Price API reports. */ +export const USDC_SPOT_PRICE = 0.999966; + // RPC mocks export const MAINNET_CHAIN_ID_HEX = '0x1' as const; export const MAINNET_NETWORK_CLIENT_ID = 'mainnet' as const; diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts index 9195800f584..1190bbe1a43 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -11,7 +11,9 @@ import { mockWsApis } from '../__fixtures__/ws-price-updates/api-responses/index import { registerMainnetNetwork } from '../__fixtures__/ws-price-updates/messenger.js'; import { ETH_ASSET_ID, + ETH_SPOT_PRICE, USDC_ASSET_ID_LOWERCASE, + USDC_SPOT_PRICE, WS_ACCOUNT_ID, } from '../__fixtures__/ws-price-updates/wallet.js'; import type { BalanceUpdatedEventPayload } from '../__fixtures__/ws-price-updates/wsEvents.js'; @@ -92,9 +94,11 @@ const DETECTED_ASSETS: ResponseSurface = { type WsUpdatePassResult = { response: DataResponse; request: DataRequest; - priceBatches: string[][]; - assetBatches: string[][]; - rpcFallbackRequests: DataRequest[]; + mocks: { + priceAPI: { priceBatches: string[][] }; + tokenAPI: { assetBatches: string[][] }; + rpc: { rpcFallbackRequests: DataRequest[] }; + }; }; /** The update lanes the controller composes. */ @@ -103,18 +107,6 @@ const LANES: { name: string; lane: UpdateLane }[] = [ { name: 'the v6 lane', lane: 'v6' }, ]; -/** - * Read the price the pass recorded for an asset. - * - * @param response - The pass response. - * @param assetId - The CAIP-19 asset ID. - * @returns The recorded price. - */ -function priceOf(response: DataResponse, assetId: string): number { - const price = PRICES.lookUp(response, assetId) as { price: number }; - return price.price; -} - /** * Asset IDs an API was asked about, lower-cased, across all batches. * @@ -251,9 +243,11 @@ async function runWsUpdatePass({ return { response: captured.response, request: captured.request, - priceBatches: prices.requestedBatches, - assetBatches: assets.requestedBatches, - rpcFallbackRequests, + mocks: { + priceAPI: { priceBatches: prices.requestedBatches }, + tokenAPI: { assetBatches: assets.requestedBatches }, + rpc: { rpcFallbackRequests }, + }, }; } @@ -306,14 +300,18 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('prices both holdings from the captured spot prices', () => { - expect(priceOf(result.response, ETH_ASSET_ID)).toBeGreaterThan(0); - // The captured USDC price is pegged around one dollar. + expect(PRICES.lookUp(result.response, ETH_ASSET_ID)).toMatchObject({ + assetPriceType: 'fungible', + price: ETH_SPOT_PRICE, + usdPrice: ETH_SPOT_PRICE, + }); expect( - priceOf(result.response, USDC_ASSET_ID_LOWERCASE), - ).toBeGreaterThan(0.9); - expect(priceOf(result.response, USDC_ASSET_ID_LOWERCASE)).toBeLessThan( - 1.1, - ); + PRICES.lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toMatchObject({ + assetPriceType: 'fungible', + price: USDC_SPOT_PRICE, + usdPrice: USDC_SPOT_PRICE, + }); }); it('queues both surfaced holdings for a price update', () => { @@ -326,13 +324,13 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('invokes the Price API for both surfaced holdings in the same pass', () => { - expect(askedAbout(result.priceBatches)).toStrictEqual( + expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), ); }); it('invokes the Token API for the new token and the native asset', () => { - expect(askedAbout(result.assetBatches)).toStrictEqual( + expect(askedAbout(result.mocks.tokenAPI.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), ); }); @@ -352,8 +350,8 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { it('runs the RPC fallback once on the balance pass', () => { // A response without errors leaves the fallback a passthrough. - expect(result.rpcFallbackRequests).toHaveLength(1); - expect(result.rpcFallbackRequests[0]?.dataTypes).toStrictEqual( + expect(result.mocks.rpc.rpcFallbackRequests).toHaveLength(1); + expect(result.mocks.rpc.rpcFallbackRequests[0]?.dataTypes).toStrictEqual( expect.arrayContaining(['balance']), ); }); @@ -383,13 +381,17 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('invokes the Price API for the held-but-unpriced asset', () => { - expect(askedAbout(result.priceBatches)).toStrictEqual( + expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); }); it('prices the held asset from the captured spot price', () => { - expect(priceOf(result.response, ETH_ASSET_ID)).toBeGreaterThan(0); + expect(PRICES.lookUp(result.response, ETH_ASSET_ID)).toMatchObject({ + assetPriceType: 'fungible', + price: ETH_SPOT_PRICE, + usdPrice: ETH_SPOT_PRICE, + }); }); it('carries the refreshed websocket balance', () => { @@ -420,11 +422,11 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { }); it('does not invoke the Price API', () => { - expect(result.priceBatches).toStrictEqual([]); + expect(result.mocks.priceAPI.priceBatches).toStrictEqual([]); }); it('does not refetch metadata for the enriched token, only the native asset', () => { - expect(askedAbout(result.assetBatches)).toStrictEqual( + expect(askedAbout(result.mocks.tokenAPI.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); }); From 1a942e6b34c96c64779fc2e9e7804c9fb29f5c22 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 28 Sep 2026 13:59:19 +0100 Subject: [PATCH 8/8] Combine the single-asset causal-chain tests One `it` per causal chain: the held-but-unpriced asset is queued, fetched and priced in one test (pipeline and controller level), and the already-priced token's not-queued/not-invoked/not-overwritten checks merge into one. The lane describes keep their per-mechanism `it`s, where each covers the full two-asset set. --- ...roller.ws-price-updates.integration.test.ts | 18 ++++++------------ ...teSources.price-updates.integration.test.ts | 13 ++----------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts index c3852ef4528..a58e07d4c47 100644 --- a/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -282,19 +282,16 @@ describe('AssetsController: websocket price updates', () => { }); }); - it('prices the held-but-unpriced asset from the captured Price API', () => { + it('prices the held-but-unpriced asset from the captured Price API in the same pass', () => { + expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), + ); expect(PRICES.lookUp(result.state, ETH_ASSET_ID)).toMatchObject({ assetPriceType: 'fungible', price: ETH_SPOT_PRICE, usdPrice: ETH_SPOT_PRICE, }); }); - - it('invoked the Price API for the held-but-unpriced asset', () => { - expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( - new Set([ETH_ASSET_ID]), - ); - }); }); describe('already-priced token: USDC in state with balance, metadata and price', () => { @@ -313,16 +310,13 @@ describe('AssetsController: websocket price updates', () => { ).toStrictEqual({ amount: USDC_WS_AMOUNT }); }); - it('keeps the seeded price untouched', () => { + it('does not re-price the already-priced token', () => { + expect(result.mocks.priceAPI.priceBatches).toStrictEqual([]); expect( PRICES.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), ).toStrictEqual(SEEDED_USDC_PRICE); }); - it('did not invoke the Price API for the priced token', () => { - expect(result.mocks.priceAPI.priceBatches).toStrictEqual([]); - }); - it('did not refetch metadata for the enriched token', () => { expect(askedAbout(result.mocks.tokenAPI.assetBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), diff --git a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts index 1190bbe1a43..a37db5c46d2 100644 --- a/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -373,20 +373,14 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { ).toBeUndefined(); }); - it('queues the held-but-unpriced asset for a price update', () => { + it('prices the held-but-unpriced asset in the same pass', () => { const queued = result.request.assetsForPriceUpdate?.map((id) => id.toLowerCase()) ?? []; expect(queued).toStrictEqual([ETH_ASSET_ID]); - }); - - it('invokes the Price API for the held-but-unpriced asset', () => { expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( new Set([ETH_ASSET_ID]), ); - }); - - it('prices the held asset from the captured spot price', () => { expect(PRICES.lookUp(result.response, ETH_ASSET_ID)).toMatchObject({ assetPriceType: 'fungible', price: ETH_SPOT_PRICE, @@ -417,11 +411,8 @@ describe('websocket update pipeline: prices for surfaced holdings', () => { ).toBeUndefined(); }); - it('does not queue the priced token for another price update', () => { + it('does not re-price the already-priced token', () => { expect(result.request.assetsForPriceUpdate ?? []).toStrictEqual([]); - }); - - it('does not invoke the Price API', () => { expect(result.mocks.priceAPI.priceBatches).toStrictEqual([]); });