Skip to content

Commit 1251b86

Browse files
feat: add and use Accounts API for Account balance calls (#4781)
## Explanation This integrates the Accounts API (Multi-chain Balances Endpoint) to help alleviate expensive RPC calls made by Token Detection. The aim is to attempt to use the Accounts API when making balance calls for expensive functionality (e.g. Token Detection) <details><summary>Code Walkthrough</summary> https://www.loom.com/share/e540cae3967746b0aca343d4c59d0af6?sid=69c2556c-96d3-451e-bd67-7d03f32fff03 </details> ## References #4743 https://consensyssoftware.atlassian.net/browse/NOTIFY-1179 ## Changelog <!-- If you're making any consumer-facing changes, list those changes here as if you were updating a changelog, using the template below as a guide. (CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or FIXED. For security-related issues, follow the Security Advisory process.) Please take care to name the exact pieces of the API you've added or changed (e.g. types, interfaces, functions, or methods). If there are any breaking changes, make sure to offer a solution for consumers to follow once they upgrade to the changes. Finally, if you're only making changes to development scripts or tests, you may replace the template below with "None". --> ### `@metamask/assets-controllers` - **ADDED**: MultiChain Accounts Service - **ADDED**: `fetchSupportedNetworks()` function to dynamically fetch supported networks by the Accounts API - **ADDED**: `fetchMultiChainBalances()` function to get balances for a given address - **ADDED**: `useAccountsAPI` to the `TokenDetectionController` constructor to enable/disable the accounts API feature. - **ADDED**: `#addDetectedTokensViaAPI()` private method in `TokenDetectionController` to get detected tokens via the Accounts API. - **CHANGED**: `detectTokens()` method in `TokenDetectionController` to try AccountsAPI first before using RPC flow to detect tokens. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent 8fb04fc commit 1251b86

File tree

8 files changed

+681
-5
lines changed

8 files changed

+681
-5
lines changed

packages/assets-controllers/src/TokenDetectionController.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ import {
3333
buildInfuraNetworkConfiguration,
3434
} from '../../network-controller/tests/helpers';
3535
import { formatAggregatorNames } from './assetsUtil';
36+
import * as MutliChainAccountsServiceModule from './multi-chain-accounts-service';
37+
import {
38+
MOCK_GET_BALANCES_RESPONSE,
39+
createMockGetBalancesResponse,
40+
} from './multi-chain-accounts-service/mocks/mock-get-balances';
41+
import { MOCK_GET_SUPPORTED_NETWORKS_RESPONSE } from './multi-chain-accounts-service/mocks/mock-get-supported-networks';
3642
import { TOKEN_END_POINT_API } from './token-service';
3743
import type {
3844
AllowedActions,
@@ -46,9 +52,11 @@ import {
4652
} from './TokenDetectionController';
4753
import {
4854
getDefaultTokenListState,
55+
type TokenListMap,
4956
type TokenListState,
5057
type TokenListToken,
5158
} from './TokenListController';
59+
import type { Token } from './TokenRatesController';
5260
import type {
5361
TokensController,
5462
TokensControllerState,
@@ -173,9 +181,25 @@ function buildTokenDetectionControllerMessenger(
173181
});
174182
}
175183

184+
const mockMultiChainAccountsService = () => {
185+
const mockFetchSupportedNetworks = jest
186+
.spyOn(MutliChainAccountsServiceModule, 'fetchSupportedNetworks')
187+
.mockResolvedValue(MOCK_GET_SUPPORTED_NETWORKS_RESPONSE.fullSupport);
188+
const mockFetchMultiChainBalances = jest
189+
.spyOn(MutliChainAccountsServiceModule, 'fetchMultiChainBalances')
190+
.mockResolvedValue(MOCK_GET_BALANCES_RESPONSE);
191+
192+
return {
193+
mockFetchSupportedNetworks,
194+
mockFetchMultiChainBalances,
195+
};
196+
};
197+
176198
describe('TokenDetectionController', () => {
177199
const defaultSelectedAccount = createMockInternalAccount();
178200

201+
mockMultiChainAccountsService();
202+
179203
beforeEach(async () => {
180204
nock(TOKEN_END_POINT_API)
181205
.get(getTokensPath(ChainId.mainnet))
@@ -2236,6 +2260,218 @@ describe('TokenDetectionController', () => {
22362260
},
22372261
);
22382262
});
2263+
2264+
/**
2265+
* Test Utility - Arrange and Act `detectTokens()` with the Accounts API feature
2266+
* RPC flow will return `sampleTokenA` and the Accounts API flow will use `sampleTokenB`
2267+
* @param props - options to modify these tests
2268+
* @param props.overrideMockTokensCache - change the tokens cache
2269+
* @param props.mockMultiChainAPI - change the Accounts API responses
2270+
* @param props.overrideMockTokenGetState - change the external TokensController state
2271+
* @returns properties that can be used for assertions
2272+
*/
2273+
const arrangeActTestDetectTokensWithAccountsAPI = async (props?: {
2274+
/** Overwrite the tokens cache inside Tokens Controller */
2275+
overrideMockTokensCache?: (typeof sampleTokenA)[];
2276+
mockMultiChainAPI?: ReturnType<typeof mockMultiChainAccountsService>;
2277+
overrideMockTokenGetState?: Partial<TokensControllerState>;
2278+
}) => {
2279+
const {
2280+
overrideMockTokensCache = [sampleTokenA, sampleTokenB],
2281+
mockMultiChainAPI,
2282+
overrideMockTokenGetState,
2283+
} = props ?? {};
2284+
2285+
// Arrange - RPC Tokens Flow - Uses sampleTokenA
2286+
const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
2287+
[sampleTokenA.address]: new BN(1),
2288+
});
2289+
2290+
// Arrange - API Tokens Flow - Uses sampleTokenB
2291+
const { mockFetchSupportedNetworks, mockFetchMultiChainBalances } =
2292+
mockMultiChainAPI ?? mockMultiChainAccountsService();
2293+
2294+
if (!mockMultiChainAPI) {
2295+
mockFetchSupportedNetworks.mockResolvedValue([1]);
2296+
mockFetchMultiChainBalances.mockResolvedValue(
2297+
createMockGetBalancesResponse([sampleTokenB.address], 1),
2298+
);
2299+
}
2300+
2301+
// Arrange - Selected Account
2302+
const selectedAccount = createMockInternalAccount({
2303+
address: '0x0000000000000000000000000000000000000001',
2304+
});
2305+
2306+
// Arrange / Act - withController setup + invoke detectTokens
2307+
const { callAction } = await withController(
2308+
{
2309+
options: {
2310+
disabled: false,
2311+
getBalancesInSingleCall: mockGetBalancesInSingleCall,
2312+
useAccountsAPI: true, // USING ACCOUNTS API
2313+
},
2314+
mocks: {
2315+
getSelectedAccount: selectedAccount,
2316+
getAccount: selectedAccount,
2317+
},
2318+
},
2319+
async ({
2320+
controller,
2321+
mockTokenListGetState,
2322+
callActionSpy,
2323+
mockTokensGetState,
2324+
}) => {
2325+
const tokenCacheData: TokenListMap = {};
2326+
overrideMockTokensCache.forEach(
2327+
(t) =>
2328+
(tokenCacheData[t.address] = {
2329+
name: t.name,
2330+
symbol: t.symbol,
2331+
decimals: t.decimals,
2332+
address: t.address,
2333+
occurrences: 1,
2334+
aggregators: t.aggregators,
2335+
iconUrl: t.image,
2336+
}),
2337+
);
2338+
2339+
mockTokenListGetState({
2340+
...getDefaultTokenListState(),
2341+
tokensChainsCache: {
2342+
'0x1': {
2343+
timestamp: 0,
2344+
data: tokenCacheData,
2345+
},
2346+
},
2347+
});
2348+
2349+
if (overrideMockTokenGetState) {
2350+
mockTokensGetState({
2351+
...getDefaultTokensState(),
2352+
...overrideMockTokenGetState,
2353+
});
2354+
}
2355+
2356+
// Act
2357+
await controller.detectTokens({
2358+
networkClientId: NetworkType.mainnet,
2359+
selectedAddress: selectedAccount.address,
2360+
});
2361+
2362+
return {
2363+
callAction: callActionSpy,
2364+
};
2365+
},
2366+
);
2367+
2368+
const assertAddedTokens = (token: Token) =>
2369+
expect(callAction).toHaveBeenCalledWith(
2370+
'TokensController:addDetectedTokens',
2371+
[token],
2372+
{
2373+
chainId: ChainId.mainnet,
2374+
selectedAddress: selectedAccount.address,
2375+
},
2376+
);
2377+
2378+
const assertTokensNeverAdded = () =>
2379+
expect(callAction).not.toHaveBeenCalledWith(
2380+
'TokensController:addDetectedTokens',
2381+
);
2382+
2383+
return {
2384+
assertAddedTokens,
2385+
assertTokensNeverAdded,
2386+
mockFetchMultiChainBalances,
2387+
mockGetBalancesInSingleCall,
2388+
rpcToken: sampleTokenA,
2389+
apiToken: sampleTokenB,
2390+
};
2391+
};
2392+
2393+
it('should trigger and use Accounts API for detection', async () => {
2394+
const {
2395+
assertAddedTokens,
2396+
mockFetchMultiChainBalances,
2397+
apiToken,
2398+
mockGetBalancesInSingleCall,
2399+
} = await arrangeActTestDetectTokensWithAccountsAPI();
2400+
2401+
expect(mockFetchMultiChainBalances).toHaveBeenCalled();
2402+
expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled();
2403+
assertAddedTokens(apiToken);
2404+
});
2405+
2406+
it('uses the Accounts API but does not add unknown tokens', async () => {
2407+
// API returns sampleTokenB
2408+
// As this is not a known token (in cache), then is not added
2409+
const {
2410+
assertTokensNeverAdded,
2411+
mockFetchMultiChainBalances,
2412+
mockGetBalancesInSingleCall,
2413+
} = await arrangeActTestDetectTokensWithAccountsAPI({
2414+
overrideMockTokensCache: [sampleTokenA],
2415+
});
2416+
2417+
expect(mockFetchMultiChainBalances).toHaveBeenCalled();
2418+
expect(mockGetBalancesInSingleCall).not.toHaveBeenCalled();
2419+
assertTokensNeverAdded();
2420+
});
2421+
2422+
it('fallbacks from using the Accounts API if fails', async () => {
2423+
// Test 1 - fetch supported networks fails
2424+
let mockAPI = mockMultiChainAccountsService();
2425+
mockAPI.mockFetchSupportedNetworks.mockRejectedValue(
2426+
new Error('Mock Error'),
2427+
);
2428+
let actResult = await arrangeActTestDetectTokensWithAccountsAPI({
2429+
mockMultiChainAPI: mockAPI,
2430+
});
2431+
2432+
expect(actResult.mockFetchMultiChainBalances).not.toHaveBeenCalled(); // never called as could not fetch supported networks...
2433+
expect(actResult.mockGetBalancesInSingleCall).toHaveBeenCalled(); // ...so then RPC flow was initiated
2434+
actResult.assertAddedTokens(actResult.rpcToken);
2435+
2436+
// Test 2 - fetch multi chain fails
2437+
mockAPI = mockMultiChainAccountsService();
2438+
mockAPI.mockFetchMultiChainBalances.mockRejectedValue(
2439+
new Error('Mock Error'),
2440+
);
2441+
actResult = await arrangeActTestDetectTokensWithAccountsAPI({
2442+
mockMultiChainAPI: mockAPI,
2443+
});
2444+
2445+
expect(actResult.mockFetchMultiChainBalances).toHaveBeenCalled(); // API was called, but failed...
2446+
expect(actResult.mockGetBalancesInSingleCall).toHaveBeenCalled(); // ...so then RPC flow was initiated
2447+
actResult.assertAddedTokens(actResult.rpcToken);
2448+
});
2449+
2450+
it('uses the Accounts API but does not add tokens that are already added', async () => {
2451+
// Here we populate the token state with a token that exists in the tokenAPI.
2452+
// So the token retrieved from the API should not be added
2453+
const { assertTokensNeverAdded, mockFetchMultiChainBalances } =
2454+
await arrangeActTestDetectTokensWithAccountsAPI({
2455+
overrideMockTokenGetState: {
2456+
allDetectedTokens: {
2457+
'0x1': {
2458+
'0x0000000000000000000000000000000000000001': [
2459+
{
2460+
address: sampleTokenB.address,
2461+
name: sampleTokenB.name,
2462+
symbol: sampleTokenB.symbol,
2463+
decimals: sampleTokenB.decimals,
2464+
aggregators: sampleTokenB.aggregators,
2465+
},
2466+
],
2467+
},
2468+
},
2469+
},
2470+
});
2471+
2472+
expect(mockFetchMultiChainBalances).toHaveBeenCalled();
2473+
assertTokensNeverAdded();
2474+
});
22392475
});
22402476
});
22412477

@@ -2415,6 +2651,7 @@ async function withController<ReturnValue>(
24152651
getBalancesInSingleCall: jest.fn(),
24162652
trackMetaMetricsEvent: jest.fn(),
24172653
messenger: buildTokenDetectionControllerMessenger(controllerMessenger),
2654+
useAccountsAPI: false,
24182655
...options,
24192656
});
24202657
try {

0 commit comments

Comments
 (0)