diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 4dab50746a..9a4c4e67ec 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `AccountsApiDataSource` now selects the Accounts API balances endpoint version from the `RemoteFeatureFlagController` (`assetsAccountsApiV6` flag, read per fetch off the shared messenger, default v5) so the v6 endpoint is gated consistently across clients (extension, mobile) without each client wiring a getter. The flag is read as a JSON variation shaped `{ value: boolean }` (same shape as `backendWebSocketConnection`). Adds a required `messenger` option and `RemoteFeatureFlagController:getState` to `AccountsApiDataSourceAllowedActions`. Only `category: 'token'` rows from the v6 response are consumed (DeFi positions are ignored) to preserve parity with v5 ([#9344](https://github.com/MetaMask/core/pull/9344)) +- Add `getAsset(accountId, assetId)` method and `AssetsController:getAsset` messenger action that returns the combined `Asset` (balance, metadata, price) for a single account/asset pair from controller state, or `undefined` when a complete renderable asset is not available ([#9521](https://github.com/MetaMask/core/pull/9521)) ### Changed diff --git a/packages/assets-controller/src/AssetsController-method-action-types.ts b/packages/assets-controller/src/AssetsController-method-action-types.ts index 8cdd37b170..f886b37b4d 100644 --- a/packages/assets-controller/src/AssetsController-method-action-types.ts +++ b/packages/assets-controller/src/AssetsController-method-action-types.ts @@ -20,6 +20,29 @@ export type AssetsControllerGetAssetMetadataAction = { handler: AssetsController['getAssetMetadata']; }; +/** + * Get a single combined asset (balance + metadata + price + computed + * `fiatValue`) for an account directly from controller state. + * + * Reuses the same state-composition and filtering logic as `getAssets` + * (balance and metadata are required, a missing price falls back to + * `{ price: 0, lastUpdated: 0 }` with `fiatValue: 0`, and hidden or + * otherwise filtered assets are excluded) so the returned shape never + * drifts from `getAssets`. Reads from current state only and does not + * trigger a data-source refresh. + * + * @param accountId - The account ID (`InternalAccount.id`, not an address). + * @param assetId - The CAIP-19 asset ID including chain scope + * (e.g. `eip155:1/erc20:0x...`). + * @returns The combined `Asset`, or `undefined` when no complete + * renderable asset (balance + metadata) exists for the account/asset pair. + * @throws If `accountId` is empty or `assetId` is not a valid CAIP-19 asset ID. + */ +export type AssetsControllerGetAssetAction = { + type: `AssetsController:getAsset`; + handler: AssetsController['getAsset']; +}; + export type AssetsControllerGetAssetsPriceAction = { type: `AssetsController:getAssetsPrice`; handler: AssetsController['getAssetsPrice']; @@ -132,6 +155,7 @@ export type AssetsControllerMethodActions = | AssetsControllerGetAssetsAction | AssetsControllerGetAssetsBalanceAction | AssetsControllerGetAssetMetadataAction + | AssetsControllerGetAssetAction | AssetsControllerGetAssetsPriceAction | AssetsControllerGetExchangeRatesForBridgeAction | AssetsControllerGetStateForTransactionPayAction diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 7c1735712f..0dce0bb3b1 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -875,13 +875,161 @@ describe('AssetsController', () => { }); }); + describe('getAsset', () => { + const metadata = { + type: 'erc20' as const, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }; + + it('returns the combined asset with computed fiatValue', async () => { + const initialState: Partial = { + assetsInfo: { [MOCK_ASSET_ID]: metadata }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } }, + }, + assetsPrice: { [MOCK_ASSET_ID]: { price: 2, lastUpdated: 123 } }, + }; + + await withController({ state: initialState }, ({ controller }) => { + const asset = controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + expect(asset).toStrictEqual({ + id: MOCK_ASSET_ID, + chainId: 'eip155:1', + balance: { amount: '100' }, + metadata, + price: { price: 2, lastUpdated: 123 }, + fiatValue: 200, + }); + }); + }); + + it('normalizes a lowercase EVM asset ID before lookup', async () => { + const initialState: Partial = { + assetsInfo: { [MOCK_ASSET_ID]: metadata }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '1' } }, + }, + assetsPrice: { [MOCK_ASSET_ID]: { price: 1, lastUpdated: 1 } }, + }; + + await withController({ state: initialState }, ({ controller }) => { + const asset = controller.getAsset( + MOCK_ACCOUNT_ID, + MOCK_ASSET_ID_LOWERCASE, + ); + + expect(asset?.id).toBe(MOCK_ASSET_ID); + }); + }); + + it('returns undefined when the balance is missing', async () => { + const initialState: Partial = { + assetsInfo: { [MOCK_ASSET_ID]: metadata }, + }; + + await withController({ state: initialState }, ({ controller }) => { + expect( + controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID), + ).toBeUndefined(); + }); + }); + + it('returns undefined when the metadata is missing', async () => { + const initialState: Partial = { + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } }, + }, + }; + + await withController({ state: initialState }, ({ controller }) => { + expect( + controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID), + ).toBeUndefined(); + }); + }); + + it('falls back to a zero price and fiatValue when the price is missing', async () => { + const initialState: Partial = { + assetsInfo: { [MOCK_ASSET_ID]: metadata }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } }, + }, + }; + + await withController({ state: initialState }, ({ controller }) => { + const asset = controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID); + + expect(asset?.price).toStrictEqual({ price: 0, lastUpdated: 0 }); + expect(asset?.fiatValue).toBe(0); + }); + }); + + it('returns undefined for a hidden asset', async () => { + const initialState: Partial = { + assetsInfo: { [MOCK_ASSET_ID]: metadata }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } }, + }, + assetPreferences: { [MOCK_ASSET_ID]: { hidden: true } }, + }; + + await withController({ state: initialState }, ({ controller }) => { + expect( + controller.getAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID), + ).toBeUndefined(); + }); + }); + + it('throws when accountId is empty', async () => { + await withController(({ controller }) => { + expect(() => + controller.getAsset('' as AccountId, MOCK_ASSET_ID), + ).toThrow('accountId must be a non-empty string'); + }); + }); + + it('throws when assetId is not a valid CAIP-19 asset ID', async () => { + await withController(({ controller }) => { + expect(() => + controller.getAsset( + MOCK_ACCOUNT_ID, + 'not-a-caip-19' as Caip19AssetId, + ), + ).toThrow('invalid CAIP-19 assetId'); + }); + }); + + it('is exposed as the AssetsController:getAsset messenger action', async () => { + const initialState: Partial = { + assetsInfo: { [MOCK_ASSET_ID]: metadata }, + assetsBalance: { + [MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '100' } }, + }, + assetsPrice: { [MOCK_ASSET_ID]: { price: 2, lastUpdated: 123 } }, + }; + + await withController({ state: initialState }, ({ messenger }) => { + const asset = messenger.call( + 'AssetsController:getAsset', + MOCK_ACCOUNT_ID, + MOCK_ASSET_ID, + ); + + expect(asset?.fiatValue).toBe(200); + }); + }); + }); + describe('getAssets', () => { it('returns empty object when no balances exist', async () => { await withController(async ({ controller }) => { const accounts = [createMockInternalAccount()]; const assets = await controller.getAssets(accounts); - expect(assets[MOCK_ACCOUNT_ID]).toStrictEqual({}); + expect(assets[MOCK_ACCOUNT_ID]).toEqual({}); }); }); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index b630773430..0ed051dcce 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -182,6 +182,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getAssets', 'getAssetsBalance', 'getAssetMetadata', + 'getAsset', 'getAssetsPrice', 'getExchangeRatesForBridge', 'getStateForTransactionPay', @@ -1779,6 +1780,45 @@ export class AssetsController extends BaseController< return this.state.assetsInfo[assetId] as AssetMetadata | undefined; } + /** + * Get a single combined asset (balance + metadata + price + computed + * `fiatValue`) for an account directly from controller state. + * + * Reuses the same state-composition and filtering logic as `getAssets` + * (balance and metadata are required, a missing price falls back to + * `{ price: 0, lastUpdated: 0 }` with `fiatValue: 0`, and hidden or + * otherwise filtered assets are excluded) so the returned shape never + * drifts from `getAssets`. Reads from current state only and does not + * trigger a data-source refresh. + * + * @param accountId - The account ID (`InternalAccount.id`, not an address). + * @param assetId - The CAIP-19 asset ID including chain scope + * (e.g. `eip155:1/erc20:0x...`). + * @returns The combined `Asset`, or `undefined` when no complete + * renderable asset (balance + metadata) exists for the account/asset pair. + * @throws If `accountId` is empty or `assetId` is not a valid CAIP-19 asset ID. + */ + getAsset(accountId: AccountId, assetId: Caip19AssetId): Asset | undefined { + if (typeof accountId !== 'string' || accountId.length === 0) { + throw new Error( + 'AssetsController.getAsset: accountId must be a non-empty string', + ); + } + + let normalizedAssetId: Caip19AssetId; + try { + normalizedAssetId = normalizeAssetId(assetId); + } catch { + throw new Error( + `AssetsController.getAsset: invalid CAIP-19 assetId "${assetId}"`, + ); + } + + return this.#getAssetFromState(accountId, normalizedAssetId, { + assetTypeSet: new Set(['fungible']), + }); + } + async getAssetsPrice( accounts: InternalAccount[], options?: { @@ -2702,84 +2742,120 @@ export class AssetsController extends BaseController< } #getAssetsFromState( - accounts: InternalAccount[], + accounts: Pick[], chainIds: ChainId[], assetTypes: AssetType[], ): Record> { - const result: Record> = {}; + const result = Object.create(null) as Record< + AccountId, + Record + >; // Convert to Sets for O(1) lookups const chainIdSet = new Set(chainIds); const assetTypeSet = new Set(assetTypes); for (const account of accounts) { - result[account.id] = {}; + result[account.id] = Object.create(null) as Record; const accountBalances = this.state.assetsBalance[account.id] ?? {}; - for (const [assetId, balance] of Object.entries(accountBalances)) { + for (const assetId of Object.keys(accountBalances)) { const typedAssetId = assetId as Caip19AssetId; - const metadataRaw = this.state.assetsInfo[typedAssetId]; + const asset = this.#getAssetFromState(account.id, typedAssetId, { + chainIdSet, + assetTypeSet, + }); - // Skip assets without metadata - if (!metadataRaw) { - continue; + if (asset) { + result[account.id][typedAssetId] = asset; } + } + } - const metadata = metadataRaw; + return result; + } - // Skip hidden assets (assetPreferences) - const prefs = this.state.assetPreferences[typedAssetId]; - if (prefs?.hidden) { - continue; - } + /** + * Compose a single combined `Asset` (balance + metadata + price + computed + * `fiatValue`) for an account/asset pair directly from controller state, + * applying the same filtering rules as `#getAssetsFromState`. + * + * @param accountId - The account ID (`InternalAccount.id`). + * @param assetId - The normalized CAIP-19 asset ID. + * @param filters - Optional filters applied before composing the asset. + * @param filters.chainIdSet - When provided, the asset's chain must be in this set. + * @param filters.assetTypeSet - When provided, the asset's type must be in this set. + * @returns The combined `Asset`, or `undefined` when the asset is missing a + * balance/metadata, is hidden/filtered out, or fails a provided filter. + */ + #getAssetFromState( + accountId: AccountId, + assetId: Caip19AssetId, + filters?: { + chainIdSet?: Set; + assetTypeSet?: Set; + }, + ): Asset | undefined { + const balance = this.state.assetsBalance[accountId]?.[assetId]; - const assetChainId = extractChainId(typedAssetId); + // Skip assets without a balance entry + if (!balance) { + return undefined; + } - // Skip native tokens on Tempo networks - if (this.#shouldHideNativeToken(assetChainId, metadata)) { - continue; - } + const metadata = this.state.assetsInfo[assetId]; - if (!chainIdSet.has(assetChainId)) { - continue; - } + // Skip assets without metadata + if (!metadata) { + return undefined; + } - // Filter by asset type - const tokenAssetType = this.#tokenStandardToAssetType(metadata.type); - if (!assetTypeSet.has(tokenAssetType)) { - continue; - } + // Skip hidden assets (assetPreferences) + const prefs = this.state.assetPreferences[assetId]; + if (prefs?.hidden) { + return undefined; + } - const typedBalance = balance; - const priceRaw = this.state.assetsPrice[typedAssetId]; - const price: AssetPrice = priceRaw ?? { - price: 0, - lastUpdated: 0, - }; + const assetChainId = extractChainId(assetId); - // Compute fiat value using BigNumber for precision - // Note: typedBalance.amount is already in human-readable format (e.g., "1" for 1 ETH) - // so we do NOT divide by 10^decimals here - const balanceAmount = new BigNumberJS(typedBalance.amount || '0'); - const fiatValue = balanceAmount - .multipliedBy(price.price || 0) - .toNumber(); - - const asset: Asset = { - id: typedAssetId, - chainId: assetChainId, - balance: typedBalance, - metadata, - price, - fiatValue, - }; + // Skip native tokens on Tempo networks + if (this.#shouldHideNativeToken(assetChainId, metadata)) { + return undefined; + } + + if (filters?.chainIdSet && !filters.chainIdSet.has(assetChainId)) { + return undefined; + } - result[account.id][typedAssetId] = asset; + // Filter by asset type + if (filters?.assetTypeSet) { + const tokenAssetType = this.#tokenStandardToAssetType(metadata.type); + if (!filters.assetTypeSet.has(tokenAssetType)) { + return undefined; } } - return result; + const priceRaw = this.state.assetsPrice[assetId]; + const price: AssetPrice = priceRaw ?? { + price: 0, + lastUpdated: 0, + }; + + // Compute fiat value using BigNumber for precision + // Note: balance.amount is already in human-readable format (e.g., "1" for 1 ETH) + // so we do NOT divide by 10^decimals here + const balanceAmount = new BigNumberJS(balance.amount || '0'); + const fiatValue = balanceAmount.multipliedBy(price.price || 0).toNumber(); + + return { + id: assetId, + chainId: assetChainId, + balance, + metadata, + price, + fiatValue, + }; } /** @@ -3675,6 +3751,7 @@ export class AssetsController extends BaseController< this.messenger.unregisterActionHandler('AssetsController:getAssets'); this.messenger.unregisterActionHandler('AssetsController:getAssetsBalance'); this.messenger.unregisterActionHandler('AssetsController:getAssetMetadata'); + this.messenger.unregisterActionHandler('AssetsController:getAsset'); this.messenger.unregisterActionHandler('AssetsController:getAssetsPrice'); this.messenger.unregisterActionHandler( 'AssetsController:getExchangeRatesForBridge', diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 1ebe4be876..4f69c98951 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -31,6 +31,7 @@ export type { AssetsControllerGetAssetsAction, AssetsControllerGetAssetsBalanceAction, AssetsControllerGetAssetMetadataAction, + AssetsControllerGetAssetAction, AssetsControllerGetAssetsPriceAction, AssetsControllerAddCustomAssetAction, AssetsControllerRemoveCustomAssetAction,