Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -440,14 +440,6 @@
"count": 4
}
},
"packages/assets-controllers/src/multi-chain-accounts-service/api-balance-fetcher.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 1
},
"id-length": {
"count": 1
}
},
"packages/assets-controllers/src/multi-chain-accounts-service/multi-chain-accounts.test.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 3
Expand Down Expand Up @@ -476,17 +468,6 @@
"count": 17
}
},
"packages/assets-controllers/src/rpc-service/rpc-balance-fetcher.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 1
},
"@typescript-eslint/prefer-nullish-coalescing": {
"count": 2
},
"id-length": {
"count": 1
}
},
"packages/assets-controllers/src/selectors/stringify-balance.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 1
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Gate `TokenListController` polling on controller initialization to avoid duplicate token list API requests during startup races ([#8113](https://github.com/MetaMask/core/pull/8113))
- 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))

## [100.1.0]

Expand Down
89 changes: 89 additions & 0 deletions packages/assets-controllers/src/TokenBalancesController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import BN from 'bn.js';
import type nock from 'nock';

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

it('should forward unprocessed token fallbacks from API fetcher to RPC fetcher', async () => {
const chainId = '0x1' as ChainIdHex;
const accountAddress = '0x0000000000000000000000000000000000000000';
const token1 = '0x1111111111111111111111111111111111111111';

const tokens = {
allDetectedTokens: {},
allTokens: {
[chainId]: {
[accountAddress]: [
{ address: token1, symbol: 'TK1', decimals: 18 },
],
},
},
};

const selectedAccount = createMockInternalAccount({
address: accountAddress,
});

const apiFetchSpy = jest
.spyOn(AccountsApiBalanceFetcher.prototype, 'fetch')
.mockResolvedValue({
balances: [
{
success: true,
value: new BN(1),
account: accountAddress,
token: NATIVE_TOKEN_ADDRESS as Hex,
chainId,
},
],
unprocessedTokens: {
[accountAddress]: {
[chainId]: [token1],
},
},
});

const { controller } = setupController({
tokens,
listAccounts: [selectedAccount],
config: {
accountsApiChainIds: () => [chainId],
},
});

const rpcFetchSpy = jest
.spyOn(RpcBalanceFetcher.prototype, 'fetch')
.mockResolvedValue({
balances: [
{
success: true,
value: new BN(200),
account: accountAddress as ChecksumAddress,
token: token1 as Hex,
chainId,
},
],
});

await controller.updateBalances({
chainIds: [chainId],
queryAllAccounts: true,
});

expect(apiFetchSpy).toHaveBeenCalled();
expect(rpcFetchSpy).toHaveBeenCalledWith(
expect.objectContaining({
chainIds: [chainId],
unprocessedTokens: {
[accountAddress]: {
[chainId]: [token1],
},
},
}),
);

expect(
controller.state.tokenBalances[accountAddress as ChecksumAddress]?.[
chainId
]?.[toChecksumHexAddress(token1) as ChecksumAddress],
).toStrictEqual(toHex(200));

apiFetchSpy.mockRestore();
rpcFetchSpy.mockRestore();
});

