⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `forceRefreshTokenList()` method to `TokenListController` to allow manual refresh of token cache ([#39628](https://github.com/MetaMask/metamask-extension/issues/39628))
- Add `TokenListController:forceRefreshTokenList` messenger action
- Method bypasses cache validation to fetch fresh token data from API
- Useful for refreshing stale token data on Base chain and other supported networks
- Add `HYPEREVM` support ([#7790](https://github.com/MetaMask/core/pull/7790))
- Add `HYPEREVM` in `SupportedTokenDetectionNetworks`
- Add `HYPEREVM` in `SUPPORTED_NETWORKS_ACCOUNTS_API_V4`
Expand Down
178 changes: 178 additions & 0 deletions packages/assets-controllers/src/TokenListController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2117,6 +2117,184 @@ describe('TokenListController', () => {
controller.destroy();
});
});

describe('forceRefreshTokenList', () => {
it('should force refresh token list even when cache is valid', async () => {
nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(ChainId.mainnet))
.reply(200, sampleMainnetTokenList)
.persist();

const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);
const controller = new TokenListController({
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
cacheRefreshThreshold: 4 * 60 * 60 * 1000, // 4 hours
});
await controller.initialize();

// First fetch to populate cache
await controller.fetchTokenList(ChainId.mainnet);
const initialTimestamp =
controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp;
expect(initialTimestamp).toBeDefined();

// Wait a bit to ensure timestamp would be different
await new Promise((resolve) => setTimeout(resolve, 10));

// Force refresh - should bypass cache validation
await controller.forceRefreshTokenList(ChainId.mainnet);

const refreshedTimestamp =
controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp;
expect(refreshedTimestamp).toBeDefined();
expect(refreshedTimestamp).toBeGreaterThan(initialTimestamp as number);

// Verify data is still correct
expect(
controller.state.tokensChainsCache[ChainId.mainnet].data,
).toStrictEqual(
sampleSingleChainState.tokensChainsCache[ChainId.mainnet].data,
);

controller.destroy();
});

it('should work via messenger action', async () => {
nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(ChainId.mainnet))
.reply(200, sampleMainnetTokenList)
.persist();

const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);
const controller = new TokenListController({
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
});
await controller.initialize();

// First fetch to populate cache
await controller.fetchTokenList(ChainId.mainnet);
const initialTimestamp =
controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp;
expect(initialTimestamp).toBeDefined();

// Wait a bit to ensure timestamp would be different
await new Promise((resolve) => setTimeout(resolve, 10));

// Force refresh via messenger action
await messenger.call('TokenListController:forceRefreshTokenList', ChainId.mainnet);

const refreshedTimestamp =
controller.state.tokensChainsCache[ChainId.mainnet]?.timestamp;
expect(refreshedTimestamp).toBeDefined();
expect(refreshedTimestamp).toBeGreaterThan(initialTimestamp as number);

controller.destroy();
});

it('should not refresh for unsupported networks', async () => {
const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);
const controller = new TokenListController({
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
});
await controller.initialize();

const unsupportedChainId = '0x1337' as Hex;
const initialState = { ...controller.state.tokensChainsCache };

// Should return early without error
await controller.forceRefreshTokenList(unsupportedChainId);

// State should remain unchanged
expect(controller.state.tokensChainsCache).toStrictEqual(initialState);

controller.destroy();
});

it('should handle API errors gracefully', async () => {
nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(ChainId.mainnet))
.reply(500, { error: 'Internal Server Error' })
.persist();

const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);
const controller = new TokenListController({
chainId: ChainId.mainnet,
messenger: restrictedMessenger,
state: {
tokensChainsCache: {
[ChainId.mainnet]: {
data: sampleMainnetTokensChainsCache,
timestamp: Date.now(),
},
},
},
});
await controller.initialize();

const existingCache = controller.state.tokensChainsCache[ChainId.mainnet];
expect(existingCache).toBeDefined();

// Force refresh should handle error gracefully
await controller.forceRefreshTokenList(ChainId.mainnet);

// Cache should remain unchanged on error
expect(controller.state.tokensChainsCache[ChainId.mainnet]).toStrictEqual(
existingCache,
);

controller.destroy();
});

it('should refresh Base chain (0x2105) token list', async () => {
const baseChainId = '0x2105' as Hex;
const baseTokenList = [
{
address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
symbol: 'USDC',
decimals: 6,
occurrences: 5,
name: 'USD Coin',
aggregators: ['CoinGecko', '1inch'],
iconUrl: `https://static.cx.metamask.io/api/v1/tokenIcons/${convertHexToDecimal(baseChainId)}/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.png`,
},
];

nock(tokenService.TOKEN_END_POINT_API)
.get(getTokensPath(baseChainId))
.reply(200, baseTokenList)
.persist();

const messenger = getMessenger();
const restrictedMessenger = getRestrictedMessenger(messenger);
const controller = new TokenListController({
chainId: baseChainId,
messenger: restrictedMessenger,
cacheRefreshThreshold: 4 * 60 * 60 * 1000, // 4 hours
});
await controller.initialize();

// Force refresh Base chain
await controller.forceRefreshTokenList(baseChainId);

const cache = controller.state.tokensChainsCache[baseChainId];
expect(cache).toBeDefined();
expect(cache?.data).toBeDefined();
expect(cache?.timestamp).toBeDefined();
expect(cache?.data['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913']).toBeDefined();
expect(
cache?.data['0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'].symbol,
).toBe('USDC');

controller.destroy();
});
});
});

