diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index 72af3018be..fdce86d678 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -33,6 +33,12 @@ import { buildInfuraNetworkConfiguration, } from '../../network-controller/tests/helpers'; import { formatAggregatorNames } from './assetsUtil'; +import * as MutliChainAccountsServiceModule from './multi-chain-accounts-service'; +import { + MOCK_GET_BALANCES_RESPONSE, + createMockGetBalancesResponse, +} from './multi-chain-accounts-service/mocks/mock-get-balances'; +import { MOCK_GET_SUPPORTED_NETWORKS_RESPONSE } from './multi-chain-accounts-service/mocks/mock-get-supported-networks'; import { TOKEN_END_POINT_API } from './token-service'; import type { AllowedActions, @@ -46,9 +52,11 @@ import { } from './TokenDetectionController'; import { getDefaultTokenListState, + type TokenListMap, type TokenListState, type TokenListToken, } from './TokenListController'; +import type { Token } from './TokenRatesController'; import type { TokensController, TokensControllerState, @@ -173,9 +181,25 @@ function buildTokenDetectionControllerMessenger( }); } +const mockMultiChainAccountsService = () => { + const mockFetchSupportedNetworks = jest + .spyOn(MutliChainAccountsServiceModule, 'fetchSupportedNetworks') + .mockResolvedValue(MOCK_GET_SUPPORTED_NETWORKS_RESPONSE.fullSupport); + const mockFetchMultiChainBalances = jest + .spyOn(MutliChainAccountsServiceModule, 'fetchMultiChainBalances') + .mockResolvedValue(MOCK_GET_BALANCES_RESPONSE); + + return { + mockFetchSupportedNetworks, + mockFetchMultiChainBalances, + }; +}; + describe('TokenDetectionController', () => { const defaultSelectedAccount = createMockInternalAccount(); + mockMultiChainAccountsService(); + beforeEach(async () => { nock(TOKEN_END_POINT_API) .get(getTokensPath(ChainId.mainnet)) @@ -2236,6 +2260,218 @@ describe('TokenDetectionController', () => { }, ); }); + + /** + * Test Utility - Arrange and Act `detectTokens()` with the Accounts API feature + * RPC flow will return `sampleTokenA` and the Accounts API flow will use `sampleTokenB` + * @param props - options to modify these tests + * @param props.overrideMockTokensCache - change the tokens cache + * @param props.mockMultiChainAPI - change the Accounts API responses + * @param props.overrideMockTokenGetState - change the external TokensController state + * @returns properties that can be used for assertions + */ + const arrangeActTestDetectTokensWithAccountsAPI = async (props?: { + /** Overwrite the tokens cache inside Tokens Controller */ + overrideMockTokensCache?: (typeof sampleTokenA)[]; + mockMultiChainAPI?: ReturnType; + overrideMockTokenGetState?: Partial; + }) => { + const { + overrideMockTokensCache = [sampleTokenA, sampleTokenB], + mockMultiChainAPI, + overrideMockTokenGetState, + } = props ?? {}; + + // Arrange - RPC Tokens Flow - Uses sampleTokenA + const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({ + [sampleTokenA.address]: new BN(1), + }); + + // Arrange - API Tokens Flow - Uses sampleTokenB + const { mockFetchSupportedNetworks, mockFetchMultiChainBalances } = + mockMultiChainAPI ?? mockMultiChainAccountsService(); + + if (!mockMultiChainAPI) { + mockFetchSupportedNetworks.mockResolvedValue([1]); + mockFetchMultiChainBalances.mockResolvedValue( + createMockGetBalancesResponse([sampleTokenB.address], 1), + ); + } + + // Arrange - Selected Account + const selectedAccount = createMockInternalAccount({ + address: '0x0000000000000000000000000000000000000001', + }); + + // Arrange / Act - withController setup + invoke detectTokens + const { callAction } = await withController( + { + options: { + disabled: false, + getBalancesInSingleCall: mockGetBalancesInSingleCall, + useAccountsAPI: true, // USING ACCOUNTS API + }, + mocks: { + getSelectedAccount: selectedAccount, + getAccount: selectedAccount, + }, + }, + async ({ + controller, + mockTokenListGetState, + callActionSpy, + mockTokensGetState, + }) => { + const tokenCacheData: TokenListMap = {}; + overrideMockTokensCache.forEach( + (t) => + (tokenCacheData[t.address] = { + name: t.name, + symbol: t.symbol, + decimals: t.decimals, + address: t.address, + occurrences: 1, + aggregators: t.aggregators, + iconUrl: t.image, + }), + ); + + mockTokenListGetState({ + ...getDefaultTokenListState(), + tokensChainsCache: { + '0x1': { + timestamp: 0, + data: tokenCacheData, + }, + }, + }); + + if (overrideMockTokenGetState) { + mockTokensGetState({ + ...getDefaultTokensState(), + ...overrideMockTokenGetState, + }); + } + + // Act + await controller.detectTokens({ + networkClientId: NetworkType.mainnet, + selectedAddress: selectedAccount.address, + }); + + return { + callAction: callActionSpy, + }; + }, + ); + + const assertAddedTokens = (token: Token) => + expect(callAction).toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + [token], + { + chainId: ChainId.mainnet, + selectedAddress: selectedAccount.address, + }, + ); + + const assertTokensNeverAdded = () => + expect(callAction).not.toHaveBeenCalledWith( + 'TokensController:addDetectedTokens', + ); + + return { + assertAddedTokens, + assertTokensNeverAdded, + mockFetchMultiChainBalances, + mockGetBalancesInSingleCall, + rpcToken: sampleTokenA, + apiToken: sampleTokenB, + }; + }; + + it('should trigger and use Accounts API for detection', async () => { + const { + assertAddedTokens, + mockFetchMultiChainBalances, + apiToken, + mockGetBalancesInSingleCall, + } = await arrangeActTestDetectTokensWithAccountsAPI(); + + expect(mockFetchMultiChainBalances).toHaveBeenCalled(); + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + assertAddedTokens(apiToken); + }); + + it('uses the Accounts API but does not add unknown tokens', async () => { + // API returns sampleTokenB + // As this is not a known token (in cache), then is not added + const { + assertTokensNeverAdded, + mockFetchMultiChainBalances, + mockGetBalancesInSingleCall, + } = await arrangeActTestDetectTokensWithAccountsAPI({ + overrideMockTokensCache: [sampleTokenA], + }); + + expect(mockFetchMultiChainBalances).toHaveBeenCalled(); + expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled(); + assertTokensNeverAdded(); + }); + + it('fallbacks from using the Accounts API if fails', async () => { + // Test 1 - fetch supported networks fails + let mockAPI = mockMultiChainAccountsService(); + mockAPI.mockFetchSupportedNetworks.mockRejectedValue( + new Error('Mock Error'), + ); + let actResult = await arrangeActTestDetectTokensWithAccountsAPI({ + mockMultiChainAPI: mockAPI, + }); + + expect(actResult.mockFetchMultiChainBalances).not.toHaveBeenCalled(); // never called as could not fetch supported networks... + expect(actResult.mockGetBalancesInSingleCall).toHaveBeenCalled(); // ...so then RPC flow was initiated + actResult.assertAddedTokens(actResult.rpcToken); + + // Test 2 - fetch multi chain fails + mockAPI = mockMultiChainAccountsService(); + mockAPI.mockFetchMultiChainBalances.mockRejectedValue( + new Error('Mock Error'), + ); + actResult = await arrangeActTestDetectTokensWithAccountsAPI({ + mockMultiChainAPI: mockAPI, + }); + + expect(actResult.mockFetchMultiChainBalances).toHaveBeenCalled(); // API was called, but failed... + expect(actResult.mockGetBalancesInSingleCall).toHaveBeenCalled(); // ...so then RPC flow was initiated + actResult.assertAddedTokens(actResult.rpcToken); + }); + + it('uses the Accounts API but does not add tokens that are already added', async () => { + // Here we populate the token state with a token that exists in the tokenAPI. + // So the token retrieved from the API should not be added + const { assertTokensNeverAdded, mockFetchMultiChainBalances } = + await arrangeActTestDetectTokensWithAccountsAPI({ + overrideMockTokenGetState: { + allDetectedTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [ + { + address: sampleTokenB.address, + name: sampleTokenB.name, + symbol: sampleTokenB.symbol, + decimals: sampleTokenB.decimals, + aggregators: sampleTokenB.aggregators, + }, + ], + }, + }, + }, + }); + + expect(mockFetchMultiChainBalances).toHaveBeenCalled(); + assertTokensNeverAdded(); + }); }); }); @@ -2415,6 +2651,7 @@ async function withController( getBalancesInSingleCall: jest.fn(), trackMetaMetricsEvent: jest.fn(), messenger: buildTokenDetectionControllerMessenger(controllerMessenger), + useAccountsAPI: false, ...options, }); try { diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts index 2459baea38..7c7b9e60d8 100644 --- a/packages/assets-controllers/src/TokenDetectionController.ts +++ b/packages/assets-controllers/src/TokenDetectionController.ts @@ -9,7 +9,12 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import contractMap from '@metamask/contract-metadata'; -import { ChainId, safelyExecute } from '@metamask/controller-utils'; +import { + ASSET_TYPES, + ChainId, + ERC20, + safelyExecute, +} from '@metamask/controller-utils'; import type { KeyringControllerGetStateAction, KeyringControllerLockEvent, @@ -28,9 +33,14 @@ import type { PreferencesControllerStateChangeEvent, } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; +import { hexToNumber } from '@metamask/utils'; import type { AssetsContractController } from './AssetsContractController'; import { isTokenDetectionSupportedForNetwork } from './assetsUtil'; +import { + fetchMultiChainBalances, + fetchSupportedNetworks, +} from './multi-chain-accounts-service'; import type { GetTokenListState, TokenListMap, @@ -191,6 +201,48 @@ export class TokenDetectionController extends StaticIntervalPollingController void; + #accountsAPI = { + isAccountsAPIEnabled: true, + supportedNetworksCache: null as number[] | null, + async getSupportedNetworks() { + /* istanbul ignore next */ + if (!this.isAccountsAPIEnabled) { + throw new Error('Accounts API Feature Switch is disabled'); + } + + /* istanbul ignore next */ + if (this.supportedNetworksCache) { + return this.supportedNetworksCache; + } + + const result = await fetchSupportedNetworks().catch(() => null); + this.supportedNetworksCache = result; + return result; + }, + + async getMultiChainBalances(address: string, chainId: Hex) { + if (!this.isAccountsAPIEnabled) { + throw new Error('Accounts API Feature Switch is disabled'); + } + + const chainIdNumber = hexToNumber(chainId); + const supportedNetworks = await this.getSupportedNetworks(); + + if (!supportedNetworks || !supportedNetworks.includes(chainIdNumber)) { + const supportedNetworksErrStr = (supportedNetworks ?? []).toString(); + throw new Error( + `Unsupported Network: supported networks ${supportedNetworksErrStr}, network: ${chainIdNumber}`, + ); + } + + const result = await fetchMultiChainBalances(address, { + networks: [chainIdNumber], + }); + + return result.balances; + }, + }; + /** * Creates a TokenDetectionController instance. * @@ -200,6 +252,7 @@ export class TokenDetectionController extends StaticIntervalPollingController void; messenger: TokenDetectionControllerMessenger; + useAccountsAPI?: boolean; }) { super({ name: controllerName, @@ -257,6 +312,8 @@ export class TokenDetectionController extends StaticIntervalPollingController + tokenCandidateSlices, + }); + if (accountAPIResult?.result === 'success') { + return; + } + + // Attempt RPC Detection + const tokenDetectionPromises = tokenCandidateSlices.map((tokensSlice) => this.#addDetectedTokens({ tokensSlice, selectedAddress: addressAgainstWhichToDetect, @@ -578,6 +648,97 @@ export class TokenDetectionController extends StaticIntervalPollingController { + const tokenBalances = await this.#accountsAPI + .getMultiChainBalances(selectedAddress, chainId) + .catch(() => null); + + if (!tokenBalances || tokenBalances.length === 0) { + return { result: 'failed' } as const; + } + + const tokensWithBalance: Token[] = []; + const eventTokensDetails: string[] = []; + + const tokenCandidateSet = new Set(tokenCandidateSlices.flat()); + + tokenBalances.forEach((token) => { + const tokenAddress = token.address; + + // Make sure that the token to add is in our candidate list + // Ensures we don't add tokens we already own + if (!tokenCandidateSet.has(token.address)) { + return; + } + + // We need specific data from tokenList to correctly create a token + // So even if we have a token that was detected correctly by the API, if its missing data we cannot safely add it. + if (!this.#tokenList[token.address]) { + return; + } + + const { decimals, symbol, aggregators, iconUrl, name } = + this.#tokenList[token.address]; + eventTokensDetails.push(`${symbol} - ${tokenAddress}`); + tokensWithBalance.push({ + address: tokenAddress, + decimals, + symbol, + aggregators, + image: iconUrl, + isERC721: false, + name, + }); + }); + + if (tokensWithBalance.length) { + this.#trackMetaMetricsEvent({ + event: 'Token Detected', + category: 'Wallet', + properties: { + tokens: eventTokensDetails, + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + token_standard: ERC20, + // TODO: Either fix this lint violation or explain why it's necessary to ignore. + // eslint-disable-next-line @typescript-eslint/naming-convention + asset_type: ASSET_TYPES.TOKEN, + }, + }); + + await this.messagingSystem.call( + 'TokensController:addDetectedTokens', + tokensWithBalance, + { + selectedAddress, + chainId, + }, + ); + } + + return { result: 'success' } as const; + }); + } + async #addDetectedTokens({ tokensSlice, selectedAddress, @@ -621,10 +782,10 @@ export class TokenDetectionController extends StaticIntervalPollingController ({ + count: tokenAddrs.length, + balances: tokenAddrs.map((a) => ({ + object: 'token', + address: a, + name: 'Mock Token', + symbol: 'MOCK', + decimals: 18, + balance: '10.18', + chainId, + })), + unprocessedNetworks: [], +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-supported-networks.ts b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-supported-networks.ts new file mode 100644 index 0000000000..7337015072 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/mocks/mock-get-supported-networks.ts @@ -0,0 +1,9 @@ +import type { GetSupportedNetworksResponse } from '../types'; + +export const MOCK_GET_SUPPORTED_NETWORKS_RESPONSE: GetSupportedNetworksResponse = + { + fullSupport: [1, 137, 56, 59144, 8453, 10, 42161, 534352], + partialSupport: { + balances: [59141, 42220, 43114], + }, + }; diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts new file mode 100644 index 0000000000..0545a5ef4a --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts @@ -0,0 +1,87 @@ +import nock from 'nock'; + +import { MOCK_GET_BALANCES_RESPONSE } from './mocks/mock-get-balances'; +import { MOCK_GET_SUPPORTED_NETWORKS_RESPONSE } from './mocks/mock-get-supported-networks'; +import { + MULTICHAIN_ACCOUNTS_DOMAIN, + fetchMultiChainBalances, + fetchSupportedNetworks, +} from './multi-chain-accounts'; + +const MOCK_ADDRESS = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + +describe('fetchSupportedNetworks()', () => { + const createMockAPI = () => + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get('/v1/supportedNetworks'); + + it('should successfully return supported networks array', async () => { + const mockAPI = createMockAPI().reply( + 200, + MOCK_GET_SUPPORTED_NETWORKS_RESPONSE, + ); + + const result = await fetchSupportedNetworks(); + expect(result).toStrictEqual( + MOCK_GET_SUPPORTED_NETWORKS_RESPONSE.fullSupport, + ); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should throw error when fetch fails', async () => { + const mockAPI = createMockAPI().reply(500); + + await expect(async () => await fetchSupportedNetworks()).rejects.toThrow( + expect.any(Error), + ); + expect(mockAPI.isDone()).toBe(true); + }); +}); + +describe('fetchMultiChainBalances()', () => { + const createMockAPI = () => + nock(MULTICHAIN_ACCOUNTS_DOMAIN).get( + `/v2/accounts/${MOCK_ADDRESS}/balances`, + ); + + it('should successfully return balances response', async () => { + const mockAPI = createMockAPI().reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances(MOCK_ADDRESS); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + it('should successfully return balances response with query params to refine search', async () => { + const mockAPI = createMockAPI() + .query({ + networks: '1,10', + }) + .reply(200, MOCK_GET_BALANCES_RESPONSE); + + const result = await fetchMultiChainBalances(MOCK_ADDRESS, { + networks: [1, 10], + }); + expect(result).toBeDefined(); + expect(result).toStrictEqual(MOCK_GET_BALANCES_RESPONSE); + expect(mockAPI.isDone()).toBe(true); + }); + + const testMatrix = [ + { httpCode: 429, httpCodeName: 'Too Many Requests' }, // E.g. Rate Limit + { httpCode: 422, httpCodeName: 'Unprocessable Content' }, // E.g. fails to fetch any balances from specified chains + { httpCode: 500, httpCodeName: 'Internal Server Error' }, // E.g. Server Rekt + ]; + + it.each(testMatrix)( + 'should throw when $httpCode "$httpCodeName"', + async ({ httpCode }) => { + const mockAPI = createMockAPI().reply(httpCode); + + await expect( + async () => await fetchMultiChainBalances(MOCK_ADDRESS), + ).rejects.toThrow(expect.any(Error)); + expect(mockAPI.isDone()).toBe(true); + }, + ); +}); diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts new file mode 100644 index 0000000000..f40afd2809 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.ts @@ -0,0 +1,52 @@ +import { handleFetch } from '@metamask/controller-utils'; + +import type { + GetBalancesQueryParams, + GetBalancesResponse, + GetSupportedNetworksResponse, +} from './types'; + +export const MULTICHAIN_ACCOUNTS_DOMAIN = 'https://accounts.api.cx.metamask.io'; + +const getBalancesUrl = ( + address: string, + queryParams?: GetBalancesQueryParams, +) => { + const url = new URL( + `${MULTICHAIN_ACCOUNTS_DOMAIN}/v2/accounts/${address}/balances`, + ); + + if (queryParams?.networks !== undefined) { + url.searchParams.append('networks', queryParams.networks); + } + + return url; +}; + +/** + * Fetches Supported Networks. + * @returns supported networks (decimal) + */ +export async function fetchSupportedNetworks(): Promise { + const url = new URL(`${MULTICHAIN_ACCOUNTS_DOMAIN}/v1/supportedNetworks`); + const response: GetSupportedNetworksResponse = await handleFetch(url); + return response.fullSupport; +} + +/** + * Fetches Balances for multiple networks. + * @param address - address to fetch balances from + * @param options - params to pass down for a more refined search + * @param options.networks - the networks (in decimal) that you want to filter by + * @returns a Balances Response + */ +export async function fetchMultiChainBalances( + address: string, + options?: { networks?: number[] }, +) { + const url = getBalancesUrl(address, { + networks: options?.networks?.join(), + }); + const response: GetBalancesResponse = await handleFetch(url); + return response; +} diff --git a/packages/assets-controllers/src/multi-chain-accounts-service/types.ts b/packages/assets-controllers/src/multi-chain-accounts-service/types.ts new file mode 100644 index 0000000000..3778d3a671 --- /dev/null +++ b/packages/assets-controllers/src/multi-chain-accounts-service/types.ts @@ -0,0 +1,38 @@ +export type GetSupportedNetworksResponse = { + fullSupport: number[]; + partialSupport: { + balances: number[]; + }; +}; + +export type GetBalancesQueryParams = { + /** Comma-separated network/chain IDs */ + networks?: string; + /** Whether or not to filter the assets to contain only the tokens existing in the Token API */ + filterSupportedTokens?: boolean; + /** Specific token addresses to fetch balances for across specified network(s) */ + includeTokenAddresses?: string; + /** Whether to include balances of the account's staked asset balances */ + includeStakedAssets?: boolean; +}; + +export type GetBalancesResponse = { + count: number; + balances: { + /** Underlying object type. Seems to be always `token` */ + object: string; + /** Token Type: This is only supplied as `native` to native chain tokens (e.g. - ETH, POL) */ + type?: string; + /** Timestamp is only provided for `native` chain tokens */ + timestamp?: string; + address: string; + symbol: string; + name: string; + decimals: number; + chainId: number; + /** string representation of the balance in decimal format (decimals adjusted). e.g. - 123.456789 */ + balance: string; + }[]; + /** networks that failed to process, if no network is processed, returns HTTP 422 */ + unprocessedNetworks: number[]; +};