it('should handle fetcher throwing error (lines 868-880)', async () => {
const chainId = '0x1';
const accountAddress = '0x0000000000000000000000000000000000000000';
Expand Down
95 changes: 80 additions & 15 deletions packages/assets-controllers/src/TokenBalancesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-ba
import type {
BalanceFetcher,
ProcessedBalance,
UnprocessedTokens,
} from './multi-chain-accounts-service/api-balance-fetcher';
import { RpcBalanceFetcher } from './rpc-service/rpc-balance-fetcher';
import type {
Expand Down Expand Up @@ -278,7 +279,7 @@ export class TokenBalancesController extends StaticIntervalPollingController<{

readonly #isOnboarded: () => boolean;

readonly #balanceFetchers: BalanceFetcher[];
readonly #balanceFetchers: { fetcher: BalanceFetcher; name: string }[];

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

Expand Down Expand Up @@ -348,11 +349,21 @@ export class TokenBalancesController extends StaticIntervalPollingController<{

// Always include AccountsApiFetcher - it dynamically checks allowExternalServices() in supports()
this.#balanceFetchers = [
this.#createAccountsApiFetcher(),
new RpcBalanceFetcher(this.#getProvider, this.#getNetworkClient, () => ({
allTokens: this.#allTokens,
allDetectedTokens: this.#detectedTokens,
})),
{
fetcher: this.#createAccountsApiFetcher(),
name: 'AccountsApiFetcher',
},
{
fetcher: new RpcBalanceFetcher(
this.#getProvider,
this.#getNetworkClient,
() => ({
allTokens: this.#allTokens,
allDetectedTokens: this.#detectedTokens,
}),
),
name: 'RpcFetcher',
},
];

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

for (const fetcher of this.#balanceFetchers) {
for (const { fetcher, name: fetcherName } of this.#balanceFetchers) {
const supportedChains = remainingChains.filter((chain) =>
fetcher.supports(chain),
);
Expand All @@ -834,8 +847,10 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
selectedAccount,
allAccounts,
jwtToken,
unprocessedTokens: previousUnprocessedTokens,
});

// Add balances, and removed processed chains
if (result.balances?.length) {
aggregated.push(...result.balances);

Expand All @@ -845,24 +860,74 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
);
}

if (result.unprocessedChainIds?.length) {
const currentRemaining = [...remainingChains];
const chainsToAdd = result.unprocessedChainIds.filter(
(chainId) =>
supportedChains.includes(chainId) &&
!currentRemaining.includes(chainId),
// Add unprocessed chains (from missing chains or missing tokens)
if (result.unprocessedChainIds || result.unprocessedTokens) {
const resultUnprocessedChains = result.unprocessedChainIds ?? [];
const resultUnsupportedTokenChains = Object.entries(
result.unprocessedTokens ?? {},
).flatMap(([_account, chainMap]) => Object.keys(chainMap)) as Hex[];
const unprocessedChainIds = Array.from(
new Set([
...resultUnprocessedChains,
...resultUnsupportedTokenChains,
]),
);

remainingChains = Array.from(
new Set([...remainingChains, ...unprocessedChainIds]),
);
remainingChains.push(...chainsToAdd);

this.messenger
.call('TokenDetectionController:detectTokens', {
chainIds: result.unprocessedChainIds,
chainIds: unprocessedChainIds,
forceRpc: true,
})
.catch(() => {
// Silently handle token detection errors
});
}

// Balance Error Reporting - for unprocessed tokens from last fetcher, if balances are retrieved
const unprocessedTokensForReporting = previousUnprocessedTokens;
if (unprocessedTokensForReporting && result.balances?.length) {
const confirmedUnprocessedTokens: {
chainId: string;
tokenAddress: string;
}[] = [];

// Capture balances that were found (> 0 balance), and was unprocessed
result.balances.forEach((bal) => {
const lowercaseAccount = bal.account.toLowerCase();
const lowercaseTokenAddress = bal.token.toLowerCase();

const hasResultBalance =
bal.success && bal.token && bal.value && !bal.value.isZero();
const isUnprocessed = unprocessedTokensForReporting?.[
lowercaseAccount
]?.[bal.chainId]?.includes(lowercaseTokenAddress);

if (hasResultBalance && isUnprocessed) {
confirmedUnprocessedTokens.push({
chainId: bal.chainId,
tokenAddress: lowercaseTokenAddress,
});
}
});

const confirmedUnprocessedTokenStrings =
confirmedUnprocessedTokens.map(
(token) => `${token.chainId}:${token.tokenAddress}`,
);
if (confirmedUnprocessedTokens.length) {
console.warn(
`TokenBalanceController: fetcher ${previousFetcherName} did not process tokens (instead handled by fetcher ${fetcherName}): ${confirmedUnprocessedTokenStrings.join(', ')}`,
);
}
}

// Set new previous fields
previousUnprocessedTokens = result.unprocessedTokens;
previousFetcherName = fetcherName;
} catch (error) {
console.warn(
`Balance fetcher failed for chains ${supportedChains.join(', ')}: ${String(error)}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3408,12 +3408,10 @@ describe('TokenDetectionController', () => {
);
});

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

const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();

await withController(
{
options: {
Expand All @@ -3434,19 +3432,12 @@ describe('TokenDetectionController', () => {
chainId: chainId as Hex,
});

// Should log warning about missing token metadata
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Token metadata not found in cache'),
);

// Should not call addTokens if no tokens have metadata
expect(callActionSpy).not.toHaveBeenCalledWith(
'TokensController:addTokens',
expect.anything(),
expect.anything(),
);

consoleSpy.mockRestore();
},
);
});
Expand Down
6 changes: 0 additions & 6 deletions packages/assets-controllers/src/TokenDetectionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,9 +838,6 @@ export class TokenDetectionController extends StaticIntervalPollingController<To
this.#tokensChainsCache[chainId]?.data?.[lowercaseTokenAddress];

if (!tokenData) {
console.warn(
`Token metadata not found in cache for ${tokenAddress} on chain ${chainId}`,
);
continue;
}

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

if (!tokenData) {
console.warn(
`Token metadata not found in cache for ${tokenAddress} on chain ${chainId}`,
);
continue;
}

Expand Down
Loading
Loading