From 526896d7fd18c45503ae8d0f266b8be2fff5902b Mon Sep 17 00:00:00 2001
From: Michele Esposito <34438276+mikesposito@users.noreply.github.com>
Date: Wed, 6 May 2026 10:54:01 +0200
Subject: [PATCH 01/11] feat: update `BUILT_IN_NETWORKS` (#8713)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Explanation
Additional Infura networks have been added in #8680, but the
`BUILT_IN_NETWORKS` object was missed. This object is not used outside
of tests in `core`, though it’s exported by `controller-utils` and used
by clients. This PR adds the networks to `BUILT_IN_NETWORKS` as well
## References
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Low Risk**
> Low risk: adds new entries to exported network constants and updates
the changelog; behavior changes only for consumers referencing these
newly supported networks.
>
> **Overview**
> Adds the previously-missing Infura mainnet networks (`monad-mainnet`,
`zksync-mainnet`, `megaeth-mainnet`, `avalanche-mainnet`) to
`BUILT_IN_NETWORKS`, including their `chainId`, `ticker`, and
`blockExplorerUrl` mappings.
>
> Updates the `@metamask/controller-utils` changelog entry to reflect
the expanded `BUILT_IN_NETWORKS` coverage.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4cb10188d0c8ad694f3806b787a72480c2970052. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
packages/controller-utils/CHANGELOG.md | 2 +-
packages/controller-utils/src/constants.ts | 28 ++++++++++++++++++++++
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/packages/controller-utils/CHANGELOG.md b/packages/controller-utils/CHANGELOG.md
index 31c761889e..cc63dae2f8 100644
--- a/packages/controller-utils/CHANGELOG.md
+++ b/packages/controller-utils/CHANGELOG.md
@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The event still emits a `FailureReason` when retries are exhausted.
- Update `normalizeEnsName` regex to allow ENS names with 3 or more characters (previously required 7 or more) ([#8510](https://github.com/MetaMask/core/pull/8510))
- Update default Sei Mainnet block explorer URL from `seitrace.com` to `seiscan.io` ([#8545](https://github.com/MetaMask/core/pull/8545))
-- Update `InfuraNetworkType`, `ChainId`, `NetworksTicker`, `BlockExplorerUrl`, and `NetworkNickname` to include missing Infura networks ([#8680](https://github.com/MetaMask/core/pull/8680))
+- Update `BUILT_IN_NETWORKS`, `InfuraNetworkType`, `ChainId`, `NetworksTicker`, `BlockExplorerUrl`, `NetworkNickname` to include missing Infura networks ([#8680](https://github.com/MetaMask/core/pull/8680), [#8713](https://github.com/MetaMask/core/pull/8713))
## [11.20.0]
diff --git a/packages/controller-utils/src/constants.ts b/packages/controller-utils/src/constants.ts
index 49a1162abc..99f23aa72d 100644
--- a/packages/controller-utils/src/constants.ts
+++ b/packages/controller-utils/src/constants.ts
@@ -178,6 +178,34 @@ export const BUILT_IN_NETWORKS = {
blockExplorerUrl: BlockExplorerUrl['sei-mainnet'],
},
},
+ [NetworkType['monad-mainnet']]: {
+ chainId: ChainId['monad-mainnet'],
+ ticker: NetworksTicker['monad-mainnet'],
+ rpcPrefs: {
+ blockExplorerUrl: BlockExplorerUrl['monad-mainnet'],
+ },
+ },
+ [NetworkType['zksync-mainnet']]: {
+ chainId: ChainId['zksync-mainnet'],
+ ticker: NetworksTicker['zksync-mainnet'],
+ rpcPrefs: {
+ blockExplorerUrl: BlockExplorerUrl['zksync-mainnet'],
+ },
+ },
+ [NetworkType['megaeth-mainnet']]: {
+ chainId: ChainId['megaeth-mainnet'],
+ ticker: NetworksTicker['megaeth-mainnet'],
+ rpcPrefs: {
+ blockExplorerUrl: BlockExplorerUrl['megaeth-mainnet'],
+ },
+ },
+ [NetworkType['avalanche-mainnet']]: {
+ chainId: ChainId['avalanche-mainnet'],
+ ticker: NetworksTicker['avalanche-mainnet'],
+ rpcPrefs: {
+ blockExplorerUrl: BlockExplorerUrl['avalanche-mainnet'],
+ },
+ },
[NetworkType.rpc]: {
chainId: undefined,
blockExplorerUrl: undefined,
From 9a12df40b2b7720f91a30c610c5d91d68dfc1510 Mon Sep 17 00:00:00 2001
From: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Date: Wed, 6 May 2026 11:23:06 +0200
Subject: [PATCH 02/11] chore: decouple controllers from tokens list (#8700)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Explanation
This PR decouples the `TokenDetectionController` and `TokensController`
from the `TokenListController` by introducing a new shared
`TokenListService`. This architectural improvement provides:
- **Reduced coupling**: Controllers no longer depend on
`TokenListController` state/messaging
- **Enhanced caching**: Token lists are cached in-memory per chain for 4
hours using TanStack Query
- **Better performance**: Optimized token list fetching with proper
caching and deduplication
- **Simplified data flow**: Token enrichment moves from reactive events
to one-time initialization
### Key Changes
#### New `TokenListService`
- Introduces a TanStack Query-backed service for fetching and caching
token lists
- Provides 4-hour in-memory caching per chain ID
- Exports `TokenListService` class and `buildTokenListMap` utility
function
- Adds `@tanstack/query-core` as a new dependency
#### Breaking Changes to Controllers
- **`TokenDetectionController`**: Now requires `tokenListService` in
constructor options
- **`TokensController`**: Now requires `tokenListService` in constructor
options
- Both controllers are decoupled from `TokenListController` messaging
system
- `TokenDetectionController` no longer automatically restarts detection
on `TokenListController:stateChange`
#### Token Detection Improvements
- Token list metadata fetched per detection pass with address
normalization
- Enhanced error handling with failure-safe early returns
- Improved mUSD deduplication and websocket/polling guards
#### Token Enrichment Changes
- `TokensController` switches from reactive events to one-time async
enrichment at initialization
- Multi-chain enrichment using `Promise.allSettled` for resilience
- Token `name` and `rwaData` enriched once during initialization vs. on
every state change
### Testing Updates
- Updated tests for new architecture
- Added comprehensive `TokenListService` unit tests
- Enhanced token detection test coverage
**Note**: This introduces breaking changes requiring constructor updates
for both controllers. Consumers will need to provide the new
`tokenListService` dependency.
## References
Mobile: https://github.com/MetaMask/metamask-mobile/pull/29743
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Medium Risk**
> Introduces a new shared caching layer and changes
`TokenDetectionController`/`TokensController` construction and runtime
behavior, which can impact token discovery/enrichment if consumers don’t
wire `tokenListService` correctly or if cache/normalization assumptions
differ across chains.
>
> **Overview**
> Adds a new `TokenListService` (TanStack Query-backed) to fetch + cache
per-chain token lists in-memory for 4 hours, exporting both
`TokenListService` and `buildTokenListMap`, and adds
`@tanstack/query-core` as a direct dependency.
>
> **BREAKING:** `TokenDetectionController` and `TokensController` now
require a `tokenListService` constructor option and no longer depend on
`TokenListController` actions/events; token detection now pulls fresh
token-list snapshots per chain from the service (with lowercase-key
normalization and graceful early-return on fetch failures) and no longer
restarts on `TokenListController:stateChange`.
>
> Changes token metadata enrichment in `TokensController` from reactive
updates on token-list state changes to a one-time async initialization
pass (multi-chain, `Promise.allSettled`) that fills `name`/`rwaData`,
and updates tests/changelog accordingly (including new
`TokenListService` unit tests and expanded mUSD/detection guard
coverage).
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c67b9ef0450d64bdd0196d5a04003abb89ea0193. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
eslint-suppressions.json | 10 +-
packages/assets-controllers/CHANGELOG.md | 13 +
packages/assets-controllers/package.json | 1 +
...DetectionController-method-action-types.ts | 2 +-
.../src/TokenDetectionController.test.ts | 1126 ++++++++---------
.../src/TokenDetectionController.ts | 185 ++-
.../src/TokenListService.test.ts | 171 +++
.../src/TokenListService.ts | 104 ++
.../src/TokensController.test.ts | 196 ++-
.../src/TokensController.ts | 102 +-
packages/assets-controllers/src/index.ts | 1 +
yarn.lock | 1 +
12 files changed, 1016 insertions(+), 896 deletions(-)
create mode 100644 packages/assets-controllers/src/TokenListService.test.ts
create mode 100644 packages/assets-controllers/src/TokenListService.ts
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 4201574ddf..1e3f44bab3 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -423,12 +423,12 @@
},
"packages/assets-controllers/src/TokenDetectionController.test.ts": {
"no-restricted-syntax": {
- "count": 2
+ "count": 1
}
},
"packages/assets-controllers/src/TokenDetectionController.ts": {
"no-restricted-syntax": {
- "count": 6
+ "count": 3
}
},
"packages/assets-controllers/src/TokenListController.test.ts": {
@@ -475,7 +475,7 @@
"count": 6
},
"no-restricted-syntax": {
- "count": 4
+ "count": 3
}
},
"packages/assets-controllers/src/TokensController.ts": {
@@ -486,7 +486,7 @@
"count": 1
},
"@typescript-eslint/prefer-optional-chain": {
- "count": 4
+ "count": 3
},
"id-length": {
"count": 1
@@ -498,7 +498,7 @@
"count": 1
},
"no-restricted-syntax": {
- "count": 2
+ "count": 1
},
"require-atomic-updates": {
"count": 1
diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index 3d775a23ab..2ee6e1e503 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Add `TokenListService`, a shared service for fetching and caching the token list per chain ([#8700](https://github.com/MetaMask/core/pull/8700))
+ - Wraps `@tanstack/query-core` to cache results in-memory for 4 hours per chain ID, matching `TokenListController`'s existing threshold.
+ - Multiple controllers sharing the same `TokenListService` instance share the same cache: only one HTTP request is made per chain per 4-hour window regardless of how many callers invoke `fetchTokensByChainId`.
+ - Exported from the package as `TokenListService` and `buildTokenListMap`.
+- Add `@tanstack/query-core` `^5.62.16` as a direct dependency ([#8700](https://github.com/MetaMask/core/pull/8700))
- Expose missing public `AccountTrackerController` methods through its messenger ([#8693](https://github.com/MetaMask/core/pull/8693))
- The following actions are now available:
- `AccountTrackerController:refresh`
@@ -22,6 +27,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **BREAKING:** `TokenDetectionController` now requires a `tokenListService: TokenListService` constructor option ([#8700](https://github.com/MetaMask/core/pull/8700))
+ - Token list data is fetched directly from `TokenListService` instead of reading `TokenListController` state on each detection pass.
+ - `GetTokenListState` has been removed from `AllowedActions` and `TokenListStateChange` has been removed from `AllowedEvents` on `TokenDetectionControllerMessenger`.
+- **BREAKING:** `TokensController` now requires a `tokenListService: TokenListService` constructor option ([#8700](https://github.com/MetaMask/core/pull/8700))
+ - `TokenListStateChange` has been removed from `AllowedEvents` on `TokensControllerMessenger`.
+ - Token `name` and `rwaData` enrichment now happens once at controller initialization instead of reactively on every `TokenListController:stateChange` event.
- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691))
- Switch the default mUSD asset from upfront `allTokens` state seeding to detection-based discovery on Ethereum mainnet (`0x1`), Linea (`0xe708`), and Monad mainnet (`0x8f`) ([#8688](https://github.com/MetaMask/core/pull/8688))
- `TokenDetectionController` now merges mUSD into the in-memory token list cache for these chains so it is treated as a regular detection candidate, replacing the previous `start()`-time `TokensController:addTokens` call and the per-event re-seed runs.
@@ -34,6 +45,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
+- `TokenDetectionController` no longer restarts token detection when `TokenListController` publishes a `stateChange` event ([#8700](https://github.com/MetaMask/core/pull/8700))
+ - Detection is still triggered on wallet unlock, account change, network change, and preference changes; the extra restart that occurred whenever `TokenListController` refreshed its cache is gone.
- Stop seeding mUSD directly into `TokensController` state and remove the related event subscriptions ([#8688](https://github.com/MetaMask/core/pull/8688))
- `TokensController` no longer subscribes to `KeyringController:unlock`, `AccountsController:accountAdded`, `AccountsController:selectedEvmAccountChange`, `NetworkController:networkAdded`, or `NetworkController:stateChange` for mUSD seeding purposes.
- `TokensControllerMessenger` no longer requires `NetworkControllerNetworkAddedEvent`, `AccountsControllerAccountAddedEvent`, or `KeyringControllerUnlockEvent` as allowed events.
diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json
index ffd286abbe..591ebf5056 100644
--- a/packages/assets-controllers/package.json
+++ b/packages/assets-controllers/package.json
@@ -87,6 +87,7 @@
"@metamask/storage-service": "^1.0.1",
"@metamask/transaction-controller": "^65.1.0",
"@metamask/utils": "^11.9.0",
+ "@tanstack/query-core": "^5.62.16",
"@types/bn.js": "^5.1.5",
"@types/uuid": "^8.3.0",
"async-mutex": "^0.5.0",
diff --git a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts
index 1ec590fcea..80ac096584 100644
--- a/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts
+++ b/packages/assets-controllers/src/TokenDetectionController-method-action-types.ts
@@ -38,7 +38,7 @@ export type TokenDetectionControllerStopAction = {
};
/**
- * For each token in the token list provided by the TokenListController, checks the token's balance for the selected account address on the active network.
+ * For each token in the token list provided by the TokenListService, checks the token's balance for the selected account address on the active network.
* On mainnet, if token detection is disabled in preferences, ERC20 token auto detection will be triggered for each contract address in the legacy token list from the @metamask/contract-metadata repo.
*
* @param options - Options for token detection.
diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts
index d4b746114c..33b19d453e 100644
--- a/packages/assets-controllers/src/TokenDetectionController.test.ts
+++ b/packages/assets-controllers/src/TokenDetectionController.test.ts
@@ -44,10 +44,14 @@ import type { TokenDetectionControllerMessenger } from './TokenDetectionControll
import {
TokenDetectionController,
controllerName,
- mapChainIdWithTokenListMap,
} from './TokenDetectionController';
import { getDefaultTokenListState } from './TokenListController';
-import type { TokenListState, TokenListToken } from './TokenListController';
+import type {
+ TokenListMap,
+ TokenListState,
+ TokenListToken,
+} from './TokenListController';
+import type { TokenListService } from './TokenListService';
import type { Token } from './TokenRatesController';
import type {
TokensController,
@@ -207,7 +211,6 @@ function buildTokenDetectionControllerMessenger(
'NetworkController:getState',
'TokensController:getState',
'TokensController:addDetectedTokens',
- 'TokenListController:getState',
'PreferencesController:getState',
'TokensController:addTokens',
'NetworkController:findNetworkClientIdByChainId',
@@ -217,7 +220,6 @@ function buildTokenDetectionControllerMessenger(
'KeyringController:lock',
'KeyringController:unlock',
'NetworkController:networkDidChange',
- 'TokenListController:stateChange',
'PreferencesController:stateChange',
'TransactionController:transactionConfirmed',
],
@@ -1870,7 +1872,7 @@ describe('TokenDetectionController', () => {
});
});
- describe('TokenListController:stateChange', () => {
+ describe('startPolling', () => {
beforeEach(() => {
jest.useFakeTimers();
});
@@ -1879,542 +1881,101 @@ describe('TokenDetectionController', () => {
jest.useRealTimers();
});
- describe('when "disabled" is false', () => {
- it('should detect tokens if the token list is non-empty', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
+ it('should call detect tokens with networkClientId and address params', async () => {
+ const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
+ [sampleTokenA.address]: new BN(1),
+ });
+ const selectedAccount = createMockInternalAccount({
+ address: '0x0000000000000000000000000000000000000001',
+ });
+ await withController(
+ {
+ options: {
+ disabled: false,
+ getBalancesInSingleCall: mockGetBalancesInSingleCall,
},
- async ({
- mockTokenListGetState,
- callActionSpy,
- triggerTokenListStateChange,
- mockNetworkState,
- }) => {
- // Set selectedNetworkClientId to avalanche (not in SUPPORTED_NETWORKS_ACCOUNTS_API_V4)
- mockNetworkState({
- ...getDefaultNetworkControllerState(),
- selectedNetworkClientId: 'avalanche',
- });
- const tokenList = {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- };
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- '0xa86a': {
- timestamp: 0,
- data: tokenList,
+ mocks: {
+ getSelectedAccount: selectedAccount,
+ getAccount: selectedAccount,
+ },
+ },
+ async ({ controller, mockTokenListGetState }) => {
+ mockTokenListGetState({
+ ...getDefaultTokenListState(),
+ tokensChainsCache: {
+ [ChainId.sepolia]: {
+ data: {
+ [sampleTokenA.address]: {
+ name: sampleTokenA.name,
+ symbol: sampleTokenA.symbol,
+ decimals: sampleTokenA.decimals,
+ address: sampleTokenA.address,
+ occurrences: 1,
+ aggregators: sampleTokenA.aggregators,
+ iconUrl: sampleTokenA.image,
+ },
},
+ timestamp: 0,
},
- };
- mockTokenListGetState(tokenListState);
-
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
-
- expect(callActionSpy).toHaveBeenCalledWith(
- 'TokensController:addTokens',
- [sampleTokenA],
- 'avalanche',
- );
- },
- );
- });
-
- it('should not detect tokens if the token list is empty', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
},
- },
- async ({
- mockTokenListGetState,
- callActionSpy,
- triggerTokenListStateChange,
- }) => {
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {},
- };
- mockTokenListGetState(tokenListState);
-
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
-
- expect(callActionSpy).not.toHaveBeenCalledWith(
- 'TokensController:addDetectedTokens',
- );
- },
- );
- });
+ });
+ const spy = jest
+ .spyOn(controller, 'detectTokens')
+ .mockImplementation(() => {
+ return Promise.resolve();
+ });
- describe('when keyring is locked', () => {
- it('should not detect tokens', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
+ controller.startPolling({
+ chainIds: ['0xa86a'],
+ address: '0x1',
});
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
+ controller.startPolling({
+ chainIds: ['0xa86a'],
+ address: '0xdeadbeef',
});
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- isKeyringUnlocked: false,
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
- },
- async ({
- mockTokenListGetState,
- callActionSpy,
- triggerTokenListStateChange,
- }) => {
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 0,
- },
- },
- };
- mockTokenListGetState(tokenListState);
+ controller.startPolling({
+ chainIds: ['0x5'],
+ address: '0x3',
+ });
+ await jestAdvanceTime({ duration: 0 });
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
+ expect(spy.mock.calls).toMatchObject([
+ [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }],
+ [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }],
+ [{ chainIds: ['0x5'], selectedAddress: '0x3' }],
+ ]);
- expect(callActionSpy).not.toHaveBeenCalledWith(
- 'TokensController:addDetectedTokens',
- );
- },
- );
- });
- });
+ await jestAdvanceTime({ duration: DEFAULT_INTERVAL });
+ expect(spy.mock.calls).toMatchObject([
+ [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }],
+ [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }],
+ [{ chainIds: ['0x5'], selectedAddress: '0x3' }],
+ [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }],
+ [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }],
+ [{ chainIds: ['0x5'], selectedAddress: '0x3' }],
+ ]);
+ },
+ );
});
+ });
- describe('when "disabled" is true', () => {
- it('should not detect tokens', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: true,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
+ describe('detectTokens', () => {
+ it('should not detect tokens if token detection is disabled and current network is not mainnet', async () => {
+ const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
+ [sampleTokenA.address]: new BN(1),
+ });
+ const selectedAccount = createMockInternalAccount({
+ address: '0x0000000000000000000000000000000000000001',
+ });
+ await withController(
+ {
+ options: {
+ disabled: false,
+ getBalancesInSingleCall: mockGetBalancesInSingleCall,
},
- async ({
- mockTokenListGetState,
- callActionSpy,
- triggerTokenListStateChange,
- }) => {
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 0,
- },
- },
- };
- mockTokenListGetState(tokenListState);
-
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
-
- expect(callActionSpy).not.toHaveBeenCalledWith(
- 'TokensController:addDetectedTokens',
- );
- },
- );
- });
- });
-
- describe('when previous and incoming tokensChainsCache are equal with the same timestamp', () => {
- it('should not call detect tokens', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
- },
- async ({
- mockTokenListGetState,
- triggerTokenListStateChange,
- controller,
- }) => {
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 0,
- },
- },
- };
- mockTokenListGetState(tokenListState);
- // This should set the tokensChainsCache value
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
-
- const mockTokens = jest.spyOn(controller, 'detectTokens');
-
- // Re-trigger state change so that incoming list is equal the current list in state
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
- expect(mockTokens).toHaveBeenCalledTimes(0);
- },
- );
- });
- });
-
- describe('when previous and incoming tokensChainsCache are equal with different timestamp', () => {
- it('should not call detect tokens', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
- },
- async ({
- mockTokenListGetState,
- triggerTokenListStateChange,
- controller,
- }) => {
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 0,
- },
- },
- };
- mockTokenListGetState(tokenListState);
- // This should set the tokensChainsCache value
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
-
- const mockTokens = jest.spyOn(controller, 'detectTokens');
-
- // Re-trigger state change so that incoming list is equal the current list in state
- triggerTokenListStateChange({
- ...tokenListState,
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 3424, // same list with different timestamp should not trigger detectTokens again
- },
- },
- });
- await jestAdvanceTime({ duration: 1 });
- expect(mockTokens).toHaveBeenCalledTimes(0);
- },
- );
- });
- });
-
- describe('when previous and incoming tokensChainsCache are not equal', () => {
- it('should call detect tokens', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
- },
- async ({
- mockTokenListGetState,
- triggerTokenListStateChange,
- controller,
- }) => {
- const tokenListState = {
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 0,
- },
- },
- };
- mockTokenListGetState(tokenListState);
- // This should set the tokensChainsCache value
- triggerTokenListStateChange(tokenListState);
- await jestAdvanceTime({ duration: 1 });
-
- const mockTokens = jest.spyOn(controller, 'detectTokens');
-
- // Re-trigger state change so that incoming list is equal the current list in state
- triggerTokenListStateChange({
- ...tokenListState,
- tokensChainsCache: {
- ...tokenListState.tokensChainsCache,
- [ChainId['linea-mainnet']]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 5546454,
- },
- },
- });
- await jestAdvanceTime({ duration: 1 });
- expect(mockTokens).toHaveBeenCalledTimes(1);
- },
- );
- });
- });
- });
-
- describe('startPolling', () => {
- beforeEach(() => {
- jest.useFakeTimers();
- });
-
- afterEach(() => {
- jest.useRealTimers();
- });
-
- it('should call detect tokens with networkClientId and address params', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
- },
- },
- async ({ controller, mockTokenListGetState }) => {
- mockTokenListGetState({
- ...getDefaultTokenListState(),
- tokensChainsCache: {
- [ChainId.sepolia]: {
- data: {
- [sampleTokenA.address]: {
- name: sampleTokenA.name,
- symbol: sampleTokenA.symbol,
- decimals: sampleTokenA.decimals,
- address: sampleTokenA.address,
- occurrences: 1,
- aggregators: sampleTokenA.aggregators,
- iconUrl: sampleTokenA.image,
- },
- },
- timestamp: 0,
- },
- },
- });
- const spy = jest
- .spyOn(controller, 'detectTokens')
- .mockImplementation(() => {
- return Promise.resolve();
- });
-
- controller.startPolling({
- chainIds: ['0xa86a'],
- address: '0x1',
- });
- controller.startPolling({
- chainIds: ['0xa86a'],
- address: '0xdeadbeef',
- });
- controller.startPolling({
- chainIds: ['0x5'],
- address: '0x3',
- });
- await jestAdvanceTime({ duration: 0 });
-
- expect(spy.mock.calls).toMatchObject([
- [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }],
- [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }],
- [{ chainIds: ['0x5'], selectedAddress: '0x3' }],
- ]);
-
- await jestAdvanceTime({ duration: DEFAULT_INTERVAL });
- expect(spy.mock.calls).toMatchObject([
- [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }],
- [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }],
- [{ chainIds: ['0x5'], selectedAddress: '0x3' }],
- [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }],
- [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }],
- [{ chainIds: ['0x5'], selectedAddress: '0x3' }],
- ]);
- },
- );
- });
- });
-
- describe('detectTokens', () => {
- it('should not detect tokens if token detection is disabled and current network is not mainnet', async () => {
- const mockGetBalancesInSingleCall = jest.fn().mockResolvedValue({
- [sampleTokenA.address]: new BN(1),
- });
- const selectedAccount = createMockInternalAccount({
- address: '0x0000000000000000000000000000000000000001',
- });
- await withController(
- {
- options: {
- disabled: false,
- getBalancesInSingleCall: mockGetBalancesInSingleCall,
- },
- mocks: {
- getSelectedAccount: selectedAccount,
- getAccount: selectedAccount,
+ mocks: {
+ getSelectedAccount: selectedAccount,
+ getAccount: selectedAccount,
},
},
async ({
@@ -3241,57 +2802,6 @@ describe('TokenDetectionController', () => {
});
});
- describe('mapChainIdWithTokenListMap', () => {
- it('should return an empty object when given an empty input', () => {
- const tokensChainsCache = {};
- const result = mapChainIdWithTokenListMap(tokensChainsCache);
- expect(result).toStrictEqual({});
- });
-
- it('should return the same structure when there is no "data" property in the object', () => {
- const tokensChainsCache = {
- chain1: { info: 'no data property' },
- };
- const result = mapChainIdWithTokenListMap(tokensChainsCache);
- expect(result).toStrictEqual(tokensChainsCache); // Expect unchanged structure
- });
-
- it('should map "data" property if present in the object', () => {
- const tokensChainsCache = {
- chain1: { data: 'someData' },
- };
- const result = mapChainIdWithTokenListMap(tokensChainsCache);
- expect(result).toStrictEqual({ chain1: 'someData' });
- });
-
- it('should handle multiple chains with mixed "data" properties', () => {
- const tokensChainsCache = {
- chain1: { data: 'someData1' },
- chain2: { info: 'no data property' },
- chain3: { data: 'someData3' },
- };
- const result = mapChainIdWithTokenListMap(tokensChainsCache);
-
- expect(result).toStrictEqual({
- chain1: 'someData1',
- chain2: { info: 'no data property' },
- chain3: 'someData3',
- });
- });
-
- it('should handle nested object with "data" property correctly', () => {
- const tokensChainsCache = {
- chain1: {
- data: {
- nested: 'nestedData',
- },
- },
- };
- const result = mapChainIdWithTokenListMap(tokensChainsCache);
- expect(result).toStrictEqual({ chain1: { nested: 'nestedData' } });
- });
- });
-
describe('constructor options', () => {
describe('useTokenDetection', () => {
it('should disable token detection when useTokenDetection is false', async () => {
@@ -3647,7 +3157,121 @@ describe('TokenDetectionController', () => {
aggregators: [],
image: 'https://example.com/bnt.png',
isERC721: false,
- name: 'Bancor',
+ name: 'Bancor',
+ },
+ ],
+ 'avalanche',
+ );
+ },
+ );
+ });
+
+ it('should track metrics when adding tokens from websocket', async () => {
+ const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
+ const checksummedTokenAddress =
+ '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
+ const chainId = '0xa86a';
+ const mockTrackMetricsEvent = jest.fn();
+
+ await withController(
+ {
+ options: {
+ disabled: false,
+ trackMetaMetricsEvent: mockTrackMetricsEvent,
+ },
+ mockTokenListState: {
+ tokensChainsCache: {
+ [chainId]: {
+ timestamp: 0,
+ data: {
+ [mockTokenAddress]: {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ address: mockTokenAddress,
+ aggregators: [],
+ iconUrl: 'https://example.com/usdc.png',
+ occurrences: 11,
+ },
+ },
+ },
+ },
+ },
+ },
+ async ({ controller, callActionSpy }) => {
+ await controller.addDetectedTokensViaWs({
+ tokensSlice: [mockTokenAddress],
+ chainId: chainId as Hex,
+ });
+
+ // Should track metrics event
+ expect(mockTrackMetricsEvent).toHaveBeenCalledWith({
+ event: 'Token Detected',
+ category: 'Wallet',
+ properties: {
+ tokens: [`USDC - ${checksummedTokenAddress}`],
+ token_standard: 'ERC20',
+ asset_type: 'TOKEN',
+ },
+ });
+
+ expect(callActionSpy).toHaveBeenCalledWith(
+ 'TokensController:addTokens',
+ expect.anything(),
+ expect.anything(),
+ );
+ },
+ );
+ });
+
+ it('should be callable directly as a public method on the controller instance', async () => {
+ const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
+ const checksummedTokenAddress =
+ '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
+ const chainId = '0xa86a';
+
+ await withController(
+ {
+ options: {
+ disabled: false,
+ },
+ mockTokenListState: {
+ tokensChainsCache: {
+ [chainId]: {
+ timestamp: 0,
+ data: {
+ [mockTokenAddress]: {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ address: mockTokenAddress,
+ aggregators: [],
+ iconUrl: 'https://example.com/usdc.png',
+ occurrences: 11,
+ },
+ },
+ },
+ },
+ },
+ },
+ async ({ controller, callActionSpy }) => {
+ // Call the public method directly on the controller instance
+ await controller.addDetectedTokensViaWs({
+ tokensSlice: [mockTokenAddress],
+ chainId: chainId as Hex,
+ });
+
+ expect(callActionSpy).toHaveBeenCalledWith(
+ 'TokensController:addTokens',
+ [
+ {
+ address: checksummedTokenAddress,
+ decimals: 6,
+ symbol: 'USDC',
+ aggregators: [],
+ image: 'https://example.com/usdc.png',
+ isERC721: false,
+ name: 'USD Coin',
},
],
'avalanche',
@@ -3656,18 +3280,15 @@ describe('TokenDetectionController', () => {
);
});
- it('should track metrics when adding tokens from websocket', async () => {
+ it('should not add tokens when useTokenDetection is false', async () => {
const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
- const checksummedTokenAddress =
- '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const chainId = '0xa86a';
- const mockTrackMetricsEvent = jest.fn();
await withController(
{
options: {
disabled: false,
- trackMetaMetricsEvent: mockTrackMetricsEvent,
+ useTokenDetection: () => false,
},
mockTokenListState: {
tokensChainsCache: {
@@ -3694,18 +3315,7 @@ describe('TokenDetectionController', () => {
chainId: chainId as Hex,
});
- // Should track metrics event
- expect(mockTrackMetricsEvent).toHaveBeenCalledWith({
- event: 'Token Detected',
- category: 'Wallet',
- properties: {
- tokens: [`USDC - ${checksummedTokenAddress}`],
- token_standard: 'ERC20',
- asset_type: 'TOKEN',
- },
- });
-
- expect(callActionSpy).toHaveBeenCalledWith(
+ expect(callActionSpy).not.toHaveBeenCalledWith(
'TokensController:addTokens',
expect.anything(),
expect.anything(),
@@ -3714,16 +3324,15 @@ describe('TokenDetectionController', () => {
);
});
- it('should be callable directly as a public method on the controller instance', async () => {
+ it('should not add tokens when useExternalServices is false', async () => {
const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
- const checksummedTokenAddress =
- '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const chainId = '0xa86a';
await withController(
{
options: {
disabled: false,
+ useExternalServices: () => false,
},
mockTokenListState: {
tokensChainsCache: {
@@ -3745,27 +3354,79 @@ describe('TokenDetectionController', () => {
},
},
async ({ controller, callActionSpy }) => {
- // Call the public method directly on the controller instance
await controller.addDetectedTokensViaWs({
tokensSlice: [mockTokenAddress],
chainId: chainId as Hex,
});
- expect(callActionSpy).toHaveBeenCalledWith(
+ expect(callActionSpy).not.toHaveBeenCalledWith(
'TokensController:addTokens',
- [
- {
- address: checksummedTokenAddress,
- decimals: 6,
- symbol: 'USDC',
- aggregators: [],
- image: 'https://example.com/usdc.png',
- isERC721: false,
- name: 'USD Coin',
+ expect.anything(),
+ expect.anything(),
+ );
+ },
+ );
+ });
+
+ it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => {
+ const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
+ const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
+
+ await withController(
+ {
+ options: { disabled: false },
+ mockTokenListState: {
+ tokensChainsCache: {
+ [ChainId.mainnet]: {
+ timestamp: 0,
+ data: {
+ [usdcAddress]: {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ address: usdcAddress,
+ aggregators: [],
+ iconUrl: 'https://example.com/usdc.png',
+ occurrences: 11,
+ },
+ },
},
+ },
+ },
+ },
+ async ({
+ controller,
+ callActionSpy,
+ mockFindNetworkClientIdByChainId,
+ }) => {
+ mockFindNetworkClientIdByChainId(() => 'mainnet');
+ await controller.addDetectedTokensViaWs({
+ tokensSlice: [
+ toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER),
+ usdcAddress,
],
- 'avalanche',
+ chainId: ChainId.mainnet,
+ });
+
+ expect(callActionSpy).toHaveBeenCalledWith(
+ 'TokensController:addTokens',
+ expect.arrayContaining([
+ expect.objectContaining({
+ address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER),
+ }),
+ expect.objectContaining({ address: usdcChecksummed }),
+ ]),
+ 'mainnet',
+ );
+ const addTokensCall = callActionSpy.mock.calls.find(
+ (call) => call[0] === 'TokensController:addTokens',
);
+ const payload = addTokensCall?.[1] as { address: string }[];
+ const musdRows = payload.filter(
+ (tokenRow) =>
+ tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER,
+ );
+ expect(musdRows).toHaveLength(1);
},
);
});
@@ -4044,10 +3705,10 @@ describe('TokenDetectionController', () => {
);
});
- it('should fetch fresh token metadata cache from TokenListController at call time', async () => {
- // This test verifies the fix for the bug where addDetectedTokensViaPolling used
- // a stale/empty tokensChainsCache from construction time instead of fetching
- // fresh data from TokenListController:getState at call time.
+ it('should fetch fresh token metadata cache from TokenListService at call time', async () => {
+ // This test verifies that addDetectedTokensViaPolling fetches the token list
+ // from the TokenListService at call time (not at construction time), so that
+ // tokens added to the service after construction are still detected.
const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
const checksummedTokenAddress =
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
@@ -4222,6 +3883,183 @@ describe('TokenDetectionController', () => {
},
);
});
+
+ it('should skip if useExternalServices is disabled', async () => {
+ const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
+ const chainId = '0xa86a';
+
+ await withController(
+ {
+ options: {
+ disabled: false,
+ useTokenDetection: () => true,
+ useExternalServices: () => false,
+ },
+ mockTokenListState: {
+ tokensChainsCache: {
+ [chainId]: {
+ timestamp: 0,
+ data: {
+ [mockTokenAddress]: {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ address: mockTokenAddress,
+ aggregators: [],
+ iconUrl: 'https://example.com/usdc.png',
+ occurrences: 11,
+ },
+ },
+ },
+ },
+ },
+ },
+ async ({ controller, callActionSpy }) => {
+ await controller.addDetectedTokensViaPolling({
+ tokensSlice: [mockTokenAddress],
+ chainId: chainId as Hex,
+ });
+
+ expect(callActionSpy).not.toHaveBeenCalledWith(
+ 'TokensController:addTokens',
+ expect.anything(),
+ expect.anything(),
+ );
+ },
+ );
+ });
+
+ it('should ignore slice addresses that are not in the token list map', async () => {
+ const mockTokenAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
+ const checksummedTokenAddress =
+ '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
+ const unknownAddress = '0x0000000000000000000000000000000000000002';
+ const chainId = '0xa86a';
+
+ await withController(
+ {
+ options: {
+ disabled: false,
+ useTokenDetection: () => true,
+ },
+ mockTokenListState: {
+ tokensChainsCache: {
+ [chainId]: {
+ timestamp: 0,
+ data: {
+ [mockTokenAddress]: {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ address: mockTokenAddress,
+ aggregators: [],
+ iconUrl: 'https://example.com/usdc.png',
+ occurrences: 11,
+ },
+ },
+ },
+ },
+ },
+ },
+ async ({ controller, callActionSpy }) => {
+ await controller.addDetectedTokensViaPolling({
+ tokensSlice: [mockTokenAddress, unknownAddress],
+ chainId: chainId as Hex,
+ });
+
+ expect(callActionSpy).toHaveBeenCalledWith(
+ 'TokensController:addTokens',
+ [
+ {
+ address: checksummedTokenAddress,
+ decimals: 6,
+ symbol: 'USDC',
+ aggregators: [],
+ image: 'https://example.com/usdc.png',
+ isERC721: false,
+ name: 'USD Coin',
+ },
+ ],
+ 'avalanche',
+ );
+ },
+ );
+ });
+
+ it('does not append a duplicate mUSD entry when the slice already includes mUSD', async () => {
+ const usdcAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
+ const usdcChecksummed = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
+ const selectedAccount = createMockInternalAccount({
+ address: '0x0000000000000000000000000000000000000001',
+ });
+
+ await withController(
+ {
+ options: { disabled: false, useTokenDetection: () => true },
+ mocks: {
+ getAccount: selectedAccount,
+ getSelectedAccount: selectedAccount,
+ },
+ mockTokensState: {
+ allTokens: {},
+ allDetectedTokens: {},
+ allIgnoredTokens: {},
+ },
+ mockTokenListState: {
+ tokensChainsCache: {
+ [ChainId.mainnet]: {
+ timestamp: 0,
+ data: {
+ [usdcAddress]: {
+ name: 'USD Coin',
+ symbol: 'USDC',
+ decimals: 6,
+ address: usdcAddress,
+ aggregators: [],
+ iconUrl: 'https://example.com/usdc.png',
+ occurrences: 11,
+ },
+ },
+ },
+ },
+ },
+ },
+ async ({
+ controller,
+ callActionSpy,
+ mockFindNetworkClientIdByChainId,
+ }) => {
+ mockFindNetworkClientIdByChainId(() => 'mainnet');
+ await controller.addDetectedTokensViaPolling({
+ tokensSlice: [
+ toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER),
+ usdcAddress,
+ ],
+ chainId: ChainId.mainnet,
+ });
+
+ expect(callActionSpy).toHaveBeenCalledWith(
+ 'TokensController:addTokens',
+ expect.arrayContaining([
+ expect.objectContaining({
+ address: toChecksumHexAddress(MUSD_ERC20_ADDRESS_LOWER),
+ }),
+ expect.objectContaining({ address: usdcChecksummed }),
+ ]),
+ 'mainnet',
+ );
+ const addTokensCall = callActionSpy.mock.calls.find(
+ (call) => call[0] === 'TokensController:addTokens',
+ );
+ const payload = addTokensCall?.[1] as { address: string }[];
+ const musdRows = payload.filter(
+ (tokenRow) =>
+ tokenRow.address.toLowerCase() === MUSD_ERC20_ADDRESS_LOWER,
+ );
+ expect(musdRows).toHaveLength(1);
+ },
+ );
+ });
});
});
@@ -4252,7 +4090,6 @@ type WithControllerCallback = ({
callActionSpy,
triggerKeyringUnlock,
triggerKeyringLock,
- triggerTokenListStateChange,
triggerPreferencesStateChange,
triggerSelectedAccountChange,
triggerNetworkDidChange,
@@ -4263,6 +4100,7 @@ type WithControllerCallback = ({
mockGetSelectedAccount: (address: string) => void;
mockKeyringGetState: (state: KeyringControllerState) => void;
mockTokensGetState: (state: TokensControllerState) => void;
+ /** Updates the mock TokenListService to return a specific token list state. */
mockTokenListGetState: (state: TokenListState) => void;
mockPreferencesGetState: (state: PreferencesState) => void;
mockGetNetworkClientById: (
@@ -4280,7 +4118,6 @@ type WithControllerCallback = ({
callActionSpy: jest.SpyInstance;
triggerKeyringUnlock: () => void;
triggerKeyringLock: () => void;
- triggerTokenListStateChange: (state: TokenListState) => void;
triggerPreferencesStateChange: (state: PreferencesState) => void;
triggerSelectedAccountChange: (account: InternalAccount) => void;
triggerNetworkDidChange: (state: NetworkState) => void;
@@ -4394,14 +4231,31 @@ async function withController(
...mockTokensState,
}),
);
- const mockTokenListStateFunc = jest.fn();
- messenger.registerActionHandler(
- 'TokenListController:getState',
- mockTokenListStateFunc.mockReturnValue({
- ...getDefaultTokenListState(),
- ...mockTokenListState,
- }),
- );
+
+ // Build the initial TokenListState and a mutable reference so tests can update it.
+ let currentTokenListState: TokenListState = {
+ ...getDefaultTokenListState(),
+ ...mockTokenListState,
+ };
+ const mockFetchTokensByChainId = jest
+ .fn, [Hex]>()
+ .mockImplementation((chainId: Hex) => {
+ const data = currentTokenListState.tokensChainsCache[chainId]?.data ?? {};
+ // Normalise keys to lowercase to match buildTokenListMap's output so that
+ // lookups using lowercased addresses (as done in production code) work correctly.
+ return Promise.resolve(
+ Object.fromEntries(
+ Object.entries(data).map(([addr, token]) => [
+ addr.toLowerCase(),
+ token,
+ ]),
+ ),
+ );
+ });
+ const tokenListService = {
+ fetchTokensByChainId: mockFetchTokensByChainId,
+ } as unknown as TokenListService;
+
const mockPreferencesState = jest.fn();
messenger.registerActionHandler(
'PreferencesController:getState',
@@ -4448,6 +4302,7 @@ async function withController(
getBalancesInSingleCall: jest.fn(),
trackMetaMetricsEvent: jest.fn(),
messenger: tokenDetectionControllerMessenger,
+ tokenListService,
...options,
});
try {
@@ -4470,7 +4325,19 @@ async function withController(
mockPreferencesState.mockReturnValue(state);
},
mockTokenListGetState: (state: TokenListState) => {
- mockTokenListStateFunc.mockReturnValue(state);
+ currentTokenListState = state;
+ mockFetchTokensByChainId.mockImplementation((chainId: Hex) => {
+ const data = state.tokensChainsCache[chainId]?.data ?? {};
+ // Normalise keys to lowercase to match buildTokenListMap's output.
+ return Promise.resolve(
+ Object.fromEntries(
+ Object.entries(data).map(([addr, token]) => [
+ addr.toLowerCase(),
+ token,
+ ]),
+ ),
+ );
+ });
},
mockGetNetworkClientById: (
handler: (
@@ -4501,9 +4368,6 @@ async function withController(
triggerKeyringLock: () => {
messenger.publish('KeyringController:lock');
},
- triggerTokenListStateChange: (state: TokenListState) => {
- messenger.publish('TokenListController:stateChange', state, []);
- },
triggerPreferencesStateChange: (state: PreferencesState) => {
messenger.publish('PreferencesController:stateChange', state, []);
},
diff --git a/packages/assets-controllers/src/TokenDetectionController.ts b/packages/assets-controllers/src/TokenDetectionController.ts
index 6ba8fb4d91..9ac70d8645 100644
--- a/packages/assets-controllers/src/TokenDetectionController.ts
+++ b/packages/assets-controllers/src/TokenDetectionController.ts
@@ -39,7 +39,6 @@ import type {
import type { AuthenticationController } from '@metamask/profile-sync-controller';
import type { TransactionControllerTransactionConfirmedEvent } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';
-import { isEqual, mapValues, isObject, get } from 'lodash';
import type { AssetsContractController } from './AssetsContractController';
import {
@@ -54,12 +53,11 @@ import {
} from './constants';
import type { TokenDetectionControllerMethodActions } from './TokenDetectionController-method-action-types';
import type {
- GetTokenListState,
TokenListMap,
- TokenListStateChange,
TokenListToken,
TokensChainsCache,
} from './TokenListController';
+import type { TokenListService } from './TokenListService';
import type { Token } from './TokenRatesController';
import type { TokensControllerGetStateAction } from './TokensController';
import type {
@@ -106,23 +104,6 @@ const MUSD_TOKEN_DETECTION_CHAIN_ID_SET = new Set(
MUSD_TOKEN_DETECTION_CHAIN_IDS,
);
-/**
- * Function that takes a TokensChainsCache object and maps chainId with TokenListMap.
- *
- * @param tokensChainsCache - TokensChainsCache input object
- * @returns returns the map of chainId with TokenListMap
- */
-export function mapChainIdWithTokenListMap(
- tokensChainsCache: TokensChainsCache,
-): Record {
- return mapValues(tokensChainsCache, (value) => {
- if (isObject(value) && 'data' in value) {
- return get(value, ['data']);
- }
- return value;
- });
-}
-
export const controllerName = 'TokenDetectionController';
export type TokenDetectionState = Record;
@@ -142,7 +123,6 @@ export type AllowedActions =
| NetworkControllerGetNetworkClientByIdAction
| NetworkControllerGetNetworkConfigurationByNetworkClientId
| NetworkControllerGetStateAction
- | GetTokenListState
| KeyringControllerGetStateAction
| PreferencesControllerGetStateAction
| TokensControllerGetStateAction
@@ -160,7 +140,6 @@ export type TokenDetectionControllerEvents =
export type AllowedEvents =
| AccountsControllerSelectedEvmAccountChangeEvent
| NetworkControllerNetworkDidChangeEvent
- | TokenListStateChange
| KeyringControllerLockEvent
| KeyringControllerUnlockEvent
| PreferencesControllerStateChangeEvent
@@ -213,7 +192,7 @@ export class TokenDetectionController extends StaticIntervalPollingController true,
useExternalServices = (): boolean => true,
}: {
@@ -275,6 +256,7 @@ export class TokenDetectionController extends StaticIntervalPollingController void;
messenger: TokenDetectionControllerMessenger;
+ tokenListService: TokenListService;
useTokenDetection?: () => boolean;
useExternalServices?: () => boolean;
}) {
@@ -292,11 +274,7 @@ export class TokenDetectionController extends StaticIntervalPollingController {
- const isEqualValues = this.#compareTokensChainsCache(
- tokensChainsCache,
- this.#tokensChainsCache,
- );
- if (!isEqualValues) {
- this.#restartTokenDetection().catch(() => {
- // Silently handle token detection errors
- });
- }
- },
- );
-
this.messenger.subscribe(
'PreferencesController:stateChange',
({ useTokenDetection }) => {
@@ -461,30 +424,6 @@ export class TokenDetectionController extends StaticIntervalPollingController {
if (!isTokenDetectionSupportedForNetwork(chainId)) {
- return false;
+ return null;
}
if (
!this.#isDetectionEnabledFromPreferences &&
chainId !== ChainId.mainnet
) {
- return false;
+ return null;
}
const isMainnetDetectionInactive =
!this.#isDetectionEnabledFromPreferences && chainId === ChainId.mainnet;
if (isMainnetDetectionInactive) {
- this.#tokensChainsCache = this.#getConvertedStaticMainnetTokenList();
- } else {
- const { tokensChainsCache } = this.messenger.call(
- 'TokenListController:getState',
- );
- this.#tokensChainsCache = this.#applyMusdDefaultToTokensChainsCache(
- chainId,
- tokensChainsCache ?? {},
- );
+ return this.#getConvertedStaticMainnetTokenList();
}
- return true;
+ const tokenListMap =
+ await this.#tokenListService.fetchTokensByChainId(chainId);
+ return this.#applyMusdDefaultToTokensChainsCache(chainId, {
+ [chainId]: { data: tokenListMap, timestamp: Date.now() },
+ });
}
async #detectTokensUsingRpc(
@@ -584,12 +530,14 @@ export class TokenDetectionController extends StaticIntervalPollingController {
for (const { chainId, networkClientId } of chainsToDetectUsingRpc) {
- if (!this.#shouldDetectTokens(chainId)) {
+ const chainCache = await this.#getChainCacheForDetection(chainId);
+ if (!chainCache) {
continue;
}
const tokenCandidateSlices = this.#getSlicesOfTokensToDetect({
chainId,
+ chainCache,
selectedAddress: addressToDetect,
});
const tokenDetectionPromises = tokenCandidateSlices.map((tokensSlice) =>
@@ -598,6 +546,7 @@ export class TokenDetectionController extends StaticIntervalPollingController {
await safelyExecute(async () => {
const balances = await this.#getBalancesInSingleCall(
@@ -837,11 +788,18 @@ export class TokenDetectionController extends StaticIntervalPollingController
+ const musdListToken = Object.entries(chainData).find(([key]) =>
isEqualCaseInsensitive(key, MUSD_ERC20_ADDRESS_LOWER),
)?.[1];
if (musdListToken) {
@@ -939,15 +896,18 @@ export class TokenDetectionController extends StaticIntervalPollingController ({
+ fetchTokenListByChainId: jest.fn(),
+}));
+
+const mockedFetchTokenListByChainId = jest.mocked(fetchTokenListByChainId);
+
+describe('buildTokenListMap', () => {
+ it('maps tokens by address and applies aggregator and icon formatting', () => {
+ const chainId = '0x1' as Hex;
+ const tokens: TokenListToken[] = [
+ {
+ name: 'Sample',
+ symbol: 'SMP',
+ decimals: 18,
+ address: '0xabc0000000000000000000000000000000000001',
+ occurrences: 3,
+ aggregators: ['bancor', 'cmc'],
+ iconUrl: 'https://example.com/icon.png',
+ },
+ ];
+
+ const map = buildTokenListMap(tokens, chainId);
+
+ expect(Object.keys(map)).toStrictEqual([
+ '0xabc0000000000000000000000000000000000001',
+ ]);
+ expect(map['0xabc0000000000000000000000000000000000001']).toMatchObject({
+ name: 'Sample',
+ symbol: 'SMP',
+ decimals: 18,
+ address: '0xabc0000000000000000000000000000000000001',
+ aggregators: ['Bancor', 'CMC'],
+ });
+ expect(map['0xabc0000000000000000000000000000000000001'].iconUrl).toContain(
+ 'https://static.cx.metamask.io',
+ );
+ });
+
+ it('returns an empty map when the token array is empty', () => {
+ expect(buildTokenListMap([], '0x1' as Hex)).toStrictEqual({});
+ });
+});
+
+describe('TokenListService', () => {
+ beforeEach(() => {
+ mockedFetchTokenListByChainId.mockReset();
+ });
+
+ it('fetches via token-service and caches results for the same chain', async () => {
+ const chainId = '0xa86a' as Hex;
+ const apiToken = {
+ name: 'Avalanche Token',
+ symbol: 'AVT',
+ decimals: 18,
+ address: '0x1000000000000000000000000000000000000001',
+ occurrences: 5,
+ aggregators: [] as string[],
+ iconUrl: '',
+ };
+ mockedFetchTokenListByChainId.mockResolvedValue([apiToken]);
+
+ const service = new TokenListService();
+ const first = await service.fetchTokensByChainId(chainId);
+ const second = await service.fetchTokensByChainId(chainId);
+
+ expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1);
+ expect(first).toStrictEqual(second);
+ expect(first[apiToken.address]).toMatchObject({
+ symbol: 'AVT',
+ name: 'Avalanche Token',
+ });
+
+ service.destroy();
+ });
+
+ it('does not re-run map formatting on cache hits for the same chain', async () => {
+ const chainId = '0xa86a' as Hex;
+ const apiTokens = [
+ {
+ name: 'Token A',
+ symbol: 'TKA',
+ decimals: 18,
+ address: '0x1000000000000000000000000000000000000001',
+ occurrences: 1,
+ aggregators: ['bancor'] as string[],
+ iconUrl: '',
+ },
+ {
+ name: 'Token B',
+ symbol: 'TKB',
+ decimals: 6,
+ address: '0x2000000000000000000000000000000000000002',
+ occurrences: 1,
+ aggregators: ['cmc'] as string[],
+ iconUrl: '',
+ },
+ ];
+ mockedFetchTokenListByChainId.mockResolvedValue(apiTokens);
+
+ const formatAggregatorsSpy = jest.spyOn(
+ assetsUtil,
+ 'formatAggregatorNames',
+ );
+ const formatIconSpy = jest.spyOn(assetsUtil, 'formatIconUrlWithProxy');
+
+ const service = new TokenListService();
+ await service.fetchTokensByChainId(chainId);
+ await service.fetchTokensByChainId(chainId);
+
+ expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1);
+ expect(formatAggregatorsSpy).toHaveBeenCalledTimes(2);
+ expect(formatIconSpy).toHaveBeenCalledTimes(2);
+
+ formatAggregatorsSpy.mockRestore();
+ formatIconSpy.mockRestore();
+ service.destroy();
+ });
+
+ it('treats an undefined API response as an empty list', async () => {
+ mockedFetchTokenListByChainId.mockResolvedValue(undefined);
+
+ const service = new TokenListService();
+ expect(await service.fetchTokensByChainId('0x1' as Hex)).toStrictEqual({});
+
+ service.destroy();
+ });
+
+ it('clearing the cache via destroy causes the next fetch to hit the network again', async () => {
+ const chainId = '0x1' as Hex;
+ const apiToken = {
+ name: 'Restored After Destroy',
+ symbol: 'RAD',
+ decimals: 18,
+ address: '0x1000000000000000000000000000000000000001',
+ occurrences: 1,
+ aggregators: [] as string[],
+ iconUrl: '',
+ };
+ mockedFetchTokenListByChainId.mockImplementation(
+ async (_chainId, abortSignal) => {
+ // Mirror token-service: aborted fetches resolve to undefined, not a list.
+ if (abortSignal.aborted) {
+ return undefined;
+ }
+ return [apiToken];
+ },
+ );
+
+ const service = new TokenListService();
+ const first = await service.fetchTokensByChainId(chainId);
+ const cached = await service.fetchTokensByChainId(chainId);
+ expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(1);
+ expect(cached).toStrictEqual(first);
+ expect(first[apiToken.address]).toMatchObject({ symbol: 'RAD' });
+
+ service.destroy();
+
+ const afterDestroy = await service.fetchTokensByChainId(chainId);
+ expect(mockedFetchTokenListByChainId).toHaveBeenCalledTimes(2);
+ expect(afterDestroy[apiToken.address]).toMatchObject({ symbol: 'RAD' });
+
+ service.destroy();
+ });
+});
diff --git a/packages/assets-controllers/src/TokenListService.ts b/packages/assets-controllers/src/TokenListService.ts
new file mode 100644
index 0000000000..4f6db1a015
--- /dev/null
+++ b/packages/assets-controllers/src/TokenListService.ts
@@ -0,0 +1,104 @@
+import type { Hex } from '@metamask/utils';
+import { QueryClient } from '@tanstack/query-core';
+
+import { formatAggregatorNames, formatIconUrlWithProxy } from './assetsUtil';
+import { fetchTokenListByChainId } from './token-service';
+import type { TokenListMap, TokenListToken } from './TokenListController';
+
+// 4 hours — mirrors TokenListController's DEFAULT_THRESHOLD
+const FOUR_HOURS_MS = 4 * 60 * 60 * 1000;
+
+/**
+ * Shared service for fetching and caching the token list per chain.
+ *
+ * Callers invoke `fetchTokensByChainId` directly. TanStack Query caches the
+ * normalised `TokenListMap` for 4 hours so that multiple controllers share the
+ * same in-memory cache without redundant network requests or per-token
+ * formatting work on cache hits.
+ */
+export class TokenListService {
+ readonly #queryClient: QueryClient;
+
+ #abortController: AbortController;
+
+ constructor() {
+ this.#abortController = new AbortController();
+ this.#queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: FOUR_HOURS_MS,
+ // fetchQuery never creates an observer, so entries are immediately
+ // inactive. Without an explicit gcTime the default 5-minute GC would
+ // evict them long before the 4-hour staleTime window expires.
+ gcTime: FOUR_HOURS_MS,
+ retry: false,
+ },
+ },
+ });
+ }
+
+ /**
+ * Fetch the token list for a given chain, normalising the raw API response
+ * into a `TokenListMap` keyed by lowercase address.
+ *
+ * Results are cached in-memory for 4 hours. A second call within the cache
+ * window returns the cached value without a network request.
+ *
+ * @param chainId - The hex chain ID to fetch tokens for.
+ * @returns A map of lowercase token address → token metadata.
+ */
+ async fetchTokensByChainId(chainId: Hex): Promise {
+ const queryKey = ['TokenListService:fetchTokensByChainId', chainId];
+ // On failure, TanStack Query v5 sets isInvalidated=true and leaves state.data
+ // undefined, so the next fetchQuery call always triggers a fresh network request
+ // rather than serving the cached error. No manual cache eviction is needed.
+ return this.#queryClient.fetchQuery({
+ queryKey,
+ queryFn: async () => {
+ const list = (await fetchTokenListByChainId(
+ chainId,
+ this.#abortController.signal,
+ )) as TokenListToken[] | undefined;
+ return buildTokenListMap(list ?? [], chainId);
+ },
+ staleTime: FOUR_HOURS_MS,
+ gcTime: FOUR_HOURS_MS,
+ });
+ }
+
+ /**
+ * Abort in-flight requests, clear the query cache, and reset the abort
+ * controller so subsequent `fetchTokensByChainId` calls are not stuck with an
+ * already-aborted signal (which would cache empty results).
+ */
+ destroy(): void {
+ this.#abortController.abort();
+ this.#queryClient.clear();
+ this.#abortController = new AbortController();
+ }
+}
+
+/**
+ * Normalise a raw token list array (from the token API) into a `TokenListMap`.
+ *
+ * @param tokens - Raw array of token objects returned by the API.
+ * @param chainId - The chain the tokens belong to (used for icon URL proxy).
+ * @returns A record keyed by lowercased token address.
+ */
+export function buildTokenListMap(
+ tokens: TokenListToken[],
+ chainId: Hex,
+): TokenListMap {
+ const tokenListMap: TokenListMap = {};
+ for (const token of tokens) {
+ tokenListMap[token.address.toLowerCase()] = {
+ ...token,
+ aggregators: formatAggregatorNames(token.aggregators),
+ iconUrl: formatIconUrlWithProxy({
+ chainId,
+ tokenAddress: token.address,
+ }),
+ };
+ }
+ return tokenListMap;
+}
diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts
index d374840e5a..db3bf37c7a 100644
--- a/packages/assets-controllers/src/TokensController.test.ts
+++ b/packages/assets-controllers/src/TokensController.test.ts
@@ -3261,124 +3261,104 @@ describe('TokensController', () => {
});
});
- describe('when TokenListController:stateChange is published', () => {
+ describe('on initialization, token list enrichment', () => {
it('updates the name of each token to match its counterpart in the token list', async () => {
- await withController(async ({ controller, messenger }) => {
- ContractMock.mockReturnValue(
- buildMockEthersERC721Contract({ supportsInterface: false }),
- );
- await controller.addToken({
- address: '0x01',
- symbol: 'bar',
- decimals: 2,
- networkClientId: 'mainnet',
- });
- expect(
- controller.state.allTokens[ChainId.mainnet][
- defaultMockInternalAccount.address
- ][0],
- ).toStrictEqual({
- address: '0x01',
- decimals: 2,
- image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
- symbol: 'bar',
- isERC721: false,
- aggregators: [],
- name: undefined,
- });
-
- messenger.publish(
- 'TokenListController:stateChange',
- {
- tokensChainsCache: {
- [ChainId.mainnet]: {
- timestamp: 1,
- data: {
- '0x01': {
- address: '0x01',
- symbol: 'bar',
- decimals: 2,
- occurrences: 1,
- name: 'BarName',
- iconUrl:
- 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
- aggregators: ['Aave'],
- },
+ await withController(
+ {
+ options: {
+ state: {
+ allTokens: {
+ [ChainId.mainnet]: {
+ [defaultMockInternalAccount.address]: [
+ {
+ address: '0x01',
+ decimals: 2,
+ image:
+ 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
+ symbol: 'bar',
+ isERC721: false,
+ aggregators: [],
+ name: undefined,
+ },
+ ],
},
},
},
},
- [],
- );
-
- expect(
- controller.state.allTokens[ChainId.mainnet][
- defaultMockInternalAccount.address
- ][0],
- ).toStrictEqual({
- address: '0x01',
- decimals: 2,
- image: 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
- symbol: 'bar',
- isERC721: false,
- aggregators: [],
- name: 'BarName',
- });
- });
+ },
+ async ({ controller }) => {
+ // The enrichment is async (fires in constructor); wait for it.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ // TokenListService returns the token list for mainnet with a name.
+ // withController stubs fetchTokensByChainId to return {} by default;
+ // for this test we rely on the fact that the name stays undefined
+ // because the service returned nothing — verifying the plumbing at
+ // a unit level would require a more detailed setup tested below.
+ expect(
+ controller.state.allTokens[ChainId.mainnet][
+ defaultMockInternalAccount.address
+ ][0].name,
+ ).toBeUndefined();
+ },
+ );
});
- it('overwrites rwaData for tokens with cached rwaData', async () => {
- await withController(async ({ controller, messenger }) => {
- ContractMock.mockReturnValue(
- buildMockEthersERC721Contract({ supportsInterface: false }),
- );
-
- await controller.addTokens(
- [
- {
- address: '0x01',
- symbol: 'bar',
- decimals: 2,
- aggregators: [],
- image: undefined,
- name: undefined,
- rwaData: { ticker: 'OLD' },
- },
- ],
- 'mainnet',
- );
+ it('enriches name and rwaData from the token list service at init time', async () => {
+ const tokenAddress = '0x01';
- messenger.publish(
- 'TokenListController:stateChange',
- {
- tokensChainsCache: {
- [ChainId.mainnet]: {
- timestamp: 1,
- data: {
- '0x01': {
- address: '0x01',
- symbol: 'bar',
- decimals: 2,
- occurrences: 1,
- name: 'BarName',
- iconUrl:
- 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
- aggregators: ['Aave'],
- rwaData: { ticker: 'NEW' },
- },
+ await withController(
+ {
+ options: {
+ state: {
+ allTokens: {
+ [ChainId.mainnet]: {
+ [defaultMockInternalAccount.address]: [
+ {
+ address: tokenAddress,
+ decimals: 2,
+ image:
+ 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
+ symbol: 'bar',
+ isERC721: false,
+ aggregators: [],
+ name: undefined,
+ rwaData: { ticker: 'OLD' } as TokenRwaData,
+ },
+ ],
},
},
},
+ tokenListService: {
+ fetchTokensByChainId: jest.fn().mockResolvedValue({
+ [tokenAddress]: {
+ address: tokenAddress,
+ symbol: 'bar',
+ decimals: 2,
+ occurrences: 1,
+ name: 'BarName',
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x01.png',
+ aggregators: ['Aave'],
+ rwaData: { ticker: 'NEW' },
+ },
+ }),
+ } as unknown as import('./TokenListService').TokenListService,
},
- [],
- );
+ },
+ async ({ controller }) => {
+ // Enrichment is a fire-and-forget async call in the constructor.
+ await new Promise((resolve) => setTimeout(resolve, 0));
- expect(
- controller.state.allTokens[ChainId.mainnet][
- defaultMockInternalAccount.address
- ][0].rwaData,
- ).toStrictEqual({ ticker: 'NEW' });
- });
+ const token =
+ controller.state.allTokens[ChainId.mainnet][
+ defaultMockInternalAccount.address
+ ][0];
+
+ expect(token.name).toBe('BarName');
+ expect(token.rwaData).toStrictEqual({ ticker: 'NEW' });
+ },
+ );
});
});
@@ -3934,7 +3914,6 @@ async function withController(
'NetworkController:networkDidChange',
'NetworkController:stateChange',
'AccountsController:selectedEvmAccountChange',
- 'TokenListController:stateChange',
'KeyringController:accountRemoved',
],
});
@@ -3961,6 +3940,10 @@ async function withController(
mockListAccounts,
);
+ const tokenListService = {
+ fetchTokensByChainId: jest.fn().mockResolvedValue({}),
+ } as unknown as import('./TokenListService').TokenListService;
+
const controller = new TokensController({
chainId: ChainId.mainnet,
// The tests assume that this is set, but they shouldn't make that
@@ -3969,6 +3952,7 @@ async function withController(
// not specified.
provider: new FakeProvider(),
messenger: tokensControllerMessenger,
+ tokenListService,
...options,
});
diff --git a/packages/assets-controllers/src/TokensController.ts b/packages/assets-controllers/src/TokensController.ts
index 5b2ae1e351..c386f4e4f1 100644
--- a/packages/assets-controllers/src/TokensController.ts
+++ b/packages/assets-controllers/src/TokensController.ts
@@ -53,10 +53,8 @@ import {
TOKEN_METADATA_NO_SUPPORT_ERROR,
TokenRwaData,
} from './token-service';
-import type {
- TokenListStateChange,
- TokenListToken,
-} from './TokenListController';
+import type { TokenListMap, TokenListToken } from './TokenListController';
+import type { TokenListService } from './TokenListService';
import type { Token } from './TokenRatesController';
import type { TokensControllerMethodActions } from './TokensController-method-action-types';
@@ -161,7 +159,6 @@ export type TokensControllerEvents = TokensControllerStateChangeEvent;
export type AllowedEvents =
| NetworkControllerStateChangeEvent
| NetworkControllerNetworkDidChangeEvent
- | TokenListStateChange
| AccountsControllerSelectedEvmAccountChangeEvent
| KeyringControllerAccountRemovedEvent;
@@ -217,16 +214,19 @@ export class TokensController extends BaseController<
* @param options.provider - Network provider.
* @param options.state - Initial state to set on this controller.
* @param options.messenger - The messenger.
+ * @param options.tokenListService - Shared service for fetching token metadata per chain.
*/
constructor({
provider,
state,
messenger,
+ tokenListService,
}: {
chainId: Hex;
provider: Provider;
state?: Partial;
messenger: TokensControllerMessenger;
+ tokenListService: TokenListService;
}) {
super({
name: controllerName,
@@ -261,44 +261,64 @@ export class TokensController extends BaseController<
(accountAddress: string) => this.#handleOnAccountRemoved(accountAddress),
);
- this.messenger.subscribe(
- 'TokenListController:stateChange',
- ({ tokensChainsCache }) => {
- const { allTokens } = this.state;
- const selectedAddress = this.#getSelectedAddress();
-
- // Deep clone the `allTokens` object to ensure mutability
- const updatedAllTokens = cloneDeep(allTokens);
-
- for (const [chainId, chainCache] of Object.entries(tokensChainsCache)) {
- const chainData = chainCache?.data ?? {};
-
- if (updatedAllTokens[chainId as Hex]) {
- if (updatedAllTokens[chainId as Hex][selectedAddress]) {
- const tokens = updatedAllTokens[chainId as Hex][selectedAddress];
-
- for (const [, token] of Object.entries(tokens)) {
- const cachedToken = chainData[token.address.toLowerCase()];
- if (cachedToken && cachedToken.name && !token.name) {
- token.name = cachedToken.name; // Update the token name
- }
- if (cachedToken?.rwaData) {
- token.rwaData = cachedToken.rwaData; // Update the token RWA data
- }
- }
- }
- }
- }
+ // Enrich persisted tokens with name/rwaData from the token list once at init.
+ this.#enrichTokensFromTokenList(tokenListService).catch(() => {
+ // Tokens remain usable without metadata enrichment
+ });
+ }
- // Update the state with the modified tokens
- this.update(() => {
- return {
- ...this.state,
- allTokens: updatedAllTokens,
- };
- });
- },
+ async #enrichTokensFromTokenList(
+ tokenListService: TokenListService,
+ ): Promise {
+ const chainIds = Object.keys(this.state.allTokens) as Hex[];
+ if (chainIds.length === 0) {
+ return;
+ }
+
+ // Fetch all chain data concurrently before touching state so the async gap
+ // is as short as possible and we never hold a stale T0 snapshot while
+ // awaiting individual chain requests.
+ // Promise.allSettled ensures a transient error on one chain does not
+ // prevent other chains from being enriched.
+ const results = await Promise.allSettled(
+ chainIds.map(async (chainId) => {
+ const data = await tokenListService.fetchTokensByChainId(chainId);
+ return [chainId, data] as const;
+ }),
+ );
+ const chainDataMap = Object.fromEntries(
+ results
+ .filter(
+ (
+ result,
+ ): result is PromiseFulfilledResult =>
+ result.status === 'fulfilled',
+ )
+ .map((result) => result.value),
);
+
+ // Read selectedAddress inside the updater so it reflects the live account
+ // at the moment the state write happens, not a snapshot taken before the
+ // async fetch gap above.
+ this.update((state) => {
+ const selectedAddress = this.#getSelectedAddress();
+ for (const chainId of chainIds) {
+ const chainData = chainDataMap[chainId];
+ const tokens = state.allTokens[chainId]?.[selectedAddress];
+ if (!tokens || !chainData) {
+ continue;
+ }
+ for (const token of tokens) {
+ const cachedToken = chainData[token.address.toLowerCase()];
+ if (cachedToken?.name && !token.name) {
+ token.name = cachedToken.name;
+ }
+ if (cachedToken?.rwaData) {
+ token.rwaData = cachedToken.rwaData;
+ }
+ }
+ }
+ });
}
#handleOnAccountRemoved(accountAddress: string) {
diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts
index 8b51e77e4a..8bc2c17247 100644
--- a/packages/assets-controllers/src/index.ts
+++ b/packages/assets-controllers/src/index.ts
@@ -128,6 +128,7 @@ export type {
TokenListControllerMessenger,
} from './TokenListController';
export { TokenListController } from './TokenListController';
+export { TokenListService, buildTokenListMap } from './TokenListService';
export type {
ContractExchangeRates,
ContractMarketData,
diff --git a/yarn.lock b/yarn.lock
index 22b95c7789..221f161ac0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2860,6 +2860,7 @@ __metadata:
"@metamask/storage-service": "npm:^1.0.1"
"@metamask/transaction-controller": "npm:^65.1.0"
"@metamask/utils": "npm:^11.9.0"
+ "@tanstack/query-core": "npm:^5.62.16"
"@ts-bridge/cli": "npm:^0.6.4"
"@types/bn.js": "npm:^5.1.5"
"@types/jest": "npm:^29.5.14"
From 4a766a991a04a2ae76c8bd13979d5e8b7612be58 Mon Sep 17 00:00:00 2001
From: Nick Gambino
Date: Wed, 6 May 2026 02:38:44 -0700
Subject: [PATCH 03/11] Release/961.0.0 (#8708)
## Explanation
This PR releases major version bump of perps-controller to v6.0.0
## References
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Medium Risk**
> Low code-change risk (version/changelog only), but medium integration
risk because `@metamask/perps-controller` is released as a new major
version with documented breaking API field renames that consumers must
adopt.
>
> **Overview**
> Publishes a new release by bumping the monorepo version to `961.0.0`
and `@metamask/perps-controller` to `6.0.0`.
>
> Updates `packages/perps-controller/CHANGELOG.md` with a new `6.0.0`
section (including noted **breaking** `AccountState` balance field
renames) and adjusts the compare links for the new release tag.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8b2f8d62679a43697e8e0852062c3e56e662f0d6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Michal Szorad
---
package.json | 2 +-
packages/perps-controller/CHANGELOG.md | 5 ++++-
packages/perps-controller/package.json | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 652ab0d6bf..b0ccc44a17 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "960.0.0",
+ "version": "961.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md
index c27f796c78..0f884f1541 100644
--- a/packages/perps-controller/CHANGELOG.md
+++ b/packages/perps-controller/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [6.0.0]
+
### Changed
- **BREAKING:** Rename `AccountState.availableBalance` to `spendableBalance` and `AccountState.availableToTradeBalance` to `withdrawableBalance` for clearer semantics across abstraction modes ([#8678](https://github.com/MetaMask/core/pull/8678))
@@ -277,7 +279,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@5.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@6.0.0...HEAD
+[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@5.0.0...@metamask/perps-controller@6.0.0
[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@4.0.0...@metamask/perps-controller@5.0.0
[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.2.0...@metamask/perps-controller@4.0.0
[3.2.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@3.1.1...@metamask/perps-controller@3.2.0
diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json
index c325bdb981..125b1d166c 100644
--- a/packages/perps-controller/package.json
+++ b/packages/perps-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/perps-controller",
- "version": "5.0.0",
+ "version": "6.0.0",
"description": "Controller for perpetual trading functionality in MetaMask",
"keywords": [
"Ethereum",
From ef57665944fb2c7db35086048ae3dca5578747d6 Mon Sep 17 00:00:00 2001
From: Tai Nguyen TT <16631641+tanguyenvn@users.noreply.github.com>
Date: Wed, 6 May 2026 16:40:26 +0700
Subject: [PATCH 04/11] fix(passkey): require user verification in passkey
response verification (#8696)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Explanation
**Current state:** `@metamask/passkey-controller` verified registration
and authentication responses with `requireUserVerification: false`, so
assertions without the WebAuthn **UV** (user verification) flag could
still pass server-side verification. Authentication also documented that
UV was intentionally optional for device compatibility.
**Why change:** Requiring user verification strengthens passkey
ceremonies by ensuring the authenticator performed user verification
(PIN, biometrics, etc.) when the response is accepted.
**Solution:** Pass `requireUserVerification: true` to both
`verifyRegistrationResponse` and `verifyAuthenticationResponse` in
`PasskeyController`. Remove the outdated comment that described UV as
optional on the authentication path. Update unit tests that assert the
options passed into the verify helpers so they expect
`requireUserVerification: true`.
**Consumer note:** Callers should request UV-capable ceremonies (e.g.
align `userVerification` in WebAuthn options with this policy) so real
clients do not fail verification after this change.
## References
* Internal: TO-541 (branch `fix/TO-541-passkey-user-verification`)
*
## Changelog
* [ ] Update `packages/passkey-controller/CHANGELOG.md` with an entry
for this user-facing behavior change (recommended: note that
verification now requires the UV flag).
## 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
- [ ] 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
---
> [!NOTE]
> **High Risk**
> Tightens WebAuthn verification by requiring the UV flag on both
registration and authentication assertions, which can reject previously
accepted responses on some authenticators/clients unless they request UV
explicitly.
>
> **Overview**
> Enforces **user verification (UV)** across passkey enrollment and
unlock flows by switching `verifyRegistrationResponse` and
`verifyAuthenticationResponse` calls to `requireUserVerification: true`.
>
> Aligns client request generation with the new policy by setting
`userVerification: 'required'` in `generateRegistrationOptions`,
`generatePostRegistrationAuthenticationOptions`, and
`generateAuthenticationOptions`, and updates tests/changelog to reflect
the stricter verification behavior.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c258e596f138c02cdf9536dc5de29c2362ad2e5e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
packages/passkey-controller/CHANGELOG.md | 8 ++++
.../src/PasskeyController.test.ts | 44 ++++++++++++++++++-
.../src/PasskeyController.ts | 11 +++--
3 files changed, 55 insertions(+), 8 deletions(-)
diff --git a/packages/passkey-controller/CHANGELOG.md b/packages/passkey-controller/CHANGELOG.md
index cbf1c3c755..bb68b38f9d 100644
--- a/packages/passkey-controller/CHANGELOG.md
+++ b/packages/passkey-controller/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- `PasskeyController` verifies registration and authentication responses with `requireUserVerification: true`, so the WebAuthn user verification (UV) flag must be set; assertions with user presence only no longer pass verification ([#8696](https://github.com/MetaMask/core/pull/8696))
+
+### Fixed
+
+- `generateAuthenticationOptions` now sets `userVerification: 'required'` so client WebAuthn requests align with server-side verification requirements and do not fail on authenticators that skip UV when set to `'preferred'` ([#8696](https://github.com/MetaMask/core/pull/8696))
+
## [2.0.0]
### Added
diff --git a/packages/passkey-controller/src/PasskeyController.test.ts b/packages/passkey-controller/src/PasskeyController.test.ts
index de37013629..75d6698b5a 100644
--- a/packages/passkey-controller/src/PasskeyController.test.ts
+++ b/packages/passkey-controller/src/PasskeyController.test.ts
@@ -284,6 +284,11 @@ describe('PasskeyController', () => {
]);
expect(options.attestation).toBe('none');
expect(options.timeout).toBe(WEBAUTHN_TIMEOUT_MS);
+ expect(options.authenticatorSelection).toStrictEqual({
+ userVerification: 'required',
+ authenticatorAttachment: 'platform',
+ residentKey: 'preferred',
+ });
expect(
(options.extensions as Record)?.prf,
).toBeDefined();
@@ -405,6 +410,21 @@ describe('PasskeyController', () => {
}),
).toThrow(PasskeyControllerErrorMessage.NoRegistrationCeremony);
});
+
+ it('returns options with userVerification required', () => {
+ const controller = createController();
+ const regOpts = controller.generateRegistrationOptions();
+ const authOpts = controller.generatePostRegistrationAuthenticationOptions(
+ {
+ registrationResponse: minimalRegistrationResponse(
+ undefined,
+ regOpts.challenge,
+ ),
+ },
+ );
+
+ expect(authOpts.userVerification).toBe('required');
+ });
});
describe('generateAuthenticationOptions', () => {
@@ -447,6 +467,26 @@ describe('PasskeyController', () => {
(authOpts.extensions as Record)?.prf,
).toBeDefined();
});
+
+ it('returns options with userVerification required', async () => {
+ setupRegistrationMocks();
+ setupAuthenticationMocks();
+ const controller = createController();
+ const regOpts = controller.generateRegistrationOptions();
+
+ await enrollWithPostRegistrationAuth(controller, {
+ registrationResponse: minimalRegistrationResponse(
+ undefined,
+ regOpts.challenge,
+ ),
+ vaultKey: 'vault-key',
+ userHandle: regOpts.user.id,
+ });
+
+ const authOpts = controller.generateAuthenticationOptions();
+
+ expect(authOpts.userVerification).toBe('required');
+ });
});
describe('protectVaultKeyWithPasskey', () => {
@@ -1673,7 +1713,7 @@ describe('PasskeyController', () => {
expect.objectContaining({
expectedOrigin: 'chrome-extension://abc123',
expectedRPIDs: ['custom-rp.com'],
- requireUserVerification: false,
+ requireUserVerification: true,
}),
);
});
@@ -1723,7 +1763,7 @@ describe('PasskeyController', () => {
id: TEST_CREDENTIAL_ID,
counter: 0,
}),
- requireUserVerification: false,
+ requireUserVerification: true,
}),
);
});
diff --git a/packages/passkey-controller/src/PasskeyController.ts b/packages/passkey-controller/src/PasskeyController.ts
index f8f0298e50..7b5bc2b8b1 100644
--- a/packages/passkey-controller/src/PasskeyController.ts
+++ b/packages/passkey-controller/src/PasskeyController.ts
@@ -253,7 +253,7 @@ export class PasskeyController extends BaseController<
],
timeout: WEBAUTHN_TIMEOUT_MS,
authenticatorSelection: {
- userVerification: 'preferred',
+ userVerification: 'required',
authenticatorAttachment: 'platform',
residentKey: 'preferred',
},
@@ -316,7 +316,7 @@ export class PasskeyController extends BaseController<
| undefined,
},
],
- userVerification: 'preferred',
+ userVerification: 'required',
hints: ['client-device', 'hybrid'],
timeout: WEBAUTHN_TIMEOUT_MS,
extensions,
@@ -356,7 +356,7 @@ export class PasskeyController extends BaseController<
transports: record.credential.transports,
},
],
- userVerification: 'preferred',
+ userVerification: 'required',
hints: ['client-device', 'hybrid'],
timeout: WEBAUTHN_TIMEOUT_MS,
extensions,
@@ -413,7 +413,7 @@ export class PasskeyController extends BaseController<
expectedChallenge: registrationCeremony.challenge,
expectedOrigin: this.#expectedOrigin,
expectedRPIDs: this.#expectedRPIDs,
- requireUserVerification: false,
+ requireUserVerification: true,
}).catch((error) => {
log('Error verifying passkey registration response', error);
throw new PasskeyControllerError(
@@ -724,8 +724,7 @@ export class PasskeyController extends BaseController<
counter: credential.counter,
transports: credential.transports,
},
- // UV optional for device compatibility; vault key remains password-gated.
- requireUserVerification: false,
+ requireUserVerification: true,
}).catch((error) => {
log(
'Error verifying passkey authentication response',
From 14767c0cc9fb13e6e50405ba2f48b7efdf4d2474 Mon Sep 17 00:00:00 2001
From: Guillaume Roux
Date: Wed, 6 May 2026 11:57:06 +0200
Subject: [PATCH 05/11] chore: Expose missing `AccountTreeController` methods
through the messenger (#8716)
## Explanation
This exposes missing methods used in the clients through the messenger
after https://github.com/MetaMask/core/pull/7976
## References
Progresses https://consensyssoftware.atlassian.net/browse/WPC-989
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Low Risk**
> Low risk, additive API surface change that only exposes existing
`AccountTreeController` lifecycle methods (`init`, `reinit`) through the
messenger without altering controller behavior or persistence logic.
>
> **Overview**
> **Exposes `AccountTreeController` lifecycle methods via the
messenger.** The controller now registers `init` and `reinit` in
`MESSENGER_EXPOSED_METHODS`, and the auto-generated method action union
adds `AccountTreeController:init`/`AccountTreeController:reinit` (with
exported action types).
>
> Updates the package changelog to document the newly available
messenger actions.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
618ad509c8ab84f841d8f95e0bf56aadbbe47012. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
packages/account-tree-controller/CHANGELOG.md | 8 ++++++
...countTreeController-method-action-types.ts | 25 +++++++++++++++++++
.../src/AccountTreeController.ts | 2 ++
packages/account-tree-controller/src/index.ts | 2 ++
4 files changed, 37 insertions(+)
diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md
index 197d72af33..8944cca95d 100644
--- a/packages/account-tree-controller/CHANGELOG.md
+++ b/packages/account-tree-controller/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Expose missing public `AccountTreeController` methods through its messenger ([#8716](https://github.com/MetaMask/core/pull/8716))
+ - The following actions are now available:
+ - `AccountTreeController:init`
+ - `AccountTreeController:reinit`
+ - Corresponding action types are available as well.
+
## [7.2.0]
### Changed
diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts
index ae86e20f01..abc6ca61e9 100644
--- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts
+++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts
@@ -5,6 +5,29 @@
import type { AccountTreeController } from './AccountTreeController';
+/**
+ * Initialize the controller's state.
+ *
+ * It constructs the initial state of the account tree (tree nodes, nodes
+ * names, metadata, etc..) and will automatically update the controller's
+ * state with it.
+ */
+export type AccountTreeControllerInitAction = {
+ type: `AccountTreeController:init`;
+ handler: AccountTreeController['init'];
+};
+
+/**
+ * Re-initialize the controller's state.
+ *
+ * This is done in one single (atomic) `update` block to avoid having a temporary
+ * cleared state. Use this when you need to force a full re-init even if already initialized.
+ */
+export type AccountTreeControllerReinitAction = {
+ type: `AccountTreeController:reinit`;
+ handler: AccountTreeController['reinit'];
+};
+
/**
* Gets the account wallet object from its ID.
*
@@ -180,6 +203,8 @@ export type AccountTreeControllerSyncWithUserStorageAtLeastOnceAction = {
* Union of all AccountTreeController action types.
*/
export type AccountTreeControllerMethodActions =
+ | AccountTreeControllerInitAction
+ | AccountTreeControllerReinitAction
| AccountTreeControllerGetAccountWalletObjectAction
| AccountTreeControllerGetAccountWalletObjectsAction
| AccountTreeControllerGetAccountsFromSelectedAccountGroupAction
diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts
index 676df296fa..211b16041f 100644
--- a/packages/account-tree-controller/src/AccountTreeController.ts
+++ b/packages/account-tree-controller/src/AccountTreeController.ts
@@ -59,6 +59,8 @@ const MESSENGER_EXPOSED_METHODS = [
'clearState',
'syncWithUserStorage',
'syncWithUserStorageAtLeastOnce',
+ 'init',
+ 'reinit',
] as const;
const accountTreeControllerMetadata: StateMetadata =
diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts
index 96df965c0a..ab9fdcd59f 100644
--- a/packages/account-tree-controller/src/index.ts
+++ b/packages/account-tree-controller/src/index.ts
@@ -33,6 +33,8 @@ export type {
AccountTreeControllerClearStateAction,
AccountTreeControllerSyncWithUserStorageAction,
AccountTreeControllerSyncWithUserStorageAtLeastOnceAction,
+ AccountTreeControllerInitAction,
+ AccountTreeControllerReinitAction,
} from './AccountTreeController-method-action-types';
export type { AccountContext } from './AccountTreeController';
From a082f9c10128ac1f04035bd1024c8ca5ea8f53c2 Mon Sep 17 00:00:00 2001
From: Guillaume Roux
Date: Wed, 6 May 2026 11:57:12 +0200
Subject: [PATCH 06/11] chore: Expose missing `MultichainAccountService`
methods through the messenger (#8717)
## Explanation
This exposes missing methods used in the clients through the messenger
after https://github.com/MetaMask/core/pull/7976
## References
Progresses https://consensyssoftware.atlassian.net/browse/WPC-989
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Low Risk**
> Low risk: this only exposes an existing `init` method through the
messenger and updates exported types/changelog, without changing
initialization behavior or data handling.
>
> **Overview**
> Exposes `MultichainAccountService:init` through the service messenger
by adding it to `MESSENGER_EXPOSED_METHODS` and introducing the
corresponding `MultichainAccountServiceInitAction` type.
>
> Updates public exports (`src/index.ts`) and the package changelog to
reflect the newly available messenger action and action type.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
daa46d938d21bb0b9a9c820551fe4da9251e1366. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
packages/multichain-account-service/CHANGELOG.md | 2 ++
.../MultichainAccountService-method-action-types.ts | 10 ++++++++++
.../src/MultichainAccountService.ts | 1 +
packages/multichain-account-service/src/index.ts | 1 +
4 files changed, 14 insertions(+)
diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md
index 317a8d9cea..61e18b1741 100644
--- a/packages/multichain-account-service/CHANGELOG.md
+++ b/packages/multichain-account-service/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Expose missing `MultichainAccountService:init` action through its messenger ([#8717](https://github.com/MetaMask/core/pull/8717))
+ - Corresponding action type is available as well.
- Filter out `KeyringController` locked errors from sentry reporting ([#8619](https://github.com/MetaMask/core/pull/8619))
### Changed
diff --git a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts
index 7b4d112206..535e341433 100644
--- a/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts
+++ b/packages/multichain-account-service/src/MultichainAccountService-method-action-types.ts
@@ -5,6 +5,15 @@
import type { MultichainAccountService } from './MultichainAccountService';
+/**
+ * Initialize the service and constructs the internal reprensentation of
+ * multichain accounts and wallets.
+ */
+export type MultichainAccountServiceInitAction = {
+ type: `MultichainAccountService:init`;
+ handler: MultichainAccountService['init'];
+};
+
/**
* Re-synchronize MetaMask accounts and the providers accounts if needed.
*
@@ -190,6 +199,7 @@ export type MultichainAccountServiceAlignWalletAction = {
* Union of all MultichainAccountService action types.
*/
export type MultichainAccountServiceMethodActions =
+ | MultichainAccountServiceInitAction
| MultichainAccountServiceResyncAccountsAction
| MultichainAccountServiceEnsureCanUseSnapPlatformAction
| MultichainAccountServiceGetMultichainAccountWalletAction
diff --git a/packages/multichain-account-service/src/MultichainAccountService.ts b/packages/multichain-account-service/src/MultichainAccountService.ts
index 283e747c46..16db4776a3 100644
--- a/packages/multichain-account-service/src/MultichainAccountService.ts
+++ b/packages/multichain-account-service/src/MultichainAccountService.ts
@@ -117,6 +117,7 @@ const MESSENGER_EXPOSED_METHODS = [
'resyncAccounts',
'removeMultichainAccountWallet',
'ensureCanUseSnapPlatform',
+ 'init',
] as const;
/**
diff --git a/packages/multichain-account-service/src/index.ts b/packages/multichain-account-service/src/index.ts
index 802cbfbc97..940b2fe675 100644
--- a/packages/multichain-account-service/src/index.ts
+++ b/packages/multichain-account-service/src/index.ts
@@ -21,6 +21,7 @@ export type {
MultichainAccountServiceSetBasicFunctionalityAction,
MultichainAccountServiceAlignWalletsAction,
MultichainAccountServiceAlignWalletAction,
+ MultichainAccountServiceInitAction,
} from './MultichainAccountService-method-action-types';
export {
AccountProviderWrapper,
From 7b67a01afb715e5c398dbef49b868ca5813fa6bc Mon Sep 17 00:00:00 2001
From: Guillaume Roux
Date: Wed, 6 May 2026 11:57:26 +0200
Subject: [PATCH 07/11] chore: Expose missing `MoneyAccountService` methods
through the messenger (#8718)
## Explanation
This exposes missing methods used in the clients through the messenger.
## References
Progresses https://consensyssoftware.atlassian.net/browse/WPC-989
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Low Risk**
> Low risk: this only exposes an existing `MoneyAccountController.init`
method via the messenger and adds the corresponding action type, without
changing account/keyring logic.
>
> **Overview**
> Exposes the previously unexposed `MoneyAccountController:init` method
through the controller messenger by adding `init` to
`MESSENGER_EXPOSED_METHODS`.
>
> Adds the corresponding `MoneyAccountControllerInitAction` type,
exports it from the package entrypoint, and documents the new exposed
action in the changelog.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
b1a4ad5b1bf848daf79b3faa765320dfbae51838. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
packages/money-account-controller/CHANGELOG.md | 5 +++++
.../src/MoneyAccountController-method-action-types.ts | 10 ++++++++++
.../src/MoneyAccountController.ts | 1 +
packages/money-account-controller/src/index.ts | 1 +
4 files changed, 17 insertions(+)
diff --git a/packages/money-account-controller/CHANGELOG.md b/packages/money-account-controller/CHANGELOG.md
index 0fa137fdf8..58295ebf4e 100644
--- a/packages/money-account-controller/CHANGELOG.md
+++ b/packages/money-account-controller/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Expose missing `MoneyAccountController:init` action through its messenger ([#8718](https://github.com/MetaMask/core/pull/8718))
+ - Corresponding action type is available as well.
+
## [0.2.0]
### Changed
diff --git a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts
index b3f244ba9a..5edf280cef 100644
--- a/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts
+++ b/packages/money-account-controller/src/MoneyAccountController-method-action-types.ts
@@ -5,6 +5,15 @@
import type { MoneyAccountController } from './MoneyAccountController';
+/**
+ * Initializes the controller by creating a money account for the primary
+ * entropy source if one does not already exist.
+ */
+export type MoneyAccountControllerInitAction = {
+ type: `MoneyAccountController:init`;
+ handler: MoneyAccountController['init'];
+};
+
/**
* Creates a money account for the given entropy source. If an account
* already exists for that entropy source, it is returned as-is (idempotent).
@@ -46,6 +55,7 @@ export type MoneyAccountControllerClearStateAction = {
* Union of all MoneyAccountController action types.
*/
export type MoneyAccountControllerMethodActions =
+ | MoneyAccountControllerInitAction
| MoneyAccountControllerCreateMoneyAccountAction
| MoneyAccountControllerGetMoneyAccountAction
| MoneyAccountControllerClearStateAction;
diff --git a/packages/money-account-controller/src/MoneyAccountController.ts b/packages/money-account-controller/src/MoneyAccountController.ts
index 8c1f2f51ff..3898debb25 100644
--- a/packages/money-account-controller/src/MoneyAccountController.ts
+++ b/packages/money-account-controller/src/MoneyAccountController.ts
@@ -59,6 +59,7 @@ const MESSENGER_EXPOSED_METHODS = [
'createMoneyAccount',
'getMoneyAccount',
'clearState',
+ 'init',
] as const;
export type MoneyAccountControllerGetStateAction = ControllerGetStateAction<
diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts
index cfc0f01c00..6ad8e5fe97 100644
--- a/packages/money-account-controller/src/index.ts
+++ b/packages/money-account-controller/src/index.ts
@@ -17,4 +17,5 @@ export type {
MoneyAccountControllerClearStateAction,
MoneyAccountControllerCreateMoneyAccountAction,
MoneyAccountControllerGetMoneyAccountAction,
+ MoneyAccountControllerInitAction,
} from './MoneyAccountController-method-action-types';
From e22b7ae76fc44708f6f1e6762bb098d2a5b559c2 Mon Sep 17 00:00:00 2001
From: MJ Kiwi
Date: Wed, 6 May 2026 22:18:53 +1200
Subject: [PATCH 08/11] Release/962.0.0 (#8712)
## Explanation
This release updates `@metamask/gator-permissions-controller` to `4.1.0`
and updates `@metamask/signature-controller` to consume
`@metamask/gator-permissions-controller@^4.1.0`.
The changelogs were updated to include consumer-facing changes only,
including the `signature-controller` dependency bump entry for this PR.
## References
N/A
## 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 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
---
> [!NOTE]
> **Low Risk**
> Low risk release bookkeeping: only package version/changelog updates
and a dependency range bump in `@metamask/signature-controller`, with no
code logic changes in this PR.
>
> **Overview**
> Bumps the monorepo version to `962.0.0` and releases
`@metamask/gator-permissions-controller` as `4.1.0` (with corresponding
changelog entry/link updates).
>
> Updates `@metamask/signature-controller` to consume
`@metamask/gator-permissions-controller@^4.1.0`, and records this
dependency bump in the signature controller changelog; lockfile is
updated accordingly.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8c688392d08a0f60370e26897ec398f8fe57fbad. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Jeff Smale <6363749+jeffsmale90@users.noreply.github.com>
---
package.json | 2 +-
packages/gator-permissions-controller/CHANGELOG.md | 14 +++++++-------
packages/gator-permissions-controller/package.json | 2 +-
packages/signature-controller/CHANGELOG.md | 1 +
packages/signature-controller/package.json | 2 +-
yarn.lock | 4 ++--
6 files changed, 13 insertions(+), 12 deletions(-)
diff --git a/package.json b/package.json
index b0ccc44a17..63544b9d98 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "961.0.0",
+ "version": "962.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md
index 629bc3f7e7..04599cd0bd 100644
--- a/packages/gator-permissions-controller/CHANGELOG.md
+++ b/packages/gator-permissions-controller/CHANGELOG.md
@@ -7,14 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [4.1.0]
+
### Added
-- Add `payee` rule to execution permission decoding for all known permission types ([#8668](https://github.com/MetaMask/core/pull/8668))
-- Support `RedeemerEnforcer` caveat when decoding execution permissions ([#8537](https://github.com/MetaMask/core/pull/8537))
- - Permission decoding now recognizes the `RedeemerEnforcer` as an optional caveat on all execution permission types and extracts a `redeemer` rule containing the allowlisted addresses.
- - `DecodedPermission` type now includes an optional `rules` property for rules recovered from caveats.
- - Export new `EXECUTION_PERMISSION_REDEEMER_RULE_TYPE` constant and `RedeemerRule` type.
-- New decoding rules for `native-token-allowance` and `erc20-token-allowance` ([#8553](https://github.com/MetaMask/core/pull/8553))
+- Add `payee` rule decoding for execution permissions, extracting allowed recipient addresses from `AllowedTargetsEnforcer` (native token) and `AllowedCalldataEnforcer` (ERC-20 token) caveats ([#8668](https://github.com/MetaMask/core/pull/8668))
+- Add `redeemer` rule decoding for execution permissions, extracting addresses from `RedeemerEnforcer` caveats ([#8537](https://github.com/MetaMask/core/pull/8537))
+- Add `native-token-allowance` and `erc20-token-allowance` execution permission type decoding ([#8553](https://github.com/MetaMask/core/pull/8553))
### Changed
@@ -234,7 +233,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release ([#6033](https://github.com/MetaMask/core/pull/6033))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.1.0...HEAD
+[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@4.0.0...@metamask/gator-permissions-controller@4.1.0
[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@3.0.1...@metamask/gator-permissions-controller@4.0.0
[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@3.0.0...@metamask/gator-permissions-controller@3.0.1
[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.2.0...@metamask/gator-permissions-controller@3.0.0
diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json
index 117b5086a9..917cf5e131 100644
--- a/packages/gator-permissions-controller/package.json
+++ b/packages/gator-permissions-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/gator-permissions-controller",
- "version": "4.0.0",
+ "version": "4.1.0",
"description": "Controller for managing gator permissions with profile sync integration",
"keywords": [
"Ethereum",
diff --git a/packages/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md
index cc5baed7a2..2ddacce1db 100644
--- a/packages/signature-controller/CHANGELOG.md
+++ b/packages/signature-controller/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632))
- Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.4.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665))
- Bump `@metamask/network-controller` from `^30.0.1` to `^30.1.0` ([#8636](https://github.com/MetaMask/core/pull/8636))
+- Bump `@metamask/gator-permissions-controller` from `^4.0.0` to `^4.1.0` ([#8712](https://github.com/MetaMask/core/pull/8712))
## [39.2.0]
diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json
index 3df322b587..a89c9bfead 100644
--- a/packages/signature-controller/package.json
+++ b/packages/signature-controller/package.json
@@ -58,7 +58,7 @@
"@metamask/base-controller": "^9.1.0",
"@metamask/controller-utils": "^11.20.0",
"@metamask/eth-sig-util": "^8.2.0",
- "@metamask/gator-permissions-controller": "^4.0.0",
+ "@metamask/gator-permissions-controller": "^4.1.0",
"@metamask/keyring-controller": "^25.4.0",
"@metamask/logging-controller": "^8.0.1",
"@metamask/messenger": "^1.2.0",
diff --git a/yarn.lock b/yarn.lock
index 221f161ac0..84e9db7ed5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4100,7 +4100,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@metamask/gator-permissions-controller@npm:^4.0.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller":
+"@metamask/gator-permissions-controller@npm:^4.1.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller":
version: 0.0.0-use.local
resolution: "@metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller"
dependencies:
@@ -5398,7 +5398,7 @@ __metadata:
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/eth-sig-util": "npm:^8.2.0"
- "@metamask/gator-permissions-controller": "npm:^4.0.0"
+ "@metamask/gator-permissions-controller": "npm:^4.1.0"
"@metamask/keyring-controller": "npm:^25.4.0"
"@metamask/logging-controller": "npm:^8.0.1"
"@metamask/messenger": "npm:^1.2.0"
From e354b0b72672d0c8365fe7248b5179f3151948a1 Mon Sep 17 00:00:00 2001
From: Guillaume Roux
Date: Wed, 6 May 2026 12:31:00 +0200
Subject: [PATCH 09/11] chore: Expose missing `AssetsController` methods
through the messenger (#8719)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Explanation
This exposes missing methods used in the clients through the messenger.
## References
Progresses https://consensyssoftware.atlassian.net/browse/WPC-989
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Low Risk**
> Low risk: this only expands the messenger-exposed API surface to
include an existing `setSelectedCurrency` method, with no changes to
business logic or data handling.
>
> **Overview**
> **Exposes `AssetsController.setSelectedCurrency` via the messenger.**
This adds the `AssetsController:setSelectedCurrency` action type,
includes `setSelectedCurrency` in the controller’s
`MESSENGER_EXPOSED_METHODS`, exports the new action type from
`index.ts`, and unregisters the handler on `destroy()`.
>
> Updates the `@metamask/assets-controller` changelog to document the
newly exposed action.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8ae3b436392c032264a14fa4f23b1d0cb0324c34. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
packages/assets-controller/CHANGELOG.md | 2 ++
.../src/AssetsController-method-action-types.ts | 13 ++++++++++++-
packages/assets-controller/src/AssetsController.ts | 4 ++++
packages/assets-controller/src/index.ts | 1 +
4 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md
index f07deedc2f..9527c165ce 100644
--- a/packages/assets-controller/CHANGELOG.md
+++ b/packages/assets-controller/CHANGELOG.md
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Expose missing `AssetsController:setSelectedCurrency` action through its messenger ([#8719](https://github.com/MetaMask/core/pull/8719))
+ - Corresponding action type is available as well.
- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691))
- Bump `@metamask/network-enablement-controller` from `^5.0.2` to `^5.1.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
diff --git a/packages/assets-controller/src/AssetsController-method-action-types.ts b/packages/assets-controller/src/AssetsController-method-action-types.ts
index 9ef3f3ba84..8cdd37b170 100644
--- a/packages/assets-controller/src/AssetsController-method-action-types.ts
+++ b/packages/assets-controller/src/AssetsController-method-action-types.ts
@@ -115,6 +115,16 @@ export type AssetsControllerUnhideAssetAction = {
handler: AssetsController['unhideAsset'];
};
+/**
+ * Set the current currency.
+ *
+ * @param selectedCurrency - The ISO 4217 currency code to set.
+ */
+export type AssetsControllerSetSelectedCurrencyAction = {
+ type: `AssetsController:setSelectedCurrency`;
+ handler: AssetsController['setSelectedCurrency'];
+};
+
/**
* Union of all AssetsController action types.
*/
@@ -129,4 +139,5 @@ export type AssetsControllerMethodActions =
| AssetsControllerRemoveCustomAssetAction
| AssetsControllerGetCustomAssetsAction
| AssetsControllerHideAssetAction
- | AssetsControllerUnhideAssetAction;
+ | AssetsControllerUnhideAssetAction
+ | AssetsControllerSetSelectedCurrencyAction;
diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts
index f18fe2d56e..7a8d0a5416 100644
--- a/packages/assets-controller/src/AssetsController.ts
+++ b/packages/assets-controller/src/AssetsController.ts
@@ -175,6 +175,7 @@ const MESSENGER_EXPOSED_METHODS = [
'getCustomAssets',
'hideAsset',
'unhideAsset',
+ 'setSelectedCurrency',
] as const;
/** Default polling interval hint for data sources (30 seconds) */
@@ -3165,5 +3166,8 @@ export class AssetsController extends BaseController<
this.messenger.unregisterActionHandler('AssetsController:getCustomAssets');
this.messenger.unregisterActionHandler('AssetsController:hideAsset');
this.messenger.unregisterActionHandler('AssetsController:unhideAsset');
+ this.messenger.unregisterActionHandler(
+ 'AssetsController:setSelectedCurrency',
+ );
}
}
diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts
index 2060f832f9..8b4aa80433 100644
--- a/packages/assets-controller/src/index.ts
+++ b/packages/assets-controller/src/index.ts
@@ -39,6 +39,7 @@ export type {
AssetsControllerUnhideAssetAction,
AssetsControllerGetExchangeRatesForBridgeAction,
AssetsControllerGetStateForTransactionPayAction,
+ AssetsControllerSetSelectedCurrencyAction,
} from './AssetsController-method-action-types';
// Core types
From 024e07f7d7f0b68eb87996eb1eaf7833aecfd1a2 Mon Sep 17 00:00:00 2001
From: Juanmi <95381763+juanmigdr@users.noreply.github.com>
Date: Wed, 6 May 2026 14:18:55 +0200
Subject: [PATCH 10/11] Release/963.0.0 (#8721)
## Explanation
Major update to assets-controllers
## References
https://consensyssoftware.atlassian.net/browse/ASSETS-3091
## Checklist
- [ ] 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
- [ ] 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
---
> [!NOTE]
> **Medium Risk**
> Primarily a dependency/version release bump, but it pulls in
`@metamask/assets-controllers@106.0.0` which includes breaking API
changes (e.g., new `TokenListService` constructor requirements) that can
affect consumers at runtime/build time.
>
> **Overview**
> Updates the monorepo release to `963.0.0` and publishes new package
versions for `@metamask/assets-controller` (`6.4.0`),
`@metamask/assets-controllers` (`106.0.0`),
`@metamask/bridge-controller` (`71.1.1`), and
`@metamask/transaction-pay-controller` (`21.1.0`).
>
> Downstream controllers (`bridge-controller`,
`bridge-status-controller`, `transaction-pay-controller`) are updated to
depend on the new assets packages, and changelogs/lockfile are updated
accordingly.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ae12011ba23fc041e0ce71e6c7221bddc9337774. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---------
Co-authored-by: Guillaume Roux
---
package.json | 2 +-
packages/assets-controller/CHANGELOG.md | 11 ++++++++--
packages/assets-controller/package.json | 4 ++--
packages/assets-controllers/CHANGELOG.md | 5 ++++-
packages/assets-controllers/package.json | 2 +-
packages/bridge-controller/CHANGELOG.md | 10 +++++++++-
packages/bridge-controller/package.json | 6 +++---
.../bridge-status-controller/CHANGELOG.md | 2 +-
.../bridge-status-controller/package.json | 2 +-
.../transaction-pay-controller/CHANGELOG.md | 11 +++++++---
.../transaction-pay-controller/package.json | 8 ++++----
yarn.lock | 20 +++++++++----------
12 files changed, 53 insertions(+), 30 deletions(-)
diff --git a/package.json b/package.json
index 63544b9d98..d45a691947 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "962.0.0",
+ "version": "963.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md
index 9527c165ce..7519b073e3 100644
--- a/packages/assets-controller/CHANGELOG.md
+++ b/packages/assets-controller/CHANGELOG.md
@@ -7,15 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
-### Changed
+## [6.4.0]
+
+### Added
- Expose missing `AssetsController:setSelectedCurrency` action through its messenger ([#8719](https://github.com/MetaMask/core/pull/8719))
- Corresponding action type is available as well.
+
+### Changed
+
- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691))
- Bump `@metamask/network-enablement-controller` from `^5.0.2` to `^5.1.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
- Bump `@metamask/keyring-controller` from `^25.3.0` to `^25.4.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
- Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
- Bump `@metamask/account-tree-controller` from `^7.1.0` to `^7.2.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
+- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721))
## [6.3.0]
@@ -414,7 +420,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709))
- Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.4.0...HEAD
+[6.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...@metamask/assets-controller@6.4.0
[6.3.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.2.1...@metamask/assets-controller@6.3.0
[6.2.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.2.0...@metamask/assets-controller@6.2.1
[6.2.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.1.0...@metamask/assets-controller@6.2.0
diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json
index 4cd6586553..aa20a150df 100644
--- a/packages/assets-controller/package.json
+++ b/packages/assets-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/assets-controller",
- "version": "6.3.0",
+ "version": "6.4.0",
"description": "Tracks assets balances/prices and handles token detection across all digital assets",
"keywords": [
"Ethereum",
@@ -58,7 +58,7 @@
"@ethersproject/providers": "^5.7.0",
"@metamask/account-tree-controller": "^7.2.0",
"@metamask/accounts-controller": "^38.0.0",
- "@metamask/assets-controllers": "^105.1.0",
+ "@metamask/assets-controllers": "^106.0.0",
"@metamask/base-controller": "^9.1.0",
"@metamask/client-controller": "^1.0.1",
"@metamask/controller-utils": "^11.20.0",
diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index 2ee6e1e503..61004f023c 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [106.0.0]
+
### Added
- Add `TokenListService`, a shared service for fetching and caching the token list per chain ([#8700](https://github.com/MetaMask/core/pull/8700))
@@ -3041,7 +3043,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...HEAD
+[106.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...@metamask/assets-controllers@106.0.0
[105.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.0.0...@metamask/assets-controllers@105.1.0
[105.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@104.3.0...@metamask/assets-controllers@105.0.0
[104.3.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@104.2.0...@metamask/assets-controllers@104.3.0
diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json
index 591ebf5056..e842ea6e70 100644
--- a/packages/assets-controllers/package.json
+++ b/packages/assets-controllers/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/assets-controllers",
- "version": "105.1.0",
+ "version": "106.0.0",
"description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)",
"keywords": [
"Ethereum",
diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md
index 6e44cbccdc..f32f937c94 100644
--- a/packages/bridge-controller/CHANGELOG.md
+++ b/packages/bridge-controller/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [71.1.1]
+
+### Changed
+
+- Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721))
+- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721))
+
## [71.1.0]
### Added
@@ -1409,7 +1416,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release ([#5317](https://github.com/MetaMask/core/pull/5317))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...HEAD
+[71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.0...@metamask/bridge-controller@71.1.1
[71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.0.0...@metamask/bridge-controller@71.1.0
[71.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.2.0...@metamask/bridge-controller@71.0.0
[70.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@70.1.1...@metamask/bridge-controller@70.2.0
diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json
index 82f8122ee0..9d7c69b7e4 100644
--- a/packages/bridge-controller/package.json
+++ b/packages/bridge-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/bridge-controller",
- "version": "71.1.0",
+ "version": "71.1.1",
"description": "Manages bridge-related quote fetching functionality for MetaMask",
"keywords": [
"Ethereum",
@@ -58,8 +58,8 @@
"@ethersproject/contracts": "^5.7.0",
"@ethersproject/providers": "^5.7.0",
"@metamask/accounts-controller": "^38.0.0",
- "@metamask/assets-controller": "^6.3.0",
- "@metamask/assets-controllers": "^105.1.0",
+ "@metamask/assets-controller": "^6.4.0",
+ "@metamask/assets-controllers": "^106.0.0",
"@metamask/base-controller": "^9.1.0",
"@metamask/controller-utils": "^11.20.0",
"@metamask/gas-fee-controller": "^26.1.1",
diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md
index c6043dc99f..c962d2c1d2 100644
--- a/packages/bridge-status-controller/CHANGELOG.md
+++ b/packages/bridge-status-controller/CHANGELOG.md
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.0` ([#8706](https://github.com/MetaMask/core/pull/8706))
+- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.1` ([#8706](https://github.com/MetaMask/core/pull/8706), [#8721](https://github.com/MetaMask/core/pull/8721))
- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691))
- Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665))
- Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632))
diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json
index 1b8ed01e5b..0815bc942c 100644
--- a/packages/bridge-status-controller/package.json
+++ b/packages/bridge-status-controller/package.json
@@ -54,7 +54,7 @@
"dependencies": {
"@metamask/accounts-controller": "^38.0.0",
"@metamask/base-controller": "^9.1.0",
- "@metamask/bridge-controller": "^71.1.0",
+ "@metamask/bridge-controller": "^71.1.1",
"@metamask/controller-utils": "^11.20.0",
"@metamask/gas-fee-controller": "^26.1.1",
"@metamask/keyring-controller": "^25.4.0",
diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md
index b1ea46c656..8e4f609561 100644
--- a/packages/transaction-pay-controller/CHANGELOG.md
+++ b/packages/transaction-pay-controller/CHANGELOG.md
@@ -7,16 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [21.1.0]
+
### Added
-- Allow EIP-7702 authorizations from accounts in the Money keyring ([#8687](https://github.com/MetaMask/core/pull/8687)).
+- Allow EIP-7702 authorizations from accounts in the Money keyring ([#8687](https://github.com/MetaMask/core/pull/8687))
- Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347))
### Changed
-- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.0` ([#8706](https://github.com/MetaMask/core/pull/8706))
+- Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.1` ([#8706](https://github.com/MetaMask/core/pull/8706), [#8721](https://github.com/MetaMask/core/pull/8721))
- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691))
- Bump `@metamask/ramps-controller` from `^13.2.0` to `^13.3.0` ([#8698](https://github.com/MetaMask/core/pull/8698))
+- Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721))
+- Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721))
## [21.0.0]
@@ -775,7 +779,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release ([#6820](https://github.com/MetaMask/core/pull/6820))
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.1.0...HEAD
+[21.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.0.0...@metamask/transaction-pay-controller@21.1.0
[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.2.0...@metamask/transaction-pay-controller@21.0.0
[20.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.1.0...@metamask/transaction-pay-controller@20.2.0
[20.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.0.1...@metamask/transaction-pay-controller@20.1.0
diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json
index da58bbfbc1..8d181c5714 100644
--- a/packages/transaction-pay-controller/package.json
+++ b/packages/transaction-pay-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/transaction-pay-controller",
- "version": "21.0.0",
+ "version": "21.1.0",
"description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask",
"keywords": [
"Ethereum",
@@ -57,10 +57,10 @@
"@ethersproject/abi": "^5.7.0",
"@ethersproject/contracts": "^5.7.0",
"@ethersproject/providers": "^5.7.0",
- "@metamask/assets-controller": "^6.3.0",
- "@metamask/assets-controllers": "^105.1.0",
+ "@metamask/assets-controller": "^6.4.0",
+ "@metamask/assets-controllers": "^106.0.0",
"@metamask/base-controller": "^9.1.0",
- "@metamask/bridge-controller": "^71.1.0",
+ "@metamask/bridge-controller": "^71.1.1",
"@metamask/bridge-status-controller": "^71.1.0",
"@metamask/controller-utils": "^11.20.0",
"@metamask/gas-fee-controller": "^26.1.1",
diff --git a/yarn.lock b/yarn.lock
index 84e9db7ed5..2268bfb398 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2768,7 +2768,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@metamask/assets-controller@npm:^6.3.0, @metamask/assets-controller@workspace:packages/assets-controller":
+"@metamask/assets-controller@npm:^6.4.0, @metamask/assets-controller@workspace:packages/assets-controller":
version: 0.0.0-use.local
resolution: "@metamask/assets-controller@workspace:packages/assets-controller"
dependencies:
@@ -2777,7 +2777,7 @@ __metadata:
"@ethersproject/providers": "npm:^5.7.0"
"@metamask/account-tree-controller": "npm:^7.2.0"
"@metamask/accounts-controller": "npm:^38.0.0"
- "@metamask/assets-controllers": "npm:^105.1.0"
+ "@metamask/assets-controllers": "npm:^106.0.0"
"@metamask/auto-changelog": "npm:^6.1.0"
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/client-controller": "npm:^1.0.1"
@@ -2815,7 +2815,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@metamask/assets-controllers@npm:^105.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers":
+"@metamask/assets-controllers@npm:^106.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers":
version: 0.0.0-use.local
resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers"
dependencies:
@@ -3015,7 +3015,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@metamask/bridge-controller@npm:^71.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller":
+"@metamask/bridge-controller@npm:^71.1.1, @metamask/bridge-controller@workspace:packages/bridge-controller":
version: 0.0.0-use.local
resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller"
dependencies:
@@ -3025,8 +3025,8 @@ __metadata:
"@ethersproject/contracts": "npm:^5.7.0"
"@ethersproject/providers": "npm:^5.7.0"
"@metamask/accounts-controller": "npm:^38.0.0"
- "@metamask/assets-controller": "npm:^6.3.0"
- "@metamask/assets-controllers": "npm:^105.1.0"
+ "@metamask/assets-controller": "npm:^6.4.0"
+ "@metamask/assets-controllers": "npm:^106.0.0"
"@metamask/auto-changelog": "npm:^6.1.0"
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/controller-utils": "npm:^11.20.0"
@@ -3069,7 +3069,7 @@ __metadata:
"@metamask/accounts-controller": "npm:^38.0.0"
"@metamask/auto-changelog": "npm:^6.1.0"
"@metamask/base-controller": "npm:^9.1.0"
- "@metamask/bridge-controller": "npm:^71.1.0"
+ "@metamask/bridge-controller": "npm:^71.1.1"
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/gas-fee-controller": "npm:^26.1.1"
"@metamask/keyring-controller": "npm:^25.4.0"
@@ -5731,11 +5731,11 @@ __metadata:
"@ethersproject/abi": "npm:^5.7.0"
"@ethersproject/contracts": "npm:^5.7.0"
"@ethersproject/providers": "npm:^5.7.0"
- "@metamask/assets-controller": "npm:^6.3.0"
- "@metamask/assets-controllers": "npm:^105.1.0"
+ "@metamask/assets-controller": "npm:^6.4.0"
+ "@metamask/assets-controllers": "npm:^106.0.0"
"@metamask/auto-changelog": "npm:^6.1.0"
"@metamask/base-controller": "npm:^9.1.0"
- "@metamask/bridge-controller": "npm:^71.1.0"
+ "@metamask/bridge-controller": "npm:^71.1.1"
"@metamask/bridge-status-controller": "npm:^71.1.0"
"@metamask/controller-utils": "npm:^11.20.0"
"@metamask/gas-fee-controller": "npm:^26.1.1"
From 528babf5b5fe37737edf2d1e820dc18a11914d24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=96mer=20G=C3=B6ktu=C4=9F=20Poyraz?=
Date: Wed, 6 May 2026 15:05:06 +0200
Subject: [PATCH 11/11] fix: route fiat payment method through strategy
selection to enable fiat strategy (#8720)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Explanation
The fiat strategy was never selected during quote retrieval because of
two issues:
1. **`getStrategyOrder` never included `Fiat`** — The default strategy
order only contained `Relay` and `Across`. The fiat payment method ID
was not passed into the strategy routing function, so
`TransactionPayStrategy.Fiat` was never part of the strategy list.
2. **`getQuotes` bailed early on empty requests** — When a fiat payment
method is selected without a crypto payment token, `buildQuoteRequests`
returns an empty array (no `paymentToken`). The guard `if
(!requests?.length)` short-circuited before any strategy could run. The
fiat strategy doesn't need these requests — it derives its own relay
request from `amountFiat` internally.
### Changes
- **`getStrategyOrder`** — Added optional `fiatPaymentMethodId`
parameter. When provided, early returns `[TransactionPayStrategy.Fiat]`
so only the fiat strategy is used.
- **`#getStrategiesWithFallback`** — Now passes
`transactionData.fiatPayment.selectedPaymentMethodId` through to
`getStrategyOrder`.
- **`getQuotes`** — Changed the empty-requests guard from
`!requests?.length` to `!requests?.length && !fiatPaymentMethod` so the
strategy loop runs when a fiat payment method is active.
## References
- Related to CONF-1065
## 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
---
> [!NOTE]
> **Medium Risk**
> Changes strategy routing and quote retrieval guard logic, which can
affect which pay strategy executes and when quotes are fetched, but
scope is limited to the transaction-pay controller and covered by new
unit tests.
>
> **Overview**
> Fixes fiat pay quote retrieval by threading
`fiatPayment.selectedPaymentMethodId` into strategy selection and
ensuring it can drive the `Fiat` strategy.
>
> `getStrategyOrder` now accepts an optional fiat payment method ID and
short-circuits to **Fiat-only** strategy ordering when present, and
`TransactionPayController` passes this value through during fallback
strategy resolution. Quote retrieval no longer bails early on empty
request sets when a fiat payment method is selected, allowing fiat
quotes even without a crypto `paymentToken`.
>
> Adds targeted test coverage for the new routing behavior and updates
the package changelog under **Fixed**.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5e81276d5177364894df56c0b33b9679f8ab6666. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../transaction-pay-controller/CHANGELOG.md | 4 ++
.../src/TransactionPayController.test.ts | 42 ++++++++++++++++++
.../src/TransactionPayController.ts | 5 ++-
.../src/utils/feature-flags.test.ts | 43 +++++++++++++++++++
.../src/utils/feature-flags.ts | 7 +++
.../src/utils/quotes.test.ts | 36 ++++++++++++++++
.../src/utils/quotes.ts | 2 +-
7 files changed, 136 insertions(+), 3 deletions(-)
diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md
index 8e4f609561..c2fbde7f2b 100644
--- a/packages/transaction-pay-controller/CHANGELOG.md
+++ b/packages/transaction-pay-controller/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- Fix fiat strategy never being selected by routing fiat payment method through `getStrategyOrder` and allowing quote retrieval when no crypto payment token is set ([#8720](https://github.com/MetaMask/core/pull/8720))
+
## [21.1.0]
### Added
diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts
index bd8e5a4cfe..1e18803732 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts
@@ -540,6 +540,48 @@ describe('TransactionPayController', () => {
CHAIN_ID_MOCK,
TOKEN_ADDRESS_MOCK,
'perpsDeposit',
+ undefined,
+ );
+ });
+
+ it('passes fiat payment method ID into getStrategyOrder', async () => {
+ const controller = createController();
+
+ controller.updatePaymentToken({
+ transactionId: TRANSACTION_ID_MOCK,
+ tokenAddress: TOKEN_ADDRESS_MOCK,
+ chainId: CHAIN_ID_MOCK,
+ });
+
+ const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1];
+
+ updateTransactionData(TRANSACTION_ID_MOCK, (data) => {
+ data.paymentToken = {
+ address: TOKEN_ADDRESS_MOCK,
+ balanceFiat: '1',
+ balanceHuman: '1',
+ balanceRaw: '1',
+ balanceUsd: '1',
+ chainId: CHAIN_ID_MOCK,
+ decimals: 6,
+ symbol: 'USDC',
+ };
+ data.fiatPayment = { selectedPaymentMethodId: 'card-123' };
+ });
+
+ const transactionMeta = {
+ id: TRANSACTION_ID_MOCK,
+ type: 'perpsDeposit',
+ } as TransactionMeta;
+
+ messenger.call('TransactionPayController:getStrategy', transactionMeta);
+
+ expect(getStrategyOrderMock).toHaveBeenCalledWith(
+ messenger,
+ CHAIN_ID_MOCK,
+ TOKEN_ADDRESS_MOCK,
+ 'perpsDeposit',
+ 'card-123',
);
});
});
diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts
index e1b7670379..61cb6b4790 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController.ts
@@ -324,14 +324,15 @@ export class TransactionPayController extends BaseController<
return validStrategies;
}
- const paymentToken =
- this.state.transactionData[transaction.id]?.paymentToken;
+ const transactionData = this.state.transactionData[transaction.id];
+ const paymentToken = transactionData?.paymentToken;
return getStrategyOrder(
this.messenger,
paymentToken?.chainId,
paymentToken?.address,
transaction.type,
+ transactionData?.fiatPayment?.selectedPaymentMethodId,
);
}
}
diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts
index 6e16c174cb..2257127c07 100644
--- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts
+++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts
@@ -809,6 +809,49 @@ describe('Feature Flags Utils', () => {
TransactionPayStrategy.Relay,
]);
});
+
+ it('returns only Fiat strategy when fiatPaymentMethodId is provided', () => {
+ const strategyOrder = getStrategyOrder(
+ messenger,
+ undefined,
+ undefined,
+ undefined,
+ 'card-123',
+ );
+
+ expect(strategyOrder).toStrictEqual([TransactionPayStrategy.Fiat]);
+ });
+
+ it('returns only Fiat strategy regardless of other routing config when fiatPaymentMethodId is provided', () => {
+ getRemoteFeatureFlagControllerStateMock.mockReturnValue({
+ ...getDefaultRemoteFeatureFlagControllerState(),
+ remoteFeatureFlags: {
+ confirmations_pay: {
+ strategyOrder: [
+ TransactionPayStrategy.Relay,
+ TransactionPayStrategy.Across,
+ ],
+ strategyOverrides: {
+ default: {
+ chains: {
+ [CHAIN_ID_MOCK]: [TransactionPayStrategy.Bridge],
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const strategyOrder = getStrategyOrder(
+ messenger,
+ CHAIN_ID_MOCK,
+ TOKEN_ADDRESS_MOCK,
+ 'perpsDeposit',
+ '/payments/debit-credit-card',
+ );
+
+ expect(strategyOrder).toStrictEqual([TransactionPayStrategy.Fiat]);
+ });
});
describe('getStrategyOrder route-aware resolution', () => {
diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts
index 911c5bc605..491853bfdf 100644
--- a/packages/transaction-pay-controller/src/utils/feature-flags.ts
+++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts
@@ -312,6 +312,7 @@ function getDefaultOverrideStrategies(
* @param tokenAddress - Optional token address used to match route overrides.
* @param transactionType - Optional transaction type used to match route
* overrides.
+ * @param fiatPaymentMethodId - Optional fiat payment method ID used to match route overrides.
* @returns Ordered strategy list.
*/
export function getStrategyOrder(
@@ -319,7 +320,13 @@ export function getStrategyOrder(
chainId?: Hex,
tokenAddress?: Hex,
transactionType?: string,
+ fiatPaymentMethodId?: string,
): StrategyOrder {
+ // If fiat payment method is selected, use Fiat strategy only
+ if (fiatPaymentMethodId) {
+ return [TransactionPayStrategy.Fiat];
+ }
+
const routingConfig = getStrategyRoutingConfig(messenger);
const normalizedChainId = normalizeHex(chainId);
const normalizedTokenAddress = normalizeHex(tokenAddress);
diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts
index c0a9eabcf3..30e37f1040 100644
--- a/packages/transaction-pay-controller/src/utils/quotes.test.ts
+++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts
@@ -589,6 +589,42 @@ describe('Quotes Utils', () => {
});
});
+ it('still invokes strategies when no payment token but fiat payment method is set', async () => {
+ await run({
+ transactionData: {
+ ...TRANSACTION_DATA_MOCK,
+ paymentToken: undefined,
+ fiatPayment: { selectedPaymentMethodId: 'card-123' },
+ },
+ });
+
+ expect(getQuotesMock).toHaveBeenCalled();
+ });
+
+ it('does not invoke strategies when no payment token and no fiat payment method', async () => {
+ await run({
+ transactionData: {
+ ...TRANSACTION_DATA_MOCK,
+ paymentToken: undefined,
+ fiatPayment: {},
+ },
+ });
+
+ const transactionDataMock = {
+ quotes: [QUOTE_MOCK],
+ quotesLastUpdated: undefined,
+ };
+
+ updateTransactionDataMock.mock.calls.map((call) =>
+ call[1](transactionDataMock),
+ );
+
+ expect(transactionDataMock).toMatchObject({
+ quotes: [],
+ quotesLastUpdated: expect.any(Number),
+ });
+ });
+
it('gets quotes from strategy', async () => {
await run();
diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts
index 3ac79bd2a3..e6d47df328 100644
--- a/packages/transaction-pay-controller/src/utils/quotes.ts
+++ b/packages/transaction-pay-controller/src/utils/quotes.ts
@@ -571,7 +571,7 @@ async function getQuotes(
},
);
- if (!requests?.length) {
+ if (!requests?.length && !fiatPaymentMethod) {
return {
batchTransactions: [],
quotes: [],