/**
Expand Down
86 changes: 67 additions & 19 deletions packages/assets-controllers/src/TokenListController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ export type GetTokenListState = ControllerGetStateAction<
TokenListState
>;

export type TokenListControllerActions = GetTokenListState;
export type ForceRefreshTokenList = {
type: `${typeof name}:forceRefreshTokenList`;
handler: (chainId: Hex) => Promise<void>;
};

export type TokenListControllerActions = GetTokenListState | ForceRefreshTokenList;

type AllowedActions =
| NetworkControllerGetNetworkClientByIdAction
Expand Down Expand Up @@ -134,6 +139,12 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
*/
#previousTokensChainsCache: TokensChainsCache = {};

/**
* Tracks whether the controller has been initialized.
* Used to safely unregister action handlers in destroy().
*/
#isInitialized = false;

/**
* Debounce delay for persisting state changes (in milliseconds).
*/
Expand Down Expand Up @@ -236,6 +247,14 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
(newCache: TokensChainsCache) => this.#onCacheChanged(newCache),
(controllerState) => controllerState.tokensChainsCache,
);

// Register action handlers
this.messenger.registerActionHandler(
`${name}:forceRefreshTokenList`,
(chainId: Hex) => this.forceRefreshTokenList(chainId),
);

this.#isInitialized = true;
}

/**
Expand Down Expand Up @@ -521,6 +540,11 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
this.#persistDebounceTimer = undefined;
}
this.#changedChainsToPersist.clear();

// Unregister action handlers if they were registered
if (this.#isInitialized) {
this.messenger.unregisterActionHandler(`${name}:forceRefreshTokenList`);
}
}

/**
Expand Down Expand Up @@ -563,16 +587,13 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
}

/**
* Fetching token list from the Token Service API. This will fetch tokens across chains.
* State changes are automatically persisted via the stateChange subscription.
* Internal helper method to fetch and update token list from the API.
* Handles token formatting, state updates, and error handling.
*
* @param chainId - The chainId of the current chain triggering the fetch.
* @param chainId - The chainId of the chain to fetch tokens for.
* @returns A promise that resolves when the operation completes.
*/
async fetchTokenList(chainId: Hex): Promise<void> {
if (this.isCacheValid(chainId)) {
return;
}

async #fetchAndUpdateTokenList(chainId: Hex): Promise<void> {
// Fetch fresh token list from the API
const tokensFromAPI = await safelyExecute(
() =>
Expand Down Expand Up @@ -612,17 +633,29 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
// Only initialize with a new timestamp if there's no existing cache.
// If there's existing cache, keep it as-is without updating the timestamp
// to avoid making stale data appear "fresh" and preventing retry attempts.
if (!tokensFromAPI) {
const existingCache = this.state.tokensChainsCache[chainId];
if (!existingCache) {
// No existing cache - initialize empty (persistence happens automatically)
const newDataCache: DataCache = { data: {}, timestamp: Date.now() };
this.update((state) => {
state.tokensChainsCache[chainId] = newDataCache;
});
}
// If there's existing cache, keep it as-is (don't update timestamp or persist)
const existingCache = this.state.tokensChainsCache[chainId];
if (!existingCache) {
// No existing cache - initialize empty (persistence happens automatically)
const newDataCache: DataCache = { data: {}, timestamp: Date.now() };
this.update((state) => {
state.tokensChainsCache[chainId] = newDataCache;
});
}
// If there's existing cache, keep it as-is (don't update timestamp or persist)
}

/**
* Fetching token list from the Token Service API. This will fetch tokens across chains.
* State changes are automatically persisted via the stateChange subscription.
*
* @param chainId - The chainId of the current chain triggering the fetch.
*/
async fetchTokenList(chainId: Hex): Promise<void> {
if (this.isCacheValid(chainId)) {
return;
}

await this.#fetchAndUpdateTokenList(chainId);
}

isCacheValid(chainId: Hex): boolean {
Expand All @@ -633,6 +666,21 @@ export class TokenListController extends StaticIntervalPollingController<TokenLi
timestamp !== undefined && now - timestamp < this.#cacheRefreshThreshold
);
}

/**
* Force refresh the token list for a specific chain, bypassing cache validation.
* This method will fetch fresh data from the API regardless of cache validity.
*
* @param chainId - The chainId of the chain to refresh.
* @returns A promise that resolves when the refresh is complete.
*/
async forceRefreshTokenList(chainId: Hex): Promise<void> {
if (!isTokenListSupportedForNetwork(chainId)) {
return;
}

await this.#fetchAndUpdateTokenList(chainId);
}
}

export default TokenListController;