diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cec253674b..2537c6e0ed 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -34,6 +34,9 @@ ## Snaps Team /packages/rate-limit-controller @MetaMask/snaps-devs +## Portfolio Team +/packages/token-search-discovery-controller @MetaMask/portfolio + ## Wallet API Platform Team /packages/multichain @MetaMask/wallet-api-platform-engineers /packages/queued-request-controller @MetaMask/wallet-api-platform-engineers @@ -100,3 +103,5 @@ /packages/transaction-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/wallet-framework-engineers /packages/user-operation-controller/package.json @MetaMask/confirmations @MetaMask/wallet-framework-engineers /packages/user-operation-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/wallet-framework-engineers +/packages/token-search-discovery-controller/package.json @MetaMask/portfolio @MetaMask/wallet-framework-engineers +/packages/token-search-discovery-controller/CHANGELOG.md @MetaMask/portfolio @MetaMask/wallet-framework-engineers diff --git a/README.md b/README.md index 895a7ebc69..3aa258a4a5 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/rate-limit-controller`](packages/rate-limit-controller) - [`@metamask/selected-network-controller`](packages/selected-network-controller) - [`@metamask/signature-controller`](packages/signature-controller) +- [`@metamask/token-search-discovery-controller`](packages/token-search-discovery-controller) - [`@metamask/transaction-controller`](packages/transaction-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) @@ -91,8 +92,10 @@ linkStyle default opacity:0.5 profile_sync_controller(["@metamask/profile-sync-controller"]); queued_request_controller(["@metamask/queued-request-controller"]); rate_limit_controller(["@metamask/rate-limit-controller"]); + remote_feature_flag_controller(["@metamask/remote-feature-flag-controller"]); selected_network_controller(["@metamask/selected-network-controller"]); signature_controller(["@metamask/signature-controller"]); + token_search_discovery_controller(["@metamask/token-search-discovery-controller"]); transaction_controller(["@metamask/transaction-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); accounts_controller --> base_controller; @@ -164,6 +167,8 @@ linkStyle default opacity:0.5 queued_request_controller --> network_controller; queued_request_controller --> selected_network_controller; rate_limit_controller --> base_controller; + remote_feature_flag_controller --> base_controller; + remote_feature_flag_controller --> controller_utils; selected_network_controller --> base_controller; selected_network_controller --> json_rpc_engine; selected_network_controller --> network_controller; diff --git a/eslint.config.mjs b/eslint.config.mjs index 2ed8169174..a82bd5c29d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,8 @@ import jest from '@metamask/eslint-config-jest'; import nodejs from '@metamask/eslint-config-nodejs'; import typescript from '@metamask/eslint-config-typescript'; +const NODE_LTS_VERSION = 22; + const config = createConfig([ ...base, { @@ -83,6 +85,11 @@ const config = createConfig([ 'jest/prefer-lowercase-title': 'warn', 'jest/prefer-strict-equal': 'warn', }, + settings: { + node: { + version: `^${NODE_LTS_VERSION}`, + }, + }, }, { // These files are test helpers, not tests. We still use the Jest ESLint diff --git a/package.json b/package.json index 67464d8c8b..61ef6f9d5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "283.0.0", + "version": "284.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index a6c0fd8aaf..d02cef8353 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [21.0.1] + +### Changed + +- Bump `@metamask/eth-snap-keyring` from `^8.0.0` to `^8.1.0` ([#5167](https://github.com/MetaMask/core/pull/5167)) + ## [21.0.0] ### Changed @@ -388,7 +394,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#1637](https://github.com/MetaMask/core/pull/1637)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.1...HEAD +[21.0.1]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@21.0.0...@metamask/accounts-controller@21.0.1 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@20.0.2...@metamask/accounts-controller@21.0.0 [20.0.2]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@20.0.1...@metamask/accounts-controller@20.0.2 [20.0.1]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@20.0.0...@metamask/accounts-controller@20.0.1 diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index dcc430634e..7d02f7b89f 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/accounts-controller", - "version": "21.0.0", + "version": "21.0.1", "description": "Manages internal accounts", "keywords": [ "MetaMask", @@ -49,7 +49,7 @@ "dependencies": { "@ethereumjs/util": "^8.1.0", "@metamask/base-controller": "^7.1.1", - "@metamask/eth-snap-keyring": "^8.0.0", + "@metamask/eth-snap-keyring": "^8.1.0", "@metamask/keyring-api": "^13.0.0", "@metamask/keyring-internal-api": "^2.0.0", "@metamask/snaps-sdk": "^6.7.0", diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index d2daa3b1ca..4c4c8d5cd7 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -77,7 +77,7 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^21.0.0", + "@metamask/accounts-controller": "^21.0.1", "@metamask/approval-controller": "^7.1.2", "@metamask/auto-changelog": "^3.4.4", "@metamask/ethjs-provider-http": "^0.3.0", diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index d0f98e5f1c..a3cb4972ce 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -56,7 +56,7 @@ "@metamask/eth-simple-keyring": "^6.0.5", "@metamask/keyring-api": "^13.0.0", "@metamask/keyring-internal-api": "^2.0.0", - "@metamask/message-manager": "^11.0.3", + "@metamask/message-manager": "^12.0.0", "@metamask/utils": "^11.0.1", "async-mutex": "^0.5.0", "ethereumjs-wallet": "^1.0.1", diff --git a/packages/message-manager/CHANGELOG.md b/packages/message-manager/CHANGELOG.md index f9ca2a8819..0395cc7f3e 100644 --- a/packages/message-manager/CHANGELOG.md +++ b/packages/message-manager/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [12.0.0] + ### Changed - **BREAKING:** Base class of `DecryptMessageManager` and `EncryptionPublicKeyManager`(`AbstractMessageManager`) now expects new options to initialise ([#5103](https://github.com/MetaMask/core/pull/5103)) @@ -357,7 +359,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.3...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/message-manager@12.0.0...HEAD +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.3...@metamask/message-manager@12.0.0 [11.0.3]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.2...@metamask/message-manager@11.0.3 [11.0.2]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.1...@metamask/message-manager@11.0.2 [11.0.1]: https://github.com/MetaMask/core/compare/@metamask/message-manager@11.0.0...@metamask/message-manager@11.0.1 diff --git a/packages/message-manager/package.json b/packages/message-manager/package.json index 37cd315da2..ee76d4fdd2 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/message-manager", - "version": "11.0.3", + "version": "12.0.0", "description": "Stores and manages interactions with signing requests", "keywords": [ "MetaMask", diff --git a/packages/network-controller/src/NetworkController.ts b/packages/network-controller/src/NetworkController.ts index 0e20648bde..aeda162aa5 100644 --- a/packages/network-controller/src/NetworkController.ts +++ b/packages/network-controller/src/NetworkController.ts @@ -22,6 +22,7 @@ import type { Hex } from '@metamask/utils'; import { hasProperty, isPlainObject, isStrictHexString } from '@metamask/utils'; import deepEqual from 'fast-deep-equal'; import type { Draft } from 'immer'; +import { cloneDeep } from 'lodash'; import type { Logger } from 'loglevel'; import { createSelector } from 'reselect'; import * as URI from 'uri-js'; @@ -1636,11 +1637,6 @@ export class NetworkController extends BaseController< }); }); - this.#networkConfigurationsByNetworkClientId = - buildNetworkConfigurationsByNetworkClientId( - this.state.networkConfigurationsByChainId, - ); - this.messagingSystem.publish( `${controllerName}:networkAdded`, newNetworkConfiguration, @@ -1919,11 +1915,6 @@ export class NetworkController extends BaseController< }); } - this.#networkConfigurationsByNetworkClientId = - buildNetworkConfigurationsByNetworkClientId( - this.state.networkConfigurationsByChainId, - ); - this.#unregisterNetworkClientsAsNeeded({ networkClientOperations, autoManagedNetworkClientRegistry, @@ -1983,11 +1974,6 @@ export class NetworkController extends BaseController< }); }); - this.#networkConfigurationsByNetworkClientId = - buildNetworkConfigurationsByNetworkClientId( - this.state.networkConfigurationsByChainId, - ); - this.messagingSystem.publish( 'NetworkController:networkRemoved', existingNetworkConfiguration, @@ -2500,6 +2486,11 @@ export class NetworkController extends BaseController< state.networkConfigurationsByChainId[args.networkFields.chainId] = args.networkConfigurationToPersist; } + + this.#networkConfigurationsByNetworkClientId = + buildNetworkConfigurationsByNetworkClientId( + cloneDeep(state.networkConfigurationsByChainId), + ); } /** diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 009fdc9513..0928674279 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -11338,6 +11338,60 @@ describe('NetworkController', () => { }); }); }); + + it('allows calling `getNetworkConfigurationByNetworkClientId` when subscribing to state changes containing new endpoints', async () => { + const network = buildCustomNetworkConfiguration({ + chainId: '0x1' as Hex, + name: 'mainnet', + nativeCurrency: 'ETH', + blockExplorerUrls: [], + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/1', + networkClientId: 'client1', + }, + ], + }); + + await withController( + { + state: { + selectedNetworkClientId: 'client1', + networkConfigurationsByChainId: { '0x1': network }, + }, + }, + async ({ controller, messenger }) => { + + const stateChangePromise = new Promise((resolve) => { + messenger.subscribe('NetworkController:stateChange', (state) => { + const { networkClientId } = + state.networkConfigurationsByChainId['0x1'].rpcEndpoints[1]; + + resolve( + controller.getNetworkConfigurationByNetworkClientId(networkClientId), + ); + }); + }); + + // Add a new endpoint + await controller.updateNetwork('0x1', { + ...network, + rpcEndpoints: [ + ...network.rpcEndpoints, + { + type: RpcEndpointType.Custom, + url: 'https://test.endpoint/2', + }, + ], + }); + + const networkConfiguration = await stateChangePromise; + expect(networkConfiguration).toBeDefined(); + }, + ); + }); }); describe('removeNetwork', () => { diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 232671b4cd..fe1a9dda54 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -115,7 +115,7 @@ "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", - "@metamask/accounts-controller": "^21.0.0", + "@metamask/accounts-controller": "^21.0.1", "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-internal-api": "^2.0.0", "@metamask/providers": "^18.1.1", diff --git a/packages/token-search-discovery-controller/CHANGELOG.md b/packages/token-search-discovery-controller/CHANGELOG.md new file mode 100644 index 0000000000..c223b16311 --- /dev/null +++ b/packages/token-search-discovery-controller/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial work to introduce the TokenSearchDiscoveryController ([#5142](https://github.com/MetaMask/core/pull/5142/)) + - This controller manages token search and discovery through the Portfolio API + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/token-search-discovery-controller/LICENSE b/packages/token-search-discovery-controller/LICENSE new file mode 100644 index 0000000000..7d002dced3 --- /dev/null +++ b/packages/token-search-discovery-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/token-search-discovery-controller/README.md b/packages/token-search-discovery-controller/README.md new file mode 100644 index 0000000000..a58c0e1f6a --- /dev/null +++ b/packages/token-search-discovery-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/token-search-discovery-controller` + +Manages token search and discovery through the Portfolio API. + +## Installation + +`yarn add @metamask/token-search-discovery-controller` + +or + +`npm install @metamask/token-search-discovery-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/token-search-discovery-controller/jest.config.js b/packages/token-search-discovery-controller/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/token-search-discovery-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/token-search-discovery-controller/package.json b/packages/token-search-discovery-controller/package.json new file mode 100644 index 0000000000..9bc17aa362 --- /dev/null +++ b/packages/token-search-discovery-controller/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/token-search-discovery-controller", + "version": "0.0.0", + "description": "Manages token search and discovery through the Portfolio API", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/token-search-discovery-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/token-search-discovery-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/token-search-discovery-controller", + "publish:preview": "yarn npm publish --tag preview", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", + "since-latest-release": "../../scripts/since-latest-release.sh" + }, + "dependencies": { + "@metamask/base-controller": "^7.1.1", + "@metamask/utils": "^11.0.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/token-search-discovery-controller/src/index.ts b/packages/token-search-discovery-controller/src/index.ts new file mode 100644 index 0000000000..ce5c5022ef --- /dev/null +++ b/packages/token-search-discovery-controller/src/index.ts @@ -0,0 +1,6 @@ +export { TokenSearchDiscoveryController } from './token-search-discovery-controller'; +export type { TokenSearchDiscoveryControllerState } from './token-search-discovery-controller'; +export type { TokenSearchResponseItem } from './types'; + +export { AbstractTokenSearchApiService } from './token-search-api-service/abstract-token-search-api-service'; +export { TokenSearchApiService } from './token-search-api-service/token-search-api-service'; diff --git a/packages/token-search-discovery-controller/src/token-search-api-service/abstract-token-search-api-service.ts b/packages/token-search-discovery-controller/src/token-search-api-service/abstract-token-search-api-service.ts new file mode 100644 index 0000000000..4e1e80edb8 --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-search-api-service/abstract-token-search-api-service.ts @@ -0,0 +1,16 @@ +import type { TokenSearchParams, TokenSearchResponseItem } from '../types'; + +/** + * Abstract class for fetching token search results. + */ +export abstract class AbstractTokenSearchApiService { + /** + * Fetches token search results from the portfolio API. + * + * @param tokenSearchParams - Optional search parameters including chains, name, and limit {@link TokenSearchParams} + * @returns A promise resolving to an array of {@link TokenSearchResponseItem} + */ + abstract searchTokens( + tokenSearchParams?: TokenSearchParams, + ): Promise; +} diff --git a/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts new file mode 100644 index 0000000000..077687cf50 --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.test.ts @@ -0,0 +1,151 @@ +import { TokenSearchApiService } from './token-search-api-service'; + +describe('TokenSearchApiService', () => { + const baseUrl = 'https://test-api'; + let service: TokenSearchApiService; + let mockFetch: jest.SpyInstance; + + const mockResponses = { + allParams: [ + { + name: 'Token1', + symbol: 'TK1', + chainId: '1', + tokenAddress: '0x1', + usdPrice: 100, + usdPricePercentChange: { oneDay: 10 }, + }, + { + name: 'Token2', + symbol: 'TK2', + chainId: '1', + tokenAddress: '0x2', + usdPrice: 200, + usdPricePercentChange: { oneDay: 20 }, + }, + ], + onlyChain: [ + { + name: 'ChainToken', + symbol: 'CTK', + chainId: '1', + tokenAddress: '0x3', + usdPrice: 300, + usdPricePercentChange: { oneDay: 30 }, + }, + ], + onlyName: [ + { + name: 'NameMatch', + symbol: 'NM', + chainId: '1', + tokenAddress: '0x4', + usdPrice: 400, + usdPricePercentChange: { oneDay: 40 }, + }, + ], + }; + + beforeEach(() => { + service = new TokenSearchApiService(baseUrl); + mockFetch = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })); + }); + + afterEach(() => { + mockFetch.mockRestore(); + }); + + describe('constructor', () => { + it('should throw if baseUrl is empty', () => { + expect(() => new TokenSearchApiService('')).toThrow( + 'Portfolio API URL is not set', + ); + }); + }); + + describe('searchTokens', () => { + it.each([ + { + params: { chains: ['1'], name: 'Test', limit: '10' }, + expectedUrl: new URL( + `${baseUrl}/tokens-search/name?chains=1&name=Test&limit=10`, + ), + }, + { + params: { chains: ['1', '137'], name: 'Test' }, + expectedUrl: new URL( + `${baseUrl}/tokens-search/name?chains=1%2C137&name=Test`, + ), + }, + { + params: { name: 'Test' }, + expectedUrl: new URL(`${baseUrl}/tokens-search/name?name=Test`), + }, + { + params: { chains: ['1'] }, + expectedUrl: new URL(`${baseUrl}/tokens-search/name?chains=1`), + }, + { + params: { limit: '20' }, + expectedUrl: new URL(`${baseUrl}/tokens-search/name?limit=20`), + }, + { + params: {}, + expectedUrl: new URL(`${baseUrl}/tokens-search/name`), + }, + ])( + 'should construct correct URL for params: $params', + async ({ params, expectedUrl }) => { + await service.searchTokens(params); + expect(mockFetch.mock.calls[0][0].toString()).toBe( + expectedUrl.toString(), + ); + }, + ); + + it('should handle API errors', async () => { + mockFetch.mockResolvedValueOnce( + new Response('Server Error', { status: 500 }), + ); + + await expect(service.searchTokens({})).rejects.toThrow( + 'Portfolio API request failed with status: 500', + ); + }); + }); + + describe('searchTokens response handling', () => { + it.each([ + { + params: { chains: ['1'], name: 'Test', limit: '2' }, + mockResponse: mockResponses.allParams, + description: 'all parameters', + }, + { + params: { chains: ['1'] }, + mockResponse: mockResponses.onlyChain, + description: 'only chain parameter', + }, + { + params: { name: 'Name' }, + mockResponse: mockResponses.onlyName, + description: 'only name parameter', + }, + ])( + 'should handle response correctly regardless of params', + async ({ params, mockResponse }) => { + mockFetch = jest + .spyOn(global, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }), + ); + + const response = await service.searchTokens(params); + + expect(response).toStrictEqual(mockResponse); + }, + ); + }); +}); diff --git a/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts new file mode 100644 index 0000000000..04e888e950 --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-search-api-service/token-search-api-service.ts @@ -0,0 +1,45 @@ +import { AbstractTokenSearchApiService } from './abstract-token-search-api-service'; +import type { TokenSearchParams, TokenSearchResponseItem } from '../types'; + +export class TokenSearchApiService extends AbstractTokenSearchApiService { + readonly #baseUrl: string; + + constructor(baseUrl: string) { + super(); + if (!baseUrl) { + throw new Error('Portfolio API URL is not set'); + } + this.#baseUrl = baseUrl; + } + + async searchTokens( + tokenSearchParams?: TokenSearchParams, + ): Promise { + const url = new URL('/tokens-search/name', this.#baseUrl); + + if (tokenSearchParams?.chains && tokenSearchParams.chains.length > 0) { + url.searchParams.append('chains', tokenSearchParams.chains.join()); + } + if (tokenSearchParams?.name) { + url.searchParams.append('name', tokenSearchParams.name); + } + if (tokenSearchParams?.limit) { + url.searchParams.append('limit', tokenSearchParams.limit); + } + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Portfolio API request failed with status: ${response.status}`, + ); + } + + return response.json(); + } +} diff --git a/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts b/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts new file mode 100644 index 0000000000..d7e237b9c4 --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-search-discovery-controller.test.ts @@ -0,0 +1,110 @@ +import { ControllerMessenger } from '@metamask/base-controller'; + +import { AbstractTokenSearchApiService } from './token-search-api-service/abstract-token-search-api-service'; +import { + getDefaultTokenSearchDiscoveryControllerState, + TokenSearchDiscoveryController, +} from './token-search-discovery-controller'; +import type { TokenSearchDiscoveryControllerMessenger } from './token-search-discovery-controller'; +import type { TokenSearchResponseItem } from './types'; + +const controllerName = 'TokenSearchDiscoveryController'; + +/** + * Helper function to get a restricted messenger for testing + * + * @returns A restricted messenger for the TokenSearchDiscoveryController + */ +function getRestrictedMessenger() { + const controllerMessenger = new ControllerMessenger(); + return controllerMessenger.getRestricted({ + name: controllerName, + allowedActions: [], + allowedEvents: [], + }) as TokenSearchDiscoveryControllerMessenger; +} + +describe('TokenSearchDiscoveryController', () => { + const mockSearchResults: TokenSearchResponseItem[] = [ + { + name: 'Test Token', + symbol: 'TEST', + chainId: '1', + tokenAddress: '0x123', + usdPrice: 100, + usdPricePercentChange: { + oneDay: 10, + }, + }, + ]; + + class MockTokenSearchService extends AbstractTokenSearchApiService { + async searchTokens(): Promise { + return mockSearchResults; + } + } + + describe('constructor', () => { + it('should initialize with default state', () => { + const controller = new TokenSearchDiscoveryController({ + tokenSearchService: new MockTokenSearchService(), + messenger: getRestrictedMessenger(), + }); + + expect(controller.state).toStrictEqual( + getDefaultTokenSearchDiscoveryControllerState(), + ); + }); + + it('should initialize with initial state', () => { + const initialState = { + recentSearches: mockSearchResults, + lastSearchTimestamp: 123, + }; + + const controller = new TokenSearchDiscoveryController({ + tokenSearchService: new MockTokenSearchService(), + state: initialState, + messenger: getRestrictedMessenger(), + }); + + expect(controller.state).toStrictEqual(initialState); + }); + + it('should merge to complete state', () => { + const partialState = { + recentSearches: mockSearchResults, + }; + + const controller = new TokenSearchDiscoveryController({ + tokenSearchService: new MockTokenSearchService(), + state: partialState, + messenger: getRestrictedMessenger(), + }); + + expect(controller.state).toStrictEqual({ + ...getDefaultTokenSearchDiscoveryControllerState(), + ...partialState, + }); + }); + }); + + describe('searchTokens', () => { + it('should update state with search results', async () => { + const mockService = new MockTokenSearchService(); + const controller = new TokenSearchDiscoveryController({ + tokenSearchService: mockService, + messenger: getRestrictedMessenger(), + }); + + const response = await controller.searchTokens({ + chains: ['1'], + name: 'Test', + }); + + expect(response).toStrictEqual(mockSearchResults); + expect(controller.state.recentSearches).toStrictEqual(mockSearchResults); + expect(controller.state.lastSearchTimestamp).toBeDefined(); + }); + }); +}); diff --git a/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts b/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts new file mode 100644 index 0000000000..0f14962ffa --- /dev/null +++ b/packages/token-search-discovery-controller/src/token-search-discovery-controller.ts @@ -0,0 +1,144 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; + +import type { AbstractTokenSearchApiService } from './token-search-api-service/abstract-token-search-api-service'; +import type { TokenSearchParams, TokenSearchResponseItem } from './types'; + +// === GENERAL === + +const controllerName = 'TokenSearchDiscoveryController'; + +// === STATE === + +export type TokenSearchDiscoveryControllerState = { + recentSearches: TokenSearchResponseItem[]; + lastSearchTimestamp: number | null; +}; + +const tokenSearchDiscoveryControllerMetadata = { + recentSearches: { persist: true, anonymous: false }, + lastSearchTimestamp: { persist: true, anonymous: false }, +} as const; + +// === MESSENGER === + +/** + * The action which can be used to retrieve the state of the + * {@link TokenSearchDiscoveryController}. + */ +export type TokenSearchDiscoveryControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + TokenSearchDiscoveryControllerState + >; + +/** + * All actions that {@link TokenSearchDiscoveryController} registers, to be + * called externally. + */ +export type TokenSearchDiscoveryControllerActions = + TokenSearchDiscoveryControllerGetStateAction; + +/** + * All actions that {@link TokenSearchDiscoveryController} calls internally. + */ +type AllowedActions = never; + +/** + * The event that {@link TokenSearchDiscoveryController} publishes when updating + * state. + */ +export type TokenSearchDiscoveryControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + TokenSearchDiscoveryControllerState + >; + +/** + * All events that {@link TokenSearchDiscoveryController} publishes, to be + * subscribed to externally. + */ +export type TokenSearchDiscoveryControllerEvents = + TokenSearchDiscoveryControllerStateChangeEvent; + +/** + * All events that {@link TokenSearchDiscoveryController} subscribes to internally. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link TokenSearchDiscoveryController}. + */ +export type TokenSearchDiscoveryControllerMessenger = + RestrictedControllerMessenger< + typeof controllerName, + TokenSearchDiscoveryControllerActions | AllowedActions, + TokenSearchDiscoveryControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] + >; + +/** + * Constructs the default {@link TokenSearchDiscoveryController} state. This allows + * consumers to provide a partial state object when initializing the controller + * and also helps in constructing complete state objects for this controller in + * tests. + * + * @returns The default {@link TokenSearchDiscoveryController} state. + */ +export function getDefaultTokenSearchDiscoveryControllerState(): TokenSearchDiscoveryControllerState { + return { + recentSearches: [], + lastSearchTimestamp: null, + }; +} + +/** + * The TokenSearchDiscoveryController manages the retrieval of token search results and token discovery. + * It fetches token search results from the portfolio API. + */ +export class TokenSearchDiscoveryController extends BaseController< + typeof controllerName, + TokenSearchDiscoveryControllerState, + TokenSearchDiscoveryControllerMessenger +> { + readonly #tokenSearchService: AbstractTokenSearchApiService; + + constructor({ + tokenSearchService, + state = {}, + messenger, + }: { + tokenSearchService: AbstractTokenSearchApiService; + state?: Partial; + messenger: TokenSearchDiscoveryControllerMessenger; + }) { + super({ + name: controllerName, + metadata: tokenSearchDiscoveryControllerMetadata, + messenger, + state: { ...getDefaultTokenSearchDiscoveryControllerState(), ...state }, + }); + + this.#tokenSearchService = tokenSearchService; + } + + async searchTokens( + tokenSearchParams: TokenSearchParams, + ): Promise { + const results = + await this.#tokenSearchService.searchTokens(tokenSearchParams); + + this.update((state) => { + state.recentSearches = results; + state.lastSearchTimestamp = Date.now(); + }); + + return results; + } +} diff --git a/packages/token-search-discovery-controller/src/types.ts b/packages/token-search-discovery-controller/src/types.ts new file mode 100644 index 0000000000..1dbd924433 --- /dev/null +++ b/packages/token-search-discovery-controller/src/types.ts @@ -0,0 +1,16 @@ +export type TokenSearchParams = { + chains?: string[]; + name?: string; + limit?: string; +}; + +export type TokenSearchResponseItem = { + tokenAddress: string; + chainId: string; + name: string; + symbol: string; + usdPrice: number; + usdPricePercentChange: { + oneDay: number; + }; +}; diff --git a/packages/token-search-discovery-controller/tsconfig.build.json b/packages/token-search-discovery-controller/tsconfig.build.json new file mode 100644 index 0000000000..e5fd7422b9 --- /dev/null +++ b/packages/token-search-discovery-controller/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../base-controller/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/token-search-discovery-controller/tsconfig.json b/packages/token-search-discovery-controller/tsconfig.json new file mode 100644 index 0000000000..831cc7b867 --- /dev/null +++ b/packages/token-search-discovery-controller/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../../packages/base-controller" }], + "include": ["../../types", "./src"] +} diff --git a/packages/token-search-discovery-controller/typedoc.json b/packages/token-search-discovery-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/token-search-discovery-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 7efdd227f6..7e4aa89ee8 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -69,7 +69,7 @@ }, "devDependencies": { "@babel/runtime": "^7.23.9", - "@metamask/accounts-controller": "^21.0.0", + "@metamask/accounts-controller": "^21.0.1", "@metamask/approval-controller": "^7.1.2", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^11.0.3", diff --git a/teams.json b/teams.json index 2f941fc522..31af66f7ae 100644 --- a/teams.json +++ b/teams.json @@ -33,5 +33,6 @@ "metamask/selected-network-controller": "team-wallet-api-platform,team-wallet-framework,team-assets", "metamask/signature-controller": "team-confirmations", "metamask/transaction-controller": "team-confirmations", + "metamask/token-search-discovery-controller": "team-portfolio", "metamask/user-operation-controller": "team-confirmations" } diff --git a/tsconfig.build.json b/tsconfig.build.json index b104bf1b42..a5ac14a68c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -26,6 +26,7 @@ { "path": "./packages/permission-controller/tsconfig.build.json" }, { "path": "./packages/permission-log-controller/tsconfig.build.json" }, { "path": "./packages/phishing-controller/tsconfig.build.json" }, + { "path": "./packages/polling-controller/tsconfig.build.json" }, { "path": "./packages/preferences-controller/tsconfig.build.json" }, { "path": "./packages/profile-sync-controller/tsconfig.build.json" }, { "path": "./packages/queued-request-controller/tsconfig.build.json" }, @@ -33,9 +34,11 @@ { "path": "./packages/remote-feature-flag-controller/tsconfig.build.json" }, { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/signature-controller/tsconfig.build.json" }, + { + "path": "./packages/token-search-discovery-controller/tsconfig.build.json" + }, { "path": "./packages/transaction-controller/tsconfig.build.json" }, - { "path": "./packages/user-operation-controller/tsconfig.build.json" }, - { "path": "./packages/polling-controller/tsconfig.build.json" } + { "path": "./packages/user-operation-controller/tsconfig.build.json" } ], "files": [], "include": [] diff --git a/tsconfig.json b/tsconfig.json index c1b71cc0af..d1e3046fd5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ { "path": "./packages/permission-controller" }, { "path": "./packages/permission-log-controller" }, { "path": "./packages/phishing-controller" }, + { "path": "./packages/polling-controller" }, { "path": "./packages/preferences-controller" }, { "path": "./packages/profile-sync-controller" }, { "path": "./packages/queued-request-controller" }, @@ -37,9 +38,9 @@ { "path": "./packages/remote-feature-flag-controller" }, { "path": "./packages/selected-network-controller" }, { "path": "./packages/signature-controller" }, + { "path": "./packages/token-search-discovery-controller" }, { "path": "./packages/transaction-controller" }, - { "path": "./packages/user-operation-controller" }, - { "path": "./packages/polling-controller" } + { "path": "./packages/user-operation-controller" } ], "files": [], "include": ["./types"] diff --git a/yarn.lock b/yarn.lock index c386c0a134..7be49a6021 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2297,14 +2297,14 @@ __metadata: languageName: node linkType: hard -"@metamask/accounts-controller@npm:^21.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@npm:^21.0.1, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: "@ethereumjs/util": "npm:^8.1.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^7.1.1" - "@metamask/eth-snap-keyring": "npm:^8.0.0" + "@metamask/eth-snap-keyring": "npm:^8.1.0" "@metamask/keyring-api": "npm:^13.0.0" "@metamask/keyring-controller": "npm:^19.0.3" "@metamask/keyring-internal-api": "npm:^2.0.0" @@ -2417,7 +2417,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/accounts-controller": "npm:^21.0.0" + "@metamask/accounts-controller": "npm:^21.0.1" "@metamask/approval-controller": "npm:^7.1.2" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^7.1.1" @@ -2920,16 +2920,16 @@ __metadata: languageName: node linkType: hard -"@metamask/eth-snap-keyring@npm:^8.0.0": - version: 8.0.0 - resolution: "@metamask/eth-snap-keyring@npm:8.0.0" +"@metamask/eth-snap-keyring@npm:^8.1.0": + version: 8.1.0 + resolution: "@metamask/eth-snap-keyring@npm:8.1.0" dependencies: "@ethereumjs/tx": "npm:^4.2.0" "@metamask/eth-sig-util": "npm:^8.1.2" "@metamask/keyring-api": "npm:^13.0.0" "@metamask/keyring-internal-api": "npm:^2.0.0" "@metamask/keyring-internal-snap-client": "npm:^2.0.0" - "@metamask/keyring-utils": "npm:^1.0.0" + "@metamask/keyring-utils": "npm:^1.1.0" "@metamask/snaps-controllers": "npm:^9.10.0" "@metamask/snaps-sdk": "npm:^6.7.0" "@metamask/snaps-utils": "npm:^8.3.0" @@ -2941,7 +2941,7 @@ __metadata: peerDependencies: "@metamask/keyring-api": ^13.0.0 "@metamask/providers": ^18.3.1 - checksum: 10/51bc9f703109acd662aac36986568cb25995307a2607f4634067cdd2353afda2eb620438993452f1376f6c7323660556df6c3e02c0c0b9e3dbe2d11d56d2a93f + checksum: 10/4b758f14540cf8ea892a80d8cf70234305e7f6978ab46dc58b727d6c6944d11dee22841fc17b22a7620662a5051ec189675f2dd06638733b9b1c3e8436c8b623 languageName: node linkType: hard @@ -3229,7 +3229,7 @@ __metadata: "@metamask/eth-simple-keyring": "npm:^6.0.5" "@metamask/keyring-api": "npm:^13.0.0" "@metamask/keyring-internal-api": "npm:^2.0.0" - "@metamask/message-manager": "npm:^11.0.3" + "@metamask/message-manager": "npm:^12.0.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.0.1" "@types/jest": "npm:^27.4.1" @@ -3293,13 +3293,14 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-utils@npm:^1.0.0": - version: 1.0.0 - resolution: "@metamask/keyring-utils@npm:1.0.0" +"@metamask/keyring-utils@npm:^1.0.0, @metamask/keyring-utils@npm:^1.1.0": + version: 1.1.0 + resolution: "@metamask/keyring-utils@npm:1.1.0" dependencies: "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^9.3.0" - checksum: 10/f74f7343a7154b029e0fa4c25735c589eba4dc25a9e323d43b7c733ce5dbb23ce603a4f02aac455163993649ceeaf714b8b843985ba7a9cb00b926b3b8dc6b51 + "@metamask/utils": "npm:^11.0.1" + bitcoin-address-validation: "npm:^2.2.3" + checksum: 10/327eb37dcee41f47df212a9790672deec15c11692e370c15bb5687a2c90078b4d14dc61a9d7ce317e4bda03f18284731229feee19c1adae35bc859313da37ba5 languageName: node linkType: hard @@ -3321,7 +3322,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/message-manager@npm:^11.0.3, @metamask/message-manager@workspace:packages/message-manager": +"@metamask/message-manager@npm:^12.0.0, @metamask/message-manager@workspace:packages/message-manager": version: 0.0.0-use.local resolution: "@metamask/message-manager@workspace:packages/message-manager" dependencies: @@ -3650,7 +3651,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" - "@metamask/accounts-controller": "npm:^21.0.0" + "@metamask/accounts-controller": "npm:^21.0.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^7.1.1" "@metamask/keyring-api": "npm:^13.0.0" @@ -3995,6 +3996,23 @@ __metadata: languageName: node linkType: hard +"@metamask/token-search-discovery-controller@workspace:packages/token-search-discovery-controller": + version: 0.0.0-use.local + resolution: "@metamask/token-search-discovery-controller@workspace:packages/token-search-discovery-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^7.1.1" + "@metamask/utils": "npm:^11.0.1" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + languageName: unknown + linkType: soft + "@metamask/transaction-controller@npm:^43.0.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" @@ -4006,7 +4024,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^21.0.0" + "@metamask/accounts-controller": "npm:^21.0.1" "@metamask/approval-controller": "npm:^7.1.2" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^7.1.1" @@ -4139,7 +4157,7 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.1.0, @metamask/utils@npm:^9.2.1, @metamask/utils@npm:^9.3.0": +"@metamask/utils@npm:^9.0.0, @metamask/utils@npm:^9.1.0, @metamask/utils@npm:^9.2.1": version: 9.3.0 resolution: "@metamask/utils@npm:9.3.0" dependencies: