diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bd45695eab5..3e43d7f8878 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,10 @@ e2e/specs/identity @MetaMask/identity ses.cjs @MetaMask/supply-chain patches/react-native+0.*.patch @MetaMask/supply-chain +# Portfolio Team +app/components/hooks/useTokenSearchDiscovery @MetaMask/portfolio +app/core/Engine/controllers/TokenSearchDiscoveryController @MetaMask/portfolio + # Snaps Team **/snaps/** @MetaMask/snaps-devs **/Snaps/** @MetaMask/snaps-devs diff --git a/app/components/hooks/useTokenSearchDiscovery/useTokenSearchDiscovery.test.ts b/app/components/hooks/useTokenSearchDiscovery/useTokenSearchDiscovery.test.ts new file mode 100644 index 00000000000..65d26e33072 --- /dev/null +++ b/app/components/hooks/useTokenSearchDiscovery/useTokenSearchDiscovery.test.ts @@ -0,0 +1,82 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import Engine from '../../../core/Engine'; +import useTokenSearchDiscovery from './useTokenSearchDiscovery'; +import { TokenSearchParams } from '@metamask/token-search-discovery-controller'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +jest.mock('../../../core/Engine', () => ({ + context: { + TokenSearchDiscoveryController: { + searchTokens: jest.fn(), + }, + }, +})); + +describe('useTokenSearchDiscovery', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('updates states correctly when searching tokens', async () => { + const mockSearchParams: TokenSearchParams = { + chains: ['0x1'], + query: 'DAI', + limit: '10', + }; + const mockSearchResult = [{ name: 'DAI', address: '0x123' }]; + + ( + Engine.context.TokenSearchDiscoveryController.searchTokens as jest.Mock + ).mockResolvedValueOnce(mockSearchResult); + + const { result } = renderHook(() => useTokenSearchDiscovery()); + + // Initial state + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.results).toEqual([]); + + // Call search + await act(async () => { + result.current.searchTokens(mockSearchParams); + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + // Final state + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.results).toEqual(mockSearchResult); + expect( + Engine.context.TokenSearchDiscoveryController.searchTokens, + ).toHaveBeenCalledWith(mockSearchParams); + }); + + it('returns error and empty results if search failed', async () => { + const mockError = new Error('Search failed'); + ( + Engine.context.TokenSearchDiscoveryController.searchTokens as jest.Mock + ).mockRejectedValueOnce(mockError); + + const { result } = renderHook(() => useTokenSearchDiscovery()); + + await act(async () => { + result.current.searchTokens({}); + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toEqual(mockError); + expect(result.current.results).toEqual([]); + }); +}); diff --git a/app/components/hooks/useTokenSearchDiscovery/useTokenSearchDiscovery.ts b/app/components/hooks/useTokenSearchDiscovery/useTokenSearchDiscovery.ts new file mode 100644 index 00000000000..bf752842c79 --- /dev/null +++ b/app/components/hooks/useTokenSearchDiscovery/useTokenSearchDiscovery.ts @@ -0,0 +1,57 @@ +import { useState, useRef, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { debounce } from 'lodash'; +import Engine from '../../../core/Engine'; +import { selectRecentTokenSearches } from '../../../selectors/tokenSearchDiscoveryController'; +import { + TokenSearchResponseItem, + TokenSearchParams, +} from '@metamask/token-search-discovery-controller'; + +const SEARCH_DEBOUNCE_DELAY = 300; + +export const useTokenSearchDiscovery = () => { + const recentSearches = useSelector(selectRecentTokenSearches); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [results, setResults] = useState([]); + const latestRequestId = useRef(0); + + const searchTokens = useMemo( + () => + debounce(async (params: TokenSearchParams) => { + setIsLoading(true); + setError(null); + const requestId = ++latestRequestId.current; + + try { + const { TokenSearchDiscoveryController } = Engine.context; + const result = await TokenSearchDiscoveryController.searchTokens( + params, + ); + if (requestId === latestRequestId.current) { + setResults(result); + } + } catch (err) { + if (requestId === latestRequestId.current) { + setError(err as Error); + } + } finally { + if (requestId === latestRequestId.current) { + setIsLoading(false); + } + } + }, SEARCH_DEBOUNCE_DELAY), + [], + ); + + return { + searchTokens, + recentSearches, + isLoading, + error, + results, + }; +}; + +export default useTokenSearchDiscovery; diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 66af7240bd5..0c843230c94 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -79,6 +79,7 @@ import { Duplex } from 'stream'; ///: END:ONLY_INCLUDE_IF import { MetaMaskKeyring as QRHardwareKeyring } from '@keystonehq/metamask-airgapped-keyring'; import { LoggingController } from '@metamask/logging-controller'; +import { TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller'; import { LedgerKeyring, LedgerMobileBridge, @@ -232,6 +233,7 @@ import { getGlobalNetworkClientId, } from '../../util/networks/global-network'; import { logEngineCreation } from './utils/logger'; +import { createTokenSearchDiscoveryController } from './controllers/TokenSearchDiscoveryController'; import { SnapControllerClearSnapStateAction, SnapControllerGetSnapAction, @@ -631,6 +633,17 @@ export class Engine { getMetaMetricsId: () => metaMetricsId ?? '', }); + const tokenSearchDiscoveryController = createTokenSearchDiscoveryController( + { + state: initialState.TokenSearchDiscoveryController, + messenger: this.controllerMessenger.getRestricted({ + name: 'TokenSearchDiscoveryController', + allowedActions: [], + allowedEvents: [], + }) as TokenSearchDiscoveryControllerMessenger, + }, + ); + const phishingController = new PhishingController({ // @ts-expect-error TODO: Resolve mismatch between base-controller versions. messenger: this.controllerMessenger.getRestricted({ @@ -1575,6 +1588,7 @@ export class Engine { isDecodeSignatureRequestEnabled: () => preferencesController.state.useTransactionSimulations, }), + TokenSearchDiscoveryController: tokenSearchDiscoveryController, LoggingController: loggingController, ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) SnapController: this.snapController, @@ -2224,6 +2238,7 @@ export default { PPOMController, TokenBalancesController, TokenRatesController, + TokenSearchDiscoveryController, TransactionController, SmartTransactionsController, SwapsController, @@ -2265,6 +2280,7 @@ export default { PreferencesController, TokenBalancesController, TokenRatesController, + TokenSearchDiscoveryController, TokensController, TransactionController, SmartTransactionsController, diff --git a/app/core/Engine/constants.ts b/app/core/Engine/constants.ts index f9f611abe29..7a7440b81c8 100644 --- a/app/core/Engine/constants.ts +++ b/app/core/Engine/constants.ts @@ -41,6 +41,7 @@ export const BACKGROUND_STATE_CHANGE_EVENT_NAMES = [ 'TokenListController:stateChange', 'TokenRatesController:stateChange', 'TokensController:stateChange', + 'TokenSearchDiscoveryController:stateChange', 'TransactionController:stateChange', ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) SnapControllerStateChangeEvent, diff --git a/app/core/Engine/controllers/TokenSearchDiscoveryController/constants.ts b/app/core/Engine/controllers/TokenSearchDiscoveryController/constants.ts new file mode 100644 index 00000000000..cbd0d569bc3 --- /dev/null +++ b/app/core/Engine/controllers/TokenSearchDiscoveryController/constants.ts @@ -0,0 +1,4 @@ +export const PORTFOLIO_API_URL = { + dev: 'https://portfolio.dev-api.cx.metamask.io/', + prod: 'https://portfolio.api.cx.metamask.io/', +}; diff --git a/app/core/Engine/controllers/TokenSearchDiscoveryController/index.ts b/app/core/Engine/controllers/TokenSearchDiscoveryController/index.ts new file mode 100644 index 00000000000..6099020bebe --- /dev/null +++ b/app/core/Engine/controllers/TokenSearchDiscoveryController/index.ts @@ -0,0 +1 @@ +export { createTokenSearchDiscoveryController } from './utils'; diff --git a/app/core/Engine/controllers/TokenSearchDiscoveryController/types.ts b/app/core/Engine/controllers/TokenSearchDiscoveryController/types.ts new file mode 100644 index 00000000000..28d12f4a180 --- /dev/null +++ b/app/core/Engine/controllers/TokenSearchDiscoveryController/types.ts @@ -0,0 +1,7 @@ +import { TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller/dist/token-search-discovery-controller.cjs'; +import { TokenSearchDiscoveryControllerState } from '@metamask/token-search-discovery-controller'; + +export interface TokenSearchDiscoveryControllerParams { + state?: Partial; + messenger: TokenSearchDiscoveryControllerMessenger; +} diff --git a/app/core/Engine/controllers/TokenSearchDiscoveryController/utils.test.ts b/app/core/Engine/controllers/TokenSearchDiscoveryController/utils.test.ts new file mode 100644 index 00000000000..7a9375c84d5 --- /dev/null +++ b/app/core/Engine/controllers/TokenSearchDiscoveryController/utils.test.ts @@ -0,0 +1,192 @@ +import { createTokenSearchDiscoveryController } from './utils'; +import Logger from '../../../../util/Logger'; + +import { + TokenSearchApiService, + TokenSearchDiscoveryControllerState, +} from '@metamask/token-search-discovery-controller'; +import { TokenSearchDiscoveryControllerMessenger } from '@metamask/token-search-discovery-controller/dist/token-search-discovery-controller.cjs'; + +const mockError = new Error('Controller creation failed'); + +// Top-level mocks +jest.mock('../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('@metamask/token-search-discovery-controller', () => ({ + TokenSearchApiService: jest.fn().mockImplementation(() => ({})), + TokenDiscoveryApiService: jest.fn().mockImplementation(() => ({})), + TokenSearchDiscoveryController: jest + .fn() + .mockImplementation( + (params: { state?: TokenSearchDiscoveryControllerState }) => ({ + state: { + lastSearchTimestamp: null, + recentSearches: [], + ...params.state, + }, + }), + ), +})); + +describe('TokenSearchDiscoveryController utils', () => { + let messenger: TokenSearchDiscoveryControllerMessenger; + + beforeEach(() => { + messenger = {} as TokenSearchDiscoveryControllerMessenger; + }); + + describe('createTokenSearchDiscoveryController', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + it('creates controller with initial undefined state', () => { + const controller = createTokenSearchDiscoveryController({ + state: undefined, + messenger, + }); + + expect(controller).toBeDefined(); + expect(controller.state).toStrictEqual({ + lastSearchTimestamp: null, + recentSearches: [], + }); + }); + + it('internal state matches initial state', () => { + const initialState: TokenSearchDiscoveryControllerState = { + lastSearchTimestamp: 123456789, + recentSearches: [ + { + tokenAddress: '0x123', + chainId: '0x1', + name: 'Test Token 1', + symbol: 'TEST1', + usdPrice: 1.0, + usdPricePercentChange: { + oneDay: 0.0, + }, + }, + { + tokenAddress: '0x456', + chainId: '0x1', + name: 'Test Token 2', + symbol: 'TEST2', + usdPrice: 2.0, + usdPricePercentChange: { + oneDay: 0.0, + }, + }, + ], + }; + + const controller = createTokenSearchDiscoveryController({ + state: initialState, + messenger, + }); + + expect(controller.state).toStrictEqual(initialState); + }); + + it('keeps initial extra data in controller state', () => { + const initialState = { + extraData: true, + }; + + const controller = createTokenSearchDiscoveryController({ + // @ts-expect-error giving a wrong initial state + state: initialState, + messenger, + }); + + expect(controller.state).toStrictEqual({ + lastSearchTimestamp: null, + recentSearches: [], + extraData: true, + }); + }); + + it('logs and rethrows error when controller creation fails', () => { + (TokenSearchApiService as jest.Mock).mockImplementation(() => { + throw mockError; + }); + + expect(() => + createTokenSearchDiscoveryController({ + state: undefined, + messenger, + }), + ).toThrow(mockError); + + expect(Logger.error).toHaveBeenCalledWith(mockError); + }); + }); + + describe('getPortfolioApiBaseUrl', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns dev URL when environment is local', () => { + process.env.METAMASK_ENVIRONMENT = 'local'; + jest.isolateModules(() => { + const { createTokenSearchDiscoveryController: freshCreate } = + jest.requireActual('./utils'); + const controller = freshCreate({ + state: undefined, + messenger, + }); + expect(controller.state).toBeDefined(); + }); + }); + + it('returns prod URL when environment is pre-release', () => { + process.env.METAMASK_ENVIRONMENT = 'pre-release'; + jest.isolateModules(() => { + const { createTokenSearchDiscoveryController: freshCreate } = + jest.requireActual('./utils'); + const controller = freshCreate({ + state: undefined, + messenger, + }); + expect(controller.state).toBeDefined(); + }); + }); + + it('returns prod URL when environment is production', () => { + process.env.METAMASK_ENVIRONMENT = 'production'; + jest.isolateModules(() => { + const { createTokenSearchDiscoveryController: freshCreate } = + jest.requireActual('./utils'); + const controller = freshCreate({ + state: undefined, + messenger, + }); + expect(controller.state).toBeDefined(); + }); + }); + + it('returns dev URL when environment is not recognized', () => { + process.env.METAMASK_ENVIRONMENT = 'unknown'; + jest.isolateModules(() => { + const { createTokenSearchDiscoveryController: freshCreate } = + jest.requireActual('./utils'); + const controller = freshCreate({ + state: undefined, + messenger, + }); + expect(controller.state).toBeDefined(); + }); + }); + }); +}); diff --git a/app/core/Engine/controllers/TokenSearchDiscoveryController/utils.ts b/app/core/Engine/controllers/TokenSearchDiscoveryController/utils.ts new file mode 100644 index 00000000000..7712bbca538 --- /dev/null +++ b/app/core/Engine/controllers/TokenSearchDiscoveryController/utils.ts @@ -0,0 +1,40 @@ +import Logger from '../../../../util/Logger'; +import { + TokenSearchApiService, + TokenSearchDiscoveryController, + TokenDiscoveryApiService, +} from '@metamask/token-search-discovery-controller'; +import { TokenSearchDiscoveryControllerParams } from './types'; +import { PORTFOLIO_API_URL } from './constants'; + +const getPortfolioApiBaseUrl = () => { + const env = process.env.METAMASK_ENVIRONMENT; + switch (env) { + case 'local': + return PORTFOLIO_API_URL.dev; + case 'pre-release': + case 'production': + return PORTFOLIO_API_URL.prod; + default: + return PORTFOLIO_API_URL.dev; + } +}; + +export const createTokenSearchDiscoveryController = ({ + state, + messenger, +}: TokenSearchDiscoveryControllerParams) => { + try { + const baseUrl = getPortfolioApiBaseUrl(); + const controller = new TokenSearchDiscoveryController({ + state, + messenger, + tokenSearchService: new TokenSearchApiService(baseUrl), + tokenDiscoveryService: new TokenDiscoveryApiService(baseUrl), + }); + return controller; + } catch (error) { + Logger.error(error as Error); + throw error; + } +}; diff --git a/app/core/Engine/types.ts b/app/core/Engine/types.ts index 6468fa7c836..1ab3a3c5c83 100644 --- a/app/core/Engine/types.ts +++ b/app/core/Engine/types.ts @@ -184,8 +184,15 @@ import { RemoteFeatureFlagControllerActions, RemoteFeatureFlagControllerEvents, } from '@metamask/remote-feature-flag-controller/dist/remote-feature-flag-controller.cjs'; +import { + TokenSearchDiscoveryController, + TokenSearchDiscoveryControllerState, +} from '@metamask/token-search-discovery-controller'; +import { + TokenSearchDiscoveryControllerActions, + TokenSearchDiscoveryControllerEvents, +} from '@metamask/token-search-discovery-controller/dist/token-search-discovery-controller.cjs'; import { SnapKeyringEvents } from '@metamask/eth-snap-keyring'; - import { MultichainNetworkController, MultichainNetworkControllerActions, @@ -267,6 +274,7 @@ type GlobalActions = | SmartTransactionsControllerActions | AssetsContractControllerActions | RemoteFeatureFlagControllerActions + | TokenSearchDiscoveryControllerActions | MultichainNetworkControllerActions; type GlobalEvents = @@ -307,6 +315,7 @@ type GlobalEvents = | SmartTransactionsControllerEvents | AssetsContractControllerEvents | RemoteFeatureFlagControllerEvents + | TokenSearchDiscoveryControllerEvents | SnapKeyringEvents | MultichainNetworkControllerEvents; @@ -358,6 +367,7 @@ export type Controllers = { TokenListController: TokenListController; TokenDetectionController: TokenDetectionController; TokenRatesController: TokenRatesController; + TokenSearchDiscoveryController: TokenSearchDiscoveryController; TokensController: TokensController; TransactionController: TransactionController; SmartTransactionsController: SmartTransactionsController; @@ -404,6 +414,7 @@ export type EngineState = { PhishingController: PhishingControllerState; TokenBalancesController: TokenBalancesControllerState; TokenRatesController: TokenRatesControllerState; + TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState; TransactionController: TransactionControllerState; SmartTransactionsController: SmartTransactionsControllerState; SwapsController: SwapsControllerState; diff --git a/app/core/EngineService/EngineService.test.ts b/app/core/EngineService/EngineService.test.ts index dd871055e6c..3d65265a3cb 100644 --- a/app/core/EngineService/EngineService.test.ts +++ b/app/core/EngineService/EngineService.test.ts @@ -78,6 +78,7 @@ jest.mock('../Engine', () => { NotificationServicesController: { subscribe: jest.fn() }, SelectedNetworkController: { subscribe: jest.fn() }, SignatureController: { subscribe: jest.fn() }, + TokenSearchDiscoveryController: { subscribe: jest.fn() }, MultichainBalancesController: { subscribe: jest.fn() }, RatesController: { subscribe: jest.fn() }, }, diff --git a/app/selectors/tokenSearchDiscoveryController.test.ts b/app/selectors/tokenSearchDiscoveryController.test.ts new file mode 100644 index 00000000000..a9b946526ca --- /dev/null +++ b/app/selectors/tokenSearchDiscoveryController.test.ts @@ -0,0 +1,46 @@ +import { RootState } from '../reducers'; +import { selectRecentTokenSearches } from './tokenSearchDiscoveryController'; + +describe('Token Search Discovery Controller Selectors', () => { + const mockRecentSearches = ['ETH', 'USDC', 'DAI']; + + const mockState = { + engine: { + backgroundState: { + TokenSearchDiscoveryController: { + recentSearches: mockRecentSearches, + }, + }, + }, + } as unknown as RootState; + + describe('selectRecentTokenSearches', () => { + it('returns recent token searches from state', () => { + expect(selectRecentTokenSearches(mockState)).toEqual(mockRecentSearches); + }); + + it('returns empty array when no recent searches exist', () => { + const stateWithoutSearches = { + engine: { + backgroundState: { + TokenSearchDiscoveryController: { + recentSearches: [], + }, + }, + }, + } as unknown as RootState; + + expect(selectRecentTokenSearches(stateWithoutSearches)).toEqual([]); + }); + + it('returns empty array when TokenSearchDiscoveryController is not initialized', () => { + const stateWithoutController = { + engine: { + backgroundState: {}, + }, + } as unknown as RootState; + + expect(selectRecentTokenSearches(stateWithoutController)).toEqual([]); + }); + }); +}); diff --git a/app/selectors/tokenSearchDiscoveryController.ts b/app/selectors/tokenSearchDiscoveryController.ts new file mode 100644 index 00000000000..4aff774c7b5 --- /dev/null +++ b/app/selectors/tokenSearchDiscoveryController.ts @@ -0,0 +1,10 @@ +import { createSelector } from 'reselect'; +import { RootState } from '../reducers'; + +const selectTokenSearchDiscoveryControllerState = (state: RootState) => + state.engine.backgroundState.TokenSearchDiscoveryController; + +export const selectRecentTokenSearches = createSelector( + selectTokenSearchDiscoveryControllerState, + (state) => state?.recentSearches ?? [], +); diff --git a/app/selectors/types.ts b/app/selectors/types.ts index ecfedfb3ac4..af524cb1667 100644 --- a/app/selectors/types.ts +++ b/app/selectors/types.ts @@ -18,9 +18,11 @@ import { GasFeeController } from '@metamask/gas-fee-controller'; import { PPOMState } from '@metamask/ppom-validator'; import { ApprovalControllerState } from '@metamask/approval-controller'; import { AccountsControllerState } from '@metamask/accounts-controller'; +import { TokenSearchDiscoveryControllerState } from '@metamask/token-search-discovery-controller'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { SnapController } from '@metamask/snaps-controllers'; ///: END:ONLY_INCLUDE_IF + export interface EngineState { engine: { backgroundState: { @@ -45,6 +47,7 @@ export interface EngineState { TokensController: TokensControllerState; ApprovalController: ApprovalControllerState; AccountsController: AccountsControllerState; + TokenSearchDiscoveryController: TokenSearchDiscoveryControllerState; }; }; } diff --git a/app/util/logs/__snapshots__/index.test.ts.snap b/app/util/logs/__snapshots__/index.test.ts.snap index 953db9b2e5d..17b125380c2 100644 --- a/app/util/logs/__snapshots__/index.test.ts.snap +++ b/app/util/logs/__snapshots__/index.test.ts.snap @@ -352,6 +352,10 @@ exports[`logs :: generateStateLogs generates a valid json export 1`] = ` "TokenRatesController": { "marketData": {}, }, + "TokenSearchDiscoveryController": { + "lastSearchTimestamp": null, + "recentSearches": [], + }, "TransactionController": { "lastFetchedBlockNumbers": {}, "methodData": {}, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index 7cf0aba5a13..ad52ba4907e 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -175,6 +175,10 @@ "TokenRatesController": { "marketData": {} }, + "TokenSearchDiscoveryController": { + "lastSearchTimestamp": null, + "recentSearches": [] + }, "TransactionController": { "lastFetchedBlockNumbers": {}, "methodData": {}, diff --git a/package.json b/package.json index 935e0d7f8ed..12f50629e29 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,7 @@ "@metamask/solana-wallet-snap": "^1.2.0", "@metamask/stake-sdk": "^1.0.0", "@metamask/swappable-obj-proxy": "^2.1.0", + "@metamask/token-search-discovery-controller": "^2.1.0", "@metamask/swaps-controller": "^12.1.0", "@metamask/transaction-controller": "^46.0.0", "@metamask/utils": "^11.1.0", diff --git a/yarn.lock b/yarn.lock index 273c26bb47b..1e05506d1d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5563,6 +5563,14 @@ resolved "https://registry.yarnpkg.com/@metamask/test-dapp/-/test-dapp-8.9.0.tgz#bac680e8f0007b3a11440f7e311674d6457d37ed" integrity sha512-N/WfmdrzJm+xbpuqJsfMrlrAhiNDsllIpwt9gDDeEKDlQAfJnMtT9xvOvBJbXY7zgMdtGZuD+KY64jNKabbuVQ== +"@metamask/token-search-discovery-controller@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@metamask/token-search-discovery-controller/-/token-search-discovery-controller-2.1.0.tgz#ff8f78134da5b2451603ccf957ab67a1ba399db2" + integrity sha512-mda8IZAZGb0IN91fZqn5KvuvF36iwLgkH5ZLiOwRy6kbMsNFat0Lmqtapg1R2QoHPAEm0LGs7RPepBu1NftURw== + dependencies: + "@metamask/base-controller" "^8.0.0" + "@metamask/utils" "^11.1.0" + "@metamask/transaction-controller@^46.0.0": version "46.0.0" resolved "https://registry.yarnpkg.com/@metamask/transaction-controller/-/transaction-controller-46.0.0.tgz#ae20c10e413a164e65aa6ea6d65b7512b1530673"