diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cec253674b..22f5bb0b0d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -58,6 +58,7 @@ /packages/permission-log-controller @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers /packages/selected-network-controller @MetaMask/wallet-api-platform-engineers @MetaMask/wallet-framework-engineers @MetaMask/metamask-assets /packages/profile-sync-controller @MetaMask/notifications @MetaMask/identity +/packages/multichain-transactions-controller @MetaMask/accounts-engineers ## Package Release related /packages/accounts-controller/package.json @MetaMask/accounts-engineers @MetaMask/wallet-framework-engineers diff --git a/README.md b/README.md index 895a7ebc69..e131dacb5b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/logging-controller`](packages/logging-controller) - [`@metamask/message-manager`](packages/message-manager) - [`@metamask/multichain`](packages/multichain) +- [`@metamask/multichain-transactions-controller`](packages/multichain-transactions-controller) - [`@metamask/name-controller`](packages/name-controller) - [`@metamask/network-controller`](packages/network-controller) - [`@metamask/notification-services-controller`](packages/notification-services-controller) @@ -49,6 +50,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/profile-sync-controller`](packages/profile-sync-controller) - [`@metamask/queued-request-controller`](packages/queued-request-controller) - [`@metamask/rate-limit-controller`](packages/rate-limit-controller) +- [`@metamask/remote-feature-flag-controller`](packages/remote-feature-flag-controller) - [`@metamask/selected-network-controller`](packages/selected-network-controller) - [`@metamask/signature-controller`](packages/signature-controller) - [`@metamask/transaction-controller`](packages/transaction-controller) @@ -80,6 +82,7 @@ linkStyle default opacity:0.5 logging_controller(["@metamask/logging-controller"]); message_manager(["@metamask/message-manager"]); multichain(["@metamask/multichain"]); + multichain_transactions_controller(["@metamask/multichain-transactions-controller"]); name_controller(["@metamask/name-controller"]); network_controller(["@metamask/network-controller"]); notification_services_controller(["@metamask/notification-services-controller"]); @@ -91,6 +94,7 @@ 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"]); transaction_controller(["@metamask/transaction-controller"]); @@ -164,6 +168,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-warning-thresholds.json b/eslint-warning-thresholds.json index fc7d2173b6..9383a03e92 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -2,16 +2,16 @@ "@typescript-eslint/consistent-type-exports": 19, "@typescript-eslint/no-base-to-string": 3, "@typescript-eslint/no-duplicate-enum-values": 2, - "@typescript-eslint/no-misused-promises": 3, - "@typescript-eslint/no-unsafe-enum-comparison": 59, + "@typescript-eslint/no-misused-promises": 1, + "@typescript-eslint/no-unsafe-enum-comparison": 35, "@typescript-eslint/no-unused-vars": 36, "@typescript-eslint/prefer-promise-reject-errors": 13, "@typescript-eslint/prefer-readonly": 145, - "@typescript-eslint/switch-exhaustiveness-check": 10, + "@typescript-eslint/switch-exhaustiveness-check": 9, "import-x/namespace": 189, "import-x/no-named-as-default": 1, "import-x/no-named-as-default-member": 8, - "import-x/order": 209, + "import-x/order": 208, "jest/no-conditional-in-test": 129, "jest/prefer-lowercase-title": 2, "jest/prefer-strict-equal": 2, diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md new file mode 100644 index 0000000000..b518709c7b --- /dev/null +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/multichain-transactions-controller/LICENSE b/packages/multichain-transactions-controller/LICENSE new file mode 100644 index 0000000000..7d002dced3 --- /dev/null +++ b/packages/multichain-transactions-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/multichain-transactions-controller/README.md b/packages/multichain-transactions-controller/README.md new file mode 100644 index 0000000000..5ae3333ab0 --- /dev/null +++ b/packages/multichain-transactions-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/multichain-transactions-controller` + +This package is responsible for getting transactions from our Bitcoin and Solana snaps. + +## Installation + +`yarn add @metamask/multichain-transactions-controller` + +or + +`npm install @metamask/multichain-transactions-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/multichain-transactions-controller/jest.config.js b/packages/multichain-transactions-controller/jest.config.js new file mode 100644 index 0000000000..1d1aca52db --- /dev/null +++ b/packages/multichain-transactions-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: 80, + functions: 95, + lines: 90, + statements: 90, + }, + }, +}); diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json new file mode 100644 index 0000000000..5c5d061043 --- /dev/null +++ b/packages/multichain-transactions-controller/package.json @@ -0,0 +1,86 @@ +{ + "name": "@metamask/multichain-transactions-controller", + "version": "0.0.0", + "description": "This package is responsible for getting transactions from our Bitcoin and Solana snaps", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/multichain-transactions-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/multichain-transactions-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-transactions-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "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" + }, + "dependencies": { + "@metamask/base-controller": "^7.1.1", + "@metamask/keyring-api": "^13.0.0", + "@metamask/polling-controller": "^12.0.2", + "@metamask/snaps-utils": "^8.3.0", + "@metamask/utils": "^11.0.1", + "@types/uuid": "^8.3.0", + "immer": "^9.0.6", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/accounts-controller": "^21.0.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-controller": "^19.0.3", + "@metamask/keyring-internal-api": "^2.0.0", + "@metamask/keyring-snap-client": "^2.0.0", + "@metamask/snaps-controllers": "^9.10.0", + "@metamask/snaps-sdk": "^6.7.0", + "@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" + }, + "peerDependencies": { + "@metamask/accounts-controller": "^21.0.0", + "@metamask/keyring-controller": "^19.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts new file mode 100644 index 0000000000..27ecb2596c --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -0,0 +1,340 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import type { CaipAssetType, Transaction } from '@metamask/keyring-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthMethod, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { v4 as uuidv4 } from 'uuid'; + +import { + MultichainTransactionsController, + defaultState, + type AllowedActions, + type AllowedEvents, + type MultichainTransactionsControllerState, + type MultichainTransactionsControllerMessenger, +} from './MultichainTransactionsController'; +import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; + +const mockBtcAccount = { + address: 'bc1qssdcp5kvwh6nghzg9tuk99xsflwkdv4hgvq58q', + id: uuidv4(), + metadata: { + name: 'Bitcoin Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-btc-snap', + name: 'mock-btc-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: {}, + methods: [BtcMethod.SendBitcoin], + type: BtcAccountType.P2wpkh, + scopes: [], +}; + +const mockEthAccount = { + address: '0x807dE1cf8f39E83258904b2f7b473E5C506E4aC1', + id: uuidv4(), + metadata: { + name: 'Ethereum Account 1', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-eth-snap', + name: 'mock-eth-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: {}, + methods: [EthMethod.SignTypedDataV4, EthMethod.SignTransaction], + type: EthAccountType.Eoa, + scopes: [], +}; + +const mockTransactionResult = { + data: [ + { + id: '123', + account: mockBtcAccount.id, + chain: 'bip122:000000000019d6689c085ae165831e93', + type: 'send', + status: 'confirmed', + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed', + timestamp: Date.now(), + }, + ], + }, + ], + next: null, +}; + +const setupController = ({ + state = defaultState, + mocks, +}: { + state?: MultichainTransactionsControllerState; + mocks?: { + listMultichainAccounts?: InternalAccount[]; + handleRequestReturnValue?: Record; + }; +} = {}) => { + const controllerMessenger = new ControllerMessenger< + AllowedActions, + AllowedEvents + >(); + + const multichainTransactionsControllerMessenger: MultichainTransactionsControllerMessenger = + controllerMessenger.getRestricted({ + name: 'MultichainTransactionsController', + allowedActions: [ + 'SnapController:handleRequest', + 'AccountsController:listMultichainAccounts', + ], + allowedEvents: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + ], + }); + + const mockSnapHandleRequest = jest.fn(); + controllerMessenger.registerActionHandler( + 'SnapController:handleRequest', + mockSnapHandleRequest.mockReturnValue( + mocks?.handleRequestReturnValue ?? mockTransactionResult, + ), + ); + + const mockListMultichainAccounts = jest.fn(); + controllerMessenger.registerActionHandler( + 'AccountsController:listMultichainAccounts', + mockListMultichainAccounts.mockReturnValue( + mocks?.listMultichainAccounts ?? [mockBtcAccount, mockEthAccount], + ), + ); + + const controller = new MultichainTransactionsController({ + messenger: multichainTransactionsControllerMessenger, + state, + }); + + return { + controller, + messenger: controllerMessenger, + mockSnapHandleRequest, + mockListMultichainAccounts, + }; +}; + +describe('MultichainTransactionsController', () => { + it('initialize with default state', () => { + const { controller } = setupController({}); + expect(controller.state).toStrictEqual({ nonEvmTransactions: {} }); + }); + + it('starts tracking when calling start', async () => { + const spyTracker = jest.spyOn( + MultichainTransactionsTracker.prototype, + 'start', + ); + const { controller } = setupController(); + controller.start(); + expect(spyTracker).toHaveBeenCalledTimes(1); + }); + + it('stops tracking when calling stop', async () => { + const spyTracker = jest.spyOn( + MultichainTransactionsTracker.prototype, + 'stop', + ); + const { controller } = setupController(); + controller.start(); + controller.stop(); + expect(spyTracker).toHaveBeenCalledTimes(1); + }); + + it('update transactions when calling updateTransactions', async () => { + const { controller } = setupController(); + + await controller.updateTransactions(); + + expect(controller.state).toStrictEqual({ + nonEvmTransactions: { + [mockBtcAccount.id]: { + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }, + }, + }); + }); + + it('update transactions when "AccountsController:accountAdded" is fired', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + controller.start(); + mockListMultichainAccounts.mockReturnValue([mockBtcAccount]); + messenger.publish('AccountsController:accountAdded', mockBtcAccount); + await controller.updateTransactions(); + + expect(controller.state).toStrictEqual({ + nonEvmTransactions: { + [mockBtcAccount.id]: { + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }, + }, + }); + }); + + it('update transactions when "AccountsController:accountRemoved" is fired', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController(); + + controller.start(); + await controller.updateTransactions(); + expect(controller.state).toStrictEqual({ + nonEvmTransactions: { + [mockBtcAccount.id]: { + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }, + }, + }); + + messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id); + mockListMultichainAccounts.mockReturnValue([]); + await controller.updateTransactions(); + + expect(controller.state).toStrictEqual({ + nonEvmTransactions: {}, + }); + }); + + it('does not track balances for EVM accounts', async () => { + const { controller, messenger, mockListMultichainAccounts } = + setupController({ + mocks: { + listMultichainAccounts: [], + }, + }); + + controller.start(); + mockListMultichainAccounts.mockReturnValue([mockEthAccount]); + messenger.publish('AccountsController:accountAdded', mockEthAccount); + await controller.updateTransactions(); + + expect(controller.state).toStrictEqual({ + nonEvmTransactions: {}, + }); + }); + + it('should update transactions for a specific account', async () => { + const { controller } = setupController(); + await controller.updateTransactionsForAccount(mockBtcAccount.id); + + expect( + controller.state.nonEvmTransactions[mockBtcAccount.id], + ).toStrictEqual({ + transactions: mockTransactionResult.data, + next: null, + lastUpdated: expect.any(Number), + }); + }); + + it('should handle pagination when fetching transactions', async () => { + const firstPage = { + data: [ + { + id: '1', + account: mockBtcAccount.id, + chain: 'bip122:000000000933ea01ad0ee984209779ba', + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }, + ], + next: 'page2', + }; + + const secondPage = { + data: [ + { + id: '2', + account: mockBtcAccount.id, + chain: 'bip122:000000000933ea01ad0ee984209779ba', + type: 'send' as const, + status: 'confirmed' as const, + timestamp: Date.now(), + from: [], + to: [], + fees: [], + events: [ + { + status: 'confirmed' as const, + timestamp: Date.now(), + }, + ], + }, + ], + next: null, + }; + + const { controller, mockSnapHandleRequest } = setupController(); + mockSnapHandleRequest + .mockReturnValueOnce(firstPage) + .mockReturnValueOnce(secondPage); + + await controller.updateTransactionsForAccount(mockBtcAccount.id); + + expect(mockSnapHandleRequest).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + method: 'keyring_listAccountTransactions', + }), + }), + ); + }); + + it('should handle errors gracefully when updating transactions', async () => { + const { controller, mockSnapHandleRequest } = setupController(); + mockSnapHandleRequest.mockRejectedValue(new Error('Failed to fetch')); + + await controller.updateTransactions(); + expect(controller.state.nonEvmTransactions).toStrictEqual({}); + }); +}); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts new file mode 100644 index 0000000000..65e0a69357 --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -0,0 +1,379 @@ +import { + BaseController, + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { isEvmAccountType, type Transaction } from '@metamask/keyring-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { HandleSnapRequest } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Draft } from 'immer'; + +import { MultichainNetworks, TRANSACTIONS_CHECK_INTERVALS } from './constants'; +import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; + +const controllerName = 'MultichainTransactionsController'; + +export type PaginationOptions = { + limit: number; + next?: string | null; +}; + +/** + * State used by the {@link MultichainTransactionsController} to cache account transactions. + */ +export type MultichainTransactionsControllerState = { + nonEvmTransactions: { + [accountId: string]: TransactionStateEntry; + }; +}; + +/** + * Default state of the {@link MultichainTransactionsController}. + */ +export const defaultState: MultichainTransactionsControllerState = { + nonEvmTransactions: {}, +}; + +/** + * Returns the state of the {@link MultichainTransactionsController}. + */ +export type MultichainTransactionsControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MultichainTransactionsControllerState + >; + +/** + * Updates the transactions of all supported accounts. + */ +export type MultichainTransactionsControllerListTransactionsAction = { + type: `${typeof controllerName}:updateTransactions`; + handler: MultichainTransactionsController['updateTransactions']; +}; + +/** + * Event emitted when the state of the {@link MultichainTransactionsController} changes. + */ +export type MultichainTransactionsControllerStateChange = + ControllerStateChangeEvent< + typeof controllerName, + MultichainTransactionsControllerState + >; + +/** + * Actions exposed by the {@link MultichainTransactionsController}. + */ +export type MultichainTransactionsControllerActions = + | MultichainTransactionsControllerGetStateAction + | MultichainTransactionsControllerListTransactionsAction; + +/** + * Events emitted by {@link MultichainTransactionsController}. + */ +export type MultichainTransactionsControllerEvents = + MultichainTransactionsControllerStateChange; + +/** + * Messenger type for the MultichainTransactionsController. + */ +export type MultichainTransactionsControllerMessenger = + RestrictedControllerMessenger< + typeof controllerName, + MultichainTransactionsControllerActions | AllowedActions, + MultichainTransactionsControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] + >; + +/** + * Actions that this controller is allowed to call. + */ +export type AllowedActions = + | HandleSnapRequest + | AccountsControllerListMultichainAccountsAction; + +/** + * Events that this controller is allowed to subscribe. + */ +export type AllowedEvents = + | AccountsControllerAccountAddedEvent + | AccountsControllerAccountRemovedEvent; + +/** + * {@link MultichainTransactionsController}'s metadata. + * + * This allows us to choose if fields of the state should be persisted or not + * using the `persist` flag; and if they can be sent to Sentry or not, using + * the `anonymous` flag. + */ +const MultichainTransactionsControllerMetadata = { + nonEvmTransactions: { + persist: true, + anonymous: false, + }, +}; + +/** + * The state of transactions for a specific account. + */ +export type TransactionStateEntry = { + transactions: Transaction[]; + next: string | null; + lastUpdated: number; +}; + +/** + * The MultichainTransactionsController is responsible for fetching and caching account + * transactions for non-EVM accounts. + */ +export class MultichainTransactionsController extends BaseController< + typeof controllerName, + MultichainTransactionsControllerState, + MultichainTransactionsControllerMessenger +> { + readonly #tracker: MultichainTransactionsTracker; + + constructor({ + messenger, + state, + }: { + messenger: MultichainTransactionsControllerMessenger; + state: MultichainTransactionsControllerState; + }) { + super({ + messenger, + name: controllerName, + metadata: MultichainTransactionsControllerMetadata, + state: { + ...defaultState, + ...state, + }, + }); + + this.#tracker = new MultichainTransactionsTracker( + async (accountId: string, pagination: PaginationOptions) => + await this.#updateTransactions(accountId, pagination), + ); + + // Register all non-EVM accounts into the tracker + for (const account of this.#listAccounts()) { + if (this.#isNonEvmAccount(account)) { + this.#tracker.track(account.id, this.#getBlockTimeFor(account)); + } + } + + this.messagingSystem.subscribe( + 'AccountsController:accountAdded', + (account) => this.#handleOnAccountAdded(account), + ); + this.messagingSystem.subscribe( + 'AccountsController:accountRemoved', + (accountId: string) => this.#handleOnAccountRemoved(accountId), + ); + } + + /** + * Lists the multichain accounts coming from the `AccountsController`. + * + * @returns A list of multichain accounts. + */ + #listMultichainAccounts(): InternalAccount[] { + return this.messagingSystem.call( + 'AccountsController:listMultichainAccounts', + ); + } + + /** + * Lists the accounts that we should get transactions for. + * + * @returns A list of accounts that we should get transactions for. + */ + #listAccounts(): InternalAccount[] { + const accounts = this.#listMultichainAccounts(); + return accounts.filter((account) => this.#isNonEvmAccount(account)); + } + + /** + * Updates the transactions for one account. + * + * @param accountId - The ID of the account to update transactions for. + * @param pagination - Options for paginating transaction results. + */ + async #updateTransactions(accountId: string, pagination: PaginationOptions) { + const account = this.#listAccounts().find( + (accountItem) => accountItem.id === accountId, + ); + + if (account?.metadata.snap) { + const response = await this.#getTransactions( + account.id, + account.metadata.snap.id, + pagination, + ); + + /** + * Filter only Solana transactions to ensure they're mainnet + * All other chain transactions are included as-is + */ + const transactions = response.data.filter((tx) => { + const chain = tx.chain as MultichainNetworks; + if (chain.startsWith(MultichainNetworks.Solana)) { + return chain === MultichainNetworks.Solana; + } + return true; + }); + + this.update((state: Draft) => { + const entry: TransactionStateEntry = { + transactions, + next: response.next, + lastUpdated: Date.now(), + }; + + Object.assign(state.nonEvmTransactions, { [account.id]: entry }); + }); + } + } + + /** + * Gets transactions for an account. + * + * @param accountId - The ID of the account to get transactions for. + * @param snapId - The ID of the snap that manages the account. + * @param pagination - Options for paginating transaction results. + * @returns A promise that resolves to the transaction data and pagination info. + */ + async #getTransactions( + accountId: string, + snapId: string, + pagination: PaginationOptions, + ): Promise<{ + data: Transaction[]; + next: string | null; + }> { + return await this.#getClient(snapId).listAccountTransactions( + accountId, + pagination, + ); + } + + /** + * Updates transactions for a specific account + * + * @param accountId - The ID of the account to get transactions for. + */ + async updateTransactionsForAccount(accountId: string) { + await this.#tracker.updateTransactionsForAccount(accountId); + } + + /** + * Updates the transactions of all supported accounts. This method doesn't return + * anything, but it updates the state of the controller. + */ + async updateTransactions() { + await this.#tracker.updateTransactions(); + } + + /** + * Starts the polling process. + */ + start(): void { + this.#tracker.start(); + } + + /** + * Stops the polling process. + */ + stop(): void { + this.#tracker.stop(); + } + + /** + * Gets the block time for a given account. + * + * @param account - The account to get the block time for. + * @returns The block time for the account. + */ + #getBlockTimeFor(account: InternalAccount): number { + if (account.type in TRANSACTIONS_CHECK_INTERVALS) { + return TRANSACTIONS_CHECK_INTERVALS[ + account.type as keyof typeof TRANSACTIONS_CHECK_INTERVALS + ]; + } + throw new Error( + `Unsupported account type for transactions tracking: ${account.type}`, + ); + } + + /** + * Checks for non-EVM accounts. + * + * @param account - The new account to be checked. + * @returns True if the account is a non-EVM account, false otherwise. + */ + #isNonEvmAccount(account: InternalAccount): boolean { + return ( + !isEvmAccountType(account.type) && + // Non-EVM accounts are backed by a Snap for now + account.metadata.snap !== undefined + ); + } + + /** + * Handles changes when a new account has been added. + * + * @param account - The new account being added. + */ + async #handleOnAccountAdded(account: InternalAccount) { + if (!this.#isNonEvmAccount(account)) { + return; + } + + this.#tracker.track(account.id, this.#getBlockTimeFor(account)); + } + + /** + * Handles changes when a new account has been removed. + * + * @param accountId - The account ID being removed. + */ + async #handleOnAccountRemoved(accountId: string) { + if (this.#tracker.isTracked(accountId)) { + this.#tracker.untrack(accountId); + } + + if (accountId in this.state.nonEvmTransactions) { + this.update((state: Draft) => { + delete state.nonEvmTransactions[accountId]; + }); + } + } + + /** + * Gets a `KeyringClient` for a Snap. + * + * @param snapId - ID of the Snap to get the client for. + * @returns A `KeyringClient` for the Snap. + */ + #getClient(snapId: string): KeyringClient { + return new KeyringClient({ + send: async (request: JsonRpcRequest) => + (await this.messagingSystem.call('SnapController:handleRequest', { + snapId: snapId as SnapId, + origin: 'metamask', + handler: HandlerType.OnKeyringRequest, + request, + })) as Promise, + }); + } +} diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts new file mode 100644 index 0000000000..386d07616f --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts @@ -0,0 +1,141 @@ +import { SolAccountType, SolMethod } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { v4 as uuidv4 } from 'uuid'; + +import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; +import { Poller } from './Poller'; + +const MOCK_TIMESTAMP = 1733788800; + +const mockSolanaAccount = { + address: '', + id: uuidv4(), + metadata: { + name: 'Solana Account', + importTime: Date.now(), + keyring: { + type: KeyringTypes.snap, + }, + snap: { + id: 'mock-solana-snap', + name: 'mock-solana-snap', + enabled: true, + }, + lastSelected: 0, + }, + options: {}, + methods: [SolMethod.SendAndConfirmTransaction], + type: SolAccountType.DataAccount, +}; + +/** + * Creates and returns a new MultichainTransactionsTracker instance with a mock update function. + * + * @returns The tracker instance and mock update function. + */ +function setupTracker(): { + tracker: MultichainTransactionsTracker; + mockUpdateTransactions: jest.Mock; +} { + const mockUpdateTransactions = jest.fn(); + const tracker = new MultichainTransactionsTracker(mockUpdateTransactions); + + return { + tracker, + mockUpdateTransactions, + }; +} + +describe('MultichainTransactionsTracker', () => { + it('starts polling when calling start', async () => { + const { tracker } = setupTracker(); + const spyPoller = jest.spyOn(Poller.prototype, 'start'); + + tracker.start(); + expect(spyPoller).toHaveBeenCalledTimes(1); + }); + + it('stops polling when calling stop', async () => { + const { tracker } = setupTracker(); + const spyPoller = jest.spyOn(Poller.prototype, 'stop'); + + tracker.start(); + tracker.stop(); + expect(spyPoller).toHaveBeenCalledTimes(1); + }); + + it('is not tracking if none accounts have been registered', async () => { + const { tracker, mockUpdateTransactions } = setupTracker(); + + tracker.start(); + await tracker.updateTransactions(); + + expect(mockUpdateTransactions).not.toHaveBeenCalled(); + }); + + it('tracks account transactions', async () => { + const { tracker, mockUpdateTransactions } = setupTracker(); + + tracker.start(); + tracker.track(mockSolanaAccount.id, 0); + await tracker.updateTransactions(); + + expect(mockUpdateTransactions).toHaveBeenCalledWith(mockSolanaAccount.id, { + limit: 10, + }); + }); + + it('untracks account transactions', async () => { + const { tracker, mockUpdateTransactions } = setupTracker(); + + tracker.start(); + tracker.track(mockSolanaAccount.id, 0); + await tracker.updateTransactions(); + expect(mockUpdateTransactions).toHaveBeenCalledWith(mockSolanaAccount.id, { + limit: 10, + }); + + tracker.untrack(mockSolanaAccount.id); + await tracker.updateTransactions(); + expect(mockUpdateTransactions).toHaveBeenCalledTimes(1); + }); + + it('tracks account after being registered', async () => { + const { tracker } = setupTracker(); + + tracker.start(); + tracker.track(mockSolanaAccount.id, 0); + expect(tracker.isTracked(mockSolanaAccount.id)).toBe(true); + }); + + it('does not track account if not registered', async () => { + const { tracker } = setupTracker(); + + tracker.start(); + expect(tracker.isTracked(mockSolanaAccount.id)).toBe(false); + }); + + it('does not refresh transactions if they are considered up-to-date', async () => { + const { tracker, mockUpdateTransactions } = setupTracker(); + + const blockTime = 400; + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date(MOCK_TIMESTAMP).getTime()); + + tracker.start(); + tracker.track(mockSolanaAccount.id, blockTime); + await tracker.updateTransactions(); + expect(mockUpdateTransactions).toHaveBeenCalledTimes(1); + + await tracker.updateTransactions(); + expect(mockUpdateTransactions).toHaveBeenCalledTimes(1); + + jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date(MOCK_TIMESTAMP + blockTime).getTime()); + + await tracker.updateTransactions(); + expect(mockUpdateTransactions).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts new file mode 100644 index 0000000000..bf6a3dc6a5 --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts @@ -0,0 +1,134 @@ +import { Poller } from './Poller'; +import type { PaginationOptions } from './MultichainTransactionsController'; + +type TransactionInfo = { + lastUpdated: number; + blockTime: number; + pagination: PaginationOptions; +}; + +// Every 5s in milliseconds. +const TRANSACTIONS_TRACKING_INTERVAL = 5 * 1000; + +export class MultichainTransactionsTracker { + readonly #poller: Poller; + + readonly #updateTransactions: ( + accountId: string, + pagination: PaginationOptions, + ) => Promise; + + #transactions: Record = {}; + + constructor( + updateTransactionsCallback: ( + accountId: string, + pagination: PaginationOptions, + ) => Promise, + ) { + this.#updateTransactions = updateTransactionsCallback; + + this.#poller = new Poller(async () => { + await this.updateTransactions(); + }, TRANSACTIONS_TRACKING_INTERVAL); + } + + /** + * Starts the tracking process. + */ + start(): void { + this.#poller.start(); + } + + /** + * Stops the tracking process. + */ + stop(): void { + this.#poller.stop(); + } + + /** + * Checks if an account ID is being tracked. + * + * @param accountId - The account ID. + * @returns True if the account is being tracked, false otherwise. + */ + isTracked(accountId: string) { + return accountId in this.#transactions; + } + + /** + * Asserts that an account ID is being tracked. + * + * @param accountId - The account ID. + * @throws If the account ID is not being tracked. + */ + assertBeingTracked(accountId: string) { + if (!this.isTracked(accountId)) { + throw new Error(`Account is not being tracked: ${accountId}`); + } + } + + /** + * Starts tracking a new account ID. This method has no effect on already tracked + * accounts. + * + * @param accountId - The account ID. + * @param blockTime - The block time (used when refreshing the account transactions). + * @param pagination - Options for paginating transaction results. Defaults to { limit: 10 }. + */ + track( + accountId: string, + blockTime: number, + pagination: PaginationOptions = { limit: 10 }, + ) { + if (!this.isTracked(accountId)) { + this.#transactions[accountId] = { + lastUpdated: 0, + blockTime, + pagination, + }; + } + } + + /** + * Stops tracking a tracked account ID. + * + * @param accountId - The account ID. + * @throws If the account ID is not being tracked. + */ + untrack(accountId: string) { + this.assertBeingTracked(accountId); + delete this.#transactions[accountId]; + } + + /** + * Update the transactions for a tracked account ID. + * + * @param accountId - The account ID. + * @throws If the account ID is not being tracked. + */ + async updateTransactionsForAccount(accountId: string) { + this.assertBeingTracked(accountId); + + const info = this.#transactions[accountId]; + const isOutdated = Date.now() - info.lastUpdated >= info.blockTime; + const hasNoTransactionsYet = info.lastUpdated === 0; + + if (hasNoTransactionsYet || isOutdated) { + await this.#updateTransactions(accountId, info.pagination); + this.#transactions[accountId].lastUpdated = Date.now(); + } + } + + /** + * Update the transactions of all tracked accounts + */ + async updateTransactions() { + await Promise.allSettled( + Object.keys(this.#transactions).map(async (accountId) => { + await this.updateTransactionsForAccount(accountId); + }), + ); + } +} diff --git a/packages/multichain-transactions-controller/src/Poller.ts b/packages/multichain-transactions-controller/src/Poller.ts new file mode 100644 index 0000000000..166014a5f3 --- /dev/null +++ b/packages/multichain-transactions-controller/src/Poller.ts @@ -0,0 +1,28 @@ +export class Poller { + readonly #interval: number; + + readonly #callback: () => void; + + #handle: NodeJS.Timeout | undefined = undefined; + + constructor(callback: () => void, interval: number) { + this.#interval = interval; + this.#callback = callback; + } + + start() { + if (this.#handle) { + return; + } + + this.#handle = setInterval(this.#callback, this.#interval); + } + + stop() { + if (!this.#handle) { + return; + } + clearInterval(this.#handle); + this.#handle = undefined; + } +} diff --git a/packages/multichain-transactions-controller/src/constants.ts b/packages/multichain-transactions-controller/src/constants.ts new file mode 100644 index 0000000000..8b5c477cfe --- /dev/null +++ b/packages/multichain-transactions-controller/src/constants.ts @@ -0,0 +1,45 @@ +import { BtcAccountType, SolAccountType } from '@metamask/keyring-api'; + +/** + * The network identifiers for supported networks in CAIP-2 format. + * Note: This is a temporary workaround until we have a more robust + * solution for network identifiers. + */ +export enum MultichainNetworks { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93', + BitcoinTestnet = 'bip122:000000000933ea01ad0ee984209779ba', + Solana = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + SolanaDevnet = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + SolanaTestnet = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', +} + +export enum MultichainNativeAssets { + Bitcoin = `${MultichainNetworks.Bitcoin}/slip44:0`, + BitcoinTestnet = `${MultichainNetworks.BitcoinTestnet}/slip44:0`, + Solana = `${MultichainNetworks.Solana}/slip44:501`, + SolanaDevnet = `${MultichainNetworks.SolanaDevnet}/slip44:501`, + SolanaTestnet = `${MultichainNetworks.SolanaTestnet}/slip44:501`, +} + +const BITCOIN_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds +const SOLANA_TRANSACTIONS_UPDATE_TIME = 7000; // 7 seconds +const BTC_TRANSACTIONS_UPDATE_TIME = BITCOIN_AVG_BLOCK_TIME / 2; + +export const TRANSACTIONS_CHECK_INTERVALS = { + // NOTE: We set an interval of half the average block time for bitcoin + // to mitigate when our interval is de-synchronized with the actual block time. + [BtcAccountType.P2wpkh]: BTC_TRANSACTIONS_UPDATE_TIME, + [SolAccountType.DataAccount]: SOLANA_TRANSACTIONS_UPDATE_TIME, +}; + +/** + * Maps network identifiers to their corresponding native asset types. + * Each network is mapped to an array containing its native asset for consistency. + */ +export const NETWORK_ASSETS_MAP: Record = { + [MultichainNetworks.Solana]: [MultichainNativeAssets.Solana], + [MultichainNetworks.SolanaTestnet]: [MultichainNativeAssets.SolanaTestnet], + [MultichainNetworks.SolanaDevnet]: [MultichainNativeAssets.SolanaDevnet], + [MultichainNetworks.Bitcoin]: [MultichainNativeAssets.Bitcoin], + [MultichainNetworks.BitcoinTestnet]: [MultichainNativeAssets.BitcoinTestnet], +}; diff --git a/packages/multichain-transactions-controller/src/index.ts b/packages/multichain-transactions-controller/src/index.ts new file mode 100644 index 0000000000..4605ca2cba --- /dev/null +++ b/packages/multichain-transactions-controller/src/index.ts @@ -0,0 +1,12 @@ +export { MultichainTransactionsController } from './MultichainTransactionsController'; +export type { + MultichainTransactionsControllerState, + PaginationOptions, + TransactionStateEntry, +} from './MultichainTransactionsController'; +export { + TRANSACTIONS_CHECK_INTERVALS, + NETWORK_ASSETS_MAP, + MultichainNetworks, + MultichainNativeAssets, +} from './constants'; diff --git a/packages/multichain-transactions-controller/tsconfig.build.json b/packages/multichain-transactions-controller/tsconfig.build.json new file mode 100644 index 0000000000..048cb0e3be --- /dev/null +++ b/packages/multichain-transactions-controller/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../keyring-controller/tsconfig.build.json" }, + { "path": "../polling-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-transactions-controller/tsconfig.json b/packages/multichain-transactions-controller/tsconfig.json new file mode 100644 index 0000000000..e0331deb7e --- /dev/null +++ b/packages/multichain-transactions-controller/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../accounts-controller" }, + { "path": "../base-controller" }, + { "path": "../keyring-controller" }, + { "path": "../polling-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/multichain-transactions-controller/typedoc.json b/packages/multichain-transactions-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/multichain-transactions-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 2f941fc522..1989ab50c1 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/user-operation-controller": "team-confirmations" + "metamask/user-operation-controller": "team-confirmations", + "metamask/multichain-transactions-controller": "team-sol,team-accounts" } diff --git a/tsconfig.build.json b/tsconfig.build.json index b104bf1b42..b557b4fd95 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,9 @@ { "path": "./packages/keyring-controller/tsconfig.build.json" }, { "path": "./packages/logging-controller/tsconfig.build.json" }, { "path": "./packages/message-manager/tsconfig.build.json" }, + { + "path": "./packages/multichain-transactions-controller/tsconfig.build.json" + }, { "path": "./packages/multichain/tsconfig.build.json" }, { "path": "./packages/name-controller/tsconfig.build.json" }, { "path": "./packages/network-controller/tsconfig.build.json" }, @@ -26,6 +29,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" }, @@ -34,8 +38,7 @@ { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/signature-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..15074dfa7a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,12 +24,14 @@ { "path": "./packages/keyring-controller" }, { "path": "./packages/message-manager" }, { "path": "./packages/multichain" }, + { "path": "./packages/multichain-transactions-controller" }, { "path": "./packages/name-controller" }, { "path": "./packages/network-controller" }, { "path": "./packages/notification-services-controller" }, { "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" }, @@ -38,8 +40,7 @@ { "path": "./packages/selected-network-controller" }, { "path": "./packages/signature-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 f88317fe24..df90b4304c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3308,6 +3308,38 @@ __metadata: languageName: node linkType: hard +"@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller": + version: 0.0.0-use.local + resolution: "@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller" + dependencies: + "@metamask/accounts-controller": "npm:^21.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^7.1.1" + "@metamask/keyring-api": "npm:^13.0.0" + "@metamask/keyring-controller": "npm:^19.0.3" + "@metamask/keyring-internal-api": "npm:^2.0.0" + "@metamask/keyring-snap-client": "npm:^2.0.0" + "@metamask/polling-controller": "npm:^12.0.2" + "@metamask/snaps-controllers": "npm:^9.10.0" + "@metamask/snaps-sdk": "npm:^6.7.0" + "@metamask/snaps-utils": "npm:^8.3.0" + "@metamask/utils": "npm:^11.0.1" + "@types/jest": "npm:^27.4.1" + "@types/uuid": "npm:^8.3.0" + deepmerge: "npm:^4.2.2" + immer: "npm:^9.0.6" + 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" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/accounts-controller": ^21.0.0 + "@metamask/keyring-controller": ^19.0.0 + languageName: unknown + linkType: soft + "@metamask/multichain@workspace:packages/multichain": version: 0.0.0-use.local resolution: "@metamask/multichain@workspace:packages/multichain"