diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index 5a499ce7dc6d..6097120905e0 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -736,9 +736,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Bitcoin-Aktivität wird nicht unterstützt" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index d930bafaa3d7..f5bb26c0e029 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -736,9 +736,6 @@ "message": "Δ", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Δεν υποστηρίζεται η δραστηριότητα στα Bitcoins" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0ef2ec82406f..1d1bbb3aeefb 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -759,9 +759,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Bitcoin activity is not supported" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index f66111064a90..c5b8cd08b495 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -732,9 +732,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Bitcoin activity is not supported" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index bfb53061f73b..e4cadcfc46d3 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -736,9 +736,6 @@ "message": "mm", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "La actividad de Bitcoin no es compatible" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index a86d465b786d..4198881f08ef 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -736,9 +736,6 @@ "message": "Mrd", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "L’activité Bitcoin n’est pas prise en charge" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 144db40e41a3..7c89938f0abb 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -736,9 +736,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Bitcoin गतिविधि सपोर्ट नहीं करती है" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 44b2bf804727..3fa41694f633 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -736,9 +736,6 @@ "message": "M", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Aktivitas Bitcoin tidak didukung" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 70d6da31301e..ebd965b54eb8 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -736,9 +736,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "ビットコインアクティビティはサポートされていません" - }, "bitcoinSupportSectionTitle": { "message": "ビットコイン" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index 6978164f82f4..f0fa6f81235c 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -736,9 +736,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "비트코인 활동이 지원되지 않습니다." - }, "bitcoinSupportSectionTitle": { "message": "비트코인" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 7819be595e94..aedd3bc75bcc 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -736,9 +736,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Atividades com Bitcoin não são suportadas" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 5e281f34ac05..11f1d0bdd709 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -736,9 +736,6 @@ "message": "Б", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Активность биткойна не поддерживается" - }, "bitcoinSupportSectionTitle": { "message": "Биткойн" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index 5a6bce602970..04ab4627e3c0 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -736,9 +736,6 @@ "message": "B", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Ang aktibidad ng Bitcoin ay hindi suportado" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index 046905392710..6b5ed5177809 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -736,9 +736,6 @@ "message": "MR", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Bitcoin aktivitesi desteklenmiyor" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index a6baaff586aa..5e3768acb6db 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -736,9 +736,6 @@ "message": "Tỷ", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "Hoạt động Bitcoin không được hỗ trợ" - }, "bitcoinSupportSectionTitle": { "message": "Bitcoin" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 5472a92a5660..cc4aa4627002 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -736,9 +736,6 @@ "message": "十亿", "description": "Shortened form of 'billion'" }, - "bitcoinActivityNotSupported": { - "message": "比特币活动不受支持" - }, "bitcoinSupportSectionTitle": { "message": "比特币" }, diff --git a/app/scripts/lib/transaction/MultichainTransactionsController.test.ts b/app/scripts/lib/transaction/MultichainTransactionsController.test.ts new file mode 100644 index 000000000000..8ae4e73a054b --- /dev/null +++ b/app/scripts/lib/transaction/MultichainTransactionsController.test.ts @@ -0,0 +1,335 @@ +import { ControllerMessenger } from '@metamask/base-controller'; +import type { Transaction, CaipAssetType } from '@metamask/keyring-api'; +import { type InternalAccount } from '@metamask/keyring-internal-api'; +import { + BtcAccountType, + BtcMethod, + EthAccountType, + EthMethod, +} from '@metamask/keyring-api'; +import { KeyringTypes } from '@metamask/keyring-controller'; +import { v4 as uuidv4 } from 'uuid'; +import { + MultichainTransactionsController, + AllowedActions, + AllowedEvents, + MultichainTransactionsControllerState, + defaultState, + 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, +}; + +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, +}; + +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).toEqual({ nonEvmTransactions: {} }); + }); + + it('starts tracking when calling start', async () => { + const spyTracker = jest.spyOn( + MultichainTransactionsTracker.prototype, + 'start', + ); + const { controller } = setupController(); + await controller.start(); + expect(spyTracker).toHaveBeenCalledTimes(1); + }); + + it('stops tracking when calling stop', async () => { + const spyTracker = jest.spyOn( + MultichainTransactionsTracker.prototype, + 'stop', + ); + const { controller } = setupController(); + await controller.start(); + await controller.stop(); + expect(spyTracker).toHaveBeenCalledTimes(1); + }); + + it('update transactions when calling updateTransactions', async () => { + const { controller } = setupController(); + + await controller.updateTransactions(); + + expect(controller.state).toEqual({ + 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).toEqual({ + 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).toEqual({ + 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).toEqual({ + 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]).toEqual({ + 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 expect(controller.updateTransactions()).resolves.not.toThrow(); + expect(controller.state.nonEvmTransactions).toEqual({}); + }); +}); diff --git a/app/scripts/lib/transaction/MultichainTransactionsController.ts b/app/scripts/lib/transaction/MultichainTransactionsController.ts new file mode 100644 index 000000000000..2549356d6366 --- /dev/null +++ b/app/scripts/lib/transaction/MultichainTransactionsController.ts @@ -0,0 +1,392 @@ +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { + BaseController, + type ControllerGetStateAction, + type ControllerStateChangeEvent, + type RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { + BtcAccountType, + isEvmAccountType, + SolAccountType, + 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 { Draft } from 'immer'; +import type { + AccountsControllerAccountAddedEvent, + AccountsControllerAccountRemovedEvent, + AccountsControllerListMultichainAccountsAction, +} from '@metamask/accounts-controller'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; +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, + }, +}; + +const BTC_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds +const SOLANA_TRANSACTIONS_UPDATE_TIME = 7000; // 7 seconds +const BTC_TRANSACTIONS_UPDATE_TIME = BTC_AVG_BLOCK_TIME / 2; + +const TRANSACTIONS_CHECK_INTERVALS = { + [BtcAccountType.P2wpkh]: BTC_TRANSACTIONS_UPDATE_TIME, + [SolAccountType.DataAccount]: SOLANA_TRANSACTIONS_UPDATE_TIME, +}; + +/** + * 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 +> { + #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: InternalAccount) => 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) => { + if (tx.chain.startsWith(MultichainNetworks.SOLANA)) { + return tx.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. + */ + async start(): Promise { + this.#tracker.start(); + } + + /** + * Stops the polling process. + */ + async stop(): Promise { + 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/app/scripts/lib/transaction/MultichainTransactionsTracker.test.ts b/app/scripts/lib/transaction/MultichainTransactionsTracker.test.ts new file mode 100644 index 000000000000..3edb99479d93 --- /dev/null +++ b/app/scripts/lib/transaction/MultichainTransactionsTracker.test.ts @@ -0,0 +1,121 @@ +import { SolAccountType } from '@metamask/keyring-api'; +import { createMockInternalAccount } from '../../../../test/jest/mocks'; +import { Poller } from '../accounts/Poller'; +import { MultichainTransactionsTracker } from './MultichainTransactionsTracker'; + +const MOCK_TIMESTAMP = 1733788800; + +const mockSolanaAccount = createMockInternalAccount({ + address: '', + name: 'Solana Account', + type: SolAccountType.DataAccount, + snapOptions: { + id: 'mock-solana-snap', + name: 'mock-solana-snap', + enabled: true, + }, +}); + +function setupTracker() { + 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'); + + await tracker.start(); + expect(spyPoller).toHaveBeenCalledTimes(1); + }); + + it('stops polling when calling stop', async () => { + const { tracker } = setupTracker(); + const spyPoller = jest.spyOn(Poller.prototype, 'stop'); + + await tracker.start(); + await tracker.stop(); + expect(spyPoller).toHaveBeenCalledTimes(1); + }); + + it('is not tracking if none accounts have been registered', async () => { + const { tracker, mockUpdateTransactions } = setupTracker(); + + await tracker.start(); + await tracker.updateTransactions(); + + expect(mockUpdateTransactions).not.toHaveBeenCalled(); + }); + + it('tracks account transactions', async () => { + const { tracker, mockUpdateTransactions } = setupTracker(); + + await 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(); + + await 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(); + + await 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(); + + await 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()); + + await 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/app/scripts/lib/transaction/MultichainTransactionsTracker.ts b/app/scripts/lib/transaction/MultichainTransactionsTracker.ts new file mode 100644 index 000000000000..97fcd825e5dc --- /dev/null +++ b/app/scripts/lib/transaction/MultichainTransactionsTracker.ts @@ -0,0 +1,134 @@ +import { Poller } from '../accounts/Poller'; +import { PaginationOptions } from './MultichainTransactionsController'; + +type TransactionInfo = { + lastUpdated: number; + blockTime: number; + pagination: PaginationOptions; +}; + +// Every 5s in milliseconds. +const TRANSACTIONS_TRACKING_INTERVAL = 5 * 1000; + +export class MultichainTransactionsTracker { + #poller: Poller; + + #updateTransactions: ( + accountId: string, + pagination: PaginationOptions, + ) => Promise; + + #transactions: Record = {}; + + constructor( + updateTransactionsCallback: ( + accountId: string, + pagination: PaginationOptions, + ) => Promise, + ) { + this.#updateTransactions = updateTransactionsCallback; + + this.#poller = new Poller(() => { + this.updateTransactions(); + }, TRANSACTIONS_TRACKING_INTERVAL); + } + + /** + * Starts the tracking process. + */ + async start(): Promise { + this.#poller.start(); + } + + /** + * Stops the tracking process. + */ + async stop(): Promise { + 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/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 0e568424a69c..b73e59aaeaa6 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -281,6 +281,9 @@ import { submitSmartTransactionHook } from './lib/transaction/smart-transactions import { keyringSnapPermissionsBuilder } from './lib/snap-keyring/keyring-snaps-permissions'; ///: END:ONLY_INCLUDE_IF +///: BEGIN:ONLY_INCLUDE_IF(build-flask) +import { MultichainTransactionsController } from './lib/transaction/MultichainTransactionsController'; +///: END:ONLY_INCLUDE_IF import { SnapsNameProvider } from './lib/SnapsNameProvider'; import { AddressBookPetnamesBridge } from './lib/AddressBookPetnamesBridge'; import { AccountIdentitiesPetnamesBridge } from './lib/AccountIdentitiesPetnamesBridge'; @@ -988,6 +991,27 @@ export default class MetamaskController extends EventEmitter { state: initState.AnnouncementController, }); + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + const multichainTransactionsControllerMessenger = + this.controllerMessenger.getRestricted({ + name: 'MultichainTransactionsController', + allowedEvents: [ + 'AccountsController:accountAdded', + 'AccountsController:accountRemoved', + ], + allowedActions: [ + 'AccountsController:listMultichainAccounts', + 'SnapController:handleRequest', + ], + }); + + this.multichainTransactionsController = + new MultichainTransactionsController({ + messenger: multichainTransactionsControllerMessenger, + state: initState.MultichainTransactionsController, + }); + ///: END:ONLY_INCLUDE_IF + const networkOrderMessenger = this.controllerMessenger.getRestricted({ name: 'NetworkOrderController', allowedEvents: ['NetworkController:stateChange'], @@ -2563,6 +2587,9 @@ export default class MetamaskController extends EventEmitter { AppStateController: this.appStateController, AppMetadataController: this.appMetadataController, MultichainBalancesController: this.multichainBalancesController, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + MultichainTransactionsController: this.multichainTransactionsController, + ///: END:ONLY_INCLUDE_IF TransactionController: this.txController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, @@ -2619,6 +2646,9 @@ export default class MetamaskController extends EventEmitter { AppStateController: this.appStateController, AppMetadataController: this.appMetadataController, MultichainBalancesController: this.multichainBalancesController, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + MultichainTransactionsController: this.multichainTransactionsController, + ///: END:ONLY_INCLUDE_IF NetworkController: this.networkController, KeyringController: this.keyringController, PreferencesController: this.preferencesController, @@ -3304,6 +3334,11 @@ export default class MetamaskController extends EventEmitter { this.multichainBalancesController.start(); this.multichainBalancesController.updateBalances(); + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + this.multichainTransactionsController.start(); + this.multichainTransactionsController.updateTransactions(); + ///: END:ONLY_INCLUDE_IF + this.controllerMessenger.subscribe( 'CurrencyRateController:stateChange', ({ currentCurrency }) => { @@ -4433,6 +4468,11 @@ export default class MetamaskController extends EventEmitter { multichainUpdateBalances: () => this.multichainBalancesController.updateBalances(), + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + // MultichainTransactionsController + multichainUpdateTransactions: () => + this.multichainTransactionsController.updateTransactions(), + ///: END:ONLY_INCLUDE_IF // Transaction Decode decodeTransactionData: (request) => decodeTransactionData({ diff --git a/shared/types/multichain/transactions.ts b/shared/types/multichain/transactions.ts new file mode 100644 index 000000000000..8ea03f549349 --- /dev/null +++ b/shared/types/multichain/transactions.ts @@ -0,0 +1,15 @@ +// TODO: Use types from core once this controller has been moved there +import { Transaction } from '@metamask/keyring-api'; + +/** + * State used by the MultichainTransactionsController to cache account transactions. + */ +export type MultichainTransactionsControllerState = { + nonEvmTransactions: { + [accountId: string]: { + transactions: Transaction[]; + next: string | null; + lastUpdated: number; + }; + }; +}; diff --git a/test/e2e/flask/btc/btc-send.spec.ts b/test/e2e/flask/btc/btc-send.spec.ts index 71d58c5c41ea..54f8841a45b2 100644 --- a/test/e2e/flask/btc/btc-send.spec.ts +++ b/test/e2e/flask/btc/btc-send.spec.ts @@ -87,12 +87,6 @@ describe('BTC Account - Send', function (this: Suite) { assert.equal(await snapSendButton.isEnabled(), true); await snapSendButton.click(); - // Check that we are selecting the "Activity tab" right after the send. - await driver.waitForSelector({ - tag: 'div', - text: 'Bitcoin activity is not supported', - }); - const transaction = await getTransactionRequest(mockServer); assert(transaction !== undefined); }, @@ -149,12 +143,6 @@ describe('BTC Account - Send', function (this: Suite) { assert.equal(await snapSendButton.isEnabled(), true); await snapSendButton.click(); - // Check that we are selecting the "Activity tab" right after the send. - await driver.waitForSelector({ - tag: 'div', - text: 'Bitcoin activity is not supported', - }); - const transaction = await getTransactionRequest(mockServer); assert(transaction !== undefined); }, diff --git a/test/jest/mocks.ts b/test/jest/mocks.ts index 9fdc6538d9d6..f97937de118e 100644 --- a/test/jest/mocks.ts +++ b/test/jest/mocks.ts @@ -4,6 +4,8 @@ import { BtcMethod, BtcAccountType, isEvmAccountType, + SolAccountType, + SolMethod, } from '@metamask/keyring-api'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { KeyringTypes } from '@metamask/keyring-controller'; @@ -223,6 +225,9 @@ export function createMockInternalAccount({ case BtcAccountType.P2wpkh: methods = [BtcMethod.SendBitcoin]; break; + case SolAccountType.DataAccount: + methods = [SolMethod.SendAndConfirmTransaction]; + break; default: throw new Error(`Unknown account type: ${type}`); } diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js index 8ea78dee1948..180bde6de5b5 100644 --- a/ui/components/app/transaction-list/transaction-list.component.js +++ b/ui/components/app/transaction-list/transaction-list.component.js @@ -3,12 +3,18 @@ import React, { useState, useCallback, Fragment, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) useContext, + ///: END:ONLY_INCLUDE_IF useEffect, } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { TransactionType } from '@metamask/transaction-controller'; +///: BEGIN:ONLY_INCLUDE_IF(build-flask) +import { capitalize } from 'lodash'; +import { isEvmAccountType } from '@metamask/keyring-api'; +///: END:ONLY_INCLUDE_IF import { nonceSortedCompletedTransactionsSelector, nonceSortedPendingTransactionsSelector, @@ -29,13 +35,24 @@ import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils' import { Box, Button, + Text, + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) ButtonSize, ButtonVariant, IconName, - Text, + BadgeWrapper, + AvatarNetwork, + ///: END:ONLY_INCLUDE_IF } from '../../component-library'; +///: BEGIN:ONLY_INCLUDE_IF(build-flask) +import TransactionIcon from '../transaction-icon'; +import TransactionStatusLabel from '../transaction-status-label/transaction-status-label'; +///: END:ONLY_INCLUDE_IF + import { + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) Display, + ///: END:ONLY_INCLUDE_IF TextColor, TextVariant, } from '../../../helpers/constants/design-system'; @@ -48,12 +65,25 @@ import { } from '../../multichain/ramps-card/ramps-card'; import { getIsNativeTokenBuyable } from '../../../ducks/ramps'; ///: END:ONLY_INCLUDE_IF -import { isSelectedInternalAccountBtc } from '../../../selectors/accounts'; +///: BEGIN:ONLY_INCLUDE_IF(build-flask) import { openBlockExplorer } from '../../multichain/menu-items/view-explorer-menu-item'; import { getMultichainAccountUrl } from '../../../helpers/utils/multichain/blockExplorer'; +import { ActivityListItem } from '../../multichain'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; -import { getMultichainNetwork } from '../../../selectors/multichain'; +import { + getMultichainNetwork, + getSelectedAccountMultichainTransactions, +} from '../../../selectors/multichain'; +import { isSelectedInternalAccountSolana } from '../../../selectors/accounts'; +import { + MULTICHAIN_PROVIDER_CONFIGS, + MultichainNetworks, + SOLANA_TOKEN_IMAGE_URL, + BITCOIN_TOKEN_IMAGE_URL, +} from '../../../../shared/constants/multichain/networks'; +///: END:ONLY_INCLUDE_IF + import { endTrace, TraceName } from '../../../../shared/lib/trace'; const PAGE_INCREMENT = 10; @@ -107,15 +137,19 @@ const getFilteredTransactionGroups = ( return transactionGroups; }; -const groupTransactionsByDate = (transactionGroups) => { +const groupTransactionsByDate = ( + transactionGroups, + getTransactionTimestamp, +) => { const groupedTransactions = []; + if (!transactionGroups) { + return groupedTransactions; + } + transactionGroups.forEach((transactionGroup) => { - const date = formatDateWithYearContext( - transactionGroup.primaryTransaction.time, - 'MMM d, y', - 'MMM d', - ); + const timestamp = getTransactionTimestamp(transactionGroup); + const date = formatDateWithYearContext(timestamp, 'MMM d, y', 'MMM d'); const existingGroup = groupedTransactions.find( (group) => group.date === date, @@ -126,7 +160,7 @@ const groupTransactionsByDate = (transactionGroups) => { } else { groupedTransactions.push({ date, - dateMillis: transactionGroup.primaryTransaction.time, + dateMillis: timestamp, transactionGroups: [transactionGroup], }); } @@ -136,6 +170,20 @@ const groupTransactionsByDate = (transactionGroups) => { return groupedTransactions; }; +const groupEvmTransactionsByDate = (transactionGroups) => + groupTransactionsByDate( + transactionGroups, + (transactionGroup) => transactionGroup.primaryTransaction.time, + ); + +///: BEGIN:ONLY_INCLUDE_IF(build-flask) +const groupNonEvmTransactionsByDate = (nonEvmTransactions) => + groupTransactionsByDate( + nonEvmTransactions?.transactions, + (transaction) => transaction.timestamp * 1000, + ); +///: END:ONLY_INCLUDE_IF + export default function TransactionList({ hideTokenTransactions, tokenAddress, @@ -144,6 +192,14 @@ export default function TransactionList({ const [limit, setLimit] = useState(PAGE_INCREMENT); const t = useI18nContext(); + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + const nonEvmTransactions = useSelector( + getSelectedAccountMultichainTransactions, + ); + + const isSolanaAccount = useSelector(isSelectedInternalAccountSolana); + ///: END:ONLY_INCLUDE_IF + const unfilteredPendingTransactions = useSelector( nonceSortedPendingTransactionsSelector, ); @@ -183,7 +239,7 @@ export default function TransactionList({ const pendingTransactions = useMemo( () => - groupTransactionsByDate( + groupEvmTransactionsByDate( getFilteredTransactionGroups( unfilteredPendingTransactions, hideTokenTransactions, @@ -201,7 +257,7 @@ export default function TransactionList({ const completedTransactions = useMemo( () => - groupTransactionsByDate( + groupEvmTransactionsByDate( getFilteredTransactionGroups( unfilteredCompletedTransactions, hideTokenTransactions, @@ -258,45 +314,142 @@ export default function TransactionList({ const dateGroupsWithTransactionGroups = (dateGroup) => dateGroup.transactionGroups.length > 0; - // Check if the current account is a bitcoin account - const isBitcoinAccount = useSelector(isSelectedInternalAccountBtc); - const trackEvent = useContext(MetaMetricsContext); - useEffect(() => { endTrace({ name: TraceName.AccountOverviewActivityTab }); }, []); + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) const multichainNetwork = useMultichainSelector( getMultichainNetwork, selectedAccount, ); - if (isBitcoinAccount) { + + const trackEvent = useContext(MetaMetricsContext); + + if (!isEvmAccountType(selectedAccount.type)) { const addressLink = getMultichainAccountUrl( selectedAccount.address, multichainNetwork, ); + const metricsLocation = 'Activity Tab'; return ( - - {t('bitcoinActivityNotSupported')} - - - + {/* TODO: Non-EVM transactions are not paginated for now. */} + + {nonEvmTransactions.transactions.length > 0 ? ( + + {groupNonEvmTransactionsByDate(nonEvmTransactions).map( + (dateGroup) => ( + + + {dateGroup.date} + + {dateGroup.transactionGroups.map((transaction, index) => ( + + } + display="block" + positionObj={{ right: -4, top: -4 }} + > + + + } + rightContent={ + <> + + {`${transaction.from[0]?.asset?.amount} ${transaction.from[0]?.asset?.unit}`} + + + } + subtitle={ + + } + title={capitalize(transaction.type)} + > + ))} + + ), + )} + + + + + ) : ( + + + {t('noTransactions')} + + + )} ); } + ///: END:ONLY_INCLUDE_IF return ( <> diff --git a/ui/components/app/transaction-list/transaction-list.test.js b/ui/components/app/transaction-list/transaction-list.test.js index 47a7947d3970..1009a43cbd5d 100644 --- a/ui/components/app/transaction-list/transaction-list.test.js +++ b/ui/components/app/transaction-list/transaction-list.test.js @@ -26,6 +26,25 @@ const defaultState = { const btcState = { metamask: { ...mockState.metamask, + nonEvmTransactions: { + [MOCK_ACCOUNT_BIP122_P2WPKH.id]: { + transactions: [ + { + timestamp: 1733736433, + chain: MultichainNetworks.BITCOIN, + status: 'confirmed', + type: 'send', + account: MOCK_ACCOUNT_BIP122_P2WPKH.id, + from: [], + to: [], + fees: [], + events: [], + }, + ], + next: null, + lastUpdated: expect.any(Number), + }, + }, internalAccounts: { ...mockState.metamask.internalAccounts, accounts: { @@ -62,10 +81,16 @@ describe('TransactionList', () => { expect(queryByText('You have no transactions')).toBeNull(); }); - it('renders TransactionList component and shows Bitcoin activity is not supported text', () => { - const { getByText, getByRole } = render(btcState); + it('renders TransactionList component and shows a Bitcoin Tx in the activity list', () => { + const { getByText, getByRole, getByTestId } = render(btcState); + + // The activity list item has a status of "Confirmed" and a type of "Send" + expect(getByText('Confirmed')).toBeInTheDocument(); + expect(getByText('Send')).toBeInTheDocument(); + + // A BTC activity list iteem exists + expect(getByTestId('activity-list-item')).toBeInTheDocument(); - expect(getByText('Bitcoin activity is not supported')).toBeInTheDocument(); const viewOnExplorerBtn = getByRole('button', { name: 'View on block explorer', }); diff --git a/ui/selectors/accounts.ts b/ui/selectors/accounts.ts index 1646c4759552..7515a68e04e5 100644 --- a/ui/selectors/accounts.ts +++ b/ui/selectors/accounts.ts @@ -1,4 +1,8 @@ -import { EthAccountType, BtcAccountType } from '@metamask/keyring-api'; +import { + EthAccountType, + BtcAccountType, + SolAccountType, +} from '@metamask/keyring-api'; import { InternalAccount } from '@metamask/keyring-internal-api'; import { AccountsControllerState } from '@metamask/accounts-controller'; import { @@ -16,6 +20,12 @@ function isBtcAccount(account: InternalAccount) { return Boolean(account && account.type === P2wpkh); } +function isSolanaAccount(account: InternalAccount) { + const { DataAccount } = SolAccountType; + + return Boolean(account && account.type === DataAccount); +} + export function getInternalAccounts(state: AccountsState) { return Object.values(state.metamask.internalAccounts.accounts); } @@ -36,6 +46,10 @@ export function isSelectedInternalAccountBtc(state: AccountsState) { return isBtcAccount(getSelectedInternalAccount(state)); } +export function isSelectedInternalAccountSolana(state: AccountsState) { + return isSolanaAccount(getSelectedInternalAccount(state)); +} + function hasCreatedBtcAccount( state: AccountsState, isAddressCallback: (address: string) => boolean, diff --git a/ui/selectors/multichain.test.ts b/ui/selectors/multichain.test.ts index 5b4b07a93a57..7572b0a9ccf6 100644 --- a/ui/selectors/multichain.test.ts +++ b/ui/selectors/multichain.test.ts @@ -83,6 +83,13 @@ function getEvmState(chainId: Hex = CHAIN_IDS.MAINNET): TestState { }, }, }, + nonEvmTransactions: { + [MOCK_ACCOUNT_BIP122_P2WPKH.id]: { + transactions: [], + next: null, + lastUpdated: 0, + }, + }, balances: { [MOCK_ACCOUNT_BIP122_P2WPKH.id]: { [MultichainNativeAssets.BITCOIN]: { diff --git a/ui/selectors/multichain.ts b/ui/selectors/multichain.ts index 3fdea53414e9..0593f4dad671 100644 --- a/ui/selectors/multichain.ts +++ b/ui/selectors/multichain.ts @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { isEvmAccountType } from '@metamask/keyring-api'; +import { isEvmAccountType, Transaction } from '@metamask/keyring-api'; import { InternalAccount } from '@metamask/keyring-internal-api'; import type { RatesControllerState } from '@metamask/assets-controllers'; import { CaipChainId, Hex, KnownCaipNamespace } from '@metamask/utils'; @@ -21,6 +21,7 @@ import { // TODO: Remove restricted import // eslint-disable-next-line import/no-restricted-paths import { BalancesControllerState } from '../../app/scripts/lib/accounts/BalancesController'; +import { MultichainTransactionsControllerState } from '../../shared/types/multichain/transactions'; import { MULTICHAIN_NETWORK_TO_ASSET_TYPES } from '../../shared/constants/multichain/assets'; import { CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, @@ -51,9 +52,14 @@ export type BalancesState = { metamask: BalancesControllerState; }; +export type TransactionsState = { + metamask: MultichainTransactionsControllerState; +}; + export type MultichainState = AccountsState & RatesState & BalancesState & + TransactionsState & NetworkState; // TODO: Remove after updating to @metamask/network-controller 20.0.0 @@ -358,6 +364,26 @@ export function getMultichainBalances( return state.metamask.balances; } +export function getMultichainTransactions( + state: MultichainState, +): TransactionsState['metamask']['nonEvmTransactions'] { + return state.metamask.nonEvmTransactions; +} + +export function getSelectedAccountMultichainTransactions( + state: MultichainState, +): + | { transactions: Transaction[]; next: string | null; lastUpdated: number } + | undefined { + const selectedAccount = getSelectedInternalAccount(state); + + if (isEvmAccountType(selectedAccount.type)) { + return undefined; + } + + return state.metamask.nonEvmTransactions[selectedAccount.id]; +} + export const getMultichainCoinRates = (state: MultichainState) => { return state.metamask.rates; };