diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2537c6e0ed9..1554a37ec5e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,7 @@ ## Accounts Team /packages/accounts-controller @MetaMask/accounts-engineers /packages/keyring-controller @MetaMask/accounts-engineers +/packages/multichain-transactions-controller @MetaMask/accounts-engineers ## Assets Team /packages/assets-controllers @MetaMask/metamask-assets @@ -103,5 +104,7 @@ /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/multichain-transactions-controller/package.json @MetaMask/accounts-engineers @MetaMask/wallet-framework-engineers +/packages/multichain-transactions-controller/CHANGELOG.md @MetaMask/accounts-engineers @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 3aa258a4a55..3035e645a09 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/token-search-discovery-controller`](packages/token-search-discovery-controller) @@ -81,6 +83,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"]); diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /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 00000000000..7d002dced3a --- /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 00000000000..5ae3333ab00 --- /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 00000000000..a6493bc83d5 --- /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: 95, + functions: 97, + lines: 97, + statements: 97, + }, + }, +}); diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json new file mode 100644 index 00000000000..8bd0a8f81b1 --- /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/keyring-internal-api": "^2.0.0", + "@metamask/keyring-snap-client": "^2.0.0", + "@metamask/polling-controller": "^12.0.2", + "@metamask/snaps-controllers": "^9.10.0", + "@metamask/snaps-sdk": "^6.7.0", + "@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.1", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/keyring-controller": "^19.0.3", + "@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/snaps-controllers": "^9.10.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 00000000000..a85e24db68a --- /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, + getDefaultMultichainTransactionsControllerState, + 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 = getDefaultMultichainTransactionsControllerState(), + 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 00000000000..683067bfaaf --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -0,0 +1,390 @@ +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import { + BaseController, + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { isEvmAccountType, type Transaction } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { KeyringClient } from '@metamask/keyring-snap-client'; +import type { HandleSnapRequest } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { Draft } from 'immer'; + +import { MultichainNetwork, TRANSACTIONS_CHECK_INTERVALS } from './constants'; +import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; + +const controllerName = 'MultichainTransactionsController'; + +/** + * PaginationOptions + * + * Represents options for paginating transaction results + * limit - The maximum number of transactions to return + * next - The cursor for the next page of transactions, or null if there is no next page + */ +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; + }; +}; + +/** + * Constructs the default {@link MultichainTransactionsController} state. + * + * @returns The default {@link MultichainTransactionsController} state. + */ +export function getDefaultMultichainTransactionsControllerState(): MultichainTransactionsControllerState { + return { + 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?: Partial; + }) { + super({ + messenger, + name: controllerName, + metadata: multichainTransactionsControllerMetadata, + state: { + ...getDefaultMultichainTransactionsControllerState(), + ...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 MultichainNetwork; + if (chain.startsWith(MultichainNetwork.Solana)) { + return chain === MultichainNetwork.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 00000000000..d469e19add5 --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.test.ts @@ -0,0 +1,186 @@ +import { SolAccountType, SolMethod } from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { v4 as uuidv4 } from 'uuid'; + +import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; + +const mockStart = jest.fn(); +const mockStop = jest.fn(); + +jest.mock('./Poller', () => ({ + __esModule: true, + Poller: class { + readonly #callback: () => void; + + constructor(callback: () => void) { + this.#callback = callback; + } + + start = () => { + mockStart(); + this.#callback(); + }; + + stop = mockStop; + }, +})); + +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(); + + tracker.start(); + expect(mockStart).toHaveBeenCalledTimes(1); + }); + + it('stops polling when calling stop', async () => { + const { tracker } = setupTracker(); + + tracker.start(); + tracker.stop(); + expect(mockStop).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); + }); + + it('calls updateTransactions when polling', async () => { + const { tracker } = setupTracker(); + const spyUpdateTransactions = jest.spyOn(tracker, 'updateTransactions'); + + tracker.start(); + jest.runOnlyPendingTimers(); + + expect(spyUpdateTransactions).toHaveBeenCalled(); + }); + + it('throws when asserting an untracked account', () => { + const { tracker } = setupTracker(); + const untrackerId = 'untracked-account'; + + expect(() => tracker.assertBeingTracked(untrackerId)).toThrow( + `Account is not being tracked: ${untrackerId}`, + ); + }); + + it('does not throw when asserting a tracked account', () => { + const { tracker } = setupTracker(); + const trackerId = 'tracked-account'; + + tracker.track(trackerId, 1000); + expect(() => tracker.assertBeingTracked(trackerId)).not.toThrow(); + }); +}); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts new file mode 100644 index 00000000000..29de3cb64f7 --- /dev/null +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsTracker.ts @@ -0,0 +1,143 @@ +import type { PaginationOptions } from './MultichainTransactionsController'; +import { Poller } from './Poller'; + +type TransactionInfo = { + lastUpdated: number; + blockTime: number; + pagination: PaginationOptions; +}; + +// Every 5s in milliseconds. +const TRANSACTIONS_TRACKING_INTERVAL = 5 * 1000; + +/** + * This class manages the tracking and periodic updating of transactions for multiple blockchain accounts. + * + * The tracker uses a polling mechanism to periodically check and update transactions + * for all tracked accounts, respecting each account's specific block time to determine + * when updates are needed. + */ +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(() => { + this.updateTransactions().catch((error) => { + console.error('Failed to update transactions:', error); + }); + }, 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.test.ts b/packages/multichain-transactions-controller/src/Poller.test.ts new file mode 100644 index 00000000000..ce82b7e5add --- /dev/null +++ b/packages/multichain-transactions-controller/src/Poller.test.ts @@ -0,0 +1,85 @@ +import { Poller } from './Poller'; + +describe('Poller', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('executes callback after starting', async () => { + const mockCallback = jest.fn(); + const poller = new Poller(mockCallback, 1000); + + poller.start(); + + expect(mockCallback).not.toHaveBeenCalled(); + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(0); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it('executes callback multiple times with interval', async () => { + const mockCallback = jest.fn(); + const poller = new Poller(mockCallback, 1000); + + poller.start(); + + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(0); + expect(mockCallback).toHaveBeenCalledTimes(1); + + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(0); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + it('stops executing after stop is called', async () => { + const mockCallback = jest.fn(); + const poller = new Poller(mockCallback, 1000); + + poller.start(); + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(0); + expect(mockCallback).toHaveBeenCalledTimes(1); + + poller.stop(); + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(0); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it('handles async callbacks', async () => { + const mockCallback = jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + const poller = new Poller(mockCallback, 1000); + + poller.start(); + + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(500); // Advance time to complete the async operation + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + it('does nothing when start is called multiple times', async () => { + const mockCallback = jest.fn(); + const poller = new Poller(mockCallback, 1000); + + poller.start(); + poller.start(); // Second call should do nothing + + jest.runOnlyPendingTimers(); + jest.advanceTimersByTime(0); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it('does nothing when stop is called before start', () => { + const mockCallback = jest.fn(); + const poller = new Poller(mockCallback, 1000); + + poller.stop(); + expect(mockCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/multichain-transactions-controller/src/Poller.ts b/packages/multichain-transactions-controller/src/Poller.ts new file mode 100644 index 00000000000..166014a5f3f --- /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 00000000000..167331528f4 --- /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 MultichainNetwork { + Bitcoin = 'bip122:000000000019d6689c085ae165831e93', + BitcoinTestnet = 'bip122:000000000933ea01ad0ee984209779ba', + Solana = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + SolanaDevnet = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', + SolanaTestnet = 'solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z', +} + +export enum MultichainNativeAsset { + Bitcoin = `${MultichainNetwork.Bitcoin}/slip44:0`, + BitcoinTestnet = `${MultichainNetwork.BitcoinTestnet}/slip44:0`, + Solana = `${MultichainNetwork.Solana}/slip44:501`, + SolanaDevnet = `${MultichainNetwork.SolanaDevnet}/slip44:501`, + SolanaTestnet = `${MultichainNetwork.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 = { + [MultichainNetwork.Solana]: [MultichainNativeAsset.Solana], + [MultichainNetwork.SolanaTestnet]: [MultichainNativeAsset.SolanaTestnet], + [MultichainNetwork.SolanaDevnet]: [MultichainNativeAsset.SolanaDevnet], + [MultichainNetwork.Bitcoin]: [MultichainNativeAsset.Bitcoin], + [MultichainNetwork.BitcoinTestnet]: [MultichainNativeAsset.BitcoinTestnet], +}; diff --git a/packages/multichain-transactions-controller/src/index.ts b/packages/multichain-transactions-controller/src/index.ts new file mode 100644 index 00000000000..cc3b01064a5 --- /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, + MultichainNetwork, + MultichainNativeAsset, +} 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 00000000000..048cb0e3bef --- /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 00000000000..e0331deb7e0 --- /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 00000000000..c9da015dbf8 --- /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 31af66f7aed..8041bd3ee28 100644 --- a/teams.json +++ b/teams.json @@ -33,6 +33,7 @@ "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" + "metamask/user-operation-controller": "team-confirmations", + "metamask/multichain-transactions-controller": "team-sol,team-accounts", + "metamask/token-search-discovery-controller": "team-portfolio" } diff --git a/tsconfig.build.json b/tsconfig.build.json index a5ac14a68ca..6017c40c24b 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" }, diff --git a/tsconfig.json b/tsconfig.json index d1e3046fd56..f77d0f4f623 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ { "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" }, diff --git a/yarn.lock b/yarn.lock index 50adbb9b74d..c1491f70314 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3309,6 +3309,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.1" + "@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/snaps-controllers": ^9.10.0 + languageName: unknown + linkType: soft + "@metamask/multichain@workspace:packages/multichain": version: 0.0.0-use.local resolution: "@metamask/multichain@workspace:packages/multichain"