Skip to content

Commit 82e9c02

Browse files
feat: TokenBalanceController architect handling unprocessed tokens (#8132)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Explanation * **What is the current state of things and why does it need to change?** Previously, the `AccountsApiBalanceFetcher` would zero out balances for non-native tokens it couldn't retrieve. The `TokenBalancesController` would then only pass on unsupported *chains* to subsequent fetchers. This prevented the RPC balance fetcher from attempting to retrieve balances for these "missed" tokens. Additionally, `TokenDetectionController` had noisy `console.warn` logs for token metadata cache misses. * **What is the solution your changes offer and how does it work?** This PR modifies the balance fetching flow to return "unprocessed tokens" for non-native tokens that the `AccountsApiBalanceFetcher` cannot process. These `unprocessedTokens` are then explicitly passed through the `TokenBalancesController` to subsequent fetchers. The `RpcBalanceFetcher` now accepts these `unprocessedTokens` and, when provided, will only fetch balances for those specific tokens, avoiding redundant work for native or staked tokens. The `console.warn` logs in `TokenDetectionController` have also been removed. * **Are there any changes whose purpose might not obvious to those unfamiliar with the domain?** The introduction of the `unprocessedTokens` parameter and its propagation across fetchers is key. When `unprocessedTokens` are present, the RPC fetcher operates in a targeted mode, only querying for the specified ERC-20 tokens, rather than performing a full chain-wide balance fetch. * **If your primary goal was to update one package but you found you had to update another one along the way, why did you do so?** The change required modifications across `api-balance-fetcher`, `TokenBalancesController`, and `rpc-balance-fetcher` to correctly implement the "unprocessed tokens" handoff and fallback mechanism. `TokenDetectionController` was updated to remove noisy logs as requested. Code Walkthrough: https://www.loom.com/share/c38c5369dcb54d62908747078a899be9 * **If you had to upgrade a dependency, why did you do so?** No dependency upgrades were required. ## References * https://consensyssoftware.atlassian.net/browse/ASSETS-2871 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- <p><a href="https://cursor.com/agents/bc-03398f4f-6aed-4e3f-a24a-cfe4b63b7946"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-03398f4f-6aed-4e3f-a24a-cfe4b63b7946"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</p> <!-- CURSOR_AGENT_PR_BODY_END -->
1 parent e3e7bab commit 82e9c02

File tree

10 files changed

+548
-190
lines changed

10 files changed

+548
-190
lines changed

eslint-suppressions.json

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -440,14 +440,6 @@
440440
"count": 4
441441
}
442442
},
443-
"packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts": {
444-
"@typescript-eslint/explicit-function-return-type": {
445-
"count": 1
446-
},
447-
"id-length": {
448-
"count": 1
449-
}
450-
},
451443
"packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts": {
452444
"@typescript-eslint/explicit-function-return-type": {
453445
"count": 3
@@ -476,17 +468,6 @@
476468
"count": 17
477469
}
478470
},
479-
"packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts": {
480-
"@typescript-eslint/explicit-function-return-type": {
481-
"count": 1
482-
},
483-
"@typescript-eslint/prefer-nullish-coalescing": {
484-
"count": 2
485-
},
486-
"id-length": {
487-
"count": 1
488-
}
489-
},
490471
"packages/assets-controllers/src/selectors/stringify-balance.ts": {
491472
"@typescript-eslint/explicit-function-return-type": {
492473
"count": 1

packages/assets-controllers/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
### Fixed
1919

2020
- Gate `TokenListController` polling on controller initialization to avoid duplicate token list API requests during startup races ([#8113](https://github.com/MetaMask/core/pull/8113))
21+
- Update token balance fallback behavior so missing ERC-20 balances from `AccountsApiBalanceFetcher` are returned as `unprocessedTokens` and fetched through RPC fallback, rather than being forcibly set to zero ([#8132](https://github.com/MetaMask/core/pull/8132))
2122

2223
## [100.1.0]
2324

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

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import BN from 'bn.js';
1717
import type nock from 'nock';
1818

1919
import { mockAPI_accountsAPI_MultichainAccountBalances as mockAPIAccountsAPIMultichainAccountBalancesCamelCase } from './__fixtures__/account-api-v4-mocks';
20+
import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher';
2021
import * as multicall from './multicall';
2122
import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher';
2223
import type {
@@ -6680,6 +6681,94 @@ describe('TokenBalancesController', () => {
66806681
messengerCallSpy.mockRestore();
66816682
});
66826683

6684+
it('should forward unprocessed token fallbacks from API fetcher to RPC fetcher', async () => {
6685+
const chainId = '0x1' as ChainIdHex;
6686+
const accountAddress = '0x0000000000000000000000000000000000000000';
6687+
const token1 = '0x1111111111111111111111111111111111111111';
6688+
6689+
const tokens = {
6690+
allDetectedTokens: {},
6691+
allTokens: {
6692+
[chainId]: {
6693+
[accountAddress]: [
6694+
{ address: token1, symbol: 'TK1', decimals: 18 },
6695+
],
6696+
},
6697+
},
6698+
};
6699+
6700+
const selectedAccount = createMockInternalAccount({
6701+
address: accountAddress,
6702+
});
6703+
6704+
const apiFetchSpy = jest
6705+
.spyOn(AccountsApiBalanceFetcher.prototype, 'fetch')
6706+
.mockResolvedValue({
6707+
balances: [
6708+
{
6709+
success: true,
6710+
value: new BN(1),
6711+
account: accountAddress,
6712+
token: NATIVE_TOKEN_ADDRESS as Hex,
6713+
chainId,
6714+
},
6715+
],
6716+
unprocessedTokens: {
6717+
[accountAddress]: {
6718+
[chainId]: [token1],
6719+
},
6720+
},
6721+
});
6722+
6723+
const { controller } = setupController({
6724+
tokens,
6725+
listAccounts: [selectedAccount],
6726+
config: {
6727+
accountsApiChainIds: () => [chainId],
6728+
},
6729+
});
6730+
6731+
const rpcFetchSpy = jest
6732+
.spyOn(RpcBalanceFetcher.prototype, 'fetch')
6733+
.mockResolvedValue({
6734+
balances: [
6735+
{
6736+
success: true,
6737+
value: new BN(200),
6738+
account: accountAddress as ChecksumAddress,
6739+
token: token1 as Hex,
6740+
chainId,
6741+
},
6742+
],
6743+
});
6744+
6745+
await controller.updateBalances({
6746+
chainIds: [chainId],
6747+
queryAllAccounts: true,
6748+
});
6749+
6750+
expect(apiFetchSpy).toHaveBeenCalled();
6751+
expect(rpcFetchSpy).toHaveBeenCalledWith(
6752+
expect.objectContaining({
6753+
chainIds: [chainId],
6754+
unprocessedTokens: {
6755+
[accountAddress]: {
6756+
[chainId]: [token1],
6757+
},
6758+
},
6759+
}),
6760+
);
6761+
6762+
expect(
6763+
controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[
6764+
chainId
6765+
]?.[toChecksumHexAddress(token1) as ChecksumAddress],
6766+
).toStrictEqual(toHex(200));
6767+
6768+
apiFetchSpy.mockRestore();
6769+
rpcFetchSpy.mockRestore();
6770+
});
6771+
66836772
it('should handle fetcher throwing error (lines 868-880)', async () => {
66846773
const chainId = '0x1';
66856774
const accountAddress = '0x0000000000000000000000000000000000000000';

packages/assets-controllers/src/TokenBalancesController.ts

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-ba
6666
import type {
6767
BalanceFetcher,
6868
ProcessedBalance,
69+
UnprocessedTokens,
6970
} from './multi-chain-accounts-service/api-balance-fetcher';
7071
import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher';
7172
import type {
@@ -278,7 +279,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
278279

279280
readonly #isOnboarded: () => boolean;
280281

281-
readonly #balanceFetchers: BalanceFetcher[];
282+
readonly #balanceFetchers: { fetcher: BalanceFetcher; name: string }[];
282283

283284
#allTokens: TokensControllerState['allTokens'] = {};
284285

@@ -348,11 +349,21 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
348349

349350
// Always include AccountsApiFetcher - it dynamically checks allowExternalServices() in supports()
350351
this.#balanceFetchers = [
351-
this.#createAccountsApiFetcher(),
352-
new RpcBalanceFetcher(this.#getProvider, this.#getNetworkClient, () => ({
353-
allTokens: this.#allTokens,
354-
allDetectedTokens: this.#detectedTokens,
355-
})),
352+
{
353+
fetcher: this.#createAccountsApiFetcher(),
354+
name: 'AccountsApiFetcher',
355+
},
356+
{
357+
fetcher: new RpcBalanceFetcher(
358+
this.#getProvider,
359+
this.#getNetworkClient,
360+
() => ({
361+
allTokens: this.#allTokens,
362+
allDetectedTokens: this.#detectedTokens,
363+
}),
364+
),
365+
name: 'RpcFetcher',
366+
},
356367
];
357368

358369
this.setIntervalLength(interval);
@@ -818,8 +829,10 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
818829
}): Promise<ProcessedBalance[]> {
819830
const aggregated: ProcessedBalance[] = [];
820831
let remainingChains = [...targetChains];
832+
let previousUnprocessedTokens: UnprocessedTokens | undefined;
833+
let previousFetcherName: string | undefined;
821834

822-
for (const fetcher of this.#balanceFetchers) {
835+
for (const { fetcher, name: fetcherName } of this.#balanceFetchers) {
823836
const supportedChains = remainingChains.filter((chain) =>
824837
fetcher.supports(chain),
825838
);
@@ -834,8 +847,10 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
834847
selectedAccount,
835848
allAccounts,
836849
jwtToken,
850+
unprocessedTokens: previousUnprocessedTokens,
837851
});
838852

853+
// Add balances, and removed processed chains
839854
if (result.balances?.length) {
840855
aggregated.push(...result.balances);
841856

@@ -845,24 +860,74 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
845860
);
846861
}
847862

848-
if (result.unprocessedChainIds?.length) {
849-
const currentRemaining = [...remainingChains];
850-
const chainsToAdd = result.unprocessedChainIds.filter(
851-
(chainId) =>
852-
supportedChains.includes(chainId) &&
853-
!currentRemaining.includes(chainId),
863+
// Add unprocessed chains (from missing chains or missing tokens)
864+
if (result.unprocessedChainIds || result.unprocessedTokens) {
865+
const resultUnprocessedChains = result.unprocessedChainIds ?? [];
866+
const resultUnsupportedTokenChains = Object.entries(
867+
result.unprocessedTokens ?? {},
868+
).flatMap(([_account, chainMap]) => Object.keys(chainMap)) as Hex[];
869+
const unprocessedChainIds = Array.from(
870+
new Set([
871+
...resultUnprocessedChains,
872+
...resultUnsupportedTokenChains,
873+
]),
874+
);
875+
876+
remainingChains = Array.from(
877+
new Set([...remainingChains, ...unprocessedChainIds]),
854878
);
855-
remainingChains.push(...chainsToAdd);
856879

857880
this.messenger
858881
.call('TokenDetectionController:detectTokens', {
859-
chainIds: result.unprocessedChainIds,
882+
chainIds: unprocessedChainIds,
860883
forceRpc: true,
861884
})
862885
.catch(() => {
863886
// Silently handle token detection errors
864887
});
865888
}
889+
890+
// Balance Error Reporting - for unprocessed tokens from last fetcher, if balances are retrieved
891+
const unprocessedTokensForReporting = previousUnprocessedTokens;
892+
if (unprocessedTokensForReporting && result.balances?.length) {
893+
const confirmedUnprocessedTokens: {
894+
chainId: string;
895+
tokenAddress: string;
896+
}[] = [];
897+
898+
// Capture balances that were found (> 0 balance), and was unprocessed
899+
result.balances.forEach((bal) => {
900+
const lowercaseAccount = bal.account.toLowerCase();
901+
const lowercaseTokenAddress = bal.token.toLowerCase();
902+
903+
const hasResultBalance =
904+
bal.success && bal.token && bal.value && !bal.value.isZero();
905+
const isUnprocessed = unprocessedTokensForReporting?.[
906+
lowercaseAccount
907+
]?.[bal.chainId]?.includes(lowercaseTokenAddress);
908+
909+
if (hasResultBalance && isUnprocessed) {
910+
confirmedUnprocessedTokens.push({
911+
chainId: bal.chainId,
912+
tokenAddress: lowercaseTokenAddress,
913+
});
914+
}
915+
});
916+
917+
const confirmedUnprocessedTokenStrings =
918+
confirmedUnprocessedTokens.map(
919+
(token) => `${token.chainId}:${token.tokenAddress}`,
920+
);
921+
if (confirmedUnprocessedTokens.length) {
922+
console.warn(
923+
`TokenBalanceController: fetcher ${previousFetcherName} did not process tokens (instead handled by fetcher ${fetcherName}): ${confirmedUnprocessedTokenStrings.join(', ')}`,
924+
);
925+
}
926+
}
927+
928+
// Set new previous fields
929+
previousUnprocessedTokens = result.unprocessedTokens;
930+
previousFetcherName = fetcherName;
866931
} catch (error) {
867932
console.warn(
868933
`Balance fetcher failed for chains ${supportedChains.join(', ')}: ${String(error)}`,

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

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3408,12 +3408,10 @@ describe('TokenDetectionController', () => {
34083408
);
34093409
});
34103410

3411-
it('should skip tokens not found in cache and log warning', async () => {
3411+
it('should skip tokens not found in cache', async () => {
34123412
const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
34133413
const chainId = '0xa86a';
34143414

3415-
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
3416-
34173415
await withController(
34183416
{
34193417
options: {
@@ -3434,19 +3432,12 @@ describe('TokenDetectionController', () => {
34343432
chainId: chainId as Hex,
34353433
});
34363434

3437-
// Should log warning about missing token metadata
3438-
expect(consoleSpy).toHaveBeenCalledWith(
3439-
expect.stringContaining('Token metadata not found in cache'),
3440-
);
3441-
34423435
// Should not call addTokens if no tokens have metadata
34433436
expect(callActionSpy).not.toHaveBeenCalledWith(
34443437
'TokensController:addTokens',
34453438
expect.anything(),
34463439
expect.anything(),
34473440
);
3448-
3449-
consoleSpy.mockRestore();
34503441
},
34513442
);
34523443
});

packages/assets-controllers/src/TokenDetectionController.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -838,9 +838,6 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
838838
this.#tokensChainsCache[chainId]?.data?.[lowercaseTokenAddress];
839839

840840
if (!tokenData) {
841-
console.warn(
842-
`Token metadata not found in cache for ${tokenAddress} on chain ${chainId}`,
843-
);
844841
continue;
845842
}
846843

@@ -961,9 +958,6 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
961958
this.#tokensChainsCache[chainId]?.data?.[lowercaseTokenAddress];
962959

963960
if (!tokenData) {
964-
console.warn(
965-
`Token metadata not found in cache for ${tokenAddress} on chain ${chainId}`,
966-
);
967961
continue;
968962
}
969963

0 commit comments

Comments
 (0)