diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 8ed6db23720..05601020f42 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -126,6 +126,8 @@ import { } from './migrations/healAssetsInfoMetadata.js'; import { buildFastFetchSources, + buildUpdateSources, + buildWsUpdateSources, executeAssetsPipeline, } from './pipeline/index.js'; import type { @@ -4225,38 +4227,36 @@ export class AssetsController extends BaseController< fn: async (parentContext) => { const updateStart = performance.now(); const pipelineRequest = this.#getUpdatePipelineRequest(request); - const shouldGraduateCustomAssets = - sourceId === 'AccountsApiDataSource' || - sourceId === 'AccountActivityDataSource'; - const shouldFilterOccurrences = - sourceId === 'AccountActivityDataSource' && - this.#isBasicFunctionality(); - - const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource'; - const enrichmentSources: AssetsDataSource[] = [ - ...(shouldGraduateCustomAssets - ? [this.#customAssetGraduationMiddleware] - : []), - ...(shouldFilterOccurrences - ? [ + const isBasicFunctionality = this.#isBasicFunctionality(); + const isAccountsApiUpdate = sourceId === 'AccountsApiDataSource'; + 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, }, - ] - : []), - ...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []), - this.#detectionMiddleware, - ]; - if (this.#isBasicFunctionality()) { - enrichmentSources.push( - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ); - } + { isBasicFunctionality, includeCustomAssetGraduation: true }, + ) + : 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, @@ -4297,32 +4297,35 @@ 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 }, + ) + : 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/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..a58e07d4c47 --- /dev/null +++ b/packages/assets-controller/src/AssetsController.ws-price-updates.integration.test.ts @@ -0,0 +1,326 @@ +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'; +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, + 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'; +import { + buildEthAndUsdcBalanceUpdatedEvent, + buildEthBalanceUpdatedEvent, + buildUsdcBalanceUpdatedEvent, + ETH_WS_AMOUNT, + USDC_WS_AMOUNT, +} 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; + mocks: { + priceAPI: { priceBatches: string[][] }; + tokenAPI: { assetBatches: string[][] }; + }; +}; + +/** + * 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; +}) => Promise; + +async function withController( + { + state, + queryApiClient = createTestApiClient(), + remoteFeatureFlags, + }: { + state: Partial; + queryApiClient?: ApiPlatformClient; + remoteFeatureFlags?: FeatureFlags; + }, + fn: WithControllerCallback, +): Promise { + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger: MockRootMessenger): void => + registerWsControllerActions(messenger, { remoteFeatureFlags }), + }); + + 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), 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. + * @param options.remoteFeatureFlags - Flags to boot with + * (`assetsAccountsApiV6: true` selects the v6 update lane). + * @returns The event result. + */ +async function runWsEvent({ + state, + event, + remoteFeatureFlags, +}: { + state: Partial; + event: BalanceUpdatedEventPayload; + remoteFeatureFlags?: FeatureFlags; +}): Promise { + cleanAll(); + const { accountsSupportedNetworks, assets, prices } = mockWsApis(); + const queryApiClient = createTestApiClient(); + + const controllerState = await withController( + { state, queryApiClient, remoteFeatureFlags }, + async ({ controller, messenger }) => { + // Wait for boot so the assertions cannot pass on a half-started wallet. + await waitFor(() => + expect(accountsSupportedNetworks.isDone()).toBe(true), + ); + + messenger.publish('AccountActivityService:balanceUpdated', event); + + await waitFor(() => { + 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, + mocks: { + priceAPI: { priceBatches: prices.requestedBatches }, + tokenAPI: { assetBatches: assets.requestedBatches }, + }, + }; +} + +describe('AssetsController: websocket price updates', () => { + afterEach(() => { + cleanAll(); + }); + + 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.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(); + }, + ); + + 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', () => { + 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', () => { + 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.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.mocks.tokenAPI.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: WsEventResult; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildEthHeldUnpricedState(), + event: buildEthBalanceUpdatedEvent(), + }); + }); + + it('persists the refreshed websocket balance', () => { + 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 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, + }); + }); + }); + + describe('already-priced token: USDC in state with balance, metadata and price', () => { + let result: WsEventResult; + + beforeAll(async () => { + result = await runWsEvent({ + state: buildUsdcHeldAndPricedState(), + event: buildUsdcBalanceUpdatedEvent(), + }); + }); + + it('persists the refreshed websocket balance', () => { + expect( + BALANCES.lookUp(result.state, USDC_ASSET_ID_LOWERCASE), + ).toStrictEqual({ amount: USDC_WS_AMOUNT }); + }); + + 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 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/__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..34cf482ca96 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/api-responses/index.ts @@ -0,0 +1,202 @@ +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`. + * + * @returns The nock scope. + */ +function mockChainIdNetwork(): nock.Scope { + return nock('https://chainid.network') + .persist() + .get('/chains.json') + .reply(200, []); +} + +/** + * Intercept `GET {ACCOUNTS}/v2/supportedNetworks`. + * + * @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`. + * + * @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`. + * + * @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 with the API's lower-case `assetId` echo. + * + * @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`. + * + * @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 + * with the API's lower-case keying. + * + * @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 batched Tokens and Price interceptors record what they were + * asked about. + * + * @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..f58ec32e54e --- /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: 2688.8502994319642, + marketCap: 328513379213, + allTimeHigh: 4946.05, + allTimeLow: 0.432979, + totalVolume: 15835506586, + high1d: 2739.52, + low1d: 2653.81, + circulatingSupply: 122078905.3531288, + dilutedMarketCap: 328513379213, + marketCapPercentChange1d: 0.29756, + priceChange1d: 6.77, + pricePercentChange1h: -0.8412, + pricePercentChange1d: 0.23, + pricePercentChange7d: 7.8196, + pricePercentChange14d: 7.4282, + pricePercentChange30d: 9.7921, + pricePercentChange200d: 34.5566, + pricePercentChange1y: -32.2667, + liquidity: 212545673.00375578, + }, + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + price: 0.999966, + marketCap: 75282922672, + allTimeHigh: 1.043, + allTimeLow: 0.877647, + totalVolume: 20839152939, + high1d: 1.001, + low1d: 0.99942, + circulatingSupply: 75286229145.11284, + dilutedMarketCap: 75353804428, + marketCapPercentChange1d: 0.12371, + priceChange1d: 0.00015413, + pricePercentChange1h: 0.0034, + pricePercentChange1d: 0.01079, + pricePercentChange7d: 0.0408, + pricePercentChange14d: 0.0114, + pricePercentChange30d: 0.0005, + pricePercentChange200d: 0.0226, + pricePercentChange1y: 0.0223, + liquidity: 1781894605.7637963, + }, +} 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..76079a88ec6 --- /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/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, + }, + '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, + }, +} 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..559e79bb664 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/captureWsApiResponses.ts @@ -0,0 +1,141 @@ +import { API_URLS } from '@metamask/core-backend'; +import { writeFile } from '@metamask/utils/node'; + +/** + * 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`; + +/** The assets the example websocket event reports balances for, checksummed. */ +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`, + ); +} + +/** Base URLs for the services the capture script reads. */ +const SERVICE_BASE_URLS: Record<'accounts' | 'tokens' | 'price', string> = { + accounts: API_URLS.ACCOUNTS, + tokens: API_URLS.TOKENS, + price: API_URLS.PRICES, +}; + +/** + * Capture a service's `/v2/supportedNetworks` manifest. + * + * @param service - The service whose manifest to capture. + */ +async function captureSupportedNetworks( + service: 'accounts' | 'tokens' | 'price', +): Promise { + const networks = await fetchJson( + `${SERVICE_BASE_URLS[service]}/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. + 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..5b3a094be53 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/messenger.ts @@ -0,0 +1,144 @@ +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'; + +/** + * The RPC provider, stubbing the probes `RpcDataSource` makes. + * + * @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 network handlers the websocket price-update fixtures need. + * + * @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, + ); + + // Only `provider` is read; the configuration keeps the client shape honest. + 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 wallet, + * plus 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..1e801fa919b --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wallet.ts @@ -0,0 +1,36 @@ +/** Ethereum Mainnet. */ +export const MAINNET_CHAIN_ID = 'eip155:1' as const; + +/** The wallet address, lower case as the websocket reports it. */ +export const WS_WALLET_ADDRESS = '0x742d35cc6634c0532925a3b844bc454e4438f44e'; + +/** `InternalAccount.id` (a UUID), not the address. */ +export const WS_ACCOUNT_ID = 'c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f'; + +/** Native ETH. */ +export const ETH_ASSET_ID = `${MAINNET_CHAIN_ID}/slip44:60` as const; + +/** USDC, lower case as the websocket reports it. */ +export const USDC_ADDRESS_LOWERCASE = + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + +/** Checksummed USDC, as state keys assets. */ +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; + +/** 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; +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..63b6489de89 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsEvents.ts @@ -0,0 +1,96 @@ +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, 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, as AADS derives them from the hex quantities. */ +export const ETH_WS_AMOUNT = '2'; +export const USDC_WS_AMOUNT = '10'; + +/** + * 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. + * @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 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. + * @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: [], + }, + ], + }; +} + +/** + * 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 new file mode 100644 index 00000000000..6ea29995cd9 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/ws-price-updates/wsWallet.ts @@ -0,0 +1,126 @@ +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'; + +/** + * Build the wallet's `InternalAccount`. + * + * @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, + }; +} + +/** + * A fresh wallet's state, empty of balances, metadata, prices and custom + * assets. + * + * @param overrides - State slices to override. + * @returns The starting state. + */ +export function buildEmptyAssetsState( + overrides?: Partial, +): AssetsControllerState { + 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]; +} + +/** 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 new file mode 100644 index 00000000000..4bf5248c390 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildUpdateSources.test.ts @@ -0,0 +1,92 @@ +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/buildWsUpdateSources.price-updates.integration.test.ts b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts new file mode 100644 index 00000000000..a37db5c46d2 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.price-updates.integration.test.ts @@ -0,0 +1,431 @@ +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, + 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'; +import { + buildEthAndUsdcBalanceUpdatedEvent, + buildEthBalanceUpdatedEvent, + buildUsdcBalanceUpdatedEvent, + ETH_WS_AMOUNT, + USDC_WS_AMOUNT, +} from '../__fixtures__/ws-price-updates/wsEvents.js'; +import { + buildEmptyAssetsState, + buildEthHeldUnpricedState, + buildUsdcHeldAndPricedState, + 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, + AssetsControllerState, + AssetsDataSource, + Context, + DataRequest, + DataResponse, +} from '../types.js'; +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; + mocks: { + priceAPI: { priceBatches: string[][] }; + tokenAPI: { assetBatches: string[][] }; + rpc: { 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' }, +]; + +/** + * 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. + * + * @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). + * @returns The pass result. + */ +async function runWsUpdatePass({ + state, + event, + lane = 'v5', +}: { + state: ReturnType; + event: BalanceUpdatedEventPayload; + lane?: UpdateLane; +}): Promise { + cleanAll(); + const { assets, prices } = mockWsApis(); + + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger) => { + registerAccountMocks(messenger, { accounts: [buildWsAccount()] }); + registerMainnetNetwork(messenger); + }, + }); + + const queryApiClient = createTestApiClient(); + const { middleware: rpcFallbackMiddleware, requests: rpcFallbackRequests } = + createRecordingRpcFallback(); + + const tokenDataSource = new TokenDataSource(assetsControllerMessenger, { + queryApiClient, + getNativeAssetIds: (): string[] => [ETH_ASSET_ID], + getAssetType: (assetId): 'native' | 'erc20' => + parseCaipAssetType(assetId).assetNamespace === 'erc20' + ? 'erc20' + : 'native', + getAssetsState: (): AssetsControllerState => state, + }); + + const priceDataSource = new PriceDataSource({ + queryApiClient, + getSelectedCurrency: (): 'usd' => 'usd', + getAssetsState: (): AssetsControllerState => state, + }); + + const sources = buildWsUpdateSources( + { + customAssetGraduationMiddleware: new CustomAssetGraduationMiddleware({ + getSelectedAccountId: (): AccountId => WS_ACCOUNT_ID, + removeCustomAsset: (): void => { + throw new Error( + 'Websocket pass should not graduate custom assets away!', + ); + }, + getAssetsState: (): AssetsControllerState => state, + }), + rpcFallbackMiddleware, + detectionMiddleware: new DetectionMiddleware({ + getAssetsState: (): AssetsControllerState => state, + }), + tokenDataSource, + priceDataSource, + }, + { + isBasicFunctionality: true, + includeCustomAssetGraduation: lane === 'v5', + }, + ); + + 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, + }); + 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, + mocks: { + priceAPI: { priceBatches: prices.requestedBatches }, + tokenAPI: { assetBatches: assets.requestedBatches }, + rpc: { rpcFallbackRequests }, + }, + }; +} + +describe('websocket update pipeline: prices for surfaced holdings', () => { + afterEach(() => { + cleanAll(); + }); + + 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.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('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 }); + }); + + 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('prices both holdings from the captured spot prices', () => { + expect(PRICES.lookUp(result.response, ETH_ASSET_ID)).toMatchObject({ + assetPriceType: 'fungible', + price: ETH_SPOT_PRICE, + usdPrice: ETH_SPOT_PRICE, + }); + expect( + 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', () => { + 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 surfaced holdings in the same pass', () => { + 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.mocks.tokenAPI.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID, USDC_ASSET_ID_LOWERCASE]), + ); + }); + }, + ); + + describe('v6 lane: the RPC fallback on the balance pass', () => { + let result: WsUpdatePassResult; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEmptyAssetsState(), + event: buildEthAndUsdcBalanceUpdatedEvent(), + lane: 'v6', + }); + }); + + it('runs the RPC fallback once on the balance pass', () => { + // A response without errors leaves the fallback a passthrough. + expect(result.mocks.rpc.rpcFallbackRequests).toHaveLength(1); + expect(result.mocks.rpc.rpcFallbackRequests[0]?.dataTypes).toStrictEqual( + expect.arrayContaining(['balance']), + ); + }); + }); + + describe('held-but-unpriced native asset: ETH in state without a price', () => { + let result: WsUpdatePassResult; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildEthHeldUnpricedState(), + event: buildEthBalanceUpdatedEvent(), + }); + }); + + it('does not re-detect the holding', () => { + expect( + DETECTED_ASSETS.lookUp(result.response, ETH_ASSET_ID), + ).toBeUndefined(); + }); + + 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]); + expect(askedAbout(result.mocks.priceAPI.priceBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), + ); + 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', () => { + 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: WsUpdatePassResult; + + beforeAll(async () => { + result = await runWsUpdatePass({ + state: buildUsdcHeldAndPricedState(), + event: buildUsdcBalanceUpdatedEvent(), + }); + }); + + it('does not re-detect the token', () => { + expect( + DETECTED_ASSETS.lookUp(result.response, USDC_ASSET_ID_LOWERCASE), + ).toBeUndefined(); + }); + + it('does not re-price the already-priced token', () => { + expect(result.request.assetsForPriceUpdate ?? []).toStrictEqual([]); + expect(result.mocks.priceAPI.priceBatches).toStrictEqual([]); + }); + + it('does not refetch metadata for the enriched token, only the native asset', () => { + expect(askedAbout(result.mocks.tokenAPI.assetBatches)).toStrictEqual( + new Set([ETH_ASSET_ID]), + ); + }); + + it('carries the websocket balance refresh', () => { + expect( + BALANCES.lookUp(result.response, 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..77cbf6b0ac6 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.test.ts @@ -0,0 +1,96 @@ +import type { AssetsDataSource } 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, + }; +} + +function stubTokenSource(name: string): WsUpdateSources['tokenDataSource'] { + return { + ...stubSource(name), + occurrenceFilterMiddleware: async (ctx) => ctx, + }; +} + +function buildSources(): WsUpdateSources { + return { + customAssetGraduationMiddleware: stubSource( + 'CustomAssetGraduationMiddleware', + ), + rpcFallbackMiddleware: stubSource('RpcFallbackMiddleware'), + detectionMiddleware: stubSource('DetectionMiddleware'), + tokenDataSource: stubTokenSource('TokenDataSource'), + priceDataSource: stubSource('PriceDataSource'), + }; +} + +describe('buildWsUpdateSources', () => { + it.each([ + { + title: 'v5 lane: graduation → occurrence filter → detection → enrichment', + includeCustomAssetGraduation: true, + isBasicFunctionality: true, + expected: [ + 'CustomAssetGraduationMiddleware', + 'OccurrenceFloorFilter', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, + { + title: 'v5 lane: runs only the graduation and detection', + includeCustomAssetGraduation: true, + isBasicFunctionality: false, + expected: ['CustomAssetGraduationMiddleware', 'DetectionMiddleware'], + }, + { + 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); + }, + ); + + 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, includeCustomAssetGraduation: 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..4e620ff2c2e --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildWsUpdateSources.ts @@ -0,0 +1,60 @@ +import { createParallelMiddleware } from '../middlewares/ParallelMiddleware.js'; +import type { AssetsDataSource, Middleware } from '../types.js'; + +/** The sources the websocket update lane composes. */ +export type WsUpdateSources = { + customAssetGraduationMiddleware: AssetsDataSource; + rpcFallbackMiddleware: AssetsDataSource; + detectionMiddleware: AssetsDataSource; + /** Also supplies the occurrence-filter middleware used for websocket airdrops. */ + tokenDataSource: AssetsDataSource & { + readonly occurrenceFilterMiddleware: Middleware; + }; + priceDataSource: AssetsDataSource; +}; + +/** + * Compose the websocket update lane. + * + * @param sources - The sources to place into the lane. + * @param options - Lane options. + * @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( + sources: WsUpdateSources, + options: { + isBasicFunctionality: boolean; + includeCustomAssetGraduation: boolean; + }, +): AssetsDataSource[] { + const { + customAssetGraduationMiddleware, + rpcFallbackMiddleware, + detectionMiddleware, + tokenDataSource, + priceDataSource, + } = sources; + + const occurrenceFloorFilter: AssetsDataSource = { + getName: (): string => 'OccurrenceFloorFilter', + assetsMiddleware: tokenDataSource.occurrenceFilterMiddleware, + }; + + return [ + ...(options.includeCustomAssetGraduation + ? [customAssetGraduationMiddleware] + : []), + ...(options.isBasicFunctionality ? [occurrenceFloorFilter] : []), + ...(!options.includeCustomAssetGraduation && options.isBasicFunctionality + ? [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 5b128bd82b5..05367f7e3cd 100644 --- a/packages/assets-controller/src/pipeline/index.ts +++ b/packages/assets-controller/src/pipeline/index.ts @@ -1,4 +1,8 @@ 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'; export type { ExecuteAssetsPipelineParams } from './executeAssetsPipeline.js';