Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add in-flight promise caching for `fetchSupportedNetworks()` to prevent duplicate concurrent requests
- Update `fetchTokenPrices()` and `fetchExchangeRates()` to only refresh supported networks/currencies when no cached value exists

### Fixed

- Fix `TokenBalancesController` missing custom tokens on AccountsAPI-supported chains
- AccountsAPI fetcher returns balances but misses custom tokens, the controller now detects this and triggers an RPC fallback for those specific tokens

## [99.3.1]

### Fixed
Expand Down
56 changes: 56 additions & 0 deletions packages/assets-controllers/src/TokenBalancesController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6860,5 +6860,61 @@ describe('TokenBalancesController', () => {
// token2 should NOT be present (value=undefined)
expect(balances?.[token2Checksum]).toBeUndefined();
});

it('should check for missed tokens and add chain back to remainingChains for fallback', () => {
// This test documents the fix for the bug where AccountsAPI misses custom tokens
// and doesn't trigger RPC fallback on popular chains.
//
// The fix checks if any custom tokens
// from this.#allTokens were missed by the API response. If so, it adds the chain back
// to `remainingChains` to force an RPC fallback for those tokens.
//
// Test scenario:
// 1. User has USDC (popular) and CustomToken (unknown) on Ethereum mainnet
// 2. AccountsAPI returns balance for USDC but misses CustomToken
// 3. Controller detects CustomToken is missing and adds 0x1 back to remainingChains
// 4. RPC fallback fetches CustomToken balance
// 5. Final state includes balances for both tokens

const chainId = '0x1';
const account = '0x123';
const popularToken = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; // USDC
const customToken = '0x9208d82f121806a34a39bb90733b4c5c54f3993e'; // Custom

// Simulated controller state
const allTokens = {
[chainId]: {
[account]: [
{ address: popularToken },
{ address: customToken }, // This will be missed by API
],
},
};

// Simulated API response (missing customToken)
const apiBalances = [
{ token: popularToken, chainId, account },
// customToken is MISSING!
];

// The fix logic: check for missed tokens
const remainingChains: string[] = [];
const expectedTokens = allTokens[chainId][account];
const returnedTokens = new Set(
apiBalances.map((b) => b.token.toLowerCase()),
);

expectedTokens.forEach((token) => {
if (!returnedTokens.has(token.address.toLowerCase())) {
// Custom token was missed! Add chain back for RPC fallback
if (!remainingChains.includes(chainId)) {
remainingChains.push(chainId);
}
}
});

// Verify the fix adds the chain back for fallback
expect(remainingChains).toContain(chainId);
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
});
38 changes: 38 additions & 0 deletions packages/assets-controllers/src/TokenBalancesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,44 @@ export class TokenBalancesController extends StaticIntervalPollingController<{
remainingChains = remainingChains.filter(
(chain) => !processed.has(chain),
);

// Check if API missed any custom tokens and force RPC fallback
const chainsWithMissingTokens = new Set<ChainIdHex>();
for (const chainId of supportedChains) {
const allTokensForChain = this.#allTokens[chainId];
if (!allTokensForChain) {
continue;
}

// Get all tokens that should exist for this chain
const accountsWithTokens = Object.keys(allTokensForChain);
for (const account of accountsWithTokens) {
const tokensForAccount = allTokensForChain[account];
if (!tokensForAccount) {
continue;
}

// Check if all tokens got balances from API
for (const token of tokensForAccount) {
const tokenAddress = checksum(token.address);
const hasBalance = result.balances.some(
(b) =>
b.chainId === chainId &&
checksum(b.account) === checksum(account) &&
checksum(b.token) === tokenAddress,
);
if (!hasBalance) {
chainsWithMissingTokens.add(chainId);
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

// Add chains with missing tokens back to remaining for RPC fallback
if (chainsWithMissingTokens.size > 0) {
const missingChainsList = Array.from(chainsWithMissingTokens);
remainingChains.push(...missingChainsList);
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

if (result.unprocessedChainIds?.length) {
Expand Down