Skip to content

Commit

Permalink
feat: add and use Accounts API for Account balance calls (#4781)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
Prithpal-Sooriya authored Oct 18, 2024
1 parent 8fb04fc commit 1251b86
Show file tree
Hide file tree
Showing 8 changed files with 681 additions and 5 deletions.
237 changes: 237 additions & 0 deletions packages/assets-controllers/src/TokenDetectionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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<typeof mockMultiChainAccountsService>;
overrideMockTokenGetState?: Partial<TokensControllerState>;
}) => {
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();
});
});
});

Expand Down Expand Up @@ -2415,6 +2651,7 @@ async function withController<ReturnValue>(
getBalancesInSingleCall: jest.fn(),
trackMetaMetricsEvent: jest.fn(),
messenger: buildTokenDetectionControllerMessenger(controllerMessenger),
useAccountsAPI: false,
...options,
});
try {
Expand Down
Loading

0 comments on commit 1251b86

Please sign in to comment.