diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index c5fe54b01cb..a69ebf5f557 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountsApiClient` support for the Accounts API `/v6/multiaccount/balances` endpoint via `fetchV6MultiAccountBalances` / `getV6MultiAccountBalancesQueryOptions`, returning token balances plus optional DeFi positions and spot prices (new types `V6BalancesResponse`, `V6AccountBalancesEntry`, `V6BalanceItem`, `V6BalanceMetadata`, `V6TokenMetadata`, `V6VsCurrency`) ([#9302](https://github.com/MetaMask/core/pull/9302)) + ## [6.4.0] ### Changed diff --git a/packages/core-backend/README.md b/packages/core-backend/README.md index cc1d8945bd1..c42d3a7aaf9 100644 --- a/packages/core-backend/README.md +++ b/packages/core-backend/README.md @@ -463,6 +463,7 @@ Handles account-related operations including balances, transactions, NFTs, and t | `fetchV2Balances(address, queryOptions?, options?)` | Get balances for single address (supports networks, filterSupportedTokens, includeTokenAddresses, includeStakedAssets) | | `fetchV4MultiAccountBalances(addresses, queryOptions?, options?)` | Get balances for multiple addresses | | `fetchV5MultiAccountBalances(accountIds, queryOptions?, options?)` | Get balances using CAIP-10 IDs | +| `fetchV6MultiAccountBalances(accountIds, queryOptions?, options?)` | Get balances + DeFi positions + spot prices using CAIP-10 IDs | | `fetchV1TransactionByHash(chainId, txHash, queryOptions?, options?)` | Get transaction by hash | | `fetchV1AccountTransactions(address, queryOptions?, options?)` | Get account transactions | | `fetchV4MultiAccountTransactions(accountAddresses, queryOptions?, options?)` | Get multi-account transactions | diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts index 14cf3b07ccf..bdd5ec32367 100644 --- a/packages/core-backend/src/api/accounts/client.test.ts +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -14,6 +14,7 @@ import type { V2SupportedNetworksResponse, V2BalancesResponse, V5BalancesResponse, + V6BalancesResponse, } from './types'; describe('AccountsApiClient', () => { @@ -251,6 +252,89 @@ describe('AccountsApiClient', () => { }); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('fetches v6 multi-account balances with token and defi rows', async () => { + const mockResponse: V6BalancesResponse = { + unprocessedNetworks: ['eip155:1329'], + unprocessedIncludeAssetIds: ['eip155:1/erc20:0xabc'], + accounts: [ + { + accountId: 'eip155:1:0x123', + balances: [ + { + category: 'token', + assetId: 'eip155:1/erc20:0xc02aaa39', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: '0.283549083429656057', + price: '2119.66', + }, + { + category: 'token', + assetId: + 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + name: 'USD Coin', + symbol: 'USDC', + decimals: 7, + balance: '10.5', + metadata: { + limit: '9223372036854775807', + authorized: true, + }, + }, + { + category: 'defi', + assetId: 'eip155:1/erc20:0x4fef9d74', + name: 'MetaMask Swaps', + symbol: 'MMS', + decimals: 18, + balance: '1.0', + metadata: { + protocolId: 'metamask', + protocolName: 'MetaMask Swaps', + description: 'MetaMask Swaps on ethereum', + protocolUrl: 'https://metamask.io/', + protocolIconUrl: 'https://example.com/icon.jpg', + positionType: 'deposit', + poolAddress: '0x4fef9d741011476750a243ac70b9789a63dd47df', + }, + }, + ], + processingDefiPositions: false, + }, + ], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const result = await client.accounts.fetchV6MultiAccountBalances( + ['eip155:1:0x123'], + { + includeDeFiBalances: true, + includePrices: true, + vsCurrency: 'usd', + includeAssetIds: ['eip155:1/erc20:0xabc'], + }, + ); + + expect(result).toStrictEqual(mockResponse); + const calledUrl = mockFetch.mock.calls[0]?.[0] as string; + expect(calledUrl).toContain('/v6/multiaccount/balances'); + expect(calledUrl).toContain('includeDeFiBalances=true'); + expect(calledUrl).toContain('includePrices=true'); + expect(calledUrl).toContain('vsCurrency=usd'); + }); + + it('returns empty balances for empty accountIds in v6', async () => { + const result = await client.accounts.fetchV6MultiAccountBalances([]); + + expect(result).toStrictEqual({ + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + accounts: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('Transactions', () => { @@ -752,6 +836,32 @@ describe('AccountsApiClient', () => { ]); }); + it('getV6MultiAccountBalancesQueryOptions includes sorted networks, includeAssetIds and excludeAssetIds in queryKey when provided', () => { + const options = client.accounts.getV6MultiAccountBalancesQueryOptions( + ['eip155:1:0xzzz', 'eip155:1:0xaaa'], + { + networks: ['eip155:137', 'eip155:1'], + includeAssetIds: ['eip155:1/erc20:0xddd', 'eip155:1/erc20:0xccc'], + excludeAssetIds: ['eip155:1/erc20:0xbbb', 'eip155:1/erc20:0xaaa'], + includeDeFiBalances: true, + }, + ); + expect(options.queryKey).toStrictEqual([ + 'accounts', + 'balances', + 'v6', + { + accountIds: ['eip155:1:0xaaa', 'eip155:1:0xzzz'], + options: { + networks: ['eip155:1', 'eip155:137'], + includeAssetIds: ['eip155:1/erc20:0xccc', 'eip155:1/erc20:0xddd'], + excludeAssetIds: ['eip155:1/erc20:0xaaa', 'eip155:1/erc20:0xbbb'], + includeDeFiBalances: true, + }, + }, + ]); + }); + it('getV2AccountNftsQueryOptions includes sorted networks in queryKey when queryOptions.networks provided', () => { const options = client.accounts.getV2AccountNftsQueryOptions('0x123', { networks: [137, 1], @@ -868,6 +978,27 @@ describe('AccountsApiClient', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('getV6MultiAccountBalancesQueryOptions queryFn returns empty result for empty accountIds without calling fetch', async () => { + const options = client.accounts.getV6MultiAccountBalancesQueryOptions([]); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + accounts: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('getV1AccountTransactionsQueryOptions queryFn returns empty result for empty address without calling fetch', async () => { const options = client.accounts.getV1AccountTransactionsQueryOptions(''); const { queryFn } = options; diff --git a/packages/core-backend/src/api/accounts/client.ts b/packages/core-backend/src/api/accounts/client.ts index 4fb29122d02..e5cec8f0f7e 100644 --- a/packages/core-backend/src/api/accounts/client.ts +++ b/packages/core-backend/src/api/accounts/client.ts @@ -27,6 +27,8 @@ import type { V2BalancesResponse, V4BalancesResponse, V5BalancesResponse, + V6BalancesResponse, + V6VsCurrency, V1TransactionByHashResponse, V1AccountTransactionsResponse, V4MultiAccountTransactionsResponse, @@ -489,6 +491,160 @@ export class AccountsApiClient extends BaseApiClient { ); } + /** + * Returns the TanStack Query options object for v6 multi-account balances. + * The v6 endpoint returns token balances and, optionally, DeFi positions and + * spot prices. + * + * @param accountIds - Array of CAIP-10 account IDs. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Comma-separated CAIP-2 chain IDs to filter by. + * @param queryOptions.filterSupportedTokens - Whether to filter the assets to only tokens existing in the Token API. + * @param queryOptions.startTimestamp - Start timestamp (epoch) from which to return results. + * @param queryOptions.endTimestamp - End timestamp (epoch) for which to return results. + * @param queryOptions.includeLabels - Whether to include asset metadata labels in the response. + * @param queryOptions.includeCanonicalHead - Whether to include the canonical head asset ID in the response. + * @param queryOptions.includeDeFiBalances - Whether to include DeFi positions (token balances are always returned). + * @param queryOptions.forceFetchDeFiPositions - Whether to fetch DeFi positions for all accounts, skipping the cached non-DeFi-user check. + * @param queryOptions.includePrices - Whether to include spot prices for each token and DeFi position asset. + * @param queryOptions.vsCurrency - Quote currency for spot prices when `includePrices` is true (default `usd`). + * @param queryOptions.includeAssetIds - ERC-20 CAIP-19 asset IDs to confirm detection for; undetected IDs are returned in `unprocessedIncludeAssetIds`. + * @param queryOptions.excludeAssetIds - ERC-20 CAIP-19 asset IDs to exclude from balance results. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV6MultiAccountBalancesQueryOptions( + accountIds: string[], + queryOptions?: { + networks?: string[]; + filterSupportedTokens?: boolean; + startTimestamp?: number; + endTimestamp?: number; + includeLabels?: boolean; + includeCanonicalHead?: boolean; + includeDeFiBalances?: boolean; + forceFetchDeFiPositions?: boolean; + includePrices?: boolean; + vsCurrency?: V6VsCurrency; + includeAssetIds?: string[]; + excludeAssetIds?: string[]; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'accounts', + 'balances', + 'v6', + { + accountIds: [...accountIds].sort(), + options: queryOptions && { + ...queryOptions, + networks: + queryOptions.networks && [...queryOptions.networks].sort(), + includeAssetIds: + queryOptions.includeAssetIds && + [...queryOptions.includeAssetIds].sort(), + excludeAssetIds: + queryOptions.excludeAssetIds && + [...queryOptions.excludeAssetIds].sort(), + }, + }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountIds.length === 0) { + return { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + accounts: [], + }; + } + return this.fetch( + API_URLS.ACCOUNTS, + '/v6/multiaccount/balances', + { + signal, + params: { + accountIds, + networks: queryOptions?.networks, + filterSupportedTokens: queryOptions?.filterSupportedTokens, + startTimestamp: queryOptions?.startTimestamp, + endTimestamp: queryOptions?.endTimestamp, + includeLabels: queryOptions?.includeLabels, + includeCanonicalHead: queryOptions?.includeCanonicalHead, + includeDeFiBalances: queryOptions?.includeDeFiBalances, + forceFetchDeFiPositions: queryOptions?.forceFetchDeFiPositions, + includePrices: queryOptions?.includePrices, + vsCurrency: queryOptions?.vsCurrency, + includeAssetIds: queryOptions?.includeAssetIds, + excludeAssetIds: queryOptions?.excludeAssetIds, + }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + + /** + * Get balances and DeFi positions for multiple accounts using CAIP-10 IDs + * (v6 endpoint). + * + * @param accountIds - Array of CAIP-10 account IDs. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Comma-separated CAIP-2 chain IDs to filter by. + * @param queryOptions.filterSupportedTokens - Whether to filter the assets to only tokens existing in the Token API. + * @param queryOptions.startTimestamp - Start timestamp (epoch) from which to return results. + * @param queryOptions.endTimestamp - End timestamp (epoch) for which to return results. + * @param queryOptions.includeLabels - Whether to include asset metadata labels in the response. + * @param queryOptions.includeCanonicalHead - Whether to include the canonical head asset ID in the response. + * @param queryOptions.includeDeFiBalances - Whether to include DeFi positions (token balances are always returned). + * @param queryOptions.forceFetchDeFiPositions - Whether to fetch DeFi positions for all accounts, skipping the cached non-DeFi-user check. + * @param queryOptions.includePrices - Whether to include spot prices for each token and DeFi position asset. + * @param queryOptions.vsCurrency - Quote currency for spot prices when `includePrices` is true (default `usd`). + * @param queryOptions.includeAssetIds - ERC-20 CAIP-19 asset IDs to confirm detection for; undetected IDs are returned in `unprocessedIncludeAssetIds`. + * @param queryOptions.excludeAssetIds - ERC-20 CAIP-19 asset IDs to exclude from balance results. + * @param options - Fetch options including cache settings. + * @returns The multi-account balances and DeFi positions response. + */ + async fetchV6MultiAccountBalances( + accountIds: string[], + queryOptions?: { + networks?: string[]; + filterSupportedTokens?: boolean; + startTimestamp?: number; + endTimestamp?: number; + includeLabels?: boolean; + includeCanonicalHead?: boolean; + includeDeFiBalances?: boolean; + forceFetchDeFiPositions?: boolean; + includePrices?: boolean; + vsCurrency?: V6VsCurrency; + includeAssetIds?: string[]; + excludeAssetIds?: string[]; + }, + options?: FetchOptions, + ): Promise { + if (accountIds.length === 0) { + return { + unprocessedNetworks: [], + unprocessedIncludeAssetIds: [], + accounts: [], + }; + } + return this.queryClient.fetchQuery( + this.getV6MultiAccountBalancesQueryOptions( + accountIds, + queryOptions, + options, + ), + ); + } + // ========================================================================== // TRANSACTIONS // ========================================================================== diff --git a/packages/core-backend/src/api/accounts/index.ts b/packages/core-backend/src/api/accounts/index.ts index 8e97c1238a2..3f08819c3fa 100644 --- a/packages/core-backend/src/api/accounts/index.ts +++ b/packages/core-backend/src/api/accounts/index.ts @@ -9,6 +9,12 @@ export type { V2BalanceItem, V2BalancesResponse, V4BalancesResponse, + V6VsCurrency, + V6BalanceMetadata, + V6TokenMetadata, + V6BalanceItem, + V6AccountBalancesEntry, + V6BalancesResponse, V1SupportedNetworksResponse, V2SupportedNetworksResponse, V2ActiveNetworksResponse, diff --git a/packages/core-backend/src/api/accounts/types.ts b/packages/core-backend/src/api/accounts/types.ts index c1da26e2611..41eeb031767 100644 --- a/packages/core-backend/src/api/accounts/types.ts +++ b/packages/core-backend/src/api/accounts/types.ts @@ -56,6 +56,95 @@ export type V4BalancesResponse = { unprocessedNetworks: number[]; }; +/** + * Quote currency accepted by the v6 balances endpoint when `includePrices` is + * true. A superset of {@link SupportedCurrency} (adds e.g. `sol`, `xdr`, + * `xag`, `xau`, `bits`, `sats`). Defaults to `usd`. + */ +export type V6VsCurrency = string; + +/** + * DeFi protocol metadata attached to a `category: defi` row in the v6 balances + * response (`BalanceMetadataV3ResponseDto`). + */ +export type V6BalanceMetadata = { + protocolId: string; + protocolName: string; + description: string; + protocolUrl: string; + protocolIconUrl: string; + positionType: string; + poolAddress: string; +}; + +/** + * Token-level metadata attached to a `category: token` row in the v6 balances + * response, e.g. Stellar trustline metadata. Additional keys may be present. + */ +export type V6TokenMetadata = { + /** Stellar trustline limit. */ + limit?: string; + /** Whether the Stellar trustline is authorized. */ + authorized?: boolean; + [key: string]: unknown; +}; + +/** + * A single balance row in the v6 balances response (`BalanceV3ResponseDto`). + * `category: token` rows are EVM/Solana token balances (and may carry + * {@link V6TokenMetadata}, e.g. Stellar trustline info). `category: defi` rows + * are flat DeFi positions and include {@link V6BalanceMetadata}. + */ +export type V6BalanceItem = { + category: 'token' | 'defi'; + assetId: string; + name: string; + symbol: string; + decimals: number; + balance: string; + /** Spot price in the requested `vsCurrency`. Present when `includePrices` is true. */ + price?: string; + /** Asset metadata labels. Present when `includeLabels` is true. */ + labels?: string[]; + /** Canonical head asset ID. Present when `includeCanonicalHead` is true. */ + canonicalHead?: string; + /** + * DeFi protocol metadata for `category: defi` rows; token-level metadata such + * as Stellar trustline info (e.g. `limit`, `authorized`) for `category: token` + * rows. + */ + metadata?: V6BalanceMetadata | V6TokenMetadata; +}; + +/** + * A per-account entry in the v6 balances response + * (`AccountBalancesV3EntryDto`). + */ +export type V6AccountBalancesEntry = { + accountId: string; + balances: V6BalanceItem[]; + /** + * When true, DeFi positions for this account are still being indexed + * upstream; poll again shortly. + */ + processingDefiPositions?: boolean; +}; + +/** + * V6 multi-account balances response (`MultiAccountBalancesV3ResponseDto`). + */ +export type V6BalancesResponse = { + /** CAIP-2 networks that could not be processed for this request. */ + unprocessedNetworks: string[]; + /** + * ERC-20 IDs from `includeAssetIds` that were not detected on any requested + * account, plus other IDs that still need a client fallback flow. + */ + unprocessedIncludeAssetIds: string[]; + /** Per-account balance entries. */ + accounts: V6AccountBalancesEntry[]; +}; + // ============================================================================ // SUPPORTED NETWORKS TYPES // ============================================================================ diff --git a/packages/core-backend/src/api/index.ts b/packages/core-backend/src/api/index.ts index c2b57cb0851..02c01b9838e 100644 --- a/packages/core-backend/src/api/index.ts +++ b/packages/core-backend/src/api/index.ts @@ -30,6 +30,12 @@ export type { V2BalanceItem, V2BalancesResponse, V4BalancesResponse, + V6VsCurrency, + V6BalanceMetadata, + V6TokenMetadata, + V6BalanceItem, + V6AccountBalancesEntry, + V6BalancesResponse, V1SupportedNetworksResponse, V2SupportedNetworksResponse, V2ActiveNetworksResponse, diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index 6197c068f80..f88d719373e 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -147,6 +147,12 @@ export type { V2BalanceItem, V2BalancesResponse, V4BalancesResponse, + V6VsCurrency, + V6BalanceMetadata, + V6TokenMetadata, + V6BalanceItem, + V6AccountBalancesEntry, + V6BalancesResponse, V1SupportedNetworksResponse, V2SupportedNetworksResponse, V2ActiveNetworksResponse,