diff --git a/README.md b/README.md index 0a4278a3847..7b05731b953 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,7 @@ linkStyle default opacity:0.5 assets_controller --> remote_feature_flag_controller; assets_controller --> transaction_controller; assets_controller --> utils; + assets_controller --> eth_json_rpc_provider; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8e1bbf0f5bd..e3070e7e759 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AccountsApiDataSource` now reads Accounts API `/v2/supportedNetworks` as CAIP-2 `fullSupport` and `partialSupport` string arrays, matching the current API payload - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) +### Fixed + +- Remove spam assets from balances, metadata, and detected assets regardless of asset ID casing, so filtered spam tokens no longer appear in state ([#10172](https://github.com/MetaMask/core/pull/10172)) + ## [16.0.0] ### Changed diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index f1379d9393b..c048f14e888 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -84,6 +84,7 @@ }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", + "@metamask/eth-json-rpc-provider": "^7.0.0", "@types/jest": "^30.0.0", "@types/lodash-es": "^4.17.12", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts new file mode 100644 index 00000000000..66048972372 --- /dev/null +++ b/packages/assets-controller/src/AssetsController.bsc-spam-token-filtering.integration.test.ts @@ -0,0 +1,170 @@ +import type { ApiPlatformClient } from '@metamask/core-backend'; +import { cleanAll } from 'nock'; + +import { mockBscSpamApis } from './__fixtures__/bsc-spam-token/api-responses/index.js'; +import { + buildBscSpamAccount, + buildEmptyAssetsState, + getIgnoringCase, +} from './__fixtures__/bsc-spam-token/bscSpamWallet.js'; +import { registerBscSpamControllerActions } from './__fixtures__/bsc-spam-token/messenger.js'; +import { + BNB_ASSET_ID, + BSC_CHAIN_ID, + BSC_SPAM_ACCOUNT_ID, + CDOGE_ASSET_ID_CHECKSUM, + CDOGE_ASSET_ID_LOWERCASE, +} from './__fixtures__/bsc-spam-token/wallet.js'; +import { createMockMessengers } from './__fixtures__/MockAssetControllerMessenger.js'; +import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js'; +import { createTestApiClient } from './__fixtures__/mockTokenApi.js'; +import { waitFor, waitUntilStable } from './__fixtures__/test-utils.js'; +import { AssetsController } from './AssetsController.js'; +import type { AssetsControllerState } from './AssetsController.js'; + +/** + * Integration coverage for `AssetsController` against the BNB Chain wallet + * from the `$$$DOGECHAIN` (`CDOGE`) spam-token report. + * + * Boots the real controller, answers the same captured APIs as + * `buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts`, and + * asserts CDOGE never lands in persisted state. + * + * Integration Expectation - CDOGE is correctly filtered out of controller state. + */ + +type StateSurface = { + surface: string; + lookUp: (state: AssetsControllerState, assetId: string) => unknown; +}; + +const BALANCES: StateSurface = { + surface: 'balances', + lookUp: (state, assetId) => + getIgnoringCase(state.assetsBalance[BSC_SPAM_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), +}; + +type WithControllerCallback = (args: { + controller: AssetsController; + messenger: MockRootMessenger; +}) => Promise; + +async function withController( + { + state = buildEmptyAssetsState(), + queryApiClient = createTestApiClient(), + }: { + state?: Partial; + queryApiClient?: ApiPlatformClient; + }, + fn: WithControllerCallback, +): Promise { + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: registerBscSpamControllerActions, + }); + + const controller = new AssetsController({ + messenger: assetsControllerMessenger, + state, + queryApiClient, + isBasicFunctionality: (): boolean => true, + }); + + try { + return await fn({ controller, messenger: rootMessenger }); + } finally { + controller.destroy(); + queryApiClient.clear(); + } +} + +async function fetchWallet( + state: Partial = buildEmptyAssetsState(), +): Promise { + const { accountsSupportedNetworks } = mockBscSpamApis(); + + return await withController({ state }, async ({ controller }) => { + // wait for `AccountsApiDataSource` to ask `/v2/supportedNetworks` to indicate the fast-lane is ready + await waitFor(() => expect(accountsSupportedNetworks.isDone()).toBe(true)); + + await controller.getAssets([buildBscSpamAccount()], { + chainIds: [BSC_CHAIN_ID], + forceUpdate: true, + }); + + // `getAssets` awaits the fast lane only; the slow lane is fire-and-forget + // and can still be writing. Let state settle so the assertions about CDOGE + // being absent cannot pass just because nothing has landed yet. + await waitUntilStable(() => controller.state); + + return controller.state; + }); +} + +const WALLET_PASSES = [ + { + pass: 'first pass over a fresh wallet', + run: (): Promise => fetchWallet(), + }, + { + pass: 'second pass over the wallet the first pass left behind', + run: async (): Promise => { + const firstPass = await fetchWallet(); + cleanAll(); + + const secondPass = await fetchWallet( + buildEmptyAssetsState({ + assetsBalance: firstPass.assetsBalance, + assetsInfo: firstPass.assetsInfo, + assetsPrice: firstPass.assetsPrice, + }), + ); + return secondPass; + }, + }, +]; + +describe('AssetsController: BNB Chain spam token (CDOGE)', () => { + afterEach(() => { + cleanAll(); + }); + + describe.each(WALLET_PASSES)('$pass', ({ run }) => { + let state: AssetsControllerState; + + beforeAll(async () => { + state = await run(); + }); + + it.each([BALANCES, METADATA])( + '$surface - filter out the spam token', + ({ lookUp }) => { + expect(lookUp(state, CDOGE_ASSET_ID_LOWERCASE)).toBeUndefined(); + expect(lookUp(state, CDOGE_ASSET_ID_CHECKSUM)).toBeUndefined(); + }, + ); + + it.each([BALANCES, METADATA])( + '$surface - keeps the native BNB asset despite low occurrences', + ({ lookUp }) => { + expect(lookUp(state, BNB_ASSET_ID)).toBeDefined(); + }, + ); + + // Same gap as the pipeline suite: prices are not occurrence-filtered. + // Unlock cleanup eventually strips them; this flags the hole. + it.failing('keeps the spam token out of prices', () => { + expect(PRICES.lookUp(state, CDOGE_ASSET_ID_LOWERCASE)).toBeUndefined(); + }); + }); +}); diff --git a/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts b/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts index 7d5cea67a8a..6e50404c13f 100644 --- a/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts +++ b/packages/assets-controller/src/AssetsController.spam-cleanup.integration.test.ts @@ -3,8 +3,8 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { - createMockAssetControllerMessenger, createMockInternalAccount, + createMockMessengers, registerAssetsControllerActions, } from './__fixtures__/MockAssetControllerMessenger.js'; import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js'; @@ -67,9 +67,6 @@ async function withController( }: WithControllerOptions, fn: WithControllerCallback, ): Promise { - const { rootMessenger, assetsControllerMessenger } = - createMockAssetControllerMessenger({ delegateGetState: false }); - // Every account the wallet tracks balances for: the synthetic catch-all // account plus the real custom-asset owner. const accounts = [ @@ -87,11 +84,14 @@ async function withController( ), ]; - registerAssetsControllerActions(rootMessenger, { - accounts, - enabledNetworkMap: { eip155: { '1': true, '10': true, '8453': true } }, - nativeAssetIdentifiers: SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS, - remoteFeatureFlags, + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger) => + registerAssetsControllerActions(messenger, { + accounts, + enabledNetworkMap: { eip155: { '1': true, '10': true, '8453': true } }, + nativeAssetIdentifiers: SCAM_WALLET_NATIVE_ASSET_IDENTIFIERS, + remoteFeatureFlags, + }), }); const controller = new AssetsController({ diff --git a/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts b/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts index 9135809bdaa..12a51bd194b 100644 --- a/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts +++ b/packages/assets-controller/src/AssetsController.spam-cleanup.test.ts @@ -3,8 +3,8 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { - createMockAssetControllerMessenger, createMockInternalAccount, + createMockMessengers, registerAssetsControllerActions, } from './__fixtures__/MockAssetControllerMessenger.js'; import type { MockRootMessenger } from './__fixtures__/MockAssetControllerMessenger.js'; @@ -89,8 +89,6 @@ async function withController( }: WithControllerOptions, fn: WithControllerCallback, ): Promise { - const { rootMessenger, assetsControllerMessenger } = - createMockAssetControllerMessenger({ delegateGetState: false }); const accounts = [ createMockInternalAccount({ id: ACCOUNT_ONE_ID, @@ -104,11 +102,14 @@ async function withController( }), ]; - registerAssetsControllerActions(rootMessenger, { - accounts, - enabledNetworkMap: { eip155: { '1': true, '10': true } }, - nativeAssetIdentifiers: { 'eip155:1': MAINNET_NATIVE }, - remoteFeatureFlags, + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger) => + registerAssetsControllerActions(messenger, { + accounts, + enabledNetworkMap: { eip155: { '1': true, '10': true } }, + nativeAssetIdentifiers: { 'eip155:1': MAINNET_NATIVE }, + remoteFeatureFlags, + }), }); const controller = new AssetsController({ diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index e3dffd5630b..e4fd555a1db 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -110,7 +110,6 @@ import { buildDefaultAssetsInfo, getDefaultAssetMetadata, } from './defaults.js'; -import { AssetsDataSourceError } from './errors.js'; import { projectLogger, createModuleLogger } from './logger.js'; import { CustomAssetGraduationMiddleware } from './middlewares/CustomAssetGraduationMiddleware.js'; import { DetectionMiddleware } from './middlewares/DetectionMiddleware.js'; @@ -125,6 +124,10 @@ import { isUnlockCleanupEnabled, tempHealAssetsInfoMetadata, } from './migrations/healAssetsInfoMetadata.js'; +import { + buildFastFetchSources, + executeAssetsPipeline, +} from './pipeline/index.js'; import type { AccountId, AssetPreferences, @@ -142,10 +145,6 @@ import type { DataType, DataRequest, DataResponse, - FetchContext, - FetchNextFunction, - NextFunction, - Middleware, SubscriptionResponse, Asset, } from './types.js'; @@ -226,8 +225,6 @@ const TRACE_FULL_FETCH = 'AssetsFullFetch'; /** Parent span that nests per-source timings; dashboard charts {@link TRACE_FULL_FETCH}. */ const TRACE_FETCH_PIPELINE = 'AssetsFetchPipeline'; const TRACE_BACKGROUND_FETCH = 'AssetsBackgroundFetch'; -const TRACE_DATA_SOURCE_TIMING = 'AssetsDataSourceTiming'; -const TRACE_DATA_SOURCE_ERROR = 'AssetsDataSourceError'; const TRACE_UPDATE_PIPELINE = 'AssetsUpdatePipeline'; /** Parent span that nests update enrichment; dashboard charts {@link TRACE_UPDATE_PIPELINE}. */ const TRACE_UPDATE_PARENT = 'AssetsUpdateEnrichment'; @@ -1453,6 +1450,9 @@ export class AssetsController extends BaseController< * Execute middlewares with request/response context. * Returns response and exclusive duration per source (sum ≈ wall time). * + * Thin wrapper over {@link executeAssetsPipeline} that supplies the + * controller-owned state accessor and exception reporter. + * * @param params - Middleware execution options. * @param params.sources - Data sources or middlewares with getName() and assetsMiddleware. * @param params.request - The data request. @@ -1472,132 +1472,11 @@ export class AssetsController extends BaseController< response: DataResponse; durationByDataSource: Record; }> { - const { - sources, - request, - initialResponse = {}, - parentContext, - trace, - } = params; - const names = sources.map((source) => source.getName()); - const middlewares = sources.map((source) => source.assetsMiddleware); - const inclusive: number[] = []; - const wrapped = middlewares.map( - (middleware, i) => - (async ( - ctx: FetchContext, - next: FetchNextFunction, - ): Promise<{ - request: DataRequest; - response: DataResponse; - getAssetsState: () => AssetsControllerStateInternal; - }> => { - const start = performance.now(); - try { - return await middleware(ctx, next); - } finally { - inclusive[i] = performance.now() - start; - } - }) as Middleware, - ); - - const middlewareErrors: string[] = []; - const chain = wrapped.reduceRight( - (next, middleware, index) => - async ( - ctx, - ): Promise<{ - request: DataRequest; - response: DataResponse; - getAssetsState: () => AssetsControllerStateInternal; - }> => { - try { - return await middleware(ctx, next); - } catch (error) { - const sourceName = names[index] ?? `middleware_${index}`; - middlewareErrors.push(sourceName); - console.error('[AssetsController] Middleware failed:', error); - return next(ctx); - } - }, - async (ctx) => ctx, - ); - - const result = await chain({ - request, - response: initialResponse, + return executeAssetsPipeline({ + ...params, getAssetsState: () => this.state as AssetsControllerStateInternal, + captureException: this.#captureException, }); - - const durationByDataSource: Record = {}; - for (let i = 0; i < inclusive.length; i++) { - const nextInc = i + 1 < inclusive.length ? (inclusive[i + 1] ?? 0) : 0; - const exclusive = Math.max(0, (inclusive[i] ?? 0) - nextInc); - const name = names[i]; - if (name !== undefined) { - durationByDataSource[name] = exclusive; - } - } - if (result.durationByDataSource) { - for (const [key, ms] of Object.entries(result.durationByDataSource)) { - durationByDataSource[key] = ms; - } - } - - // Emit per-source timing as subspans under the parent fetch/update span - // (no-op when `trace` is omitted — unlock/first-init only). - for (const [sourceName, durationMs] of Object.entries( - durationByDataSource, - )) { - emitTrace({ - name: TRACE_DATA_SOURCE_TIMING, - trace, - data: { - source: sourceName, - duration_ms: durationMs, - chain_count: request.chainIds.length, - account_count: request.accountsWithSupportedChains.length, - }, - tags: { - controller: 'AssetsController', - // String tag so Spans widgets can group by `source`. - source: sourceName, - }, - parentContext, - }); - } - - // Failed middlewares: Issues (optional) + perf/Dashboard spans - if (middlewareErrors.length > 0) { - const failedSources = middlewareErrors.join(','); - const assetsError = new AssetsDataSourceError({ - failedSources, - errorCount: middlewareErrors.length, - chainCount: request.chainIds.length, - }); - try { - this.#captureException?.(assetsError); - } catch { - // Never let telemetry throw. - } - emitTrace({ - name: TRACE_DATA_SOURCE_ERROR, - trace, - data: { - failed_sources: failedSources, - error_count: middlewareErrors.length, - chain_count: request.chainIds.length, - }, - tags: { - controller: 'AssetsController', - severity: 'error', - error_type: assetsError.name, - }, - parentContext, - }); - } - - return { response: result.response, durationByDataSource }; } // ============================================================================ @@ -1662,24 +1541,19 @@ export class AssetsController extends BaseController< // Fast/slow pipelines use merge so partial API snapshots cannot wipe // tokens missing from the response (e.g. USDC when only native balance // is returned). Balances present in the response are still refreshed. - const fastSources = this.#isBasicFunctionality() - ? [ - createParallelBalanceMiddleware([ - this.#accountsApiDataSource, - this.#stakedBalanceDataSource, - ]), - // Graduation must run BEFORE the RPC fallback so it only sees - // AccountsApi/Websocket balances. RPC intentionally carries - // custom assets and must never trigger graduation. + const fastSources = buildFastFetchSources( + { + accountsApiDataSource: this.#accountsApiDataSource, + stakedBalanceDataSource: this.#stakedBalanceDataSource, + customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware, - this.#rpcFallbackMiddleware, - this.#detectionMiddleware, - createParallelMiddleware([ - this.#tokenDataSource, - this.#priceDataSource, - ]), - ] - : [this.#stakedBalanceDataSource, this.#detectionMiddleware]; + rpcFallbackMiddleware: this.#rpcFallbackMiddleware, + detectionMiddleware: this.#detectionMiddleware, + tokenDataSource: this.#tokenDataSource, + priceDataSource: this.#priceDataSource, + }, + { isBasicFunctionality: this.#isBasicFunctionality() }, + ); const { response } = await withTrace({ name: TRACE_FETCH_PIPELINE, diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 441043f131f..6794d664e75 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -1,6 +1,7 @@ import { defaultAbiCoder } from '@ethersproject/abi'; import * as ProviderModule from '@ethersproject/providers'; import { clientControllerSelectors } from '@metamask/client-controller'; +import type { KeyringControllerMessenger } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { MOCK_ANY_NAMESPACE, @@ -15,6 +16,7 @@ import type { FeatureFlags } from '@metamask/remote-feature-flag-controller'; import { AssetsControllerMessenger, + AssetsControllerState, getDefaultAssetsControllerState, } from '../AssetsController.js'; import { STAKING_INTERFACE } from '../data-sources/evm-rpc-services/services/StakedBalanceFetcher.js'; @@ -23,32 +25,31 @@ import { STAKING_INTERFACE } from '../data-sources/evm-rpc-services/services/Sta // eslint-disable-next-line @typescript-eslint/no-explicit-any type TestMockType = any; +type GlobalActions = MessengerActions< + AssetsControllerMessenger | KeyringControllerMessenger +>; +type GlobalEvents = MessengerEvents< + AssetsControllerMessenger | KeyringControllerMessenger +>; + export type MockRootMessenger = Messenger< MockAnyNamespace, - MessengerActions, - MessengerEvents + GlobalActions, + GlobalEvents >; const MAINNET_CHAIN_ID_HEX = '0x1'; const MOCK_CHAIN_ID_CAIP = 'eip155:1'; -type MessengerWithPublish = { - publish: (event: string, ...args: unknown[]) => void; - registerActionHandler: ( - action: string, - handler: (...args: unknown[]) => unknown, - ) => void; -}; - /** - * Register a mock `KeyringController:isUnlocked` handler. Updates unlock state - * before `:unlock` / `:lock` events are delivered, matching KeyringController. + * Register a mock `KeyringController:isUnlocked` handler backed by the + * `:unlock` / `:lock` events. * * @param messenger - The root messenger to register handlers on. * @param initialUnlocked - Initial unlock state. */ export function registerKeyringUnlockMock( - messenger: MessengerWithPublish, + messenger: MockRootMessenger, initialUnlocked = false, ): void { let isKeyringUnlocked = initialUnlocked; @@ -57,29 +58,23 @@ export function registerKeyringUnlockMock( () => isKeyringUnlocked, ); - const originalPublish = messenger.publish.bind(messenger); - messenger.publish = (event: string, ...args: unknown[]): void => { - if (event === 'KeyringController:unlock') { - isKeyringUnlocked = true; - } else if (event === 'KeyringController:lock') { - isKeyringUnlocked = false; - } - return originalPublish(event, ...args); - }; + messenger.subscribe('KeyringController:unlock', () => { + isKeyringUnlocked = true; + }); + messenger.subscribe('KeyringController:lock', () => { + isKeyringUnlocked = false; + }); } -export function createMockAssetControllerMessenger(options?: { - delegateGetState?: boolean; -}): { - rootMessenger: MockRootMessenger; - assetsControllerMessenger: AssetsControllerMessenger; -} { - const { delegateGetState = true } = options ?? {}; - - const rootMessenger: MockRootMessenger = new Messenger({ +export function createMockRootMessenger(): MockRootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); +} +export function createMockAssetsControllerMessenger( + rootMessenger: MockRootMessenger, +): AssetsControllerMessenger { const assetsControllerMessenger: AssetsControllerMessenger = new Messenger({ namespace: 'AssetsController', parent: rootMessenger, @@ -94,7 +89,6 @@ export function createMockAssetControllerMessenger(options?: { 'AccountTreeController:isInitialized', 'ClientController:getState', 'KeyringController:isUnlocked', - ...(delegateGetState ? ['AssetsController:getState' as const] : []), // RpcDataSource 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', 'NetworkController:getState', @@ -141,10 +135,46 @@ export function createMockAssetControllerMessenger(options?: { ], }); - return { - rootMessenger, - assetsControllerMessenger, - }; + return assetsControllerMessenger; +} + +export function createMockMessengers(options?: { + registerCustomRootActions?: (rootMessenger: MockRootMessenger) => void; +}): { + rootMessenger: MockRootMessenger; + assetsControllerMessenger: AssetsControllerMessenger; +} { + const { registerCustomRootActions } = options ?? {}; + + const rootMessenger = createMockRootMessenger(); + + registerCustomRootActions?.(rootMessenger); + + const assetsControllerMessenger = + createMockAssetsControllerMessenger(rootMessenger); + + return { rootMessenger, assetsControllerMessenger }; +} + +/** + * Register a mock `AssetsController:getState` handler. + * + * The action belongs to the `AssetsController` namespace, so it is registered + * on the controller's own messenger rather than delegated from the root. Only + * use this in tests that exercise a data source in isolation; a real + * `AssetsController` registers this handler itself. + * + * @param assetsControllerMessenger - The scoped AssetsController messenger. + * @param getState - Returns the state to serve. Defaults to the default state. + */ +export function registerAssetsControllerStateMock( + assetsControllerMessenger: AssetsControllerMessenger, + getState: () => AssetsControllerState = getDefaultAssetsControllerState, +): void { + assetsControllerMessenger.registerActionHandler( + 'AssetsController:getState', + getState, + ); } export function registerStakedMessengerActions( @@ -213,10 +243,6 @@ export function registerRpcDataSourceActions( }) as TestMockType, ); - rootMessenger.registerActionHandler('AssetsController:getState', () => - getDefaultAssetsControllerState(), - ); - rootMessenger.registerActionHandler( 'NetworkEnablementController:getState', () => ({ @@ -298,18 +324,26 @@ export function createMockNetworkState( } as unknown as NetworkState; } -export type RegisterAssetsControllerActionsOptions = { - accounts?: InternalAccount[]; - selectedAccount?: InternalAccount; - isAccountTreeInitialized?: boolean; +export type RegisterWalletLifecycleMocksOptions = { isKeyringUnlocked?: boolean; - enabledNetworkMap?: Record>; - nativeAssetIdentifiers?: Record; - networkState?: NetworkState; - remoteFeatureFlags?: FeatureFlags; + isAccountTreeInitialized?: boolean; clientControllerState?: { isUiOpen: boolean }; + remoteFeatureFlags?: FeatureFlags; +}; + +export type RegisterAccountMocksOptions = { + accounts?: InternalAccount[]; + selectedAccount?: InternalAccount; }; +export type RegisterAssetsControllerActionsOptions = + RegisterWalletLifecycleMocksOptions & + RegisterAccountMocksOptions & { + enabledNetworkMap?: Record>; + nativeAssetIdentifiers?: Record; + networkState?: NetworkState; + }; + /** * Build a mock internal account with sensible defaults. * @@ -339,15 +373,65 @@ export function createMockInternalAccount( } /** - * Register mock action handlers for external controller actions that - * AssetsController and its data sources call. + * Register the wallet-wide lifecycle mocks: keyring lock state, account-tree + * readiness, client UI state, and remote feature flags. + * + * Each of these mocks maintains its own state from the matching event, so it + * has to subscribe before the controller messenger is delegated to. * * @param rootMessenger - The root mock messenger. - * @param opts - Action handler return value overrides. + * @param opts - Initial lifecycle state. */ -export function registerAssetsControllerActions( +export function registerWalletLifecycleMocks( rootMessenger: MockRootMessenger, - opts: RegisterAssetsControllerActionsOptions = {}, + opts: RegisterWalletLifecycleMocksOptions = {}, +): void { + registerKeyringUnlockMock(rootMessenger, opts.isKeyringUnlocked ?? false); + + let isAccountTreeInitialized = opts.isAccountTreeInitialized ?? false; + rootMessenger.registerActionHandler( + 'AccountTreeController:isInitialized', + () => isAccountTreeInitialized, + ); + rootMessenger.subscribe('AccountTreeController:initialized', () => { + isAccountTreeInitialized = true; + }); + rootMessenger.subscribe('AccountTreeController:uninitialized', () => { + isAccountTreeInitialized = false; + }); + + let clientControllerState = opts.clientControllerState ?? { isUiOpen: false }; + rootMessenger.registerActionHandler( + 'ClientController:getState', + () => clientControllerState, + ); + rootMessenger.subscribe( + 'ClientController:stateChange', + (isUiOpen: boolean) => { + clientControllerState = { isUiOpen }; + }, + clientControllerSelectors.selectIsUiOpen, + ); + + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: opts.remoteFeatureFlags ?? {}, + cacheTimestamp: 0, + }), + ); +} + +/** + * Register the account mocks AssetsController reads through + * AccountsController and AccountTreeController. + * + * @param rootMessenger - The root mock messenger. + * @param opts - The accounts to serve. + */ +export function registerAccountMocks( + rootMessenger: MockRootMessenger, + opts: RegisterAccountMocksOptions = {}, ): void { const accounts = opts.accounts ?? [ opts.selectedAccount ?? createMockInternalAccount(), @@ -363,20 +447,21 @@ export function registerAssetsControllerActions( 'AccountTreeController:getAccountsFromSelectedAccountGroup', () => accounts, ); +} - let isAccountTreeInitialized = opts.isAccountTreeInitialized ?? false; - rootMessenger.registerActionHandler( - 'AccountTreeController:isInitialized', - () => isAccountTreeInitialized, - ); - rootMessenger.subscribe('AccountTreeController:initialized', () => { - isAccountTreeInitialized = true; - }); - rootMessenger.subscribe('AccountTreeController:uninitialized', () => { - isAccountTreeInitialized = false; - }); - - registerKeyringUnlockMock(rootMessenger, opts.isKeyringUnlocked ?? false); +/** + * Register mock action handlers for external controller actions that + * AssetsController and its data sources call. + * + * @param rootMessenger - The root mock messenger. + * @param opts - Action handler return value overrides. + */ +export function registerAssetsControllerActions( + rootMessenger: MockRootMessenger, + opts: RegisterAssetsControllerActionsOptions = {}, +): void { + registerWalletLifecycleMocks(rootMessenger, opts); + registerAccountMocks(rootMessenger, opts); rootMessenger.registerActionHandler( 'NetworkEnablementController:getState', @@ -405,29 +490,8 @@ export function registerAssetsControllerActions( }) as TestMockType, ); - rootMessenger.registerActionHandler( - 'RemoteFeatureFlagController:getState', - () => ({ - remoteFeatureFlags: opts.remoteFeatureFlags ?? {}, - cacheTimestamp: 0, - }), - ); - rootMessenger.registerActionHandler( 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', () => undefined, ); - - let clientControllerState = opts.clientControllerState ?? { isUiOpen: false }; - rootMessenger.registerActionHandler( - 'ClientController:getState', - () => clientControllerState, - ); - rootMessenger.subscribe( - 'ClientController:stateChanged', - (isUiOpen: boolean) => { - clientControllerState = { isUiOpen }; - }, - clientControllerSelectors.selectIsUiOpen, - ); } diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/accounts-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/accounts-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..3f7fba13e97 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/accounts-api/v2-supportedNetworks.ts @@ -0,0 +1,20 @@ +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', + ], + partialSupport: {}, +} as const; + +export default accountsV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/accounts-api/v5-multiaccount-balances.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/accounts-api/v5-multiaccount-balances.ts new file mode 100644 index 00000000000..3941e1d3e8f --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/accounts-api/v5-multiaccount-balances.ts @@ -0,0 +1,388 @@ +const v5MultiAccountBalances = { + count: 38, + balances: [ + { + object: 'token', + symbol: 'BNB', + name: 'BNB', + type: 'native', + decimals: 18, + assetId: 'eip155:56/slip44:714', + balance: '0.009495005467800000', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'GT Protocol', + symbol: 'GTAI', + decimals: 18, + assetId: 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1', + balance: '910.204000000000200000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Tether USD', + symbol: 'USDT', + decimals: 18, + assetId: 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955', + balance: '0.002916000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Openfabric AI', + symbol: 'OFN', + decimals: 18, + assetId: 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97', + balance: '2919.859199999999000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Binance-Peg USD Coin', + symbol: 'USDC', + decimals: 18, + assetId: 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + balance: '0.000253000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Swaperry', + symbol: 'PERRY', + decimals: 18, + assetId: 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097', + balance: '16779.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'EarnGuild', + symbol: 'EARN', + decimals: 8, + assetId: 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2', + balance: '68000.00000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Gains', + symbol: 'GAINS', + decimals: 18, + assetId: 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01', + balance: '2145.457995009695314132', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'BUSD', + symbol: 'BUSD', + decimals: 18, + assetId: 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56', + balance: '0.671315783745192035', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Notable', + symbol: 'NBL', + decimals: 18, + assetId: 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9', + balance: '74714.575000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'CryptoBlades Kingdoms', + symbol: 'KING', + decimals: 18, + assetId: 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2', + balance: '46530.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Zepe.io', + symbol: 'ZEPE.IO', + decimals: 18, + assetId: 'eip155:56/erc20:0x119e2ad8f0c85c6f61afdf0df69693028cdc10be', + balance: '750000.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'ArenaPlay', + symbol: 'APC', + decimals: 18, + assetId: 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025', + balance: '14.338600000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Magic Square', + symbol: 'SQR', + decimals: 8, + assetId: 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8', + balance: '5075.86500000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: '$$EURCoin', + symbol: 'EURC', + decimals: 6, + assetId: 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62', + balance: '1888888.800000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'vBSWAP', + symbol: 'VBSWAP', + decimals: 18, + assetId: 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600', + balance: '0.000000000001000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'LUNC', + symbol: 'LUNC', + decimals: 9, + assetId: 'eip155:56/erc20:0x5259639653f76f3385ba100ddb6290724891a95b', + balance: '2000.000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'axax.io', + symbol: 'AXAX.IO', + decimals: 9, + assetId: 'eip155:56/erc20:0x58b5c4697dc70f3d889225260944cdd9c270c132', + balance: '77000.000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'SPACEPI', + symbol: 'SPACEPI', + decimals: 9, + assetId: 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e', + balance: '1000000.000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'hi Dollar', + symbol: 'HI', + decimals: 18, + assetId: 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097', + balance: '27418.637920223037021652', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: '$$SafeGrowCoin', + symbol: 'SGC', + decimals: 6, + assetId: 'eip155:56/erc20:0x7aa3a53360541283ffa9192972223b47a902dc0c', + balance: '1450000.000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'WEB5 Inu', + symbol: 'WEB5', + decimals: 9, + assetId: 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82', + balance: '724680.280736556', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'SEOR Network', + symbol: 'SEOR', + decimals: 18, + assetId: 'eip155:56/erc20:0x800a25741a414ea6e6e2b382435081a479a8cc3c', + balance: '10444.444444800000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Atlantis Metaverse', + symbol: 'TAU', + decimals: 18, + assetId: 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839', + balance: '2937.500000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Realio Network Token', + symbol: 'RIO', + decimals: 18, + assetId: 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0', + balance: '4624.900000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'DRIP Reward Token', + symbol: 'RDRIP', + decimals: 6, + assetId: 'eip155:56/erc20:0xa02a0b2d67d4fa48677a79cadc483e114049916d', + balance: '40214587.120000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Aav Token', + symbol: 'AAV', + decimals: 18, + assetId: 'eip155:56/erc20:0xa18b59607b7286a6533fd8c7e8c9716eac9a5c73', + balance: '21500.320000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: '$$$DOGECHAIN', + symbol: 'CDOGE', + decimals: 9, + assetId: 'eip155:56/erc20:0xa7255c85232a42b5c602ed66c319da9af8433bb3', + balance: '48612246876233.123735212', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Bee Token', + symbol: 'BEETOKEN', + decimals: 18, + assetId: 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970', + balance: '7.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Exverse', + symbol: 'EXVG', + decimals: 18, + assetId: 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63', + balance: '17581.843800000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Zam.io', + symbol: 'ZAM', + decimals: 18, + assetId: 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8', + balance: '18800.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Minereum BSC', + symbol: 'MNEB', + decimals: 8, + assetId: 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf', + balance: '150000.00000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'ACT', + symbol: 'ACT', + decimals: 6, + assetId: 'eip155:56/erc20:0xd5da8318ce7ca005e8f5285db0e750ca9256586e', + balance: '60000.000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'SafeBlast', + symbol: 'BLAST', + decimals: 9, + assetId: 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0', + balance: '108051.867218691', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'shibainu-dividend.com', + symbol: 'SHIBA_DIVIDEND_TRACKER', + decimals: 18, + assetId: 'eip155:56/erc20:0xdddf82fb98530243fcf8d4b8dc452f918c3ac4ac', + balance: '66707.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'MM72', + symbol: 'MM72', + decimals: 18, + assetId: 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e', + balance: '72.000720067858500000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Pepe AI', + symbol: 'PEPEAI', + decimals: 9, + assetId: 'eip155:56/erc20:0xe57f73eb27da9d17f90c994744d842e95700c100', + balance: '12345.543210000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + { + object: 'token', + name: 'Meta Interstellar Token', + symbol: 'MIT', + decimals: 18, + assetId: 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0', + balance: '705.000000000000000000', + type: 'erc20', + accountId: 'eip155:56:0x9decde522cc1285efe18afde31c79e89dee2e91e', + }, + ], + unprocessedNetworks: [], +} as const; + +export default v5MultiAccountBalances; diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts new file mode 100644 index 00000000000..66476c232ec --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/index.ts @@ -0,0 +1,247 @@ +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 v5MultiAccountBalances from './accounts-api/v5-multiaccount-balances.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 to decide which chains it claims. + * + * @returns The nock scope. + */ +function mockAccountsSupportedNetworks(): nock.Scope { + return nock(API_URLS.ACCOUNTS) + .persist() + .get('/v2/supportedNetworks') + .reply(200, accountsV2SupportedNetworks); +} + +/** + * Intercept `GET {ACCOUNTS}/v5/multiaccount/balances` with the wallet's + * captured 38 holdings. + * + * @returns The nock scope and the account IDs each request asked about. + */ +function mockV5MultiAccountBalances(): { + scope: nock.Scope; + requestedAccountIds: string[][]; +} { + const requestedAccountIds: string[][] = []; + + const scope = nock(API_URLS.ACCOUNTS) + .persist() + .get('/v5/multiaccount/balances') + .query(true) + .reply(200, (uri: string) => { + requestedAccountIds.push( + readListParam(uri, 'accountIds', API_URLS.ACCOUNTS), + ); + return v5MultiAccountBalances; + }); + + return { scope, requestedAccountIds }; +} + +/** + * Intercept `GET {TOKENS}/v2/supportedNetworks`. `eip155:56` is in the captured + * `fullSupport` list, so BNB Chain assets genuinely reach the occurrence filter + * rather than being skipped as unsupported. + * + * @returns The nock scope. + */ +function mockTokensSupportedNetworks(): nock.Scope { + return nock(API_URLS.TOKENS) + .persist() + .get('/v2/supportedNetworks') + .reply(200, tokensV2SupportedNetworks); +} + +/** + * Intercept `GET {TOKEN}/v1/suggestedOccurrenceFloors`. The capture has no + * `56` entry, so BNB Chain falls back to `TokenDataSource`'s floor of three. + * + * @returns The nock scope. + */ +function mockSuggestedOccurrenceFloors(): nock.Scope { + return nock(API_URLS.TOKEN) + .persist() + .get('/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('/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('/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('/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 fast fetch lane needs for this wallet: + * Accounts API supported networks and balances, Tokens API supported networks + * and assets, the Token API occurrence floors, and the Price API supported + * networks and spot prices. Also answers `chainid.network/chains.json`, which + * `AssetsController` fetches on boot to fill native-asset gaps. + * + * All interceptors persist, so batch composition and cache misses cannot make a + * test fail for want of an interceptor. + * + * @returns The recording mocks, and other utils + */ +export function mockBscSpamApis(): { + accountsSupportedNetworks: nock.Scope; + balances: { requestedAccountIds: string[][] }; + assets: BatchRecordingMock; + prices: BatchRecordingMock; +} { + const accountsSupportedNetworks = mockAccountsSupportedNetworks(); + mockTokensSupportedNetworks(); + mockSuggestedOccurrenceFloors(); + mockPricesSupportedNetworks(); + mockChainIdNetwork(); + + const balances = mockV5MultiAccountBalances(); + const assets = mockV3Assets(); + const prices = mockV3SpotPrices(); + + return { accountsSupportedNetworks, balances, 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__/bsc-spam-token/api-responses/price-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/price-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..7c4638a6f42 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/price-api/v2-supportedNetworks.ts @@ -0,0 +1,173 @@ +const pricesV2SupportedNetworks = { + 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 pricesV2SupportedNetworks; diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/price-api/v3-spot-prices.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/price-api/v3-spot-prices.ts new file mode 100644 index 00000000000..20e52a88715 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/price-api/v3-spot-prices.ts @@ -0,0 +1,458 @@ +const v3SpotPrices = { + 'eip155:56/slip44:714': { + id: 'eip155:56/slip44:714', + price: 716.8635016621672, + marketCap: 95407675057, + allTimeHigh: 1369.99, + allTimeLow: 0.0398177, + totalVolume: 1133782896, + high1d: 751.19, + low1d: 715.6, + circulatingSupply: 133161239.47, + dilutedMarketCap: 95407679685, + marketCapPercentChange1d: -4.34605, + priceChange1d: -32.63578477335386, + pricePercentChange1h: 0.1, + pricePercentChange1d: -4.32, + pricePercentChange7d: 2.4, + pricePercentChange14d: 0.7, + pricePercentChange30d: 18.3, + pricePercentChange200d: 15, + pricePercentChange1y: -18.6, + liquidity: 27035518.468615964, + }, + 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1': { + id: 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1', + price: 0.00720484, + marketCap: 503504, + allTimeHigh: 5.46, + allTimeLow: 0.00604714, + totalVolume: 185919, + high1d: 0.00721852, + low1d: 0.00714369, + circulatingSupply: 69880943.68552426, + dilutedMarketCap: 540387, + marketCapPercentChange1d: 1.23194, + priceChange1d: 0.00008736, + pricePercentChange1h: 0.1, + pricePercentChange1d: 1.22737, + pricePercentChange7d: -22.3, + pricePercentChange14d: -23.7, + pricePercentChange30d: -13.5, + pricePercentChange200d: -84.8, + pricePercentChange1y: -92.8, + }, + 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955': { + id: 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955', + price: 0.9997597462199397, + marketCap: 9181946768, + allTimeHigh: 1.85, + allTimeLow: 0.941022, + totalVolume: 1252391401, + high1d: 0.999919, + low1d: 0.999558, + circulatingSupply: 9184991905.680809, + dilutedMarketCap: 9181946768, + marketCapPercentChange1d: -0.01702, + priceChange1d: -0.000169090781822367, + pricePercentChange1h: 0, + pricePercentChange1d: -0.02, + pricePercentChange7d: 0, + pricePercentChange14d: 0, + pricePercentChange30d: 0.1, + pricePercentChange200d: 0, + pricePercentChange1y: 0, + liquidity: 613589475.6193123, + }, + 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97': null, + 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d': { + id: 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + price: 0.9999793058607263, + marketCap: 1588817061, + allTimeHigh: 1.54, + allTimeLow: 0.107192, + totalVolume: 1092971528, + high1d: 1, + low1d: 0.999718, + circulatingSupply: 1588999877.439803, + dilutedMarketCap: 1588817061, + marketCapPercentChange1d: -0.00448, + priceChange1d: -0.000044277307959528, + pricePercentChange1h: 0, + pricePercentChange1d: -0.04, + pricePercentChange7d: 0, + pricePercentChange14d: 0, + pricePercentChange30d: 0, + pricePercentChange200d: -0.2, + pricePercentChange1y: 0, + liquidity: 48479575.53997608, + }, + 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097': { + id: 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097', + price: 0.0006560735838, + marketCap: 25516.6214230616, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 45.1025528431, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 131202.663241936, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: -2.305, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 3620.847652722667, + totalSupply: 200000000, + holderCount: null, + isMutable: null, + }, + 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2': { + id: 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2', + price: 0.0001841583888, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 0, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 18403.4899850297, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: 0, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 1187.8332805053717, + totalSupply: 100000000, + }, + 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01': null, + 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56': { + id: 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56', + price: 1.000380330628689, + marketCap: 283296437.81903726, + allTimeHigh: 1.86, + allTimeLow: 0.462821, + totalVolume: 1031780.8750981385, + high1d: 1.018440961950845, + low1d: 0.995524765786234, + circulatingSupply: 283188732.4703792, + dilutedMarketCap: null, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: -0.03, + pricePercentChange1d: -0.06, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + }, + 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9': { + id: 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9', + price: 0.001037859309, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 18.5327447356, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 103785.929891558, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: -0.225, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 15553.255162248859, + totalSupply: 100000000, + }, + 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2': null, + 'eip155:56/erc20:0x119e2ad8f0c85c6f61afdf0df69693028cdc10be': null, + 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025': { + id: 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025', + price: 0.001430968154, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 0, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 109800.696381147, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: 0, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 87007.28289524098, + totalSupply: 100000000, + }, + 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8': { + id: 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8', + price: 0.00008501, + marketCap: 61019, + allTimeHigh: 0.775708, + allTimeLow: 0.00006158, + totalVolume: 92771, + high1d: 0.00009934, + low1d: 0.00008185, + circulatingSupply: 719255546.828825, + dilutedMarketCap: 80567, + marketCapPercentChange1d: 2.89297, + priceChange1d: 0.00000256, + pricePercentChange1h: -2.5, + pricePercentChange1d: 2.73071, + pricePercentChange7d: -1.1, + pricePercentChange14d: -6.9, + pricePercentChange30d: -6.5, + pricePercentChange200d: -74.5, + pricePercentChange1y: -98.4, + }, + 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62': null, + 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600': { + id: 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600', + price: 14.6695460693, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 66.0330720783275, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 140814.349477256, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: -3.548, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 3706.966847093036, + totalSupply: 9599.09680108329, + }, + 'eip155:56/erc20:0x5259639653f76f3385ba100ddb6290724891a95b': null, + 'eip155:56/erc20:0x58b5c4697dc70f3d889225260944cdd9c270c132': null, + 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e': { + id: 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e', + price: 6.24277267e-10, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 443.901054517397, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 1197830.38634154, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: -0.085, + pricePercentChange1d: -4.624, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 701831.6721570174, + totalSupply: 1999999997999990, + }, + 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097': { + id: 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097', + price: 0.00002197, + marketCap: 1446708, + allTimeHigh: 1.57, + allTimeLow: 0.00001497, + totalVolume: 45.05, + high1d: 0.00002244, + low1d: 0.00002161, + circulatingSupply: 65859645815, + dilutedMarketCap: 2196653, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: -0.1, + pricePercentChange1d: null, + pricePercentChange7d: -0.3, + pricePercentChange14d: -1.2, + pricePercentChange30d: 30.4, + pricePercentChange200d: -54.6, + pricePercentChange1y: -71.8, + liquidity: 14473.719381952691, + }, + 'eip155:56/erc20:0x7aa3a53360541283ffa9192972223b47a902dc0c': null, + 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82': null, + 'eip155:56/erc20:0x800a25741a414ea6e6e2b382435081a479a8cc3c': null, + 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839': { + id: 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839', + price: 0.00001784990067, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 0, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 3569.9739533813, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: null, + pricePercentChange1d: null, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 0.5992849636201593, + totalSupply: 200000000, + }, + 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0': { + id: 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0', + price: 0.04897826, + marketCap: 16183936, + allTimeHigh: 5.12, + allTimeLow: 0.01342772, + totalVolume: 204638, + high1d: 0.05071, + low1d: 0.04844582, + circulatingSupply: 330500000, + dilutedMarketCap: 16183936, + marketCapPercentChange1d: -1.03478, + priceChange1d: -0.00050178316195728, + pricePercentChange1h: -0.1, + pricePercentChange1d: -0.98825, + pricePercentChange7d: 26.2, + pricePercentChange14d: 1.4, + pricePercentChange30d: 40.3, + pricePercentChange200d: -76.1, + pricePercentChange1y: -82.7, + liquidity: 286952.55296101805, + }, + 'eip155:56/erc20:0xa02a0b2d67d4fa48677a79cadc483e114049916d': null, + 'eip155:56/erc20:0xa18b59607b7286a6533fd8c7e8c9716eac9a5c73': null, + 'eip155:56/erc20:0xa7255c85232a42b5c602ed66c319da9af8433bb3': { + id: 'eip155:56/erc20:0xa7255c85232a42b5c602ed66c319da9af8433bb3', + price: 5.491481035e-11, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 0, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 5486141.97077836, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: 0, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 1215355.4989798926, + totalSupply: 100000000000000000, + }, + 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970': null, + 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63': { + id: 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63', + price: 0.00006569537134, + marketCap: null, + allTimeHigh: null, + allTimeLow: null, + totalVolume: 0, + high1d: null, + low1d: null, + circulatingSupply: null, + dilutedMarketCap: 26277.6103202188, + marketCapPercentChange1d: null, + priceChange1d: null, + pricePercentChange1h: 0, + pricePercentChange1d: 0, + pricePercentChange7d: null, + pricePercentChange14d: null, + pricePercentChange30d: null, + pricePercentChange200d: null, + pricePercentChange1y: null, + bondingCurveProgressPercent: null, + liquidity: 1101.8053137481395, + totalSupply: 400000000, + holderCount: null, + isMutable: null, + }, + 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8': null, + 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf': null, + 'eip155:56/erc20:0xd5da8318ce7ca005e8f5285db0e750ca9256586e': null, + 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0': { + id: 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0', + price: 2.871e-9, + marketCap: 0, + allTimeHigh: 0.00032667, + allTimeLow: 8.9e-17, + totalVolume: 5.65, + high1d: 3.006e-9, + low1d: 2.863e-9, + circulatingSupply: 0, + dilutedMarketCap: 2871530, + marketCapPercentChange1d: null, + priceChange1d: -1.31283041e-10, + pricePercentChange1h: -0.2, + pricePercentChange1d: -4.40722, + pricePercentChange7d: -0.8, + pricePercentChange14d: -2.8, + pricePercentChange30d: 6, + pricePercentChange200d: 5527215.5, + pricePercentChange1y: -45.2, + }, + 'eip155:56/erc20:0xdddf82fb98530243fcf8d4b8dc452f918c3ac4ac': null, + 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e': { + id: 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e', + price: 3.70568e-7, + marketCap: 32600, + allTimeHigh: 0.00502433, + allTimeLow: 3.9844e-8, + totalVolume: 194.16, + high1d: 3.90567e-7, + low1d: 3.69265e-7, + circulatingSupply: 87999997928.97575, + dilutedMarketCap: 37045, + marketCapPercentChange1d: -4.25951, + priceChange1d: -1.6367479209e-8, + pricePercentChange1h: -0.1, + pricePercentChange1d: -3.89934, + pricePercentChange7d: 4.4, + pricePercentChange14d: -0.7, + pricePercentChange30d: -0.9, + pricePercentChange200d: -34.1, + pricePercentChange1y: -91.6, + }, + 'eip155:56/erc20:0xe57f73eb27da9d17f90c994744d842e95700c100': null, + 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0': null, +} as const; + +export default v3SpotPrices; diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/token-api/suggestedOccurrenceFloors.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/token-api/suggestedOccurrenceFloors.ts new file mode 100644 index 00000000000..42acc025150 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/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__/bsc-spam-token/api-responses/tokens-api/v2-supportedNetworks.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/tokens-api/v2-supportedNetworks.ts new file mode 100644 index 00000000000..5005720331d --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/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__/bsc-spam-token/api-responses/tokens-api/v3-assets.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/tokens-api/v3-assets.ts new file mode 100644 index 00000000000..0fcfa5e3b0d --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/api-responses/tokens-api/v3-assets.ts @@ -0,0 +1,891 @@ +const v3Assets = { + 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56': { + aggregators: [ + 'pancakeExtended', + 'liFi', + 'oneInch', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'trustWallet', + 'binanceDex', + ], + assetId: 'eip155:56/erc20:0xe9e7cea3dedca5984780bafc599bd69add087d56', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xe9e7cea3dedca5984780bafc599bd69add087d56.png', + labels: ['badges:v1:stablecoin'], + name: 'BUSD', + occurrences: 9, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'BUSD', + isContractVerified: true, + }, + 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600': { + aggregators: ['pancakeCoinMarketCap', 'oneInch', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x4f0ed527e8a95ecaa132af214dfd41f30b361600', + decimals: 18, + description: { + en: 'vBSWAP is Binance Smart Chain token used as incentive for Value DeFi BSC ecosystem users. Part of fees (decided by vGovernance) from Value DeFi BSC ecosystem are used to buyback and burn vBSWAP. Max supply of vBSWAP is 100000 and it will be distributed over period of 108 weeks with emission reduction by 10% every 4 weeks (eg. first four weeks total of 10600 vBSWAP tokens will be distributed to farmers, next four weeks 9540, etc', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x4f0ed527e8a95ecaa132af214dfd41f30b361600.png', + name: 'vBSWAP', + occurrences: 4, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'VBSWAP', + isContractVerified: true, + }, + 'eip155:56/erc20:0x5259639653f76f3385ba100ddb6290724891a95b': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0x5259639653f76f3385ba100ddb6290724891a95b', + decimals: 9, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + name: 'LUNC', + occurrences: 1, + symbol: 'LUNC', + isContractVerified: true, + }, + 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955': { + aggregators: [ + 'pancakeExtended', + 'liFi', + 'oneInch', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'trustWallet', + ], + assetId: 'eip155:56/erc20:0x55d398326f99059ff775485246999027b3197955', + decimals: 18, + description: { + en: 'Tether (USDT) is a cryptocurrency with a value meant to mirror the value of the U.S. dollar. The idea was to create a stable cryptocurrency that can be used like digital dollars. Coins that serve this purpose of being a stable dollar substitute are called “stable coins.” Tether is the most popular stable coin and even acts as a dollar replacement on many popular exchanges! According to their site, Tether converts cash into digital currency, to anchor or “tether” the value of the coin to the price of national currencies like the US dollar, the Euro, and the Yen. Like other cryptos it uses blockchain. Unlike other cryptos, it is [according to the official Tether site] “100% backed by USD” (USD is held in reserve). The primary use of Tether is that it offers some stability to the otherwise volatile crypto space and offers liquidity to exchanges who can’t deal in dollars and with banks (for example to the sometimes controversial but leading exchange Bitfinex).The digital coins are issued by a company called Tether Limited that is governed by the laws of the British Virgin Islands, according to the legal part of its website. It is incorporated in Hong Kong. It has emerged that Jan Ludovicus van der Velde is the CEO of cryptocurrency exchange Bitfinex, which has been accused of being involved in the price manipulation of bitcoin, as well as tether. Many people trading on exchanges, including Bitfinex, will use tether to buy other cryptocurrencies like bitcoin. Tether Limited argues that using this method to buy virtual currencies allows users to move fiat in and out of an exchange more quickly and cheaply. Also, exchanges typically have rocky relationships with banks, and using Tether is a way to circumvent that.USDT is fairly simple to use. Once on exchanges like Poloniex or Bittrex, it can be used to purchase Bitcoin and other cryptocurrencies. It can be easily transferred from an exchange to any Omni Layer enabled wallet. Tether has no transaction fees, although external wallets and exchanges may charge one. In order to convert USDT to USD and vise versa through the Tether.to Platform, users must pay a small fee. Buying and selling Tether for Bitcoin can be done through a variety of exchanges like the ones mentioned previously or through the Tether.to platform, which also allows the conversion between USD to and from your bank account.', + ko: '미국 달러화를 기반으로 한 블록체인1) 기반 암호화폐실제 달러화 유보금과 1:1정도의 비율을 유지함으로써 가치의 변동성이 거의 없다는 것이 특징가치 변동이 심한 다른 암호화폐 거래 시 안정적인 자산 운용을 위한 역할을 수행하고 있음가치암호화폐 거래를 위한 실질적 기축통화와 1:1 비율로 가치를 형성하는 거래 수단 및 극심한 변동성을 가지고 있는 다른 암호화폐를 거래하기 위한 실질적인 화폐의 기능을 수행할 수 있는 목적으로 만들어진 암호화폐 입니다.이러한 역할을 수행할 수 있는 화폐는 신뢰성을 바탕으로 운영과 관리가 되어야 하며 이를 운영사인 Tether사에서 은행에 1:1비율로 보유하고 있는 미국 달러를 토대로 투명하게 정기적으로 재무 상태를 공개하며 운영을 하는 정책을 가지고 있지만 실질적으로 2017년 들어 의혹이 생길 만한 일들이 다소 발생하였고 이로 인하여 신뢰도가 어느정도 하락한 상태입니다.하지만 아직까지는 USD를 기반으로 한 안정적인 가치의 유지는 지속되고 있으며 여전히 거래 시장 또한 활발하게 움직이고 있는 상황입니다.', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x55d398326f99059ff775485246999027b3197955.png', + name: 'Tether USD', + occurrences: 9, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'USDT', + isContractVerified: true, + }, + 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0x4b97c9bee3677797034033337f32115115867a62', + decimals: 6, + erc20Permit: false, + honeypotStatus: { + honeypotIs: true, + goPlus: true, + }, + name: '$$EURCoin', + occurrences: 1, + symbol: 'EURC', + isContractVerified: true, + }, + 'eip155:56/slip44:714': { + aggregators: [], + assetId: 'eip155:56/slip44:714', + decimals: 18, + description: { + en: 'Binance Coin is the cryptocurrency of the Binance platform. It is a trading platform exclusively for cryptocurrencies. The name "Binance" is a combination of binary and finance.Thus, the startup name shows that only cryptocurrencies can be traded against each other. It is not possible to trade crypto currencies against Fiat. The platform achieved an enormous success within a very short time and is focused on worldwide market with Malta headquarters. The cryptocurrency currently has a daily trading volume of 1.5 billion - 2 billion US dollars and is still increasing.In total, there will only be 200 million BNBs. Binance uses the ERC20 token standard from Ethereum and has distributed it as follow: 50% sold on ICO, 40% to the team and 10% to Angel investors. The coin can be used to pay fees on Binance. These include trading fees, transaction fees, listing fees and others. Binance gives you a huge discount when fees are paid in BNB. The schedule of BNB fees discount is as follow: In the first year, 50% discount on all fees, second year 25% discount, third year 12.5% discount, fourth year 6.75 % discount, and from the fifth year onwards there is no discount. This structure is used to incentivize users to buy BNB and do trades within Binance.Binance announced in a buyback plan that it would buy back up to 100 million BNB in Q1 2018. The coins are then burned. This means that they are devaluated to increase the value of the remaining coins. This benefits investors. In the future, the cryptocurrency will remain an asset on the trading platform and will be used as gas.Other tokens that are issued by exchanges include Bibox Token, OKB, Huobi Token, and more.', + }, + erc20Permit: false, + honeypotStatus: {}, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/slip44/714.png', + name: 'Binance Coin', + occurrences: 1, + symbol: 'BNB', + isContractVerified: false, + }, + 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9': { + aggregators: ['pancakeCoinMarketCap'], + assetId: 'eip155:56/erc20:0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9', + decimals: 18, + description: { + en: 'The first Experience-NFT marketplace, allowing creators to monetise their popularity by offering NFT tied to real life experiences, and giving people a new way to invest on their favourite personalities.', + }, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xfaa0fc7b803919b091dbe5ff709b2dabb61b93d9.png', + name: 'Notable', + occurrences: 1, + storage: { + approval: 6, + balance: 5, + }, + symbol: 'NBL', + isContractVerified: true, + }, + 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xb0a2416fd12711cbcfafb429031c0f7037fab970', + decimals: 18, + erc20Permit: false, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + name: 'Bee Token', + occurrences: 1, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'BEETOKEN', + isContractVerified: true, + }, + 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2': { + aggregators: ['pancakeCoinMarketCap'], + assetId: 'eip155:56/erc20:0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2', + decimals: 8, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xb0eb3e295b44d7d405ba8026a9734a9ab354a8b2.png', + name: 'EarnGuild', + occurrences: 1, + storage: { + approval: 2, + balance: 0, + }, + symbol: 'EARN', + isContractVerified: true, + }, + 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839': { + aggregators: ['pancakeCoinMarketCap'], + assetId: 'eip155:56/erc20:0x8632055b9caeebef7c7dccd95461608ca5378839', + decimals: 18, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x8632055b9caeebef7c7dccd95461608ca5378839.png', + name: 'TAU Token', + occurrences: 1, + storage: { + approval: 17, + balance: 18, + }, + symbol: 'TAU', + isContractVerified: true, + }, + 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97': { + aggregators: ['pancakeCoinMarketCap', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97', + decimals: 18, + description: { + en: 'Openfabric is a decentralized AI platform where the collaboration between AI innovators, data providers, businesses, and infrastructure providers will facilitate the creation and use of new intelligent algorithms and services.Openfabric AI is revolutionizing AI-Apps with its decentralized Layer 1 AI protocol, powered by blockchain and advanced cryptography.', + }, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x8899ec96ed8c96b5c86c23c3f069c3def75b6d97.png', + name: 'Openfabric AI', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'OFN', + isContractVerified: true, + }, + 'eip155:56/erc20:0x800a25741a414ea6e6e2b382435081a479a8cc3c': { + aggregators: ['pancakeCoinMarketCap', 'rango'], + assetId: 'eip155:56/erc20:0x800a25741a414ea6e6e2b382435081a479a8cc3c', + decimals: 18, + description: { + en: 'SEOR is the next generation of decentralized Web3.0 application technology development infrastructure, which aims to provide users and developers of Web3.0 with an easy-to-use blockchain technology development platform. ', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x800a25741a414ea6e6e2b382435081a479a8cc3c.png', + name: 'SEOR Network', + occurrences: 2, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'SEOR', + isContractVerified: true, + }, + 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d': { + aggregators: [ + 'pancakeExtended', + 'liFi', + 'oneInch', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + ], + assetId: 'eip155:56/erc20:0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', + decimals: 18, + 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: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d.png', + name: 'Binance-Peg USD Coin', + occurrences: 8, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'USDC', + isContractVerified: true, + }, + 'eip155:56/erc20:0xa02a0b2d67d4fa48677a79cadc483e114049916d': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xa02a0b2d67d4fa48677a79cadc483e114049916d', + decimals: 6, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: true, + }, + name: 'DRIP Reward Token ', + occurrences: 1, + storage: { + approval: 1, + }, + symbol: 'RDRIP', + isContractVerified: true, + }, + 'eip155:56/erc20:0xa18b59607b7286a6533fd8c7e8c9716eac9a5c73': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xa18b59607b7286a6533fd8c7e8c9716eac9a5c73', + decimals: 18, + name: 'Aav Token', + occurrences: 1, + symbol: 'AAV', + }, + 'eip155:56/erc20:0xa7255c85232a42b5c602ed66c319da9af8433bb3': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xa7255c85232a42b5c602ed66c319da9af8433bb3', + decimals: 9, + honeypotStatus: { + honeypotIs: true, + goPlus: true, + }, + name: '$$$DOGECHAIN', + occurrences: 1, + storage: { + balance: 7, + }, + symbol: 'CDOGE', + isContractVerified: true, + }, + 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0': { + aggregators: ['pancakeCoinMarketCap', 'rango'], + assetId: 'eip155:56/erc20:0xe6906717f129427eebade5406de68cadd57aa0c0', + decimals: 18, + description: { + en: 'Galaxy Blitz is a Play-To-Earn combat strategy NFT game. The game is set in the future, as four unique highly evolved descendants of humanity fight for dominance in battles on both land and in space. Our team is dedicated to providing players with a cutting-edge experience and we see great potential in utilizing Augmented Reality (AR) to further improve immersion and the overall experience for our players. With that in mind AR has become a key component of our development plan and our pre-sale NFTs will all support AR at launch. Therefore allowing owners of Galaxy Blitz NFTs to bring their favorite Heroes, Spaceships and Superweapons to life regardless of where they are.', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xe6906717f129427eebade5406de68cadd57aa0c0.png', + name: 'Meta Interstellar Token', + occurrences: 2, + storage: { + balance: 2, + approval: 3, + }, + symbol: 'MIT', + isContractVerified: true, + }, + 'eip155:56/erc20:0xe57f73eb27da9d17f90c994744d842e95700c100': { + aggregators: ['pancakeCoinGecko', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0xe57f73eb27da9d17f90c994744d842e95700c100', + decimals: 9, + description: { + en: 'What is the project about?The most memeable memecoin in existence. The dogs have had their day, it’s time for Pepe AI to take reign.What makes your project unique?We are PEPE with Draw PEPE AI running live, every PEPE drawn is watermark with our website for further marketing exposure.History of your project.Pepe AI is here to make memecoins great again. Launched stealth with no presale, zero taxes, LP locked and contract renounced, $PEPEAI is a coin for the people, forever. Fueled by pure memetic power, let $PEPEAI show you the way.What’s next for your project?More AI tools for Meme communities.What can your token be used for?To be paid for premium subscription for removal of watermark for AI images drawn by our AI tools.', + }, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xe57f73eb27da9d17f90c994744d842e95700c100.png', + name: 'Pepe AI', + occurrences: 3, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'PEPEAI', + isContractVerified: true, + }, + 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e': { + aggregators: ['pancakeCoinMarketCap', 'oneInch', 'rango'], + assetId: 'eip155:56/erc20:0x69b14e8d3cebfdd8196bfe530954a0c226e5008e', + decimals: 9, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x69b14e8d3cebfdd8196bfe530954a0c226e5008e.png', + name: 'SPACEPI', + occurrences: 3, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'SPACEPI', + isContractVerified: true, + }, + 'eip155:56/erc20:0x58b5c4697dc70f3d889225260944cdd9c270c132': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0x58b5c4697dc70f3d889225260944cdd9c270c132', + decimals: 9, + erc20Permit: false, + fees: { + maxFee: 7700000, + avgFee: 550000, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: true, + goPlus: true, + }, + name: 'axax.io', + occurrences: 1, + storage: { + balance: 2, + approval: 4, + }, + symbol: 'AXAX.IO', + isContractVerified: false, + }, + 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0': { + aggregators: ['coinGecko', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x94a8b4ee5cd64c79d0ee816f467ea73009f51aa0', + decimals: 18, + erc20Permit: false, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + name: 'Realio Network Token', + occurrences: 4, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'RIO', + isContractVerified: true, + }, + 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097': { + aggregators: ['pancakeCoinMarketCap', 'rubic'], + assetId: 'eip155:56/erc20:0x9452d45d33490234b8c96f42342f1be28c0fe097', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x9452d45d33490234b8c96f42342f1be28c0fe097.png', + name: 'Swaperry', + occurrences: 2, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'PERRY', + isContractVerified: true, + }, + 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8': { + aggregators: ['pancakeCoinMarketCap', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0xbbcf57177d8752b21d080bf30a06ce20ad6333f8', + decimals: 18, + description: { + en: 'Zam.io is building a hybrid CeFi-DeFi financial ecosystem that bridges real capital to blockchain and accelerates the transition to the new decentralized economy. The ecosystem enables equity investors to extend their stocks portfolio to crypto markets using a platform for stablecoin loans secured by stocks as collateral (zMorgan Protocol).', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xbbcf57177d8752b21d080bf30a06ce20ad6333f8.png', + name: 'Zam.io', + occurrences: 3, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'ZAM', + isContractVerified: true, + }, + 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63': { + aggregators: ['pancakeCoinMarketCap', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0xbb7d61d2511fd2e63f02178ca9b663458af9fc63', + decimals: 18, + erc20Permit: false, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xbb7d61d2511fd2e63f02178ca9b663458af9fc63.png', + name: 'Exverse', + occurrences: 3, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'EXVG', + isContractVerified: true, + }, + 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82': { + aggregators: ['pancakeCoinMarketCap'], + assetId: 'eip155:56/erc20:0x7d220240cf958c5c47f2daac821db965f9837e82', + decimals: 9, + erc20Permit: false, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x7d220240cf958c5c47f2daac821db965f9837e82.png', + name: 'WEB5 Inu', + occurrences: 1, + storage: { + approval: 3, + balance: 1, + }, + symbol: 'WEB5', + isContractVerified: true, + }, + 'eip155:56/erc20:0x7aa3a53360541283ffa9192972223b47a902dc0c': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0x7aa3a53360541283ffa9192972223b47a902dc0c', + decimals: 6, + erc20Permit: false, + honeypotStatus: { + honeypotIs: null, + goPlus: true, + }, + name: '$$SafeGrowCoin', + occurrences: 1, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'SGC', + isContractVerified: true, + }, + 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097': { + aggregators: [ + 'pancakeCoinMarketCap', + 'oneInch', + 'rubic', + 'rango', + 'sonarwatch', + ], + assetId: 'eip155:56/erc20:0x77087ab5df23cfb52449a188e80e9096201c2097', + decimals: 18, + description: { + en: 'hi is leveraging blockchain technology to build services that are community powered. Members of hi are the key stakeholders of this ecosystem and the business is committed to maximize membership value - not profits. Our first product is a digital wallet that provides members with the most seamless payment experience via social messengers (initially Telegram and WhatsApp, next LINE, Facebook Messenger and others). For more information, visit hi.com.', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x77087ab5df23cfb52449a188e80e9096201c2097.png', + name: 'hi Dollar', + occurrences: 5, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'HI', + isContractVerified: true, + }, + 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0': { + aggregators: ['pancakeCoinMarketCap', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0', + decimals: 9, + description: { + en: '"SafeBLAST (BLAST) is both a UTILITY and a DEFLATIONARY token available on multiple blockchains. As a utility token, you can use BLAST for direct payment on goods and services where accepted worldwide. As a deflationary token, SafeBLAST is an Autonomous yield and Liquidity generation protocol. Every time someone transfers, buys or sells BLAST tokens on PancakeSwap, the total supply goes down.Every transaction also creates a passive effortless reward distribution for all token HODLers on the BNB Blockchain, which is where PancakeSwap transactions takes place. There is NO buy or sell tax on Centralized exchanges or Uniswap, which is why there is NO reward distribution either. Distribution is only to the wallets on Binance Chain (BNB Chain).Liquidity is also generated and locked automatically to support the ecosystem on PancakeSwap. As the circulating supply becomes limited, supply and demand will play a big role in the value growth, which is a win-win for BLAST holders."', + }, + erc20Permit: false, + fees: { + avgFee: 9.999999999999996, + maxFee: 10, + minFee: 10, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xddc0dbd7dc799ae53a98a60b54999cb6ebb3abf0.png', + name: 'SafeBlast', + occurrences: 3, + storage: { + balance: 7, + approval: 5, + }, + symbol: 'BLAST', + isContractVerified: true, + }, + 'eip155:56/erc20:0xdddf82fb98530243fcf8d4b8dc452f918c3ac4ac': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xdddf82fb98530243fcf8d4b8dc452f918c3ac4ac', + decimals: 18, + fees: { + maxFee: 100, + avgFee: 100, + minFee: 100, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + name: 'shibainu-dividend.com', + occurrences: 1, + storage: { + balance: 5, + approval: 7, + }, + symbol: 'SHIBA_DIVIDEND_TRACKER', + isContractVerified: true, + }, + 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e': { + aggregators: ['pancakeCoinGecko', 'rango'], + assetId: 'eip155:56/erc20:0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e', + decimals: 18, + description: { + en: 'MM72 is a long-term project related to the resolution of the Non Performing Tokens problem.An innovative SWAP promises the conversion of non-performing tokens with values from a minimum of 10 to a maximum of 100 times the ascertained value (which in MM72 can never be absolute zero). ', + }, + erc20Permit: false, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xdf9e1a85db4f985d5bb5644ad07d9d7ee5673b5e.png', + name: 'MM72', + occurrences: 2, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'MM72', + isContractVerified: true, + }, + 'eip155:56/erc20:0xd5da8318ce7ca005e8f5285db0e750ca9256586e': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xd5da8318ce7ca005e8f5285db0e750ca9256586e', + decimals: 6, + erc20Permit: false, + honeypotStatus: { + honeypotIs: true, + }, + name: 'ACT', + occurrences: 1, + symbol: 'ACT', + isContractVerified: true, + }, + 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0xd9ea58350bf120e2169a35fa1afc31975b07de01', + decimals: 18, + erc20Permit: true, + fees: { + avgFee: 0, + maxFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xd9ea58350bf120e2169a35fa1afc31975b07de01.png', + name: 'Gains', + occurrences: 1, + storage: { + balance: 2, + }, + symbol: 'GAINS', + isContractVerified: true, + }, + 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf': { + aggregators: ['pancakeCoinMarketCap'], + assetId: 'eip155:56/erc20:0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf', + decimals: 8, + erc20Permit: false, + fees: { + avgFee: 384615.3846153846, + maxFee: 15000000, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: null, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0xd22202d23fe7de9e3dbe11a2a88f42f4cb9507cf.png', + name: 'Minereum BSC', + occurrences: 1, + symbol: 'MNEB', + isContractVerified: true, + }, + 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8': { + aggregators: ['pancakeCoinMarketCap', 'oneInch', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x2b72867c32cf673f7b02d208b26889fed353b1f8', + decimals: 8, + description: { + en: "## What is Magic Square (SQR)? Magic Square is an immersive Discovery & Engagement Platform for the Web3 Crypto Ecosystem, empowering users to explore a wide array of community-vetted apps and games. Discover exciting rewards, engaging giveaways, and unlock incredible use-to-earn opportunities within our platform. The native utility token SQR is hosted on BNB Smart Chain. SQR powers all aspects of the Magic Square ecosystem and allows for the seamless integration of users, developers, and validators.## What Makes Magic Square Unique?Magic Square is a vibrant community-driven app store for web3 applications, providing crypto users with a seamless and intuitive interface. Our primary objective is to foster widespread adoption of cryptocurrencies and decentralized apps by attracting new users to the ecosystem. With our user-friendly platform and diverse app selection, we simplify web3 interaction, empowering individuals to navigate the crypto world with ease. Checkout the Magic Store [here](https://magic.store/). At Magic Square, we go beyond just offering an app store. We provide a range of exciting features designed to enhance the community experience. These include Magic Boost, an affiliate platform that rewards users for spreading the word; Hot Offers, which provide exclusive web3 deals; a Referral Program that encourages users to invite others to join; Karma Rewards, recognizing and incentivizing active community participation; Project Validation, allowing users to have a say in shaping the platform. Join us in shaping the future of decentralized technologies. Together, we can unlock the true potential of web3 and create a more inclusive and accessible digital landscape. Magic Square is backed by World-Class Investors and Partners: Binance Labs, Republic Capital, Kucoin Labs, Gate.io, Huobi Ventures, IQ Protocol, GSR, Dao Maker, AlphaGrep, Crypto.com, Forty two, Gravity Ventures##Who Are the Founders of SQR Protocol?Magic Square is the visionary creation of Andrey Nayman, a renowned Ph.D. and former Managing Director at Radical Ventures. With over 15 years of expertise in FinTech and a profound understanding of blockchain development, Nayman has played a pivotal role in shaping the industry. Notably, he was an active participant in the Ethereum ICO, witnessing firsthand the transformative power of decentralized technologies. Under Nayman's guidance, Magic Square has evolved into a formidable project supported by a team of accomplished product developers and project managers. Together, they bring a wealth of experience and expertise to drive the success of this groundbreaking venture.##Where can you buy Magic Square?This information will be disclosed with the launch of the Public Sale, which is set for 2023. To learn more about this project check https://linktr.ee/MagicSquare.", + }, + erc20Permit: true, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x2b72867c32cf673f7b02d208b26889fed353b1f8.png', + name: 'Magic Square', + occurrences: 4, + storage: { + approval: 1, + balance: 0, + }, + symbol: 'SQR', + isContractVerified: true, + }, + 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0x2aa504586d6cab3c59fa629f74c586d78b93a025', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0.04, + maxFee: 2, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x2aa504586d6cab3c59fa629f74c586d78b93a025.png', + name: 'ArenaPlay', + occurrences: 1, + storage: { + balance: 1, + approval: 2, + }, + symbol: 'APC', + isContractVerified: true, + }, + 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1': { + aggregators: ['pancakeExtended', 'rubic', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1', + decimals: 18, + erc20Permit: false, + fees: { + maxFee: 0, + avgFee: 0, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: null, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1.png', + name: 'GT Protocol', + occurrences: 4, + storage: { + balance: 0, + approval: 1, + }, + symbol: 'GTAI', + isContractVerified: true, + }, + 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2': { + aggregators: ['pancakeCoinMarketCap', 'rango', 'sonarwatch'], + assetId: 'eip155:56/erc20:0x0ccd575bf9378c06f6dca82f8122f570769f00c2', + decimals: 18, + erc20Permit: false, + fees: { + avgFee: 0.9800000000000005, + maxFee: 1, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: false, + goPlus: false, + }, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/0x0ccd575bf9378c06f6dca82f8122f570769f00c2.png', + name: 'CryptoBlades Kingdoms', + occurrences: 3, + storage: { + approval: 4, + balance: 6, + }, + symbol: 'KING', + isContractVerified: true, + }, + 'eip155:56/erc20:0x119e2ad8f0c85c6f61afdf0df69693028cdc10be': { + aggregators: ['rubic'], + assetId: 'eip155:56/erc20:0x119e2ad8f0c85c6f61afdf0df69693028cdc10be', + decimals: 18, + erc20Permit: false, + fees: { + maxFee: 75000000, + avgFee: 1500000, + minFee: 0, + }, + honeypotStatus: { + honeypotIs: null, + }, + name: 'Zepe.io', + occurrences: 1, + storage: { + balance: 6, + approval: 8, + }, + symbol: 'ZEPE.IO', + isContractVerified: false, + }, +} as const; + +export default v3Assets; diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/bscSpamWallet.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/bscSpamWallet.ts new file mode 100644 index 00000000000..8f3184f42a4 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/bscSpamWallet.ts @@ -0,0 +1,69 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { AssetsControllerStateInternal } from '../../types.js'; +import { + BSC_CHAIN_ID, + BSC_SPAM_ACCOUNT_ID, + BSC_SPAM_WALLET_ADDRESS, +} from './wallet.js'; + +/** + * Build the wallet's `InternalAccount`. + * + * Scoped to BNB Chain only, so `accountsWithSupportedChains` resolves to the + * one chain under test. + * + * @param overrides - Fields to override on the account. + * @returns The internal account. + */ +export function buildBscSpamAccount( + overrides?: Partial, +): InternalAccount { + return { + id: BSC_SPAM_ACCOUNT_ID, + address: BSC_SPAM_WALLET_ADDRESS, + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: [BSC_CHAIN_ID], + metadata: { + name: 'BSC Spam 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 pipeline pass sees every holding as newly detected. + * + * @param overrides - State slices to override. + * @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/__fixtures__/bsc-spam-token/messenger.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/messenger.ts new file mode 100644 index 00000000000..16e861a2537 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/messenger.ts @@ -0,0 +1,148 @@ +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 { buildBscSpamAccount } from './bscSpamWallet.js'; +import { + BNB_ASSET_ID, + BSC_CHAIN_ID, + BSC_CHAIN_ID_HEX, + BSC_NETWORK_CLIENT_ID, + BSC_RPC_URL, +} from './wallet.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 createBscMockProvider(): MockInternalProvider { + return new MockInternalProvider({ + stubs: [ + { method: 'eth_chainId', result: BSC_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 BNB Chain selected and enabled. + * + * @returns The network state. + */ +function buildBscNetworkState(): NetworkState { + return { + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: BSC_NETWORK_CLIENT_ID, + networkConfigurationsByChainId: { + [BSC_CHAIN_ID_HEX]: { + chainId: BSC_CHAIN_ID_HEX, + name: 'BNB Chain', + nativeCurrency: 'BNB', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: BSC_NETWORK_CLIENT_ID, + url: BSC_RPC_URL, + type: RpcEndpointType.Custom, + failoverUrls: [], + }, + ], + }, + }, + networksMetadata: { + [BSC_NETWORK_CLIENT_ID]: { status: NetworkStatus.Available, EIPS: {} }, + }, + }; +} + +/** + * Register the NetworkController / NetworkEnablement / ConfigRegistry handlers + * the BNB Chain spam-token fixtures need. Shared by the pipeline and + * controller integration tests. + * + * @param rootMessenger - The root messenger to register handlers on. + */ +export function registerBscSpamNetwork(rootMessenger: MockRootMessenger): void { + const provider = createBscMockProvider(); + const networkState = buildBscNetworkState(); + + 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: BSC_CHAIN_ID_HEX, + rpcUrl: BSC_RPC_URL, + ticker: 'BNB', + failoverRpcUrls: [], + }, + provider, + }) as never, + ); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => ({ + enabledNetworkMap: { eip155: { [BSC_CHAIN_ID_HEX]: true } }, + nativeAssetIdentifiers: { [BSC_CHAIN_ID]: BNB_ASSET_ID }, + }), + ); + + rootMessenger.registerActionHandler( + 'ConfigRegistryController:getNetworkConfigByCaip2ChainId', + () => undefined, + ); +} + +type RegisterBscSpamControllerActionsOptions = + RegisterWalletLifecycleMocksOptions; + +/** + * Register every external action `AssetsController` needs to boot the BNB + * Chain spam-token wallet: the account, lifecycle, feature flags, and the + * network handlers from {@link registerBscSpamNetwork}. + * + * @param rootMessenger - The root messenger to register handlers on. + * @param opts - Lifecycle / flag overrides. + */ +export function registerBscSpamControllerActions( + rootMessenger: MockRootMessenger, + opts: RegisterBscSpamControllerActionsOptions = {}, +): void { + registerWalletLifecycleMocks(rootMessenger, opts); + registerAccountMocks(rootMessenger, { + accounts: [buildBscSpamAccount()], + }); + registerBscSpamNetwork(rootMessenger); +} diff --git a/packages/assets-controller/src/__fixtures__/bsc-spam-token/wallet.ts b/packages/assets-controller/src/__fixtures__/bsc-spam-token/wallet.ts new file mode 100644 index 00000000000..2a13e03e489 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/bsc-spam-token/wallet.ts @@ -0,0 +1,29 @@ +/** BNB Smart Chain. Absent from `/v1/suggestedOccurrenceFloors`, so its floor is the default 3. */ +export const BSC_CHAIN_ID = 'eip155:56' as const; + +/** Example wallet, as it appears in the Accounts API request. */ +export const BSC_SPAM_WALLET_ADDRESS = + '0x9decDe522Cc1285efe18AfdE31C79e89dee2e91E'; + +/** `InternalAccount.id` (a UUID), not the address — see `AccountId`. */ +export const BSC_SPAM_ACCOUNT_ID = 'b1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'; + +export const CDOGE_ADDRESS_LOWERCASE = + '0xa7255c85232a42b5c602ed66c319da9af8433bb3'; + +export const CDOGE_ADDRESS_CHECKSUM = + '0xA7255C85232A42B5c602ed66c319dA9af8433bb3'; + +export const CDOGE_ASSET_ID_LOWERCASE = + `${BSC_CHAIN_ID}/erc20:${CDOGE_ADDRESS_LOWERCASE}` as const; + +export const CDOGE_ASSET_ID_CHECKSUM = + `${BSC_CHAIN_ID}/erc20:${CDOGE_ADDRESS_CHECKSUM}` as const; + +/** Native BNB, which is never occurrence-filtered. */ +export const BNB_ASSET_ID = `${BSC_CHAIN_ID}/slip44:714` as const; + +// RPC mocks +export const BSC_CHAIN_ID_HEX = '0x38' as const; +export const BSC_NETWORK_CLIENT_ID = 'bsc' as const; +export const BSC_RPC_URL = 'https://bsc-rpc.test'; diff --git a/packages/assets-controller/src/__fixtures__/test-utils.ts b/packages/assets-controller/src/__fixtures__/test-utils.ts index e57363787e8..d29cb529938 100644 --- a/packages/assets-controller/src/__fixtures__/test-utils.ts +++ b/packages/assets-controller/src/__fixtures__/test-utils.ts @@ -3,6 +3,11 @@ type WaitForOptions = { timeoutMs?: number; }; +type WaitUntilStableOptions = WaitForOptions & { + /** How long the snapshot has to stay unchanged before it counts as stable. */ + stableForMs?: number; +}; + /** * Testing Utility - waitFor. Waits for and checks (at an interval) if assertion is reached. * @@ -11,33 +16,68 @@ type WaitForOptions = { * @returns promise that you need to await in tests */ export const waitFor = async ( - assertionFn: () => void, + assertionFn: () => void | Promise, options: WaitForOptions = {}, ): Promise => { const { intervalMs = 50, timeoutMs = 2000 } = options; const startTime = Date.now(); + let lastError: unknown; + + while (Date.now() - startTime < timeoutMs) { + try { + await assertionFn(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + try { + await assertionFn(); + return; + } catch (error) { + lastError = error; + } - return new Promise((resolve, reject) => { - let lastError: unknown; - const intervalId = setInterval(() => { - try { - assertionFn(); - clearInterval(intervalId); - resolve(); - } catch (error) { - lastError = error; - if (Date.now() - startTime >= timeoutMs) { - clearInterval(intervalId); - const assertionDetail = - lastError instanceof Error ? lastError.message : String(lastError); - reject( - new Error( - `waitFor: timeout reached after ${timeoutMs}ms. Last assertion error: ${assertionDetail}`, - ), - ); - } + const assertionDetail = + lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `waitFor: timeout reached after ${timeoutMs}ms. Last assertion error: ${assertionDetail}`, + ); +}; + +/** + * Testing Utility - waitUntilStable. Waits until a snapshot stops changing, + * for tests that need background work to be finished rather than a particular + * value to appear. Use it before asserting something is absent, so the + * assertion cannot pass merely because the write has not landed yet. + * + * @param takeSnapshot - returns the value to watch; must be JSON-serializable + * @param options - set wait for options + * @returns promise that you need to await in tests + */ +export const waitUntilStable = async ( + takeSnapshot: () => unknown, + options: WaitUntilStableOptions = {}, +): Promise => { + const { intervalMs, stableForMs = 150, timeoutMs } = options; + + let snapshot = JSON.stringify(takeSnapshot()); + let stableSince = Date.now(); + + await waitFor( + () => { + const current = JSON.stringify(takeSnapshot()); + if (current !== snapshot) { + snapshot = current; + stableSince = Date.now(); + } + if (Date.now() - stableSince < stableForMs) { + throw new Error('snapshot is still changing'); } - }, intervalMs); - }); + }, + { intervalMs, timeoutMs }, + ); }; diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index 0fbdf5e315d..32b4a913a54 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -6,12 +6,16 @@ import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import { - createMockAssetControllerMessenger, + createMockMessengers, MockRootMessenger, + registerAssetsControllerStateMock, registerRpcDataSourceActions, } from '../__fixtures__/MockAssetControllerMessenger.js'; import { getDefaultAssetsControllerState } from '../AssetsController.js'; -import type { AssetsControllerMessenger } from '../AssetsController.js'; +import type { + AssetsControllerMessenger, + AssetsControllerState, +} from '../AssetsController.js'; import type { Caip19AssetId, ChainId, DataRequest, Context } from '../types.js'; import { normalizeAssetId } from '../utils/index.js'; import { BalanceFetcher, TokenDetector } from './evm-rpc-services/index.js'; @@ -157,13 +161,20 @@ async function withController( actionHandlerOverrides, } = controllerOptions; - const { rootMessenger, assetsControllerMessenger } = - createMockAssetControllerMessenger(); + const { rootMessenger, assetsControllerMessenger } = createMockMessengers(); const defaultNetworkState = networkState ?? createMockNetworkState(); + // TODO - code smell, why is our internal logic trying to call its own methods via messenger? + registerAssetsControllerStateMock( + assetsControllerMessenger, + actionHandlerOverrides?.['AssetsController:getState'] as + | (() => AssetsControllerState) + | undefined, + ); + if (actionHandlerOverrides) { for (const [action, handler] of Object.entries(actionHandlerOverrides)) { - if (handler) { + if (handler && action !== 'AssetsController:getState') { ( rootMessenger as { registerActionHandler: (a: string, h: () => unknown) => void; @@ -191,15 +202,6 @@ async function withController( configuration: { chainId: MOCK_CHAIN_ID_HEX }, })); } - if (!actionHandlerOverrides['AssetsController:getState']) { - ( - rootMessenger as { - registerActionHandler: (a: string, h: () => unknown) => void; - } - ).registerActionHandler('AssetsController:getState', () => - getDefaultAssetsControllerState(), - ); - } if (!actionHandlerOverrides['NetworkEnablementController:getState']) { ( rootMessenger as { @@ -291,11 +293,12 @@ describe('caipChainIdToHex', () => { describe('createRpcDataSource', () => { it('returns an instance of RpcDataSource', () => { - const { assetsControllerMessenger } = createMockAssetControllerMessenger(); + const { assetsControllerMessenger } = createMockMessengers(); const source = createRpcDataSource({ messenger: assetsControllerMessenger, onActiveChainsUpdated: jest.fn(), getNativeAssetForChain: jest.fn(), + getAssetType: jest.fn(), }); expect(source).toBeInstanceOf(RpcDataSource); source.destroy(); @@ -2038,10 +2041,11 @@ describe('RpcDataSource', () => { describe('destroy', () => { it('cleans up subscriptions and caches', () => { - const { rootMessenger, assetsControllerMessenger } = - createMockAssetControllerMessenger(); - registerRpcDataSourceActions(rootMessenger, { - networkState: createMockNetworkState(), + const { assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (rootMessenger) => + registerRpcDataSourceActions(rootMessenger, { + networkState: createMockNetworkState(), + }), }); const controller = new RpcDataSource({ messenger: assetsControllerMessenger, diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts index ec928eb6a0e..d6028864f01 100644 --- a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts @@ -3,7 +3,7 @@ import { TransactionStatus } from '@metamask/transaction-controller'; import { MockRootMessenger, - createMockAssetControllerMessenger, + createMockMessengers, createMockWeb3Provider, registerStakedMessengerActions, } from '../__fixtures__/MockAssetControllerMessenger.js'; @@ -67,6 +67,7 @@ function getMockAssetsState(): AssetsControllerStateInternal { assetsPrice: {}, customAssets: {}, assetPreferences: {}, + selectedCurrency: 'usd', }; } @@ -120,11 +121,12 @@ async function withController( }), } = controllerOptions; - const { assetsControllerMessenger, rootMessenger } = - createMockAssetControllerMessenger(); - registerStakedMessengerActions(rootMessenger, { - enabledNetworkMap, - mockProvider, + const { rootMessenger, assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (messenger) => + registerStakedMessengerActions(messenger, { + enabledNetworkMap, + mockProvider, + }), }); // spy on staked messenger calls, so we can inspect and assert diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts index fffed2b9461..013fe6d115d 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -16,6 +16,7 @@ import type { AssetMetadata, Middleware, FungibleAssetMetadata, + DataResponse, } from '../types.js'; import { fetchWithTimeout } from '../utils/index.js'; import { @@ -145,6 +146,44 @@ function getOccurrenceFloorForAsset( } } +function cleanResponseSpam( + spamAssetIds: Set, + response: DataResponse, +): void { + const spamLowerIds = new Set([...spamAssetIds].map((id) => id.toLowerCase())); + + // Correctly clean assetsBalance by its own Ids + if (response.assetsBalance) { + for (const accountBalances of Object.values(response.assetsBalance)) { + for (const assetId of Object.keys(accountBalances)) { + if (spamLowerIds.has(assetId.toLowerCase())) { + delete (accountBalances as Record)[assetId]; + } + } + } + } + + // Correctly clean assetsInfo by its own Ids + if (response.assetsInfo) { + for (const assetId of Object.keys(response.assetsInfo)) { + if (spamLowerIds.has(assetId.toLowerCase())) { + delete response.assetsInfo[assetId as Caip19AssetId]; + } + } + } + + // Correctly clean detectedAssets by its own Ids + if (response.detectedAssets) { + for (const [accountId, assetIds] of Object.entries( + response.detectedAssets, + )) { + response.detectedAssets[accountId] = assetIds.filter( + (id) => !spamLowerIds.has(id.toLowerCase()), + ); + } + } +} + // ============================================================================ // TOKEN DATA SOURCE // ============================================================================ @@ -464,23 +503,7 @@ export class TokenDataSource { } if (spamAssetIds.size > 0) { - for (const accountBalances of Object.values( - response.assetsBalance ?? {}, - )) { - for (const assetId of spamAssetIds) { - delete (accountBalances as Record)[assetId]; - } - } - if (response.assetsInfo) { - const spamLowerIds = new Set( - [...spamAssetIds].map((id) => id.toLowerCase()), - ); - for (const assetId of Object.keys(response.assetsInfo)) { - if (spamLowerIds.has(assetId.toLowerCase())) { - delete response.assetsInfo[assetId as Caip19AssetId]; - } - } - } + cleanResponseSpam(spamAssetIds, response); log('Filtered low-occurrence websocket assets', { assetIds: [...spamAssetIds], }); @@ -734,39 +757,7 @@ export class TokenDataSource { } if (filteredOutAssets.size > 0) { - if (response.assetsBalance) { - for (const accountBalances of Object.values( - response.assetsBalance, - )) { - for (const assetId of filteredOutAssets) { - delete (accountBalances as Record)[assetId]; - } - } - } - - if (response.detectedAssets) { - for (const [accountId, assetIds] of Object.entries( - response.detectedAssets, - )) { - response.detectedAssets[accountId] = assetIds.filter( - (id) => !filteredOutAssets.has(id), - ); - } - } - - // Drop stub metadata (e.g. websocket-seeded name/symbol) for - // filtered-out assets so it never persists to state — a persisted - // stub would make the asset look "known" on the next update and - // let its balance skip spam filtering as a heal. Case-insensitive - // because the API may return asset IDs in a different case. - const filteredOutLower = new Set( - [...filteredOutAssets].map((id) => id.toLowerCase()), - ); - for (const assetId of Object.keys(response.assetsInfo)) { - if (filteredOutLower.has(assetId.toLowerCase())) { - delete response.assetsInfo[assetId as Caip19AssetId]; - } - } + cleanResponseSpam(filteredOutAssets, response); } } catch (error) { log('Failed to fetch metadata', { error }); diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts new file mode 100644 index 00000000000..e1e0f6b1a87 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.bsc-spam-token-filtering.integration.test.ts @@ -0,0 +1,241 @@ +import { parseCaipAssetType } from '@metamask/utils'; +import { cleanAll } from 'nock'; + +import { mockBscSpamApis } from '../__fixtures__/bsc-spam-token/api-responses/index.js'; +import { + buildBscSpamAccount, + buildEmptyAssetsState, + getIgnoringCase, +} from '../__fixtures__/bsc-spam-token/bscSpamWallet.js'; +import { registerBscSpamNetwork } from '../__fixtures__/bsc-spam-token/messenger.js'; +import { + BNB_ASSET_ID, + BSC_CHAIN_ID, + BSC_SPAM_ACCOUNT_ID, + CDOGE_ASSET_ID_LOWERCASE, +} from '../__fixtures__/bsc-spam-token/wallet.js'; +import { createMockMessengers } from '../__fixtures__/MockAssetControllerMessenger.js'; +import { createTestApiClient } from '../__fixtures__/mockTokenApi.js'; +import { AccountsApiDataSource } from '../data-sources/AccountsApiDataSource.js'; +import { PriceDataSource } from '../data-sources/PriceDataSource.js'; +import { RpcDataSource } from '../data-sources/RpcDataSource.js'; +import { StakedBalanceDataSource } from '../data-sources/StakedBalanceDataSource.js'; +import { TokenDataSource } from '../data-sources/TokenDataSource.js'; +import { CustomAssetGraduationMiddleware } from '../middlewares/CustomAssetGraduationMiddleware.js'; +import { DetectionMiddleware } from '../middlewares/DetectionMiddleware.js'; +import { RpcFallbackMiddleware } from '../middlewares/RpcFallbackMiddleware.js'; +import type { + AccountId, + AssetsControllerStateInternal, + Caip19AssetId, + DataRequest, + DataResponse, +} from '../types.js'; +import { buildFastFetchSources, executeAssetsPipeline } from './index.js'; + +/** + * Integration coverage for the fast fetch lane against the BNB Chain wallet + * from the `$$$DOGECHAIN` (`CDOGE`) spam-token report. + * + * Executes the real fast-lane pipeline against realistic APIs. + * + * Integration Expectation - CDOGE is correctly filtered out. + */ + +type ResponseSurface = { + surface: string; + lookUp: (response: DataResponse, assetId: string) => unknown; +}; + +const BALANCES: ResponseSurface = { + surface: 'balances', + lookUp: (response, assetId) => + getIgnoringCase( + response.assetsBalance?.[BSC_SPAM_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()), +}; + +async function runPipeline( + state: AssetsControllerStateInternal, +): Promise { + const { assetsControllerMessenger } = createMockMessengers({ + registerCustomRootActions: (rootMessenger) => { + // Note - this may change as we add feature flags to the controller/pipeline + // e.g. Accounts API v6 + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + (): { + remoteFeatureFlags: Record; + cacheTimestamp: number; + } => ({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }), + ); + + registerBscSpamNetwork(rootMessenger); + }, + }); + + const queryApiClient = createTestApiClient(); + + const accountsApiDataSource = new AccountsApiDataSource({ + messenger: assetsControllerMessenger, + queryApiClient, + onActiveChainsUpdated: jest.fn(), + }); + + const stakedBalanceDataSource = new StakedBalanceDataSource({ + messenger: assetsControllerMessenger, + onActiveChainsUpdated: jest.fn(), + }); + + const rpcDataSource = new RpcDataSource({ + messenger: assetsControllerMessenger, + onActiveChainsUpdated: jest.fn(), + getNativeAssetForChain: (): Caip19AssetId => BNB_ASSET_ID, + getAssetType: (assetId): 'native' | 'erc20' => + parseCaipAssetType(assetId).assetNamespace === 'erc20' + ? 'erc20' + : 'native', + }); + + const tokenDataSource = new TokenDataSource(assetsControllerMessenger, { + queryApiClient, + getNativeAssetIds: (): string[] => [BNB_ASSET_ID], + getAssetType: (assetId): 'native' | 'erc20' => + parseCaipAssetType(assetId).assetNamespace === 'erc20' + ? 'erc20' + : 'native', + }); + + const priceDataSource = new PriceDataSource({ + queryApiClient, + getSelectedCurrency: (): 'usd' => 'usd', + }); + + mockBscSpamApis(); + + await accountsApiDataSource.refreshActiveChains(); + + const account = buildBscSpamAccount(); + const request: DataRequest = { + accountsWithSupportedChains: [{ account, supportedChains: [BSC_CHAIN_ID] }], + chainIds: [BSC_CHAIN_ID], + assetTypes: ['fungible'], + dataTypes: ['balance', 'metadata', 'price'], + forceUpdate: true, + }; + + const sources = buildFastFetchSources( + { + accountsApiDataSource, + stakedBalanceDataSource, + customAssetGraduationMiddleware: new CustomAssetGraduationMiddleware({ + getSelectedAccountId: (): AccountId => BSC_SPAM_ACCOUNT_ID, + removeCustomAsset: (): void => { + throw new Error( + 'Integration should not call graduation to remove assets!', + ); + }, + }), + rpcFallbackMiddleware: new RpcFallbackMiddleware({ rpcDataSource }), + detectionMiddleware: new DetectionMiddleware(), + tokenDataSource, + priceDataSource, + }, + { isBasicFunctionality: true }, + ); + + const { response } = await executeAssetsPipeline({ + sources, + request, + getAssetsState: () => state, + }); + + accountsApiDataSource.destroy(); + stakedBalanceDataSource.destroy(); + rpcDataSource.destroy(); + queryApiClient.clear(); + + return response; +} + +const WALLET_PASSES = [ + { + pass: 'first pass over a fresh wallet', + run: async (): Promise => + runPipeline(buildEmptyAssetsState()), + }, + { + pass: 'second pass over the wallet the first pass left behind', + run: async (): Promise => { + const firstPass = await runPipeline(buildEmptyAssetsState()); + cleanAll(); + + return runPipeline( + buildEmptyAssetsState({ + assetsBalance: firstPass.assetsBalance, + assetsInfo: firstPass.assetsInfo, + assetsPrice: firstPass.assetsPrice, + }), + ); + }, + }, +]; + +describe('assets pipeline: BNB Chain spam token (CDOGE)', () => { + afterEach(() => { + cleanAll(); + }); + + describe.each(WALLET_PASSES)('$pass', ({ run }) => { + let response: DataResponse; + + beforeAll(async () => { + response = await run(); + }); + + it.each([BALANCES, METADATA, DETECTED_ASSETS])( + '$surface - filter out the spam token', + ({ lookUp }) => { + expect(lookUp(response, CDOGE_ASSET_ID_LOWERCASE)).toBeUndefined(); + }, + ); + + it.each([BALANCES, METADATA])( + '$surface - keeps the native BNB asset despite low occurrences', + ({ lookUp }) => { + expect(lookUp(response, BNB_ASSET_ID)).toBeDefined(); + }, + ); + + // Legitimate failing test, our middleware stack does not filter out spam + // asset prices! This does eventually get cleaned up during unlock cleanup, + // but worth flagging. + it.failing('keeps the spam token out of prices', () => { + expect(PRICES.lookUp(response, CDOGE_ASSET_ID_LOWERCASE)).toBeUndefined(); + }); + }); +}); diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts new file mode 100644 index 00000000000..632cc6c616f --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts @@ -0,0 +1,61 @@ +import type { BalanceSource } from '../middlewares/ParallelMiddleware.js'; +import type { AssetsDataSource, ChainId, Middleware } from '../types.js'; +import { buildFastFetchSources } from './buildFastFetchSources.js'; +import type { FastFetchSources } from './buildFastFetchSources.js'; + +function stubSource(name: string): AssetsDataSource { + return { + getName: () => name, + assetsMiddleware: (async (ctx) => ctx) as Middleware, + }; +} + +function stubBalanceSource(name: string): BalanceSource { + return { + ...stubSource(name), + getActiveChainsSync: (): ChainId[] => [], + }; +} + +function buildSources(): FastFetchSources { + return { + accountsApiDataSource: stubBalanceSource('AccountsApiDataSource'), + stakedBalanceDataSource: stubBalanceSource('StakedBalanceDataSource'), + customAssetGraduationMiddleware: stubSource( + 'CustomAssetGraduationMiddleware', + ), + rpcFallbackMiddleware: stubSource('RpcFallbackMiddleware'), + detectionMiddleware: stubSource('DetectionMiddleware'), + tokenDataSource: stubSource('TokenDataSource'), + priceDataSource: stubSource('PriceDataSource'), + }; +} + +describe('buildFastFetchSources', () => { + it.each([ + { + title: + 'orders the lane balances → graduation → rpc fallback → detection → enrichment', + isBasicFunctionality: true, + expected: [ + 'ParallelBalanceMiddleware', + 'CustomAssetGraduationMiddleware', + 'RpcFallbackMiddleware', + 'DetectionMiddleware', + 'ParallelMiddleware', + ], + }, + { + title: 'runs only the staking balance and detection', + isBasicFunctionality: false, + // No network-backed source may run when the user has opted out. + expected: ['StakedBalanceDataSource', 'DetectionMiddleware'], + }, + ])('$title', ({ isBasicFunctionality, expected }) => { + const sources = buildFastFetchSources(buildSources(), { + isBasicFunctionality, + }); + + expect(sources.map((source) => source.getName())).toStrictEqual(expected); + }); +}); diff --git a/packages/assets-controller/src/pipeline/buildFastFetchSources.ts b/packages/assets-controller/src/pipeline/buildFastFetchSources.ts new file mode 100644 index 00000000000..b81db374d23 --- /dev/null +++ b/packages/assets-controller/src/pipeline/buildFastFetchSources.ts @@ -0,0 +1,73 @@ +import { + createParallelBalanceMiddleware, + createParallelMiddleware, +} from '../middlewares/ParallelMiddleware.js'; +import type { BalanceSource } from '../middlewares/ParallelMiddleware.js'; +import type { AssetsDataSource } from '../types.js'; + +/** + * The sources the fast fetch 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 FastFetchSources = { + accountsApiDataSource: BalanceSource; + stakedBalanceDataSource: BalanceSource; + customAssetGraduationMiddleware: AssetsDataSource; + rpcFallbackMiddleware: AssetsDataSource; + detectionMiddleware: AssetsDataSource; + tokenDataSource: AssetsDataSource; + priceDataSource: AssetsDataSource; +}; + +/** + * Compose the fast fetch lane: balances in parallel → custom-asset graduation → + * RPC fallback → detection → token metadata and prices in parallel. + * + * Snap and RPC balance sources are deliberately absent — the controller runs + * those in a background lane because of their latency. + * + * Ordering carries two invariants: + * - Graduation runs BEFORE the RPC fallback so it only ever sees Accounts + * API / websocket balances. RPC intentionally carries custom assets and must + * never trigger graduation. + * - 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 the staking balance and + * detection run; no network-backed source is used. + * @returns The composed source list, ready for `executeAssetsPipeline`. + */ +export function buildFastFetchSources( + sources: FastFetchSources, + options: { isBasicFunctionality: boolean }, +): AssetsDataSource[] { + const { + accountsApiDataSource, + stakedBalanceDataSource, + customAssetGraduationMiddleware, + rpcFallbackMiddleware, + detectionMiddleware, + tokenDataSource, + priceDataSource, + } = sources; + + if (!options.isBasicFunctionality) { + return [stakedBalanceDataSource, detectionMiddleware]; + } + + return [ + createParallelBalanceMiddleware([ + accountsApiDataSource, + stakedBalanceDataSource, + ]), + customAssetGraduationMiddleware, + rpcFallbackMiddleware, + detectionMiddleware, + createParallelMiddleware([tokenDataSource, priceDataSource]), + ]; +} diff --git a/packages/assets-controller/src/pipeline/executeAssetsPipeline.ts b/packages/assets-controller/src/pipeline/executeAssetsPipeline.ts new file mode 100644 index 00000000000..9cf7a460f87 --- /dev/null +++ b/packages/assets-controller/src/pipeline/executeAssetsPipeline.ts @@ -0,0 +1,178 @@ +import type { TraceCallback, TraceContext } from '@metamask/controller-utils'; + +import { AssetsDataSourceError } from '../errors.js'; +import type { + AssetsControllerStateInternal, + AssetsDataSource, + DataRequest, + DataResponse, + FetchContext, + FetchNextFunction, + Middleware, + NextFunction, +} from '../types.js'; +import { emitTrace } from '../utils/trace.js'; + +const TRACE_DATA_SOURCE_TIMING = 'AssetsDataSourceTiming'; +const TRACE_DATA_SOURCE_ERROR = 'AssetsDataSourceError'; + +export type ExecuteAssetsPipelineParams = { + /** Data sources or middlewares with getName() and assetsMiddleware. */ + sources: AssetsDataSource[]; + /** The data request. */ + request: DataRequest; + /** Optional initial response (for enriching existing data). */ + initialResponse?: DataResponse; + /** Reads the current controller state, exposed to every middleware via context. */ + getAssetsState: () => AssetsControllerStateInternal; + /** Reports middleware failures as an issue. Never allowed to throw. */ + captureException?: (error: Error) => void; + /** Optional parent Sentry span; per-source timings nest under it. */ + parentContext?: TraceContext; + /** Omit after unlock/first-init so timing spans are not emitted. */ + trace?: TraceCallback; +}; + +/** + * Execute middlewares with request/response context. + * Returns response and exclusive duration per source (sum ≈ wall time). + * + * Extracted from `AssetsController` so a pipeline can be composed and driven + * without booting the controller (see `buildFastFetchSources`). + * + * @param params - Middleware execution options. + * @returns Response and durationByDataSource (exclusive ms per source name). + */ +export async function executeAssetsPipeline( + params: ExecuteAssetsPipelineParams, +): Promise<{ + response: DataResponse; + durationByDataSource: Record; +}> { + const { + sources, + request, + initialResponse = {}, + getAssetsState, + captureException, + parentContext, + trace, + } = params; + const names = sources.map((source) => source.getName()); + const middlewares = sources.map((source) => source.assetsMiddleware); + const inclusive: number[] = []; + const wrapped = middlewares.map( + (middleware, i) => + (async ( + ctx: FetchContext, + next: FetchNextFunction, + ): Promise<{ + request: DataRequest; + response: DataResponse; + getAssetsState: () => AssetsControllerStateInternal; + }> => { + const start = performance.now(); + try { + return await middleware(ctx, next); + } finally { + inclusive[i] = performance.now() - start; + } + }) as Middleware, + ); + + const middlewareErrors: string[] = []; + const chain = wrapped.reduceRight( + (next, middleware, index) => + async ( + ctx, + ): Promise<{ + request: DataRequest; + response: DataResponse; + getAssetsState: () => AssetsControllerStateInternal; + }> => { + try { + return await middleware(ctx, next); + } catch (error) { + const sourceName = names[index] ?? `middleware_${index}`; + middlewareErrors.push(sourceName); + console.error('[AssetsController] Middleware failed:', error); + return next(ctx); + } + }, + async (ctx) => ctx, + ); + + const result = await chain({ + request, + response: initialResponse, + getAssetsState, + }); + + const durationByDataSource: Record = {}; + for (let i = 0; i < inclusive.length; i++) { + const nextInc = i + 1 < inclusive.length ? (inclusive[i + 1] ?? 0) : 0; + const exclusive = Math.max(0, (inclusive[i] ?? 0) - nextInc); + const name = names[i]; + if (name !== undefined) { + durationByDataSource[name] = exclusive; + } + } + if (result.durationByDataSource) { + for (const [key, ms] of Object.entries(result.durationByDataSource)) { + durationByDataSource[key] = ms; + } + } + + // Emit per-source timing as subspans under the parent fetch/update span + // (no-op when `trace` is omitted — unlock/first-init only). + for (const [sourceName, durationMs] of Object.entries(durationByDataSource)) { + emitTrace({ + name: TRACE_DATA_SOURCE_TIMING, + trace, + data: { + source: sourceName, + duration_ms: durationMs, + chain_count: request.chainIds.length, + account_count: request.accountsWithSupportedChains.length, + }, + tags: { + controller: 'AssetsController', + // String tag so Spans widgets can group by `source`. + source: sourceName, + }, + parentContext, + }); + } + + // Failed middlewares: Issues (optional) + perf/Dashboard spans + if (middlewareErrors.length > 0) { + const failedSources = middlewareErrors.join(','); + const assetsError = new AssetsDataSourceError({ + failedSources, + errorCount: middlewareErrors.length, + chainCount: request.chainIds.length, + }); + try { + captureException?.(assetsError); + } catch { + // Never let telemetry throw. + } + emitTrace({ + name: TRACE_DATA_SOURCE_ERROR, + trace, + data: { + failed_sources: failedSources, + error_count: middlewareErrors.length, + chain_count: request.chainIds.length, + }, + tags: { + controller: 'AssetsController', + severity: 'error', + error_type: assetsError.name, + }, + parentContext, + }); + } + + return { response: result.response, durationByDataSource }; +} diff --git a/packages/assets-controller/src/pipeline/index.ts b/packages/assets-controller/src/pipeline/index.ts new file mode 100644 index 00000000000..5b128bd82b5 --- /dev/null +++ b/packages/assets-controller/src/pipeline/index.ts @@ -0,0 +1,4 @@ +export { buildFastFetchSources } from './buildFastFetchSources.js'; +export type { FastFetchSources } from './buildFastFetchSources.js'; +export { executeAssetsPipeline } from './executeAssetsPipeline.js'; +export type { ExecuteAssetsPipelineParams } from './executeAssetsPipeline.js'; diff --git a/packages/assets-controller/tsconfig.build.json b/packages/assets-controller/tsconfig.build.json index af4db63ebed..d3ca7fb59b9 100644 --- a/packages/assets-controller/tsconfig.build.json +++ b/packages/assets-controller/tsconfig.build.json @@ -59,6 +59,9 @@ { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, + { + "path": "../eth-json-rpc-provider/tsconfig.build.json" + }, { "path": "../utils/tsconfig.build.json" } diff --git a/packages/assets-controller/tsconfig.json b/packages/assets-controller/tsconfig.json index 07672d3ee4d..5643f6544ff 100644 --- a/packages/assets-controller/tsconfig.json +++ b/packages/assets-controller/tsconfig.json @@ -55,6 +55,9 @@ { "path": "../remote-feature-flag-controller" }, + { + "path": "../eth-json-rpc-provider" + }, { "path": "../utils" } diff --git a/yarn.lock b/yarn.lock index f7c6d47c78d..5e0be87b026 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5852,6 +5852,7 @@ __metadata: "@metamask/config-registry-controller": "npm:^4.0.0" "@metamask/controller-utils": "npm:^13.0.0" "@metamask/core-backend": "npm:^10.0.1" + "@metamask/eth-json-rpc-provider": "npm:^7.0.0" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^28.0.0" "@metamask/keyring-internal-api": "npm:^12.0.0"