From 1be70b0823bf380dca44166a3c8ace811809443f Mon Sep 17 00:00:00 2001
From: Lwin <147362763+lwin-kyaw@users.noreply.github.com>
Date: Thu, 30 Oct 2025 23:07:20 +0800
Subject: [PATCH 1/5] Release/652.0.0 (#7009)
## Explanation
Major release of SeedlessOnboardingController v6 ~
- Added new public method, `checkIsSeedlessOnboardingUserAuthenticated`
to validate the controller authenticate tokens state.
([#6998](https://github.com/MetaMask/core/pull/6998))
- **BREAKING** Update `refreshToken` and `revokeToken` params as
required in `Authenticate` method.
([#6998](https://github.com/MetaMask/core/pull/6998))
- Refactor `refreshAuthTokens` method, separately catch refreshJWTToken
and authenticate errors.
([#6998](https://github.com/MetaMask/core/pull/6998))
- Bump `@metamask/toprf-secure-backup` package to `0.10.0`.
([#6998](https://github.com/MetaMask/core/pull/6998))
- Fixed `Invalid Access Token` error during rehydration.
([#6998](https://github.com/MetaMask/core/pull/6998))
## References
* Fix: https://github.com/MetaMask/metamask-extension/issues/37337
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Publishes monorepo 652.0.0 and releases
`@metamask/seedless-onboarding-controller` v6.0.0 with a new auth check
method, breaking auth parameter changes, token refresh refactor,
dependency bump, and an access token fix.
>
> - **`@metamask/seedless-onboarding-controller` v6.0.0**:
> - Added `checkIsSeedlessOnboardingUserAuthenticated`.
> - BREAKING: `authenticate` now requires `refreshToken` and
`revokeToken` params.
> - Refactored `refreshAuthTokens` to separately handle refresh/auth
errors.
> - Bumped `@metamask/toprf-secure-backup` to `0.10.0`.
> - Fixed `Invalid Access Token` during rehydration.
> - **Monorepo**:
> - Bumped root `version` to `652.0.0`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9851013e16b2d9e16b799a3055fb291d946f7bfe. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
package.json | 2 +-
packages/seedless-onboarding-controller/CHANGELOG.md | 5 ++++-
packages/seedless-onboarding-controller/package.json | 2 +-
3 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 5ead8c13ac6..7acf16f4706 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
- "version": "651.0.0",
+ "version": "652.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md
index 9a8750a18d2..50ebd5cec35 100644
--- a/packages/seedless-onboarding-controller/CHANGELOG.md
+++ b/packages/seedless-onboarding-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]
+
### Added
- Added new public method, `checkIsSeedlessOnboardingUserAuthenticated` to validate the controller authenticate tokens state. ([#6998](https://github.com/MetaMask/core/pull/6998))
@@ -216,7 +218,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `checkIsPasswordOutdated`: Check if the password is current device is outdated, i.e. user changed password in another device.
- `clearState`: Reset the state of the controller to the defaults.
-[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@5.0.0...HEAD
+[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@6.0.0...HEAD
+[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@5.0.0...@metamask/seedless-onboarding-controller@6.0.0
[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@4.1.1...@metamask/seedless-onboarding-controller@5.0.0
[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@4.1.0...@metamask/seedless-onboarding-controller@4.1.1
[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@4.0.0...@metamask/seedless-onboarding-controller@4.1.0
diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json
index a4402c9c3b6..8dfb5027c95 100644
--- a/packages/seedless-onboarding-controller/package.json
+++ b/packages/seedless-onboarding-controller/package.json
@@ -1,6 +1,6 @@
{
"name": "@metamask/seedless-onboarding-controller",
- "version": "5.0.0",
+ "version": "6.0.0",
"description": "Backup and rehydrate SRP(s) using social login and password",
"keywords": [
"MetaMask",
From 9ec7167d7b2d4eb9a36d7af4a87c40f9d7267658 Mon Sep 17 00:00:00 2001
From: Bernardo Garces Chapero
Date: Thu, 30 Oct 2025 15:59:59 +0000
Subject: [PATCH 2/5] feat: changes to AccountTrackerController to enable
migration in extension (#6938)
## Explanation
Adds optional callback to disable fetching balances in
AccountTrackerController.
Attempts to refresh balance when a network is added and when the keyring
is unlocked.
## References
* Related to https://consensyssoftware.atlassian.net/browse/ASSETS-1368
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [X] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Adds optional fetchingEnabled gate, refreshes on networkAdded/unlock,
and updates tx handlers to refresh both from/to addresses via new
refreshAddresses; updates tests and changelog with breaking notes.
>
> - **AccountTrackerController**:
> - Add optional `fetchingEnabled` constructor callback; `refresh`
short-circuits when disabled.
> - Subscribe to `NetworkController:networkAdded` and
`KeyringController:unlock` to trigger balance refreshes.
> - Update tx event handlers to refresh both `from` and `to` via new
`refreshAddresses`; remove single-address refresh path.
> - Adjust allowed actions/events and inline
`PreferencesController:getState` typing; remove legacy
`AccountsController:selectedAccountChange` reference.
> - **Tests**:
> - Add cases for `networkAdded`, `unlock`, and disabled fetching;
address checksum fixes.
> - **Changelog**:
> - Document breaking event/action changes and the new `fetchingEnabled`
option.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
878ed7e1b0254edf6d523de100f346aa372da94c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
packages/assets-controllers/CHANGELOG.md | 10 +++
.../src/AccountTrackerController.test.ts | 88 ++++++++++++++++++-
.../src/AccountTrackerController.ts | 82 +++++++++++++----
3 files changed, 160 insertions(+), 20 deletions(-)
diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index 29464344c0c..7799660a3dc 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- **BREAKING:** Modify AccountTrackerController events and actions ([#6938](https://github.com/MetaMask/core/pull/6938))
+ - `AccountsController:selectedAccountChange` is no longer part of the list of allowed events
+ - `NetworkController:getState` refetches balances
+ - `TransactionController:unapprovedTransactionAdded` refetches balances
+ - `TransactionController:unapprovedTransactionAdded'` refetches balances for the transaction from address and network
+ - `TransactionController:transactionConfirmed` refetches balances for the transaction from address and network
+- Add optional `fetchingEnabled` callback to `AccountTrackerController` constructor to stop it from fetching balances ([#6938](https://github.com/MetaMask/core/pull/6938))
+
## [85.0.0]
### Added
diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts
index 3926729a360..dd660819432 100644
--- a/packages/assets-controllers/src/AccountTrackerController.test.ts
+++ b/packages/assets-controllers/src/AccountTrackerController.test.ts
@@ -8,6 +8,7 @@ import {
type MessengerEvents,
type MockAnyNamespace,
} from '@metamask/messenger';
+import type { NetworkConfiguration } from '@metamask/network-controller';
import {
type NetworkClientId,
type NetworkClientConfiguration,
@@ -167,7 +168,7 @@ describe('AccountTrackerController', () => {
mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({
tokenBalances: {
'0x0000000000000000000000000000000000000000': {
- [CHECKSUM_ADDRESS_1]: new BN('123456', 16),
+ [ADDRESS_1]: new BN('123456', 16),
},
},
stakedBalances: {},
@@ -208,7 +209,7 @@ describe('AccountTrackerController', () => {
mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({
tokenBalances: {
'0x0000000000000000000000000000000000000000': {
- [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16),
+ [ADDRESS_1]: new BN('abcdef', 16),
},
},
stakedBalances: {},
@@ -239,7 +240,88 @@ describe('AccountTrackerController', () => {
);
});
+ it('refreshes addresses when network is added', async () => {
+ await withController(
+ {
+ selectedAccount: ACCOUNT_1,
+ listAccounts: [ACCOUNT_1],
+ },
+ async ({ controller, messenger }) => {
+ mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({
+ tokenBalances: {
+ '0x0000000000000000000000000000000000000000': {
+ [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16),
+ },
+ },
+ stakedBalances: {},
+ });
+
+ messenger.publish(
+ 'NetworkController:networkAdded',
+ {} as NetworkConfiguration,
+ );
+
+ await clock.tickAsync(1);
+
+ expect(
+ controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance,
+ ).toBe('0xabcdef');
+ },
+ );
+ });
+
+ it('refreshes addresses when keyring is unlocked', async () => {
+ await withController(
+ {
+ selectedAccount: ACCOUNT_1,
+ listAccounts: [ACCOUNT_1],
+ },
+ async ({ controller, messenger }) => {
+ mockedGetTokenBalancesForMultipleAddresses.mockResolvedValueOnce({
+ tokenBalances: {
+ '0x0000000000000000000000000000000000000000': {
+ [CHECKSUM_ADDRESS_1]: new BN('abcdef', 16),
+ },
+ },
+ stakedBalances: {},
+ });
+
+ messenger.publish('KeyringController:unlock');
+
+ await clock.tickAsync(1);
+
+ expect(
+ controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance,
+ ).toBe('0xabcdef');
+ },
+ );
+ });
+
describe('refresh', () => {
+ it('does not refresh when fetching is disabled', async () => {
+ const expectedState = {
+ accountsByChainId: {
+ '0x1': {
+ [CHECKSUM_ADDRESS_1]: { balance: '0x0' },
+ [CHECKSUM_ADDRESS_2]: { balance: '0x0' },
+ },
+ },
+ };
+
+ await withController(
+ {
+ options: { fetchingEnabled: () => false },
+ selectedAccount: ACCOUNT_1,
+ listAccounts: [ACCOUNT_1, ACCOUNT_2],
+ },
+ async ({ controller, refresh }) => {
+ await refresh(clock, ['mainnet'], true);
+
+ expect(controller.state).toStrictEqual(expectedState);
+ },
+ );
+ });
+
describe('without networkClientId', () => {
it('should sync addresses', async () => {
await withController(
@@ -1613,6 +1695,8 @@ async function withController(
'AccountsController:selectedEvmAccountChange',
'TransactionController:unapprovedTransactionAdded',
'TransactionController:transactionConfirmed',
+ 'NetworkController:networkAdded',
+ 'KeyringController:unlock',
],
});
diff --git a/packages/assets-controllers/src/AccountTrackerController.ts b/packages/assets-controllers/src/AccountTrackerController.ts
index 627dc2a0fe6..527d99cccde 100644
--- a/packages/assets-controllers/src/AccountTrackerController.ts
+++ b/packages/assets-controllers/src/AccountTrackerController.ts
@@ -3,7 +3,6 @@ import type {
AccountsControllerSelectedEvmAccountChangeEvent,
AccountsControllerGetSelectedAccountAction,
AccountsControllerListAccountsAction,
- AccountsControllerSelectedAccountChangeEvent,
} from '@metamask/accounts-controller';
import type {
ControllerStateChangeEvent,
@@ -16,6 +15,7 @@ import {
toChecksumHexAddress,
} from '@metamask/controller-utils';
import EthQuery from '@metamask/eth-query';
+import type { KeyringControllerUnlockEvent } from '@metamask/keyring-controller';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { Messenger } from '@metamask/messenger';
import type {
@@ -23,9 +23,9 @@ import type {
NetworkClientId,
NetworkControllerGetNetworkClientByIdAction,
NetworkControllerGetStateAction,
+ NetworkControllerNetworkAddedEvent,
} from '@metamask/network-controller';
import { StaticIntervalPollingController } from '@metamask/polling-controller';
-import type { PreferencesControllerGetStateAction } from '@metamask/preferences-controller';
import type {
TransactionControllerTransactionConfirmedEvent,
TransactionControllerUnapprovedTransactionAddedEvent,
@@ -174,7 +174,10 @@ export type AccountTrackerControllerActions =
*/
export type AllowedActions =
| AccountsControllerListAccountsAction
- | PreferencesControllerGetStateAction
+ | {
+ type: 'PreferencesController:getState';
+ handler: () => { isMultiAccountBalancesEnabled: boolean };
+ }
| AccountsControllerGetSelectedAccountAction
| NetworkControllerGetStateAction
| NetworkControllerGetNetworkClientByIdAction;
@@ -199,9 +202,10 @@ export type AccountTrackerControllerEvents =
*/
export type AllowedEvents =
| AccountsControllerSelectedEvmAccountChangeEvent
- | AccountsControllerSelectedAccountChangeEvent
| TransactionControllerUnapprovedTransactionAddedEvent
- | TransactionControllerTransactionConfirmedEvent;
+ | TransactionControllerTransactionConfirmedEvent
+ | NetworkControllerNetworkAddedEvent
+ | KeyringControllerUnlockEvent;
/**
* The messenger of the {@link AccountTrackerController}.
@@ -236,6 +240,8 @@ export class AccountTrackerController extends StaticIntervalPollingController boolean;
+
/**
* Creates an AccountTracker instance.
*
@@ -247,6 +253,7 @@ export class AccountTrackerController extends StaticIntervalPollingController [],
allowExternalServices = () => true,
+ fetchingEnabled = () => true,
}: {
interval?: number;
state?: Partial;
@@ -264,6 +272,7 @@ export class AccountTrackerController extends StaticIntervalPollingController ChainIdHex[];
allowExternalServices?: () => boolean;
+ fetchingEnabled?: () => boolean;
}) {
const { selectedNetworkClientId } = messenger.call(
'NetworkController:getState',
@@ -302,6 +311,8 @@ export class AccountTrackerController extends StaticIntervalPollingController event.address,
);
+ this.messenger.subscribe('NetworkController:networkAdded', async () => {
+ await this.refresh(this.#getNetworkClientIds());
+ });
+
+ this.messenger.subscribe('KeyringController:unlock', async () => {
+ await this.refresh(this.#getNetworkClientIds());
+ });
+
this.messenger.subscribe(
'TransactionController:unapprovedTransactionAdded',
async (transactionMeta: TransactionMeta) => {
- await this.#refreshAddress(
- [transactionMeta.networkClientId],
- transactionMeta.txParams.from,
- );
+ const addresses = [transactionMeta.txParams.from];
+ if (transactionMeta.txParams.to) {
+ addresses.push(transactionMeta.txParams.to);
+ }
+ await this.refreshAddresses({
+ networkClientIds: [transactionMeta.networkClientId],
+ addresses,
+ });
},
);
this.messenger.subscribe(
'TransactionController:transactionConfirmed',
async (transactionMeta: TransactionMeta) => {
- await this.#refreshAddress(
- [transactionMeta.networkClientId],
- transactionMeta.txParams.from,
- );
+ const addresses = [transactionMeta.txParams.from];
+ if (transactionMeta.txParams.to) {
+ addresses.push(transactionMeta.txParams.to);
+ }
+ await this.refreshAddresses({
+ networkClientIds: [transactionMeta.networkClientId],
+ addresses,
+ });
},
);
@@ -540,13 +567,28 @@ export class AccountTrackerController extends StaticIntervalPollingController
+ toChecksumHexAddress(address),
+ );
+
+ const accounts = this.messenger
+ .call('AccountsController:listAccounts')
+ .filter((account) =>
+ checksummedAddresses.includes(toChecksumHexAddress(account.address)),
+ );
+
await this.#refreshAccounts({
networkClientIds,
- queryAllAccounts: false,
- selectedAccount: checksumAddress,
- allAccounts: [],
+ queryAllAccounts: true,
+ selectedAccount: '0x0',
+ allAccounts: accounts,
});
}
@@ -570,6 +612,10 @@ export class AccountTrackerController extends StaticIntervalPollingController
Date: Thu, 30 Oct 2025 16:36:52 +0000
Subject: [PATCH 3/5] feat: introduce operator fee for OP stack networks
(#6979)
## Explanation
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [ ] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Adds OP Stack operator fee to `layer1GasFee` by querying the oracle
(`getOperatorFee`) and summing with `getL1Fee`, plus BN utilities and
tests.
>
> - **Layer 1 Gas Fee (oracle flow)**:
> - Sum oracle `getL1Fee` with operator fee from
`getOperatorFee(gasUsed)`; default operator fee to zero on
failure/undefined; output normalized hex via `add0x` +
`padHexToEvenLength`.
> - Refactor to BN-based math and value coercion via new `toBN`; extract
`#getGasPriceOracleContract`; keep default OP Stack oracle address and
optional signing.
> - Extend ABI with `getOperatorFee`.
> - **Utils**:
> - Add `bnFromHex` and `toBN` helpers for parsing/converting to `BN`.
> - **Tests**:
> - Update `OracleLayer1GasFeeFlow.test.ts` to mock BN returns and cover
operator fee present/failure/undefined paths.
> - Add tests for `bnFromHex` and `toBN`.
> - **Docs**:
> - Update `CHANGELOG.md` to note inclusion of operator fee in
`layer1GasFee`.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
6e8d30444178eb9d4c2ae45dce84fef425b58c26. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
packages/transaction-controller/CHANGELOG.md | 4 +
.../gas-flows/OracleLayer1GasFeeFlow.test.ts | 103 +++++++++++++--
.../src/gas-flows/OracleLayer1GasFeeFlow.ts | 118 +++++++++++++++---
.../src/utils/utils.test.ts | 83 +++++++++++-
.../transaction-controller/src/utils/utils.ts | 72 ++++++++++-
5 files changed, 345 insertions(+), 35 deletions(-)
diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md
index bbe35622d1a..ccb7cc1d150 100644
--- a/packages/transaction-controller/CHANGELOG.md
+++ b/packages/transaction-controller/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Calculate operator fee for OP stack networks and include it in `layer1GasFee` ([#6979](https://github.com/MetaMask/core/pull/6979))
+
## [61.1.0]
### Added
diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts
index 8ee08e9ed85..3b1c80d35b6 100644
--- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts
+++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.test.ts
@@ -2,13 +2,18 @@ import type { TypedTransaction } from '@ethereumjs/tx';
import { TransactionFactory } from '@ethereumjs/tx';
import { Contract } from '@ethersproject/contracts';
import type { Provider } from '@metamask/network-controller';
-import type { Hex } from '@metamask/utils';
+import { add0x, type Hex } from '@metamask/utils';
+import BN from 'bn.js';
import { OracleLayer1GasFeeFlow } from './OracleLayer1GasFeeFlow';
import { CHAIN_IDS } from '../constants';
import type { TransactionControllerMessenger } from '../TransactionController';
-import type { Layer1GasFeeFlowRequest, TransactionMeta } from '../types';
-import { TransactionStatus } from '../types';
+import {
+ type Layer1GasFeeFlowRequest,
+ type TransactionMeta,
+ TransactionStatus,
+} from '../types';
+import { bnFromHex, padHexToEvenLength } from '../utils/utils';
jest.mock('@ethersproject/contracts', () => ({
Contract: jest.fn(),
@@ -36,7 +41,8 @@ const TRANSACTION_META_MOCK: TransactionMeta = {
const SERIALIZED_TRANSACTION_MOCK = '0x1234';
const ORACLE_ADDRESS_MOCK = '0x5678' as Hex;
-const LAYER_1_FEE_MOCK = '0x9ABCD';
+const LAYER_1_FEE_MOCK = '0x09abcd';
+const OPERATOR_FEE_MOCK = '0x5';
const DEFAULT_GAS_PRICE_ORACLE_ADDRESS =
'0x420000000000000000000000000000000000000F';
@@ -98,9 +104,9 @@ class DefaultOracleLayer1GasFeeFlow extends OracleLayer1GasFeeFlow {
describe('OracleLayer1GasFeeFlow', () => {
const contractMock = jest.mocked(Contract);
- const contractGetL1FeeMock: jest.MockedFn<
- () => Promise<{ toHexString: () => string }>
- > = jest.fn();
+ const contractGetL1FeeMock: jest.MockedFn<() => Promise> = jest.fn();
+ const contractGetOperatorFeeMock: jest.MockedFn<() => Promise> =
+ jest.fn();
let request: Layer1GasFeeFlowRequest;
@@ -112,13 +118,14 @@ describe('OracleLayer1GasFeeFlow', () => {
contractMock.mockClear();
contractGetL1FeeMock.mockClear();
+ contractGetOperatorFeeMock.mockClear();
- contractGetL1FeeMock.mockResolvedValue({
- toHexString: () => LAYER_1_FEE_MOCK,
- });
+ contractGetL1FeeMock.mockResolvedValue(bnFromHex(LAYER_1_FEE_MOCK));
+ contractGetOperatorFeeMock.mockResolvedValue(new BN(0));
contractMock.mockReturnValue({
getL1Fee: contractGetL1FeeMock,
+ getOperatorFee: contractGetOperatorFeeMock,
} as unknown as Contract);
});
@@ -156,6 +163,7 @@ describe('OracleLayer1GasFeeFlow', () => {
expect(contractGetL1FeeMock).toHaveBeenCalledWith(
serializedTransactionMock,
);
+ expect(contractGetOperatorFeeMock).not.toHaveBeenCalled();
});
it('signs transaction with dummy key if supported by flow', async () => {
@@ -180,6 +188,7 @@ describe('OracleLayer1GasFeeFlow', () => {
});
expect(typedTransactionMock.sign).toHaveBeenCalledTimes(1);
+ expect(contractGetOperatorFeeMock).not.toHaveBeenCalled();
});
describe('throws', () => {
@@ -228,5 +237,79 @@ describe('OracleLayer1GasFeeFlow', () => {
expect(oracleAddress).toBe(DEFAULT_GAS_PRICE_ORACLE_ADDRESS);
expect(typedTransactionMock.sign).not.toHaveBeenCalled();
});
+
+ it('adds operator fee when gas used is available', async () => {
+ const gasUsed = '0x5208';
+ request = {
+ ...request,
+ transactionMeta: {
+ ...request.transactionMeta,
+ gasUsed,
+ },
+ };
+
+ contractGetOperatorFeeMock.mockResolvedValueOnce(
+ bnFromHex(OPERATOR_FEE_MOCK),
+ );
+
+ const flow = new MockOracleLayer1GasFeeFlow(false);
+ const response = await flow.getLayer1Fee(request);
+
+ expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
+ expect(contractGetOperatorFeeMock).toHaveBeenCalledWith(gasUsed);
+ expect(response).toStrictEqual({
+ layer1Fee: add0x(
+ padHexToEvenLength(
+ bnFromHex(LAYER_1_FEE_MOCK)
+ .add(bnFromHex(OPERATOR_FEE_MOCK))
+ .toString(16),
+ ),
+ ) as Hex,
+ });
+ });
+
+ it('defaults operator fee to zero when call fails', async () => {
+ const gasUsed = '0x1';
+ request = {
+ ...request,
+ transactionMeta: {
+ ...request.transactionMeta,
+ gasUsed,
+ },
+ };
+
+ contractGetOperatorFeeMock.mockRejectedValueOnce(new Error('revert'));
+
+ const flow = new MockOracleLayer1GasFeeFlow(false);
+ const response = await flow.getLayer1Fee(request);
+
+ expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
+ expect(response).toStrictEqual({
+ layer1Fee: LAYER_1_FEE_MOCK,
+ });
+ });
+
+ it('defaults operator fee to zero when call returns undefined', async () => {
+ const gasUsed = '0x2';
+ request = {
+ ...request,
+ transactionMeta: {
+ ...request.transactionMeta,
+ gasUsed,
+ },
+ };
+
+ contractGetOperatorFeeMock.mockResolvedValueOnce(
+ undefined as unknown as BN,
+ );
+
+ const flow = new MockOracleLayer1GasFeeFlow(false);
+ const response = await flow.getLayer1Fee(request);
+
+ expect(contractGetOperatorFeeMock).toHaveBeenCalledTimes(1);
+ expect(response).toStrictEqual({
+ layer1Fee: LAYER_1_FEE_MOCK,
+ });
+ });
});
});
diff --git a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts
index 4648ed7f13e..78d986a2bc1 100644
--- a/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts
+++ b/packages/transaction-controller/src/gas-flows/OracleLayer1GasFeeFlow.ts
@@ -1,7 +1,8 @@
import { Contract } from '@ethersproject/contracts';
import { Web3Provider, type ExternalProvider } from '@ethersproject/providers';
import type { Hex } from '@metamask/utils';
-import { createModuleLogger } from '@metamask/utils';
+import { add0x, createModuleLogger } from '@metamask/utils';
+import BN from 'bn.js';
import { projectLogger } from '../logger';
import type { TransactionControllerMessenger } from '../TransactionController';
@@ -12,17 +13,52 @@ import type {
TransactionMeta,
} from '../types';
import { prepareTransaction } from '../utils/prepare';
+import { padHexToEvenLength, toBN } from '../utils/utils';
const log = createModuleLogger(projectLogger, 'oracle-layer1-gas-fee-flow');
+const ZERO = new BN(0);
+
const DUMMY_KEY =
'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
const GAS_PRICE_ORACLE_ABI = [
{
- inputs: [{ internalType: 'bytes', name: '_data', type: 'bytes' }],
+ inputs: [
+ {
+ internalType: 'bytes',
+ name: '_data',
+ type: 'bytes',
+ },
+ ],
name: 'getL1Fee',
- outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
+ outputs: [
+ {
+ internalType: 'uint256',
+ name: '',
+ type: 'uint256',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ // only available post Isthmus
+ {
+ inputs: [
+ {
+ internalType: 'uint256',
+ name: '_gasUsed',
+ type: 'uint256',
+ },
+ ],
+ name: 'getOperatorFee',
+ outputs: [
+ {
+ internalType: 'uint256',
+ name: '',
+ type: 'uint256',
+ },
+ ],
stateMutability: 'view',
type: 'function',
},
@@ -73,7 +109,27 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
request: Layer1GasFeeFlowRequest,
): Promise {
try {
- return await this.#getOracleLayer1GasFee(request);
+ const { provider, transactionMeta } = request;
+
+ const contract = this.#getGasPriceOracleContract(
+ provider,
+ transactionMeta.chainId,
+ );
+
+ const oracleFee = await this.#getOracleLayer1GasFee(
+ contract,
+ transactionMeta,
+ );
+ const operatorFee = await this.#getOperatorLayer1GasFee(
+ contract,
+ transactionMeta,
+ );
+
+ return {
+ layer1Fee: add0x(
+ padHexToEvenLength(oracleFee.add(operatorFee).toString(16)),
+ ) as Hex,
+ };
} catch (error) {
log('Failed to get oracle layer 1 gas fee', error);
throw new Error(`Failed to get oracle layer 1 gas fee`);
@@ -81,17 +137,9 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
}
async #getOracleLayer1GasFee(
- request: Layer1GasFeeFlowRequest,
- ): Promise {
- const { provider, transactionMeta } = request;
-
- const contract = new Contract(
- this.getOracleAddressForChain(transactionMeta.chainId),
- GAS_PRICE_ORACLE_ABI,
- // Network controller provider type is incompatible with ethers provider
- new Web3Provider(provider as unknown as ExternalProvider),
- );
-
+ contract: Contract,
+ transactionMeta: TransactionMeta,
+ ): Promise {
const serializedTransaction = this.#buildUnserializedTransaction(
transactionMeta,
this.shouldSignTransaction(),
@@ -103,9 +151,31 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
throw new Error('No value returned from oracle contract');
}
- return {
- layer1Fee: result.toHexString(),
- };
+ return toBN(result);
+ }
+
+ async #getOperatorLayer1GasFee(
+ contract: Contract,
+ transactionMeta: TransactionMeta,
+ ): Promise {
+ const { gasUsed } = transactionMeta;
+
+ if (!gasUsed) {
+ return ZERO;
+ }
+
+ try {
+ const result = await contract.getOperatorFee(gasUsed);
+
+ if (result === undefined) {
+ return ZERO;
+ }
+
+ return toBN(result);
+ } catch (error) {
+ log('Failed to get operator layer 1 gas fee', error);
+ return ZERO;
+ }
}
#buildUnserializedTransaction(
@@ -134,4 +204,16 @@ export abstract class OracleLayer1GasFeeFlow implements Layer1GasFeeFlow {
gasLimit: transactionMeta.txParams.gas,
};
}
+
+ #getGasPriceOracleContract(
+ provider: Layer1GasFeeFlowRequest['provider'],
+ chainId: Hex,
+ ) {
+ return new Contract(
+ this.getOracleAddressForChain(chainId),
+ GAS_PRICE_ORACLE_ABI,
+ // Network controller provider type is incompatible with ethers provider
+ new Web3Provider(provider as unknown as ExternalProvider),
+ );
+ }
}
diff --git a/packages/transaction-controller/src/utils/utils.test.ts b/packages/transaction-controller/src/utils/utils.test.ts
index 3b6c124c41e..fbdd94576db 100644
--- a/packages/transaction-controller/src/utils/utils.test.ts
+++ b/packages/transaction-controller/src/utils/utils.test.ts
@@ -1,4 +1,4 @@
-import { BN } from 'bn.js';
+import BN from 'bn.js';
import * as util from './utils';
import type {
@@ -274,4 +274,85 @@ describe('utils', () => {
expect(util.getPercentageChange(new BN(2), new BN(-1))).toBe(150);
});
});
+
+ describe('bnFromHex', () => {
+ it('parses hex with 0x prefix', () => {
+ const result = util.bnFromHex('0x1a');
+ expect(result.eq(new BN(26))).toBe(true);
+ });
+
+ it('parses hex without prefix', () => {
+ const result = util.bnFromHex('1a');
+ expect(result.eq(new BN(26))).toBe(true);
+ });
+
+ it('parses uppercase 0X prefix', () => {
+ const result = util.bnFromHex('0XFF');
+ expect(result.eq(new BN(255))).toBe(true);
+ });
+
+ it('returns zero for empty data with 0x', () => {
+ const result = util.bnFromHex('0x');
+ expect(result.isZero()).toBe(true);
+ });
+
+ it('returns zero for empty string', () => {
+ const result = util.bnFromHex('');
+ expect(result.isZero()).toBe(true);
+ });
+
+ it('throws for invalid hex', () => {
+ expect(() => util.bnFromHex('0xzz')).toThrow(Error);
+ });
+ });
+
+ describe('toBN', () => {
+ it('returns the same BN instance', () => {
+ const input = new BN(123);
+ const result = util.toBN(input);
+ expect(result).toBe(input);
+ });
+
+ it('converts ethers-like BigNumber with toHexString', () => {
+ const bigNumberLike = { toHexString: () => '0x2a' };
+ const result = util.toBN(bigNumberLike);
+ expect(result.eq(new BN(42))).toBe(true);
+ });
+
+ it('converts object with _hex property', () => {
+ const hexLike = { _hex: '0x2a' };
+ const result = util.toBN(hexLike);
+ expect(result.eq(new BN(42))).toBe(true);
+ });
+
+ it('converts hex string values', () => {
+ expect(util.toBN('0x10').eq(new BN(16))).toBe(true);
+ expect(util.toBN('10').eq(new BN(16))).toBe(true);
+ });
+
+ it('converts bigint values', () => {
+ const result = util.toBN(123n);
+ expect(result.eq(new BN(123))).toBe(true);
+ });
+
+ it('converts number values', () => {
+ const result = util.toBN(456);
+ expect(result.eq(new BN(456))).toBe(true);
+ });
+
+ it('throws for unsupported types', () => {
+ expect(() => util.toBN(true as unknown)).toThrow(
+ 'Unexpected value returned from oracle contract',
+ );
+ expect(() => util.toBN(null as unknown)).toThrow(
+ 'Unexpected value returned from oracle contract',
+ );
+ expect(() => util.toBN(undefined as unknown)).toThrow(
+ 'Unexpected value returned from oracle contract',
+ );
+ expect(() => util.toBN({} as unknown)).toThrow(
+ 'Unexpected value returned from oracle contract',
+ );
+ });
+ });
});
diff --git a/packages/transaction-controller/src/utils/utils.ts b/packages/transaction-controller/src/utils/utils.ts
index a33e11c65a3..672d4b21bd7 100644
--- a/packages/transaction-controller/src/utils/utils.ts
+++ b/packages/transaction-controller/src/utils/utils.ts
@@ -1,20 +1,20 @@
import type { AccessList, AuthorizationList } from '@ethereumjs/common';
+import type { Hex, Json } from '@metamask/utils';
import {
add0x,
getKnownPropertyNames,
isStrictHexString,
} from '@metamask/utils';
-import type { Json } from '@metamask/utils';
import BN from 'bn.js';
-import { TransactionEnvelopeType, TransactionStatus } from '../types';
import type {
- TransactionParams,
- TransactionMeta,
- TransactionError,
- GasPriceValue,
FeeMarketEIP1559Values,
+ GasPriceValue,
+ TransactionError,
+ TransactionMeta,
+ TransactionParams,
} from '../types';
+import { TransactionEnvelopeType, TransactionStatus } from '../types';
export const ESTIMATE_GAS_ERROR = 'eth_estimateGas rpc method error';
@@ -189,6 +189,66 @@ export function padHexToEvenLength(hex: string) {
return prefix + evenData;
}
+/**
+ * Create a BN from a hex string, accepting an optional 0x prefix.
+ *
+ * @param hex - Hex string with or without 0x prefix.
+ * @returns BN parsed as base-16.
+ */
+export function bnFromHex(hex: string | Hex): BN {
+ const str = typeof hex === 'string' ? hex : (hex as string);
+ const withoutPrefix =
+ str.startsWith('0x') || str.startsWith('0X') ? str.slice(2) : str;
+ if (withoutPrefix.length === 0) {
+ return new BN(0);
+ }
+ return new BN(withoutPrefix, 16);
+}
+
+/**
+ * Convert various numeric-like values to a BN instance.
+ * Accepts BN, ethers BigNumber, hex string, bigint, or number.
+ *
+ * @param value - The value to convert.
+ * @returns BN representation of the input value.
+ */
+export function toBN(value: unknown): BN {
+ if (value instanceof BN) {
+ return value as BN;
+ }
+ if (
+ typeof (BN as unknown as { isBN?: (v: unknown) => boolean }).isBN ===
+ 'function' &&
+ (BN as unknown as { isBN: (v: unknown) => boolean }).isBN(value)
+ ) {
+ return value as BN;
+ }
+ if (
+ value !== null &&
+ typeof value === 'object' &&
+ typeof (value as { toHexString?: () => string }).toHexString === 'function'
+ ) {
+ return bnFromHex((value as { toHexString: () => string }).toHexString());
+ }
+ if (
+ value !== null &&
+ typeof value === 'object' &&
+ typeof (value as { _hex?: string })._hex === 'string'
+ ) {
+ return bnFromHex((value as { _hex: string })._hex);
+ }
+ if (typeof value === 'string') {
+ return bnFromHex(value);
+ }
+ if (typeof value === 'bigint') {
+ return new BN(value.toString());
+ }
+ if (typeof value === 'number') {
+ return new BN(value);
+ }
+ throw new Error('Unexpected value returned from oracle contract');
+}
+
/**
* Calculate the absolute percentage change between two values.
*
From 20116487190602f8f0d19fa209db862f006002f9 Mon Sep 17 00:00:00 2001
From: Salim TOUBAL
Date: Thu, 30 Oct 2025 19:18:00 +0100
Subject: [PATCH 4/5] feat(assets-controllers): add searchTokens function
(#7004)
## Explanation
**What is the current state of things and why does it need to change?**
Currently, the assets-controllers package provides functionality to
fetch token lists and metadata for individual networks, but lacks the
ability to search for tokens across multiple blockchain networks
simultaneously. Users and applications need to search for tokens by
name, symbol, or address across different chains (Ethereum, Polygon,
Solana, etc.) in a unified way, which requires multiple separate API
calls with the existing implementation.
**What is the solution your changes offer and how does it work?**
This PR introduces a new `searchTokens` function that enables
cross-chain token search functionality using CAIP (Chain Agnostic
Improvement Proposal) format chain IDs. The solution includes:
1. **New `searchTokens` function**: Accepts an array of CAIP chain IDs
(e.g., `['eip155:1', 'eip155:137',
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']`) and a search query string
2. **URL construction helper**: `getTokenSearchURL` function that
properly encodes chain IDs and query parameters
3. **Structured response**: Returns `{ count: number, data: unknown[] }`
format for consistent API responses
4. **Error handling**: Gracefully handles API failures by returning
empty results instead of throwing errors
5. **Configurable limits**: Supports customizable result limits
(default: 10)
The function makes a single API call to the `/tokens/search` endpoint,
making it more efficient than multiple individual network searches.
**Are there any changes whose purpose might not obvious to those
unfamiliar with the domain?**
- **CAIP format requirement**: The function specifically requires CAIP
format chain IDs rather than hex chain IDs to support multi-chain
protocols including non-EVM chains like Solana
- **Error handling strategy**: The function returns `{ count: 0, data:
[] }` on errors rather than throwing, which prevents search failures
from breaking the user experience
- **Response normalization**: The function handles both new API response
format (object with count/data) and legacy format (direct array) for
backwards compatibility
## References
* Implements search token service functionality for cross-chain token
discovery
* Related to MetaMask's multi-chain token support initiative
## 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/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [ ] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Introduces `searchTokens` to query tokens across multiple networks
using CAIP chain IDs, exports it, and adds comprehensive tests and
changelog entry.
>
> - **Token service (`src/token-service.ts`)**:
> - Add `searchTokens(chainIds: CaipChainId[], query, { limit })` using
`/tokens/search` and `handleFetch`; returns `{ count, data }` with
graceful error handling.
> - Add helper `getTokenSearchURL` and CAIP chain ID support; import
`CaipChainId`.
> - **Exports**:
> - Export `searchTokens` from `src/index.ts`.
> - **Tests (`src/token-service.test.ts`)**:
> - Add coverage for single/multi-chain searches, custom `limit`, query
encoding, empty/invalid responses, and network/HTTP errors.
> - **Changelog**:
> - Document addition of `searchTokens` for multi-network searches via
CAIP IDs.
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
bf6224cd97f0c004644c84cfa2f261b9a812498e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---
packages/assets-controllers/CHANGELOG.md | 2 +
packages/assets-controllers/src/index.ts | 1 +
.../src/token-service.test.ts | 272 ++++++++++++++++++
.../assets-controllers/src/token-service.ts | 59 +++-
4 files changed, 333 insertions(+), 1 deletion(-)
diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md
index 7799660a3dc..76766501abf 100644
--- a/packages/assets-controllers/CHANGELOG.md
+++ b/packages/assets-controllers/CHANGELOG.md
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `fetchExchangeRates` function to fetch exchange rates from price-api ([#6863](https://github.com/MetaMask/core/pull/6863))
- Added `ignoreAssets` to allow ignoring assets for non-EVM chains ([#6981](https://github.com/MetaMask/core/pull/6981))
+- Added `searchTokens` function to search for tokens across multiple networks using CAIP format chain IDs ([#7004](https://github.com/MetaMask/core/pull/7004))
+
### Changed
- Bump `@metamask/controller-utils` from `^11.14.1` to `^11.15.0` ([#7003](https://github.com/MetaMask/core/pull/7003))
diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts
index c7f64f8b2bd..22ed9936b6c 100644
--- a/packages/assets-controllers/src/index.ts
+++ b/packages/assets-controllers/src/index.ts
@@ -142,6 +142,7 @@ export {
SUPPORTED_CHAIN_IDS,
getNativeTokenAddress,
} from './token-prices-service';
+export { searchTokens } from './token-service';
export { RatesController, Cryptocurrency } from './RatesController';
export type {
RatesControllerState,
diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts
index 05b4fa025eb..78b8ebff6fa 100644
--- a/packages/assets-controllers/src/token-service.test.ts
+++ b/packages/assets-controllers/src/token-service.test.ts
@@ -1,9 +1,11 @@
import { toHex } from '@metamask/controller-utils';
+import type { CaipChainId } from '@metamask/utils';
import nock from 'nock';
import {
fetchTokenListByChainId,
fetchTokenMetadata,
+ searchTokens,
TOKEN_END_POINT_API,
TOKEN_METADATA_NO_SUPPORT_ERROR,
} from './token-service';
@@ -234,8 +236,54 @@ const sampleToken = {
name: 'Chainlink',
};
+const sampleSearchResults = [
+ {
+ address: '0xa0b86a33e6c166428cf041c73490a6b448b7f2c2',
+ symbol: 'USDC',
+ decimals: 6,
+ name: 'USD Coin',
+ occurrences: 12,
+ aggregators: [
+ 'paraswap',
+ 'pmm',
+ 'airswapLight',
+ 'zeroEx',
+ 'bancor',
+ 'coinGecko',
+ 'zapper',
+ 'kleros',
+ 'zerion',
+ 'cmc',
+ 'oneInch',
+ 'uniswap',
+ ],
+ },
+ {
+ address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
+ symbol: 'USDT',
+ decimals: 6,
+ name: 'Tether USD',
+ occurrences: 11,
+ aggregators: [
+ 'paraswap',
+ 'pmm',
+ 'airswapLight',
+ 'zeroEx',
+ 'bancor',
+ 'coinGecko',
+ 'zapper',
+ 'kleros',
+ 'zerion',
+ 'cmc',
+ 'oneInch',
+ ],
+ },
+];
+
const sampleDecimalChainId = 1;
const sampleChainId = toHex(sampleDecimalChainId);
+const sampleCaipChainId: CaipChainId = 'eip155:1';
+const polygonCaipChainId: CaipChainId = 'eip155:137';
describe('Token service', () => {
describe('fetchTokenListByChainId', () => {
@@ -437,4 +485,228 @@ describe('Token service', () => {
).rejects.toThrow(TOKEN_METADATA_NO_SUPPORT_ERROR);
});
});
+
+ describe('searchTokens', () => {
+ it('should call the search api and return the list of matching tokens for single chain', async () => {
+ const searchQuery = 'USD';
+ const mockResponse = {
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ };
+
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
+ )
+ .reply(200, mockResponse)
+ .persist();
+
+ const results = await searchTokens([sampleCaipChainId], searchQuery);
+
+ expect(results).toStrictEqual({
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ });
+ });
+
+ it('should call the search api with custom limit parameter', async () => {
+ const searchQuery = 'USDC';
+ const customLimit = 5;
+ const mockResponse = {
+ count: 1,
+ data: [sampleSearchResults[0]],
+ pageInfo: { hasNextPage: false, endCursor: null },
+ };
+
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=${customLimit}`,
+ )
+ .reply(200, mockResponse)
+ .persist();
+
+ const results = await searchTokens([sampleCaipChainId], searchQuery, {
+ limit: customLimit,
+ });
+
+ expect(results).toStrictEqual({
+ count: 1,
+ data: [sampleSearchResults[0]],
+ });
+ });
+
+ it('should properly encode search queries with special characters', async () => {
+ const searchQuery = 'USD Coin & Token';
+ const encodedQuery = 'USD%20Coin%20%26%20Token';
+ const mockResponse = {
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ };
+
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${encodedQuery}&limit=10`,
+ )
+ .reply(200, mockResponse)
+ .persist();
+
+ const results = await searchTokens([sampleCaipChainId], searchQuery);
+
+ expect(results).toStrictEqual({
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ });
+ });
+
+ it('should search across multiple chains in a single request', async () => {
+ const searchQuery = 'USD';
+ const encodedChainIds = [sampleCaipChainId, polygonCaipChainId]
+ .map((id) => encodeURIComponent(id))
+ .join(',');
+ const mockResponse = {
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ };
+
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`,
+ )
+ .reply(200, mockResponse)
+ .persist();
+
+ const results = await searchTokens(
+ [sampleCaipChainId, polygonCaipChainId],
+ searchQuery,
+ );
+
+ expect(results).toStrictEqual({
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ });
+ });
+
+ it('should return empty array if the fetch fails with a network error', async () => {
+ const searchQuery = 'USD';
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
+ )
+ .replyWithError('Example network error')
+ .persist();
+
+ const result = await searchTokens([sampleCaipChainId], searchQuery);
+
+ expect(result).toStrictEqual({ count: 0, data: [] });
+ });
+
+ it('should return empty array if the fetch fails with 400 error', async () => {
+ const searchQuery = 'USD';
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
+ )
+ .reply(400, { error: 'Bad Request' })
+ .persist();
+
+ const result = await searchTokens([sampleCaipChainId], searchQuery);
+
+ expect(result).toStrictEqual({ count: 0, data: [] });
+ });
+
+ it('should return empty array if the fetch fails with 500 error', async () => {
+ const searchQuery = 'USD';
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
+ )
+ .reply(500)
+ .persist();
+
+ const result = await searchTokens([sampleCaipChainId], searchQuery);
+
+ expect(result).toStrictEqual({ count: 0, data: [] });
+ });
+
+ it('should handle empty search results', async () => {
+ const searchQuery = 'NONEXISTENT';
+ const mockResponse = {
+ count: 0,
+ data: [],
+ pageInfo: { hasNextPage: false, endCursor: null },
+ };
+
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
+ )
+ .reply(200, mockResponse)
+ .persist();
+
+ const results = await searchTokens([sampleCaipChainId], searchQuery);
+
+ expect(results).toStrictEqual({ count: 0, data: [] });
+ });
+
+ it('should return empty array when no chainIds are provided', async () => {
+ const searchQuery = 'USD';
+ const results = await searchTokens([], searchQuery);
+
+ expect(results).toStrictEqual({ count: 0, data: [] });
+ });
+
+ it('should handle API error responses in JSON format', async () => {
+ const searchQuery = 'USD';
+ const errorResponse = { error: 'Invalid search query' };
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10`,
+ )
+ .reply(200, errorResponse)
+ .persist();
+
+ const result = await searchTokens([sampleCaipChainId], searchQuery);
+
+ // Non-array responses should be converted to empty object with count 0
+ expect(result).toStrictEqual({ count: 0, data: [] });
+ });
+
+ it('should handle supported CAIP format chain IDs', async () => {
+ const searchQuery = 'USD';
+ const solanaChainId: CaipChainId =
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
+ const tronChainId: CaipChainId = 'tron:728126428';
+
+ const multiChainIds: CaipChainId[] = [
+ sampleCaipChainId,
+ solanaChainId,
+ tronChainId,
+ ];
+ const encodedChainIds = multiChainIds
+ .map((id) => encodeURIComponent(id))
+ .join(',');
+ const mockResponse = {
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ };
+
+ nock(TOKEN_END_POINT_API)
+ .get(
+ `/tokens/search?chainIds=${encodedChainIds}&query=${searchQuery}&limit=10`,
+ )
+ .reply(200, mockResponse)
+ .persist();
+
+ const result = await searchTokens(multiChainIds, searchQuery);
+
+ expect(result).toStrictEqual({
+ count: sampleSearchResults.length,
+ data: sampleSearchResults,
+ });
+ });
+ });
});
diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts
index b347079e02d..24fc7228e81 100644
--- a/packages/assets-controllers/src/token-service.ts
+++ b/packages/assets-controllers/src/token-service.ts
@@ -1,9 +1,10 @@
import {
ChainId,
convertHexToDecimal,
+ handleFetch,
timeoutFetch,
} from '@metamask/controller-utils';
-import type { Hex } from '@metamask/utils';
+import type { CaipChainId, Hex } from '@metamask/utils';
import { isTokenListSupportedForNetwork } from './assetsUtil';
@@ -41,6 +42,22 @@ function getTokenMetadataURL(chainId: Hex, tokenAddress: string) {
)}?address=${tokenAddress}`;
}
+/**
+ * Get the token search URL for the given networks and search query.
+ *
+ * @param chainIds - Array of CAIP format chain IDs (e.g., 'eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp').
+ * @param query - The search query (token name, symbol, or address).
+ * @param limit - Optional limit for the number of results (defaults to 10).
+ * @returns The token search URL.
+ */
+function getTokenSearchURL(chainIds: CaipChainId[], query: string, limit = 10) {
+ const encodedQuery = encodeURIComponent(query);
+ const encodedChainIds = chainIds
+ .map((id) => encodeURIComponent(id))
+ .join(',');
+ return `${TOKEN_END_POINT_API}/tokens/search?chainIds=${encodedChainIds}&query=${encodedQuery}&limit=${limit}`;
+}
+
const tenSecondsInMilliseconds = 10_000;
// Token list averages 1.6 MB in size
@@ -77,6 +94,46 @@ export async function fetchTokenListByChainId(
return undefined;
}
+/**
+ * Search for tokens across one or more networks by query string using CAIP format chain IDs.
+ *
+ * @param chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']).
+ * @param query - The search query (token name, symbol, or address).
+ * @param options - Additional fetch options.
+ * @param options.limit - The maximum number of results to return.
+ * @returns Object containing count and data array. Returns { count: 0, data: [] } if request fails.
+ */
+export async function searchTokens(
+ chainIds: CaipChainId[],
+ query: string,
+ { limit = 10 } = {},
+): Promise<{ count: number; data: unknown[] }> {
+ if (chainIds.length === 0) {
+ return { count: 0, data: [] };
+ }
+
+ const tokenSearchURL = getTokenSearchURL(chainIds, query, limit);
+
+ try {
+ const result = await handleFetch(tokenSearchURL);
+
+ // The API returns an object with structure: { count: number, data: array, pageInfo: object }
+ if (result && typeof result === 'object' && Array.isArray(result.data)) {
+ return {
+ count: result.count || result.data.length,
+ data: result.data,
+ };
+ }
+
+ // Handle non-expected responses
+ return { count: 0, data: [] };
+ } catch (error) {
+ // Handle 400 errors and other failures by returning count 0 and empty array
+ console.log('Search request failed:', error);
+ return { count: 0, data: [] };
+ }
+}
+
/**
* Fetch metadata for the token address provided for a given network. This request is cancellable
* using the abort signal passed in.
From 6aedb66364f733dc9d15a930e8f3cecd2f86e860 Mon Sep 17 00:00:00 2001
From: ChingizMardanov
Date: Thu, 30 Oct 2025 14:21:59 -0400
Subject: [PATCH 5/5] (SWAPS3309): Replace `noFee` bridge api parameter with
`fee`. (#6964)
## Explanation
Bridge API used to have a `noFee` parameter that was changed to `fee`
parameter. For some time api will support both, however to eventually
sunset a less flexible `noFee` parameter controller had to be changed.
## References
Associated bridge-api
[PR](https://github.com/consensys-vertical-apps/va-mmcx-bridge-api/pull/610).
## 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
- [ ] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs),
highlighting breaking changes as necessary
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
---
> [!NOTE]
> Replaces `noFee` with numeric `fee` across request types, validators,
request formatting, and tests, and documents the breaking change.
>
> - **Breaking change**
> - Replace `noFee` flag with numeric `fee` in bridge API requests and
feature flag overrides.
> - **Core updates**
> - Types: Update `QuoteRequest` to use optional `fee?: number`
(`src/types.ts`).
> - Validation: Update `GenericQuoteRequestSchema` to accept `fee`
(`src/utils/validators.ts`).
> - Request formatting: Map `fee` into normalized query params; remove
`noFee` handling (`src/utils/fetch.ts`).
> - **Tests**
> - Update expectations and snapshots to use `fee` (e.g., URL query
`fee=0`) and feature flag overrides (`src/bridge-controller.test.ts`,
`src/utils/fetch.test.ts`).
> - **Docs**
> - Add changelog entry noting the breaking replacement
(`CHANGELOG.md`).
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9afac58b998073220dcc355789d08217f2072d82. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Bryan Fullam
---
packages/bridge-controller/CHANGELOG.md | 4 ++++
.../src/bridge-controller.test.ts | 15 +++++++--------
packages/bridge-controller/src/types.ts | 5 ++++-
.../bridge-controller/src/utils/fetch.test.ts | 6 +++---
packages/bridge-controller/src/utils/fetch.ts | 4 ++--
.../bridge-controller/src/utils/validators.ts | 2 +-
6 files changed, 21 insertions(+), 15 deletions(-)
diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md
index 705594d0005..7ed4623d1ad 100644
--- a/packages/bridge-controller/CHANGELOG.md
+++ b/packages/bridge-controller/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- **BREAKING:** `noFee` flag was replaced with `fee` flag in bridge api requests ([#6964](https://github.com/MetaMask/core/pull/6964))
+
## [57.0.0]
### Changed
diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts
index 4cb7cd36a19..b44f2e4bd4d 100644
--- a/packages/bridge-controller/src/bridge-controller.test.ts
+++ b/packages/bridge-controller/src/bridge-controller.test.ts
@@ -2625,7 +2625,7 @@ describe('BridgeController', function () {
[FeatureId.PERPS]: {
aggIds: ['debridge', 'socket'],
bridgeIds: ['bridge1', 'bridge2'],
- noFee: true,
+ fee: 0,
},
},
});
@@ -2634,7 +2634,7 @@ describe('BridgeController', function () {
}));
});
- it('should override aggIds and noFee in perps request', async () => {
+ it('should override aggIds and fee in perps request', async () => {
const fetchBridgeQuotesSpy = jest
.spyOn(fetchUtils, 'fetchBridgeQuotes')
.mockResolvedValueOnce({
@@ -2656,7 +2656,7 @@ describe('BridgeController', function () {
bridgeIds: ['other', 'debridge'],
gasIncluded: false,
gasIncluded7702: false,
- noFee: false,
+ fee: 0,
},
null,
FeatureId.PERPS,
@@ -2677,9 +2677,9 @@ describe('BridgeController', function () {
],
"destChainId": "1",
"destTokenAddress": "0x1234",
+ "fee": 0,
"gasIncluded": false,
"gasIncluded7702": false,
- "noFee": true,
"slippage": 0.5,
"srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"srcTokenAddress": "NATIVE",
@@ -2722,7 +2722,6 @@ describe('BridgeController', function () {
bridgeIds: ['other', 'debridge'],
gasIncluded: false,
gasIncluded7702: false,
- noFee: false,
} as never,
null,
FeatureId.PERPS,
@@ -2733,7 +2732,7 @@ describe('BridgeController', function () {
expect(bridgeController.state).toStrictEqual(expectedControllerState);
});
- it('should add aggIds and noFee to perps request', async () => {
+ it('should add aggIds and fee to perps request', async () => {
const fetchBridgeQuotesSpy = jest
.spyOn(fetchUtils, 'fetchBridgeQuotes')
.mockResolvedValueOnce({
@@ -2773,9 +2772,9 @@ describe('BridgeController', function () {
],
"destChainId": "1",
"destTokenAddress": "0x1234",
+ "fee": 0,
"gasIncluded": false,
"gasIncluded7702": false,
- "noFee": true,
"slippage": 0.5,
"srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"srcTokenAddress": "NATIVE",
@@ -2795,7 +2794,7 @@ describe('BridgeController', function () {
expect(bridgeController.state).toStrictEqual(expectedControllerState);
});
- it('should not add aggIds and noFee if featureId is not specified', async () => {
+ it('should not add aggIds and fee if featureId is not specified', async () => {
const fetchBridgeQuotesSpy = jest
.spyOn(fetchUtils, 'fetchBridgeQuotes')
.mockResolvedValueOnce({
diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts
index af2f293ed22..cc30e8b50e5 100644
--- a/packages/bridge-controller/src/types.ts
+++ b/packages/bridge-controller/src/types.ts
@@ -215,7 +215,10 @@ export type QuoteRequest<
* Whether to request quotes that use EIP-7702 delegated gasless execution
*/
gasIncluded7702: boolean;
- noFee?: boolean;
+ /**
+ * The fee that will be charged by MetaMask
+ */
+ fee?: number;
};
export enum StatusTypes {
diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts
index 9627e9bec82..2411edeef49 100644
--- a/packages/bridge-controller/src/utils/fetch.test.ts
+++ b/packages/bridge-controller/src/utils/fetch.test.ts
@@ -354,7 +354,7 @@ describe('fetch', () => {
mockConsoleWarn.mockRestore();
});
- it('should fetch bridge quotes successfully, with aggIds, bridgeIds and noFee=true', async () => {
+ it('should fetch bridge quotes successfully, with aggIds, bridgeIds and fee=0', async () => {
mockFetchFn.mockResolvedValue(mockBridgeQuotesNativeErc20);
const { signal } = new AbortController();
@@ -371,7 +371,7 @@ describe('fetch', () => {
gasIncluded7702: false,
aggIds: ['socket', 'lifi'],
bridgeIds: ['bridge1', 'bridge2'],
- noFee: true,
+ fee: 0,
},
signal,
BridgeClientId.EXTENSION,
@@ -382,7 +382,7 @@ describe('fetch', () => {
);
expect(mockFetchFn).toHaveBeenCalledWith(
- 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5&noFee=true&aggIds=socket%2Clifi&bridgeIds=bridge1%2Cbridge2',
+ 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5&fee=0&aggIds=socket%2Clifi&bridgeIds=bridge1%2Cbridge2',
{
headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' },
signal,
diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts
index 96e9311ad13..95f994d14de 100644
--- a/packages/bridge-controller/src/utils/fetch.ts
+++ b/packages/bridge-controller/src/utils/fetch.ts
@@ -82,8 +82,8 @@ const formatQueryParams = (request: GenericQuoteRequest): URLSearchParams => {
if (request.slippage !== undefined) {
normalizedRequest.slippage = request.slippage;
}
- if (request.noFee !== undefined) {
- normalizedRequest.noFee = request.noFee;
+ if (request.fee !== undefined) {
+ normalizedRequest.fee = request.fee;
}
if (request.aggIds && request.aggIds.length > 0) {
normalizedRequest.aggIds = request.aggIds;
diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts
index 3082ae0f196..be950ab8564 100644
--- a/packages/bridge-controller/src/utils/validators.ts
+++ b/packages/bridge-controller/src/utils/validators.ts
@@ -122,7 +122,7 @@ export const PriceImpactThresholdSchema = type({
const GenericQuoteRequestSchema = type({
aggIds: optional(array(string())),
bridgeIds: optional(array(string())),
- noFee: optional(boolean()),
+ fee: optional(number()),
});
const FeatureIdSchema = enums(Object.values(FeatureId));