diff --git a/packages/assets-controllers/src/AssetsController/AssetsController.ts b/packages/assets-controllers/src/AssetsController/AssetsController.ts new file mode 100644 index 00000000000..e83142f46e0 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/AssetsController.ts @@ -0,0 +1,1519 @@ +import { toChecksumAddress } from '@ethereumjs/util'; +import type { + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerSelectedAccountGroupChangeEvent, +} from '@metamask/account-tree-controller'; +import { + BaseController, + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type StateMetadata, +} from '@metamask/base-controller'; +import type { + KeyringControllerLockEvent, + KeyringControllerUnlockEvent, +} from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkEnablementControllerGetStateAction, + NetworkEnablementControllerEvents, + NetworkEnablementControllerState, +} from '@metamask/network-enablement-controller'; +import type { Json } from '@metamask/utils'; +import { parseCaipAssetType, parseCaipChainId } from '@metamask/utils'; +import { Mutex } from 'async-mutex'; +import { isEqual } from 'lodash'; + +import type { + TokensGetV3AssetsAction, + PricesGetV3SpotPricesAction, +} from '@metamask/core-backend'; + +import { projectLogger, createModuleLogger } from '../logger'; +import type { AccountsApiDataSourceGetAssetsMiddlewareAction } from './data-sources/AccountsApiDataSource'; +import type { DetectionMiddlewareGetAssetsMiddlewareAction } from './data-sources/DetectionMiddleware'; +import type { + PriceDataSourceGetAssetsMiddlewareAction, + PriceDataSourceFetchAction, + PriceDataSourceSubscribeAction, + PriceDataSourceUnsubscribeAction, +} from './data-sources/PriceDataSource'; +import type { RpcDataSourceGetAssetsMiddlewareAction } from './data-sources/RpcDataSource'; +import type { SnapDataSourceGetAssetsMiddlewareAction } from './data-sources/SnapDataSource'; +import type { TokenDataSourceGetAssetsMiddlewareAction } from './data-sources/TokenDataSource'; +import type { + AccountId, + ChainId, + Caip19AssetId, + AssetMetadata, + AssetPrice, + AssetBalance, + AssetType, + DataType, + DataRequest, + DataResponse, + NextFunction, + Middleware, + DataSourceDefinition, + RegisteredDataSource, + SubscriptionResponse, + Asset, + AssetsControllerStateInternal, +} from './types'; + +// ============================================================================ +// CONTROLLER CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'AssetsController' as const; + +/** Default polling interval hint for data sources (30 seconds) */ +const DEFAULT_POLLING_INTERVAL_MS = 30_000; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +// ============================================================================ +// STATE TYPES +// ============================================================================ + +/** + * State structure for AssetsController. + * + * All values are JSON-serializable. The type is widened to satisfy + * StateConstraint from BaseController, but the actual runtime values + * conform to AssetMetadata, AssetPrice, and AssetBalance interfaces. + * + * @see AssetsControllerStateInternal for the semantic type structure + */ +export type AssetsControllerState = { + /** Shared metadata for all assets (stored once per asset) */ + assetsMetadata: { [assetId: string]: Json }; + /** Per-account balance data */ + assetsBalance: { [accountId: string]: { [assetId: string]: Json } }; +}; + +/** + * Returns the default state for AssetsController. + */ +export function getDefaultAssetsControllerState(): AssetsControllerState { + return { + assetsMetadata: {}, + assetsBalance: {}, + }; +} + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +export type AssetsControllerGetStateAction = ControllerGetStateAction< + typeof CONTROLLER_NAME, + AssetsControllerState +>; + +export type AssetsControllerGetAssetsAction = { + type: `${typeof CONTROLLER_NAME}:getAssets`; + handler: AssetsController['getAssets']; +}; + +export type AssetsControllerGetAssetsBalanceAction = { + type: `${typeof CONTROLLER_NAME}:getAssetsBalance`; + handler: AssetsController['getAssetsBalance']; +}; + +export type AssetsControllerGetAssetMetadataAction = { + type: `${typeof CONTROLLER_NAME}:getAssetMetadata`; + handler: AssetsController['getAssetMetadata']; +}; + +export type AssetsControllerGetAssetsPriceAction = { + type: `${typeof CONTROLLER_NAME}:getAssetsPrice`; + handler: AssetsController['getAssetsPrice']; +}; + +export type AssetsControllerActiveChainsUpdateAction = { + type: `${typeof CONTROLLER_NAME}:activeChainsUpdate`; + handler: AssetsController['handleActiveChainsUpdate']; +}; + +export type AssetsControllerAssetsUpdateAction = { + type: `${typeof CONTROLLER_NAME}:assetsUpdate`; + handler: AssetsController['handleAssetsUpdate']; +}; + +export type AssetsControllerActions = + | AssetsControllerGetStateAction + | AssetsControllerGetAssetsAction + | AssetsControllerGetAssetsBalanceAction + | AssetsControllerGetAssetMetadataAction + | AssetsControllerGetAssetsPriceAction + | AssetsControllerActiveChainsUpdateAction + | AssetsControllerAssetsUpdateAction; + +export type AssetsControllerStateChangeEvent = ControllerStateChangeEvent< + typeof CONTROLLER_NAME, + AssetsControllerState +>; + +export type AssetsControllerBalanceChangedEvent = { + type: `${typeof CONTROLLER_NAME}:balanceChanged`; + payload: [ + { + accountId: AccountId; + assetId: Caip19AssetId; + previousAmount: string; + newAmount: string; + }, + ]; +}; + +export type AssetsControllerPriceChangedEvent = { + type: `${typeof CONTROLLER_NAME}:priceChanged`; + payload: [{ assetIds: Caip19AssetId[] }]; +}; + +export type AssetsControllerAssetsDetectedEvent = { + type: `${typeof CONTROLLER_NAME}:assetsDetected`; + payload: [{ accountId: AccountId; assetIds: Caip19AssetId[] }]; +}; + +export type AssetsControllerEvents = + | AssetsControllerStateChangeEvent + | AssetsControllerBalanceChangedEvent + | AssetsControllerPriceChangedEvent + | AssetsControllerAssetsDetectedEvent; + +type AllowedActions = + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | NetworkEnablementControllerGetStateAction + | TokensGetV3AssetsAction + | PricesGetV3SpotPricesAction + // Data source middlewares + | AccountsApiDataSourceGetAssetsMiddlewareAction + | SnapDataSourceGetAssetsMiddlewareAction + | RpcDataSourceGetAssetsMiddlewareAction + // Enrichment middlewares + | TokenDataSourceGetAssetsMiddlewareAction + | PriceDataSourceGetAssetsMiddlewareAction + | PriceDataSourceFetchAction + | PriceDataSourceSubscribeAction + | PriceDataSourceUnsubscribeAction + | DetectionMiddlewareGetAssetsMiddlewareAction; + +/** + * App lifecycle event: fired when app becomes active (opened/foregrounded) + */ +export type AppStateControllerAppOpenedEvent = { + type: 'AppStateController:appOpened'; + payload: []; +}; + +/** + * App lifecycle event: fired when app becomes inactive (closed/backgrounded) + */ +export type AppStateControllerAppClosedEvent = { + type: 'AppStateController:appClosed'; + payload: []; +}; + +type AllowedEvents = + | AccountTreeControllerSelectedAccountGroupChangeEvent + | NetworkEnablementControllerEvents + | AppStateControllerAppOpenedEvent + | AppStateControllerAppClosedEvent + | KeyringControllerLockEvent + | KeyringControllerUnlockEvent; + +export type AssetsControllerMessenger = Messenger< + typeof CONTROLLER_NAME, + AssetsControllerActions | AllowedActions, + AssetsControllerEvents | AllowedEvents +>; + +// ============================================================================ +// CONTROLLER OPTIONS +// ============================================================================ + +export interface AssetsControllerOptions { + messenger: AssetsControllerMessenger; + state?: Partial; + /** Default polling interval hint passed to data sources (ms) */ + defaultUpdateInterval?: number; +} + +// ============================================================================ +// STATE METADATA +// ============================================================================ + +const stateMetadata: StateMetadata = { + assetsMetadata: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, + assetsBalance: { + persist: true, + includeInStateLogs: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function extractChainId(assetId: Caip19AssetId): ChainId { + const parsed = parseCaipAssetType(assetId); + return parsed.chainId as ChainId; +} + +/** + * Normalizes a CAIP-19 asset ID by checksumming EVM addresses. + * This ensures consistent asset IDs regardless of the data source format. + * + * For EVM ERC20 tokens (e.g., "eip155:1/erc20:0x..."), the address is checksummed. + * All other asset types are returned unchanged. + * + * @param assetId - The CAIP-19 asset ID to normalize + * @returns The normalized asset ID with checksummed address (for EVM tokens) + */ +function normalizeAssetId(assetId: Caip19AssetId): Caip19AssetId { + const parsed = parseCaipAssetType(assetId); + const chainIdParsed = parseCaipChainId(parsed.chainId); + + // Only checksum EVM ERC20 addresses + if ( + chainIdParsed.namespace === 'eip155' && + parsed.assetNamespace === 'erc20' + ) { + const checksummedAddress = toChecksumAddress(parsed.assetReference); + return `${parsed.chainId}/${parsed.assetNamespace}:${checksummedAddress}` as Caip19AssetId; + } + + return assetId; +} + +/** + * Normalizes all asset IDs in a DataResponse. + * This is applied at the controller level to ensure consistent state + * regardless of how data sources format their asset IDs. + */ +function normalizeResponse(response: DataResponse): DataResponse { + const normalized: DataResponse = {}; + + if (response.assetsMetadata) { + normalized.assetsMetadata = {}; + for (const [assetId, metadata] of Object.entries(response.assetsMetadata)) { + const normalizedId = normalizeAssetId(assetId as Caip19AssetId); + normalized.assetsMetadata[normalizedId] = metadata; + } + } + + if (response.assetsPrice) { + normalized.assetsPrice = {}; + for (const [assetId, price] of Object.entries(response.assetsPrice)) { + const normalizedId = normalizeAssetId(assetId as Caip19AssetId); + normalized.assetsPrice[normalizedId] = price; + } + } + + if (response.assetsBalance) { + normalized.assetsBalance = {}; + for (const [accountId, balances] of Object.entries( + response.assetsBalance, + )) { + normalized.assetsBalance[accountId] = {}; + for (const [assetId, balance] of Object.entries(balances)) { + const normalizedId = normalizeAssetId(assetId as Caip19AssetId); + normalized.assetsBalance[accountId][normalizedId] = balance; + } + } + } + + return normalized; +} + +// ============================================================================ +// CONTROLLER IMPLEMENTATION +// ============================================================================ + +/** + * AssetsController provides a unified interface for managing asset balances + * across all blockchain networks (EVM and non-EVM) and all asset types. + * + * ## Core Responsibilities + * + * 1. **One-Time Fetch (Sync)**: For initial load, force refresh, or on-demand queries. + * Uses `getAssets()`, `getAssetsBalance()`, etc. with `forceUpdate: true`. + * + * 2. **Async Subscriptions**: Subscribes to data sources for ongoing updates. + * Data sources push updates via callbacks; the controller updates state. + * + * 3. **Dynamic Source Selection**: Routes requests to appropriate data sources + * based on which chains they support. When active chains change, the controller + * dynamically adjusts subscriptions. + * + * 4. **App Lifecycle Management**: Listens to app open/close events via messenger + * to start/stop subscriptions automatically, conserving resources when app is closed. + * + * ## App Lifecycle + * + * - **App Opened** (`AppStateController:appOpened`): Starts subscriptions, fetches initial data + * - **App Closed** (`AppStateController:appClosed`): Stops all subscriptions to conserve resources + * + * ## Architecture + * + * - Data sources declare their supported chains (async, can change over time) + * - Data sources are responsible for their own update mechanisms (WebSocket, polling, events) + * - The controller does NOT manage polling - it simply receives pushed updates + */ +export class AssetsController extends BaseController< + typeof CONTROLLER_NAME, + AssetsControllerState, + AssetsControllerMessenger +> { + /** Default update interval hint passed to data sources */ + private readonly defaultUpdateInterval: number; + + private readonly controllerMutex = new Mutex(); + + /** + * Active balance subscriptions keyed by account ID. + * Each account has one logical subscription that may span multiple data sources. + * For example, if WebSocket covers chains A,B and RPC covers chain C, + * the account subscribes to both data sources for its chains. + */ + private readonly activeSubscriptions: Map = + new Map(); + + /** Active price subscription ID (one global subscription for all assets) */ + private activePriceSubscription: string | undefined; + + /** Currently enabled chains from NetworkEnablementController */ + private enabledChains: ChainId[] = []; + + /** + * Get the currently selected accounts from AccountTreeController. + * This includes all accounts in the same group as the selected account + * (EVM, Bitcoin, Solana, Tron, etc. that belong to the same logical account group). + */ + private get selectedAccounts(): InternalAccount[] { + return this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); + } + + /** Price data for assets (in-memory only, not persisted) */ + private assetsPrice: Record = {}; + + /** + * Registered data sources with their available chains. + * Updated continuously and independently from subscription flows. + * Key: sourceId, Value: Set of currently available chainIds + */ + private readonly dataSources: Map> = new Map(); + + constructor({ + messenger, + state = {}, + defaultUpdateInterval = DEFAULT_POLLING_INTERVAL_MS, + }: AssetsControllerOptions) { + super({ + name: CONTROLLER_NAME, + messenger, + metadata: stateMetadata, + state: { + ...getDefaultAssetsControllerState(), + ...state, + }, + }); + + this.defaultUpdateInterval = defaultUpdateInterval; + + log('Initializing AssetsController', { + defaultUpdateInterval, + }); + + this.initializeState(); + this.subscribeToEvents(); + this.registerActionHandlers(); + + // Register data sources (order = subscription priority) + this.registerDataSources([ + 'BackendWebsocketDataSource', // Real-time push updates + 'AccountsApiDataSource', // HTTP polling fallback + 'SnapDataSource', // Solana/Bitcoin/Tron snaps + 'RpcDataSource', // Direct blockchain queries + ]); + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + private initializeState(): void { + const { enabledNetworkMap } = this.messenger.call( + 'NetworkEnablementController:getState', + ); + this.enabledChains = this.extractEnabledChains(enabledNetworkMap); + + log('Initialized state', { + enabledNetworkMap, + enabledChains: this.enabledChains, + }); + } + + /** + * Extract enabled chains from enabledNetworkMap. + * Returns CAIP-2 chain IDs for all enabled networks across all namespaces. + * + * Note: For EIP155 (EVM) chains, the reference is normalized to decimal format + * to ensure consistency with CAIP-2 standard and API responses. + */ + private extractEnabledChains( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): ChainId[] { + const chains: ChainId[] = []; + + for (const [namespace, networks] of Object.entries(enabledNetworkMap)) { + for (const [reference, isEnabled] of Object.entries(networks)) { + if (isEnabled) { + // Check if reference is already a full CAIP-2 chain ID (contains colon) + if (reference.includes(':')) { + // Already a full chain ID, use as-is + chains.push(reference as ChainId); + } else { + // Normalize EIP155 chain references from hex to decimal (CAIP-2 standard) + const normalizedReference = this.normalizeChainReference( + namespace, + reference, + ); + chains.push(`${namespace}:${normalizedReference}` as ChainId); + } + } + } + } + return chains; + } + + /** + * Normalize chain reference to CAIP-2 standard format. + * For EIP155, converts hex chain IDs to decimal. + */ + private normalizeChainReference( + namespace: string, + reference: string, + ): string { + if (namespace === 'eip155' && reference.startsWith('0x')) { + // Convert hex to decimal for EIP155 chains + return parseInt(reference, 16).toString(); + } + return reference; + } + + private subscribeToEvents(): void { + // Subscribe to account group changes (when user switches between account groups like Account 1 -> Account 2) + this.messenger.subscribe( + 'AccountTreeController:selectedAccountGroupChange', + () => { + this.handleAccountGroupChanged().catch(console.error); + }, + ); + + // Subscribe to network enablement changes (only enabledNetworkMap) + this.messenger.subscribe( + 'NetworkEnablementController:stateChange', + ({ enabledNetworkMap }) => { + this.handleEnabledNetworksChanged(enabledNetworkMap).catch( + console.error, + ); + }, + ); + + // App lifecycle: start when opened, stop when closed + this.messenger.subscribe('AppStateController:appOpened', () => this.start()); + this.messenger.subscribe('AppStateController:appClosed', () => this.stop()); + + // Keyring lifecycle: start when unlocked, stop when locked + this.messenger.subscribe('KeyringController:unlock', () => this.start()); + this.messenger.subscribe('KeyringController:lock', () => this.stop()); + } + + private registerActionHandlers(): void { + this.messenger.registerActionHandler( + 'AssetsController:getAssets', + this.getAssets.bind(this), + ); + + this.messenger.registerActionHandler( + 'AssetsController:getAssetsBalance', + this.getAssetsBalance.bind(this), + ); + + this.messenger.registerActionHandler( + 'AssetsController:getAssetMetadata', + this.getAssetMetadata.bind(this), + ); + + this.messenger.registerActionHandler( + 'AssetsController:getAssetsPrice', + this.getAssetsPrice.bind(this), + ); + + this.messenger.registerActionHandler( + 'AssetsController:activeChainsUpdate', + this.handleActiveChainsUpdate.bind(this), + ); + + this.messenger.registerActionHandler( + 'AssetsController:assetsUpdate', + this.handleAssetsUpdate.bind(this), + ); + } + + // ============================================================================ + // DATA SOURCE MANAGEMENT + // ============================================================================ + + /** + * Register data sources with the controller. + * Order of the array determines subscription order. + * + * Data sources report chain changes by calling `AssetsController:activeChainsUpdate` action. + */ + registerDataSources(dataSourceIds: DataSourceDefinition[]): void { + for (const id of dataSourceIds) { + log('Registering data source', { id }); + + // Initialize available chains tracking for this source + this.dataSources.set(id, new Set()); + } + } + + // ============================================================================ + // DATA SOURCE CHAIN MANAGEMENT + // ============================================================================ + + /** + * Handle when a data source's active chains change. + * Active chains are chains that are both supported AND available. + * Updates centralized chain tracking and triggers re-selection if needed. + * + * Data sources should call this via `AssetsController:activeChainsUpdate` action. + */ + handleActiveChainsUpdate( + dataSourceId: string, + activeChains: ChainId[], + ): void { + log('Data source active chains changed', { + dataSourceId, + chainCount: activeChains.length, + chains: activeChains, + }); + + const previousChains = this.dataSources.get(dataSourceId) ?? new Set(); + const newChains = new Set(activeChains); + + // Update centralized available chains tracking + this.dataSources.set(dataSourceId, newChains); + + // Check for changes + const addedChains = activeChains.filter( + (chain) => !previousChains.has(chain), + ); + const removedChains = Array.from(previousChains).filter( + (chain) => !newChains.has(chain), + ); + + if (addedChains.length > 0 || removedChains.length > 0) { + // Refresh subscriptions to use updated data source availability + this.subscribeToDataSources(); + } + + // If chains were added and we have selected accounts, do one-time fetch + if (addedChains.length > 0 && this.selectedAccounts.length > 0) { + const addedEnabledChains = addedChains.filter((chain) => + this.enabledChains.includes(chain), + ); + if (addedEnabledChains.length > 0) { + log('Fetching balances for newly added chains', { addedEnabledChains }); + this.getAssets(this.selectedAccounts, { + chainIds: addedEnabledChains, + forceUpdate: true, + }).catch((error) => { + log('Failed to fetch balance for added chains', { error }); + }); + } + } + } + + // ============================================================================ + // MIDDLEWARE EXECUTION + // ============================================================================ + + /** + * Execute middlewares with request/response context. + * @param middlewares - Middlewares to execute in order + * @param request - The data request + * @param initialResponse - Optional initial response (for enriching existing data) + */ + private async executeMiddlewares( + middlewares: Middleware[], + request: DataRequest, + initialResponse: DataResponse = {}, + ): Promise { + const chain = middlewares.reduceRight( + (next, middleware) => async (ctx) => { + try { + return await middleware(ctx, next); + } catch (error) { + console.error('[AssetsController] Middleware failed:', error); + return next(ctx); + } + }, + async (ctx) => ctx, + ); + + const result = await chain({ + request, + response: initialResponse, + getAssetsState: () => this.state as AssetsControllerStateInternal, + }); + return result.response; + } + + // ============================================================================ + // PUBLIC API: QUERY METHODS + // ============================================================================ + + async getAssets( + accounts: InternalAccount[], + options?: { + chainIds?: ChainId[]; + assetTypes?: AssetType[]; + forceUpdate?: boolean; + dataTypes?: DataType[]; + }, + ): Promise>> { + const chainIds = options?.chainIds ?? this.enabledChains; + const assetTypes = options?.assetTypes ?? ['fungible']; + const dataTypes = options?.dataTypes ?? ['balance', 'metadata', 'price']; + + if (options?.forceUpdate) { + const response = await this.executeMiddlewares( + [ + this.messenger.call('AccountsApiDataSource:getAssetsMiddleware'), + this.messenger.call('SnapDataSource:getAssetsMiddleware'), + this.messenger.call('RpcDataSource:getAssetsMiddleware'), + this.messenger.call('DetectionMiddleware:getAssetsMiddleware'), + this.messenger.call('TokenDataSource:getAssetsMiddleware'), + this.messenger.call('PriceDataSource:getAssetsMiddleware'), + ], { + accounts, + chainIds, + assetTypes, + dataTypes, + forceUpdate: true, + }); + await this.updateState(response); + } + + return this.getAssetsFromState(accounts, chainIds, assetTypes); + } + + async getAssetsBalance( + accounts: InternalAccount[], + options?: { + chainIds?: ChainId[]; + assetTypes?: AssetType[]; + forceUpdate?: boolean; + }, + ): Promise>> { + // Reuse getAssets with dataTypes: ['balance'] only + const assets = await this.getAssets(accounts, { + chainIds: options?.chainIds, + assetTypes: options?.assetTypes, + forceUpdate: options?.forceUpdate, + dataTypes: ['balance'], + }); + + // Extract just the balance from each asset + const result: Record> = {}; + for (const [accountId, accountAssets] of Object.entries(assets)) { + result[accountId] = {}; + for (const [assetId, asset] of Object.entries(accountAssets)) { + if (asset.balance) { + result[accountId][assetId as Caip19AssetId] = asset.balance; + } + } + } + + return result; + } + + getAssetMetadata(assetId: Caip19AssetId): AssetMetadata | undefined { + return this.state.assetsMetadata[assetId] as AssetMetadata | undefined; + } + + async getAssetsPrice( + accounts: InternalAccount[], + options?: { + chainIds?: ChainId[]; + assetTypes?: AssetType[]; + forceUpdate?: boolean; + }, + ): Promise> { + const assets = await this.getAssets(accounts, { + chainIds: options?.chainIds, + assetTypes: options?.assetTypes, + forceUpdate: options?.forceUpdate, + dataTypes: ['price'], + }); + + // Extract just the price from each asset (flattened across accounts) + const result: Record = {}; + for (const accountAssets of Object.values(assets)) { + for (const [assetId, asset] of Object.entries(accountAssets)) { + if (asset.price) { + result[assetId as Caip19AssetId] = asset.price; + } + } + } + + return result; + } + + // ============================================================================ + // SUBSCRIPTIONS + // ============================================================================ + + /** + * Assign chains to data sources based on availability. + * Returns a map of sourceId -> chains to handle. + */ + private assignChainsToDataSources( + requestedChains: ChainId[], + ): Map { + const assignment = new Map(); + const remainingChains = new Set(requestedChains); + + for (const sourceId of this.dataSources.keys()) { + // Get available chains for this data source + const availableChains = this.dataSources.get(sourceId); + if (!availableChains || availableChains.size === 0) { + continue; + } + + const chainsForThisSource: ChainId[] = []; + + for (const chainId of remainingChains) { + // Check if this chain is available on this source + if (availableChains.has(chainId)) { + chainsForThisSource.push(chainId); + remainingChains.delete(chainId); + } + } + + if (chainsForThisSource.length > 0) { + assignment.set(sourceId, chainsForThisSource); + log('Assigned chains to data source', { + sourceId, + chains: chainsForThisSource, + }); + } + } + + return assignment; + } + + /** + * Subscribe to price updates for all assets held by the given accounts. + * Polls PriceDataSource which fetches prices from balance state. + * + * @param accounts - Accounts to subscribe price updates for + * @param chainIds - Chain IDs to filter prices for + * @param options - Subscription options + * @param options.updateInterval - Polling interval in ms + */ + subscribeAssetsPrice( + accounts: InternalAccount[], + chainIds: ChainId[], + options: { updateInterval?: number } = {}, + ): void { + const { updateInterval = this.defaultUpdateInterval } = options; + const subscriptionId = 'price'; + + const isUpdate = this.activePriceSubscription !== undefined; + + this.messenger.call('PriceDataSource:subscribe', { + request: { + accounts, + chainIds, + dataTypes: ['price'], + updateInterval, + }, + subscriptionId, + isUpdate, + }) + + this.activePriceSubscription = subscriptionId; + } + + /** + * Unsubscribe from price updates. + */ + unsubscribeAssetsPrice(): void { + if (!this.activePriceSubscription) { + return; + } + this.messenger.call( + 'PriceDataSource:unsubscribe', + this.activePriceSubscription, + ) + this.activePriceSubscription = undefined; + } + + // ============================================================================ + // STATE MANAGEMENT + // ============================================================================ + + private async updateState(response: DataResponse): Promise { + // Normalize asset IDs (checksum EVM addresses) before storing in state + const normalizedResponse = normalizeResponse(response); + + + const releaseLock = await this.controllerMutex.acquire(); + + try { + const previousState = this.state; + const previousPrices = { ...this.assetsPrice }; + // Use detectedAssets from response (assets without metadata) + const detectedAssets: Record = + normalizedResponse.detectedAssets ?? {}; + + // Update prices in memory (not persisted in state) + if (normalizedResponse.assetsPrice) { + for (const [key, value] of Object.entries( + normalizedResponse.assetsPrice, + )) { + this.assetsPrice[key as Caip19AssetId] = value; + } + } + + // Track actual changes for logging + const changedBalances: Array<{ + accountId: string; + assetId: string; + oldAmount: string | undefined; + newAmount: string; + }> = []; + const changedMetadata: string[] = []; + + this.update((state) => { + // Use type assertions to avoid deep type instantiation issues with Draft + const metadata = state.assetsMetadata as unknown as Record< + string, + unknown + >; + const balances = state.assetsBalance as unknown as Record< + string, + Record + >; + + if (normalizedResponse.assetsMetadata) { + for (const [key, value] of Object.entries( + normalizedResponse.assetsMetadata, + )) { + if (!isEqual(previousState.assetsMetadata[key as Caip19AssetId], value)) { + changedMetadata.push(key); + } + metadata[key] = value; + } + } + + if (normalizedResponse.assetsBalance) { + for (const [accountId, accountBalances] of Object.entries( + normalizedResponse.assetsBalance, + )) { + const previousBalances = + previousState.assetsBalance[accountId] ?? {}; + + if (!balances[accountId]) { + balances[accountId] = {}; + } + + for (const [assetId, balance] of Object.entries(accountBalances)) { + const previousBalance = previousBalances[assetId as Caip19AssetId] as { amount: string } | undefined; + const balanceData = balance as { amount: string }; + const newAmount = balanceData.amount; + const oldAmount = previousBalance?.amount; + + // Track if balance actually changed + if (oldAmount !== newAmount) { + changedBalances.push({ + accountId, + assetId, + oldAmount, + newAmount, + }); + } + } + + Object.assign(balances[accountId], accountBalances); + } + } + }); + + // Calculate changed prices + const changedPriceAssets: string[] = normalizedResponse.assetsPrice + ? Object.keys(normalizedResponse.assetsPrice).filter( + (assetId) => + !isEqual( + previousPrices[assetId as Caip19AssetId], + normalizedResponse.assetsPrice?.[assetId as Caip19AssetId], + ), + ) + : []; + + // Log only actual changes + if (changedBalances.length > 0 || changedMetadata.length > 0 || changedPriceAssets.length > 0) { + log('State updated', { + changedBalances: changedBalances.length > 0 ? changedBalances : undefined, + changedMetadataCount: changedMetadata.length > 0 ? changedMetadata.length : undefined, + changedPricesCount: changedPriceAssets.length > 0 ? changedPriceAssets.length : undefined, + newAssets: Object.keys(detectedAssets).length > 0 + ? Object.entries(detectedAssets).map(([accountId, assets]) => ({ + accountId, + assets, + })) + : undefined, + }); + } + + for (const [accountId, assetIds] of Object.entries(detectedAssets)) { + if (assetIds.length > 0) { + this.messenger.publish('AssetsController:assetsDetected', { + accountId, + assetIds, + }); + } + } + + // Note: Prices for detected assets (assets without metadata) are fetched by the price middleware + // which subscribes to assetsBalance state changes + } finally { + releaseLock(); + } + } + + private getAssetsFromState( + accounts: InternalAccount[], + chainIds: ChainId[], + assetTypes: AssetType[], + ): Record> { + const result: Record> = {}; + + for (const account of accounts) { + result[account.id] = {}; + + const accountBalances = this.state.assetsBalance[account.id] ?? {}; + + for (const [assetId, balance] of Object.entries(accountBalances)) { + const typedAssetId = assetId as Caip19AssetId; + const assetChainId = extractChainId(typedAssetId); + + if (!chainIds.includes(assetChainId)) { + continue; + } + + const metadataRaw = this.state.assetsMetadata[typedAssetId]; + + // Skip assets without metadata + if (!metadataRaw) { + continue; + } + + const metadata = metadataRaw as AssetMetadata; + + // Filter by asset type + const isFungible = ['native', 'erc20', 'spl'].includes(metadata.type); + const isNft = ['erc721', 'erc1155'].includes(metadata.type); + + if (assetTypes.includes('fungible') && !isFungible) { + continue; + } + if (assetTypes.includes('nft') && !isNft) { + continue; + } + + const typedBalance = balance as AssetBalance; + const priceRaw = this.assetsPrice[typedAssetId]; + const price: AssetPrice = (priceRaw as AssetPrice) ?? { + price: 0, + lastUpdated: 0, + }; + + // Compute fiat value + const balanceAmount = parseFloat(typedBalance.amount) || 0; + const normalizedAmount = + balanceAmount / Math.pow(10, metadata.decimals); + const fiatValue = normalizedAmount * (price.price || 0); + + const asset: Asset = { + id: typedAssetId, + chainId: assetChainId, + balance: typedBalance, + metadata, + price, + fiatValue, + }; + + result[account.id][typedAssetId] = asset; + } + } + + return result; + } + + // ============================================================================ + // START / STOP + // ============================================================================ + + /** + * Start asset tracking: subscribe to updates and fetch current balances. + * Called when app opens, account changes, or keyring unlocks. + */ + private start(): void { + log('Starting asset tracking', { + selectedAccountCount: this.selectedAccounts.length, + enabledChainCount: this.enabledChains.length, + }); + + this.subscribeToDataSources(); + if (this.selectedAccounts.length > 0) { + this.getAssets(this.selectedAccounts, { + chainIds: this.enabledChains, + forceUpdate: true, + }).catch((error) => { + log('Failed to fetch assets', error); + }); + } + } + + /** + * Stop asset tracking: unsubscribe from all updates. + * Called when app closes or keyring locks. + */ + private stop(): void { + log('Stopping asset tracking', { + activeSubscriptionCount: this.activeSubscriptions.size, + hasPriceSubscription: !!this.activePriceSubscription, + }); + + // Stop balance subscriptions + for (const subscription of this.activeSubscriptions.values()) { + subscription.unsubscribe(); + } + this.activeSubscriptions.clear(); + + // Stop price subscription + this.unsubscribeAssetsPrice(); + } + + /** + * Subscribe to asset updates for all selected accounts. + */ + private subscribeToDataSources(): void { + if (this.selectedAccounts.length === 0) { + return; + } + + // Subscribe to balance updates (batched by data source) + this.subscribeAssetsBalance(); + + // Subscribe to price updates for all assets held by selected accounts + this.subscribeAssetsPrice(this.selectedAccounts, this.enabledChains); + } + + /** + * Subscribe to balance updates for all selected accounts. + * + * Strategy to minimize data source calls: + * 1. Collect all chains to subscribe based on enabled networks + * 2. Map chains to accounts based on their scopes + * 3. Split by data source (ordered by priority) - each data source gets ONE subscription + * + * This ensures we make minimal subscriptions to each data source while covering + * all accounts and chains. + */ + private subscribeAssetsBalance(): void { + // Step 1: Collect all chains to subscribe based on enabled networks + const allChainsToSubscribe = new Set(this.enabledChains); + + // Step 2: Build chain -> accounts mapping based on account scopes + const chainToAccounts = this.buildChainToAccountsMap( + this.selectedAccounts, + allChainsToSubscribe, + ); + + // Step 3: Split by data source active chains (ordered by priority) + // Get all chains that need to be subscribed + const remainingChains = new Set(chainToAccounts.keys()); + + // Assign chains to data sources based on availability (ordered by priority) + const chainAssignment = this.assignChainsToDataSources( + Array.from(remainingChains), + ); + + log('Subscribe - chain assignment', { + totalChains: remainingChains.size, + dataSourceAssignments: Array.from(chainAssignment.entries()).map( + ([sourceId, chains]) => ({ sourceId, chainCount: chains.length }), + ), + }); + + // Subscribe to each data source with its assigned chains and relevant accounts + for (const sourceId of this.dataSources.keys()) { + const assignedChains = chainAssignment.get(sourceId); + + if (!assignedChains || assignedChains.length === 0) { + // Unsubscribe from data sources with no assigned chains + this.unsubscribeDataSource(sourceId); + continue; + } + + // Collect unique accounts that need any of the assigned chains + const accountsForSource = this.getAccountsForChains( + assignedChains, + chainToAccounts, + ); + + if (accountsForSource.length === 0) { + continue; + } + + // Subscribe with ONE call per data source + this.subscribeToDataSource(sourceId, accountsForSource, assignedChains); + } + } + + /** + * Build a mapping of chainId -> accounts that support that chain. + * Only includes chains that are in the chainsToSubscribe set. + */ + private buildChainToAccountsMap( + accounts: InternalAccount[], + chainsToSubscribe: Set, + ): Map { + const chainToAccounts = new Map(); + + for (const account of accounts) { + const accountChains = this.getEnabledChainsForAccount(account); + + for (const chainId of accountChains) { + if (!chainsToSubscribe.has(chainId)) { + continue; + } + + const existingAccounts = chainToAccounts.get(chainId) ?? []; + existingAccounts.push(account); + chainToAccounts.set(chainId, existingAccounts); + } + } + + return chainToAccounts; + } + + /** + * Get unique accounts that need any of the specified chains. + */ + private getAccountsForChains( + chains: ChainId[], + chainToAccounts: Map, + ): InternalAccount[] { + const accountIds = new Set(); + const accounts: InternalAccount[] = []; + + for (const chainId of chains) { + const chainAccounts = chainToAccounts.get(chainId) ?? []; + for (const account of chainAccounts) { + if (!accountIds.has(account.id)) { + accountIds.add(account.id); + accounts.push(account); + } + } + } + + return accounts; + } + + /** + * Subscribe to a specific data source with accounts and chains. + * Uses the data source ID as the subscription key for batching. + */ + private subscribeToDataSource( + sourceId: string, + accounts: InternalAccount[], + chains: ChainId[], + ): void { + const subscriptionKey = `ds:${sourceId}`; + const existingSubscription = this.activeSubscriptions.get(subscriptionKey); + const isUpdate = existingSubscription !== undefined; + + log('Subscribe to data source', { + sourceId, + subscriptionKey, + isUpdate, + accountCount: accounts.length, + chainCount: chains.length, + }); + + // Call data source subscribe action via Messenger + (async () => { + try { + await (this.messenger.call as CallableFunction)( + `${sourceId}:subscribe`, + { + request: { + accounts, + chainIds: chains, + assetTypes: ['fungible'], + dataTypes: ['balance'], + updateInterval: this.defaultUpdateInterval, + }, + subscriptionId: subscriptionKey, + isUpdate, + }, + ); + } catch (error) { + console.error( + `[AssetsController] Failed to subscribe to '${sourceId}':`, + error, + ); + } + })(); + + // Track subscription + const subscription: SubscriptionResponse = { + chains, + accountId: subscriptionKey, + assetTypes: ['fungible'], + dataTypes: ['balance', 'price'], + unsubscribe: () => { + this.activeSubscriptions.delete(subscriptionKey); + }, + }; + + this.activeSubscriptions.set(subscriptionKey, subscription); + } + + /** + * Unsubscribe from a data source if we have an active subscription. + */ + private unsubscribeDataSource(sourceId: string): void { + const subscriptionKey = `ds:${sourceId}`; + const existingSubscription = this.activeSubscriptions.get(subscriptionKey); + + if (existingSubscription) { + (async () => { + try { + await (this.messenger.call as CallableFunction)( + `${sourceId}:unsubscribe`, + subscriptionKey, + ); + } catch { + // Ignore errors - source may not have been subscribed + } + })(); + existingSubscription.unsubscribe(); + } + } + + // ============================================================================ + // HELPERS + // ============================================================================ + + /** + * Get the chains that an account supports based on its scopes. + * Returns the intersection of the account's scopes and the enabled chains. + * + * @param account - The account to get supported chains for + * @returns Array of ChainIds that the account supports and are enabled + */ + private getEnabledChainsForAccount(account: InternalAccount): ChainId[] { + // Account scopes are CAIP-2 chain IDs like "eip155:1", "solana:mainnet", "bip122:..." + const scopes = account.scopes ?? []; + const result: ChainId[] = []; + + for (const scope of scopes) { + const [namespace, reference] = (scope as string).split(':'); + + // Wildcard scope (e.g., "eip155:0" means all enabled chains in that namespace) + if (reference === '0') { + const matchingChains = this.enabledChains.filter((chain) => + chain.startsWith(`${namespace}:`), + ); + result.push(...matchingChains); + } else if (namespace === 'eip155' && reference?.startsWith('0x')) { + // Normalize hex to decimal for EIP155 + result.push(`eip155:${parseInt(reference, 16)}` as ChainId); + } else { + result.push(scope as ChainId); + } + } + + return result; + } + + // ============================================================================ + // EVENT HANDLERS + // ============================================================================ + + private async handleAccountGroupChanged(): Promise { + const accounts = this.selectedAccounts; + + log('Account group changed', { + accountCount: accounts.length, + accountIds: accounts.map((a) => a.id), + }); + + // Subscribe and fetch for the new account group + this.subscribeToDataSources(); + if (accounts.length > 0) { + await this.getAssets(accounts, { + chainIds: this.enabledChains, + forceUpdate: true, + }); + } + } + + private async handleEnabledNetworksChanged( + enabledNetworkMap: NetworkEnablementControllerState['enabledNetworkMap'], + ): Promise { + const previousChains = this.enabledChains; + this.enabledChains = this.extractEnabledChains(enabledNetworkMap); + + // Find newly enabled chains + const addedChains = this.enabledChains.filter( + (chain) => !previousChains.includes(chain), + ); + + // Find disabled chains to clean up + const removedChains = previousChains.filter( + (chain) => !this.enabledChains.includes(chain), + ); + + log('Enabled networks changed', { + previousCount: previousChains.length, + newCount: this.enabledChains.length, + addedChains, + removedChains, + }); + + // Clean up state for disabled chains + if (removedChains.length > 0) { + this.update((state) => { + const balances = state.assetsBalance as unknown as Record< + string, + Record + >; + for (const accountId of Object.keys(balances)) { + for (const assetId of Object.keys(balances[accountId])) { + const assetChainId = extractChainId(assetId as Caip19AssetId); + if (removedChains.includes(assetChainId)) { + delete balances[accountId][assetId]; + } + } + } + }); + } + + // Refresh subscriptions for new chain set + this.subscribeToDataSources(); + + // Do one-time fetch for newly enabled chains + if (addedChains.length > 0 && this.selectedAccounts.length > 0) { + await this.getAssets(this.selectedAccounts, { + chainIds: addedChains, + forceUpdate: true, + }); + } + } + + /** + * Handle assets updated from a data source. + * Called via `AssetsController:assetsUpdate` action by data sources. + * + * @param response - The data response with updated assets + * @param sourceId - The data source ID reporting the update + */ + async handleAssetsUpdate( + response: DataResponse, + sourceId: string, + ): Promise { + log('Assets updated from data source', { + sourceId, + hasBalance: !!response.assetsBalance, + hasPrice: !!response.assetsPrice, + }); + await this.handleSubscriptionUpdate(response, sourceId); + } + + /** + * Handle an async update from a data source subscription. + * Enriches response with token metadata before updating state. + */ + private async handleSubscriptionUpdate( + response: DataResponse, + _sourceId?: string, + request?: DataRequest, + ): Promise { + // Run through enrichment middlewares (Event Stack: Detection → Token → Price) + const enrichedResponse = await this.executeMiddlewares( + [ + this.messenger.call('DetectionMiddleware:getAssetsMiddleware'), + this.messenger.call('TokenDataSource:getAssetsMiddleware'), + this.messenger.call('PriceDataSource:getAssetsMiddleware'), + ], + request ?? { accounts: [], chainIds: [], dataTypes: ['balance', 'price'] }, + response, + ); + + // Update state + await this.updateState(enrichedResponse); + } + + // ============================================================================ + // CLEANUP + // ============================================================================ + + destroy(): void { + log('Destroying AssetsController', { + dataSourceCount: this.dataSources.size, + subscriptionCount: this.activeSubscriptions.size, + }); + + // Clear data sources + this.dataSources.clear(); + + // Stop all active subscriptions + this.stop(); + + // Unregister action handlers + this.messenger.unregisterActionHandler('AssetsController:getAssets'); + this.messenger.unregisterActionHandler('AssetsController:getAssetsBalance'); + this.messenger.unregisterActionHandler( + 'AssetsController:getAssetMetadata', + ); + this.messenger.unregisterActionHandler('AssetsController:getAssetsPrice'); + this.messenger.unregisterActionHandler( + 'AssetsController:activeChainsUpdate', + ); + this.messenger.unregisterActionHandler( + 'AssetsController:assetsUpdate', + ); + } +} diff --git a/packages/assets-controllers/src/AssetsController/README.md b/packages/assets-controllers/src/AssetsController/README.md new file mode 100644 index 00000000000..7de92637594 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/README.md @@ -0,0 +1,928 @@ +# AssetsController + +## Overview + +The `AssetsController` is a unified asset management system that provides real-time balance tracking across all blockchain networks (EVM and non-EVM) and all asset types (native tokens, ERC-20, NFTs, etc.). It follows a **middleware architecture**. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ AssetsController │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ State │ │Subscriptions│ │ Middleware │ │ Events │ │ +│ │ Manager │ │ Manager │ │ Chain │ │ Publisher │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Fetch Stack Event Stack │ +│ ┌───────────────────┐ ┌───────────────────┐ │ +│ │ WebSocket │ │ │ │ +│ ├───────────────────┤ │ │ │ +│ │ AccountsAPI │ │ │ │ +│ ├───────────────────┤ │ │ │ +│ │ Snap │ │ │ │ +│ ├───────────────────┤ │ │ │ +│ │ RPC │ │ │ │ +│ ├───────────────────┤ ├───────────────────┤ │ +│ │ Detection │ │ Detection │ │ +│ ├───────────────────┤ ├───────────────────┤ │ +│ │ Token │ │ Token │ │ +│ ├───────────────────┤ ├───────────────────┤ │ +│ │ Price │ │ Price │ │ +│ └───────────────────┘ └───────────────────┘ │ +│ │ +│ On-demand data fetch Process incoming updates │ +│ (enrichment only) │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Lifecycle Stages + +### 1. Initialization + +When the `AssetsController` constructor is called, it performs the following steps **synchronously**: + +#### 1.1 Base Controller Setup + +```typescript +super({ + name: 'AssetsController', + messenger, + metadata: stateMetadata, + state: { ...defaultState, ...providedState }, +}); +``` + +#### 1.2 Initialize Internal State + +```typescript +initializeState() +├── selectedAccounts = [] // Empty until account group event fires +├── enabledChains = extractEnabledChains(NetworkEnablementController.getState()) +└── assetsPrice = {} // In-memory price cache +``` + +#### 1.3 Subscribe to External Events + +| Event | Handler | Purpose | +|-------|---------|---------| +| `AccountTreeController:selectedAccountGroupChange` | `handleAccountGroupChanged()` | Track active accounts | +| `NetworkEnablementController:stateChange` | `handleEnabledNetworksChanged()` | Track enabled chains | +| `AppStateController:appOpened` | `handleAppOpened()` | Start subscriptions | +| `AppStateController:appClosed` | `handleAppClosed()` | Stop subscriptions | +| `KeyringController:unlock` | `refreshSubscriptions()` | Resume on unlock | +| `KeyringController:lock` | `unsubscribeAll()` | Pause on lock | + +#### 1.4 Register Action Handlers + +```typescript +registerActionHandlers() +├── AssetsController:getAssets +├── AssetsController:getAssetsBalance +├── AssetsController:getAssetMetadata +└── AssetsController:getAssetsPrice +``` + +#### 1.5 Register Data Sources + +```typescript +registerDefaultDataSources() +├── BackendWebsocketDataSource (priority: 100) - Real-time WebSocket +├── AccountsApiDataSource (priority: 99) - HTTP polling fallback +├── SnapDataSource (priority: 81) - Solana/Bitcoin/Tron snaps +└── RpcDataSource (priority: 50) - Direct blockchain RPC +``` + +For each data source: + +1. Create fetch middleware +2. Subscribe to `activeChainsUpdated` event +3. Trigger initial `getActiveChains()` refresh +4. Sort by priority (highest first) + +#### 1.6 Register Middlewares + +```typescript +registerDefaultMiddlewares() +├── TokenDataSource middleware - Enriches responses with token metadata +└── DetectionMiddleware - Identifies assets without metadata +``` + +**Order matters**: Middlewares are stacked with `reduceRight`, so Detection runs first (populates `detectedAssets`), then Token uses that data. + +--- + +### 2. Start (App Opened) + +When the app opens or the keyring unlocks: + +``` +handleAppOpened() / KeyringController:unlock +│ +├── refreshSubscriptions() +│ │ +│ └── For each selectedAccount: +│ ├── getEnabledChainsForAccount(account) // Respect account scopes +│ ├── assignChainsToDataSources(chains) // Priority-based +│ └── subscribeAssets({ account, chainIds, dataTypes: ['balance', 'metadata', 'price'] }) +│ │ +│ └── For each dataSource (in parallel): +│ ├── Filter chains this source handles +│ ├── Call dataSource:subscribe via Messenger +│ │ └── Data sources handle all subscribed dataTypes (balance, metadata, price) +│ └── Subscribe to dataSource:assetsUpdated events +│ +└── fetchAssets() + │ + └── For each selectedAccount: + ├── getEnabledChainsForAccount(account) + └── getAssets([account], { chainIds, forceUpdate: true }) + │ + └── executeMiddlewares(request) + ├── BackendWebsocket handles eip155:1, eip155:137... + ├── AccountsApi handles remaining API-supported chains + ├── SnapDataSource handles solana:*, bip122:*... + ├── RpcDataSource handles any remaining EVM chains + ├── DetectionMiddleware marks assets without metadata + ├── TokenDataSource enriches metadata + └── PriceDataSource fetches prices for discovered assets +``` + +--- + +### 3. Runtime + +#### 3.1 One-Time Fetch (Sync) + +Used for initial load, force refresh, or on-demand queries: + +```typescript +async getAssets(accounts, { chainIds, forceUpdate }) +│ +├── If forceUpdate: +│ ├── executeMiddlewares(middlewares, request) +│ └── updateState(response) +│ +└── Return getAssetsFromState(accounts, chainIds) +``` + +#### 3.2 Async Subscriptions + +Data sources push updates via Messenger events. All data types (balance, metadata, price) flow through the same unified subscription: + +``` +DataSource publishes: "{DataSourceName}:assetsUpdated" +│ +└── handleSubscriptionUpdate(response) + │ + │ Response contains any combination of: + │ ├── assetsBalance - Balance updates + │ ├── assetsMetadata - Metadata updates + │ └── assetsPrice - Price updates + │ + ├── executeMiddlewares(additionalMiddlewares, request, response) + │ ├── DetectionMiddleware - Marks assets without metadata + │ ├── TokenDataSource - Fetches missing metadata + │ └── PriceDataSource - Fetches prices for detected assets + │ + └── updateState(enrichedResponse) + │ + ├── Normalize asset IDs (checksum EVM addresses) + ├── Merge into persisted state + │ ├── assetsMetadata[assetId] = metadata + │ └── assetsBalance[accountId][assetId] = balance + ├── Update in-memory price cache (assetsPrice) + │ + └── Publish events: + ├── AssetsController:stateChange + ├── AssetsController:balanceChanged (if amount changed) + ├── AssetsController:priceChanged (if price changed) + └── AssetsController:assetsDetected (if assets without metadata) +``` + +#### 3.3 Chain Assignment Algorithm + +```typescript +assignChainsToDataSources(requestedChains) +│ +├── remainingChains = Set(requestedChains) +│ +└── For each dataSource (sorted by priority DESC): + │ + ├── availableChains = availableChainsPerSource.get(sourceId) + │ + ├── chainsForThisSource = remainingChains ∩ availableChains + │ + ├── remainingChains = remainingChains - chainsForThisSource + │ + └── assignment.set(sourceId, chainsForThisSource) + +Result: Higher priority sources get first pick; lower priority act as fallbacks +``` + +#### 3.4 Event Handlers + +| Event | Action | +|-------|--------| +| Account group changed | Refresh subscriptions + fetch for new accounts | +| Enabled networks changed | Update subscriptions, fetch new chains, cleanup removed | +| Data source chains changed | Refresh subscriptions, fetch for newly available chains | +| App opened | Start subscriptions + initial fetch | +| App closed | Stop all subscriptions | +| Keyring unlock | Refresh subscriptions | +| Keyring lock | Unsubscribe all | + +--- + +### 4. Stop (App Closed / Lock) + +When the app closes or the keyring locks: + +``` +handleAppClosed() / KeyringController:lock +│ +└── unsubscribeAll() + │ + └── For each activeSubscription: + ├── Call subscription.unsubscribe() + │ ├── Run cleanup functions (Messenger unsubscribes) + │ └── Data sources stop their update mechanisms + │ + └── activeSubscriptions.clear() +``` + +**Note**: State is preserved; only subscriptions are stopped to conserve resources. + +--- + +### 5. Destroy (Cleanup) + +When the controller is destroyed: + +``` +destroy() +│ +├── Clean up data source chain change listeners +│ └── dataSourceCleanups.forEach(cleanup => cleanup()) +│ +├── Clear chain tracking +│ └── availableChainsPerSource.clear() +│ +├── Unsubscribe all active subscriptions (includes price updates) +│ └── unsubscribeAll() +│ +└── Unregister action handlers + ├── AssetsController:getAssets + ├── AssetsController:getAssetsBalance + ├── AssetsController:getAssetMetadata + └── AssetsController:getAssetsPrice +``` + +--- + +## State Structure + +### Persisted State + +```typescript +{ + // Shared metadata (stored once per asset) + assetsMetadata: { + "eip155:1/slip44:60": { type: "native", symbol: "ETH", ... }, + "eip155:1/erc20:0xA0b8...": { type: "erc20", symbol: "USDC", ... }, + }, + + // Per-account balances + assetsBalance: { + "account-uuid-1": { + "eip155:1/slip44:60": { amount: "1000000000000000000" }, + "eip155:1/erc20:0xA0b8...": { amount: "1000000" }, + }, + "account-uuid-2": { ... }, + }, +} +``` + +### In-Memory State + +```typescript +{ + // Price data (not persisted) + assetsPrice: { + "eip155:1/slip44:60": { price: 2500, priceChange24h: 2.5, ... }, + }, + + // Selected accounts from current account group + selectedAccounts: InternalAccount[], + + // Enabled chains from NetworkEnablementController + enabledChains: ChainId[], + + // Available chains per data source + availableChainsPerSource: Map>, + + // Active subscriptions by account ID + activeSubscriptions: Map, +} +``` + +--- + +## Public API + +This section documents the public API available for **other controllers** (via Messenger) and **UI components** (via selectors/hooks). + +--- + +### Messenger Actions + +Other controllers can call these actions via the Messenger pattern: + +#### `AssetsController:getState` + +Returns the current persisted state. + +```typescript +const state = messenger.call('AssetsController:getState'); +// Returns: AssetsControllerState +``` + +**Return Type:** + +```typescript +interface AssetsControllerState { + assetsMetadata: { [assetId: string]: AssetMetadata }; + assetsBalance: { [accountId: string]: { [assetId: string]: AssetBalance } }; +} +``` + +--- + +#### `AssetsController:getAssets` + +Get complete asset data including balance, metadata, and price with computed fiat value. + +```typescript +const assets = await messenger.call( + 'AssetsController:getAssets', + accounts, // InternalAccount[] + options? // GetAssetsOptions +); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `accounts` | `InternalAccount[]` | Yes | Accounts to fetch assets for | +| `options.chainIds` | `ChainId[]` | No | Filter by chains (default: all enabled chains) | +| `options.assetTypes` | `AssetType[]` | No | `'fungible'` \| `'nft'` (default: `['fungible']`) | +| `options.forceUpdate` | `boolean` | No | Force fresh fetch from data sources | +| `options.dataTypes` | `DataType[]` | No | `'balance'` \| `'metadata'` \| `'price'` (default: all) | + +**Return Type:** + +```typescript +Record> + +interface Asset { + id: Caip19AssetId; // "eip155:1/erc20:0xA0b8..." + chainId: ChainId; // "eip155:1" + balance: AssetBalance; // { amount: "1000000" } + metadata: AssetMetadata; // { type, symbol, name, decimals, image, ... } + price: AssetPrice; // { price: 2500, priceChange24h: 2.5, ... } + fiatValue: number; // Computed: (balance / 10^decimals) * price +} +``` + +**Example:** + +```typescript +// Get all fungible assets for selected accounts on Ethereum mainnet +const assets = await messenger.call('AssetsController:getAssets', accounts, { + chainIds: ['eip155:1'], + assetTypes: ['fungible'], + forceUpdate: true, +}); + +// Access ETH balance for first account +const ethAsset = assets[accounts[0].id]['eip155:1/slip44:60']; +console.log(`ETH Balance: ${ethAsset.fiatValue} USD`); +``` + +--- + +#### `AssetsController:getAssetsBalance` + +Get only balance data (lighter query). + +```typescript +const balances = await messenger.call( + 'AssetsController:getAssetsBalance', + accounts, + options? +); +``` + +**Return Type:** + +```typescript +Record> + +interface AssetBalance { + amount: string; // Raw amount as string (e.g., "1000000000000000000" for 1 ETH) +} +``` + +**Example:** + +```typescript +const balances = await messenger.call('AssetsController:getAssetsBalance', accounts, { + chainIds: ['eip155:1', 'eip155:137'], + forceUpdate: true, +}); + +// Get raw ETH balance +const ethBalance = balances[accountId]['eip155:1/slip44:60'].amount; +``` + +--- + +#### `AssetsController:getAssetMetadata` + +Get metadata for a specific asset (symbol, name, decimals, image, etc.). + +```typescript +const metadata = await messenger.call( + 'AssetsController:getAssetMetadata', + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' // USDC on Ethereum +); +``` + +**Return Type:** + +```typescript +AssetMetadata | undefined + +interface AssetMetadata { + type: 'native' | 'erc20' | 'erc721' | 'erc1155' | 'spl' | string; + symbol: string; // "ETH", "USDC" + name: string; // "Ethereum", "USD Coin" + decimals: number; // 18, 6, etc. + image?: string; // Logo URL + isSpam?: boolean; // Spam detection flag + verified?: boolean; // Verification status +} +``` + +--- + +#### `AssetsController:getAssetsPrice` + +Get only price data. + +```typescript +const prices = await messenger.call( + 'AssetsController:getAssetsPrice', + accounts, + options? +); +``` + +**Return Type:** + +```typescript +Record + +interface AssetPrice { + price: number; // Current price in USD + priceChange24h?: number; // 24h change percentage + lastUpdated: number; // Timestamp + marketCap?: number; + volume24h?: number; +} +``` + +--- + +### Published Events + +Subscribe to these events for real-time updates. + +#### `AssetsController:stateChange` + +Emitted on any state change. + +```typescript +messenger.subscribe('AssetsController:stateChange', (state) => { + console.log('State updated:', state); +}); +``` + +**Payload:** + +```typescript +{ + assetsMetadata: { [assetId: string]: AssetMetadata }; + assetsBalance: { [accountId: string]: { [assetId: string]: AssetBalance } }; +} +``` + +--- + +#### `AssetsController:balanceChanged` + +Emitted when a specific asset balance changes. + +```typescript +messenger.subscribe('AssetsController:balanceChanged', (event) => { + console.log(`Balance changed for ${event.accountId}`); + console.log(`Asset: ${event.assetId}`); + console.log(`${event.previousAmount} → ${event.newAmount}`); +}); +``` + +**Payload:** + +```typescript +{ + accountId: AccountId; // Account UUID + assetId: Caip19AssetId; // "eip155:1/slip44:60" + previousAmount: string; // "1000000000000000000" + newAmount: string; // "2000000000000000000" +} +``` + +--- + +#### `AssetsController:priceChanged` + +Emitted when asset prices are updated. + +```typescript +messenger.subscribe('AssetsController:priceChanged', (event) => { + console.log('Prices updated for:', event.assetIds); +}); +``` + +**Payload:** + +```typescript +{ + assetIds: Caip19AssetId[]; // ["eip155:1/slip44:60", "eip155:1/erc20:0x..."] +} +``` + +--- + +#### `AssetsController:assetsDetected` + +Emitted when assets without metadata are detected for an account. + +```typescript +messenger.subscribe('AssetsController:assetsDetected', (event) => { + console.log(`New assets detected for ${event.accountId}:`, event.assetIds); +}); +``` + +**Payload:** + +```typescript +{ + accountId: AccountId; + assetIds: Caip19AssetId[]; +} +``` + +--- + +### Type Definitions + +#### Identifier Types + +```typescript +// CAIP-19 asset identifier +// Format: "{chainId}/{assetNamespace}:{assetReference}" +type Caip19AssetId = string; +// Examples: +// - Native ETH: "eip155:1/slip44:60" +// - USDC on Ethereum: "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +// - SOL: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501" +// - SPL Token: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5..." + +// CAIP-2 chain identifier +type ChainId = string; +// Examples: "eip155:1", "eip155:137", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + +// Account UUID (from AccountsController, NOT the blockchain address) +type AccountId = string; +// Example: "c3c7f9a2-8b1d-4e5f-9a2c-1b3d4e5f6a7b" +``` + +#### Asset Types + +```typescript +// Asset type for filtering +type AssetType = 'fungible' | 'nft' | 'collectible'; + +// Data types for selective fetching +type DataType = 'balance' | 'metadata' | 'price'; +``` + +--- + +### Usage Examples + +#### For Other Controllers + +```typescript +// In another controller that needs asset data +class MyController { + constructor({ messenger }) { + this.messenger = messenger; + + // Subscribe to balance changes + messenger.subscribe('AssetsController:balanceChanged', (event) => { + this.handleBalanceChange(event); + }); + } + + async getAccountValue(account: InternalAccount): Promise { + const assets = await this.messenger.call( + 'AssetsController:getAssets', + [account], + { assetTypes: ['fungible'] } + ); + + // Sum all fiat values + return Object.values(assets[account.id]) + .reduce((total, asset) => total + asset.fiatValue, 0); + } +} +``` + +#### For UI Components (React) + +```typescript +// Selector for getting assets from Redux state +export const selectAssetsForAccount = (state, accountId) => { + const { assetsMetadata, assetsBalance } = state.AssetsController; + const { assetsPrice } = state; // In-memory prices from a separate slice + + const accountBalances = assetsBalance[accountId] || {}; + + return Object.entries(accountBalances).map(([assetId, balance]) => { + const metadata = assetsMetadata[assetId]; + const price = assetsPrice[assetId] || { price: 0 }; + + const normalizedAmount = parseFloat(balance.amount) / Math.pow(10, metadata.decimals); + const fiatValue = normalizedAmount * price.price; + + return { + id: assetId, + balance, + metadata, + price, + fiatValue, + }; + }); +}; + +// React hook for subscribing to balance changes +function useBalanceChanges(accountId) { + const [lastChange, setLastChange] = useState(null); + + useEffect(() => { + const unsubscribe = messenger.subscribe( + 'AssetsController:balanceChanged', + (event) => { + if (event.accountId === accountId) { + setLastChange(event); + } + } + ); + return unsubscribe; + }, [accountId]); + + return lastChange; +} +``` + +#### Force Refresh Pattern + +```typescript +// Pull-to-refresh handler +async function handleRefresh(accounts) { + await messenger.call('AssetsController:getAssets', accounts, { + forceUpdate: true, + dataTypes: ['balance', 'metadata', 'price'], + }); +} +``` + +#### Filtering by Chain + +```typescript +// Get only Polygon assets +const polygonAssets = await messenger.call('AssetsController:getAssets', accounts, { + chainIds: ['eip155:137'], +}); + +// Get assets across multiple chains +const multiChainAssets = await messenger.call('AssetsController:getAssets', accounts, { + chainIds: ['eip155:1', 'eip155:137', 'eip155:42161'], +}); +``` + +--- + +## Data Source Priority + +### Primary Data Sources (Balance Providers) + +| Priority | Data Source | Update Mechanism | Chains | +|----------|-------------|------------------|--------| +| 100 | BackendWebsocketDataSource | Real-time WebSocket push | API-supported EVM | +| 99 | AccountsApiDataSource | HTTP polling | API-supported chains | +| 81 | SnapDataSource | Snap keyring events | Solana, Bitcoin, Tron | +| 50 | RpcDataSource | Direct RPC polling | Any EVM chain | + +**Fallback behavior**: If a higher-priority source fails or doesn't support a chain, lower-priority sources handle it automatically. + +### Middlewares (Enrichment) + +These middlewares process responses after primary data sources fetch balance data: + +| Middleware | Purpose | When It Runs | +|------------|---------|--------------| +| DetectionMiddleware | Identifies assets without metadata | After balance fetch | +| TokenDataSource | Enriches with token metadata (symbol, name, image) | After detection | +| PriceDataSource | Fetches USD prices for assets | After metadata enrichment | + +All middlewares are part of the unified `subscribeAssets` flow—there are no separate subscription mechanisms. + +--- + +## Diagrams + +### Initialization & Dependencies + +```mermaid +flowchart TB + subgraph Extension["MetaMask Extension"] + MI[MetaMask Controller Init] + DSI[DataSourceInit] + ACI[AssetsController Init] + end + + subgraph DataSourcesInit["Data Sources Initialization"] + IM[initMessengers] + IDS[initDataSources] + SP[createSnapProvider] + end + + subgraph Messengers["Child Messengers"] + RPCm[RpcDataSource Messenger] + WSm[BackendWebsocket Messenger] + APIm[AccountsApi Messenger] + SNAPm[SnapDataSource Messenger] + TOKm[TokenDataSource Messenger] + PRICEm[PriceDataSource Messenger] + DETm[DetectionMiddleware Messenger] + end + + subgraph DataSources["Data Source Instances"] + RPC[RpcDataSource - Priority 50] + WS[BackendWebsocketDS - Priority 100] + API[AccountsApiDS - Priority 99] + SNAP[SnapDataSource - Priority 81] + TOK[TokenDataSource] + PRICE[PriceDataSource] + DET[DetectionMiddleware] + end + + subgraph ExternalDeps["External Dependencies"] + NC[NetworkController] + ATC[AccountTreeController] + NEC[NetworkEnablementController] + KC[KeyringController] + ASC[AppStateController] + BWSS[BackendWebSocketService] + BAC[BackendApiClient] + SC[SnapController] + end + + MI --> DSI + DSI --> IM + DSI --> SP + IM --> RPCm & WSm & APIm & SNAPm & TOKm & PRICEm & DETm + SP --> SNAPm + + IM --> IDS + IDS --> RPC & WS & API & SNAP & TOK & PRICE & DET + + DSI --> ACI + + RPCm -.-> NC + WSm -.-> BWSS + APIm -.-> BAC + SNAPm -.-> SC + TOKm -.-> BAC + PRICEm -.-> BAC + + ACI -.-> ATC + ACI -.-> NEC + ACI -.-> KC + ACI -.-> ASC +``` + +### Runtime Data Flow + +```mermaid +flowchart LR + subgraph Fetch["One-Time Fetch"] + F1[getAssets] + F2[Execute Middleware Chain] + F3[updateState] + F4[Return from State] + + F1 --> F2 --> F3 --> F4 + end + + subgraph Subscribe["Subscription Flow"] + S1[subscribeAssets] + S2[assignChainsToDataSources] + S3[Notify Data Sources] + S4[Data Sources Push Updates] + S5[handleSubscriptionUpdate] + S6[Execute Middlewares] + S7[updateState] + + S1 --> S2 --> S3 + S3 -.-> S4 + S4 --> S5 --> S6 --> S7 + end +``` + +--- + +## Quick Start + +### Initialization (Extension/Mobile) + +```typescript +import { AssetsController } from '@metamask/assets-controllers'; + +const assetsController = new AssetsController({ + messenger: controllerMessenger, + state: existingState, // Optional: restore persisted state + defaultUpdateInterval: 30_000, // Optional: polling hint (30s default) +}); +``` + +### Basic Usage (Controllers) + +```typescript +// Get all assets with fiat values +const assets = await messenger.call('AssetsController:getAssets', accounts, { + forceUpdate: true, +}); + +// Get just balances (lighter) +const balances = await messenger.call('AssetsController:getAssetsBalance', accounts); + +// Get state directly +const state = messenger.call('AssetsController:getState'); +``` + +### Event Subscriptions (UI) + +```typescript +// Balance changes (real-time updates) +messenger.subscribe('AssetsController:balanceChanged', (event) => { + console.log(`${event.assetId}: ${event.previousAmount} → ${event.newAmount}`); +}); + +// New asset detection +messenger.subscribe('AssetsController:assetsDetected', (event) => { + showNotification(`Found ${event.assetIds.length} new tokens!`); +}); + +// Price updates +messenger.subscribe('AssetsController:priceChanged', ({ assetIds }) => { + refreshPriceDisplay(assetIds); +}); +``` + +### Cleanup + +```typescript +assetsController.destroy(); +``` + diff --git a/packages/assets-controllers/src/AssetsController/data-sources/AbstractDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/AbstractDataSource.ts new file mode 100644 index 00000000000..8f58e1d367e --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/AbstractDataSource.ts @@ -0,0 +1,144 @@ +import type { ChainId, DataRequest, DataResponse } from '../types'; + +// ============================================================================ +// DATA SOURCE BASE TYPES +// ============================================================================ + +/** + * Subscription request from AssetsController. + */ +export interface SubscriptionRequest { + request: DataRequest; + subscriptionId: string; + isUpdate: boolean; +} + +/** + * Base state for all data sources. + */ +export interface DataSourceState { + /** Currently active chains (supported AND available) */ + activeChains: ChainId[]; +} + +// ============================================================================ +// ABSTRACT DATA SOURCE +// ============================================================================ + +/** + * Abstract base class for data sources. + * + * Data sources communicate with AssetsController via Messenger: + * - Register actions that AssetsController can call + * - Publish events that AssetsController subscribes to + */ +export abstract class AbstractDataSource< + Name extends string, + State extends DataSourceState = DataSourceState, +> { + protected readonly name: Name; + protected state: State; + + /** Active subscriptions by ID */ + protected readonly activeSubscriptions: Map< + string, + { cleanup: () => void; chains: ChainId[] } + > = new Map(); + + constructor(name: Name, initialState: State) { + this.name = name; + this.state = initialState; + } + + /** + * Get the data source name/ID. + */ + getName(): Name { + return this.name; + } + + /** + * Get currently active chains (supported AND available). + */ + async getActiveChains(): Promise { + return this.state.activeChains; + } + + /** + * Subscribe to updates for the given request. + */ + abstract subscribe(request: SubscriptionRequest): Promise; + + /** + * Unsubscribe from updates. + */ + async unsubscribe(subscriptionId: string): Promise { + const subscription = this.activeSubscriptions.get(subscriptionId); + if (subscription) { + subscription.cleanup(); + this.activeSubscriptions.delete(subscriptionId); + } + } + + /** + * Update active chains and notify listeners only if changed. + */ + protected updateActiveChains( + chains: ChainId[], + publishEvent: (chains: ChainId[]) => void, + ): void { + const previousChains = new Set(this.state.activeChains); + const newChains = new Set(chains); + + // Check if chains have actually changed + const hasChanges = + previousChains.size !== newChains.size || + chains.some((chain) => !previousChains.has(chain)); + + // Always update state + this.state.activeChains = chains; + + // Only publish event if there are actual changes + if (hasChanges) { + publishEvent(chains); + } + } + + /** + * Add a chain to active chains. + */ + protected addActiveChain( + chainId: ChainId, + publishEvent: (chains: ChainId[]) => void, + ): void { + if (!this.state.activeChains.includes(chainId)) { + this.state.activeChains = [...this.state.activeChains, chainId]; + publishEvent(this.state.activeChains); + } + } + + /** + * Remove a chain from active chains. + */ + protected removeActiveChain( + chainId: ChainId, + publishEvent: (chains: ChainId[]) => void, + ): void { + if (this.state.activeChains.includes(chainId)) { + this.state.activeChains = this.state.activeChains.filter( + (c) => c !== chainId, + ); + publishEvent(this.state.activeChains); + } + } + + /** + * Destroy the data source and clean up resources. + */ + destroy(): void { + for (const subscription of this.activeSubscriptions.values()) { + subscription.cleanup(); + } + this.activeSubscriptions.clear(); + } +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/AccountsApiDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/AccountsApiDataSource.ts new file mode 100644 index 00000000000..79acae49a84 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/AccountsApiDataSource.ts @@ -0,0 +1,818 @@ +import type { + AccountsApiActions, + GetV2SupportedNetworksResponse, + GetV5MultiAccountBalancesResponse, +} from '@metamask/core-backend'; +import { toChecksumAddress } from '@ethereumjs/util'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import { parseCaipAssetType, parseCaipChainId } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import type { + ChainId, + Caip19AssetId, + AssetBalance, + DataRequest, + DataResponse, + Middleware, +} from '../types'; +import { + AbstractDataSource, + type DataSourceState, + type SubscriptionRequest, +} from './AbstractDataSource'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'AccountsApiDataSource'; +const DEFAULT_POLL_INTERVAL = 30_000; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +// Action types that AccountsApiDataSource exposes +export type AccountsApiDataSourceGetAssetsMiddlewareAction = { + type: 'AccountsApiDataSource:getAssetsMiddleware'; + handler: () => Middleware; +}; + +export type AccountsApiDataSourceGetActiveChainsAction = { + type: 'AccountsApiDataSource:getActiveChains'; + handler: () => Promise; +}; + +export type AccountsApiDataSourceFetchAction = { + type: 'AccountsApiDataSource:fetch'; + handler: (request: DataRequest) => Promise; +}; + +export type AccountsApiDataSourceSubscribeAction = { + type: 'AccountsApiDataSource:subscribe'; + handler: (request: SubscriptionRequest) => Promise; +}; + +export type AccountsApiDataSourceUnsubscribeAction = { + type: 'AccountsApiDataSource:unsubscribe'; + handler: (subscriptionId: string) => Promise; +}; + +export type AccountsApiDataSourceActions = + | AccountsApiDataSourceGetAssetsMiddlewareAction + | AccountsApiDataSourceGetActiveChainsAction + | AccountsApiDataSourceFetchAction + | AccountsApiDataSourceSubscribeAction + | AccountsApiDataSourceUnsubscribeAction; + +// Event types that AccountsApiDataSource publishes +export type AccountsApiDataSourceActiveChainsChangedEvent = { + type: 'AccountsApiDataSource:activeChainsUpdated'; + payload: [ChainId[]]; +}; + +export type AccountsApiDataSourceAssetsUpdatedEvent = { + type: 'AccountsApiDataSource:assetsUpdated'; + payload: [DataResponse, string | undefined]; +}; + +export type AccountsApiDataSourceEvents = + | AccountsApiDataSourceActiveChainsChangedEvent + | AccountsApiDataSourceAssetsUpdatedEvent; + +// Actions to report to AssetsController +type AssetsControllerActiveChainsUpdateAction = { + type: 'AssetsController:activeChainsUpdate'; + handler: (dataSourceId: string, activeChains: ChainId[]) => void; +}; + +type AssetsControllerAssetsUpdateAction = { + type: 'AssetsController:assetsUpdate'; + handler: (response: DataResponse, sourceId: string) => Promise; +}; + +// Allowed actions that AccountsApiDataSource can call +export type AccountsApiDataSourceAllowedActions = + | AccountsApiActions + | AssetsControllerActiveChainsUpdateAction + | AssetsControllerAssetsUpdateAction; + +export type AccountsApiDataSourceMessenger = Messenger< + typeof CONTROLLER_NAME, + AccountsApiDataSourceActions | AccountsApiDataSourceAllowedActions, + AccountsApiDataSourceEvents +>; + +// ============================================================================ +// STATE +// ============================================================================ + +export type AccountsApiDataSourceState = DataSourceState; + +const defaultState: AccountsApiDataSourceState = { + activeChains: [], +}; + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface AccountsApiDataSourceOptions { + messenger: AccountsApiDataSourceMessenger; + pollInterval?: number; + state?: Partial; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function decimalToChainId(decimalChainId: number | string): ChainId { + // Handle both decimal numbers and already-formatted CAIP chain IDs + if (typeof decimalChainId === 'string') { + // If already a CAIP chain ID (e.g., "eip155:1"), return as-is + if (decimalChainId.startsWith('eip155:')) { + return decimalChainId as ChainId; + } + // If it's a string number, convert + return `eip155:${decimalChainId}` as ChainId; + } + return `eip155:${decimalChainId}` as ChainId; +} + +/** + * Convert a CAIP-2 chain ID from the API response to our ChainId type. + * Handles both formats: "eip155:1" or just "1" (decimal). + */ +function caipChainIdToChainId(chainIdStr: string): ChainId { + // If already in CAIP-2 format, return as-is + if (chainIdStr.includes(':')) { + return chainIdStr as ChainId; + } + // If decimal number, convert to CAIP-2 + return `eip155:${chainIdStr}` as ChainId; +} + +/** + * Normalizes a CAIP-19 asset ID by checksumming EVM addresses. + * This ensures consistent asset IDs regardless of the data source format. + * + * For EVM ERC20 tokens (e.g., "eip155:1/erc20:0x..."), the address is checksummed. + * All other asset types are returned unchanged. + * + * @param assetId - The CAIP-19 asset ID to normalize + * @returns The normalized asset ID with checksummed address (for EVM tokens) + */ +function normalizeAssetId(assetId: Caip19AssetId): Caip19AssetId { + const parsed = parseCaipAssetType(assetId); + const chainIdParsed = parseCaipChainId(parsed.chainId); + + // Only checksum EVM ERC20 addresses + if ( + chainIdParsed.namespace === 'eip155' && + parsed.assetNamespace === 'erc20' + ) { + const checksummedAddress = toChecksumAddress(parsed.assetReference); + return `${parsed.chainId}/${parsed.assetNamespace}:${checksummedAddress}` as Caip19AssetId; + } + + return assetId; +} + + +// ============================================================================ +// ACCOUNTS API DATA SOURCE +// ============================================================================ + +/** + * Data source for fetching balances from the MetaMask Accounts API. + * + * Uses Messenger pattern for all interactions: + * - Calls BackendApiClient methods via messenger actions + * - Exposes its own actions for AssetsController to call + * - Publishes events for AssetsController to subscribe to + * + * Actions exposed: + * - AccountsApiDataSource:getActiveChains + * - AccountsApiDataSource:fetch + * - AccountsApiDataSource:subscribe + * - AccountsApiDataSource:unsubscribe + * + * Events published: + * - AccountsApiDataSource:activeChainsUpdated + * - AccountsApiDataSource:assetsUpdated + * + * Actions called (from BackendApiClient): + * - BackendApiClient:Accounts:getV2SupportedNetworks + * - BackendApiClient:Accounts:getV5MultiAccountBalances + */ +export class AccountsApiDataSource extends AbstractDataSource< + typeof CONTROLLER_NAME, + AccountsApiDataSourceState +> { + readonly #messenger: AccountsApiDataSourceMessenger; + + readonly #pollInterval: number; + + /** WebSocket connection for real-time updates */ + #websocket: WebSocket | null = null; + + /** Chains refresh timer */ + #chainsRefreshTimer: ReturnType | null = null; + + constructor(options: AccountsApiDataSourceOptions) { + super(CONTROLLER_NAME, { + ...defaultState, + ...options.state, + }); + + this.#messenger = options.messenger; + this.#pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL; + + log('Initializing AccountsApiDataSource', { + pollInterval: this.#pollInterval, + }); + + this.#registerActionHandlers(); + this.#initializeActiveChains(); + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + #registerActionHandlers(): void { + // Define strongly-typed handlers + const getAssetsMiddlewareHandler: AccountsApiDataSourceGetAssetsMiddlewareAction['handler'] = + () => this.assetsMiddleware; + + const getActiveChainsHandler: AccountsApiDataSourceGetActiveChainsAction['handler'] = + async () => this.getActiveChains(); + + const fetchHandler: AccountsApiDataSourceFetchAction['handler'] = async ( + request, + ) => this.fetch(request); + + const subscribeHandler: AccountsApiDataSourceSubscribeAction['handler'] = + async (request) => this.subscribe(request); + + const unsubscribeHandler: AccountsApiDataSourceUnsubscribeAction['handler'] = + async (subscriptionId) => this.unsubscribe(subscriptionId); + + // Register handlers + this.#messenger.registerActionHandler( + 'AccountsApiDataSource:getAssetsMiddleware', + getAssetsMiddlewareHandler, + ); + + this.#messenger.registerActionHandler( + 'AccountsApiDataSource:getActiveChains', + getActiveChainsHandler, + ); + + this.#messenger.registerActionHandler( + 'AccountsApiDataSource:fetch', + fetchHandler, + ); + + this.#messenger.registerActionHandler( + 'AccountsApiDataSource:subscribe', + subscribeHandler, + ); + + this.#messenger.registerActionHandler( + 'AccountsApiDataSource:unsubscribe', + unsubscribeHandler, + ); + } + + async #initializeActiveChains(): Promise { + log('Initializing active chains'); + try { + const chains = await this.#fetchActiveChains(); + log('Active chains fetched', { chainCount: chains.length, chains }); + this.updateActiveChains(chains, (c) => { + this.#messenger.call('AssetsController:activeChainsUpdate', CONTROLLER_NAME, c); + // Also publish event for BackendWebsocketDataSource to sync + this.#messenger.publish('AccountsApiDataSource:activeChainsUpdated', c); + }); + + // Periodically refresh active chains (every 20 minutes) + this.#chainsRefreshTimer = setInterval( + () => this.#refreshActiveChains(), + 20 * 60 * 1000, + ); + } catch (error) { + log('Failed to fetch active chains', error); + } + } + + async #refreshActiveChains(): Promise { + log('Refreshing active chains'); + try { + const chains = await this.#fetchActiveChains(); + const previousChains = new Set(this.state.activeChains); + const newChains = new Set(chains); + + // Check if chains changed + const added = chains.filter((c) => !previousChains.has(c)); + const removed = Array.from(previousChains).filter( + (c) => !newChains.has(c), + ); + + if (added.length > 0 || removed.length > 0) { + log('Active chains changed', { added, removed }); + this.updateActiveChains(chains, (c) => { + this.#messenger.call('AssetsController:activeChainsUpdate', CONTROLLER_NAME, c); + // Also publish event for BackendWebsocketDataSource to sync + this.#messenger.publish('AccountsApiDataSource:activeChainsUpdated', c); + }); + } + } catch (error) { + log('Failed to refresh active chains', error); + } + } + + async #fetchActiveChains(): Promise { + // Call BackendApiClient via messenger (v2 provides fullSupport and partialSupport) + const response = (await this.#messenger.call( + 'BackendApiClient:Accounts:getV2SupportedNetworks', + )) as GetV2SupportedNetworksResponse; + // Use fullSupport networks as active chains + return response.fullSupport.map(decimalToChainId); + } + + // ============================================================================ + // ACCOUNT SCOPE HELPERS + // ============================================================================ + + /** + * Check if an account supports a specific chain based on its scopes. + * AccountsApiDataSource only handles EVM chains, so we check for EIP155 scopes. + * + * @param account - The account to check + * @param chainId - The chain ID to check (e.g., "eip155:1") + * @returns True if the account supports the chain + */ + #accountSupportsChain(account: InternalAccount, chainId: ChainId): boolean { + const scopes = account.scopes ?? []; + + // If no scopes defined, assume it supports the chain (backward compatibility) + if (scopes.length === 0) { + return true; + } + + // Extract namespace and reference from chainId (e.g., "eip155:1" -> ["eip155", "1"]) + const [chainNamespace, chainReference] = chainId.split(':'); + + for (const scope of scopes) { + const [scopeNamespace, scopeReference] = (scope as string).split(':'); + + // Check if namespaces match + if (scopeNamespace !== chainNamespace) { + continue; + } + + // Wildcard scope (e.g., "eip155:0" means all chains in that namespace) + if (scopeReference === '0') { + return true; + } + + // Exact match check - normalize hex to decimal for EIP155 + if (chainNamespace === 'eip155') { + const normalizedScopeRef = scopeReference?.startsWith('0x') + ? parseInt(scopeReference, 16).toString() + : scopeReference; + if (normalizedScopeRef === chainReference) { + return true; + } + } else if (scopeReference === chainReference) { + return true; + } + } + + return false; + } + + // ============================================================================ + // FETCH + // ============================================================================ + + async fetch(request: DataRequest): Promise { + const response: DataResponse = {}; + + // Filter to only chains supported by Accounts API + const supportedChains = new Set(this.state.activeChains); + const chainsToFetch = request.chainIds.filter((chainId) => + supportedChains.has(chainId), + ); + + log('Fetch requested', { + accounts: request.accounts.map((a) => a.id), + requestedChains: request.chainIds.length, + supportedChains: chainsToFetch.length, + filteredOut: request.chainIds.length - chainsToFetch.length, + }); + + if (chainsToFetch.length === 0) { + log('No supported chains to fetch'); + // Mark unsupported chains as errors so they pass to next middleware + for (const chainId of request.chainIds) { + if (!supportedChains.has(chainId)) { + response.errors = response.errors ?? {}; + response.errors[chainId] = 'Chain not supported by Accounts API'; + } + } + return response; + } + + try { + // Build CAIP-10 account IDs (e.g., "eip155:1:0x1234...") + // Only include account-chain combinations where the account's scopes + // overlap with the chains being fetched + const accountIds = request.accounts.flatMap((account) => + chainsToFetch + .filter((chainId) => this.#accountSupportsChain(account, chainId)) + .map((chainId) => `${chainId}:${account.address}`), + ); + + log('Built account IDs from scope matching', { + totalAccounts: request.accounts.length, + totalChains: chainsToFetch.length, + matchingAccountIds: accountIds.length, + }); + + // Skip API call if no valid account-chain combinations + if (accountIds.length === 0) { + log('No valid account-chain combinations to fetch'); + return response; + } + + log('Calling Accounts API V5', { + accountIdCount: accountIds.length, + chainCount: chainsToFetch.length, + }); + + // Call BackendApiClient via messenger (V5 API with CAIP-10 account IDs) + const apiResponse = (await this.#messenger.call( + 'BackendApiClient:Accounts:getV5MultiAccountBalances', + accountIds, + )) as GetV5MultiAccountBalancesResponse; + + log('Accounts API V5 response received', { + count: apiResponse.count, + balanceItemCount: apiResponse.balances.length, + unprocessedNetworks: apiResponse.unprocessedNetworks, + }); + + // Handle unprocessed networks - these will be passed to next middleware + if (apiResponse.unprocessedNetworks.length > 0) { + const unprocessedChainIds = + apiResponse.unprocessedNetworks.map(caipChainIdToChainId); + log('Unprocessed networks detected (will fallback to other data sources)', { + unprocessedChainIds, + }); + + // Add unprocessed chains to errors so middleware passes them to next data source + response.errors = response.errors ?? {}; + for (const chainId of unprocessedChainIds) { + response.errors[chainId] = 'Unprocessed by Accounts API'; + } + } + + // Process V5 balances (metadata is fetched separately via metadata enrichment) + const { assetsBalance } = this.#processV5Balances( + apiResponse.balances, + request, + ); + + // Log detailed balance information + for (const [accountId, balances] of Object.entries(assetsBalance)) { + const balanceDetails = Object.entries(balances).map( + ([assetId, balance]) => ({ + assetId, + amount: balance.amount, + }), + ); + log('Fetch result - account balances', { + accountId, + balances: balanceDetails, + }); + } + + log('Fetch SUCCESS', { + accountCount: Object.keys(assetsBalance).length, + assetCount: Object.keys( + Object.values(assetsBalance).reduce( + (acc, b) => ({ ...acc, ...b }), + {}, + ), + ).length, + chains: chainsToFetch, + failedChains: Object.keys(response.errors ?? {}).length, + }); + + response.assetsBalance = assetsBalance; + } catch (error) { + log('Fetch FAILED', { error, chains: chainsToFetch }); + + // On error, mark all chains as errors so they can be handled by next middleware + response.errors = response.errors ?? {}; + for (const chainId of chainsToFetch) { + response.errors[chainId] = `Fetch failed: ${error instanceof Error ? error.message : String(error)}`; + } + } + + // Mark unsupported chains as errors so they pass to next middleware + for (const chainId of request.chainIds) { + if (!supportedChains.has(chainId)) { + response.errors = response.errors ?? {}; + response.errors[chainId] = 'Chain not supported by Accounts API'; + } + } + + return response; + } + + /** + * Process V5 API balances response. + * V5 returns a flat array of balance items, each with accountId and assetId. + */ + #processV5Balances( + balances: GetV5MultiAccountBalancesResponse['balances'], + request: DataRequest, + ): { + assetsBalance: Record>; + } { + const assetsBalance: Record< + string, + Record + > = {}; + + // Build a map of lowercase addresses to account IDs for efficient lookup + const addressToAccountId = new Map(); + for (const account of request.accounts) { + if (account.address) { + addressToAccountId.set(account.address.toLowerCase(), account.id); + } + } + + log('Processing V5 balances', { + balanceItemCount: balances.length, + requestAccountCount: request.accounts.length, + requestAddresses: Array.from(addressToAccountId.keys()), + }); + + let matchedCount = 0; + let skippedCount = 0; + + // V5 response: array of { accountId, assetId, balance, ... } + for (const item of balances) { + // Extract address from CAIP-10 account ID (e.g., "eip155:1:0x1234..." -> "0x1234...") + const addressParts = item.accountId.split(':'); + if (addressParts.length < 3) { + skippedCount++; + continue; + } + const address = addressParts[2].toLowerCase(); + + // Find the matching account ID from request + const accountId = addressToAccountId.get(address); + if (!accountId) { + // This is normal - API returns balances for all chains, but request may only have one account + skippedCount++; + continue; + } + + if (!assetsBalance[accountId]) { + assetsBalance[accountId] = {}; + } + + // Normalize asset ID (checksum EVM addresses for ERC20 tokens) + const normalizedAssetId = normalizeAssetId(item.assetId as Caip19AssetId); + assetsBalance[accountId][normalizedAssetId] = { + amount: item.balance, + }; + matchedCount++; + } + + log('V5 balances processed', { + matchedCount, + skippedCount, + accountsWithBalances: Object.keys(assetsBalance).length, + totalAssets: Object.values(assetsBalance).reduce( + (sum, acc) => sum + Object.keys(acc).length, + 0, + ), + }); + + return { assetsBalance }; + } + + // ============================================================================ + // MIDDLEWARE + // ============================================================================ + + /** + * Get the middleware for fetching balances via Accounts API. + * This middleware: + * - Supports multiple accounts in a single request + * - Uses unprocessedNetworks from API response to determine what to pass to next middleware + * - Merges response into context + * - Removes handled chains from request for next middleware + */ + get assetsMiddleware(): Middleware { + return async (context, next) => { + const { request } = context; + + // If no chains requested, skip to next middleware + if (request.chainIds.length === 0) { + return next(context); + } + + let successfullyHandledChains: ChainId[] = []; + + log('Middleware fetching', { + requestedChains: request.chainIds, + accounts: request.accounts.map((a) => a.id), + }); + + try { + const response = await this.fetch(request); + + // Merge response into context + if (response.assetsBalance) { + if (!context.response.assetsBalance) { + context.response.assetsBalance = {}; + } + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance, + )) { + if (!context.response.assetsBalance[accountId]) { + context.response.assetsBalance[accountId] = {}; + } + context.response.assetsBalance[accountId] = { + ...context.response.assetsBalance[accountId], + ...accountBalances, + }; + } + } + + // Determine successfully handled chains (exclude unprocessed/error chains) + const unprocessedChains = new Set(Object.keys(response.errors ?? {})); + successfullyHandledChains = request.chainIds.filter( + (chainId) => !unprocessedChains.has(chainId), + ); + + log('Middleware fetch complete', { + requestedChains: request.chainIds.length, + handledChains: successfullyHandledChains.length, + unprocessedChains: unprocessedChains.size, + }); + } catch (error) { + log('Middleware fetch failed', { error }); + successfullyHandledChains = []; + } + + // Remove successfully handled chains from request for next middleware + if (successfullyHandledChains.length > 0) { + const remainingChains = request.chainIds.filter( + (chainId) => !successfullyHandledChains.includes(chainId), + ); + + return next({ + ...context, + request: { + ...request, + chainIds: remainingChains, + }, + }); + } + + // No chains handled - pass context unchanged + return next(context); + }; + } + + // ============================================================================ + // SUBSCRIBE + // ============================================================================ + + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { + const { request, subscriptionId, isUpdate } = subscriptionRequest; + + // Try all requested chains - API will handle unsupported ones via unprocessedNetworks + const chainsToSubscribe = request.chainIds; + + log('Subscribe requested', { + subscriptionId, + isUpdate, + accounts: request.accounts.map((a) => a.id), + requestedChains: request.chainIds, + chainsToSubscribe, + }); + + if (chainsToSubscribe.length === 0) { + log('No chains to subscribe'); + return; + } + + // Handle subscription update + if (isUpdate) { + const existing = this.activeSubscriptions.get(subscriptionId); + if (existing) { + log('Updating existing subscription', { + subscriptionId, + chainsToSubscribe, + }); + existing.chains = chainsToSubscribe; + return; + } + } + + // Clean up existing subscription if any + await this.unsubscribe(subscriptionId); + + const pollInterval = request.updateInterval ?? this.#pollInterval; + log('Setting up polling subscription', { subscriptionId, pollInterval }); + + // Create poll function for this subscription + const pollFn = async () => { + try { + const subscription = this.activeSubscriptions.get(subscriptionId); + if (!subscription) { + return; + } + + const fetchResponse = await this.fetch({ + ...request, + chainIds: subscription.chains, + }); + + // Report update to AssetsController + this.#messenger.call( + 'AssetsController:assetsUpdate', + fetchResponse, + CONTROLLER_NAME, + ); + } catch (error) { + log('Subscription poll failed', { subscriptionId, error }); + } + }; + + // Set up polling + const timer = setInterval(pollFn, pollInterval); + + // Store subscription + this.activeSubscriptions.set(subscriptionId, { + cleanup: () => { + log('Cleaning up subscription', { subscriptionId }); + clearInterval(timer); + }, + chains: chainsToSubscribe, + }); + + log('Subscription SUCCESS', { subscriptionId, chains: chainsToSubscribe }); + + // Initial fetch + await pollFn(); + } + + // ============================================================================ + // CLEANUP + // ============================================================================ + + destroy(): void { + log('Destroying AccountsApiDataSource'); + + // Clean up timers + if (this.#chainsRefreshTimer) { + clearInterval(this.#chainsRefreshTimer); + } + + // Clean up WebSocket + if (this.#websocket) { + this.#websocket.close(); + } + + // Clean up subscriptions + super.destroy(); + } +} + +// ============================================================================ +// FACTORY FUNCTION +// ============================================================================ + +/** + * Creates an AccountsApiDataSource instance. + */ +export function createAccountsApiDataSource( + options: AccountsApiDataSourceOptions, +): AccountsApiDataSource { + return new AccountsApiDataSource(options); +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/BackendWebsocketDataSource.ts new file mode 100644 index 00000000000..ed1385ff218 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/BackendWebsocketDataSource.ts @@ -0,0 +1,616 @@ +import type { + BackendWebSocketServiceActions, + BackendWebSocketServiceEvents, + ServerNotificationMessage, + WebSocketSubscription, + WebSocketState, + AccountActivityMessage, + BalanceUpdate, +} from '@metamask/core-backend'; +import type { Messenger } from '@metamask/messenger'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import type { + ChainId, + Caip19AssetId, + AssetMetadata, + AssetBalance, + DataRequest, + DataResponse, +} from '../types'; +import { + AbstractDataSource, + type DataSourceState, + type SubscriptionRequest, +} from './AbstractDataSource'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'BackendWebsocketDataSource'; +const CHANNEL_TYPE = 'account-activity.v1'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +// Action types that BackendWebsocketDataSource exposes +export type BackendWebsocketDataSourceGetActiveChainsAction = { + type: 'BackendWebsocketDataSource:getActiveChains'; + handler: () => Promise; +}; + +export type BackendWebsocketDataSourceSubscribeAction = { + type: 'BackendWebsocketDataSource:subscribe'; + handler: (request: SubscriptionRequest) => Promise; +}; + +export type BackendWebsocketDataSourceUnsubscribeAction = { + type: 'BackendWebsocketDataSource:unsubscribe'; + handler: (subscriptionId: string) => Promise; +}; + +export type BackendWebsocketDataSourceActions = + | BackendWebsocketDataSourceGetActiveChainsAction + | BackendWebsocketDataSourceSubscribeAction + | BackendWebsocketDataSourceUnsubscribeAction; + +// Event types that BackendWebsocketDataSource publishes +export type BackendWebsocketDataSourceActiveChainsChangedEvent = { + type: 'BackendWebsocketDataSource:activeChainsUpdated'; + payload: [ChainId[]]; +}; + +export type BackendWebsocketDataSourceAssetsUpdatedEvent = { + type: 'BackendWebsocketDataSource:assetsUpdated'; + payload: [DataResponse, string | undefined]; +}; + +export type BackendWebsocketDataSourceEvents = + | BackendWebsocketDataSourceActiveChainsChangedEvent + | BackendWebsocketDataSourceAssetsUpdatedEvent; + +// Actions to report to AssetsController +type AssetsControllerActiveChainsUpdateAction = { + type: 'AssetsController:activeChainsUpdate'; + handler: (dataSourceId: string, activeChains: ChainId[]) => void; +}; + +type AssetsControllerAssetsUpdateAction = { + type: 'AssetsController:assetsUpdate'; + handler: (response: DataResponse, sourceId: string) => Promise; +}; + +// Allowed actions that BackendWebsocketDataSource can call +export type BackendWebsocketDataSourceAllowedActions = + | BackendWebSocketServiceActions + | AssetsControllerActiveChainsUpdateAction + | AssetsControllerAssetsUpdateAction; + +// Event type from AccountsApiDataSource that we subscribe to +export type AccountsApiDataSourceActiveChainsChangedEvent = { + type: 'AccountsApiDataSource:activeChainsUpdated'; + payload: [ChainId[]]; +}; + +// Allowed events that BackendWebsocketDataSource can subscribe to +export type BackendWebsocketDataSourceAllowedEvents = + | BackendWebSocketServiceEvents + | AccountsApiDataSourceActiveChainsChangedEvent; + +export type BackendWebsocketDataSourceMessenger = Messenger< + typeof CONTROLLER_NAME, + BackendWebsocketDataSourceActions | BackendWebsocketDataSourceAllowedActions, + BackendWebsocketDataSourceEvents | BackendWebsocketDataSourceAllowedEvents +>; + +// ============================================================================ +// STATE +// ============================================================================ + +export type BackendWebsocketDataSourceState = DataSourceState; + +const defaultState: BackendWebsocketDataSourceState = { + activeChains: [], +}; + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface BackendWebsocketDataSourceOptions { + messenger: BackendWebsocketDataSourceMessenger; + state?: Partial; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Extract namespace from a CAIP-2 chain ID. + * E.g., "eip155:1" -> "eip155", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" -> "solana" + */ +function extractNamespace(chainId: ChainId): string { + const [namespace] = chainId.split(':'); + return namespace; +} + +/** + * Get unique namespaces from chain IDs. + */ +function getUniqueNamespaces(chainIds: ChainId[]): string[] { + const namespaces = new Set(); + for (const chainId of chainIds) { + namespaces.add(extractNamespace(chainId)); + } + return Array.from(namespaces); +} + +/** + * Build WebSocket channel name for account activity using CAIP-10 wildcard format. + * Uses 0 as the chain reference to subscribe to all chains in the namespace. + * Format: account-activity.v1.eip155:0:0x1234... (all EVM chains) + * Format: account-activity.v1.solana:0:ABC123... (all Solana chains) + */ +function buildAccountActivityChannel( + namespace: string, + address: string, +): string { + return `${CHANNEL_TYPE}.${namespace}:0:${address.toLowerCase()}`; +} + +// Note: AccountActivityMessage and BalanceUpdate types are imported from @metamask/core-backend + +// ============================================================================ +// BACKEND WEBSOCKET DATA SOURCE +// ============================================================================ + +/** + * Data source for receiving real-time balance updates via WebSocket. + * + * This data source connects directly to BackendWebSocketService to receive + * push notifications for account balance changes. Unlike AccountsApiDataSource + * which polls for data, this provides instant updates. + * + * Uses Messenger pattern for all interactions: + * - Calls BackendWebSocketService methods via messenger actions + * - Exposes its own actions for AssetsController to call + * - Publishes events for AssetsController to subscribe to + * + * Actions exposed: + * - BackendWebsocketDataSource:getActiveChains + * - BackendWebsocketDataSource:subscribe + * - BackendWebsocketDataSource:unsubscribe + * + * Events published: + * - BackendWebsocketDataSource:activeChainsUpdated + * - BackendWebsocketDataSource:assetsUpdated + * + * Actions called (from BackendWebSocketService): + * - BackendWebSocketService:subscribe + * - BackendWebSocketService:getConnectionInfo + * - BackendWebSocketService:findSubscriptionsByChannelPrefix + */ +export class BackendWebsocketDataSource extends AbstractDataSource< + typeof CONTROLLER_NAME, + BackendWebsocketDataSourceState +> { + readonly #messenger: BackendWebsocketDataSourceMessenger; + + /** WebSocket subscriptions by our internal subscription ID */ + readonly #wsSubscriptions: Map = new Map(); + + /** Pending subscription requests to process when WebSocket connects */ + readonly #pendingSubscriptions: Map = new Map(); + + constructor(options: BackendWebsocketDataSourceOptions) { + super(CONTROLLER_NAME, { + ...defaultState, + ...options.state, + }); + + this.#messenger = options.messenger; + + log('Initializing BackendWebsocketDataSource'); + + this.#registerActionHandlers(); + this.#subscribeToEvents(); + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + #registerActionHandlers(): void { + const getActiveChainsHandler: BackendWebsocketDataSourceGetActiveChainsAction['handler'] = + async () => this.getActiveChains(); + + const subscribeHandler: BackendWebsocketDataSourceSubscribeAction['handler'] = + async (request) => this.subscribe(request); + + const unsubscribeHandler: BackendWebsocketDataSourceUnsubscribeAction['handler'] = + async (subscriptionId) => this.unsubscribe(subscriptionId); + + this.#messenger.registerActionHandler( + 'BackendWebsocketDataSource:getActiveChains', + getActiveChainsHandler, + ); + + this.#messenger.registerActionHandler( + 'BackendWebsocketDataSource:subscribe', + subscribeHandler, + ); + + this.#messenger.registerActionHandler( + 'BackendWebsocketDataSource:unsubscribe', + unsubscribeHandler, + ); + } + + #subscribeToEvents(): void { + // Listen for WebSocket connection state changes + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + (connectionInfo) => { + log('WebSocket connection state changed', { + state: connectionInfo.state, + }); + + if (connectionInfo.state === ('connected' as WebSocketState)) { + // WebSocket connected - process any pending subscriptions + this.#processPendingSubscriptions(); + } else if ( + connectionInfo.state === ('disconnected' as WebSocketState) + ) { + // When disconnected, all subscriptions are cleared server-side + // We need to clear our local tracking + log('WebSocket disconnected, clearing subscriptions'); + this.#wsSubscriptions.clear(); + } + }, + ); + + // Listen for AccountsApiDataSource active chains changes + // This keeps BackendWebsocketDataSource in sync with the supported chains + // since both use the same backend infrastructure + this.#messenger.subscribe( + 'AccountsApiDataSource:activeChainsUpdated', + (chains: ChainId[]) => { + log('Active chains changed from AccountsApiDataSource', { chains }); + this.updateActiveChains(chains, (c) => + this.#messenger.call('AssetsController:activeChainsUpdate', CONTROLLER_NAME, c), + ); + }, + ); + } + + /** + * Process any pending subscriptions that were queued while WebSocket was disconnected. + */ + async #processPendingSubscriptions(): Promise { + if (this.#pendingSubscriptions.size === 0) { + return; + } + + log('Processing pending subscriptions', { + count: this.#pendingSubscriptions.size, + subscriptionIds: Array.from(this.#pendingSubscriptions.keys()), + }); + + // Process all pending subscriptions + const pendingEntries = Array.from(this.#pendingSubscriptions.entries()); + + for (const [subscriptionId, request] of pendingEntries) { + try { + // Remove from pending before processing to avoid infinite loop + this.#pendingSubscriptions.delete(subscriptionId); + await this.subscribe(request); + log('Pending subscription processed', { subscriptionId }); + } catch (error) { + log('Failed to process pending subscription', { + subscriptionId, + error, + }); + } + } + } + + // ============================================================================ + // ACTIVE CHAINS + // ============================================================================ + + /** + * Update active chains when AccountsApiDataSource reports new supported chains. + */ + updateSupportedChains(chains: ChainId[]): void { + this.updateActiveChains(chains, (c) => + this.#messenger.call('AssetsController:activeChainsUpdate', CONTROLLER_NAME, c), + ); + } + + // ============================================================================ + // SUBSCRIBE + // ============================================================================ + + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { + const { request, subscriptionId, isUpdate } = subscriptionRequest; + + // Filter to active chains only + const chainsToSubscribe = request.chainIds.filter((chainId) => + this.state.activeChains.includes(chainId), + ); + + const addresses = request.accounts.map((a) => a.address); + + log('Subscribe requested', { + subscriptionId, + isUpdate, + accounts: request.accounts.map((a) => a.id), + requestedChains: request.chainIds, + chainsToSubscribe, + }); + + if (chainsToSubscribe.length === 0) { + log('No active chains to subscribe'); + return; + } + + // Check WebSocket connection status + try { + const connectionInfo = this.#messenger.call( + 'BackendWebSocketService:getConnectionInfo', + ); + if (connectionInfo.state !== ('connected' as WebSocketState)) { + log('WebSocket not connected, queuing subscription for later', { + state: connectionInfo.state, + subscriptionId, + }); + // Store the subscription request to process when WebSocket connects + this.#pendingSubscriptions.set(subscriptionId, subscriptionRequest); + return; + } + } catch (error) { + log('Could not get connection info, queuing subscription', { + error, + subscriptionId, + }); + // Store anyway - will be processed when we can connect + this.#pendingSubscriptions.set(subscriptionId, subscriptionRequest); + return; + } + + // Remove from pending if it was there (we're processing it now) + this.#pendingSubscriptions.delete(subscriptionId); + + // Handle subscription update + if (isUpdate) { + const existing = this.activeSubscriptions.get(subscriptionId); + if (existing) { + log('Updating existing subscription', { subscriptionId }); + existing.chains = chainsToSubscribe; + } + } + + // Clean up existing subscription if any + await this.unsubscribe(subscriptionId); + + // Extract unique namespaces from chains (e.g., eip155, solana) + const namespaces = getUniqueNamespaces(chainsToSubscribe); + + // Build channel names using CAIP-10 wildcard format + const channels: string[] = []; + for (const namespace of namespaces) { + for (const address of addresses) { + channels.push(buildAccountActivityChannel(namespace, address)); + } + } + + log('Creating WebSocket subscription', { + subscriptionId, + channels, + namespaces, + }); + + try { + // Create WebSocket subscription + const wsSubscription = await this.#messenger.call( + 'BackendWebSocketService:subscribe', + { + channels, + channelType: CHANNEL_TYPE, + callback: (notification: ServerNotificationMessage) => { + this.#handleNotification(notification, request); + }, + }, + ); + + // Store WebSocket subscription + this.#wsSubscriptions.set(subscriptionId, wsSubscription); + + // Store in abstract class tracking + this.activeSubscriptions.set(subscriptionId, { + cleanup: async () => { + log('Cleaning up WebSocket subscription', { subscriptionId }); + const wsSub = this.#wsSubscriptions.get(subscriptionId); + if (wsSub) { + try { + await wsSub.unsubscribe(); + } catch (error) { + log('Error unsubscribing', { subscriptionId, error }); + } + this.#wsSubscriptions.delete(subscriptionId); + } + }, + chains: chainsToSubscribe, + }); + + log('WebSocket subscription SUCCESS', { + subscriptionId, + channels, + chains: chainsToSubscribe, + }); + } catch (error) { + log('WebSocket subscription FAILED', { + subscriptionId, + error, + chains: chainsToSubscribe, + }); + } + } + + // ============================================================================ + // NOTIFICATION HANDLING + // ============================================================================ + + #handleNotification( + notification: ServerNotificationMessage, + request: DataRequest, + ): void { + try { + const activityMessage = + notification.data as unknown as AccountActivityMessage; + const { address, tx, updates } = activityMessage; + + if (!address || !tx || !updates) { + return; + } + + // Extract chain ID from transaction (CAIP-2 format, e.g., "eip155:8453") + const chainId = tx.chain as ChainId; + + log('Notification received', { + address, + chainId, + updateCount: updates.length, + }); + + // Find matching account in request + const account = request.accounts.find( + (a) => a.address.toLowerCase() === address.toLowerCase(), + ); + if (!account) { + log('Address not found in request', { address }); + return; + } + const accountId = account.id; + + // Process all balance updates from the activity message + const response = this.#processBalanceUpdates(updates, chainId, accountId); + + if (Object.keys(response).length > 0) { + log('Reporting balance update', { + accountId, + chainId, + assetCount: Object.keys(response.assetsMetadata ?? {}).length, + }); + // Report update to AssetsController + this.#messenger.call( + 'AssetsController:assetsUpdate', + response, + CONTROLLER_NAME, + ); + } + } catch (error) { + log('Error handling notification', error); + } + } + + /** + * Process balance updates from AccountActivityMessage. + * Each update contains asset info, post-transaction balance, and transfer details. + */ + #processBalanceUpdates( + updates: BalanceUpdate[], + chainId: ChainId, + accountId: string, + ): DataResponse { + const assetsBalance: Record> = { + [accountId]: {}, + }; + const assetsMetadata: Record = {}; + + for (const update of updates) { + const { asset, postBalance } = update; + + if (!asset || !postBalance) { + continue; + } + + // Asset type is in CAIP format: "eip155:1/erc20:0x..." or "eip155:1/slip44:60" + // We can use it directly as the asset ID + const assetId = asset.type as Caip19AssetId; + + // Determine token type from asset type string + const isNative = asset.type.includes('/slip44:'); + const tokenType = isNative ? 'native' : 'erc20'; + + // Parse balance amount (already in hex format like "0xc350") + const balanceAmount = postBalance.amount.startsWith('0x') + ? BigInt(postBalance.amount).toString() + : postBalance.amount; + + assetsBalance[accountId][assetId] = { + amount: balanceAmount, + }; + + assetsMetadata[assetId] = { + type: tokenType, + symbol: asset.unit, + name: asset.unit, // Use unit as name (actual name may not be in the message) + decimals: asset.decimals, + }; + } + + const response: DataResponse = {}; + if (Object.keys(assetsBalance[accountId]).length > 0) { + response.assetsBalance = assetsBalance; + response.assetsMetadata = assetsMetadata; + } + + return response; + } + + // ============================================================================ + // CLEANUP + // ============================================================================ + + destroy(): void { + log('Destroying BackendWebsocketDataSource', { + subscriptionCount: this.#wsSubscriptions.size, + }); + + // Clean up WebSocket subscriptions + for (const [subscriptionId, wsSub] of this.#wsSubscriptions) { + try { + // Fire and forget - don't await in destroy + wsSub.unsubscribe().catch(() => { + // Ignore errors during cleanup + }); + } catch { + // Ignore errors during cleanup + } + this.#wsSubscriptions.delete(subscriptionId); + } + + // Clean up base class subscriptions + super.destroy(); + } +} + +// ============================================================================ +// FACTORY FUNCTION +// ============================================================================ + +/** + * Creates a BackendWebsocketDataSource instance. + */ +export function createBackendWebsocketDataSource( + options: BackendWebsocketDataSourceOptions, +): BackendWebsocketDataSource { + return new BackendWebsocketDataSource(options); +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/DetectionMiddleware.ts b/packages/assets-controllers/src/AssetsController/data-sources/DetectionMiddleware.ts new file mode 100644 index 00000000000..ca57e2605d1 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/DetectionMiddleware.ts @@ -0,0 +1,149 @@ +import type { Messenger } from '@metamask/messenger'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import { + forDataTypes, + type AccountId, + type Caip19AssetId, + type Middleware, +} from '../types'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'DetectionMiddleware'; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +/** + * Action to get the DetectionMiddleware middleware. + */ +export type DetectionMiddlewareGetAssetsMiddlewareAction = { + type: `${typeof CONTROLLER_NAME}:getAssetsMiddleware`; + handler: () => Middleware; +}; + +/** + * All actions exposed by DetectionMiddleware. + */ +export type DetectionMiddlewareActions = DetectionMiddlewareGetAssetsMiddlewareAction; + +export type DetectionMiddlewareMessenger = Messenger< + typeof CONTROLLER_NAME, + DetectionMiddlewareActions, + never +>; + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface DetectionMiddlewareOptions { + messenger: DetectionMiddlewareMessenger; +} + +// ============================================================================ +// DETECTION MIDDLEWARE +// ============================================================================ + +/** + * DetectionMiddleware identifies assets that do not have metadata. + * + * This middleware: + * - Checks assets in the response for metadata in state + * - Assets in response but without metadata are considered "detected" + * - Fills response.detectedAssets with asset IDs per account that lack metadata + * + * Usage: + * ```typescript + * // Create and initialize (registers messenger actions) + * const detectionMiddleware = new DetectionMiddleware({ messenger }); + * + * // Later, get middleware via messenger + * const middleware = messenger.call('DetectionMiddleware:getAssetsMiddleware'); + * ``` + */ +export class DetectionMiddleware { + readonly name = CONTROLLER_NAME; + + readonly #messenger: DetectionMiddlewareMessenger; + + constructor(options: DetectionMiddlewareOptions) { + this.#messenger = options.messenger; + this.#registerActionHandlers(); + } + + #registerActionHandlers(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.#messenger as any).registerActionHandler( + 'DetectionMiddleware:getAssetsMiddleware', + () => this.assetsMiddleware, + ); + } + + /** + * Get the middleware for detecting assets without metadata. + * + * This middleware: + * 1. Extracts the response from context + * 2. Detects assets from the response that don't have metadata + * 3. Fills response.detectedAssets with detected asset IDs per account + * 4. Calls next() to continue the middleware chain + */ + get assetsMiddleware(): Middleware { + return forDataTypes(['balance'], async (ctx, next) => { + // Extract response from context + const { response } = ctx; + + // If no balances in response, nothing to detect - pass through + if (!response.assetsBalance) { + return next(ctx); + } + + // Get metadata from state + const { assetsMetadata: stateMetadata } = ctx.getAssetsState(); + + const detectedAssets: Record = {}; + + // Detect assets from the response that don't have metadata + for (const [accountId, accountBalances] of Object.entries(response.assetsBalance)) { + const detected: Caip19AssetId[] = []; + + for (const assetId of Object.keys(accountBalances as Record)) { + // Asset is detected if it does not have metadata in state + if (!stateMetadata[assetId as Caip19AssetId]) { + detected.push(assetId as Caip19AssetId); + } + } + + if (detected.length > 0) { + detectedAssets[accountId] = detected; + } + } + + // Fill detectedAssets in the response + if (Object.keys(detectedAssets).length > 0) { + response.detectedAssets = detectedAssets; + + log('Detected assets without metadata', { + accountCount: Object.keys(detectedAssets).length, + totalAssets: Object.values(detectedAssets).reduce((sum, arr) => sum + arr.length, 0), + byAccount: Object.fromEntries( + Object.entries(detectedAssets).map(([accountId, assets]) => [ + accountId, + { count: assets.length, assets: assets.slice(0, 5) }, + ]), + ), + }); + } + + // Call next() to continue the middleware chain + return next(ctx); + }); + } +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/PriceDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/PriceDataSource.ts new file mode 100644 index 00000000000..43767eb7410 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/PriceDataSource.ts @@ -0,0 +1,552 @@ +import type { + PricesGetV3SpotPricesAction, + MarketDataDetails, + SupportedCurrency, +} from '@metamask/core-backend'; +import type { Messenger } from '@metamask/messenger'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import type { SubscriptionRequest } from './AbstractDataSource'; +import { + forDataTypes, + type Caip19AssetId, + type AssetPrice, + type AssetBalance, + type AccountId, + type ChainId, + type DataRequest, + type DataResponse, + type Middleware, +} from '../types'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'PriceDataSource'; +const DEFAULT_POLL_INTERVAL = 60_000; // 1 minute for price updates + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +/** + * Action to get balance state (used to determine which assets need prices). + */ +type GetAssetsBalanceStateAction = { + type: 'AssetsController:getState'; + handler: () => { + assetsBalance: Record>; + }; +}; + +/** + * Action to get the PriceDataSource middleware. + */ +export type PriceDataSourceGetAssetsMiddlewareAction = { + type: `${typeof CONTROLLER_NAME}:getAssetsMiddleware`; + handler: () => Middleware; +}; + +/** + * Action to fetch prices for assets. + */ +export type PriceDataSourceFetchAction = { + type: `${typeof CONTROLLER_NAME}:fetch`; + handler: (request: DataRequest) => Promise; +}; + +/** + * Action to subscribe to price updates. + */ +export type PriceDataSourceSubscribeAction = { + type: `${typeof CONTROLLER_NAME}:subscribe`; + handler: (request: SubscriptionRequest) => Promise; +}; + +/** + * Action to unsubscribe from price updates. + */ +export type PriceDataSourceUnsubscribeAction = { + type: `${typeof CONTROLLER_NAME}:unsubscribe`; + handler: (subscriptionId: string) => Promise; +}; + +/** + * All actions exposed by PriceDataSource. + */ +export type PriceDataSourceActions = + | PriceDataSourceGetAssetsMiddlewareAction + | PriceDataSourceFetchAction + | PriceDataSourceSubscribeAction + | PriceDataSourceUnsubscribeAction; + +/** + * Event emitted when prices are updated. + */ +export type PriceDataSourceAssetsUpdatedEvent = { + type: `${typeof CONTROLLER_NAME}:assetsUpdated`; + payload: [DataResponse, string]; +}; + +/** + * All events exposed by PriceDataSource. + */ +export type PriceDataSourceEvents = PriceDataSourceAssetsUpdatedEvent; + +// Action to report assets updated to AssetsController +type AssetsControllerAssetsUpdateAction = { + type: 'AssetsController:assetsUpdate'; + handler: (response: DataResponse, sourceId: string) => Promise; +}; + +/** + * External actions that PriceDataSource needs to call. + */ +export type PriceDataSourceAllowedActions = + | PricesGetV3SpotPricesAction + | GetAssetsBalanceStateAction + | AssetsControllerAssetsUpdateAction; + +export type PriceDataSourceMessenger = Messenger< + typeof CONTROLLER_NAME, + PriceDataSourceAllowedActions | PriceDataSourceActions, + PriceDataSourceEvents +>; + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface PriceDataSourceOptions { + messenger: PriceDataSourceMessenger; + /** Currency to fetch prices in (default: 'usd') */ + currency?: SupportedCurrency; + /** Polling interval in ms (default: 60000) */ + pollInterval?: number; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Asset reference patterns that should NOT be sent to the Price API. + * These are internal resource tracking values without market prices. + */ +const NON_PRICEABLE_ASSET_PATTERNS = [ + // Tron resource assets (bandwidth, energy, staking states) + /\/slip44:\d+-staked-for-/u, + /\/slip44:bandwidth$/u, + /\/slip44:energy$/u, + /\/slip44:maximum-bandwidth$/u, + /\/slip44:maximum-energy$/u, +]; + +/** + * Check if an asset ID represents a priceable asset. + * Filters out internal resource tracking values that don't have market prices. + */ +function isPriceableAsset(assetId: Caip19AssetId): boolean { + return !NON_PRICEABLE_ASSET_PATTERNS.some((pattern) => pattern.test(assetId)); +} + +function transformMarketDataToAssetPrice( + marketData: MarketDataDetails, +): AssetPrice { + return { + price: marketData.price, + priceChange24h: marketData.pricePercentChange1d, + lastUpdated: Date.now(), + // Extended market data + marketCap: marketData.marketCap, + volume24h: marketData.totalVolume, + }; +} + +// ============================================================================ +// PRICE DATA SOURCE +// ============================================================================ + +/** + * PriceDataSource fetches asset prices from the Price API. + * + * This data source: + * - Fetches prices from Price API v3 spot-prices endpoint + * - Supports one-time fetch and subscription-based polling + * - In subscribe mode, automatically fetches prices for all assets in assetsBalance state + * - Publishes price updates via messenger events + * + * Usage: + * ```typescript + * // Create and initialize (registers messenger actions) + * const priceDataSource = new PriceDataSource({ messenger }); + * + * // One-time fetch for specific assets + * const response = await messenger.call('PriceDataSource:fetch', { + * customAssets: ['eip155:1/erc20:0x...'], + * }); + * + * // Subscribe to price updates (polls all assets in balance state) + * await messenger.call('PriceDataSource:subscribe', { request, subscriptionId }); + * + * // Listen for updates + * messenger.subscribe('PriceDataSource:assetsUpdated', (response) => { + * // Handle price updates + * }); + * ``` + */ +export class PriceDataSource { + readonly name = CONTROLLER_NAME; + + readonly #messenger: PriceDataSourceMessenger; + + readonly #currency: SupportedCurrency; + + readonly #pollInterval: number; + + /** Active subscriptions by ID */ + readonly #activeSubscriptions: Map< + string, + { cleanup: () => void; request: DataRequest } + > = new Map(); + + constructor(options: PriceDataSourceOptions) { + this.#messenger = options.messenger; + this.#currency = options.currency ?? 'usd'; + this.#pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL; + this.#registerActionHandlers(); + + log('Initialized', { + currency: this.#currency, + pollInterval: this.#pollInterval, + }); + } + + #registerActionHandlers(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messenger = this.#messenger as any; + + messenger.registerActionHandler( + 'PriceDataSource:getAssetsMiddleware', + () => this.assetsMiddleware, + ); + + messenger.registerActionHandler( + 'PriceDataSource:fetch', + (request: DataRequest) => this.fetch(request), + ); + + messenger.registerActionHandler( + 'PriceDataSource:subscribe', + (subscriptionRequest: SubscriptionRequest) => + this.subscribe(subscriptionRequest), + ); + + messenger.registerActionHandler( + 'PriceDataSource:unsubscribe', + (subscriptionId: string) => this.unsubscribe(subscriptionId), + ); + } + + // ============================================================================ + // MIDDLEWARE + // ============================================================================ + + /** + * Get the middleware for enriching responses with price data. + * + * This middleware: + * 1. Extracts the response from context + * 2. Fetches prices for detected assets (assets without metadata) + * 3. Enriches the response with fetched prices + * 4. Calls next() at the end to continue the middleware chain + * + * Note: This middleware ONLY fetches prices for detected assets. + * For fetching prices for all assets, use the subscription mechanism + * which polls prices for all assets in the balance state. + */ + get assetsMiddleware(): Middleware { + return forDataTypes(['price'], async (ctx, next) => { + // Extract response from context + const { response } = ctx; + + // Only fetch prices for detected assets (assets without metadata) + // The subscription handles fetching prices for all existing assets + if (!response.detectedAssets) { + return next(ctx); + } + + const detectedAssetIds = new Set(); + for (const detectedIds of Object.values(response.detectedAssets)) { + for (const assetId of detectedIds) { + detectedAssetIds.add(assetId); + } + } + + if (detectedAssetIds.size === 0) { + return next(ctx); + } + + // Filter to only priceable assets + const priceableAssetIds = [...detectedAssetIds].filter(isPriceableAsset); + + if (priceableAssetIds.length === 0) { + return next(ctx); + } + + log('Fetching prices for detected assets via middleware', { + count: priceableAssetIds.length, + assetIds: priceableAssetIds.slice(0, 10), + }); + + try { + const priceResponse = await this.#messenger.call( + 'BackendApiClient:Prices:getV3SpotPrices', + priceableAssetIds, + this.#currency, + true, // includeMarketData + ); + + if (!response.assetsPrice) { + response.assetsPrice = {}; + } + + let fetchedCount = 0; + for (const [assetId, marketData] of Object.entries(priceResponse)) { + if (marketData === null || marketData === undefined) { + continue; + } + + const caipAssetId = assetId as Caip19AssetId; + response.assetsPrice[caipAssetId] = transformMarketDataToAssetPrice(marketData); + fetchedCount += 1; + } + + log('Enriched response with prices for detected assets', { + requested: priceableAssetIds.length, + received: fetchedCount, + }); + } catch (error) { + log('Failed to fetch prices via middleware', { error }); + } + + // Call next() at the end to continue the middleware chain + return next(ctx); + }); + } + + // ============================================================================ + // HELPERS + // ============================================================================ + + /** + * Get unique asset IDs from the assetsBalance state. + * Filters by accounts and chains from the request. + * + * @param request - Data request with accounts and chainIds filters + */ + #getAssetIdsFromBalanceState(request: DataRequest): Caip19AssetId[] { + try { + const state = this.#messenger.call('AssetsController:getState'); + const assetIds = new Set(); + + const accountIds = request.accounts.map((a) => a.id); + const accountFilter = + accountIds.length > 0 ? new Set(accountIds) : undefined; + const chainFilter = + request.chainIds.length > 0 ? new Set(request.chainIds) : undefined; + + if (state?.assetsBalance) { + for (const [accountId, accountBalances] of Object.entries( + state.assetsBalance, + )) { + // Filter by account if specified + if (accountFilter && !accountFilter.has(accountId)) { + continue; + } + + for (const assetId of Object.keys( + accountBalances as Record, + )) { + // Filter by chain if specified + if (chainFilter) { + const chainId = assetId.split('/')[0] as ChainId; + if (!chainFilter.has(chainId)) { + continue; + } + } + assetIds.add(assetId as Caip19AssetId); + } + } + } + + return [...assetIds]; + } catch (error) { + log('Failed to get asset IDs from balance state', { error }); + return []; + } + } + + // ============================================================================ + // FETCH + // ============================================================================ + + /** + * Fetch prices for assets held by the accounts and chains in the request. + * Gets asset IDs from balance state, filtered by request.accounts and request.chainIds. + */ + async fetch(request: DataRequest): Promise { + const response: DataResponse = {}; + + // Get asset IDs from balance state, filtered by accounts and chains + const rawAssetIds = this.#getAssetIdsFromBalanceState(request); + + // Filter out non-priceable assets (e.g., Tron bandwidth/energy resources) + const assetIds = rawAssetIds.filter(isPriceableAsset); + + if (assetIds.length === 0) { + log('No asset IDs to fetch prices for'); + return response; + } + + try { + const priceResponse = await this.#messenger.call( + 'BackendApiClient:Prices:getV3SpotPrices', + [...assetIds], + this.#currency, + true, // includeMarketData + ); + + response.assetsPrice = {}; + + let nullCount = 0; + for (const [assetId, marketData] of Object.entries(priceResponse)) { + // Skip assets with null market data (API doesn't have price for this asset) + if (marketData === null || marketData === undefined) { + nullCount += 1; + continue; + } + + const caipAssetId = assetId as Caip19AssetId; + response.assetsPrice[caipAssetId] = + transformMarketDataToAssetPrice(marketData); + } + + log('Fetched prices', { + requested: assetIds.length, + received: Object.keys(response.assetsPrice).length, + nullResponses: nullCount, + }); + } catch (error) { + log('Failed to fetch prices', { error }); + } + + return response; + } + + // ============================================================================ + // SUBSCRIBE + // ============================================================================ + + /** + * Subscribe to price updates. + * Sets up polling that fetches prices for all assets in assetsBalance state. + */ + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { + const { request, subscriptionId, isUpdate } = subscriptionRequest; + + log('Subscribe requested', { + subscriptionId, + isUpdate, + }); + + // Handle subscription update - just update the request + if (isUpdate) { + const existing = this.#activeSubscriptions.get(subscriptionId); + if (existing) { + log('Updating existing subscription', { subscriptionId }); + existing.request = request; + return; + } + } + + // Clean up existing subscription + await this.unsubscribe(subscriptionId); + + const pollInterval = request.updateInterval ?? this.#pollInterval; + log('Setting up polling subscription', { subscriptionId, pollInterval }); + + // Create poll function - fetches prices for all assets in balance state + const pollFn = async () => { + try { + const subscription = this.#activeSubscriptions.get(subscriptionId); + if (!subscription) { + return; + } + + // Fetch prices for all assets currently in balance state + const fetchResponse = await this.fetch(subscription.request); + + // Only report if we got prices + if ( + fetchResponse.assetsPrice && + Object.keys(fetchResponse.assetsPrice).length > 0 + ) { + this.#messenger.call( + 'AssetsController:assetsUpdate', + fetchResponse, + CONTROLLER_NAME, + ); + } + } catch (error) { + log('Subscription poll failed', { subscriptionId, error }); + } + }; + + // Set up polling + const timer = setInterval(pollFn, pollInterval); + + // Store subscription + this.#activeSubscriptions.set(subscriptionId, { + cleanup: () => { + log('Cleaning up subscription', { subscriptionId }); + clearInterval(timer); + }, + request, + }); + + log('Subscription SUCCESS', { subscriptionId, pollInterval }); + + // Initial fetch + await pollFn(); + } + + /** + * Unsubscribe from price updates. + */ + async unsubscribe(subscriptionId: string): Promise { + const subscription = this.#activeSubscriptions.get(subscriptionId); + if (subscription) { + subscription.cleanup(); + this.#activeSubscriptions.delete(subscriptionId); + log('Unsubscribed', { subscriptionId }); + } + } + + /** + * Destroy the data source and clean up all subscriptions. + */ + destroy(): void { + log('Destroying PriceDataSource', { + subscriptionCount: this.#activeSubscriptions.size, + }); + + for (const subscription of this.#activeSubscriptions.values()) { + subscription.cleanup(); + } + this.#activeSubscriptions.clear(); + } +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/RpcDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/RpcDataSource.ts new file mode 100644 index 00000000000..d53fb169947 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/RpcDataSource.ts @@ -0,0 +1,812 @@ +import { Web3Provider } from '@ethersproject/providers'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; +import type { NetworkState, NetworkStatus } from '@metamask/network-controller'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import type { + ChainId, + Caip19AssetId, + AssetBalance, + DataRequest, + DataResponse, + Middleware, +} from '../types'; +import { + AbstractDataSource, + type DataSourceState, + type SubscriptionRequest, +} from './AbstractDataSource'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'RpcDataSource'; +const DEFAULT_POLL_INTERVAL = 30_000; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +// Action types +export type RpcDataSourceGetAssetsMiddlewareAction = { + type: 'RpcDataSource:getAssetsMiddleware'; + handler: () => Middleware; +}; + +export type RpcDataSourceGetActiveChainsAction = { + type: 'RpcDataSource:getActiveChains'; + handler: () => Promise; +}; + +export type RpcDataSourceFetchAction = { + type: 'RpcDataSource:fetch'; + handler: (request: DataRequest) => Promise; +}; + +export type RpcDataSourceSubscribeAction = { + type: 'RpcDataSource:subscribe'; + handler: (request: SubscriptionRequest) => Promise; +}; + +export type RpcDataSourceUnsubscribeAction = { + type: 'RpcDataSource:unsubscribe'; + handler: (subscriptionId: string) => Promise; +}; + +export type RpcDataSourceActions = + | RpcDataSourceGetAssetsMiddlewareAction + | RpcDataSourceGetActiveChainsAction + | RpcDataSourceFetchAction + | RpcDataSourceSubscribeAction + | RpcDataSourceUnsubscribeAction; + +// Event types +export type RpcDataSourceActiveChainsChangedEvent = { + type: 'RpcDataSource:activeChainsUpdated'; + payload: [ChainId[]]; +}; + +export type RpcDataSourceAssetsUpdatedEvent = { + type: 'RpcDataSource:assetsUpdated'; + payload: [DataResponse, string | undefined]; +}; + +export type RpcDataSourceEvents = + | RpcDataSourceActiveChainsChangedEvent + | RpcDataSourceAssetsUpdatedEvent; + +// NetworkController action to get state +export type NetworkControllerGetStateAction = { + type: 'NetworkController:getState'; + handler: () => NetworkState; +}; + +// NetworkController action to get network client by ID +export type NetworkControllerGetNetworkClientByIdAction = { + type: 'NetworkController:getNetworkClientById'; + handler: (networkClientId: string) => NetworkClient; +}; + +// Network client returned by NetworkController +export type NetworkClient = { + provider: EthereumProvider; + configuration: { + chainId: string; + }; +}; + +// Ethereum provider interface +export type EthereumProvider = { + request: (args: { method: string; params?: unknown[] }) => Promise; +}; + +// NetworkController state change event +export type NetworkControllerStateChangeEvent = { + type: 'NetworkController:stateChange'; + payload: [NetworkState, Patch[]]; +}; + +// Patch type for state changes +type Patch = { + op: 'add' | 'remove' | 'replace'; + path: string[]; + value?: unknown; +}; + +// Actions to report to AssetsController +type AssetsControllerActiveChainsUpdateAction = { + type: 'AssetsController:activeChainsUpdate'; + handler: (dataSourceId: string, activeChains: ChainId[]) => void; +}; + +type AssetsControllerAssetsUpdateAction = { + type: 'AssetsController:assetsUpdate'; + handler: (response: DataResponse, sourceId: string) => Promise; +}; + +// Allowed actions that RpcDataSource can call +export type RpcDataSourceAllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | AssetsControllerActiveChainsUpdateAction + | AssetsControllerAssetsUpdateAction; + +// Allowed events that RpcDataSource can subscribe to +export type RpcDataSourceAllowedEvents = NetworkControllerStateChangeEvent; + +export type RpcDataSourceMessenger = Messenger< + typeof CONTROLLER_NAME, + RpcDataSourceActions | RpcDataSourceAllowedActions, + RpcDataSourceEvents | RpcDataSourceAllowedEvents +>; + +// ============================================================================ +// STATE +// ============================================================================ + +/** Network status for each chain */ +export type ChainStatus = { + chainId: ChainId; + status: NetworkStatus; + name: string; + nativeCurrency: string; + /** Network client ID for getting the provider */ + networkClientId: string; +}; + +export interface RpcDataSourceState extends DataSourceState { + /** Network status for each active chain */ + chainStatuses: Record; +} + +const defaultState: RpcDataSourceState = { + activeChains: [], + chainStatuses: {}, +}; + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface RpcDataSourceOptions { + messenger: RpcDataSourceMessenger; + /** Request timeout in ms */ + timeout?: number; + /** Polling interval in ms */ + pollInterval?: number; + state?: Partial; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function buildNativeAssetId(chainId: ChainId): Caip19AssetId { + return `${chainId}/slip44:60` as Caip19AssetId; +} + +// ============================================================================ +// RPC DATA SOURCE +// ============================================================================ + +/** + * Data source for fetching balances via RPC calls. + * + * Used as a fallback when the Accounts API doesn't support a chain. + * + * Communicates with AssetsController via Messenger: + * + * Actions: + * - RpcDataSource:getActiveChains + * - RpcDataSource:fetch + * - RpcDataSource:subscribe + * - RpcDataSource:unsubscribe + * + * Events: + * - RpcDataSource:activeChainsUpdated + * - RpcDataSource:assetsUpdated + */ +export class RpcDataSource extends AbstractDataSource< + typeof CONTROLLER_NAME, + RpcDataSourceState +> { + readonly #messenger: RpcDataSourceMessenger; + + readonly #timeout: number; + + readonly #pollInterval: number; + + /** Cache of Web3Provider instances by chainId */ + readonly #providerCache: Map = new Map(); + + constructor(options: RpcDataSourceOptions) { + super(CONTROLLER_NAME, { + ...defaultState, + ...options.state, + }); + + this.#messenger = options.messenger; + this.#timeout = options.timeout ?? 10_000; + this.#pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL; + + log('Initializing RpcDataSource', { + timeout: this.#timeout, + pollInterval: this.#pollInterval, + }); + + this.#registerActionHandlers(); + this.#subscribeToNetworkController(); + this.#initializeFromNetworkController(); + } + + // ============================================================================ + // INITIALIZATION + // ============================================================================ + + #registerActionHandlers(): void { + const getAssetsMiddlewareHandler: RpcDataSourceGetAssetsMiddlewareAction['handler'] = + () => this.assetsMiddleware; + + const getActiveChainsHandler: RpcDataSourceGetActiveChainsAction['handler'] = + async () => this.getActiveChains(); + + const fetchHandler: RpcDataSourceFetchAction['handler'] = async (request) => + this.fetch(request); + + const subscribeHandler: RpcDataSourceSubscribeAction['handler'] = async ( + request, + ) => this.subscribe(request); + + const unsubscribeHandler: RpcDataSourceUnsubscribeAction['handler'] = + async (subscriptionId) => this.unsubscribe(subscriptionId); + + this.#messenger.registerActionHandler( + 'RpcDataSource:getAssetsMiddleware', + getAssetsMiddlewareHandler, + ); + + this.#messenger.registerActionHandler( + 'RpcDataSource:getActiveChains', + getActiveChainsHandler, + ); + + this.#messenger.registerActionHandler('RpcDataSource:fetch', fetchHandler); + + this.#messenger.registerActionHandler( + 'RpcDataSource:subscribe', + subscribeHandler, + ); + + this.#messenger.registerActionHandler( + 'RpcDataSource:unsubscribe', + unsubscribeHandler, + ); + } + + /** + * Subscribe to NetworkController state changes. + */ + #subscribeToNetworkController(): void { + this.#messenger.subscribe( + 'NetworkController:stateChange', + (networkState: NetworkState) => { + log('NetworkController state changed'); + // Clear provider cache since network configurations may have changed + this.#clearProviderCache(); + this.#updateFromNetworkState(networkState); + }, + ); + } + + /** + * Initialize active chains from NetworkController state. + */ + #initializeFromNetworkController(): void { + log('Initializing from NetworkController'); + try { + const networkState = this.#messenger.call('NetworkController:getState'); + this.#updateFromNetworkState(networkState); + } catch (error) { + log('Failed to initialize from NetworkController', error); + } + } + + /** + * Update active chains and statuses from NetworkController state. + * Only chains with an available provider are considered active. + */ + #updateFromNetworkState(networkState: NetworkState): void { + const { networkConfigurationsByChainId, networksMetadata } = networkState; + + const chainStatuses: Record = {}; + const activeChains: ChainId[] = []; + + // Iterate through all configured networks + for (const [hexChainId, config] of Object.entries( + networkConfigurationsByChainId, + )) { + // Convert hex chainId to CAIP-2 format (eip155:decimal) + const decimalChainId = parseInt(hexChainId, 16); + const caip2ChainId = `eip155:${decimalChainId}` as ChainId; + + // Get the default RPC endpoint's network client ID + const defaultRpcEndpoint = + config.rpcEndpoints[config.defaultRpcEndpointIndex]; + if (!defaultRpcEndpoint) { + continue; + } + + const networkClientId = defaultRpcEndpoint.networkClientId; + const metadata = networksMetadata[networkClientId]; + + // Determine status - default to 'unknown' if not in metadata + const status: NetworkStatus = + metadata?.status ?? ('unknown' as NetworkStatus); + + chainStatuses[caip2ChainId] = { + chainId: caip2ChainId, + status, + name: config.name, + nativeCurrency: config.nativeCurrency, + networkClientId, + }; + + // Only include chains that have an available status + // (not degraded/unavailable/blocked) + if (status === 'available' || status === 'unknown') { + activeChains.push(caip2ChainId); + } + } + + log('Network state updated', { + configuredChains: Object.keys(chainStatuses), + activeChains, + }); + + // Update state + this.state.chainStatuses = chainStatuses; + + // Update active chains and report to AssetsController + this.updateActiveChains(activeChains, (chains) => + this.#messenger.call('AssetsController:activeChainsUpdate', CONTROLLER_NAME, chains), + ); + } + + // ============================================================================ + // PROVIDER MANAGEMENT + // ============================================================================ + + /** + * Get or create a Web3Provider for a chain. + * Uses NetworkController to get the underlying provider. + */ + #getProvider(chainId: ChainId): Web3Provider | undefined { + // Check cache first + const cached = this.#providerCache.get(chainId); + if (cached) { + return cached; + } + + // Get chain status to find networkClientId + const chainStatus = this.state.chainStatuses[chainId]; + if (!chainStatus) { + return undefined; + } + + try { + // Get network client from NetworkController (like TokenBalancesController) + const networkClient = this.#messenger.call( + 'NetworkController:getNetworkClientById', + chainStatus.networkClientId, + ); + + // Create Web3Provider directly + const web3Provider = new Web3Provider(networkClient.provider); + + // Cache for reuse + this.#providerCache.set(chainId, web3Provider); + + return web3Provider; + } catch (error) { + console.error( + `[RpcDataSource] Failed to get provider for chain ${chainId}:`, + error, + ); + return undefined; + } + } + + /** + * Clear provider cache (e.g., when network configuration changes). + */ + #clearProviderCache(): void { + this.#providerCache.clear(); + } + + // ============================================================================ + // ACCOUNT SCOPE HELPERS + // ============================================================================ + + /** + * Check if an account supports a specific chain based on its scopes. + * RpcDataSource only handles EVM chains, so we check for EIP155 scopes. + * + * @param account - The account to check + * @param chainId - The chain ID to check (e.g., "eip155:1") + * @returns True if the account supports the chain + */ + #accountSupportsChain(account: InternalAccount, chainId: ChainId): boolean { + const scopes = account.scopes ?? []; + + // If no scopes defined, assume it supports the chain (backward compatibility) + if (scopes.length === 0) { + return true; + } + + // Extract namespace and reference from chainId (e.g., "eip155:1" -> ["eip155", "1"]) + const [chainNamespace, chainReference] = chainId.split(':'); + + for (const scope of scopes) { + const [scopeNamespace, scopeReference] = (scope as string).split(':'); + + // Check if namespaces match + if (scopeNamespace !== chainNamespace) { + continue; + } + + // Wildcard scope (e.g., "eip155:0" means all chains in that namespace) + if (scopeReference === '0') { + return true; + } + + // Exact match check - normalize hex to decimal for EIP155 + if (chainNamespace === 'eip155') { + const normalizedScopeRef = scopeReference?.startsWith('0x') + ? parseInt(scopeReference, 16).toString() + : scopeReference; + if (normalizedScopeRef === chainReference) { + return true; + } + } else if (scopeReference === chainReference) { + return true; + } + } + + return false; + } + + // ============================================================================ + // CHAIN STATUS + // ============================================================================ + + /** + * Get the status of all configured chains. + */ + getChainStatuses(): Record { + return { ...this.state.chainStatuses }; + } + + /** + * Get the status of a specific chain. + */ + getChainStatus(chainId: ChainId): ChainStatus | undefined { + return this.state.chainStatuses[chainId]; + } + + // ============================================================================ + // FETCH + // ============================================================================ + + async fetch(request: DataRequest): Promise { + const response: DataResponse = {}; + + // Filter to active chains + const chainsToFetch = request.chainIds.filter((chainId) => + this.state.activeChains.includes(chainId), + ); + + log('Fetch requested', { + accounts: request.accounts.map((a) => a.id), + requestedChains: request.chainIds, + chainsToFetch, + }); + + if (chainsToFetch.length === 0) { + log('No active chains to fetch'); + return response; + } + + const assetsBalance: Record< + string, + Record + > = {}; + const failedChains: ChainId[] = []; + + // Fetch native balance for each chain + for (const chainId of chainsToFetch) { + const provider = this.#getProvider(chainId); + + if (!provider) { + log('No provider available for chain', { chainId }); + continue; + } + + const assetId = buildNativeAssetId(chainId); + + // Fetch balance for each account that supports this chain + for (const account of request.accounts) { + // Check if account supports this chain based on its scopes + if (!this.#accountSupportsChain(account, chainId)) { + continue; + } + + const { address, id: accountId } = account; + + try { + const balancePromise = provider.getBalance(address); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('RPC timeout')), this.#timeout), + ); + + const balance = await Promise.race([balancePromise, timeoutPromise]); + + if (!assetsBalance[accountId]) { + assetsBalance[accountId] = {}; + } + + assetsBalance[accountId][assetId] = { + amount: balance.toString(), + }; + } catch (error) { + log('Failed to fetch balance', { address, chainId, error }); + + if (!assetsBalance[accountId]) { + assetsBalance[accountId] = {}; + } + assetsBalance[accountId][assetId] = { amount: '0' }; + + if (!failedChains.includes(chainId)) { + failedChains.push(chainId); + } + } + } + } + + // Log detailed balance information + for (const [accountId, balances] of Object.entries(assetsBalance)) { + const balanceDetails = Object.entries(balances).map( + ([assetId, balance]) => ({ + assetId, + amount: balance.amount, + }), + ); + log('Fetch result - account balances', { + accountId, + balances: balanceDetails, + }); + } + + if (failedChains.length > 0) { + log('Fetch PARTIAL - some chains failed', { + successChains: chainsToFetch.filter((c) => !failedChains.includes(c)), + failedChains, + accountCount: Object.keys(assetsBalance).length, + }); + + // Add failed chains to errors so they can fallback to other data sources + response.errors = {}; + for (const chainId of failedChains) { + response.errors[chainId] = 'RPC fetch failed'; + } + } else { + log('Fetch SUCCESS', { + chains: chainsToFetch, + accountCount: Object.keys(assetsBalance).length, + }); + } + + response.assetsBalance = assetsBalance; + + return response; + } + + // ============================================================================ + // MIDDLEWARE + // ============================================================================ + + /** + * Get the middleware for fetching balances via RPC. + * This middleware: + * - Filters request to only chains this data source supports + * - Fetches balances for those chains + * - Merges response into context + * - Removes handled chains from request for next middleware + */ + /** + * Get the middleware for fetching balances via RPC. + * This middleware: + * - Supports multiple accounts in a single request + * - Filters request to only chains this data source supports + * - Fetches balances for those chains for all accounts + * - Merges response into context + * - Removes handled chains from request for next middleware + */ + get assetsMiddleware(): Middleware { + return async (context, next) => { + const { request } = context; + + // Filter to chains this data source supports + const supportedChains = request.chainIds.filter((chainId) => + this.state.activeChains.includes(chainId), + ); + + // If no supported chains, skip and pass to next middleware + if (supportedChains.length === 0) { + return next(context); + } + + let successfullyHandledChains: ChainId[] = []; + + log('Middleware fetching', { + chains: supportedChains, + accounts: request.accounts.map((a) => a.id), + }); + + try { + // Fetch for supported chains + const response = await this.fetch({ + ...request, + chainIds: supportedChains, + }); + + // Merge response into context + if (response.assetsBalance) { + if (!context.response.assetsBalance) { + context.response.assetsBalance = {}; + } + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance, + )) { + if (!context.response.assetsBalance[accountId]) { + context.response.assetsBalance[accountId] = {}; + } + context.response.assetsBalance[accountId] = { + ...context.response.assetsBalance[accountId], + ...accountBalances, + }; + } + } + + // Determine successfully handled chains (exclude errors) + const failedChains = new Set(Object.keys(response.errors ?? {})); + successfullyHandledChains = supportedChains.filter( + (chainId) => !failedChains.has(chainId), + ); + } catch (error) { + log('Middleware fetch failed', { error }); + successfullyHandledChains = []; + } + + // Remove successfully handled chains from request for next middleware + if (successfullyHandledChains.length > 0) { + const remainingChains = request.chainIds.filter( + (chainId) => !successfullyHandledChains.includes(chainId), + ); + + return next({ + ...context, + request: { + ...request, + chainIds: remainingChains, + }, + }); + } + + // No chains handled - pass context unchanged + return next(context); + }; + } + + // ============================================================================ + // SUBSCRIBE + // ============================================================================ + + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { + const { request, subscriptionId, isUpdate } = subscriptionRequest; + + // Filter to active chains + const chainsToSubscribe = request.chainIds.filter((chainId) => + this.state.activeChains.includes(chainId), + ); + + log('Subscribe requested', { + subscriptionId, + isUpdate, + accounts: request.accounts.map((a) => a.id), + requestedChains: request.chainIds, + chainsToSubscribe, + }); + + if (chainsToSubscribe.length === 0) { + log('No active chains to subscribe'); + return; + } + + // Handle subscription update + if (isUpdate) { + const existing = this.activeSubscriptions.get(subscriptionId); + if (existing) { + log('Updating existing subscription', { + subscriptionId, + chainsToSubscribe, + }); + existing.chains = chainsToSubscribe; + return; + } + } + + // Clean up existing subscription + await this.unsubscribe(subscriptionId); + + const pollInterval = request.updateInterval ?? this.#pollInterval; + log('Setting up polling subscription', { subscriptionId, pollInterval }); + + // Create poll function + const pollFn = async () => { + try { + const subscription = this.activeSubscriptions.get(subscriptionId); + if (!subscription) { + return; + } + + const fetchResponse = await this.fetch({ + ...request, + chainIds: subscription.chains, + }); + + // Report update to AssetsController + this.#messenger.call( + 'AssetsController:assetsUpdate', + fetchResponse, + CONTROLLER_NAME, + ); + } catch (error) { + log('Subscription poll failed', { subscriptionId, error }); + } + }; + + // Set up polling + const timer = setInterval(pollFn, pollInterval); + + // Store subscription + this.activeSubscriptions.set(subscriptionId, { + cleanup: () => { + log('Cleaning up subscription', { subscriptionId }); + clearInterval(timer); + }, + chains: chainsToSubscribe, + }); + + log('Subscription SUCCESS', { subscriptionId, chains: chainsToSubscribe }); + + // Initial fetch + await pollFn(); + } +} + +// ============================================================================ +// FACTORY FUNCTION +// ============================================================================ + +/** + * Creates an RpcDataSource instance. + */ +export function createRpcDataSource( + options: RpcDataSourceOptions, +): RpcDataSource { + return new RpcDataSource(options); +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/SnapDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/SnapDataSource.ts new file mode 100644 index 00000000000..eef416f187b --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/SnapDataSource.ts @@ -0,0 +1,1287 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { Messenger } from '@metamask/messenger'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import { parseBalanceWithDecimalsToBigInt } from '../../selectors/stringify-balance'; +import type { ChainId, Caip19AssetId, DataRequest, DataResponse, Middleware, Context, AssetMetadata } from '../types'; +import { + AbstractDataSource, + type DataSourceState, + type SubscriptionRequest, +} from './AbstractDataSource'; + +// ============================================================================ +// SNAP KEYRING EVENT TYPES +// ============================================================================ + +/** + * Payload for AccountsController:accountBalancesUpdated event. + * Re-published from SnapKeyring:accountBalancesUpdated. + */ +export type AccountBalancesUpdatedEventPayload = { + balances: { + [accountId: string]: { + [assetId: string]: { + amount: string; + unit: string; + }; + }; + }; +}; + +/** + * Event from AccountsController when snap balances are updated. + */ +export type AccountsControllerAccountBalancesUpdatedEvent = { + type: 'AccountsController:accountBalancesUpdated'; + payload: [AccountBalancesUpdatedEventPayload]; +}; + +const log = createModuleLogger(projectLogger, 'SnapDataSource'); + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +export const SNAP_DATA_SOURCE_NAME = 'SnapDataSource'; + +// Snap IDs +export const SOLANA_SNAP_ID = 'npm:@metamask/solana-wallet-snap'; +export const BITCOIN_SNAP_ID = 'npm:@metamask/bitcoin-wallet-snap'; +export const TRON_SNAP_ID = 'npm:@metamask/tron-wallet-snap'; + +// Chain prefixes for detection +export const SOLANA_CHAIN_PREFIX = 'solana:'; +export const BITCOIN_CHAIN_PREFIX = 'bip122:'; +export const TRON_CHAIN_PREFIX = 'tron:'; + +// Default networks +export const SOLANA_MAINNET = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as ChainId; +export const SOLANA_DEVNET = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1' as ChainId; +export const SOLANA_TESTNET = + 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z' as ChainId; + +export const BITCOIN_MAINNET = + 'bip122:000000000019d6689c085ae165831e93' as ChainId; +export const BITCOIN_TESTNET = + 'bip122:000000000933ea01ad0ee984209779ba' as ChainId; + +export const TRON_MAINNET = 'tron:728126428' as ChainId; +export const TRON_SHASTA = 'tron:2494104990' as ChainId; +export const TRON_NILE = 'tron:3448148188' as ChainId; +// Hex format alternatives for Tron +export const TRON_MAINNET_HEX = 'tron:0x2b6653dc' as ChainId; +export const TRON_SHASTA_HEX = 'tron:0x94a9059e' as ChainId; +export const TRON_NILE_HEX = 'tron:0xcd8690dc' as ChainId; + +// Default poll intervals +export const DEFAULT_SOLANA_POLL_INTERVAL = 30_000; // 30 seconds +export const DEFAULT_BITCOIN_POLL_INTERVAL = 60_000; // 1 minute +export const DEFAULT_TRON_POLL_INTERVAL = 30_000; // 30 seconds +export const DEFAULT_SNAP_POLL_INTERVAL = 30_000; // Default for unknown snaps + +// All default networks +export const ALL_DEFAULT_NETWORKS: ChainId[] = [ + SOLANA_MAINNET, + SOLANA_DEVNET, + SOLANA_TESTNET, + BITCOIN_MAINNET, + BITCOIN_TESTNET, + TRON_MAINNET, + TRON_SHASTA, + TRON_NILE, + TRON_MAINNET_HEX, + TRON_SHASTA_HEX, + TRON_NILE_HEX, +]; + +// ============================================================================ +// SNAP ROUTING +// ============================================================================ + +export type SnapType = 'solana' | 'bitcoin' | 'tron'; + +export interface SnapInfo { + snapId: string; + chainPrefix: string; + pollInterval: number; + version: string | null; + available: boolean; +} + +export const SNAP_REGISTRY: Record< + SnapType, + Omit +> = { + solana: { + snapId: SOLANA_SNAP_ID, + chainPrefix: SOLANA_CHAIN_PREFIX, + pollInterval: DEFAULT_SOLANA_POLL_INTERVAL, + }, + bitcoin: { + snapId: BITCOIN_SNAP_ID, + chainPrefix: BITCOIN_CHAIN_PREFIX, + pollInterval: DEFAULT_BITCOIN_POLL_INTERVAL, + }, + tron: { + snapId: TRON_SNAP_ID, + chainPrefix: TRON_CHAIN_PREFIX, + pollInterval: DEFAULT_TRON_POLL_INTERVAL, + }, +}; + +/** + * Get the snap type for a chain ID based on its prefix. + */ +export function getSnapTypeForChain(chainId: ChainId): SnapType | null { + if (chainId.startsWith(SOLANA_CHAIN_PREFIX)) { + return 'solana'; + } + if (chainId.startsWith(BITCOIN_CHAIN_PREFIX)) { + return 'bitcoin'; + } + if (chainId.startsWith(TRON_CHAIN_PREFIX)) { + return 'tron'; + } + return null; +} + +/** + * Check if a chain ID is supported by a snap. + */ +export function isSnapSupportedChain(chainId: ChainId): boolean { + return getSnapTypeForChain(chainId) !== null; +} + +/** + * Extract chain ID from a CAIP-19 asset ID. + * e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501" -> "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + */ +export function extractChainFromAssetId(assetId: string): ChainId { + const parts = assetId.split('/'); + return parts[0] as ChainId; +} + +// Helper functions for specific chain types +export function isSolanaChain(chainId: ChainId): boolean { + return chainId.startsWith(SOLANA_CHAIN_PREFIX); +} + +export function isBitcoinChain(chainId: ChainId): boolean { + return chainId.startsWith(BITCOIN_CHAIN_PREFIX); +} + +export function isTronChain(chainId: ChainId): boolean { + return chainId.startsWith(TRON_CHAIN_PREFIX); +} + +/** + * Convert a UI amount to raw amount string using decimals from metadata. + * If decimals are unavailable, returns the original amount unchanged. + * + * @param amount - The UI amount string (e.g., "1.5") + * @param decimals - The number of decimals for the asset + * @returns The raw amount as a string, or the original amount if conversion fails + */ +function convertUiAmountToRawAmount( + amount: string, + decimals: number | undefined, +): string { + if (decimals === undefined) { + // No decimals available, return as-is + return amount; + } + + const rawAmount = parseBalanceWithDecimalsToBigInt(amount, decimals); + if (rawAmount === undefined) { + // Invalid amount format, return as-is + return amount; + } + + return rawAmount.toString(); +} + +/** + * Get decimals for an asset from metadata in response or state. + * + * @param assetId - The CAIP-19 asset ID + * @param context - The middleware context + * @returns The decimals for the asset, or undefined if not found + */ +function getDecimalsFromContext( + assetId: string, + context: Context, +): number | undefined { + // First check the response (freshly fetched metadata) + const responseMetadata = context.response.assetsMetadata?.[ + assetId as Caip19AssetId + ] as AssetMetadata | undefined; + if (responseMetadata?.decimals !== undefined) { + return responseMetadata.decimals; + } + + // Fall back to persisted state + const stateMetadata = context.getAssetsState().assetsMetadata?.[ + assetId as Caip19AssetId + ] as AssetMetadata | undefined; + return stateMetadata?.decimals; +} + +// ============================================================================ +// STATE +// ============================================================================ + +export interface SnapDataSourceState extends DataSourceState { + /** Snap availability and versions */ + snaps: Record; +} + +const defaultSnapState: SnapDataSourceState = { + activeChains: ALL_DEFAULT_NETWORKS, + snaps: { + solana: { version: null, available: false }, + bitcoin: { version: null, available: false }, + tron: { version: null, available: false }, + }, +}; + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +export type SnapDataSourceGetAssetsMiddlewareAction = { + type: 'SnapDataSource:getAssetsMiddleware'; + handler: () => Middleware; +}; + +export type SnapDataSourceGetActiveChainsAction = { + type: 'SnapDataSource:getActiveChains'; + handler: () => Promise; +}; + +export type SnapDataSourceFetchAction = { + type: 'SnapDataSource:fetch'; + handler: (request: DataRequest) => Promise; +}; + +export type SnapDataSourceSubscribeAction = { + type: 'SnapDataSource:subscribe'; + handler: (request: SubscriptionRequest) => Promise; +}; + +export type SnapDataSourceUnsubscribeAction = { + type: 'SnapDataSource:unsubscribe'; + handler: (subscriptionId: string) => Promise; +}; + +export type SnapDataSourceActions = + | SnapDataSourceGetAssetsMiddlewareAction + | SnapDataSourceGetActiveChainsAction + | SnapDataSourceFetchAction + | SnapDataSourceSubscribeAction + | SnapDataSourceUnsubscribeAction; + +export type SnapDataSourceActiveChainsChangedEvent = { + type: 'SnapDataSource:activeChainsUpdated'; + payload: [ChainId[]]; +}; + +export type SnapDataSourceAssetsUpdatedEvent = { + type: 'SnapDataSource:assetsUpdated'; + payload: [DataResponse, string | undefined]; +}; + +export type SnapDataSourceEvents = + | SnapDataSourceActiveChainsChangedEvent + | SnapDataSourceAssetsUpdatedEvent; + +/** + * Allowed events that SnapDataSource can subscribe to. + */ +export type SnapDataSourceAllowedEvents = + AccountsControllerAccountBalancesUpdatedEvent; + +// Actions to report to AssetsController +type AssetsControllerActiveChainsUpdateAction = { + type: 'AssetsController:activeChainsUpdate'; + handler: (dataSourceId: string, activeChains: ChainId[]) => void; +}; + +type AssetsControllerAssetsUpdateAction = { + type: 'AssetsController:assetsUpdate'; + handler: (response: DataResponse, sourceId: string) => Promise; +}; + +export type SnapDataSourceAllowedActions = + | AssetsControllerActiveChainsUpdateAction + | AssetsControllerAssetsUpdateAction; + +export type SnapDataSourceMessenger = Messenger< + typeof SNAP_DATA_SOURCE_NAME, + SnapDataSourceActions | SnapDataSourceAllowedActions, + SnapDataSourceEvents | SnapDataSourceAllowedEvents +>; + +// ============================================================================ +// SNAP PROVIDER INTERFACE +// ============================================================================ + +export interface SnapProvider { + request(args: { method: string; params?: unknown }): Promise; +} + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface SnapDataSourceOptions { + /** Messenger for this data source */ + messenger: SnapDataSourceMessenger; + /** + * Snap provider for communicating with snaps. + */ + snapProvider: SnapProvider; + /** Configured networks to support (defaults to all snap networks) */ + configuredNetworks?: ChainId[]; + /** Default polling interval in ms for subscriptions */ + pollInterval?: number; + /** Initial state */ + state?: Partial; +} + +// ============================================================================ +// SNAP DATA SOURCE +// ============================================================================ + +/** + * Unified Snap data source that routes requests to the appropriate wallet snap + * based on the chain ID prefix. + * + * Supports: + * - Solana chains (solana:*) → @metamask/solana-wallet-snap + * - Bitcoin chains (bip122:*) → @metamask/bitcoin-wallet-snap + * - Tron chains (tron:*) → @metamask/tron-wallet-snap + * + * @example + * ```typescript + * const snapDataSource = new SnapDataSource({ + * messenger, + * snapProvider: metamaskProvider, + * }); + * + * // Fetch will automatically route to the correct snap + * await snapDataSource.fetch({ + * chainIds: ['solana:mainnet', 'bip122:000000000019d6689c085ae165831e93'], + * accountIds: ['account1'], + * }); + * ``` + */ +export class SnapDataSource extends AbstractDataSource< + typeof SNAP_DATA_SOURCE_NAME, + SnapDataSourceState +> { + readonly #messenger: SnapDataSourceMessenger; + + readonly #snapProvider: SnapProvider; + + readonly #defaultPollInterval: number; + + constructor(options: SnapDataSourceOptions) { + const configuredNetworks = + options.configuredNetworks ?? ALL_DEFAULT_NETWORKS; + + super(SNAP_DATA_SOURCE_NAME, { + ...defaultSnapState, + ...options.state, + activeChains: configuredNetworks, + }); + + this.#messenger = options.messenger; + this.#snapProvider = options.snapProvider; + this.#defaultPollInterval = + options.pollInterval ?? DEFAULT_SNAP_POLL_INTERVAL; + + log('Initializing SnapDataSource', { + configuredNetworks: configuredNetworks.length, + defaultPollInterval: this.#defaultPollInterval, + }); + + this.#registerActionHandlers(); + this.#subscribeToSnapKeyringEvents(); + + // Check availability for all snaps on initialization + this.#checkAllSnapsAvailability().catch((error) => { + log('Failed to check snaps availability on init', error); + }); + } + + /** + * Subscribe to snap keyring events for real-time balance updates. + * The snaps emit AccountBalancesUpdated events when balances change, + * which are re-published by AccountsController. + */ + #subscribeToSnapKeyringEvents(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messenger = this.#messenger as any; + + try { + messenger.subscribe( + 'AccountsController:accountBalancesUpdated', + (payload: AccountBalancesUpdatedEventPayload) => { + this.#handleSnapBalancesUpdated(payload); + }, + ); + + log('Subscribed to AccountsController:accountBalancesUpdated'); + } catch (error) { + log('Failed to subscribe to snap keyring events', { error }); + } + } + + /** + * Handle snap balance updates from the keyring. + * Transforms the payload and publishes to AssetsController. + */ + #handleSnapBalancesUpdated(payload: AccountBalancesUpdatedEventPayload): void { + log('Received snap balance update', { + balances: payload.balances, + }); + + // Transform the snap keyring payload to DataResponse format + const response: DataResponse = { + assetsBalance: {}, + }; + + for (const [accountId, assets] of Object.entries(payload.balances)) { + response.assetsBalance![accountId] = {}; + + for (const [assetId, balance] of Object.entries(assets)) { + // Only include snap-supported assets (solana, bitcoin, tron) + if (isSnapSupportedChain(extractChainFromAssetId(assetId))) { + response.assetsBalance![accountId][assetId as Caip19AssetId] = { + amount: balance.amount, + }; + } + } + + // Remove account if no snap assets + if (Object.keys(response.assetsBalance![accountId]).length === 0) { + delete response.assetsBalance![accountId]; + } + } + + // Only report if we have snap-related updates + if (Object.keys(response.assetsBalance ?? {}).length > 0) { + this.#messenger.call( + 'AssetsController:assetsUpdate', + response, + SNAP_DATA_SOURCE_NAME, + ); + } + } + + #registerActionHandlers(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messenger = this.#messenger as any; + + messenger.registerActionHandler( + 'SnapDataSource:getAssetsMiddleware', + () => this.assetsMiddleware, + ); + + messenger.registerActionHandler( + 'SnapDataSource:getActiveChains', + async () => this.getActiveChains(), + ); + + messenger.registerActionHandler( + 'SnapDataSource:fetch', + async (request: DataRequest) => this.fetch(request), + ); + + messenger.registerActionHandler( + 'SnapDataSource:subscribe', + async (request: SubscriptionRequest) => this.subscribe(request), + ); + + messenger.registerActionHandler( + 'SnapDataSource:unsubscribe', + async (subscriptionId: string) => this.unsubscribe(subscriptionId), + ); + } + + // ============================================================================ + // SNAP AVAILABILITY + // ============================================================================ + + /** + * Get all installed snaps from the snap provider. + * Returns a map of snap IDs to their versions. + */ + async #getInstalledSnaps(): Promise> { + try { + const snaps = await this.#snapProvider.request< + Record + >({ + method: 'wallet_getSnaps', + params: {}, + }); + + // Log all installed snaps for debugging + log('wallet_getSnaps returned', { + snapIds: Object.keys(snaps), + snapDetails: Object.entries(snaps).map(([id, data]) => ({ + id, + version: data.version, + })), + }); + + // Check which expected snaps are missing + const expectedSnaps = Object.values(SNAP_REGISTRY).map((s) => s.snapId); + const missingSnaps = expectedSnaps.filter((id) => !snaps[id]); + if (missingSnaps.length > 0) { + log('Missing expected snaps (not installed or disabled/blocked)', { + missingSnaps, + }); + } + + return snaps; + } catch (error) { + log('Failed to get installed snaps', error); + return {}; + } + } + + /** + * Check availability for a single snap type on-demand. + * This is called before each fetch to ensure we have the latest availability status. + * + * @param snapType - The snap type to check (solana, bitcoin, tron) + * @returns True if the snap is available, false otherwise + */ + async #checkSnapAvailabilityOnDemand(snapType: SnapType): Promise { + const config = SNAP_REGISTRY[snapType]; + const currentState = this.state.snaps[snapType]; + + // If already marked as available, return true (snap was found previously) + if (currentState.available) { + return true; + } + + // Check if snap is now available (handles timing issues where snap wasn't ready at init) + log(`On-demand availability check for ${snapType} snap`); + + try { + const snaps = await this.#getInstalledSnaps(); + const snap = snaps[config.snapId]; + + if (snap) { + // Snap is now available - update state + this.state.snaps[snapType] = { + version: snap.version, + available: true, + }; + log(`${snapType} snap now available (on-demand check)`, { + version: snap.version, + }); + return true; + } + + log(`${snapType} snap still not available`); + return false; + } catch (error) { + log(`On-demand availability check failed for ${snapType}`, error); + return false; + } + } + + async #checkAllSnapsAvailability(): Promise { + log('Checking all snaps availability'); + + try { + const snaps = await this.#getInstalledSnaps(); + + // Log what snaps were returned + const installedSnapIds = Object.keys(snaps); + log('Installed snaps found', { + count: installedSnapIds.length, + snapIds: installedSnapIds, + }); + + for (const [snapType, config] of Object.entries(SNAP_REGISTRY)) { + const snap = snaps[config.snapId]; + log(`Checking ${snapType} snap`, { + expectedSnapId: config.snapId, + found: !!snap, + version: snap?.version, + }); + + if (snap) { + this.state.snaps[snapType as SnapType] = { + version: snap.version, + available: true, + }; + log(`${snapType} snap available`, { version: snap.version }); + } else { + this.state.snaps[snapType as SnapType] = { + version: null, + available: false, + }; + log(`${snapType} snap not installed`); + } + } + } catch (error) { + log('Failed to check snaps availability', error); + // Mark all snaps as unavailable on error + for (const snapType of Object.keys(SNAP_REGISTRY)) { + this.state.snaps[snapType as SnapType] = { + version: null, + available: false, + }; + } + } + } + + /** + * Get info about all snaps. + */ + getSnapsInfo(): Record { + const result: Record = {} as Record; + + for (const [snapType, config] of Object.entries(SNAP_REGISTRY)) { + const state = this.state.snaps[snapType as SnapType]; + result[snapType as SnapType] = { + ...config, + version: state.version, + available: state.available, + }; + } + + return result; + } + + /** + * Check if a specific snap is available. + */ + isSnapAvailable(snapType: SnapType): boolean { + return this.state.snaps[snapType]?.available ?? false; + } + + /** + * Force refresh snap availability check. + */ + async refreshSnapsStatus(): Promise { + await this.#checkAllSnapsAvailability(); + } + + // ============================================================================ + // CHAIN MANAGEMENT + // ============================================================================ + + addNetworks(chainIds: ChainId[]): void { + const snapChains = chainIds.filter(isSnapSupportedChain); + const newChains = snapChains.filter( + (c) => !this.state.activeChains.includes(c), + ); + + if (newChains.length > 0) { + const updated = [...this.state.activeChains, ...newChains]; + this.updateActiveChains(updated, (c) => + this.#messenger.call('AssetsController:activeChainsUpdate', SNAP_DATA_SOURCE_NAME, c), + ); + log('Networks added', { newChains, total: updated.length }); + } + } + + removeNetworks(chainIds: ChainId[]): void { + const chainSet = new Set(chainIds); + const updated = this.state.activeChains.filter((c) => !chainSet.has(c)); + if (updated.length !== this.state.activeChains.length) { + this.updateActiveChains(updated, (c) => + this.#messenger.call('AssetsController:activeChainsUpdate', SNAP_DATA_SOURCE_NAME, c), + ); + log('Networks removed', { + removed: chainIds.length, + remaining: updated.length, + }); + } + } + + // ============================================================================ + // ACCOUNT SCOPE HELPERS + // ============================================================================ + + /** + * Check if an account supports a specific chain based on its scopes. + * For snap chains (Solana, Bitcoin, Tron), we check for the appropriate namespace. + * + * @param account - The account to check + * @param chainId - The chain ID to check (e.g., "solana:...", "bip122:...", "tron:...") + * @returns True if the account supports the chain + */ + #accountSupportsChain(account: InternalAccount, chainId: ChainId): boolean { + const scopes = account.scopes ?? []; + + // If no scopes defined, assume it supports the chain (backward compatibility) + if (scopes.length === 0) { + return true; + } + + // Extract namespace and reference from chainId + const [chainNamespace, chainReference] = chainId.split(':'); + + for (const scope of scopes) { + const [scopeNamespace, scopeReference] = (scope as string).split(':'); + + // Check if namespaces match + if (scopeNamespace !== chainNamespace) { + continue; + } + + // Wildcard scope (e.g., "solana:0" means all chains in that namespace) + if (scopeReference === '0') { + return true; + } + + // Exact match check + if (scopeReference === chainReference) { + return true; + } + } + + return false; + } + + // ============================================================================ + // FETCH - Routes to appropriate snap(s) + // ============================================================================ + + async fetch(request: DataRequest): Promise { + // Guard against undefined request or chainIds + if (!request?.chainIds) { + log('Fetch called with undefined request or chainIds', { request }); + return {}; + } + + // Filter to only snap-supported chains + const snapChains = request.chainIds.filter(isSnapSupportedChain); + + if (snapChains.length === 0) { + log('No snap-supported chains to fetch'); + return {}; + } + + // Group chains by snap type + const chainsBySnap = this.#groupChainsBySnap(snapChains); + + log('Fetch requested', { + accounts: request.accounts.map((a) => a.id), + requestedChains: request.chainIds.length, + snapChains: snapChains.length, + snapsToCall: Object.keys(chainsBySnap), + }); + + // Fetch from each snap in parallel + const results = await Promise.all( + Object.entries(chainsBySnap).map(async ([snapType, chains]) => { + return this.#fetchFromSnap(snapType as SnapType, { + ...request, + chainIds: chains, + }); + }), + ); + + // Merge all results + const mergedResponse: DataResponse = {}; + + for (const result of results) { + if (result.assetsBalance) { + mergedResponse.assetsBalance = { + ...mergedResponse.assetsBalance, + ...result.assetsBalance, + }; + } + if (result.assetsMetadata) { + mergedResponse.assetsMetadata = { + ...mergedResponse.assetsMetadata, + ...result.assetsMetadata, + }; + } + if (result.assetsPrice) { + mergedResponse.assetsPrice = { + ...mergedResponse.assetsPrice, + ...result.assetsPrice, + }; + } + if (result.errors) { + mergedResponse.errors = { + ...mergedResponse.errors, + ...result.errors, + }; + } + } + + log('Fetch completed', { + accountCount: Object.keys(mergedResponse.assetsBalance ?? {}).length, + assetCount: Object.keys(mergedResponse.assetsMetadata ?? {}).length, + failedChains: Object.keys(mergedResponse.errors ?? {}).length, + }); + + return mergedResponse; + } + + #groupChainsBySnap( + chainIds: ChainId[], + ): Partial> { + const groups: Partial> = {}; + + for (const chainId of chainIds) { + const snapType = getSnapTypeForChain(chainId); + if (snapType) { + if (!groups[snapType]) { + groups[snapType] = []; + } + groups[snapType]!.push(chainId); + } + } + + return groups; + } + + async #fetchFromSnap( + snapType: SnapType, + request: DataRequest, + ): Promise { + const config = SNAP_REGISTRY[snapType]; + + // Check snap availability on-demand - handles timing issues where snap + // wasn't ready during initialization but is now available + const isAvailable = await this.#checkSnapAvailabilityOnDemand(snapType); + if (!isAvailable) { + log(`${snapType} snap not available, skipping fetch`); + // Return errors for these chains so they can fallback to other data sources + const errors: Record = {}; + for (const chainId of request.chainIds) { + errors[chainId] = `${snapType} snap not available`; + } + return { errors }; + } + + const results: DataResponse = { + assetsBalance: {}, + assetsMetadata: {}, + }; + + // Fetch balances for each account using Keyring API + // Important: Must first get account assets, then request balances for those specific assets + for (const account of request.accounts) { + // Filter to only process accounts that support the chains being fetched + const accountSupportedChains = request.chainIds.filter((chainId) => + this.#accountSupportsChain(account, chainId), + ); + + // Skip accounts that don't support any of the requested chains + if (accountSupportedChains.length === 0) { + continue; + } + + const accountId = account.id; + try { + // Step 1: Get the list of assets for this account + log(`${snapType} snap calling keyring_listAccountAssets`, { + snapId: config.snapId, + accountId, + }); + + const accountAssets = await this.#snapProvider.request({ + method: 'wallet_invokeSnap', + params: { + snapId: config.snapId, + request: { + method: 'keyring_listAccountAssets', + params: { + id: accountId, // Account UUID + }, + }, + }, + }); + + log(`${snapType} snap keyring_listAccountAssets response`, { + accountId, + assetCount: accountAssets?.length ?? 0, + assets: accountAssets, + }); + + // If no assets, skip to next account + if (!accountAssets || accountAssets.length === 0) { + log( + `${snapType} snap: account has no assets, skipping balance fetch`, + { + accountId, + }, + ); + continue; + } + + // Step 2: Get balances for those specific assets + log(`${snapType} snap calling keyring_getAccountBalances`, { + snapId: config.snapId, + accountId, + requestedAssets: accountAssets.length, + }); + + const balances = await this.#snapProvider.request< + Record + >({ + method: 'wallet_invokeSnap', + params: { + snapId: config.snapId, + request: { + method: 'keyring_getAccountBalances', + params: { + id: accountId, // Account UUID (the keyring API uses 'id' not 'accountId') + assets: accountAssets, // Must pass specific asset types from listAccountAssets + }, + }, + }, + }); + + log(`${snapType} snap keyring_getAccountBalances response`, { + accountId, + balances, + balancesType: typeof balances, + isNull: balances === null, + isUndefined: balances === undefined, + assetCount: balances ? Object.keys(balances).length : 0, + }); + + // Transform keyring response to DataResponse format + // Note: snap may return null/undefined if account doesn't belong to this snap + if (balances && typeof balances === 'object') { + const balanceEntries = Object.entries(balances); + log( + `${snapType} snap processing ${balanceEntries.length} balances for account ${accountId}`, + ); + + for (const [assetId, balance] of balanceEntries) { + // Initialize account balances if not exists + if (!results.assetsBalance![accountId]) { + results.assetsBalance![accountId] = {}; + } + // Store balance for this asset - only amount is needed + // Unit can be derived from the CAIP-19 asset ID + (results.assetsBalance![accountId] as Record)[ + assetId + ] = { + amount: balance.amount, + }; + } + } else { + log( + `${snapType} snap returned empty/null for account (account may not belong to this snap)`, + { + accountId, + balances, + }, + ); + } + } catch (error) { + // This is expected when querying a snap with an account it doesn't manage + log(`${snapType} snap fetch FAILED for account`, { + accountId, + error: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + }); + } + } + + log(`${snapType} snap fetch completed`, { + chains: request.chainIds.length, + accountsWithBalances: Object.keys(results.assetsBalance ?? {}).length, + }); + + return results; + } + + // ============================================================================ + // MIDDLEWARE + // ============================================================================ + + /** + * Get the middleware for fetching balances via Snap snaps. + * This middleware: + * - Filters request to only chains this data source supports + * - Fetches balances for those chains via installed snaps + * - Merges response into context + * - Removes handled chains from request for next middleware + * - After downstream middlewares run (token middleware), converts UI amounts to raw amounts + */ + /** + * Get the middleware for fetching balances via Snaps. + * This middleware: + * - Supports multiple accounts in a single request + * - Filters request to only chains this data source supports + * - Fetches balances for those chains for all accounts + * - Merges response into context + * - Removes handled chains from request for next middleware + */ + get assetsMiddleware(): Middleware { + return async (context, next) => { + const { request } = context; + + // Filter to chains this data source supports + const supportedChains = request.chainIds.filter((chainId) => + this.state.activeChains.includes(chainId), + ); + + // If no supported chains, skip and pass to next middleware + if (supportedChains.length === 0) { + return next(context); + } + + let successfullyHandledChains: ChainId[] = []; + // Track snap assets for later conversion (accountId -> assetId[]) + const snapAssets: Map = new Map(); + + log('Middleware fetching', { + chains: supportedChains, + accounts: request.accounts.map((a) => a.id), + }); + + try { + // Fetch for supported chains + const response = await this.fetch({ + ...request, + chainIds: supportedChains, + }); + + // Merge response into context and track snap assets + if (response.assetsBalance) { + if (!context.response.assetsBalance) { + context.response.assetsBalance = {}; + } + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance, + )) { + if (!context.response.assetsBalance[accountId]) { + context.response.assetsBalance[accountId] = {}; + } + context.response.assetsBalance[accountId] = { + ...context.response.assetsBalance[accountId], + ...accountBalances, + }; + + // Track which assets came from snap for later conversion + const assetIds = Object.keys(accountBalances); + snapAssets.set(accountId, [ + ...(snapAssets.get(accountId) ?? []), + ...assetIds, + ]); + } + } + + if (response.assetsMetadata) { + context.response.assetsMetadata = { + ...context.response.assetsMetadata, + ...response.assetsMetadata, + }; + } + + if (response.assetsPrice) { + context.response.assetsPrice = { + ...context.response.assetsPrice, + ...response.assetsPrice, + }; + } + + // Determine successfully handled chains (exclude errors) + const failedChains = new Set(Object.keys(response.errors ?? {})); + successfullyHandledChains = supportedChains.filter( + (chainId) => !failedChains.has(chainId), + ); + } catch (error) { + log('Middleware fetch failed', { error }); + successfullyHandledChains = []; + } + + // Prepare context for next middleware + let nextContext = context; + if (successfullyHandledChains.length > 0) { + const remainingChains = request.chainIds.filter( + (chainId) => !successfullyHandledChains.includes(chainId), + ); + nextContext = { + ...context, + request: { + ...request, + chainIds: remainingChains, + }, + }; + } + + // Call next middleware (token middleware will add metadata with decimals) + const result = await next(nextContext); + + // After downstream middlewares have run, convert snap UI amounts to raw amounts + // Now we have access to metadata decimals from either response or state + if (snapAssets.size > 0 && result.response.assetsBalance) { + log('Converting snap UI amounts to raw amounts', { + accountCount: snapAssets.size, + }); + + for (const [accountId, assetIds] of snapAssets) { + const accountBalances = result.response.assetsBalance[accountId]; + if (!accountBalances) { + continue; + } + + for (const assetId of assetIds) { + const balance = accountBalances[assetId as Caip19AssetId]; + if (!balance || typeof balance.amount !== 'string') { + continue; + } + + const decimals = getDecimalsFromContext(assetId, result); + const rawAmount = convertUiAmountToRawAmount( + balance.amount, + decimals, + ); + + // Update balance with raw amount + (accountBalances[assetId as Caip19AssetId] as { amount: string }).amount = rawAmount; + + log('Converted balance', { + assetId, + originalAmount: balance.amount, + rawAmount, + decimals, + }); + } + } + } + + return result; + }; + } + + // ============================================================================ + // SUBSCRIBE - Routes to appropriate snap(s) + // ============================================================================ + + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { + const { request, subscriptionId, isUpdate } = subscriptionRequest; + + // Guard against undefined request or chainIds + if (!request?.chainIds) { + log('Subscribe called with undefined request or chainIds', { + subscriptionRequest, + }); + return; + } + + // Filter to only snap-supported chains + const snapChains = request.chainIds.filter(isSnapSupportedChain); + + if (snapChains.length === 0) { + log('No snap-supported chains to subscribe'); + return; + } + + log('Subscribe requested', { + subscriptionId, + isUpdate, + accounts: request.accounts.map((a) => a.id), + snapChains: snapChains.length, + }); + + if (isUpdate) { + const existing = this.activeSubscriptions.get(subscriptionId); + if (existing) { + existing.chains = snapChains; + // Do a fetch to get latest data on subscription update + this.fetch({ + ...request, + chainIds: snapChains, + }) + .then((fetchResponse) => { + if (Object.keys(fetchResponse.assetsBalance ?? {}).length > 0) { + this.#messenger.call( + 'AssetsController:assetsUpdate', + fetchResponse, + SNAP_DATA_SOURCE_NAME, + ); + } + }) + .catch((error) => { + log('Subscription update fetch failed', { subscriptionId, error }); + }); + return; + } + } + + await this.unsubscribe(subscriptionId); + + // Snaps provide real-time updates via AccountsController:accountBalancesUpdated + // We only need to track the subscription and do an initial fetch + // No polling needed - updates come through #handleSnapBalancesUpdated + + this.activeSubscriptions.set(subscriptionId, { + cleanup: () => { + log('Cleaning up subscription', { subscriptionId }); + // No timer to clear - we use event-based updates + }, + chains: snapChains, + }); + + log('Subscription SUCCESS (event-based, no polling)', { + subscriptionId, + chains: snapChains.length, + }); + + // Initial fetch to get current balances + try { + const fetchResponse = await this.fetch({ + ...request, + chainIds: snapChains, + }); + + if (Object.keys(fetchResponse.assetsBalance ?? {}).length > 0) { + this.#messenger.call( + 'AssetsController:assetsUpdate', + fetchResponse, + SNAP_DATA_SOURCE_NAME, + ); + } + } catch (error) { + log('Initial fetch failed', { subscriptionId, error }); + } + } + + // ============================================================================ + // CLEANUP + // ============================================================================ + + destroy(): void { + log('Destroying SnapDataSource'); + + for (const [subscriptionId] of this.activeSubscriptions) { + this.unsubscribe(subscriptionId).catch((error) => { + log('Error cleaning up subscription', { subscriptionId, error }); + }); + } + + log('SnapDataSource destroyed'); + } +} + +// ============================================================================ +// FACTORY +// ============================================================================ + +export function createSnapDataSource( + options: SnapDataSourceOptions, +): SnapDataSource { + return new SnapDataSource(options); +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/TokenDataSource.ts b/packages/assets-controllers/src/AssetsController/data-sources/TokenDataSource.ts new file mode 100644 index 00000000000..42636107b1f --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/TokenDataSource.ts @@ -0,0 +1,302 @@ +import type { + AssetByIdResponse, + TokensGetV3AssetsAction, + TokensGetV2SupportedNetworksAction, +} from '@metamask/core-backend'; +import type { Messenger } from '@metamask/messenger'; +import { parseCaipAssetType, type CaipAssetType } from '@metamask/utils'; + +import { projectLogger, createModuleLogger } from '../../logger'; +import { + forDataTypes, + type Caip19AssetId, + type AssetMetadata, + type Middleware, +} from '../types'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const CONTROLLER_NAME = 'TokenDataSource'; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +/** + * Cache duration for supported networks (1 hour in milliseconds) + */ +const SUPPORTED_NETWORKS_CACHE_DURATION_MS = 60 * 60 * 1000; + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +/** + * Action to get the TokenDataSource middleware. + */ +export type TokenDataSourceGetAssetsMiddlewareAction = { + type: `${typeof CONTROLLER_NAME}:getAssetsMiddleware`; + handler: () => Middleware; +}; + +/** + * All actions exposed by TokenDataSource. + */ +export type TokenDataSourceActions = TokenDataSourceGetAssetsMiddlewareAction; + +/** + * External actions that TokenDataSource needs to call. + */ +export type TokenDataSourceAllowedActions = + | TokensGetV3AssetsAction + | TokensGetV2SupportedNetworksAction; + +export type TokenDataSourceMessenger = Messenger< + typeof CONTROLLER_NAME, + TokenDataSourceAllowedActions | TokenDataSourceActions, + never +>; + +// ============================================================================ +// OPTIONS +// ============================================================================ + +export interface TokenDataSourceOptions { + messenger: TokenDataSourceMessenger; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function transformAssetByIdResponseToMetadata( + assetId: string, + assetData: AssetByIdResponse, +): AssetMetadata { + const parsed = parseCaipAssetType(assetId as CaipAssetType); + let tokenType: 'native' | 'erc20' | 'spl' = 'erc20'; + + if (parsed.assetNamespace === 'slip44') { + tokenType = 'native'; + } else if (parsed.assetNamespace === 'spl') { + tokenType = 'spl'; + } + + return { + type: tokenType, + name: assetData.name, + symbol: assetData.symbol, + decimals: assetData.decimals, + image: assetData.iconUrl ?? assetData.iconUrlThumbnail, + }; +} + +// ============================================================================ +// TOKEN DATA SOURCE +// ============================================================================ + +/** + * TokenDataSource enriches responses with token metadata from the Tokens API. + * + * This middleware-based data source: + * - Checks detected assets for missing metadata/images + * - Fetches metadata from Tokens API v3 for assets needing enrichment + * - Merges fetched metadata into the response + * + * Usage: + * ```typescript + * // Create and initialize (registers messenger actions) + * const tokenDataSource = new TokenDataSource({ messenger }); + * + * // Later, get middleware via messenger + * const middleware = messenger.call('TokenDataSource:getAssetsMiddleware'); + * ``` + */ +export class TokenDataSource { + readonly name = CONTROLLER_NAME; + + readonly #messenger: TokenDataSourceMessenger; + + /** + * Cached set of supported chain IDs (CAIP format, e.g., "eip155:1", "tron:728126428") + */ + #supportedNetworks: Set | undefined; + + /** + * Timestamp when supportedNetworks cache was last updated + */ + #supportedNetworksCacheTimestamp = 0; + + constructor(options: TokenDataSourceOptions) { + this.#messenger = options.messenger; + this.#registerActionHandlers(); + } + + /** + * Gets the supported networks from cache or fetches them from the API. + * + * @returns Set of supported chain IDs in CAIP format + */ + async #getSupportedNetworks(): Promise> { + const now = Date.now(); + const cacheExpired = + now - this.#supportedNetworksCacheTimestamp > + SUPPORTED_NETWORKS_CACHE_DURATION_MS; + + if (this.#supportedNetworks && !cacheExpired) { + return this.#supportedNetworks; + } + + try { + const response = await this.#messenger.call( + 'BackendApiClient:Tokens:getV2SupportedNetworks', + ); + + // Combine full and partial support networks + const allNetworks = [ + ...response.fullSupport, + ...response.partialSupport, + ]; + + this.#supportedNetworks = new Set(allNetworks); + this.#supportedNetworksCacheTimestamp = now; + + log('Fetched supported networks', { + fullSupport: response.fullSupport.length, + partialSupport: response.partialSupport.length, + total: allNetworks.length, + }); + + return this.#supportedNetworks; + } catch (error) { + log('Failed to fetch supported networks', { error }); + + // Return cached networks if available, otherwise return empty set + return this.#supportedNetworks ?? new Set(); + } + } + + /** + * Filters asset IDs to only include those from supported networks. + * + * @param assetIds - Array of CAIP-19 asset IDs + * @param supportedNetworks - Set of supported chain IDs + * @returns Array of asset IDs from supported networks + */ + #filterAssetsByNetwork( + assetIds: string[], + supportedNetworks: Set, + ): string[] { + return assetIds.filter((assetId) => { + try { + const parsed = parseCaipAssetType(assetId as CaipAssetType); + // chainId is in format "eip155:1" or "tron:728126428" + // parsed.chain has namespace and reference properties + const chainId = `${parsed.chain.namespace}:${parsed.chain.reference}`; + return supportedNetworks.has(chainId); + } catch { + // If we can't parse the asset ID, filter it out + return false; + } + }); + } + + #registerActionHandlers(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.#messenger as any).registerActionHandler( + 'TokenDataSource:getAssetsMiddleware', + () => this.assetsMiddleware, + ); + } + + /** + * Get the middleware for enriching responses with token metadata. + * + * This middleware: + * 1. Extracts the response from context + * 2. Fetches metadata for detected assets (assets without metadata) + * 3. Enriches the response with fetched metadata + * 4. Calls next() at the end to continue the middleware chain + */ + get assetsMiddleware(): Middleware { + return forDataTypes(['metadata'], async (ctx, next) => { + // Extract response from context + const { response } = ctx; + + // Only fetch metadata for detected assets (assets without metadata) + if (!response.detectedAssets) { + return next(ctx); + } + + const { assetsMetadata: stateMetadata } = ctx.getAssetsState(); + const assetIdsNeedingMetadata = new Set(); + + for (const detectedIds of Object.values(response.detectedAssets)) { + for (const assetId of detectedIds) { + // Skip if response already has metadata with image + const responseMetadata = response.assetsMetadata?.[assetId]; + if (responseMetadata?.image) continue; + + // Skip if state already has metadata with image + const existingMetadata = stateMetadata[assetId as Caip19AssetId]; + if (existingMetadata?.image) continue; + + assetIdsNeedingMetadata.add(assetId); + } + } + + if (assetIdsNeedingMetadata.size === 0) { + return next(ctx); + } + + // Filter asset IDs to only include supported networks + const supportedNetworks = await this.#getSupportedNetworks(); + const supportedAssetIds = this.#filterAssetsByNetwork( + [...assetIdsNeedingMetadata], + supportedNetworks, + ); + + if (supportedAssetIds.length === 0) { + log('No assets from supported networks to fetch metadata for', { + originalCount: assetIdsNeedingMetadata.size, + }); + return next(ctx); + } + + log('Fetching metadata for detected assets', { + count: supportedAssetIds.length, + filteredOut: assetIdsNeedingMetadata.size - supportedAssetIds.length, + assetIds: supportedAssetIds.slice(0, 10), + }); + + try { + const metadataResponse = await this.#messenger.call( + 'BackendApiClient:Tokens:getV3Assets', + supportedAssetIds, + { includeIconUrl: true, includeCoingeckoId: true, includeOccurrences: true }, + ); + + if (!response.assetsMetadata) { + response.assetsMetadata = {}; + } + + for (const [assetId, assetData] of Object.entries(metadataResponse)) { + const caipAssetId = assetId as Caip19AssetId; + response.assetsMetadata[caipAssetId] = transformAssetByIdResponseToMetadata( + assetId, + assetData, + ); + } + + log('Enriched response with metadata', { + count: Object.keys(response.assetsMetadata).length, + }); + } catch (error) { + log('Failed to fetch metadata', { error }); + } + + // Call next() at the end to continue the middleware chain + return next(ctx); + }); + } +} diff --git a/packages/assets-controllers/src/AssetsController/data-sources/index.ts b/packages/assets-controllers/src/AssetsController/data-sources/index.ts new file mode 100644 index 00000000000..7df34d0666b --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/index.ts @@ -0,0 +1,139 @@ +export { + AbstractDataSource, + type DataSourceState, + type SubscriptionRequest, +} from './AbstractDataSource'; + +export { + AccountsApiDataSource, + createAccountsApiDataSource, + type AccountsApiDataSourceOptions, + type AccountsApiDataSourceState, + type AccountsApiDataSourceActions, + type AccountsApiDataSourceEvents, + type AccountsApiDataSourceMessenger, + type AccountsApiDataSourceGetAssetsMiddlewareAction, +} from './AccountsApiDataSource'; + +export { + BackendWebsocketDataSource, + createBackendWebsocketDataSource, + type BackendWebsocketDataSourceOptions, + type BackendWebsocketDataSourceState, + type BackendWebsocketDataSourceActions, + type BackendWebsocketDataSourceEvents, + type BackendWebsocketDataSourceMessenger, + type BackendWebsocketDataSourceAllowedActions, + type BackendWebsocketDataSourceAllowedEvents, +} from './BackendWebsocketDataSource'; + +export { + RpcDataSource, + createRpcDataSource, + type RpcDataSourceOptions, + type RpcDataSourceState, + type RpcDataSourceActions, + type RpcDataSourceEvents, + type RpcDataSourceMessenger, + type RpcDataSourceAllowedActions, + type RpcDataSourceAllowedEvents, + type ChainStatus, + type RpcDataSourceGetAssetsMiddlewareAction, +} from './RpcDataSource'; + +export { + TokenDataSource, + type TokenDataSourceOptions, + type TokenDataSourceMessenger, + type TokenDataSourceAllowedActions, + type TokenDataSourceActions, + type TokenDataSourceGetAssetsMiddlewareAction, +} from './TokenDataSource'; + +export { + PriceDataSource, + type PriceDataSourceOptions, + type PriceDataSourceMessenger, + type PriceDataSourceAllowedActions, + type PriceDataSourceActions, + type PriceDataSourceEvents, + type PriceDataSourceGetAssetsMiddlewareAction, + type PriceDataSourceFetchAction, + type PriceDataSourceSubscribeAction, + type PriceDataSourceUnsubscribeAction, + type PriceDataSourceAssetsUpdatedEvent, +} from './PriceDataSource'; + +export { + DetectionMiddleware, + type DetectionMiddlewareOptions, + type DetectionMiddlewareMessenger, + type DetectionMiddlewareActions, + type DetectionMiddlewareGetAssetsMiddlewareAction, +} from './DetectionMiddleware'; + +// Unified Snap Data Source (handles Solana, Bitcoin, Tron snaps) +export { + SnapDataSource, + createSnapDataSource, + SNAP_DATA_SOURCE_NAME, + // Snap IDs + SOLANA_SNAP_ID, + BITCOIN_SNAP_ID, + TRON_SNAP_ID, + // Chain prefixes + SOLANA_CHAIN_PREFIX, + BITCOIN_CHAIN_PREFIX, + TRON_CHAIN_PREFIX, + // Networks + SOLANA_MAINNET, + SOLANA_DEVNET, + SOLANA_TESTNET, + BITCOIN_MAINNET, + BITCOIN_TESTNET, + TRON_MAINNET, + TRON_SHASTA, + TRON_NILE, + TRON_MAINNET_HEX, + TRON_SHASTA_HEX, + TRON_NILE_HEX, + ALL_DEFAULT_NETWORKS, + // Poll intervals + DEFAULT_SOLANA_POLL_INTERVAL, + DEFAULT_BITCOIN_POLL_INTERVAL, + DEFAULT_TRON_POLL_INTERVAL, + DEFAULT_SNAP_POLL_INTERVAL, + // Snap registry + SNAP_REGISTRY, + // Helper functions + getSnapTypeForChain, + isSnapSupportedChain, + isSolanaChain, + isBitcoinChain, + isTronChain, + // Types + type SnapType, + type SnapInfo, + type SnapDataSourceState, + type SnapDataSourceOptions, + type SnapProvider, + type SnapDataSourceActions, + type SnapDataSourceEvents, + type SnapDataSourceMessenger, + type SnapDataSourceGetAssetsMiddlewareAction, +} from './SnapDataSource'; + +// Initialization helpers +export { + initMessengers, + initDataSources, + type InitMessengersOptions, + type InitDataSourcesOptions, + type DataSourceMessengers, + type DataSources, + type DataSourceActions, + type DataSourceEvents, + type DataSourceAllowedActions, + type DataSourceAllowedEvents, + type RootMessenger, +} from './initDataSources'; diff --git a/packages/assets-controllers/src/AssetsController/data-sources/initDataSources.ts b/packages/assets-controllers/src/AssetsController/data-sources/initDataSources.ts new file mode 100644 index 00000000000..db70569754c --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/data-sources/initDataSources.ts @@ -0,0 +1,373 @@ +import { Messenger, type ActionConstraint, type EventConstraint } from '@metamask/messenger'; + +import { + AccountsApiDataSource, + type AccountsApiDataSourceActions, + type AccountsApiDataSourceAllowedActions, + type AccountsApiDataSourceEvents, + type AccountsApiDataSourceMessenger, +} from './AccountsApiDataSource'; +import { + BackendWebsocketDataSource, + type BackendWebsocketDataSourceActions, + type BackendWebsocketDataSourceAllowedActions, + type BackendWebsocketDataSourceAllowedEvents, + type BackendWebsocketDataSourceEvents, + type BackendWebsocketDataSourceMessenger, +} from './BackendWebsocketDataSource'; +import { + DetectionMiddleware, + type DetectionMiddlewareActions, + type DetectionMiddlewareMessenger, +} from './DetectionMiddleware'; +import { + PriceDataSource, + type PriceDataSourceActions, + type PriceDataSourceAllowedActions, + type PriceDataSourceEvents, + type PriceDataSourceMessenger, +} from './PriceDataSource'; +import { + RpcDataSource, + type RpcDataSourceActions, + type RpcDataSourceAllowedActions, + type RpcDataSourceAllowedEvents, + type RpcDataSourceEvents, + type RpcDataSourceMessenger, +} from './RpcDataSource'; +import { + SnapDataSource, + type SnapDataSourceActions, + type SnapDataSourceEvents, + type SnapDataSourceMessenger, + type SnapProvider, +} from './SnapDataSource'; +import { + TokenDataSource, + type TokenDataSourceActions, + type TokenDataSourceAllowedActions, + type TokenDataSourceMessenger, +} from './TokenDataSource'; + +// ============================================================================ +// ACTION & EVENT TYPES +// ============================================================================ + +/** + * All actions from data sources. + */ +export type DataSourceActions = + | RpcDataSourceActions + | BackendWebsocketDataSourceActions + | AccountsApiDataSourceActions + | SnapDataSourceActions + | TokenDataSourceActions + | PriceDataSourceActions + | DetectionMiddlewareActions; + +/** + * All events from data sources. + */ +export type DataSourceEvents = + | RpcDataSourceEvents + | BackendWebsocketDataSourceEvents + | AccountsApiDataSourceEvents + | SnapDataSourceEvents + | PriceDataSourceEvents; + +/** + * All external actions that data sources need. + */ +export type DataSourceAllowedActions = + | RpcDataSourceAllowedActions + | BackendWebsocketDataSourceAllowedActions + | AccountsApiDataSourceAllowedActions + | TokenDataSourceAllowedActions + | PriceDataSourceAllowedActions; + +/** + * All external events that data sources need. + */ +export type DataSourceAllowedEvents = + | RpcDataSourceAllowedEvents + | BackendWebsocketDataSourceAllowedEvents; + +/** + * Root messenger type for all data sources. + */ +export type RootMessenger< + AllowedActions extends ActionConstraint, + AllowedEvents extends EventConstraint, +> = Messenger; + +// ============================================================================ +// MESSENGER TYPES +// ============================================================================ + +/** + * All messengers for data sources. + */ +export interface DataSourceMessengers { + rpcMessenger: RpcDataSourceMessenger; + backendWebsocketMessenger: BackendWebsocketDataSourceMessenger; + accountsApiMessenger: AccountsApiDataSourceMessenger; + snapMessenger: SnapDataSourceMessenger; + tokenMessenger: TokenDataSourceMessenger; + priceMessenger: PriceDataSourceMessenger; + detectionMessenger: DetectionMiddlewareMessenger; +} + +/** + * Options for initializing messengers. + */ +export interface InitMessengersOptions< + AllowedActions extends ActionConstraint, + AllowedEvents extends EventConstraint, +> { + /** The root controller messenger */ + messenger: RootMessenger; +} + +// ============================================================================ +// CONTROLLER TYPES +// ============================================================================ + +/** + * All data source instances. + */ +export interface DataSources { + rpcDataSource: RpcDataSource; + backendWebsocketDataSource: BackendWebsocketDataSource; + accountsApiDataSource: AccountsApiDataSource; + snapDataSource: SnapDataSource; + tokenDataSource: TokenDataSource; + priceDataSource: PriceDataSource; + detectionMiddleware: DetectionMiddleware; +} + +/** + * Options for initializing data sources. + */ +export interface InitDataSourcesOptions { + /** Messengers for each data source */ + messengers: DataSourceMessengers; + + /** Snap provider for communicating with snaps */ + snapProvider: SnapProvider; +} + +// ============================================================================ +// MESSENGER INITIALIZATION +// ============================================================================ + +/** + * Initialize all messengers for data sources. + * + * This function creates child messengers for each data source from the root + * controller messenger, with proper action/event delegation. + * + * @example + * ```typescript + * import { initMessengers } from '@metamask/assets-controllers'; + * + * const messengers = initMessengers({ messenger }); + * ``` + * + * @param options - Configuration options + * @returns Object containing all messengers + */ +export function initMessengers< + AllowedActions extends ActionConstraint, + AllowedEvents extends EventConstraint, +>(options: InitMessengersOptions): DataSourceMessengers { + const { messenger } = options; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rootMessenger = messenger as any; + + // RPC Data Source messenger + const rpcMessenger = new Messenger({ + namespace: 'RpcDataSource', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + 'AssetsController:activeChainsUpdate', + 'AssetsController:assetsUpdate', + ], + events: ['NetworkController:stateChange'], + messenger: rpcMessenger, + }); + + // Backend Websocket Data Source messenger + const backendWebsocketMessenger = new Messenger({ + namespace: 'BackendWebsocketDataSource', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:unsubscribe', + 'BackendWebSocketService:getState', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'AssetsController:activeChainsUpdate', + 'AssetsController:assetsUpdate', + ], + events: [ + 'BackendWebSocketService:stateChange', + 'BackendWebSocketService:connectionStateChanged', + 'AccountsApiDataSource:activeChainsUpdated', + ], + messenger: backendWebsocketMessenger, + }); + + // Accounts API Data Source messenger + const accountsApiMessenger = new Messenger({ + namespace: 'AccountsApiDataSource', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'BackendApiClient:Accounts:getV2SupportedNetworks', + 'BackendApiClient:Accounts:getV5MultiAccountBalances', + 'AssetsController:activeChainsUpdate', + 'AssetsController:assetsUpdate', + ], + messenger: accountsApiMessenger, + }); + + // Snap Data Source messenger + const snapMessenger = new Messenger({ + namespace: 'SnapDataSource', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AssetsController:activeChainsUpdate', + 'AssetsController:assetsUpdate', + ], + events: [ + // Snap keyring balance updates - snaps emit this when balances change + 'AccountsController:accountBalancesUpdated', + ], + messenger: snapMessenger, + }); + + // Token Data Source messenger + const tokenMessenger = new Messenger({ + namespace: 'TokenDataSource', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'BackendApiClient:Tokens:getV3Assets', + 'BackendApiClient:Tokens:getV2SupportedNetworks', + ], + messenger: tokenMessenger, + }); + + // Price Data Source messenger + const priceMessenger = new Messenger({ + namespace: 'PriceDataSource', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'BackendApiClient:Prices:getV3SpotPrices', + 'AssetsController:getState', + 'AssetsController:assetsUpdate', + ], + messenger: priceMessenger, + }); + + // Detection Middleware messenger + const detectionMessenger = new Messenger({ + namespace: 'DetectionMiddleware', + parent: rootMessenger, + }); + + return { + rpcMessenger: rpcMessenger as RpcDataSourceMessenger, + backendWebsocketMessenger: backendWebsocketMessenger as BackendWebsocketDataSourceMessenger, + accountsApiMessenger: accountsApiMessenger as AccountsApiDataSourceMessenger, + snapMessenger: snapMessenger as SnapDataSourceMessenger, + tokenMessenger: tokenMessenger as TokenDataSourceMessenger, + priceMessenger: priceMessenger as PriceDataSourceMessenger, + detectionMessenger: detectionMessenger as DetectionMiddlewareMessenger, + }; +} + +// ============================================================================ +// DATA SOURCE INITIALIZATION +// ============================================================================ + +/** + * Initialize all data sources and middlewares. + * + * This function creates and initializes all data sources, registering their + * action handlers with the messenger. + * + * @example + * ```typescript + * import { initMessengers, initDataSources } from '@metamask/assets-controllers'; + * + * // Initialize messengers first + * const messengers = initMessengers({ controllerMessenger }); + * + * // Then initialize data sources + * const dataSources = initDataSources({ + * messengers, + * snapProvider: snapController, + * }); + * ``` + * + * @param options - Configuration options + * @returns Object containing all data source instances + */ +export function initDataSources(options: InitDataSourcesOptions): DataSources { + const { messengers, snapProvider } = options; + + // Initialize primary data sources (provide balance data) + const rpcDataSource = new RpcDataSource({ + messenger: messengers.rpcMessenger, + }); + + const backendWebsocketDataSource = new BackendWebsocketDataSource({ + messenger: messengers.backendWebsocketMessenger, + }); + + const accountsApiDataSource = new AccountsApiDataSource({ + messenger: messengers.accountsApiMessenger, + }); + + const snapDataSource = new SnapDataSource({ + messenger: messengers.snapMessenger, + snapProvider, + }); + + // Initialize middleware data sources (enrich responses) + const tokenDataSource = new TokenDataSource({ + messenger: messengers.tokenMessenger, + }); + + const priceDataSource = new PriceDataSource({ + messenger: messengers.priceMessenger, + }); + + const detectionMiddleware = new DetectionMiddleware({ + messenger: messengers.detectionMessenger, + }); + + return { + rpcDataSource, + backendWebsocketDataSource, + accountsApiDataSource, + snapDataSource, + tokenDataSource, + priceDataSource, + detectionMiddleware, + }; +} diff --git a/packages/assets-controllers/src/AssetsController/index.ts b/packages/assets-controllers/src/AssetsController/index.ts new file mode 100644 index 00000000000..899eba78360 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/index.ts @@ -0,0 +1,185 @@ +// Main controller export +export { + AssetsController, + getDefaultAssetsControllerState, +} from './AssetsController'; + +// State and messenger types +export type { + AssetsControllerState, + AssetsControllerMessenger, + AssetsControllerOptions, + AssetsControllerGetStateAction, + AssetsControllerGetAssetsAction, + AssetsControllerGetAssetsBalanceAction, + AssetsControllerGetAssetMetadataAction, + AssetsControllerGetAssetsPriceAction, + AssetsControllerActiveChainsUpdateAction, + AssetsControllerAssetsUpdateAction, + AssetsControllerActions, + AssetsControllerStateChangeEvent, + AssetsControllerBalanceChangedEvent, + AssetsControllerPriceChangedEvent, + AssetsControllerAssetsDetectedEvent, + AssetsControllerEvents, +} from './AssetsController'; + +// Core types +export type { + // CAIP types + Caip19AssetId, + AccountId, + ChainId, + // Asset types + AssetType, + TokenStandard, + // Metadata types + BaseAssetMetadata, + FungibleAssetMetadata, + ERC721AssetMetadata, + ERC1155AssetMetadata, + AssetMetadata, + // Price types + BaseAssetPrice, + FungibleAssetPrice, + NFTAssetPrice, + AssetPrice, + // Balance types + FungibleAssetBalance, + ERC721AssetBalance, + ERC1155AssetBalance, + AssetBalance, + // Data source types + DataType, + DataRequest, + DataResponse, + // Middleware types + Context, + NextFunction, + Middleware, + FetchContext, + FetchNextFunction, + FetchMiddleware, + // Data source registration + DataSourceDefinition, + RegisteredDataSource, + SubscriptionResponse, + // Combined asset type + Asset, + // Event types + BalanceChangeEvent, + PriceChangeEvent, + MetadataChangeEvent, + AssetsDetectedEvent, +} from './types'; + +// Data sources - base class and types +export { AbstractDataSource } from './data-sources'; + +export type { DataSourceState, SubscriptionRequest } from './data-sources'; + +// Data sources - AccountsApi +export { + AccountsApiDataSource, + createAccountsApiDataSource, +} from './data-sources'; + +export type { + AccountsApiDataSourceOptions, + AccountsApiDataSourceState, + AccountsApiDataSourceActions, + AccountsApiDataSourceEvents, + AccountsApiDataSourceMessenger, +} from './data-sources'; + +// Data sources - BackendWebsocket +export { + BackendWebsocketDataSource, + createBackendWebsocketDataSource, +} from './data-sources'; + +export type { + BackendWebsocketDataSourceOptions, + BackendWebsocketDataSourceState, + BackendWebsocketDataSourceActions, + BackendWebsocketDataSourceEvents, + BackendWebsocketDataSourceMessenger, + BackendWebsocketDataSourceAllowedActions, + BackendWebsocketDataSourceAllowedEvents, +} from './data-sources'; + +// Data sources - RPC +export { RpcDataSource, createRpcDataSource } from './data-sources'; + +export type { + RpcDataSourceOptions, + RpcDataSourceState, + RpcDataSourceActions, + RpcDataSourceEvents, + RpcDataSourceMessenger, +} from './data-sources'; + +// Data sources - Unified Snap Data Source (handles Solana, Bitcoin, Tron) +export { + SnapDataSource, + createSnapDataSource, + SNAP_DATA_SOURCE_NAME, + // Snap IDs + SOLANA_SNAP_ID, + BITCOIN_SNAP_ID, + TRON_SNAP_ID, + // Chain prefixes + SOLANA_CHAIN_PREFIX, + BITCOIN_CHAIN_PREFIX, + TRON_CHAIN_PREFIX, + // Networks + SOLANA_MAINNET, + SOLANA_DEVNET, + SOLANA_TESTNET, + BITCOIN_MAINNET, + BITCOIN_TESTNET, + TRON_MAINNET, + TRON_SHASTA, + TRON_NILE, + TRON_MAINNET_HEX, + TRON_SHASTA_HEX, + TRON_NILE_HEX, + ALL_DEFAULT_NETWORKS, + // Poll intervals + DEFAULT_SOLANA_POLL_INTERVAL, + DEFAULT_BITCOIN_POLL_INTERVAL, + DEFAULT_TRON_POLL_INTERVAL, + DEFAULT_SNAP_POLL_INTERVAL, + // Snap registry + SNAP_REGISTRY, + // Helper functions + getSnapTypeForChain, + isSnapSupportedChain, + isSolanaChain, + isBitcoinChain, + isTronChain, +} from './data-sources'; + +export type { + SnapType, + SnapInfo, + SnapDataSourceState, + SnapDataSourceOptions, + SnapProvider, + SnapDataSourceActions, + SnapDataSourceEvents, + SnapDataSourceMessenger, +} from './data-sources'; + +// Middleware data sources +export { TokenDataSource, DetectionMiddleware, PriceDataSource } from './data-sources'; + +export type { + TokenDataSourceActions, + TokenDataSourceMessenger, + DetectionMiddlewareActions, + DetectionMiddlewareMessenger, + PriceDataSourceActions, + PriceDataSourceEvents, + PriceDataSourceMessenger, +} from './data-sources'; diff --git a/packages/assets-controllers/src/AssetsController/types.ts b/packages/assets-controllers/src/AssetsController/types.ts new file mode 100644 index 00000000000..85b063f97d1 --- /dev/null +++ b/packages/assets-controllers/src/AssetsController/types.ts @@ -0,0 +1,451 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { CaipAssetType, CaipChainId, Json } from '@metamask/utils'; + +/** + * CAIP-19 compliant asset identifier + * Format: "{chainId}/{assetNamespace}:{assetReference}[/tokenId]" + * + * Examples: + * - Native: "eip155:1/slip44:60" (ETH) + * - ERC20: "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" (USDC) + * - ERC721: "eip155:1/erc721:0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D/1234" (BAYC #1234) + * - SPL: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + */ +export type Caip19AssetId = CaipAssetType; + +/** + * InternalAccount UUID from AccountsController + * Not the blockchain address! + */ +export type AccountId = string; + +/** + * CAIP-2 chain identifier + */ +export type ChainId = CaipChainId; + +// ============================================================================ +// ASSET TYPES - Defined by metadata structure +// ============================================================================ + +/** + * Asset types define the metadata structure, not blockchain implementation. + * - "fungible" includes: native, erc20, spl - all share balance, symbol, decimals + * - "nft" includes: erc721, erc1155 - include tokenId, image, attributes + */ +export type AssetType = 'fungible' | 'nft' | 'collectible'; + +/** + * Token standards - blockchain implementation details + */ +export type TokenStandard = + | 'native' + | 'erc20' + | 'erc721' + | 'erc1155' + | 'spl' + | string; + +// ============================================================================ +// METADATA TYPES (vary by asset type) +// ============================================================================ + +/** + * Base metadata attributes shared by ALL asset types. + */ +export interface BaseAssetMetadata { + /** Token standard - how it's implemented on the blockchain */ + type: TokenStandard; + /** Display symbol (e.g., "ETH", "USDC") */ + symbol: string; + /** Full name (e.g., "Ethereum", "USD Coin") */ + name: string; + /** Token decimals (18 for ETH, 6 for USDC, etc.) */ + decimals: number; + /** Logo URL or data URI */ + image?: string; +} + +/** + * Metadata for fungible tokens + * Asset Type: "fungible" + * Includes: native, ERC-20, SPL, and other fungible token standards + */ +export interface FungibleAssetMetadata extends BaseAssetMetadata { + type: 'native' | 'erc20' | 'spl'; + /** Spam detection flag */ + isSpam?: boolean; + /** Verification status */ + verified?: boolean; + /** Token list memberships */ + collections?: string[]; +} + +/** + * Metadata for ERC721 NFTs + * Asset Type: "nft" + */ +export interface ERC721AssetMetadata extends BaseAssetMetadata { + type: 'erc721'; + decimals: 0; + /** Collection name */ + collectionName?: string; + /** Collection size */ + collectionSize?: number; + /** NFT traits/attributes - must be Json-serializable */ + traits?: Record; + /** Rarity score */ + rarity?: number; + /** Verification status */ + verified?: boolean; +} + +/** + * Metadata for ERC1155 multi-tokens + */ +export interface ERC1155AssetMetadata extends BaseAssetMetadata { + type: 'erc1155'; + /** Token URI */ + tokenUri?: string; + /** Token category */ + category?: string; + /** Spam detection flag */ + isSpam?: boolean; +} + +/** + * Union type representing all possible asset metadata types. + * All types must be JSON-serializable. + */ +export type AssetMetadata = + | FungibleAssetMetadata + | ERC721AssetMetadata + | ERC1155AssetMetadata + | (BaseAssetMetadata & { [key: string]: Json }); + +// ============================================================================ +// PRICE TYPES (vary by asset type) +// ============================================================================ + +/** + * Base price attributes. + */ +export interface BaseAssetPrice { + /** Current price in USD */ + price: number; + /** 24h price change percentage */ + priceChange24h?: number; + /** Timestamp of last price update */ + lastUpdated: number; +} + +/** + * Price data for fungible tokens (native, ERC20, SPL) + */ +export interface FungibleAssetPrice extends BaseAssetPrice { + /** Market capitalization */ + marketCap?: number; + /** 24h trading volume */ + volume24h?: number; + /** Circulating supply */ + circulatingSupply?: number; + /** Total supply */ + totalSupply?: number; +} + +/** + * Price data for NFT collections + */ +export interface NFTAssetPrice extends BaseAssetPrice { + /** Floor price */ + floorPrice?: number; + /** Last sale price */ + lastSalePrice?: number; + /** Collection trading volume */ + collectionVolume?: number; + /** Average price */ + averagePrice?: number; + /** Number of sales in 24h */ + sales24h?: number; +} + +/** + * Union type representing all possible asset price types. + * All types must be JSON-serializable. + */ +export type AssetPrice = + | FungibleAssetPrice + | NFTAssetPrice + | (BaseAssetPrice & { [key: string]: Json }); + +// ============================================================================ +// BALANCE TYPES (vary by asset type) +// ============================================================================ + +/** + * Balance data for fungible tokens (native, ERC20, SPL). + */ +export interface FungibleAssetBalance { + /** Raw balance amount as string (e.g., "1000000000" for 1000 USDC) */ + amount: string; +} + +/** + * Balance data for ERC721 NFTs. + * Each tokenId has its own CAIP-19 asset ID, so always "1". + */ +export interface ERC721AssetBalance { + /** Always "1" for ERC721 (non-fungible) */ + amount: '1'; +} + +/** + * Balance data for ERC1155 multi-tokens. + */ +export interface ERC1155AssetBalance { + /** Quantity owned of this specific tokenId */ + amount: string; +} + +/** + * Union type representing all possible asset balance types. + * All types must be JSON-serializable. + */ +export type AssetBalance = + | FungibleAssetBalance + | ERC721AssetBalance + | ERC1155AssetBalance + | { amount: string; [key: string]: Json }; + +// ============================================================================ +// DATA SOURCE TYPES +// ============================================================================ + +/** + * Data type dimension - what kind of data + */ +export type DataType = 'balance' | 'metadata' | 'price'; + +/** + * Request for data from data sources + */ +export interface DataRequest { + /** Accounts to fetch data for */ + accounts: InternalAccount[]; + /** CAIP-2 chain IDs */ + chainIds: ChainId[]; + /** Filter by asset types */ + assetTypes?: AssetType[]; + /** Which data to fetch */ + dataTypes: DataType[]; + /** Specific CAIP-19 asset IDs */ + customAssets?: Caip19AssetId[]; + /** Force fresh fetch, bypass cache */ + forceUpdate?: boolean; + /** Hint for polling interval (ms) - used by data sources that implement polling */ + updateInterval?: number; +} + +/** + * Response from data sources + */ +export interface DataResponse { + /** Metadata for assets (shared across accounts) */ + assetsMetadata?: Record; + /** Price data for assets (shared across accounts) */ + assetsPrice?: Record; + /** Balance data per account */ + assetsBalance?: Record>; + /** Errors encountered, keyed by chain ID */ + errors?: Record; + /** Detected assets (assets that do not have metadata) */ + detectedAssets?: Record; +} + +// ============================================================================ +// UNIFIED MIDDLEWARE TYPES +// ============================================================================ + +/** + * Internal state structure for AssetsController following normalized design. + * + * Keys use CAIP identifiers: + * - assetsMetadata keys: CAIP-19 asset IDs (e.g., "eip155:1/erc20:0x...") + * - assetsBalance outer keys: Account IDs (InternalAccount.id UUIDs) + * - assetsBalance inner keys: CAIP-19 asset IDs + */ +export interface AssetsControllerStateInternal { + /** Shared metadata for all assets (stored once per asset) */ + assetsMetadata: Record; + /** Per-account balance data */ + assetsBalance: Record>; +} + +/** + * Base context for all middleware operations. + * Contains the common interface shared by fetch and subscribe. + */ +export interface Context { + /** The data request */ + request: DataRequest; + /** The response data (mutated by middlewares) */ + response: DataResponse; + /** Get current assets state */ + getAssetsState: () => AssetsControllerStateInternal; +} + +/** + * Next function for middleware chain + */ +export type NextFunction = (context: Context) => Promise; + +/** + * Middleware function - works for both fetch and subscribe operations. + */ +export type Middleware = ( + context: Context, + next: NextFunction, +) => Promise; + +/** + * Wraps a middleware to only execute if specific dataTypes are requested. + * + * @param dataTypes - DataTypes that must be in the request for middleware to run + * @param middleware - The middleware to conditionally execute + * @returns A middleware that skips execution if none of the dataTypes are requested + * + * @example + * ```typescript + * // Only runs for metadata requests + * const metadataMiddleware = forDataTypes(['metadata'], async (ctx, next) => { + * const result = await next(ctx); + * // Enrich metadata... + * return result; + * }); + * + * // Runs for balance or price requests + * const balanceOrPriceMiddleware = forDataTypes(['balance', 'price'], async (ctx, next) => { + * const result = await next(ctx); + * // Process balances or prices... + * return result; + * }); + * ``` + */ +export function forDataTypes( + dataTypes: DataType[], + middleware: Middleware, +): Middleware { + return async (ctx, next) => { + const requestedTypes = ctx.request.dataTypes; + const shouldRun = dataTypes.some((dt) => requestedTypes.includes(dt)); + + if (!shouldRun) { + return next(ctx); + } + + return middleware(ctx, next); + }; +} + +/** + * Context for fetch operations. + * Extends base Context - no additional fields needed for fetch. + */ +export type FetchContext = Context; + +// Legacy aliases for backwards compatibility +export type FetchNextFunction = NextFunction; +export type FetchMiddleware = Middleware; + +/** + * Data source ID. + * + * Data sources follow a standard messenger pattern: + * - `${id}:getActiveChains` - action to get active chains + * - `${id}:activeChainsUpdated` - event when chains change + * + * Registration order determines subscription order. + */ +export type DataSourceDefinition = string; + +/** + * Registered data source + */ +export type RegisteredDataSource = DataSourceDefinition; + +/** + * Subscription response + */ +export interface SubscriptionResponse { + /** Chains actively subscribed */ + chains: ChainId[]; + /** Account ID being watched */ + accountId: AccountId; + /** Asset types being watched */ + assetTypes: AssetType[]; + /** Data types being kept fresh */ + dataTypes: DataType[]; + /** Cleanup function */ + unsubscribe: () => void; +} + +// ============================================================================ +// COMBINED ASSET TYPE (for UI) +// ============================================================================ + +/** + * Combined asset type matching state structure: balance, metadata, price + */ +export interface Asset { + /** CAIP-19 asset ID */ + id: Caip19AssetId; + /** CAIP-2 chain ID (extracted from id) */ + chainId: ChainId; + /** Balance data */ + balance: AssetBalance; + /** Metadata (symbol, name, decimals, etc.) */ + metadata: AssetMetadata; + /** Price data */ + price: AssetPrice; + /** Computed fiat value (balance × price) */ + fiatValue: number; +} + +// ============================================================================ +// EVENT TYPES +// ============================================================================ + +/** + * Event emitted when balances change + */ +export interface BalanceChangeEvent { + accountId: AccountId; + assetId: Caip19AssetId; + previousAmount: string; + newAmount: string; + timestamp: number; +} + +/** + * Event emitted when prices change + */ +export interface PriceChangeEvent { + assetIds: Caip19AssetId[]; + timestamp: number; +} + +/** + * Event emitted when metadata changes + */ +export interface MetadataChangeEvent { + assetId: Caip19AssetId; + changes: Partial; +} + +/** + * Event emitted when assets without metadata are detected + */ +export interface AssetsDetectedEvent { + accountId: AccountId; + assetIds: Caip19AssetId[]; +} diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 0ba00cef4de..7af242bffd3 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -237,3 +237,166 @@ export { } from './selectors/token-selectors'; export { createFormatters } from './utils/formatters'; export type { SortTrendingBy, TrendingAsset } from './token-service'; + +// AssetsController - Unified asset management +export { + AssetsController, + getDefaultAssetsControllerState, + AccountsApiDataSource, + createAccountsApiDataSource, + BackendWebsocketDataSource, + createBackendWebsocketDataSource, + RpcDataSource, + createRpcDataSource, + // Unified Snap Data Source (handles Solana, Bitcoin, Tron) + SnapDataSource, + createSnapDataSource, + SNAP_DATA_SOURCE_NAME, + // Snap IDs + SOLANA_SNAP_ID, + BITCOIN_SNAP_ID, + TRON_SNAP_ID, + // Chain prefixes + SOLANA_CHAIN_PREFIX, + BITCOIN_CHAIN_PREFIX, + TRON_CHAIN_PREFIX, + // Networks + SOLANA_MAINNET, + SOLANA_DEVNET, + SOLANA_TESTNET, + BITCOIN_MAINNET, + BITCOIN_TESTNET, + TRON_MAINNET, + TRON_SHASTA, + TRON_NILE, + TRON_MAINNET_HEX, + TRON_SHASTA_HEX, + TRON_NILE_HEX, + ALL_DEFAULT_NETWORKS, + // Poll intervals + DEFAULT_SOLANA_POLL_INTERVAL, + DEFAULT_BITCOIN_POLL_INTERVAL, + DEFAULT_TRON_POLL_INTERVAL, + DEFAULT_SNAP_POLL_INTERVAL, + // Snap registry + SNAP_REGISTRY, + // Helper functions + getSnapTypeForChain, + isSnapSupportedChain, + isSolanaChain, + isBitcoinChain, + isTronChain, +} from './AssetsController'; + +export type { + // State and messenger types + AssetsControllerState, + AssetsControllerMessenger, + AssetsControllerOptions, + AssetsControllerGetStateAction, + AssetsControllerGetAssetsAction, + AssetsControllerGetAssetsBalanceAction, + AssetsControllerGetAssetMetadataAction, + AssetsControllerGetAssetsPriceAction, + AssetsControllerActions, + AssetsControllerStateChangeEvent, + AssetsControllerBalanceChangedEvent, + AssetsControllerPriceChangedEvent, + AssetsControllerAssetsDetectedEvent, + AssetsControllerEvents, + // CAIP types + Caip19AssetId, + AccountId, + ChainId as AssetsControllerChainId, + // Asset types + AssetType as AssetsControllerAssetType, + TokenStandard, + // Metadata types + BaseAssetMetadata, + FungibleAssetMetadata, + ERC721AssetMetadata, + ERC1155AssetMetadata, + AssetMetadata, + // Price types + BaseAssetPrice, + FungibleAssetPrice, + NFTAssetPrice, + AssetPrice, + // Balance types + FungibleAssetBalance, + ERC721AssetBalance, + ERC1155AssetBalance, + AssetBalance, + // Data source types + DataType, + DataRequest, + DataResponse, + FetchContext, + FetchNextFunction, + FetchMiddleware, + RegisteredDataSource, + SubscriptionResponse, + // Combined asset type (renamed to avoid conflict) + Asset as UnifiedAsset, + // Event types + BalanceChangeEvent, + PriceChangeEvent, + MetadataChangeEvent, + AssetsDetectedEvent, + // Data source options + AccountsApiDataSourceOptions, + AccountsApiDataSourceState, + AccountsApiDataSourceActions, + AccountsApiDataSourceEvents, + AccountsApiDataSourceMessenger, + BackendWebsocketDataSourceOptions, + BackendWebsocketDataSourceState, + BackendWebsocketDataSourceActions, + BackendWebsocketDataSourceEvents, + BackendWebsocketDataSourceMessenger, + BackendWebsocketDataSourceAllowedActions, + BackendWebsocketDataSourceAllowedEvents, + RpcDataSourceOptions, + RpcDataSourceState, + RpcDataSourceActions, + RpcDataSourceEvents, + RpcDataSourceMessenger, + // Unified Snap Data Source types + SnapType, + SnapInfo, + SnapDataSourceState, + SnapDataSourceOptions, + SnapProvider, + SnapDataSourceActions, + SnapDataSourceEvents, + SnapDataSourceMessenger, + // Middleware data source types + TokenDataSourceActions, + TokenDataSourceMessenger, + DetectionMiddlewareActions, + DetectionMiddlewareMessenger, + PriceDataSourceActions, + PriceDataSourceEvents, + PriceDataSourceMessenger, +} from './AssetsController'; + +// Middleware data sources +export { TokenDataSource, DetectionMiddleware, PriceDataSource } from './AssetsController'; + +// Data source initialization +export { + initMessengers, + initDataSources, +} from './AssetsController/data-sources/initDataSources'; + +export type { + DataSourceMessengers, + DataSources, + InitMessengersOptions, + InitDataSourcesOptions, + DataSourceActions, + DataSourceEvents, + DataSourceAllowedActions, + DataSourceAllowedEvents, + RootMessenger, +} from './AssetsController/data-sources/initDataSources'; diff --git a/packages/assets-controllers/src/logger.ts b/packages/assets-controllers/src/logger.ts new file mode 100644 index 00000000000..c4d2a676685 --- /dev/null +++ b/packages/assets-controllers/src/logger.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('assets-controllers'); + +export { createModuleLogger }; diff --git a/packages/assets-controllers/src/selectors/stringify-balance.ts b/packages/assets-controllers/src/selectors/stringify-balance.ts index acfcce77490..942727f0d5e 100644 --- a/packages/assets-controllers/src/selectors/stringify-balance.ts +++ b/packages/assets-controllers/src/selectors/stringify-balance.ts @@ -67,6 +67,27 @@ export function parseBalanceWithDecimals( balanceString: string, decimals: number, ): Hex | undefined { + const result = parseBalanceWithDecimalsToBigInt(balanceString, decimals); + return result !== undefined ? bigIntToHex(result) : undefined; +} + +/** + * Converts a decimal string representation to a BigInt balance. + * This is the inverse operation of stringifyBalanceWithDecimals. + * + * @param balanceString - The decimal string representation (e.g., "123.456") + * @param decimals - The number of decimals to apply (shifts decimal point right) + * @returns The balance as a BigInt, or undefined if the input is invalid + * + * @example + * parseBalanceWithDecimalsToBigInt("1.5", 8) // Returns 150000000n + * parseBalanceWithDecimalsToBigInt("123.456", 3) // Returns 123456n + * parseBalanceWithDecimalsToBigInt("0.001", 18) // Returns 1000000000000000n + */ +export function parseBalanceWithDecimalsToBigInt( + balanceString: string, + decimals: number, +): bigint | undefined { // Allows: "123", "123.456", "0.123", but not: "-123", "123.", "abc", "12.34.56" if (!/^\d+(\.\d+)?$/u.test(balanceString)) { return undefined; @@ -75,20 +96,16 @@ export function parseBalanceWithDecimals( const [integerPart, fractionalPart = ''] = balanceString.split('.'); if (decimals === 0) { - return bigIntToHex(BigInt(integerPart)); + return BigInt(integerPart); } if (fractionalPart.length >= decimals) { - return bigIntToHex( - BigInt(`${integerPart}${fractionalPart.slice(0, decimals)}`), - ); + return BigInt(`${integerPart}${fractionalPart.slice(0, decimals)}`); } - return bigIntToHex( - BigInt( + return BigInt( `${integerPart}${fractionalPart}${'0'.repeat( decimals - fractionalPart.length, )}`, - ), ); } diff --git a/packages/assets-controllers/src/selectors/token-selectors.ts b/packages/assets-controllers/src/selectors/token-selectors.ts index a1772dfa6cd..790b059d78f 100644 --- a/packages/assets-controllers/src/selectors/token-selectors.ts +++ b/packages/assets-controllers/src/selectors/token-selectors.ts @@ -391,7 +391,8 @@ const selectAllMultichainAssets = createAssetListSelector( } | undefined = multichainBalances[accountId]?.[assetId]; - const decimals = assetMetadata.units.find( + // Seems like units is not always available + const decimals = assetMetadata.units?.find( (unit) => unit.name === assetMetadata.name && unit.symbol === assetMetadata.symbol, diff --git a/packages/assets-controllers/tsconfig.build.json b/packages/assets-controllers/tsconfig.build.json index 5ef0e52c3b8..cd6fefbce28 100644 --- a/packages/assets-controllers/tsconfig.build.json +++ b/packages/assets-controllers/tsconfig.build.json @@ -14,6 +14,7 @@ { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../network-enablement-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../preferences-controller/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, diff --git a/packages/assets-controllers/tsconfig.json b/packages/assets-controllers/tsconfig.json index a537b98ca39..88b08f3986a 100644 --- a/packages/assets-controllers/tsconfig.json +++ b/packages/assets-controllers/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../controller-utils" }, { "path": "../keyring-controller" }, { "path": "../network-controller" }, + { "path": "../network-enablement-controller" }, { "path": "../messenger" }, { "path": "../preferences-controller" }, { "path": "../phishing-controller" }, diff --git a/packages/core-backend/src/api/AccountsApiService-action-types.ts b/packages/core-backend/src/api/AccountsApiService-action-types.ts new file mode 100644 index 00000000000..ad97f808ab2 --- /dev/null +++ b/packages/core-backend/src/api/AccountsApiService-action-types.ts @@ -0,0 +1,246 @@ +/** + * Messenger action types for AccountsApiService + * + * Actions are namespaced as: BackendApiClient:Accounts:* + */ + +import type { + GetV2ActiveNetworksResponse, + TransactionByHashResponse, + GetV4MultiAccountTransactionsResponse, + GetV5MultiAccountBalancesResponse, +} from './AccountsApiService'; +import type { + GetBalancesOptions, + GetV2BalancesResponse, + GetMultiAccountBalancesOptions, + GetV4MultiAccountBalancesResponse, + GetV1SupportedNetworksResponse, + GetV2SupportedNetworksResponse, + GetAccountTransactionsOptions, + GetAccountTransactionsResponse, + GetAccountRelationshipOptions, + AccountRelationshipResult, +} from './types'; + +// Using string literals directly in template types to avoid unused variable lint errors +type ServiceName = 'BackendApiClient'; +type Namespace = 'Accounts'; + +// ============================================================================= +// Health & Utility Actions +// ============================================================================= + +export type AccountsGetServiceMetadataAction = { + type: `${ServiceName}:${Namespace}:getServiceMetadata`; + handler: () => Promise<{ product: string; service: string; version: string }>; +}; + +export type AccountsGetHealthAction = { + type: `${ServiceName}:${Namespace}:getHealth`; + handler: () => Promise<{ status: string }>; +}; + +// ============================================================================= +// Supported Networks Actions +// ============================================================================= + +export type AccountsGetV1SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV1SupportedNetworks`; + handler: () => Promise; +}; + +export type AccountsGetV2SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV2SupportedNetworks`; + handler: () => Promise; +}; + +// ============================================================================= +// Active Networks Actions +// ============================================================================= + +export type AccountsGetV2ActiveNetworksAction = { + type: `${ServiceName}:${Namespace}:getV2ActiveNetworks`; + handler: ( + accountIds: string[], + options?: { + filterMMListTokens?: boolean; + networks?: string[]; + }, + ) => Promise; +}; + +// ============================================================================= +// Balances Actions (v2 - single address) +// ============================================================================= + +export type AccountsGetV2BalancesAction = { + type: `${ServiceName}:${Namespace}:getV2Balances`; + handler: (options: GetBalancesOptions) => Promise; +}; + +export type AccountsGetV2BalancesWithOptionsAction = { + type: `${ServiceName}:${Namespace}:getV2BalancesWithOptions`; + handler: ( + address: string, + options?: { + networks?: number[]; + filterSupportedTokens?: boolean; + includeTokenAddresses?: string[]; + includeStakedAssets?: boolean; + }, + ) => Promise; +}; + +// ============================================================================= +// Balances Actions (v4 - multi-account with addresses) +// ============================================================================= + +export type AccountsGetV4MultiAccountBalancesAction = { + type: `${ServiceName}:${Namespace}:getV4MultiAccountBalances`; + handler: ( + options: GetMultiAccountBalancesOptions, + ) => Promise; +}; + +// ============================================================================= +// Balances Actions (v5 - multi-account with CAIP-10 IDs) +// ============================================================================= + +export type AccountsGetV5MultiAccountBalancesAction = { + type: `${ServiceName}:${Namespace}:getV5MultiAccountBalances`; + handler: ( + accountIds: string[], + options?: { + filterMMListTokens?: boolean; + networks?: string[]; + includeStakedAssets?: boolean; + }, + ) => Promise; +}; + +// ============================================================================= +// Transactions Actions +// ============================================================================= + +export type AccountsGetV1TransactionByHashAction = { + type: `${ServiceName}:${Namespace}:getV1TransactionByHash`; + handler: ( + chainId: number, + txHash: string, + options?: { + includeLogs?: boolean; + includeValueTransfers?: boolean; + includeTxMetadata?: boolean; + lang?: string; + }, + ) => Promise; +}; + +export type AccountsGetV1AccountTransactionsAction = { + type: `${ServiceName}:${Namespace}:getV1AccountTransactions`; + handler: ( + options: GetAccountTransactionsOptions, + ) => Promise; +}; + +export type AccountsGetV4MultiAccountTransactionsAction = { + type: `${ServiceName}:${Namespace}:getV4MultiAccountTransactions`; + handler: ( + accountIds: string[], + options?: { + networks?: string[]; + cursor?: string; + sortDirection?: 'ASC' | 'DESC'; + includeLogs?: boolean; + includeValueTransfers?: boolean; + includeTxMetadata?: boolean; + }, + ) => Promise; +}; + +// ============================================================================= +// Relationships Actions +// ============================================================================= + +export type AccountsGetV1AccountRelationshipAction = { + type: `${ServiceName}:${Namespace}:getV1AccountRelationship`; + handler: ( + options: GetAccountRelationshipOptions, + ) => Promise; +}; + +// ============================================================================= +// NFT Actions (v2) +// ============================================================================= + +export type AccountsGetV2AccountNftsAction = { + type: `${ServiceName}:${Namespace}:getV2AccountNfts`; + handler: ( + address: string, + options?: { networks?: number[]; cursor?: string }, + ) => Promise<{ + data: { + tokenId: string; + contractAddress: string; + chainId: number; + name?: string; + description?: string; + imageUrl?: string; + attributes?: Record[]; + }[]; + pageInfo: { count: number; hasNextPage: boolean; cursor?: string }; + }>; +}; + +// ============================================================================= +// Token Actions (v2) +// ============================================================================= + +export type AccountsGetV2AccountTokensAction = { + type: `${ServiceName}:${Namespace}:getV2AccountTokens`; + handler: ( + address: string, + options?: { networks?: number[] }, + ) => Promise<{ + data: { + address: string; + chainId: number; + symbol: string; + name: string; + decimals: number; + balance?: string; + }[]; + }>; +}; + +// ============================================================================= +// All Accounts API Actions +// ============================================================================= + +export type AccountsApiActions = + // Health & Utility + | AccountsGetServiceMetadataAction + | AccountsGetHealthAction + // Supported Networks + | AccountsGetV1SupportedNetworksAction + | AccountsGetV2SupportedNetworksAction + // Active Networks + | AccountsGetV2ActiveNetworksAction + // Balances (v2 - single address) + | AccountsGetV2BalancesAction + | AccountsGetV2BalancesWithOptionsAction + // Balances (v4 - multi-account with addresses) + | AccountsGetV4MultiAccountBalancesAction + // Balances (v5 - multi-account with CAIP-10 IDs) + | AccountsGetV5MultiAccountBalancesAction + // Transactions + | AccountsGetV1TransactionByHashAction + | AccountsGetV1AccountTransactionsAction + | AccountsGetV4MultiAccountTransactionsAction + // Relationships + | AccountsGetV1AccountRelationshipAction + // NFTs + | AccountsGetV2AccountNftsAction + // Tokens + | AccountsGetV2AccountTokensAction; diff --git a/packages/core-backend/src/api/AccountsApiService.ts b/packages/core-backend/src/api/AccountsApiService.ts new file mode 100644 index 00000000000..59b105126d8 --- /dev/null +++ b/packages/core-backend/src/api/AccountsApiService.ts @@ -0,0 +1,779 @@ +/** + * Accounts API Service for MetaMask + * + * Provides SDK methods for interacting with the Accounts API (v1, v2 and v4). + * Supports account balances, transactions, and address relationship lookups. + * + * This is a plain service class. For Messenger integration, use BackendApiClient. + * + * @see https://accounts.api.cx.metamask.io/docs-json + */ + +import { HttpClient } from './HttpClient'; +import type { + BaseApiServiceOptions, + GetBalancesOptions, + GetV2BalancesResponse, + GetMultiAccountBalancesOptions, + GetV4MultiAccountBalancesResponse, + GetV1SupportedNetworksResponse, + GetV2SupportedNetworksResponse, + GetAccountTransactionsOptions, + GetAccountTransactionsResponse, + GetAccountRelationshipOptions, + AccountRelationshipResult, +} from './types'; + +/** + * Default Accounts API base URL + */ +const DEFAULT_BASE_URL = 'https://accounts.api.cx.metamask.io'; + +/** + * Accounts API Service Options + */ +export type AccountsApiServiceOptions = BaseApiServiceOptions; + +/** + * Active networks response (v2) + */ +export type GetV2ActiveNetworksResponse = { + /** Active networks for the accounts */ + activeNetworks: string[]; +}; + +/** + * Transaction by hash response + */ +export type TransactionByHashResponse = { + hash: string; + timestamp: string; + chainId: number; + blockNumber: number; + blockHash: string; + gas: number; + gasUsed: number; + gasPrice: string; + effectiveGasPrice: number; + nonce: number; + cumulativeGasUsed: number; + methodId?: string; + value: string; + to: string; + from: string; + isError?: boolean; + valueTransfers?: { + from: string; + to: string; + amount: string; + decimal: number; + contractAddress: string; + symbol: string; + name: string; + transferType: string; + }[]; + logs?: { + data: string; + topics: string[]; + address: string; + logIndex: number; + }[]; + transactionType?: string; + transactionCategory?: string; + transactionProtocol?: string; +}; + +/** + * Multi-account transactions response (v4) + */ +export type GetV4MultiAccountTransactionsResponse = { + unprocessedNetworks: string[]; + pageInfo: { + count: number; + hasNextPage: boolean; + endCursor?: string; + }; + data: TransactionByHashResponse[]; +}; + +/** + * Multi-account balances response (v5) + * Uses CAIP-10 account IDs + */ +/** + * Individual token balance item in V5 response + */ +export type V5BalanceItem = { + /** Object type identifier */ + object: 'token'; + /** Token symbol (e.g., "ETH", "USDC") */ + symbol: string; + /** Token name (e.g., "Ether", "USD Coin") */ + name: string; + /** Token type: "native" for native assets, "erc20" for ERC-20 tokens */ + type: 'native' | 'erc20'; + /** Number of decimals */ + decimals: number; + /** CAIP-19 asset ID (e.g., "eip155:1/slip44:60" or "eip155:1/erc20:0x...") */ + assetId: string; + /** Balance as decimal string (already formatted with decimals) */ + balance: string; + /** CAIP-10 account ID (e.g., "eip155:1:0x654ea8b4...") */ + accountId: string; +}; + +export type GetV5MultiAccountBalancesResponse = { + /** Total count of balance items */ + count: number; + /** Unprocessed accounts/networks */ + unprocessedNetworks: string[]; + /** Array of token balance items */ + balances: V5BalanceItem[]; +}; + +/** + * Accounts API Service + * + * SDK for interacting with MetaMask's Accounts API endpoints. + * Provides methods for fetching account balances, transactions, and relationships. + * + * All methods are prefixed with their API version (v1, v2, v4) for clarity. + */ +/** + * Method names exposed via BackendApiClient messenger + */ +export const ACCOUNTS_API_METHODS = [ + // Health & Utility + 'getServiceMetadata', + 'getHealth', + // Supported Networks + 'getV1SupportedNetworks', + 'getV2SupportedNetworks', + // Active Networks + 'getV2ActiveNetworks', + // Balances (v2 - single address) + 'getV2Balances', + 'getV2BalancesWithOptions', + // Balances (v4 - multi-account with addresses) + 'getV4MultiAccountBalances', + // Balances (v5 - multi-account with CAIP-10 IDs) + 'getV5MultiAccountBalances', + // Transactions + 'getV1TransactionByHash', + 'getV1AccountTransactions', + 'getV4MultiAccountTransactions', + // Relationships + 'getV1AccountRelationship', + // NFTs + 'getV2AccountNfts', + // Tokens + 'getV2AccountTokens', +] as const; + +export class AccountsApiService { + readonly #client: HttpClient; + + constructor(options: AccountsApiServiceOptions = {}) { + this.#client = new HttpClient(options.baseUrl ?? DEFAULT_BASE_URL, options); + } + + // =========================================================================== + // Health & Utility Methods + // =========================================================================== + + /** + * Get service metadata + * + * @param signal - Optional abort signal + * @returns Service metadata including product, service name, and version + */ + async getServiceMetadata( + signal?: AbortSignal, + ): Promise<{ product: string; service: string; version: string }> { + return this.#client.get('/', { signal }); + } + + /** + * Get service health status + * + * @param signal - Optional abort signal + * @returns Health status + */ + async getHealth(signal?: AbortSignal): Promise<{ status: string }> { + return this.#client.get('/health', { signal }); + } + + // =========================================================================== + // Supported Networks Methods + // =========================================================================== + + /** + * Get list of supported networks (v1 endpoint) + * + * @param signal - Optional abort signal + * @returns Supported networks response with supportedNetworks array + */ + async getV1SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/supportedNetworks', { signal }); + } + + /** + * Get list of supported networks (v2 endpoint) + * + * Returns networks with full and partial support in CAIP format. + * + * @param signal - Optional abort signal + * @returns Supported networks response with fullSupport and partialSupport + */ + async getV2SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v2/supportedNetworks', { signal }); + } + + // =========================================================================== + // Active Networks Methods (v2) + // =========================================================================== + + /** + * Get active networks by CAIP-10 account IDs (v2 endpoint) + * + * Returns the active networks across multiple account IDs. + * + * @param accountIds - Array of CAIP-10 account IDs + * @param options - Optional query parameters + * @param options.filterMMListTokens - Filter to only tokens in MetaMask's token list + * @param options.networks - Comma-separated CAIP-2 network IDs to filter by + * @param signal - Optional abort signal + * @returns Active networks for the accounts + * + * @example + * ```typescript + * const active = await accountsApi.getV2ActiveNetworks([ + * 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + * 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + * ]); + * ``` + */ + async getV2ActiveNetworks( + accountIds: string[], + options?: { + filterMMListTokens?: boolean; + networks?: string[]; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + + if (accountIds.length > 0) { + params.append('accountIds', accountIds.join(',')); + } + if (options?.filterMMListTokens !== undefined) { + params.append('filterMMListTokens', String(options.filterMMListTokens)); + } + if (options?.networks && options.networks.length > 0) { + params.append('networks', options.networks.join(',')); + } + + const queryString = params.toString(); + return this.#client.get( + `/v2/activeNetworks${queryString ? `?${queryString}` : ''}`, + { signal, authenticate: true }, + ); + } + + // =========================================================================== + // Balance Methods (v2) + // =========================================================================== + + /** + * Get account balances for a single address (v2 endpoint) + * + * Returns balances across multiple networks. + * + * @param options - Balance request options + * @param signal - Optional abort signal + * @returns Account balances response + */ + async getV2Balances( + options: GetBalancesOptions, + signal?: AbortSignal, + ): Promise { + const { address, networks } = options; + + const params = new URLSearchParams(); + if (networks && networks.length > 0) { + params.append('networks', networks.join(',')); + } + + const queryString = params.toString(); + const path = `/v2/accounts/${address}/balances${queryString ? `?${queryString}` : ''}`; + + return this.#client.get(path, { signal, authenticate: true }); + } + + /** + * Get account balances with additional options (v2 endpoint) + * + * @param address - Account address + * @param options - Query options + * @param options.networks - Network IDs to filter by + * @param options.filterSupportedTokens - Filter to only supported tokens + * @param options.includeTokenAddresses - Specific token addresses to include + * @param options.includeStakedAssets - Whether to include staked assets + * @param signal - Optional abort signal + * @returns Account balances response + */ + async getV2BalancesWithOptions( + address: string, + options?: { + networks?: number[]; + filterSupportedTokens?: boolean; + includeTokenAddresses?: string[]; + includeStakedAssets?: boolean; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + + if (options?.networks && options.networks.length > 0) { + params.append('networks', options.networks.join(',')); + } + if (options?.filterSupportedTokens !== undefined) { + params.append( + 'filterSupportedTokens', + String(options.filterSupportedTokens), + ); + } + if ( + options?.includeTokenAddresses && + options.includeTokenAddresses.length > 0 + ) { + params.append( + 'includeTokenAddresses', + options.includeTokenAddresses.join(','), + ); + } + if (options?.includeStakedAssets !== undefined) { + params.append('includeStakedAssets', String(options.includeStakedAssets)); + } + + const queryString = params.toString(); + return this.#client.get( + `/v2/accounts/${address}/balances${queryString ? `?${queryString}` : ''}`, + { signal, authenticate: true }, + ); + } + + // =========================================================================== + // Multi-Account Balance Methods (v4) + // =========================================================================== + + /** + * Get balances for multiple accounts across multiple networks (v4 endpoint) + * + * Uses simple account addresses. + * + * @param options - Multi-account balance request options + * @param signal - Optional abort signal + * @returns Multi-account balances response + */ + async getV4MultiAccountBalances( + options: GetMultiAccountBalancesOptions, + signal?: AbortSignal, + ): Promise { + const { accountAddresses, networks } = options; + + const params = new URLSearchParams(); + params.append('accountAddresses', accountAddresses.join(',')); + + if (networks && networks.length > 0) { + params.append('networks', networks.join(',')); + } + + return this.#client.get(`/v4/multiaccount/balances?${params.toString()}`, { + signal, + authenticate: true, + }); + } + + /** + * Get balances for multiple accounts using CAIP-10 account IDs (v5 endpoint) + * + * Uses CAIP-10 account IDs (e.g., 'eip155:1:0x...') for cross-chain support. + * + * @param accountIds - Array of CAIP-10 account IDs + * @param options - Optional query parameters + * @param options.filterMMListTokens - Filter to only tokens in MetaMask's token list + * @param options.networks - Comma-separated CAIP-2 network IDs to filter by + * @param options.includeStakedAssets - Include staked asset balances + * @param signal - Optional abort signal + * @returns Multi-account balances response + * + * @example + * ```typescript + * const balances = await accountsApi.getV5MultiAccountBalances([ + * 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + * 'eip155:137:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + * 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7S3P4HxJpyyigGzodYwHtCxZyUQe9JiBMHyRWXArAaKv', + * ]); + * ``` + */ + async getV5MultiAccountBalances( + accountIds: string[], + options?: { + /** Filter to only tokens in MetaMask's token list */ + filterMMListTokens?: boolean; + /** Comma-separated CAIP-2 network IDs to filter by */ + networks?: string[]; + /** Include staked asset balances */ + includeStakedAssets?: boolean; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + params.append('accountIds', accountIds.join(',')); + + if (options?.filterMMListTokens !== undefined) { + params.append('filterMMListTokens', String(options.filterMMListTokens)); + } + if (options?.networks && options.networks.length > 0) { + params.append('networks', options.networks.join(',')); + } + if (options?.includeStakedAssets !== undefined) { + params.append('includeStakedAssets', String(options.includeStakedAssets)); + } + + return this.#client.get(`/v5/multiaccount/balances?${params.toString()}`, { + signal, + authenticate: true, + }); + } + + // =========================================================================== + // Transaction Methods + // =========================================================================== + + /** + * Get a specific transaction by hash (v1 endpoint) + * + * @param chainId - Chain ID (decimal) + * @param txHash - Transaction hash + * @param options - Query options + * @param options.includeLogs - Whether to include transaction logs + * @param options.includeValueTransfers - Whether to include value transfers + * @param options.includeTxMetadata - Whether to include transaction metadata + * @param options.lang - Language for response + * @param signal - Optional abort signal + * @returns Transaction details + */ + async getV1TransactionByHash( + chainId: number, + txHash: string, + options?: { + includeLogs?: boolean; + includeValueTransfers?: boolean; + includeTxMetadata?: boolean; + lang?: string; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + + if (options?.includeLogs !== undefined) { + params.append('includeLogs', String(options.includeLogs)); + } + if (options?.includeValueTransfers !== undefined) { + params.append( + 'includeValueTransfers', + String(options.includeValueTransfers), + ); + } + if (options?.includeTxMetadata !== undefined) { + params.append('includeTxMetadata', String(options.includeTxMetadata)); + } + if (options?.lang) { + params.append('lang', options.lang); + } + + const queryString = params.toString(); + return this.#client.get( + `/v1/networks/${chainId}/transactions/${txHash}${queryString ? `?${queryString}` : ''}`, + { signal }, + ); + } + + /** + * Get account transactions (v1 endpoint) + * + * Returns transactions across multiple networks. + * + * @param options - Transaction request options + * @param signal - Optional abort signal + * @returns Account transactions response + */ + async getV1AccountTransactions( + options: GetAccountTransactionsOptions, + signal?: AbortSignal, + ): Promise { + const { + address, + chainIds, + cursor, + startTimestamp, + endTimestamp, + sortDirection, + } = options; + + const params = new URLSearchParams(); + + if (chainIds && chainIds.length > 0) { + params.append('networks', chainIds.join(',')); + } + if (cursor) { + params.append('cursor', cursor); + } + if (startTimestamp !== undefined) { + params.append('startTimestamp', String(startTimestamp)); + } + if (endTimestamp !== undefined) { + params.append('endTimestamp', String(endTimestamp)); + } + if (sortDirection) { + params.append('sortDirection', sortDirection); + } + + const queryString = params.toString(); + const path = `/v1/accounts/${address}/transactions${queryString ? `?${queryString}` : ''}`; + + return this.#client.get(path, { signal, authenticate: true }); + } + + /** + * Get all account transactions with automatic pagination (v1 endpoint) + * + * @param options - Transaction request options + * @param signal - Optional abort signal + * @returns Array of all transactions + */ + async getAllV1AccountTransactions( + options: Omit, + signal?: AbortSignal, + ): Promise { + const allTransactions: GetAccountTransactionsResponse['data'] = []; + let cursor: string | undefined; + + do { + const response = await this.getV1AccountTransactions( + { ...options, cursor }, + signal, + ); + + allTransactions.push(...response.data); + + cursor = response.pageInfo.hasNextPage + ? response.pageInfo.cursor + : undefined; + } while (cursor); + + return allTransactions; + } + + /** + * Get multi-account transactions (v4 endpoint) + * + * Returns transactions across multiple accounts and networks. + * + * @param accountIds - Array of CAIP-10 account IDs + * @param options - Query options + * @param options.networks - CAIP-2 network IDs to filter by + * @param options.cursor - Pagination cursor + * @param options.sortDirection - Sort direction (ASC or DESC) + * @param options.includeLogs - Whether to include transaction logs + * @param options.includeValueTransfers - Whether to include value transfers + * @param options.includeTxMetadata - Whether to include transaction metadata + * @param signal - Optional abort signal + * @returns Multi-account transactions response + */ + async getV4MultiAccountTransactions( + accountIds: string[], + options?: { + networks?: string[]; + cursor?: string; + sortDirection?: 'ASC' | 'DESC'; + includeLogs?: boolean; + includeValueTransfers?: boolean; + includeTxMetadata?: boolean; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + params.append('accountIds', accountIds.join(',')); + + if (options?.networks && options.networks.length > 0) { + params.append('networks', options.networks.join(',')); + } + if (options?.cursor) { + params.append('cursor', options.cursor); + } + if (options?.sortDirection) { + params.append('sortDirection', options.sortDirection); + } + if (options?.includeLogs !== undefined) { + params.append('includeLogs', String(options.includeLogs)); + } + if (options?.includeValueTransfers !== undefined) { + params.append( + 'includeValueTransfers', + String(options.includeValueTransfers), + ); + } + if (options?.includeTxMetadata !== undefined) { + params.append('includeTxMetadata', String(options.includeTxMetadata)); + } + + return this.#client.get( + `/v4/multiaccount/transactions?${params.toString()}`, + { signal, authenticate: true }, + ); + } + + // =========================================================================== + // Address Relationship Methods (v1) + // =========================================================================== + + /** + * Get account address relationship (v1 endpoint) + * + * Returns the most recent transaction from accountAddress to relationshipAddress. + * Used for first-time interaction check. + * + * @param options - Relationship request options + * @param signal - Optional abort signal + * @returns Relationship result with transaction details + */ + async getV1AccountRelationship( + options: GetAccountRelationshipOptions, + signal?: AbortSignal, + ): Promise { + const { chainId, from, to } = options; + + const path = `/v1/networks/${chainId}/accounts/${from}/relationships/${to}`; + + try { + const result = await this.#client.get(path, { + signal, + }); + + return result; + } catch (error) { + // Handle API-level errors + if ( + error instanceof Error && + 'body' in error && + typeof ( + error as { body?: { error?: { code: string; message: string } } } + ).body === 'object' + ) { + const { body } = error as { + body?: { error?: { code: string; message: string } }; + }; + if (body?.error) { + return { + error: { + code: body.error.code, + message: body.error.message, + }, + }; + } + } + throw error; + } + } + + // =========================================================================== + // NFT Methods (v2) + // =========================================================================== + + /** + * Get NFTs owned by an account (v2 endpoint) + * + * @param address - Account address + * @param options - Optional query options + * @param options.networks - Network IDs to filter by + * @param options.cursor - Pagination cursor + * @param signal - Optional abort signal + * @returns NFTs response with pagination + */ + async getV2AccountNfts( + address: string, + options?: { networks?: number[]; cursor?: string }, + signal?: AbortSignal, + ): Promise<{ + data: { + tokenId: string; + contractAddress: string; + chainId: number; + name?: string; + description?: string; + imageUrl?: string; + attributes?: Record[]; + }[]; + pageInfo: { count: number; hasNextPage: boolean; cursor?: string }; + }> { + const params = new URLSearchParams(); + + if (options?.networks && options.networks.length > 0) { + params.append('networks', options.networks.join(',')); + } + if (options?.cursor) { + params.append('cursor', options.cursor); + } + + const queryString = params.toString(); + const path = `/v2/accounts/${address}/nfts${queryString ? `?${queryString}` : ''}`; + + return this.#client.get(path, { signal, authenticate: true }); + } + + // =========================================================================== + // Token Discovery Methods (v2) + // =========================================================================== + + /** + * Get ERC20 tokens detected for an account (v2 endpoint) + * + * @param address - Account address + * @param options - Optional query options + * @param options.networks - Network IDs to filter by + * @param signal - Optional abort signal + * @returns Detected tokens for the account + */ + async getV2AccountTokens( + address: string, + options?: { networks?: number[] }, + signal?: AbortSignal, + ): Promise<{ + data: { + address: string; + chainId: number; + symbol: string; + name: string; + decimals: number; + balance?: string; + }[]; + }> { + const params = new URLSearchParams(); + + if (options?.networks && options.networks.length > 0) { + params.append('networks', options.networks.join(',')); + } + + const queryString = params.toString(); + const path = `/v2/accounts/${address}/tokens${queryString ? `?${queryString}` : ''}`; + + return this.#client.get(path, { signal, authenticate: true }); + } +} diff --git a/packages/core-backend/src/api/BackendApiClient-action-types.ts b/packages/core-backend/src/api/BackendApiClient-action-types.ts new file mode 100644 index 00000000000..c4810d15aaa --- /dev/null +++ b/packages/core-backend/src/api/BackendApiClient-action-types.ts @@ -0,0 +1,100 @@ +/** + * Messenger action types for BackendApiClient + * + * BackendApiClient acts as a unified gateway to all backend API services. + * Actions are namespaced as: + * - BackendApiClient:Accounts:* - Routes to AccountsApiService + * - BackendApiClient:Token:* - Routes to TokenApiService (token.api.cx.metamask.io) + * - BackendApiClient:Tokens:* - Routes to TokensApiService (tokens.api.cx.metamask.io) + * - BackendApiClient:Prices:* - Routes to PriceApiService + */ + +// ============================================================================= +// Re-export from individual service action type files +// ============================================================================= + +// Accounts API Actions +import type { AccountsApiActions } from './AccountsApiService-action-types'; +import type { PricesApiActions } from './PriceApiService-action-types'; +import type { TokenApiActions } from './TokenApiService-action-types'; +import type { TokensApiActions } from './TokensApiService-action-types'; + +export type { + AccountsApiActions, + AccountsGetV1SupportedNetworksAction, + AccountsGetV2SupportedNetworksAction, + AccountsGetV2ActiveNetworksAction, + AccountsGetV2BalancesAction, + AccountsGetV2BalancesWithOptionsAction, + AccountsGetV4MultiAccountBalancesAction, + AccountsGetV1TransactionByHashAction, + AccountsGetV1AccountTransactionsAction, + AccountsGetV4MultiAccountTransactionsAction, + AccountsGetV1AccountRelationshipAction, +} from './AccountsApiService-action-types'; + +// Token API Actions (token.api.cx.metamask.io) +export type { + TokenApiActions, + TokenGetV1SupportedNetworksAction, + TokenGetNetworksAction, + TokenGetNetworkByChainIdAction, + TokenGetTokenListAction, + TokenGetTokenMetadataAction, + TokenGetTokenDescriptionAction, + TokenGetV3TrendingTokensAction, + TokenGetV3TopGainersAction, + TokenGetV3PopularTokensAction, + TokenGetTopAssetsAction, + TokenGetV1SuggestedOccurrenceFloorsAction, +} from './TokenApiService-action-types'; + +// Tokens API Actions (tokens.api.cx.metamask.io) +export type { + TokensApiActions, + TokensGetV1SupportedNetworksAction, + TokensGetV2SupportedNetworksAction, + TokensGetV1TokenListAction, + TokensGetV1TokenMetadataAction, + TokensGetV1TokenDetailsAction, + TokensGetV1TokensByAddressesAction, + TokensGetV1SearchTokensAction, + TokensGetV1SearchTokensOnChainAction, + TokensGetV3AssetsAction, + TokensGetV3TrendingTokensAction, + TokensGetNetworkConfigAction, + TokensGetNetworkTokenStandardAction, + TokensGetTopAssetsAction, +} from './TokensApiService-action-types'; + +// Price API Actions +export type { + PricesApiActions, + PricesGetV1SupportedNetworksAction, + PricesGetV2SupportedNetworksAction, + PricesGetV1ExchangeRatesAction, + PricesGetV1FiatExchangeRatesAction, + PricesGetV1CryptoExchangeRatesAction, + PricesGetV1SpotPricesByCoinIdsAction, + PricesGetV1SpotPriceByCoinIdAction, + PricesGetV1TokenPricesAction, + PricesGetV1TokenPriceAction, + PricesGetV2SpotPricesAction, + PricesGetV3SpotPricesAction, + PricesGetV1HistoricalPricesByCoinIdAction, + PricesGetV1HistoricalPricesByTokenAddressesAction, + PricesGetV1HistoricalPricesAction, + PricesGetV3HistoricalPricesAction, + PricesGetV1HistoricalPriceGraphByCoinIdAction, + PricesGetV1HistoricalPriceGraphByTokenAddressAction, +} from './PriceApiService-action-types'; + +// ============================================================================= +// All BackendApiClient Actions +// ============================================================================= + +export type BackendApiClientActions = + | AccountsApiActions + | TokenApiActions + | TokensApiActions + | PricesApiActions; diff --git a/packages/core-backend/src/api/BackendApiClient.ts b/packages/core-backend/src/api/BackendApiClient.ts new file mode 100644 index 00000000000..abcfaa2ac09 --- /dev/null +++ b/packages/core-backend/src/api/BackendApiClient.ts @@ -0,0 +1,225 @@ +/** + * Backend API Client - Unified Gateway to MetaMask Backend APIs + * + * Provides a single entry point for all backend API services with: + * - Shared authentication (getBearerToken) + * - Shared client product identification + * - Messenger integration for controller communication + * + * Messenger actions are namespaced as: + * - BackendApiClient:Accounts:* - Routes to AccountsApiService + * - BackendApiClient:Token:* - Routes to TokenApiService (token.api.cx.metamask.io) + * - BackendApiClient:Tokens:* - Routes to TokensApiService (tokens.api.cx.metamask.io) + * - BackendApiClient:Prices:* - Routes to PriceApiService + * + * @example Direct usage: + * ```typescript + * const apiClient = new BackendApiClient({ + * clientProduct: 'metamask-extension', + * getBearerToken: async () => authController.getBearerToken(), + * }); + * + * const networks = await apiClient.accounts.getV2SupportedNetworks(); + * const trending = await apiClient.token.getV3TrendingTokens({...}); + * const assets = await apiClient.tokens.getV3Assets([...]); + * const prices = await apiClient.prices.getV1TokenPrices({...}); + * ``` + * + * @example Messenger-based usage: + * ```typescript + * const apiClient = new BackendApiClient({ + * clientProduct: 'metamask-extension', + * getBearerToken: async () => authController.getBearerToken(), + * messenger: backendApiMessenger, + * }); + * + * // From any controller via messenger + * const networks = await messenger.call('BackendApiClient:Accounts:getV2SupportedNetworks'); + * const trending = await messenger.call('BackendApiClient:Token:getV3TrendingTokens', options); + * const assets = await messenger.call('BackendApiClient:Tokens:getV3Assets', assetIds); + * const prices = await messenger.call('BackendApiClient:Prices:getV1TokenPrices', options); + * ``` + */ + +import type { Messenger } from '@metamask/messenger'; + +import { AccountsApiService, ACCOUNTS_API_METHODS } from './AccountsApiService'; +import type { BackendApiClientActions } from './BackendApiClient-action-types'; +import { PriceApiService, PRICE_API_METHODS } from './PriceApiService'; +import { TokenApiService, TOKEN_API_METHODS } from './TokenApiService'; +import { TokensApiService, TOKENS_API_METHODS } from './TokensApiService'; +import type { BaseApiServiceOptions } from './types'; + +const SERVICE_NAME = 'BackendApiClient' as const; + +/** + * Options for BackendApiClient + */ +export type BackendApiClientOptions = BaseApiServiceOptions & { + /** Optional Messenger for action handler registration */ + messenger?: BackendApiClientMessenger; +}; + +/** + * Messenger type for BackendApiClient + */ +export type BackendApiClientMessenger = Messenger< + typeof SERVICE_NAME, + BackendApiClientActions, + never // No events published +>; + +/** + * Service method mapping configuration for registering action handlers + */ +type ServiceMethodMapping = { + service: TService; + namespace: string; + methods: TMethods; +}; + +/** + * Backend API Client + * + * Unified gateway to all MetaMask backend API services. + * Supports both direct method calls and Messenger-based communication. + */ +export class BackendApiClient { + readonly name = SERVICE_NAME; + + /** Accounts API Service instance */ + readonly accounts: AccountsApiService; + + /** Token API Service instance (token.api.cx.metamask.io) */ + readonly token: TokenApiService; + + /** Tokens API Service instance (tokens.api.cx.metamask.io) */ + readonly tokens: TokensApiService; + + /** Price API Service instance */ + readonly prices: PriceApiService; + + readonly #messenger?: BackendApiClientMessenger; + + /** + * Creates a new BackendApiClient instance + * + * @param options - Client configuration options + */ + constructor(options: BackendApiClientOptions = {}) { + this.#messenger = options.messenger; + + // Extract base options (without messenger) for underlying services + const serviceOptions: BaseApiServiceOptions = { + baseUrl: options.baseUrl, + timeout: options.timeout, + getBearerToken: options.getBearerToken, + clientProduct: options.clientProduct, + }; + + // Initialize all API services with shared configuration + this.accounts = new AccountsApiService(serviceOptions); + this.token = new TokenApiService(serviceOptions); + this.tokens = new TokensApiService(serviceOptions); + this.prices = new PriceApiService(serviceOptions); + + // Register action handlers if messenger is provided + if (this.#messenger) { + this.#registerAllActionHandlers(); + } + } + + /** + * Register all action handlers for all services + */ + #registerAllActionHandlers(): void { + // Define method mappings using exported method arrays from each service + const accountsMapping: ServiceMethodMapping< + AccountsApiService, + typeof ACCOUNTS_API_METHODS + > = { + service: this.accounts, + namespace: 'Accounts', + methods: ACCOUNTS_API_METHODS, + }; + + const tokenMapping: ServiceMethodMapping< + TokenApiService, + typeof TOKEN_API_METHODS + > = { + service: this.token, + namespace: 'Token', + methods: TOKEN_API_METHODS, + }; + + const tokensMapping: ServiceMethodMapping< + TokensApiService, + typeof TOKENS_API_METHODS + > = { + service: this.tokens, + namespace: 'Tokens', + methods: TOKENS_API_METHODS, + }; + + const pricesMapping: ServiceMethodMapping< + PriceApiService, + typeof PRICE_API_METHODS + > = { + service: this.prices, + namespace: 'Prices', + methods: PRICE_API_METHODS, + }; + + // Register handlers for each service + this.#registerServiceHandlers(accountsMapping); + this.#registerServiceHandlers(tokenMapping); + this.#registerServiceHandlers(tokensMapping); + this.#registerServiceHandlers(pricesMapping); + } + + /** + * Register action handlers for a service + * + * @param mapping - Service method mapping configuration + */ + #registerServiceHandlers< + TService extends object, + TMethods extends readonly string[], + >(mapping: ServiceMethodMapping): void { + if (!this.#messenger) { + return; + } + + for (const methodName of mapping.methods) { + const actionType = + `${SERVICE_NAME}:${mapping.namespace}:${methodName}` as BackendApiClientActions['type']; + + // Get the method from the service and bind it + const method = mapping.service[methodName as keyof TService]; + if (typeof method === 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const boundMethod = (method as (...args: any[]) => any).bind( + mapping.service, + ); + + this.#messenger.registerActionHandler( + actionType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (...args: any[]) => boundMethod(...args), + ); + } + } + } +} + +/** + * Factory function to create a BackendApiClient instance + * + * @param options - Client configuration options + * @returns BackendApiClient instance + */ +export function createBackendApiClient( + options: BackendApiClientOptions, +): BackendApiClient { + return new BackendApiClient(options); +} diff --git a/packages/core-backend/src/api/HttpClient.ts b/packages/core-backend/src/api/HttpClient.ts new file mode 100644 index 00000000000..669b36c937d --- /dev/null +++ b/packages/core-backend/src/api/HttpClient.ts @@ -0,0 +1,415 @@ +/** + * HTTP Client for MetaMask internal API services + * + * Provides a consistent interface for making authenticated HTTP requests + * to MetaMask backend services with proper error handling, timeouts, + * and request deduplication. + */ + +import type { BaseApiServiceOptions, ApiErrorResponse } from './types'; + +/** + * HTTP request options + */ +export type HttpRequestOptions = { + /** Request method */ + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + /** Request headers */ + headers?: Record; + /** Request body */ + body?: unknown; + /** Request timeout in milliseconds */ + timeout?: number; + /** Whether to include authentication header */ + authenticate?: boolean; + /** Abort signal for request cancellation */ + signal?: AbortSignal; + /** + * Whether to deduplicate this request. + * When enabled, concurrent identical GET requests will share the same response. + * Defaults to true for GET requests, false for others. + */ + dedupe?: boolean; +}; + +/** + * HTTP error with status code and response body + */ +export class HttpError extends Error { + readonly status: number; + + readonly statusText: string; + + readonly body?: unknown; + + constructor(status: number, statusText: string, body?: unknown) { + super(`HTTP ${status}: ${statusText}`); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * In-flight request entry for deduplication + */ +type InFlightRequest = { + promise: Promise; + subscriberCount: number; +}; + +/** + * HTTP Client for making requests to MetaMask internal APIs + * + * Features: + * - Automatic request deduplication for GET requests + * - Bearer token authentication + * - Configurable timeouts + * - Request cancellation via AbortSignal + */ +export class HttpClient { + readonly #baseUrl: string; + + readonly #timeout: number; + + readonly #getBearerToken?: () => Promise; + + readonly #clientProduct: string; + + /** Map of in-flight requests for deduplication */ + readonly #inFlightRequests: Map> = new Map(); + + /** + * Creates a new HTTP client instance + * + * @param baseUrl - Base URL for all requests + * @param options - Client configuration options + */ + constructor( + baseUrl: string, + options: Omit = {}, + ) { + this.#baseUrl = baseUrl; + this.#timeout = options.timeout ?? 10000; + this.#getBearerToken = options.getBearerToken; + this.#clientProduct = options.clientProduct ?? 'metamask-core-backend'; + } + + /** + * Generates a cache key for request deduplication + * + * @param method - HTTP method + * @param path - Request path + * @param authenticate - Whether request is authenticated + * @returns Cache key string + */ + #getCacheKey(method: string, path: string, authenticate: boolean): string { + return `${method}:${authenticate ? 'auth' : 'noauth'}:${this.#baseUrl}${path}`; + } + + /** + * Makes an HTTP request with optional deduplication + * + * @param path - Request path (will be appended to base URL) + * @param options - Request options + * @returns Parsed JSON response + */ + async request( + path: string, + options: HttpRequestOptions = {}, + ): Promise { + const { + method = 'GET', + headers = {}, + body, + timeout = this.#timeout, + authenticate = false, + signal, + dedupe, + } = options; + + // Determine if we should deduplicate this request + // Default: dedupe GET requests, don't dedupe others + const shouldDedupe = dedupe ?? (method === 'GET' && !body); + + if (shouldDedupe) { + const cacheKey = this.#getCacheKey(method, path, authenticate); + const inFlight = this.#inFlightRequests.get(cacheKey); + + if (inFlight) { + // Return existing in-flight request + inFlight.subscriberCount += 1; + return inFlight.promise as Promise; + } + + // Create new request and track it + const requestPromise = this.#executeRequest( + path, + method, + headers, + body, + timeout, + authenticate, + signal, + ); + + const trackedRequest: InFlightRequest = { + promise: requestPromise, + subscriberCount: 1, + }; + + this.#inFlightRequests.set( + cacheKey, + trackedRequest as InFlightRequest, + ); + + // Clean up after request completes (success or failure) + requestPromise + .finally(() => { + this.#inFlightRequests.delete(cacheKey); + }) + .catch(() => { + // Prevent unhandled promise rejection + }); + + return requestPromise; + } + + // Non-deduplicated request + return this.#executeRequest( + path, + method, + headers, + body, + timeout, + authenticate, + signal, + ); + } + + /** + * Executes the actual HTTP request + * + * @param path - Request path + * @param method - HTTP method + * @param headers - Request headers + * @param body - Request body + * @param timeout - Request timeout in milliseconds + * @param authenticate - Whether to authenticate the request + * @param signal - Optional abort signal + * @returns Parsed JSON response + */ + async #executeRequest( + path: string, + method: string, + headers: Record, + body: unknown, + timeout: number, + authenticate: boolean, + signal?: AbortSignal, + ): Promise { + const url = `${this.#baseUrl}${path}`; + + // Build headers + const requestHeaders: Record = { + 'Content-Type': 'application/json', + 'x-metamask-clientproduct': this.#clientProduct, + ...headers, + }; + + // Add authentication header if requested + if (authenticate && this.#getBearerToken) { + const token = await this.#getBearerToken(); + if (token) { + requestHeaders.Authorization = `Bearer ${token}`; + } + } + + // Create abort controller for timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // Combine with external signal if provided + const combinedSignal = signal + ? this.#combineAbortSignals(signal, controller.signal) + : controller.signal; + + try { + const response = await fetch(url, { + method, + headers: requestHeaders, + body: body ? JSON.stringify(body) : undefined, + signal: combinedSignal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + let errorBody: unknown; + try { + errorBody = await response.json(); + } catch { + // Response body is not JSON + } + throw new HttpError(response.status, response.statusText, errorBody); + } + + // Handle 204 No Content + if (response.status === 204) { + return {} as TResponse; + } + + const data = await response.json(); + + // Check for API-level errors + if (this.#isApiError(data)) { + throw new HttpError( + 400, + data.error?.message ?? 'Unknown API error', + data, + ); + } + + return data as TResponse; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof HttpError) { + throw error; + } + + if (error instanceof Error) { + if (error.name === 'AbortError') { + throw new Error(`Request timeout after ${timeout}ms`); + } + throw error; + } + + throw new Error(String(error)); + } + } + + /** + * Makes a GET request + * + * GET requests are automatically deduplicated by default. + * Concurrent requests to the same URL will share the same response. + * + * @param path - Request path + * @param options - Request options + * @returns Parsed JSON response + */ + async get( + path: string, + options: Omit = {}, + ): Promise { + return this.request(path, { ...options, method: 'GET' }); + } + + /** + * Makes a POST request + * + * POST requests are NOT deduplicated by default. + * + * @param path - Request path + * @param body - Request body + * @param options - Request options + * @returns Parsed JSON response + */ + async post( + path: string, + body?: unknown, + options: Omit = {}, + ): Promise { + return this.request(path, { ...options, method: 'POST', body }); + } + + /** + * Makes a PUT request + * + * @param path - Request path + * @param body - Request body + * @param options - Request options + * @returns Parsed JSON response + */ + async put( + path: string, + body?: unknown, + options: Omit = {}, + ): Promise { + return this.request(path, { ...options, method: 'PUT', body }); + } + + /** + * Makes a DELETE request + * + * @param path - Request path + * @param options - Request options + * @returns Parsed JSON response + */ + async delete( + path: string, + options: Omit = {}, + ): Promise { + return this.request(path, { ...options, method: 'DELETE' }); + } + + /** + * Clears all in-flight request tracking. + * Useful for testing or when resetting client state. + */ + clearInFlightRequests(): void { + this.#inFlightRequests.clear(); + } + + /** + * Gets the number of in-flight requests. + * Useful for debugging and testing. + * + * @returns The number of in-flight requests + */ + get inFlightCount(): number { + return this.#inFlightRequests.size; + } + + /** + * Combines multiple abort signals into one + * + * @param signal1 - First abort signal + * @param signal2 - Second abort signal + * @returns Combined abort signal + */ + #combineAbortSignals( + signal1: AbortSignal, + signal2: AbortSignal, + ): AbortSignal { + const controller = new AbortController(); + + const abort = (): void => controller.abort(); + + if (signal1.aborted || signal2.aborted) { + controller.abort(); + } else { + signal1.addEventListener('abort', abort); + signal2.addEventListener('abort', abort); + } + + return controller.signal; + } + + /** + * Checks if response is an API error + * + * @param data - Data to check + * @returns True if data is an API error response + */ + #isApiError(data: unknown): data is ApiErrorResponse { + return ( + typeof data === 'object' && + data !== null && + 'error' in data && + typeof (data as ApiErrorResponse).error === 'object' + ); + } +} diff --git a/packages/core-backend/src/api/PriceApiService-action-types.ts b/packages/core-backend/src/api/PriceApiService-action-types.ts new file mode 100644 index 00000000000..b0e7f814de1 --- /dev/null +++ b/packages/core-backend/src/api/PriceApiService-action-types.ts @@ -0,0 +1,232 @@ +/** + * Messenger action types for PriceApiService + * + * Actions are namespaced as: BackendApiClient:Prices:* + */ + +import type { + GetV3SpotPricesResponse, + GetExchangeRatesWithInfoResponse, + GetPriceSupportedNetworksV1Response, + GetPriceSupportedNetworksV2Response, + CoinGeckoSpotPrice, + GetV3HistoricalPricesResponse, +} from './PriceApiService'; +import type { + GetTokenPricesOptions, + GetTokenPricesResponse, + GetHistoricalPricesOptions, + GetHistoricalPricesResponse, + MarketDataDetails, + SupportedCurrency, +} from './types'; + +// Using string literals directly in template types to avoid unused variable lint errors +type ServiceName = 'BackendApiClient'; +type Namespace = 'Prices'; + +// ============================================================================= +// Supported Networks Actions +// ============================================================================= + +export type PricesGetV1SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV1SupportedNetworks`; + handler: () => Promise; +}; + +export type PricesGetV2SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV2SupportedNetworks`; + handler: () => Promise; +}; + +// ============================================================================= +// Exchange Rates Actions +// ============================================================================= + +export type PricesGetV1ExchangeRatesAction = { + type: `${ServiceName}:${Namespace}:getV1ExchangeRates`; + handler: (baseCurrency: string) => Promise; +}; + +export type PricesGetV1FiatExchangeRatesAction = { + type: `${ServiceName}:${Namespace}:getV1FiatExchangeRates`; + handler: () => Promise; +}; + +export type PricesGetV1CryptoExchangeRatesAction = { + type: `${ServiceName}:${Namespace}:getV1CryptoExchangeRates`; + handler: () => Promise; +}; + +// ============================================================================= +// V1 Spot Prices - CoinGecko ID based +// ============================================================================= + +export type PricesGetV1SpotPricesByCoinIdsAction = { + type: `${ServiceName}:${Namespace}:getV1SpotPricesByCoinIds`; + handler: (coinIds: string[]) => Promise>; +}; + +export type PricesGetV1SpotPriceByCoinIdAction = { + type: `${ServiceName}:${Namespace}:getV1SpotPriceByCoinId`; + handler: ( + coinId: string, + currency?: SupportedCurrency, + ) => Promise; +}; + +// ============================================================================= +// V1 Spot Prices - Token Address based +// ============================================================================= + +export type PricesGetV1TokenPricesAction = { + type: `${ServiceName}:${Namespace}:getV1TokenPrices`; + handler: (options: GetTokenPricesOptions) => Promise; +}; + +export type PricesGetV1TokenPriceAction = { + type: `${ServiceName}:${Namespace}:getV1TokenPrice`; + handler: ( + chainId: string, + tokenAddress: string, + currency?: SupportedCurrency, + ) => Promise; +}; + +// ============================================================================= +// V2 Spot Prices Actions +// ============================================================================= + +export type PricesGetV2SpotPricesAction = { + type: `${ServiceName}:${Namespace}:getV2SpotPrices`; + handler: ( + chainId: string, + tokenAddresses: string[], + currency?: SupportedCurrency, + includeMarketData?: boolean, + ) => Promise>; +}; + +// ============================================================================= +// V3 Spot Prices Actions +// ============================================================================= + +export type PricesGetV3SpotPricesAction = { + type: `${ServiceName}:${Namespace}:getV3SpotPrices`; + handler: ( + assetIds: string[], + currency?: SupportedCurrency, + includeMarketData?: boolean, + cacheOnly?: boolean, + ) => Promise; +}; + +// ============================================================================= +// V1 Historical Prices Actions +// ============================================================================= + +export type PricesGetV1HistoricalPricesByCoinIdAction = { + type: `${ServiceName}:${Namespace}:getV1HistoricalPricesByCoinId`; + handler: ( + coinId: string, + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + ) => Promise; +}; + +export type PricesGetV1HistoricalPricesByTokenAddressesAction = { + type: `${ServiceName}:${Namespace}:getV1HistoricalPricesByTokenAddresses`; + handler: ( + chainId: string, + tokenAddresses: string[], + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + ) => Promise; +}; + +export type PricesGetV1HistoricalPricesAction = { + type: `${ServiceName}:${Namespace}:getV1HistoricalPrices`; + handler: ( + options: GetHistoricalPricesOptions, + ) => Promise; +}; + +// ============================================================================= +// V3 Historical Prices Actions +// ============================================================================= + +export type PricesGetV3HistoricalPricesAction = { + type: `${ServiceName}:${Namespace}:getV3HistoricalPrices`; + handler: ( + chainId: string, + assetType: string, + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + interval?: '5m' | 'hourly' | 'daily'; + }, + ) => Promise; +}; + +// ============================================================================= +// V1 Historical Price Graph Actions +// ============================================================================= + +export type PricesGetV1HistoricalPriceGraphByCoinIdAction = { + type: `${ServiceName}:${Namespace}:getV1HistoricalPriceGraphByCoinId`; + handler: ( + coinId: string, + currency?: SupportedCurrency, + includeOHLC?: boolean, + ) => Promise; +}; + +export type PricesGetV1HistoricalPriceGraphByTokenAddressAction = { + type: `${ServiceName}:${Namespace}:getV1HistoricalPriceGraphByTokenAddress`; + handler: ( + chainId: string, + tokenAddress: string, + currency?: SupportedCurrency, + includeOHLC?: boolean, + ) => Promise; +}; + +// ============================================================================= +// All Price API Actions +// ============================================================================= + +export type PricesApiActions = + // Supported Networks + | PricesGetV1SupportedNetworksAction + | PricesGetV2SupportedNetworksAction + // Exchange Rates + | PricesGetV1ExchangeRatesAction + | PricesGetV1FiatExchangeRatesAction + | PricesGetV1CryptoExchangeRatesAction + // V1 Spot Prices + | PricesGetV1SpotPricesByCoinIdsAction + | PricesGetV1SpotPriceByCoinIdAction + | PricesGetV1TokenPricesAction + | PricesGetV1TokenPriceAction + // V2 Spot Prices + | PricesGetV2SpotPricesAction + // V3 Spot Prices + | PricesGetV3SpotPricesAction + // Historical Prices + | PricesGetV1HistoricalPricesByCoinIdAction + | PricesGetV1HistoricalPricesByTokenAddressesAction + | PricesGetV1HistoricalPricesAction + | PricesGetV3HistoricalPricesAction + // Historical Price Graph + | PricesGetV1HistoricalPriceGraphByCoinIdAction + | PricesGetV1HistoricalPriceGraphByTokenAddressAction; diff --git a/packages/core-backend/src/api/PriceApiService.ts b/packages/core-backend/src/api/PriceApiService.ts new file mode 100644 index 00000000000..441b0f926dc --- /dev/null +++ b/packages/core-backend/src/api/PriceApiService.ts @@ -0,0 +1,704 @@ +/** + * Price API Service for MetaMask + * + * Provides SDK methods for interacting with the Price API (v1, v2, v3). + * Supports token prices, exchange rates, and historical price data. + * + * This is a plain service class. For Messenger integration, use BackendApiClient. + * + * @see https://price.api.cx.metamask.io/docs-json + */ + +import { HttpClient } from './HttpClient'; +import type { + BaseApiServiceOptions, + GetTokenPricesOptions, + GetTokenPricesResponse, + GetHistoricalPricesOptions, + GetHistoricalPricesResponse, + MarketDataDetails, + SupportedCurrency, +} from './types'; + +/** + * Default Price API base URL + */ +const DEFAULT_BASE_URL = 'https://price.api.cx.metamask.io'; + +/** + * Price API Service Options + */ +export type PriceApiServiceOptions = BaseApiServiceOptions; + +/** + * V3 Spot Prices Response - keyed by CAIP asset ID + */ +export type GetV3SpotPricesResponse = { + [assetId: string]: MarketDataDetails; +}; + +/** + * Exchange rate with metadata + */ +export type ExchangeRateInfo = { + name: string; + ticker: string; + value: number; + currencyType: 'crypto' | 'fiat'; +}; + +/** + * Exchange rates response with metadata + */ +export type GetExchangeRatesWithInfoResponse = { + [currency: string]: ExchangeRateInfo; +}; + +/** + * Supported networks response (v1) + */ +export type GetPriceSupportedNetworksV1Response = { + fullSupport: string[]; + partialSupport: string[]; +}; + +/** + * Supported networks response (v2) - CAIP format + */ +export type GetPriceSupportedNetworksV2Response = { + fullSupport: string[]; + partialSupport: string[]; +}; + +/** + * Spot price by CoinGecko ID response + */ +export type CoinGeckoSpotPrice = { + id: string; + price: number; + marketCap?: number; + allTimeHigh?: number; + allTimeLow?: number; + totalVolume?: number; + high1d?: number; + low1d?: number; + circulatingSupply?: number; + dilutedMarketCap?: number; + marketCapPercentChange1d?: number; + priceChange1d?: number; + pricePercentChange1h?: number; + pricePercentChange1d?: number; + pricePercentChange7d?: number; + pricePercentChange14d?: number; + pricePercentChange30d?: number; + pricePercentChange200d?: number; + pricePercentChange1y?: number; +}; + +/** + * Historical prices response (v3) with arrays of [timestamp, value] tuples + */ +export type GetV3HistoricalPricesResponse = { + prices: [number, number][]; + marketCaps?: [number, number][]; + totalVolumes?: [number, number][]; +}; + +/** + * Price API Service + * + * SDK for interacting with MetaMask's Price API endpoints. + * Provides methods for fetching token prices, exchange rates, and historical data. + * + * Supports three API versions: + * - V1: Chain-specific token prices and CoinGecko ID based prices + * - V2: Chain-specific spot prices with enhanced market data + * - V3: Multi-chain spot prices using CAIP asset IDs + */ +/** + * Method names exposed via BackendApiClient messenger + */ +export const PRICE_API_METHODS = [ + // Supported Networks + 'getV1SupportedNetworks', + 'getV2SupportedNetworks', + // Exchange Rates + 'getV1ExchangeRates', + 'getV1FiatExchangeRates', + 'getV1CryptoExchangeRates', + // V1 Spot Prices - CoinGecko ID based + 'getV1SpotPricesByCoinIds', + 'getV1SpotPriceByCoinId', + // V1 Spot Prices - Token Address based + 'getV1TokenPrices', + 'getV1TokenPrice', + // V2 Spot Prices + 'getV2SpotPrices', + // V3 Spot Prices + 'getV3SpotPrices', + // V1 Historical Prices + 'getV1HistoricalPricesByCoinId', + 'getV1HistoricalPricesByTokenAddresses', + 'getV1HistoricalPrices', + // V3 Historical Prices + 'getV3HistoricalPrices', + // V1 Historical Price Graph + 'getV1HistoricalPriceGraphByCoinId', + 'getV1HistoricalPriceGraphByTokenAddress', +] as const; + +export class PriceApiService { + readonly #client: HttpClient; + + constructor(options: PriceApiServiceOptions = {}) { + this.#client = new HttpClient(options.baseUrl ?? DEFAULT_BASE_URL, options); + } + + // =========================================================================== + // Health & Utility Methods + // =========================================================================== + + /** + * Get service metadata + * + * @param signal - Optional abort signal + * @returns Service metadata including product, service name, and version + */ + async getServiceMetadata( + signal?: AbortSignal, + ): Promise<{ product: string; service: string; version: string }> { + return this.#client.get('/', { signal }); + } + + /** + * Get service health status + * + * @param signal - Optional abort signal + * @returns Health status + */ + async getHealth(signal?: AbortSignal): Promise<{ status: string }> { + return this.#client.get('/health', { signal }); + } + + /** + * Get service readiness status + * + * @param signal - Optional abort signal + * @returns Readiness status + */ + async getReadiness(signal?: AbortSignal): Promise<{ status: string }> { + return this.#client.get('/health/readiness', { signal }); + } + + // =========================================================================== + // Supported Networks Methods + // =========================================================================== + + /** + * Get supported networks (v1 endpoint) + * + * @param signal - Optional abort signal + * @returns Supported networks with fullSupport and partialSupport arrays + */ + async getV1SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/supportedNetworks', { signal }); + } + + /** + * Get supported networks in CAIP format (v2 endpoint) + * + * @param signal - Optional abort signal + * @returns Supported networks as CAIP-19 identifiers + */ + async getV2SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v2/supportedNetworks', { signal }); + } + + // =========================================================================== + // V1 Exchange Rate Methods + // =========================================================================== + + /** + * Get all exchange rates for a base currency (v1 endpoint) + * + * @param baseCurrency - Base currency code (e.g., 'eth', 'btc', 'usd') + * @param signal - Optional abort signal + * @returns Exchange rates with metadata + */ + async getV1ExchangeRates( + baseCurrency: string, + signal?: AbortSignal, + ): Promise { + return this.#client.get(`/v1/exchange-rates?baseCurrency=${baseCurrency}`, { + signal, + }); + } + + /** + * Get fiat exchange rates (v1 endpoint) + * + * @param signal - Optional abort signal + * @returns Fiat currency exchange rates + */ + async getV1FiatExchangeRates( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/exchange-rates/fiat', { signal }); + } + + /** + * Get crypto exchange rates (v1 endpoint) + * + * @param signal - Optional abort signal + * @returns Crypto currency exchange rates + */ + async getV1CryptoExchangeRates( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/exchange-rates/crypto', { signal }); + } + + // =========================================================================== + // V1 Spot Price Methods - CoinGecko ID based + // =========================================================================== + + /** + * Get spot prices by CoinGecko coin IDs (v1 endpoint) + * + * @param coinIds - Comma-separated CoinGecko IDs (e.g., 'ethereum,bitcoin') + * @param signal - Optional abort signal + * @returns Spot prices keyed by coin ID + */ + async getV1SpotPricesByCoinIds( + coinIds: string[], + signal?: AbortSignal, + ): Promise> { + if (coinIds.length === 0) { + return {}; + } + return this.#client.get(`/v1/spot-prices?coinIds=${coinIds.join(',')}`, { + signal, + }); + } + + /** + * Get spot price for a single CoinGecko coin ID (v1 endpoint) + * + * @param coinId - CoinGecko coin ID (e.g., 'ethereum', 'bitcoin') + * @param currency - Target currency (default: 'usd') + * @param signal - Optional abort signal + * @returns Spot price with market data + */ + async getV1SpotPriceByCoinId( + coinId: string, + currency: SupportedCurrency = 'usd', + signal?: AbortSignal, + ): Promise { + return this.#client.get( + `/v1/spot-prices/${coinId}?vsCurrency=${currency}`, + { signal }, + ); + } + + // =========================================================================== + // V1 Spot Price Methods - Token Address based + // =========================================================================== + + /** + * Get spot prices for tokens on a chain (v1 endpoint) + * + * @param options - Token prices request options + * @param signal - Optional abort signal + * @returns Token prices response + */ + async getV1TokenPrices( + options: GetTokenPricesOptions, + signal?: AbortSignal, + ): Promise { + const { + chainId, + tokenAddresses, + currency = 'usd', + includeMarketData = false, + } = options; + + if (tokenAddresses.length === 0) { + return {}; + } + + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('tokenAddresses', tokenAddresses.join(',')); + params.append('vsCurrency', currency); + if (includeMarketData) { + params.append('includeMarketData', 'true'); + } + + return this.#client.get( + `/v1/chains/${chainIdDecimal}/spot-prices?${params.toString()}`, + { signal }, + ); + } + + /** + * Get spot price for a single token (v1 endpoint) + * + * @param chainId - Chain ID in hex format + * @param tokenAddress - Token contract address + * @param currency - Target currency (default: 'usd') + * @param signal - Optional abort signal + * @returns Token price data + */ + async getV1TokenPrice( + chainId: string, + tokenAddress: string, + currency: SupportedCurrency = 'usd', + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + try { + return await this.#client.get( + `/v1/chains/${chainIdDecimal}/spot-prices/${tokenAddress}?vsCurrency=${currency}`, + { signal }, + ); + } catch { + return undefined; + } + } + + // =========================================================================== + // V2 Spot Price Methods + // =========================================================================== + + /** + * Get spot prices for tokens on a chain with market data (v2 endpoint) + * + * @param chainId - Chain ID in hex format + * @param tokenAddresses - Array of token contract addresses + * @param currency - Target currency (default: 'usd') + * @param includeMarketData - Include market data (default: true) + * @param signal - Optional abort signal + * @returns Token prices with market data + */ + async getV2SpotPrices( + chainId: string, + tokenAddresses: string[], + currency: SupportedCurrency = 'usd', + includeMarketData: boolean = true, + signal?: AbortSignal, + ): Promise> { + if (tokenAddresses.length === 0) { + return {}; + } + + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('tokenAddresses', tokenAddresses.join(',')); + params.append('vsCurrency', currency); + params.append('includeMarketData', String(includeMarketData)); + + return this.#client.get( + `/v2/chains/${chainIdDecimal}/spot-prices?${params.toString()}`, + { signal }, + ); + } + + // =========================================================================== + // V3 Spot Price Methods - CAIP-19 based + // =========================================================================== + + /** + * Get spot prices by CAIP-19 asset IDs (v3 endpoint) + * + * This is the most efficient method for fetching prices across multiple chains. + * + * @param assetIds - Array of CAIP-19 asset IDs + * @param currency - Target currency (default: 'usd') + * @param includeMarketData - Include market data (default: true) + * @param cacheOnly - Only return cached prices (default: false) + * @param signal - Optional abort signal + * @returns Spot prices keyed by asset ID + * + * @example + * ```typescript + * const prices = await priceApi.getV3SpotPrices([ + * 'eip155:1/slip44:60', // Native ETH + * 'eip155:1/erc20:0xa0b86991...', // USDC on Ethereum + * 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5...', // USDC on Solana + * ]); + * ``` + */ + async getV3SpotPrices( + assetIds: string[], + currency: SupportedCurrency = 'usd', + includeMarketData: boolean = true, + cacheOnly: boolean = false, + signal?: AbortSignal, + ): Promise { + if (assetIds.length === 0) { + return {}; + } + + const params = new URLSearchParams(); + params.append('assetIds', assetIds.join(',')); + params.append('vsCurrency', currency.toUpperCase()); + params.append('includeMarketData', String(includeMarketData)); + params.append('cacheOnly', String(cacheOnly)); + + return this.#client.get(`/v3/spot-prices?${params.toString()}`, { signal }); + } + + // =========================================================================== + // V1 Historical Price Methods - CoinGecko ID based + // =========================================================================== + + /** + * Get historical prices by CoinGecko coin ID (v1 endpoint) + * + * @param coinId - CoinGecko coin ID + * @param options - Query options + * @param options.currency - Currency for prices + * @param options.timePeriod - Time period for historical data + * @param options.from - Start timestamp + * @param options.to - End timestamp + * @param signal - Optional abort signal + * @returns Historical price data + */ + async getV1HistoricalPricesByCoinId( + coinId: string, + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + if (options?.currency) { + params.append('vsCurrency', options.currency); + } + if (options?.timePeriod) { + params.append('timePeriod', options.timePeriod); + } + if (options?.from) { + params.append('from', String(options.from)); + } + if (options?.to) { + params.append('to', String(options.to)); + } + + const queryString = params.toString(); + return this.#client.get( + `/v1/historical-prices/${coinId}${queryString ? `?${queryString}` : ''}`, + { signal }, + ); + } + + // =========================================================================== + // V1 Historical Price Methods - Token Address based + // =========================================================================== + + /** + * Get historical prices for tokens on a chain (v1 endpoint) + * + * @param chainId - Chain ID in hex format + * @param tokenAddresses - Array of token addresses + * @param options - Query options + * @param options.currency - Currency for prices + * @param options.timePeriod - Time period for historical data + * @param options.from - Start timestamp + * @param options.to - End timestamp + * @param signal - Optional abort signal + * @returns Historical price data + */ + async getV1HistoricalPricesByTokenAddresses( + chainId: string, + tokenAddresses: string[], + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('tokenAddresses', tokenAddresses.join(',')); + if (options?.currency) { + params.append('vsCurrency', options.currency); + } + if (options?.timePeriod) { + params.append('timePeriod', options.timePeriod); + } + if (options?.from) { + params.append('from', String(options.from)); + } + if (options?.to) { + params.append('to', String(options.to)); + } + + return this.#client.get( + `/v1/chains/${chainIdDecimal}/historical-prices?${params.toString()}`, + { signal }, + ); + } + + /** + * Get historical prices for a single token (v1 endpoint) + * + * @param options - Historical prices request options + * @param signal - Optional abort signal + * @returns Historical price data + */ + async getV1HistoricalPrices( + options: GetHistoricalPricesOptions, + signal?: AbortSignal, + ): Promise { + const { + chainId, + tokenAddress, + currency = 'usd', + timeRange = '7d', + } = options; + + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('vsCurrency', currency); + params.append('timePeriod', timeRange); + + return this.#client.get( + `/v1/chains/${chainIdDecimal}/historical-prices/${tokenAddress}?${params.toString()}`, + { signal }, + ); + } + + // =========================================================================== + // V3 Historical Price Methods - CAIP-19 based + // =========================================================================== + + /** + * Get historical prices by CAIP-19 asset ID (v3 endpoint) + * + * Returns price data optimized for chart rendering with [timestamp, value] tuples. + * + * @param chainId - CAIP-2 chain ID (e.g., 'eip155:1') + * @param assetType - Asset type (e.g., 'erc20:0x...', 'slip44:60') + * @param options - Query options + * @param options.currency - Currency for prices + * @param options.timePeriod - Time period for historical data + * @param options.from - Start timestamp + * @param options.to - End timestamp + * @param options.interval - Data point interval (5m, hourly, daily) + * @param signal - Optional abort signal + * @returns Historical prices with optional market caps and volumes + * + * @example + * ```typescript + * const history = await priceApi.getV3HistoricalPrices( + * 'eip155:1', + * 'slip44:60', + * { timePeriod: '30d', interval: 'daily' } + * ); + * ``` + */ + async getV3HistoricalPrices( + chainId: string, + assetType: string, + options?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + interval?: '5m' | 'hourly' | 'daily'; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + if (options?.currency) { + params.append('vsCurrency', options.currency); + } + if (options?.timePeriod) { + params.append('timePeriod', options.timePeriod); + } + if (options?.from) { + params.append('from', String(options.from)); + } + if (options?.to) { + params.append('to', String(options.to)); + } + if (options?.interval) { + params.append('interval', options.interval); + } + + const queryString = params.toString(); + return this.#client.get( + `/v3/historical-prices/${chainId}/${assetType}${queryString ? `?${queryString}` : ''}`, + { signal }, + ); + } + + // =========================================================================== + // V1 Historical Price Graph Methods + // =========================================================================== + + /** + * Get historical price graph data by CoinGecko coin ID (v1 endpoint) + * + * Returns data optimized for chart rendering. + * + * @param coinId - CoinGecko coin ID + * @param currency - Target currency (default: 'usd') + * @param includeOHLC - Include OHLC data (default: false) + * @param signal - Optional abort signal + * @returns Price graph data + */ + async getV1HistoricalPriceGraphByCoinId( + coinId: string, + currency: SupportedCurrency = 'usd', + includeOHLC: boolean = false, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + params.append('vsCurrency', currency); + params.append('includeOHLC', String(includeOHLC)); + + return this.#client.get( + `/v1/historical-prices-graph/${coinId}?${params.toString()}`, + { signal }, + ); + } + + /** + * Get historical price graph data by token address (v1 endpoint) + * + * @param chainId - Chain ID in hex format + * @param tokenAddress - Token contract address + * @param currency - Target currency (default: 'usd') + * @param includeOHLC - Include OHLC data (default: false) + * @param signal - Optional abort signal + * @returns Price graph data + */ + async getV1HistoricalPriceGraphByTokenAddress( + chainId: string, + tokenAddress: string, + currency: SupportedCurrency = 'usd', + includeOHLC: boolean = false, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('vsCurrency', currency); + params.append('includeOHLC', String(includeOHLC)); + + return this.#client.get( + `/v1/chains/${chainIdDecimal}/historical-prices-graph/${tokenAddress}?${params.toString()}`, + { signal }, + ); + } +} diff --git a/packages/core-backend/src/api/TokenApiService-action-types.ts b/packages/core-backend/src/api/TokenApiService-action-types.ts new file mode 100644 index 00000000000..3e223f579a3 --- /dev/null +++ b/packages/core-backend/src/api/TokenApiService-action-types.ts @@ -0,0 +1,182 @@ +/** + * Messenger action types for TokenApiService + * + * Actions are namespaced as: BackendApiClient:Token:* + * This is for the Token API at token.api.cx.metamask.io + */ + +import type { + GetTokenSupportedNetworksResponse, + NetworkInfo, + TopAsset, + TokenDescriptionResponse, + SuggestedOccurrenceFloorsResponse, + TopGainersSortOption, +} from './TokenApiService'; +import type { + TokenMetadata, + TrendingToken, + GetTrendingTokensOptions, +} from './types'; + +// Using string literals directly in template types to avoid unused variable lint errors +type ServiceName = 'BackendApiClient'; +type Namespace = 'Token'; + +// ============================================================================= +// Supported Networks Actions +// ============================================================================= + +export type TokenGetV1SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV1SupportedNetworks`; + handler: () => Promise; +}; + +// ============================================================================= +// Networks Actions +// ============================================================================= + +export type TokenGetNetworksAction = { + type: `${ServiceName}:${Namespace}:getNetworks`; + handler: () => Promise; +}; + +export type TokenGetNetworkByChainIdAction = { + type: `${ServiceName}:${Namespace}:getNetworkByChainId`; + handler: (chainId: number) => Promise; +}; + +// ============================================================================= +// Token List Actions +// ============================================================================= + +export type TokenGetTokenListAction = { + type: `${ServiceName}:${Namespace}:getTokenList`; + handler: ( + chainId: number, + options?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + ) => Promise; +}; + +// ============================================================================= +// Token Metadata Actions +// ============================================================================= + +export type TokenGetTokenMetadataAction = { + type: `${ServiceName}:${Namespace}:getTokenMetadata`; + handler: ( + chainId: number, + tokenAddress: string, + options?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + ) => Promise; +}; + +export type TokenGetTokenDescriptionAction = { + type: `${ServiceName}:${Namespace}:getTokenDescription`; + handler: ( + chainId: number, + tokenAddress: string, + ) => Promise; +}; + +// ============================================================================= +// Trending & Top Tokens (V3) Actions +// ============================================================================= + +export type TokenGetV3TrendingTokensAction = { + type: `${ServiceName}:${Namespace}:getV3TrendingTokens`; + handler: (options: GetTrendingTokensOptions) => Promise; +}; + +export type TokenGetV3TopGainersAction = { + type: `${ServiceName}:${Namespace}:getV3TopGainers`; + handler: ( + chainIds: string[], + options?: { + sort?: TopGainersSortOption; + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + ) => Promise; +}; + +export type TokenGetV3PopularTokensAction = { + type: `${ServiceName}:${Namespace}:getV3PopularTokens`; + handler: ( + chainIds: string[], + options?: { + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + ) => Promise; +}; + +// ============================================================================= +// Top Assets Actions +// ============================================================================= + +export type TokenGetTopAssetsAction = { + type: `${ServiceName}:${Namespace}:getTopAssets`; + handler: (chainId: number) => Promise; +}; + +// ============================================================================= +// Utility Actions +// ============================================================================= + +export type TokenGetV1SuggestedOccurrenceFloorsAction = { + type: `${ServiceName}:${Namespace}:getV1SuggestedOccurrenceFloors`; + handler: () => Promise; +}; + +// ============================================================================= +// All Token API Actions +// ============================================================================= + +export type TokenApiActions = + // Supported Networks + | TokenGetV1SupportedNetworksAction + // Networks + | TokenGetNetworksAction + | TokenGetNetworkByChainIdAction + // Token List + | TokenGetTokenListAction + // Token Metadata + | TokenGetTokenMetadataAction + | TokenGetTokenDescriptionAction + // Trending & Top Tokens (V3) + | TokenGetV3TrendingTokensAction + | TokenGetV3TopGainersAction + | TokenGetV3PopularTokensAction + // Top Assets + | TokenGetTopAssetsAction + // Utility + | TokenGetV1SuggestedOccurrenceFloorsAction; diff --git a/packages/core-backend/src/api/TokenApiService.ts b/packages/core-backend/src/api/TokenApiService.ts new file mode 100644 index 00000000000..9b0188c7ddb --- /dev/null +++ b/packages/core-backend/src/api/TokenApiService.ts @@ -0,0 +1,579 @@ +/** + * Token API Service for MetaMask (V1) + * + * Provides SDK methods for interacting with the Token API at token.api.cx.metamask.io + * Supports token metadata, trending tokens, top gainers/losers, and network information. + * + * This is a plain service class. For Messenger integration, use BackendApiClient. + * + * @see https://token.api.cx.metamask.io/docs-json + */ + +import { HttpClient } from './HttpClient'; +import type { + BaseApiServiceOptions, + TokenMetadata, + TrendingToken, + GetTrendingTokensOptions, +} from './types'; + +/** + * Default Token API base URL + */ +export const TOKEN_API_BASE_URL = 'https://token.api.cx.metamask.io'; + +/** + * Token API Service Options + */ +export type TokenApiServiceOptions = BaseApiServiceOptions; + +/** + * Supported networks response + */ +export type GetTokenSupportedNetworksResponse = { + fullSupport: string[]; +}; + +/** + * Network configuration + */ +export type NetworkInfo = { + active: boolean; + chainId: number; + chainName: string; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + address: string; + }; + iconUrl?: string; + blockExplorerUrl?: string; + networkType?: string; + tokenSources?: string[]; +}; + +/** + * Top asset + */ +export type TopAsset = { + address: string; + symbol: string; +}; + +/** + * Token description response + */ +export type TokenDescriptionResponse = { + description: string; +}; + +/** + * Suggested occurrence floors response + */ +export type SuggestedOccurrenceFloorsResponse = { + [chainId: string]: number; +}; + +/** + * Top gainers sort options + */ +export type TopGainersSortOption = + | 'm5_price_change_percentage_desc' + | 'h1_price_change_percentage_desc' + | 'h6_price_change_percentage_desc' + | 'h24_price_change_percentage_desc' + | 'm5_price_change_percentage_asc' + | 'h1_price_change_percentage_asc' + | 'h6_price_change_percentage_asc' + | 'h24_price_change_percentage_asc'; + +/** + * Token API Service + * + * SDK for interacting with MetaMask's Token API (token.api.cx.metamask.io). + * Provides methods for fetching token metadata, trending tokens, and network info. + */ +/** + * Method names exposed via BackendApiClient messenger + */ +export const TOKEN_API_METHODS = [ + // Supported Networks + 'getV1SupportedNetworks', + // Networks + 'getNetworks', + 'getNetworkByChainId', + // Token List + 'getTokenList', + // Token Metadata + 'getTokenMetadata', + 'getTokenDescription', + // Trending & Top Tokens (V3) + 'getV3TrendingTokens', + 'getV3TopGainers', + 'getV3PopularTokens', + // Top Assets + 'getTopAssets', + // Utility + 'getV1SuggestedOccurrenceFloors', +] as const; + +export class TokenApiService { + readonly #client: HttpClient; + + constructor(options: TokenApiServiceOptions = {}) { + this.#client = new HttpClient( + options.baseUrl ?? TOKEN_API_BASE_URL, + options, + ); + } + + // =========================================================================== + // Health Methods + // =========================================================================== + + /** + * Get service metadata + * + * @param signal - Optional abort signal + * @returns Service metadata + */ + async getServiceMetadata(signal?: AbortSignal): Promise { + return this.#client.get('/', { signal }); + } + + /** + * Get service health status + * + * @param signal - Optional abort signal + * @returns Health status + */ + async getHealth(signal?: AbortSignal): Promise<{ status: string }> { + return this.#client.get('/health', { signal }); + } + + /** + * Get service readiness status + * + * @param signal - Optional abort signal + * @returns Readiness status + */ + async getReadiness(signal?: AbortSignal): Promise<{ status: string }> { + return this.#client.get('/health/readiness', { signal }); + } + + // =========================================================================== + // Supported Networks Methods + // =========================================================================== + + /** + * Get supported networks (v1 endpoint) + * + * @param signal - Optional abort signal + * @returns Supported networks + */ + async getV1SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/supportedNetworks', { signal }); + } + + // =========================================================================== + // Network Methods + // =========================================================================== + + /** + * Get all networks + * + * @param signal - Optional abort signal + * @returns Array of network configurations + */ + async getNetworks(signal?: AbortSignal): Promise { + return this.#client.get('/networks', { signal }); + } + + /** + * Get network by chain ID + * + * @param chainId - Chain ID (decimal) + * @param signal - Optional abort signal + * @returns Network configuration + */ + async getNetworkByChainId( + chainId: number, + signal?: AbortSignal, + ): Promise { + return this.#client.get(`/networks/${chainId}`, { signal }); + } + + // =========================================================================== + // Token List Methods + // =========================================================================== + + /** + * Get token list for a chain + * + * @param chainId - Chain ID (decimal) + * @param options - Include options + * @param options.includeTokenFees - Whether to include token fees + * @param options.includeAssetType - Whether to include asset type + * @param options.includeAggregators - Whether to include aggregators + * @param options.includeERC20Permit - Whether to include ERC20 permit info + * @param options.includeOccurrences - Whether to include occurrences + * @param options.includeStorage - Whether to include storage info + * @param options.includeIconUrl - Whether to include icon URL + * @param options.includeAddress - Whether to include address + * @param options.includeName - Whether to include name + * @param signal - Optional abort signal + * @returns Array of token metadata + */ + async getTokenList( + chainId: number, + options?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + + if (options?.includeTokenFees !== undefined) { + params.append('includeTokenFees', String(options.includeTokenFees)); + } + if (options?.includeAssetType !== undefined) { + params.append('includeAssetType', String(options.includeAssetType)); + } + if (options?.includeAggregators !== undefined) { + params.append('includeAggregators', String(options.includeAggregators)); + } + if (options?.includeERC20Permit !== undefined) { + params.append('includeERC20Permit', String(options.includeERC20Permit)); + } + if (options?.includeOccurrences !== undefined) { + params.append('includeOccurrences', String(options.includeOccurrences)); + } + if (options?.includeStorage !== undefined) { + params.append('includeStorage', String(options.includeStorage)); + } + if (options?.includeIconUrl !== undefined) { + params.append('includeIconUrl', String(options.includeIconUrl)); + } + if (options?.includeAddress !== undefined) { + params.append('includeAddress', String(options.includeAddress)); + } + if (options?.includeName !== undefined) { + params.append('includeName', String(options.includeName)); + } + + const queryString = params.toString(); + return this.#client.get( + `/tokens/${chainId}${queryString ? `?${queryString}` : ''}`, + { signal }, + ); + } + + // =========================================================================== + // Token Metadata Methods + // =========================================================================== + + /** + * Get token metadata by address + * + * @param chainId - Chain ID (decimal) + * @param tokenAddress - Token contract address + * @param options - Include options + * @param options.includeTokenFees - Whether to include token fees + * @param options.includeAssetType - Whether to include asset type + * @param options.includeAggregators - Whether to include aggregators + * @param options.includeERC20Permit - Whether to include ERC20 permit info + * @param options.includeOccurrences - Whether to include occurrences + * @param options.includeStorage - Whether to include storage info + * @param options.includeIconUrl - Whether to include icon URL + * @param options.includeAddress - Whether to include address + * @param options.includeName - Whether to include name + * @param signal - Optional abort signal + * @returns Token metadata + */ + async getTokenMetadata( + chainId: number, + tokenAddress: string, + options?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + params.append('address', tokenAddress); + + if (options?.includeTokenFees !== undefined) { + params.append('includeTokenFees', String(options.includeTokenFees)); + } + if (options?.includeAssetType !== undefined) { + params.append('includeAssetType', String(options.includeAssetType)); + } + if (options?.includeAggregators !== undefined) { + params.append('includeAggregators', String(options.includeAggregators)); + } + if (options?.includeERC20Permit !== undefined) { + params.append('includeERC20Permit', String(options.includeERC20Permit)); + } + if (options?.includeOccurrences !== undefined) { + params.append('includeOccurrences', String(options.includeOccurrences)); + } + if (options?.includeStorage !== undefined) { + params.append('includeStorage', String(options.includeStorage)); + } + if (options?.includeIconUrl !== undefined) { + params.append('includeIconUrl', String(options.includeIconUrl)); + } + if (options?.includeAddress !== undefined) { + params.append('includeAddress', String(options.includeAddress)); + } + if (options?.includeName !== undefined) { + params.append('includeName', String(options.includeName)); + } + + try { + return await this.#client.get(`/token/${chainId}?${params.toString()}`, { + signal, + }); + } catch { + return undefined; + } + } + + /** + * Get token description + * + * @param chainId - Chain ID (decimal) + * @param tokenAddress - Token contract address + * @param signal - Optional abort signal + * @returns Token description + */ + async getTokenDescription( + chainId: number, + tokenAddress: string, + signal?: AbortSignal, + ): Promise { + try { + return await this.#client.get( + `/token/${chainId}/description?address=${tokenAddress}`, + { signal }, + ); + } catch { + return undefined; + } + } + + // =========================================================================== + // Trending & Top Tokens Methods (V3) + // =========================================================================== + + /** + * Get trending tokens (v3 endpoint) + * + * @param options - Trending tokens request options + * @param signal - Optional abort signal + * @returns Array of trending tokens + */ + async getV3TrendingTokens( + options: GetTrendingTokensOptions, + signal?: AbortSignal, + ): Promise { + const { + chainIds, + sortBy, + minLiquidity, + minVolume24hUsd, + maxVolume24hUsd, + minMarketCap, + maxMarketCap, + } = options; + + const params = new URLSearchParams(); + params.append('chainIds', chainIds.join(',')); + + if (sortBy) { + params.append('sort', sortBy); + } + if (minLiquidity !== undefined) { + params.append('minLiquidity', String(minLiquidity)); + } + if (minVolume24hUsd !== undefined) { + params.append('minVolume24hUsd', String(minVolume24hUsd)); + } + if (maxVolume24hUsd !== undefined) { + params.append('maxVolume24hUsd', String(maxVolume24hUsd)); + } + if (minMarketCap !== undefined) { + params.append('minMarketCap', String(minMarketCap)); + } + if (maxMarketCap !== undefined) { + params.append('maxMarketCap', String(maxMarketCap)); + } + + return this.#client.get(`/v3/tokens/trending?${params.toString()}`, { + signal, + }); + } + + /** + * Get top gainers/losers (v3 endpoint) + * + * @param chainIds - Array of CAIP-2 chain IDs + * @param options - Query options + * @param options.sort - Sort option for results + * @param options.blockRegion - Region filter (global or us) + * @param options.minLiquidity - Minimum liquidity threshold + * @param options.minVolume24hUsd - Minimum 24h volume in USD + * @param options.maxVolume24hUsd - Maximum 24h volume in USD + * @param options.minMarketCap - Minimum market cap + * @param options.maxMarketCap - Maximum market cap + * @param signal - Optional abort signal + * @returns Array of top gaining/losing tokens + */ + async getV3TopGainers( + chainIds: string[], + options?: { + sort?: TopGainersSortOption; + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + params.append('chainIds', chainIds.join(',')); + + if (options?.sort) { + params.append('sort', options.sort); + } + if (options?.blockRegion) { + params.append('blockRegion', options.blockRegion); + } + if (options?.minLiquidity !== undefined) { + params.append('minLiquidity', String(options.minLiquidity)); + } + if (options?.minVolume24hUsd !== undefined) { + params.append('minVolume24hUsd', String(options.minVolume24hUsd)); + } + if (options?.maxVolume24hUsd !== undefined) { + params.append('maxVolume24hUsd', String(options.maxVolume24hUsd)); + } + if (options?.minMarketCap !== undefined) { + params.append('minMarketCap', String(options.minMarketCap)); + } + if (options?.maxMarketCap !== undefined) { + params.append('maxMarketCap', String(options.maxMarketCap)); + } + + return this.#client.get(`/v3/tokens/top-gainers?${params.toString()}`, { + signal, + }); + } + + /** + * Get popular tokens (v3 endpoint) + * + * @param chainIds - Array of CAIP-2 chain IDs + * @param options - Query options + * @param options.blockRegion - Region filter (global or us) + * @param options.minLiquidity - Minimum liquidity threshold + * @param options.minVolume24hUsd - Minimum 24h volume in USD + * @param options.maxVolume24hUsd - Maximum 24h volume in USD + * @param options.minMarketCap - Minimum market cap + * @param options.maxMarketCap - Maximum market cap + * @param signal - Optional abort signal + * @returns Array of popular tokens + */ + async getV3PopularTokens( + chainIds: string[], + options?: { + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + signal?: AbortSignal, + ): Promise { + const params = new URLSearchParams(); + params.append('chainIds', chainIds.join(',')); + + if (options?.blockRegion) { + params.append('blockRegion', options.blockRegion); + } + if (options?.minLiquidity !== undefined) { + params.append('minLiquidity', String(options.minLiquidity)); + } + if (options?.minVolume24hUsd !== undefined) { + params.append('minVolume24hUsd', String(options.minVolume24hUsd)); + } + if (options?.maxVolume24hUsd !== undefined) { + params.append('maxVolume24hUsd', String(options.maxVolume24hUsd)); + } + if (options?.minMarketCap !== undefined) { + params.append('minMarketCap', String(options.minMarketCap)); + } + if (options?.maxMarketCap !== undefined) { + params.append('maxMarketCap', String(options.maxMarketCap)); + } + + return this.#client.get(`/v3/tokens/popular?${params.toString()}`, { + signal, + }); + } + + // =========================================================================== + // Top Assets Methods + // =========================================================================== + + /** + * Get top assets for a chain + * + * @param chainId - Chain ID (decimal) + * @param signal - Optional abort signal + * @returns Array of top assets + */ + async getTopAssets( + chainId: number, + signal?: AbortSignal, + ): Promise { + return this.#client.get(`/topAssets/${chainId}`, { signal }); + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Get suggested occurrence floors for all chains + * + * @param signal - Optional abort signal + * @returns Map of chainId to suggested occurrence floor + */ + async getV1SuggestedOccurrenceFloors( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/suggestedOccurrenceFloors', { signal }); + } +} diff --git a/packages/core-backend/src/api/TokensApiService-action-types.ts b/packages/core-backend/src/api/TokensApiService-action-types.ts new file mode 100644 index 00000000000..e5534402153 --- /dev/null +++ b/packages/core-backend/src/api/TokensApiService-action-types.ts @@ -0,0 +1,190 @@ +/** + * Messenger action types for TokensApiService + * + * Actions are namespaced as: BackendApiClient:Tokens:* + * This is for the Tokens API at tokens.api.cx.metamask.io + */ + +import type { + GetTokensSupportedNetworksV1Response, + GetTokensSupportedNetworksV2Response, + TokenDetails, + AssetByIdResponse, + TokensNetworkConfig, + TokensTopAsset, +} from './TokensApiService'; +import type { + TokenMetadata, + TokenSearchResponse, + TrendingToken, + GetTokenListOptions, + SearchTokensOptions, + GetTrendingTokensOptions, + GetTokenMetadataOptions, +} from './types'; + +// Using string literals directly in template types to avoid unused variable lint errors +type ServiceName = 'BackendApiClient'; +type Namespace = 'Tokens'; + +// ============================================================================= +// Supported Networks Actions +// ============================================================================= + +export type TokensGetV1SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV1SupportedNetworks`; + handler: () => Promise; +}; + +export type TokensGetV2SupportedNetworksAction = { + type: `${ServiceName}:${Namespace}:getV2SupportedNetworks`; + handler: () => Promise; +}; + +// ============================================================================= +// Token List Actions +// ============================================================================= + +export type TokensGetV1TokenListAction = { + type: `${ServiceName}:${Namespace}:getV1TokenList`; + handler: (options: GetTokenListOptions) => Promise; +}; + +// ============================================================================= +// Token Details Actions +// ============================================================================= + +export type TokensGetV1TokenMetadataAction = { + type: `${ServiceName}:${Namespace}:getV1TokenMetadata`; + handler: ( + options: GetTokenMetadataOptions, + ) => Promise; +}; + +export type TokensGetV1TokenDetailsAction = { + type: `${ServiceName}:${Namespace}:getV1TokenDetails`; + handler: ( + chainId: string, + tokenAddress: string, + options?: { + includeEnrichedData?: boolean; + includeCoingeckoId?: boolean; + includeAggregators?: boolean; + includeOccurrences?: boolean; + includeIconUrl?: boolean; + includeAssetType?: boolean; + includeTokenFees?: boolean; + includeHoneypotStatus?: boolean; + includeContractVerificationStatus?: boolean; + includeStorage?: boolean; + includeERC20Permit?: boolean; + includeDescription?: boolean; + includeCexData?: boolean; + }, + ) => Promise; +}; + +export type TokensGetV1TokensByAddressesAction = { + type: `${ServiceName}:${Namespace}:getV1TokensByAddresses`; + handler: ( + chainId: string, + tokenAddresses: string[], + ) => Promise; +}; + +// ============================================================================= +// Token Search Actions +// ============================================================================= + +export type TokensGetV1SearchTokensAction = { + type: `${ServiceName}:${Namespace}:getV1SearchTokens`; + handler: (options: SearchTokensOptions) => Promise; +}; + +export type TokensGetV1SearchTokensOnChainAction = { + type: `${ServiceName}:${Namespace}:getV1SearchTokensOnChain`; + handler: ( + chainId: string, + query: string, + options?: { limit?: number }, + ) => Promise; +}; + +// ============================================================================= +// V3 Assets Actions +// ============================================================================= + +export type TokensGetV3AssetsAction = { + type: `${ServiceName}:${Namespace}:getV3Assets`; + handler: ( + assetIds: string[], + options?: { + includeCoingeckoId?: boolean; + includeAggregators?: boolean; + includeOccurrences?: boolean; + includeIconUrl?: boolean; + }, + ) => Promise>; +}; + +// ============================================================================= +// Trending Tokens Actions +// ============================================================================= + +export type TokensGetV3TrendingTokensAction = { + type: `${ServiceName}:${Namespace}:getV3TrendingTokens`; + handler: (options: GetTrendingTokensOptions) => Promise; +}; + +// ============================================================================= +// Network Config Actions +// ============================================================================= + +export type TokensGetNetworkConfigAction = { + type: `${ServiceName}:${Namespace}:getNetworkConfig`; + handler: (chainId: string) => Promise; +}; + +export type TokensGetNetworkTokenStandardAction = { + type: `${ServiceName}:${Namespace}:getNetworkTokenStandard`; + handler: ( + chainId: string, + options?: { limit?: number }, + ) => Promise; +}; + +// ============================================================================= +// Top Assets Actions +// ============================================================================= + +export type TokensGetTopAssetsAction = { + type: `${ServiceName}:${Namespace}:getTopAssets`; + handler: (chainId: string) => Promise; +}; + +// ============================================================================= +// All Tokens API Actions +// ============================================================================= + +export type TokensApiActions = + // Supported Networks + | TokensGetV1SupportedNetworksAction + | TokensGetV2SupportedNetworksAction + // Token List + | TokensGetV1TokenListAction + // Token Details + | TokensGetV1TokenMetadataAction + | TokensGetV1TokenDetailsAction + | TokensGetV1TokensByAddressesAction + // Token Search + | TokensGetV1SearchTokensAction + | TokensGetV1SearchTokensOnChainAction + // V3 Assets + | TokensGetV3AssetsAction + // Trending Tokens + | TokensGetV3TrendingTokensAction + // Network Config + | TokensGetNetworkConfigAction + | TokensGetNetworkTokenStandardAction + // Top Assets + | TokensGetTopAssetsAction; diff --git a/packages/core-backend/src/api/TokensApiService.ts b/packages/core-backend/src/api/TokensApiService.ts new file mode 100644 index 00000000000..b679a1988db --- /dev/null +++ b/packages/core-backend/src/api/TokensApiService.ts @@ -0,0 +1,710 @@ +/** + * Tokens API Service for MetaMask (V2) + * + * Provides SDK methods for interacting with the Tokens API at tokens.api.cx.metamask.io + * Supports V2/V3 endpoints including CAIP-19 asset lookups, multi-chain search, and network configs. + * + * This is a plain service class. For Messenger integration, use BackendApiClient. + * + * @see https://tokens.dev-api.cx.metamask.io/docs-json + */ + +import { HttpClient } from './HttpClient'; +import type { + BaseApiServiceOptions, + TokenMetadata, + TokenSearchResponse, + TrendingToken, + GetTokenListOptions, + SearchTokensOptions, + GetTrendingTokensOptions, + GetTokenMetadataOptions, +} from './types'; + +/** + * Default Tokens API base URL + */ +export const TOKENS_API_BASE_URL = 'https://tokens.api.cx.metamask.io'; + +/** + * Tokens API Service Options + */ +export type TokensApiServiceOptions = BaseApiServiceOptions; + +/** + * Supported networks response (v1) + */ +export type GetTokensSupportedNetworksV1Response = { + fullSupport: number[]; + partialSupport: number[]; +}; + +/** + * Supported networks response (v2) - CAIP format + */ +export type GetTokensSupportedNetworksV2Response = { + fullSupport: string[]; + partialSupport: string[]; +}; + +/** + * Extended token details with enriched data + */ +export type TokenDetails = TokenMetadata & { + coingeckoId?: string; + type?: string; + isContractVerified?: boolean; + honeypotStatus?: { + honeypotIs: boolean; + goPlus: boolean; + }; + storage?: { + minFee: number; + avgFee: number; + maxFee: number; + }; + erc20Permit?: boolean; + description?: { + en: string; + }; + fees?: { + minFee: number; + avgFee: number; + maxFee: number; + }; + iconUrlThumbnail?: string; +}; + +/** + * Asset by CAIP-19 ID + */ +export type AssetByIdResponse = { + name: string; + symbol: string; + decimals: number; + address: string; + chainId: number | string; + iconUrl?: string; + iconUrlThumbnail?: string; + coingeckoId?: string; + occurrences?: number; + aggregators?: string[]; +}; + +/** + * Network configuration (Tokens API) + */ +export type TokensNetworkConfig = { + chainId: number; + chainName: string; + chainShortName: string; + evmCompatible: boolean; + nativeCurrency: { + name: string; + symbol: string; + decimals: number; + type: string; + iconUrl?: string; + coingeckoId?: string; + }; + wrappedNativeCurrency?: { + name: string; + symbol: string; + decimals: number; + type: string; + address: string; + iconUrl?: string; + coingeckoId?: string; + }; + iconUrl?: string; + rpcProviders?: { + providerName: string; + htmlUrl: string; + apiKeyRequired: boolean; + apiUrl: string; + }[]; + blockExplorer?: { + url: string; + apiUrl?: string; + name?: string; + }; + networkType?: string; + networkCategory?: string; + consensusMethod?: string; + coingeckoPlatformId?: string; + tokenSources?: string[]; +}; + +/** + * Top asset + */ +export type TokensTopAsset = { + address: string; + symbol: string; +}; + +/** + * Tokens API Service + * + * SDK for interacting with MetaMask's Tokens API (tokens.api.cx.metamask.io). + * Provides V2/V3 methods for fetching token metadata, CAIP-19 assets, + * and multi-chain token search. + */ +/** + * Method names exposed via BackendApiClient messenger + */ +export const TOKENS_API_METHODS = [ + // Supported Networks + 'getV1SupportedNetworks', + 'getV2SupportedNetworks', + // Token List + 'getV1TokenList', + // Token Details + 'getV1TokenMetadata', + 'getV1TokenDetails', + 'getV1TokensByAddresses', + // Token Search + 'getV1SearchTokens', + 'getV1SearchTokensOnChain', + // V3 Assets + 'getV3Assets', + // Trending Tokens + 'getV3TrendingTokens', + // Network Config + 'getNetworkConfig', + 'getNetworkTokenStandard', + // Top Assets + 'getTopAssets', +] as const; + +export class TokensApiService { + readonly #client: HttpClient; + + readonly #baseUrl: string; + + constructor(options: TokensApiServiceOptions = {}) { + this.#baseUrl = options.baseUrl ?? TOKENS_API_BASE_URL; + this.#client = new HttpClient(this.#baseUrl, options); + } + + // =========================================================================== + // Supported Networks Methods + // =========================================================================== + + /** + * Get supported networks (v1 endpoint) + * + * @param signal - Optional abort signal + * @returns Supported networks with full and partial support + */ + async getV1SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v1/supportedNetworks', { signal }); + } + + /** + * Get supported networks in CAIP format (v2 endpoint) + * + * @param signal - Optional abort signal + * @returns Supported networks as CAIP chain IDs + */ + async getV2SupportedNetworks( + signal?: AbortSignal, + ): Promise { + return this.#client.get('/v2/supportedNetworks', { signal }); + } + + // =========================================================================== + // Token List Methods + // =========================================================================== + + /** + * Get list of tokens for a chain + * + * @param options - Token list request options + * @param signal - Optional abort signal + * @returns Array of token metadata + */ + async getV1TokenList( + options: GetTokenListOptions, + signal?: AbortSignal, + ): Promise { + const { + chainId, + occurrenceFloor, + includeNativeAssets, + includeTokenFees, + includeAssetType, + includeERC20Permit, + includeStorage, + } = options; + + const params = new URLSearchParams(); + + if (occurrenceFloor !== undefined) { + params.append('occurrenceFloor', String(occurrenceFloor)); + } + if (includeNativeAssets !== undefined) { + params.append('includeNativeAssets', String(includeNativeAssets)); + } + if (includeTokenFees !== undefined) { + params.append('includeTokenFees', String(includeTokenFees)); + } + if (includeAssetType !== undefined) { + params.append('includeAssetType', String(includeAssetType)); + } + if (includeERC20Permit !== undefined) { + params.append('includeERC20Permit', String(includeERC20Permit)); + } + if (includeStorage !== undefined) { + params.append('includeStorage', String(includeStorage)); + } + + const queryString = params.toString(); + const chainIdDecimal = parseInt(chainId, 16); + + return this.#client.get( + `/tokens/${chainIdDecimal}${queryString ? `?${queryString}` : ''}`, + { signal }, + ); + } + + // =========================================================================== + // Token Details Methods + // =========================================================================== + + /** + * Get token metadata by address + * + * @param options - Token metadata request options + * @param signal - Optional abort signal + * @returns Token metadata or undefined if not found + */ + async getV1TokenMetadata( + options: GetTokenMetadataOptions, + signal?: AbortSignal, + ): Promise { + const { chainId, tokenAddress } = options; + const chainIdDecimal = parseInt(chainId, 16); + + try { + const response = await this.#client.get( + `/token/${chainIdDecimal}?address=${tokenAddress}`, + { signal }, + ); + + return response?.[0]; + } catch { + return undefined; + } + } + + /** + * Get detailed token information with enriched data + * + * @param chainId - Chain ID in hex format + * @param tokenAddress - Token contract address + * @param options - Include options + * @param options.includeEnrichedData - Whether to include enriched data + * @param options.includeCoingeckoId - Whether to include CoinGecko ID + * @param options.includeAggregators - Whether to include aggregators + * @param options.includeOccurrences - Whether to include occurrences + * @param options.includeIconUrl - Whether to include icon URL + * @param options.includeAssetType - Whether to include asset type + * @param options.includeTokenFees - Whether to include token fees + * @param options.includeHoneypotStatus - Whether to include honeypot status + * @param options.includeContractVerificationStatus - Whether to include contract verification status + * @param options.includeStorage - Whether to include storage info + * @param options.includeERC20Permit - Whether to include ERC20 permit info + * @param options.includeDescription - Whether to include description + * @param options.includeCexData - Whether to include CEX data + * @param signal - Optional abort signal + * @returns Token details with enriched data + */ + async getV1TokenDetails( + chainId: string, + tokenAddress: string, + options?: { + includeEnrichedData?: boolean; + includeCoingeckoId?: boolean; + includeAggregators?: boolean; + includeOccurrences?: boolean; + includeIconUrl?: boolean; + includeAssetType?: boolean; + includeTokenFees?: boolean; + includeHoneypotStatus?: boolean; + includeContractVerificationStatus?: boolean; + includeStorage?: boolean; + includeERC20Permit?: boolean; + includeDescription?: boolean; + includeCexData?: boolean; + }, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('address', tokenAddress); + + if (options?.includeEnrichedData !== undefined) { + params.append('includeEnrichedData', String(options.includeEnrichedData)); + } + if (options?.includeCoingeckoId !== undefined) { + params.append('includeCoingeckoId', String(options.includeCoingeckoId)); + } + if (options?.includeAggregators !== undefined) { + params.append('includeAggregators', String(options.includeAggregators)); + } + if (options?.includeOccurrences !== undefined) { + params.append('includeOccurrences', String(options.includeOccurrences)); + } + if (options?.includeIconUrl !== undefined) { + params.append('includeIconUrl', String(options.includeIconUrl)); + } + if (options?.includeAssetType !== undefined) { + params.append('includeAssetType', String(options.includeAssetType)); + } + if (options?.includeTokenFees !== undefined) { + params.append('includeTokenFees', String(options.includeTokenFees)); + } + if (options?.includeHoneypotStatus !== undefined) { + params.append( + 'includeHoneypotStatus', + String(options.includeHoneypotStatus), + ); + } + if (options?.includeContractVerificationStatus !== undefined) { + params.append( + 'includeContractVerificationStatus', + String(options.includeContractVerificationStatus), + ); + } + if (options?.includeStorage !== undefined) { + params.append('includeStorage', String(options.includeStorage)); + } + if (options?.includeERC20Permit !== undefined) { + params.append('includeERC20Permit', String(options.includeERC20Permit)); + } + if (options?.includeDescription !== undefined) { + params.append('includeDescription', String(options.includeDescription)); + } + if (options?.includeCexData !== undefined) { + params.append('includeCexData', String(options.includeCexData)); + } + + try { + const response = await this.#client.get( + `/token/${chainIdDecimal}?${params.toString()}`, + { signal }, + ); + return response?.[0]; + } catch { + return undefined; + } + } + + /** + * Get multiple tokens by addresses + * + * @param chainId - Chain ID in hex format + * @param tokenAddresses - Array of token addresses + * @param signal - Optional abort signal + * @returns Array of token details + */ + async getV1TokensByAddresses( + chainId: string, + tokenAddresses: string[], + signal?: AbortSignal, + ): Promise { + if (tokenAddresses.length === 0) { + return []; + } + + const chainIdDecimal = parseInt(chainId, 16); + return this.#client.get( + `/token/${chainIdDecimal}?addresses=${tokenAddresses.join(',')}`, + { signal }, + ); + } + + // =========================================================================== + // Token Search Methods + // =========================================================================== + + /** + * Search tokens across multiple chains + * + * @param options - Search options + * @param signal - Optional abort signal + * @returns Token search response + */ + async getV1SearchTokens( + options: SearchTokensOptions, + signal?: AbortSignal, + ): Promise { + const { chainIds, query, limit, includeMarketData } = options; + + const params = new URLSearchParams(); + params.append('chains', chainIds.join(',')); + params.append('query', query); + + if (limit !== undefined) { + params.append('limit', String(limit)); + } + if (includeMarketData !== undefined) { + params.append('includeMarketData', String(includeMarketData)); + } + + return this.#client.get(`/tokens/search?${params.toString()}`, { signal }); + } + + /** + * Search tokens on a specific chain + * + * @param chainId - Chain ID in hex format + * @param query - Search query + * @param options - Optional parameters + * @param options.limit - Maximum number of results to return + * @param signal - Optional abort signal + * @returns Array of matching tokens + */ + async getV1SearchTokensOnChain( + chainId: string, + query: string, + options?: { + limit?: number; + }, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + params.append('query', query); + + if (options?.limit !== undefined) { + params.append('limit', String(options.limit)); + } + + return this.#client.get( + `/tokens/${chainIdDecimal}/search?${params.toString()}`, + { signal }, + ); + } + + // =========================================================================== + // V3 Asset Methods (CAIP-19 based) + // =========================================================================== + + /** + * Get assets by CAIP-19 asset IDs (v3 endpoint) + * + * @param assetIds - Array of CAIP-19 asset IDs + * @param options - Include options + * @param options.includeCoingeckoId - Whether to include CoinGecko ID + * @param options.includeAggregators - Whether to include aggregators + * @param options.includeOccurrences - Whether to include occurrences + * @param options.includeIconUrl - Whether to include icon URL + * @param signal - Optional abort signal + * @returns Map of asset ID to asset details + * + * @example + * ```typescript + * const assets = await tokensApi.getV3Assets([ + * 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + * 'eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174', + * ]); + * ``` + */ + async getV3Assets( + assetIds: string[], + options?: { + includeCoingeckoId?: boolean; + includeAggregators?: boolean; + includeOccurrences?: boolean; + includeIconUrl?: boolean; + }, + signal?: AbortSignal, + ): Promise> { + if (assetIds.length === 0) { + return {}; + } + + const params = new URLSearchParams(); + params.append('assetIds', assetIds.join(',')); + + if (options?.includeCoingeckoId !== undefined) { + params.append('includeCoingeckoId', String(options.includeCoingeckoId)); + } + if (options?.includeAggregators !== undefined) { + params.append('includeAggregators', String(options.includeAggregators)); + } + if (options?.includeOccurrences !== undefined) { + params.append('includeOccurrences', String(options.includeOccurrences)); + } + if (options?.includeIconUrl !== undefined) { + params.append('includeIconUrl', String(options.includeIconUrl)); + } + + const response = await this.#client.get< + (AssetByIdResponse & { assetId?: string })[] + >(`/v3/assets?${params.toString()}`, { signal }); + + // Transform array response to object keyed by CAIP asset IDs + const result: Record = {}; + for (const asset of response) { + // Use assetId from response if available, otherwise construct from chainId/address + const assetId = + asset.assetId ?? + `eip155:${asset.chainId}/erc20:${asset.address.toLowerCase()}`; + result[assetId] = asset; + } + return result; + } + + // =========================================================================== + // Trending Token Methods (v3) + // =========================================================================== + + /** + * Get trending tokens (v3 endpoint) + * + * @param options - Trending tokens request options + * @param signal - Optional abort signal + * @returns Array of trending tokens + */ + async getV3TrendingTokens( + options: GetTrendingTokensOptions, + signal?: AbortSignal, + ): Promise { + const { + chainIds, + sortBy, + minLiquidity, + minVolume24hUsd, + maxVolume24hUsd, + minMarketCap, + maxMarketCap, + } = options; + + const params = new URLSearchParams(); + params.append('chains', chainIds.join(',')); + + if (sortBy) { + params.append('sortBy', sortBy); + } + if (minLiquidity !== undefined) { + params.append('minLiquidity', String(minLiquidity)); + } + if (minVolume24hUsd !== undefined) { + params.append('minVolume24hUsd', String(minVolume24hUsd)); + } + if (maxVolume24hUsd !== undefined) { + params.append('maxVolume24hUsd', String(maxVolume24hUsd)); + } + if (minMarketCap !== undefined) { + params.append('minMarketCap', String(minMarketCap)); + } + if (maxMarketCap !== undefined) { + params.append('maxMarketCap', String(maxMarketCap)); + } + + return this.#client.get(`/v3/tokens/trending?${params.toString()}`, { + signal, + }); + } + + // =========================================================================== + // Network Methods + // =========================================================================== + + /** + * Get network configuration + * + * @param chainId - Chain ID in hex format + * @param signal - Optional abort signal + * @returns Network configuration + */ + async getNetworkConfig( + chainId: string, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + + try { + const response = await this.#client.get( + `/networks/${chainIdDecimal}`, + { signal }, + ); + return response?.[0]; + } catch { + return undefined; + } + } + + /** + * Get token standard for a network + * + * @param chainId - Chain ID in hex format + * @param options - Query options + * @param options.limit - Maximum number of results to return + * @param signal - Optional abort signal + * @returns Token standard list + */ + async getNetworkTokenStandard( + chainId: string, + options?: { limit?: number }, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + const params = new URLSearchParams(); + + if (options?.limit !== undefined) { + params.append('limit', String(options.limit)); + } + + const queryString = params.toString(); + return this.#client.get( + `/networks/${chainIdDecimal}/tokenStandard${queryString ? `?${queryString}` : ''}`, + { signal }, + ); + } + + // =========================================================================== + // Top Assets Methods + // =========================================================================== + + /** + * Get top assets for a chain + * + * @param chainId - Chain ID in hex format + * @param signal - Optional abort signal + * @returns Array of top assets + */ + async getTopAssets( + chainId: string, + signal?: AbortSignal, + ): Promise { + const chainIdDecimal = parseInt(chainId, 16); + return this.#client.get(`/topAssets/${chainIdDecimal}`, { signal }); + } + + // =========================================================================== + // Icon Methods + // =========================================================================== + + /** + * Get token icon URL + * + * @param chainId - Chain ID in hex format + * @param tokenAddress - Token contract address + * @param type - Image type ('original.png' or 'thumbnail.png') + * @returns Icon URL + */ + getTokenIconUrl( + chainId: string, + tokenAddress: string, + type: 'original.png' | 'thumbnail.png' = 'original.png', + ): string { + const chainIdDecimal = parseInt(chainId, 16); + return `${this.#baseUrl}/icons/${chainIdDecimal}/${tokenAddress}/${type}`; + } +} diff --git a/packages/core-backend/src/api/index.ts b/packages/core-backend/src/api/index.ts new file mode 100644 index 00000000000..f7e66cd1b40 --- /dev/null +++ b/packages/core-backend/src/api/index.ts @@ -0,0 +1,228 @@ +/** + * MetaMask Internal API SDK + * + * This module exports SDK classes for interacting with MetaMask's internal APIs: + * - Token API (token.api.cx.metamask.io) + * - Tokens API (tokens.api.cx.metamask.io) + * - Accounts API (accounts.api.cx.metamask.io) + * - Price API (price.api.cx.metamask.io) + * + * @example + * ```typescript + * import { BackendApiClient } from '@metamask/core-backend'; + * + * // Create unified client with shared authentication + * const apiClient = new BackendApiClient({ + * clientProduct: 'metamask-extension', + * getBearerToken: async () => authController.getBearerToken(), + * }); + * + * // Access all APIs through the unified client + * const trending = await apiClient.token.getV3TrendingTokens({...}); + * const assets = await apiClient.tokens.getV3Assets([...]); + * const balances = await apiClient.accounts.getV2Balances({...}); + * const prices = await apiClient.prices.getV1TokenPrices({...}); + * ``` + */ + +// Backend API Client (unified wrapper) +export { BackendApiClient, createBackendApiClient } from './BackendApiClient'; +export type { + BackendApiClientOptions, + BackendApiClientMessenger, +} from './BackendApiClient'; + +// Combined BackendApiClient action types +export type { BackendApiClientActions } from './BackendApiClient-action-types'; + +// Accounts API action types +export type { + AccountsApiActions, + // Health & Utility + AccountsGetServiceMetadataAction, + AccountsGetHealthAction, + // Supported Networks + AccountsGetV1SupportedNetworksAction, + AccountsGetV2SupportedNetworksAction, + // Active Networks + AccountsGetV2ActiveNetworksAction, + // Balances + AccountsGetV2BalancesAction, + AccountsGetV2BalancesWithOptionsAction, + AccountsGetV4MultiAccountBalancesAction, + AccountsGetV5MultiAccountBalancesAction, + // Transactions + AccountsGetV1TransactionByHashAction, + AccountsGetV1AccountTransactionsAction, + AccountsGetV4MultiAccountTransactionsAction, + // Relationships + AccountsGetV1AccountRelationshipAction, + // NFTs + AccountsGetV2AccountNftsAction, + // Tokens + AccountsGetV2AccountTokensAction, +} from './AccountsApiService-action-types'; + +// Token API action types (token.api.cx.metamask.io) +export type { + TokenApiActions, + TokenGetV1SupportedNetworksAction, + TokenGetNetworksAction, + TokenGetNetworkByChainIdAction, + TokenGetTokenListAction, + TokenGetTokenMetadataAction, + TokenGetTokenDescriptionAction, + TokenGetV3TrendingTokensAction, + TokenGetV3TopGainersAction, + TokenGetV3PopularTokensAction, + TokenGetTopAssetsAction, + TokenGetV1SuggestedOccurrenceFloorsAction, +} from './TokenApiService-action-types'; + +// Tokens API action types (tokens.api.cx.metamask.io) +export type { + TokensApiActions, + TokensGetV1SupportedNetworksAction, + TokensGetV2SupportedNetworksAction, + TokensGetV1TokenListAction, + TokensGetV1TokenMetadataAction, + TokensGetV1TokenDetailsAction, + TokensGetV1TokensByAddressesAction, + TokensGetV1SearchTokensAction, + TokensGetV1SearchTokensOnChainAction, + TokensGetV3AssetsAction, + TokensGetV3TrendingTokensAction, + TokensGetNetworkConfigAction, + TokensGetNetworkTokenStandardAction, + TokensGetTopAssetsAction, +} from './TokensApiService-action-types'; + +// Price API action types +export type { + PricesApiActions, + PricesGetV1SupportedNetworksAction, + PricesGetV2SupportedNetworksAction, + PricesGetV1ExchangeRatesAction, + PricesGetV1FiatExchangeRatesAction, + PricesGetV1CryptoExchangeRatesAction, + PricesGetV1SpotPricesByCoinIdsAction, + PricesGetV1SpotPriceByCoinIdAction, + PricesGetV1TokenPricesAction, + PricesGetV1TokenPriceAction, + PricesGetV2SpotPricesAction, + PricesGetV3SpotPricesAction, + PricesGetV1HistoricalPricesByCoinIdAction, + PricesGetV1HistoricalPricesByTokenAddressesAction, + PricesGetV1HistoricalPricesAction, + PricesGetV3HistoricalPricesAction, + PricesGetV1HistoricalPriceGraphByCoinIdAction, + PricesGetV1HistoricalPriceGraphByTokenAddressAction, +} from './PriceApiService-action-types'; + +// HTTP Client +export { HttpClient, HttpError } from './HttpClient'; +export type { HttpRequestOptions } from './HttpClient'; + +// Token API Service (token.api.cx.metamask.io) +export { + TokenApiService, + TOKEN_API_BASE_URL, + TOKEN_API_METHODS, +} from './TokenApiService'; +export type { + TokenApiServiceOptions, + GetTokenSupportedNetworksResponse, + NetworkInfo, + TopAsset, + TokenDescriptionResponse, + SuggestedOccurrenceFloorsResponse, + TopGainersSortOption, +} from './TokenApiService'; + +// Tokens API Service (tokens.api.cx.metamask.io) +export { + TokensApiService, + TOKENS_API_BASE_URL, + TOKENS_API_METHODS, +} from './TokensApiService'; +export type { + TokensApiServiceOptions, + GetTokensSupportedNetworksV1Response, + GetTokensSupportedNetworksV2Response, + TokenDetails, + AssetByIdResponse, + TokensNetworkConfig, + TokensTopAsset, +} from './TokensApiService'; + +// Accounts API Service +export { AccountsApiService, ACCOUNTS_API_METHODS } from './AccountsApiService'; +export type { + AccountsApiServiceOptions, + GetV2ActiveNetworksResponse, + TransactionByHashResponse, + GetV4MultiAccountTransactionsResponse, + V5BalanceItem, + GetV5MultiAccountBalancesResponse, +} from './AccountsApiService'; + +// Price API Service +export { PriceApiService, PRICE_API_METHODS } from './PriceApiService'; +export type { + PriceApiServiceOptions, + GetV3SpotPricesResponse, + GetExchangeRatesWithInfoResponse, + GetPriceSupportedNetworksV1Response, + GetPriceSupportedNetworksV2Response, + CoinGeckoSpotPrice, + ExchangeRateInfo, + GetV3HistoricalPricesResponse, +} from './PriceApiService'; + +// Common Types +export type { + // Base types + ApiEnvironment, + BaseApiServiceOptions, + ApiErrorResponse, + PageInfo, + + // Token types + TokenMetadata, + TokenSearchResult, + TokenSearchResponse, + TrendingSortBy, + TrendingToken, + GetTokenListOptions, + SearchTokensOptions, + GetTrendingTokensOptions, + GetTokenMetadataOptions, + + // Accounts API types + AccountsApiBalance, + GetV2BalancesResponse, + GetV4MultiAccountBalancesResponse, + GetV1SupportedNetworksResponse, + GetV2SupportedNetworksResponse, + GetBalancesOptions, + GetMultiAccountBalancesOptions, + AccountTransaction, + GetAccountTransactionsResponse, + GetAccountTransactionsOptions, + AccountRelationshipResult, + GetAccountRelationshipOptions, + + // Price API types + MarketDataDetails, + GetTokenPricesResponse, + GetTokenPricesWithMarketDataResponse, + GetTokenPricesOptions, + GetExchangeRatesResponse, + SupportedCurrency, + GetHistoricalPricesOptions, + HistoricalPricePoint, + GetHistoricalPricesResponse, +} from './types'; + +// API Base URLs +export { ACCOUNTS_API_BASE_URL, PRICE_API_BASE_URL } from './types'; diff --git a/packages/core-backend/src/api/types.ts b/packages/core-backend/src/api/types.ts new file mode 100644 index 00000000000..d494268c12b --- /dev/null +++ b/packages/core-backend/src/api/types.ts @@ -0,0 +1,573 @@ +/** + * Common types for MetaMask internal API services + * + * These types match the API documentation at: + * - https://docs.cx.metamask.io/docs/apis/account-v2/api-reference/ + * - https://docs.cx.metamask.io/docs/apis/token/api-reference/ + * - https://docs.cx.metamask.io/docs/apis/token-v2/api-reference/ + * - https://docs.cx.metamask.io/docs/apis/price/api-reference/ + */ + +// ============================================================================= +// Common Types +// ============================================================================= + +/** + * API environment configuration + */ +export type ApiEnvironment = 'production' | 'development'; + +/** + * Base API service options + */ +export type BaseApiServiceOptions = { + /** Base URL for the API (optional, defaults to production) */ + baseUrl?: string; + /** Request timeout in milliseconds (default: 10000) */ + timeout?: number; + /** Function to get bearer token for authenticated requests */ + getBearerToken?: () => Promise; + /** Client product identifier (e.g., 'metamask-extension', 'metamask-mobile') */ + clientProduct?: string; +}; + +/** + * Common API error response + */ +export type ApiErrorResponse = { + error?: { + code: string; + message: string; + }; +}; + +/** + * Pagination info for paginated responses + */ +export type PageInfo = { + count: number; + hasNextPage: boolean; + cursor?: string; +}; + +// ============================================================================= +// Token API Types (v1, v2, v3) +// ============================================================================= + +export const TOKEN_API_BASE_URL = 'https://token.api.cx.metamask.io'; + +/** + * Token metadata from Token API v1 /tokens/{chainId} endpoint + */ +export type TokenMetadata = { + address: string; + symbol: string; + decimals: number; + name: string; + iconUrl?: string; + aggregators?: string[]; + occurrences?: number; +}; + +/** + * Token search result from Token API /tokens/search endpoint + */ +export type TokenSearchResult = { + address: string; + chainId: string; + symbol: string; + name: string; + decimals: number; + iconUrl?: string; + price?: string; + priceChange24h?: number; + marketCap?: number; + volume24h?: number; +}; + +/** + * Token search response from Token API /tokens/search endpoint + */ +export type TokenSearchResponse = { + count: number; + data: TokenSearchResult[]; + pageInfo?: PageInfo; +}; + +/** + * Sort options for trending tokens (v3) + */ +export type TrendingSortBy = + | 'm5_trending' + | 'h1_trending' + | 'h6_trending' + | 'h24_trending'; + +/** + * Trending token data from Token API v3 /tokens/trending endpoint + */ +export type TrendingToken = { + assetId: string; + name: string; + symbol: string; + decimals: number; + price: string; + aggregatedUsdVolume: number; + marketCap: number; + priceChangePct?: { + m5?: string; + m15?: string; + m30?: string; + h1?: string; + h6?: string; + h24?: string; + }; + labels?: string[]; +}; + +/** + * Token list request options for v1 /tokens/{chainId} endpoint + */ +export type GetTokenListOptions = { + /** Chain ID in hex format (e.g., '0x1') */ + chainId: string; + /** Minimum occurrence count (default: 3) */ + occurrenceFloor?: number; + /** Include native assets (default: false) */ + includeNativeAssets?: boolean; + /** Include token fees (default: false) */ + includeTokenFees?: boolean; + /** Include asset type (default: false) */ + includeAssetType?: boolean; + /** Include ERC20 permit info (default: false) */ + includeERC20Permit?: boolean; + /** Include storage info (default: false) */ + includeStorage?: boolean; +}; + +/** + * Token search request options for /tokens/search endpoint + */ +export type SearchTokensOptions = { + /** Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:mainnet') */ + chainIds: string[]; + /** Search query (token name, symbol, or address) */ + query: string; + /** Maximum number of results (default: 10) */ + limit?: number; + /** Include market data in results (default: false) */ + includeMarketData?: boolean; +}; + +/** + * Trending tokens request options for v3 /tokens/trending endpoint + */ +export type GetTrendingTokensOptions = { + /** Array of CAIP format chain IDs */ + chainIds: string[]; + /** Sort field */ + sortBy?: TrendingSortBy; + /** Minimum liquidity */ + minLiquidity?: number; + /** Minimum 24h volume in USD */ + minVolume24hUsd?: number; + /** Maximum 24h volume in USD */ + maxVolume24hUsd?: number; + /** Minimum market cap */ + minMarketCap?: number; + /** Maximum market cap */ + maxMarketCap?: number; +}; + +/** + * Token metadata request options for v1 /token/{chainId} endpoint + */ +export type GetTokenMetadataOptions = { + /** Chain ID in hex format */ + chainId: string; + /** Token contract address */ + tokenAddress: string; +}; + +// ============================================================================= +// Accounts API Types (v1, v2, v4) +// ============================================================================= + +export const ACCOUNTS_API_BASE_URL = 'https://accounts.api.cx.metamask.io'; + +/** + * Balance item from Accounts API v2/v4 endpoints + * Matches the actual API response structure + */ +export type AccountsApiBalance = { + /** Underlying object type. Always 'token' */ + object: string; + /** Token type: 'native' for native chain tokens (e.g., ETH, POL) */ + type?: string; + /** Timestamp (only provided for native chain tokens) */ + timestamp?: string; + /** Token contract address */ + address: string; + /** Token symbol */ + symbol: string; + /** Token name */ + name: string; + /** Token decimals */ + decimals: number; + /** Chain ID (decimal) */ + chainId: number; + /** Balance in decimal format (decimals adjusted), e.g., '123.456789' */ + balance: string; + /** CAIP-10 account address (for v4 API responses) */ + accountAddress?: string; +}; + +/** + * Response from Accounts API v2 /accounts/{address}/balances endpoint + */ +export type GetV2BalancesResponse = { + /** Total number of balances */ + count: number; + /** Array of balance items */ + balances: AccountsApiBalance[]; + /** Networks that failed to process. If no network is processed, returns HTTP 422 */ + unprocessedNetworks: number[]; +}; + +/** + * Response from Accounts API v4 /multiaccount/balances endpoint + */ +export type GetV4MultiAccountBalancesResponse = { + /** Total number of balances */ + count: number; + /** Array of balance items for all accounts */ + balances: AccountsApiBalance[]; + /** Networks that failed to process. If no network is processed, returns HTTP 422 */ + unprocessedNetworks: number[]; +}; + +/** + * Response from Accounts API v1 /supportedNetworks endpoint + */ +export type GetV1SupportedNetworksResponse = { + /** Supported network chain IDs (decimal) */ + supportedNetworks: number[]; +}; + +/** + * Response from Accounts API v2 /supportedNetworks endpoint + */ +export type GetV2SupportedNetworksResponse = { + /** Networks with full support */ + fullSupport: number[]; + /** Networks with partial support */ + partialSupport: { + balances: number[]; + }; +}; + +/** + * Get balances request options for v2 endpoint + */ +export type GetBalancesOptions = { + /** Account address */ + address: string; + /** Networks to query (decimal chain IDs) */ + networks?: number[]; +}; + +/** + * Get multi-account balances request options for v4 endpoint + */ +export type GetMultiAccountBalancesOptions = { + /** Account addresses in CAIP-10 format */ + accountAddresses: string[]; + /** Networks to query (decimal chain IDs) */ + networks?: number[]; +}; + +/** + * Account transaction from v1 /accounts/{address}/transactions endpoint + */ +export type AccountTransaction = { + hash: string; + timestamp: string; + chainId: number; + blockNumber: number; + blockHash: string; + gas: number; + gasUsed: number; + gasPrice: string; + effectiveGasPrice: string; + nonce: number; + cumulativeGasUsed: number; + methodId?: string; + value: string; + to: string; + from: string; + isError: boolean; + valueTransfers: { + contractAddress: string; + decimal: number; + symbol: string; + from: string; + to: string; + amount: string; + }[]; +}; + +/** + * Get account transactions response from v1 endpoint + */ +export type GetAccountTransactionsResponse = { + data: AccountTransaction[]; + pageInfo: PageInfo; +}; + +/** + * Get account transactions request options + */ +export type GetAccountTransactionsOptions = { + /** Account address */ + address: string; + /** Chain IDs in hex format (optional) */ + chainIds?: string[]; + /** Pagination cursor */ + cursor?: string; + /** End timestamp filter */ + endTimestamp?: number; + /** Sort direction */ + sortDirection?: 'ASC' | 'DESC'; + /** Start timestamp filter */ + startTimestamp?: number; +}; + +/** + * Account address relationship result from v1 endpoint + */ +export type AccountRelationshipResult = { + chainId?: number; + count?: number; + data?: { + hash: string; + timestamp: string; + chainId: number; + blockNumber: string; + blockHash: string; + gas: number; + gasUsed: number; + gasPrice: string; + effectiveGasPrice: number; + nonce: number; + cumulativeGasUsed: number; + methodId: string; + value: string; + to: string; + from: string; + }; + txHash?: string; + error?: { + code: string; + message: string; + }; +}; + +/** + * Get account relationship request options + */ +export type GetAccountRelationshipOptions = { + /** Chain ID (decimal) */ + chainId: number; + /** From address */ + from: string; + /** To address */ + to: string; +}; + +// ============================================================================= +// Price API Types (v1, v2, v3) +// ============================================================================= + +export const PRICE_API_BASE_URL = 'https://price.api.cx.metamask.io'; + +/** + * Market data details from Price API spot-prices endpoint + * Matches the actual API response structure + */ +export type MarketDataDetails = { + /** Current price in the requested currency */ + price: number; + /** Currency code (e.g., 'ETH', 'USD') */ + currency: string; + /** 24h price change amount */ + priceChange1d: number; + /** 24h price change percentage */ + pricePercentChange1d: number; + /** 1h price change percentage */ + pricePercentChange1h: number; + /** 7d price change percentage */ + pricePercentChange7d: number; + /** 14d price change percentage */ + pricePercentChange14d: number; + /** 30d price change percentage */ + pricePercentChange30d: number; + /** 200d price change percentage */ + pricePercentChange200d: number; + /** 1y price change percentage */ + pricePercentChange1y: number; + /** Market capitalization */ + marketCap: number; + /** Market cap 24h change percentage */ + marketCapPercentChange1d: number; + /** All-time high price */ + allTimeHigh: number; + /** All-time low price */ + allTimeLow: number; + /** 24h high price */ + high1d: number; + /** 24h low price */ + low1d: number; + /** Total trading volume */ + totalVolume: number; + /** Circulating supply */ + circulatingSupply: number; + /** Diluted market cap */ + dilutedMarketCap: number; +}; + +/** + * Response from Price API v1/v2 spot-prices endpoint (simple format) + * Returns a map of token address to currency-price pairs + */ +export type GetTokenPricesResponse = { + [tokenAddress: string]: { + [currency: string]: number; + }; +}; + +/** + * Response from Price API v3 spot-prices endpoint (with market data) + * Returns a map of CAIP asset ID to market data + */ +export type GetTokenPricesWithMarketDataResponse = { + [assetId: string]: MarketDataDetails; +}; + +/** + * Get token prices request options + */ +export type GetTokenPricesOptions = { + /** Chain ID in hex format */ + chainId: string; + /** Token addresses to get prices for */ + tokenAddresses: string[]; + /** Currency to get prices in (e.g., 'usd', 'eth') */ + currency?: string; + /** Include market data (24h change, market cap, etc.) */ + includeMarketData?: boolean; +}; + +/** + * Response from Price API exchange-rates endpoint + */ +export type GetExchangeRatesResponse = { + /** Map of currency code to rate */ + [currency: string]: number; +}; + +/** + * Supported currencies for Price API + * Matches the actual supported currencies list + */ +export type SupportedCurrency = + // Crypto + | 'btc' + | 'eth' + | 'ltc' + | 'bch' + | 'bnb' + | 'eos' + | 'xrp' + | 'xlm' + | 'link' + | 'dot' + | 'yfi' + // Fiat + | 'usd' + | 'aed' + | 'ars' + | 'aud' + | 'bdt' + | 'bhd' + | 'bmd' + | 'brl' + | 'cad' + | 'chf' + | 'clp' + | 'cny' + | 'czk' + | 'dkk' + | 'eur' + | 'gbp' + | 'gel' + | 'hkd' + | 'huf' + | 'idr' + | 'ils' + | 'inr' + | 'jpy' + | 'krw' + | 'kwd' + | 'lkr' + | 'mmk' + | 'mxn' + | 'myr' + | 'ngn' + | 'nok' + | 'nzd' + | 'php' + | 'pkr' + | 'pln' + | 'rub' + | 'sar' + | 'sek' + | 'sgd' + | 'thb' + | 'try' + | 'twd' + | 'uah' + | 'vef' + | 'vnd' + | 'zar'; + +/** + * Get historical prices request options + */ +export type GetHistoricalPricesOptions = { + /** Chain ID in hex format */ + chainId: string; + /** Token address */ + tokenAddress: string; + /** Currency for prices */ + currency?: string; + /** Time range: '1d', '7d', '30d', '90d', '1y', 'all' */ + timeRange?: '1d' | '7d' | '30d' | '90d' | '1y' | 'all'; +}; + +/** + * Historical price data point + */ +export type HistoricalPricePoint = { + /** Timestamp in milliseconds */ + timestamp: number; + /** Price at this timestamp */ + price: number; +}; + +/** + * Get historical prices response + */ +export type GetHistoricalPricesResponse = { + /** Array of price data points */ + prices: HistoricalPricePoint[]; +}; diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index e77bc517a75..41a0dceb8f8 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -1,5 +1,9 @@ /** * @file Backend platform services for MetaMask. + * + * This package provides: + * - Real-time WebSocket services for account activity monitoring + * - SDK for MetaMask internal APIs (Token, Tokens, Accounts, Price) */ // Transaction and balance update types @@ -24,6 +28,10 @@ export type { BackendWebSocketServiceConnectionStateChangedEvent, WebSocketState, WebSocketEventType, + ServerNotificationMessage, + ClientRequestMessage, + ServerResponseMessage, + ChannelCallback, } from './BackendWebSocketService'; export { BackendWebSocketService } from './BackendWebSocketService'; @@ -40,3 +48,202 @@ export type { AccountActivityServiceMessenger, } from './AccountActivityService'; export { AccountActivityService } from './AccountActivityService'; + +// ============================================================================= +// Internal API SDK +// ============================================================================= + +// Backend API Client (unified wrapper) +export { BackendApiClient, createBackendApiClient } from './api'; +export type { BackendApiClientOptions, BackendApiClientMessenger } from './api'; + +// Combined BackendApiClient action types +export type { BackendApiClientActions } from './api'; + +// Accounts API action types +export type { + AccountsApiActions, + // Health & Utility + AccountsGetServiceMetadataAction, + AccountsGetHealthAction, + // Supported Networks + AccountsGetV1SupportedNetworksAction, + AccountsGetV2SupportedNetworksAction, + // Active Networks + AccountsGetV2ActiveNetworksAction, + // Balances + AccountsGetV2BalancesAction, + AccountsGetV2BalancesWithOptionsAction, + AccountsGetV4MultiAccountBalancesAction, + AccountsGetV5MultiAccountBalancesAction, + // Transactions + AccountsGetV1TransactionByHashAction, + AccountsGetV1AccountTransactionsAction, + AccountsGetV4MultiAccountTransactionsAction, + // Relationships + AccountsGetV1AccountRelationshipAction, + // NFTs + AccountsGetV2AccountNftsAction, + // Tokens + AccountsGetV2AccountTokensAction, +} from './api'; + +// Token API action types (token.api.cx.metamask.io) +export type { + TokenApiActions, + TokenGetV1SupportedNetworksAction, + TokenGetNetworksAction, + TokenGetNetworkByChainIdAction, + TokenGetTokenListAction, + TokenGetTokenMetadataAction, + TokenGetTokenDescriptionAction, + TokenGetV3TrendingTokensAction, + TokenGetV3TopGainersAction, + TokenGetV3PopularTokensAction, + TokenGetTopAssetsAction, + TokenGetV1SuggestedOccurrenceFloorsAction, +} from './api'; + +// Tokens API action types (tokens.api.cx.metamask.io) +export type { + TokensApiActions, + TokensGetV1SupportedNetworksAction, + TokensGetV2SupportedNetworksAction, + TokensGetV1TokenListAction, + TokensGetV1TokenMetadataAction, + TokensGetV1TokenDetailsAction, + TokensGetV1TokensByAddressesAction, + TokensGetV1SearchTokensAction, + TokensGetV1SearchTokensOnChainAction, + TokensGetV3AssetsAction, + TokensGetV3TrendingTokensAction, + TokensGetNetworkConfigAction, + TokensGetNetworkTokenStandardAction, + TokensGetTopAssetsAction, +} from './api'; + +// Price API action types +export type { + PricesApiActions, + PricesGetV1SupportedNetworksAction, + PricesGetV2SupportedNetworksAction, + PricesGetV1ExchangeRatesAction, + PricesGetV1FiatExchangeRatesAction, + PricesGetV1CryptoExchangeRatesAction, + PricesGetV1SpotPricesByCoinIdsAction, + PricesGetV1SpotPriceByCoinIdAction, + PricesGetV1TokenPricesAction, + PricesGetV1TokenPriceAction, + PricesGetV2SpotPricesAction, + PricesGetV3SpotPricesAction, + PricesGetV1HistoricalPricesByCoinIdAction, + PricesGetV1HistoricalPricesByTokenAddressesAction, + PricesGetV1HistoricalPricesAction, + PricesGetV3HistoricalPricesAction, + PricesGetV1HistoricalPriceGraphByCoinIdAction, + PricesGetV1HistoricalPriceGraphByTokenAddressAction, +} from './api'; + +// HTTP Client +export { HttpClient, HttpError } from './api'; +export type { HttpRequestOptions } from './api'; + +// Token API Service (token.api.cx.metamask.io) +export { TokenApiService, TOKEN_API_BASE_URL, TOKEN_API_METHODS } from './api'; +export type { + TokenApiServiceOptions, + GetTokenSupportedNetworksResponse, + NetworkInfo, + TopAsset, + TokenDescriptionResponse, + SuggestedOccurrenceFloorsResponse, + TopGainersSortOption, +} from './api'; + +// Tokens API Service (tokens.api.cx.metamask.io) +export { + TokensApiService, + TOKENS_API_BASE_URL, + TOKENS_API_METHODS, +} from './api'; +export type { + TokensApiServiceOptions, + GetTokensSupportedNetworksV1Response, + GetTokensSupportedNetworksV2Response, + TokenDetails, + AssetByIdResponse, + TokensNetworkConfig, + TokensTopAsset, +} from './api'; + +// Accounts API Service +export { AccountsApiService, ACCOUNTS_API_METHODS } from './api'; +export type { + AccountsApiServiceOptions, + GetV2ActiveNetworksResponse, + TransactionByHashResponse, + GetV4MultiAccountTransactionsResponse, + V5BalanceItem, + GetV5MultiAccountBalancesResponse, +} from './api'; + +// Price API Service +export { PriceApiService, PRICE_API_METHODS } from './api'; +export type { + PriceApiServiceOptions, + GetV3SpotPricesResponse, + GetExchangeRatesWithInfoResponse, + GetPriceSupportedNetworksV1Response, + GetPriceSupportedNetworksV2Response, + CoinGeckoSpotPrice, + ExchangeRateInfo, + GetV3HistoricalPricesResponse, +} from './api'; + +// API Types +export type { + // Base types + ApiEnvironment, + BaseApiServiceOptions, + ApiErrorResponse, + PageInfo, + + // Token types + TokenMetadata, + TokenSearchResult, + TokenSearchResponse, + TrendingSortBy, + TrendingToken, + GetTokenListOptions, + SearchTokensOptions, + GetTrendingTokensOptions, + GetTokenMetadataOptions, + + // Accounts API types + AccountsApiBalance, + GetV2BalancesResponse, + GetV4MultiAccountBalancesResponse, + GetV1SupportedNetworksResponse, + GetV2SupportedNetworksResponse, + GetBalancesOptions, + GetMultiAccountBalancesOptions, + AccountTransaction, + GetAccountTransactionsResponse, + GetAccountTransactionsOptions, + AccountRelationshipResult, + GetAccountRelationshipOptions, + + // Price API types + MarketDataDetails, + GetTokenPricesResponse, + GetTokenPricesWithMarketDataResponse, + GetTokenPricesOptions, + GetExchangeRatesResponse, + SupportedCurrency, + GetHistoricalPricesOptions, + HistoricalPricePoint, + GetHistoricalPricesResponse, +} from './api'; + +// API Base URLs +export { ACCOUNTS_API_BASE_URL, PRICE_API_BASE_URL } from './api';