From fe761639c8250cbd2ec556592dc8d8d6a96970fd Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Wed, 1 Jul 2026 11:32:27 +0200 Subject: [PATCH] feat(core-backend): update AccountActivity for multichain --- eslint-suppressions.json | 2 +- packages/core-backend/CHANGELOG.md | 16 +- packages/core-backend/package.json | 2 +- ...ountActivityService-method-action-types.ts | 45 +- .../src/ws/AccountActivityService.test.ts | 1067 ++++++++--------- .../src/ws/AccountActivityService.ts | 239 ++-- packages/core-backend/tsconfig.build.json | 2 +- packages/core-backend/tsconfig.json | 2 +- yarn.lock | 2 +- 9 files changed, 732 insertions(+), 645 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bb20541c19..3e42d4a280 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -843,7 +843,7 @@ }, "packages/core-backend/src/ws/AccountActivityService.test.ts": { "no-restricted-syntax": { - "count": 2 + "count": 1 } }, "packages/core-backend/src/ws/AccountActivityService.ts": { diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index bacdc818ee..869d9d4cf4 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,13 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountActivityService.subscribeMany({ addresses: string[] })` and `AccountActivityService.unsubscribeMany({ addresses: string[] })` to subscribe/unsubscribe from account activity for multiple CAIP-10 account addresses in a single call + - `subscribeMany` is idempotent: addresses that already have an active subscription are skipped, so multiple consumers can call it safely. + - The existing single-address `subscribe({ address })` and `unsubscribe({ address })` methods are unchanged (they now delegate to the `*Many` variants). + ### Changed +- **BREAKING:** `AccountActivityService` now auto-subscribes based on the selected account group rather than the single selected account + - The service now requires the `AccountTreeController:getAccountsFromSelectedAccountGroup` action and listens to the `AccountTreeController:selectedAccountGroupChange` event instead of the `AccountsController:selectedAccountChange` event and `AccountsController:getSelectedAccount` action. Consumers must delegate these to the `AccountActivityServiceMessenger`, and no longer need to delegate any `AccountsController` actions or events. + - Auto-subscription is currently restricted to EVM (`eip155`) accounts in the group; Solana, Tron and Bitcoin are not yet supported by the backend. + - EVM addresses in auto-subscription channels are now lowercased so they match the channels produced by other consumers (idempotency). - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.1` to `^12.3.0` ([#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) - Bump `@metamask/profile-sync-controller` from `^28.1.1` to `^28.2.0` ([#9119](https://github.com/MetaMask/core/pull/9119)) - Bump `@metamask/keyring-controller` from `^27.0.0` to `^27.1.0` ([#9129](https://github.com/MetaMask/core/pull/9129)) -- Bump `@metamask/accounts-controller` from `^39.0.1` to `^39.0.3` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9231](https://github.com/MetaMask/core/pull/9231)) + +### Removed + +- Remove the direct dependency on `@metamask/accounts-controller` + - `AccountActivityService` no longer calls `AccountsController:getSelectedAccount`; it relies solely on `AccountTreeController:getAccountsFromSelectedAccountGroup` for auto-subscription. ## [6.3.3] diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index 0f8ee00cb0..b54f8abf08 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -53,7 +53,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^39.0.3", + "@metamask/account-tree-controller": "^7.5.3", "@metamask/controller-utils": "^12.3.0", "@metamask/keyring-controller": "^27.1.0", "@metamask/messenger": "^1.2.0", diff --git a/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts b/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts index b9e97818b9..b9a63fac74 100644 --- a/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts +++ b/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts @@ -6,8 +6,12 @@ import type { AccountActivityService } from './AccountActivityService'; /** - * Subscribe to account activity (transactions and balance updates) - * Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + * Subscribe to account activity (transactions and balance updates) for a single + * account. Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or + * "solana:0:ABC123..."). + * + * The call is idempotent: if the address already has an active subscription it + * is skipped, so multiple callers can use it safely. * * @param subscription - Account subscription configuration with address */ @@ -17,8 +21,25 @@ export type AccountActivityServiceSubscribeAction = { }; /** - * Unsubscribe from account activity for specified address - * Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + * Subscribe to account activity (transactions and balance updates) for one or + * more accounts. Each address should be in CAIP-10 format (e.g., + * "eip155:0:0x1234..." or "solana:0:ABC123..."). + * + * The call is idempotent: addresses that already have an active subscription are + * skipped, so multiple consumers (e.g. data sources and the auto-subscription) + * can call this safely. + * + * @param subscription - Account subscription configuration with addresses + */ +export type AccountActivityServiceSubscribeManyAction = { + type: `AccountActivityService:subscribeMany`; + handler: AccountActivityService['subscribeMany']; +}; + +/** + * Unsubscribe from account activity for the specified account. + * Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or + * "solana:0:ABC123..."). * * @param subscription - Account subscription configuration with address to unsubscribe */ @@ -27,9 +48,23 @@ export type AccountActivityServiceUnsubscribeAction = { handler: AccountActivityService['unsubscribe']; }; +/** + * Unsubscribe from account activity for the specified accounts. + * Each address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or + * "solana:0:ABC123..."). + * + * @param subscription - Account subscription configuration with addresses to unsubscribe + */ +export type AccountActivityServiceUnsubscribeManyAction = { + type: `AccountActivityService:unsubscribeMany`; + handler: AccountActivityService['unsubscribeMany']; +}; + /** * Union of all AccountActivityService action types. */ export type AccountActivityServiceMethodActions = | AccountActivityServiceSubscribeAction - | AccountActivityServiceUnsubscribeAction; + | AccountActivityServiceSubscribeManyAction + | AccountActivityServiceUnsubscribeAction + | AccountActivityServiceUnsubscribeManyAction; diff --git a/packages/core-backend/src/ws/AccountActivityService.test.ts b/packages/core-backend/src/ws/AccountActivityService.test.ts index ac5ea12dbc..b7b10b1653 100644 --- a/packages/core-backend/src/ws/AccountActivityService.test.ts +++ b/packages/core-backend/src/ws/AccountActivityService.test.ts @@ -5,7 +5,6 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; -import type { Hex } from '@metamask/utils'; import { flushPromises } from '../../../../tests/helpers'; import type { Transaction, BalanceUpdate } from '../types'; @@ -40,25 +39,6 @@ const completeAsyncOperations = async (timeoutMs = 0): Promise => { await flushPromises(); }; -// Mock function to create test accounts -const createMockInternalAccount = (options: { - address: string; -}): InternalAccount => ({ - address: options.address.toLowerCase() as Hex, - id: `test-account-${options.address.slice(-6)}`, - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { - type: 'HD Key Tree', - }, - }, - options: {}, - methods: [], - type: 'eip155:eoa', - scopes: ['eip155:1'], // Required scopes property -}); - /** * Creates and returns a root messenger for testing * @@ -70,29 +50,55 @@ function getRootMessenger(): RootMessenger { }); } +type Mocks = { + connect: jest.Mock; + subscribe: jest.Mock; + channelHasSubscription: jest.Mock; + getSubscriptionsByChannel: jest.Mock; + findSubscriptionsByChannelPrefix: jest.Mock; + forceReconnection: jest.Mock; + addChannelCallback: jest.Mock; + removeChannelCallback: jest.Mock; + getAccountsFromSelectedAccountGroup: jest.Mock; +}; + /** - * Creates a real messenger with registered mock actions for testing - * Each call creates a completely independent messenger to ensure test isolation + * Build a minimal InternalAccount for tests. + * + * @param overrides - Partial account fields to override. + * @returns A mock InternalAccount. + */ +function createMockInternalAccount( + overrides?: Partial, +): InternalAccount { + return { + id: 'mock-account-id', + address: '0x1234567890123456789012345678901234567890', + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: ['eip155:0'], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + lastSelected: Date.now(), + }, + ...overrides, + } as InternalAccount; +} + +/** + * Creates a real messenger with registered mock actions for testing. + * Each call creates a completely independent messenger to ensure test isolation. * * @returns Object containing the messenger and mock action functions */ const getMessenger = (): { rootMessenger: RootMessenger; messenger: AccountActivityServiceMessenger; - mocks: { - getSelectedAccount: jest.Mock; - connect: jest.Mock; - subscribe: jest.Mock; - channelHasSubscription: jest.Mock; - getSubscriptionsByChannel: jest.Mock; - findSubscriptionsByChannelPrefix: jest.Mock; - forceReconnection: jest.Mock; - addChannelCallback: jest.Mock; - removeChannelCallback: jest.Mock; - }; + mocks: Mocks; } => { - // Use any types for the root messenger to avoid complex type constraints in tests - // Create a unique root messenger for each test const rootMessenger = getRootMessenger(); const messenger: AccountActivityServiceMessenger = new Messenger< 'AccountActivityService', @@ -106,7 +112,7 @@ const getMessenger = (): { rootMessenger.delegate({ actions: [ - 'AccountsController:getSelectedAccount', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', 'BackendWebSocketService:connect', 'BackendWebSocketService:forceReconnection', 'BackendWebSocketService:subscribe', @@ -118,176 +124,63 @@ const getMessenger = (): { 'BackendWebSocketService:removeChannelCallback', ], events: [ - 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', 'BackendWebSocketService:connectionStateChanged', ], messenger, }); - // Create mock action handlers - const mockGetSelectedAccount = jest.fn(); - const mockConnect = jest.fn(); - const mockForceReconnection = jest.fn(); - const mockSubscribe = jest.fn(); - const mockChannelHasSubscription = jest.fn(); - const mockGetSubscriptionsByChannel = jest.fn(); - const mockFindSubscriptionsByChannelPrefix = jest.fn().mockReturnValue([]); - const mockAddChannelCallback = jest.fn(); - const mockRemoveChannelCallback = jest.fn(); - - // Register all action handlers + const mocks: Mocks = { + connect: jest.fn(), + forceReconnection: jest.fn(), + subscribe: jest.fn(), + channelHasSubscription: jest.fn().mockReturnValue(false), + getSubscriptionsByChannel: jest.fn().mockReturnValue([]), + findSubscriptionsByChannelPrefix: jest.fn().mockReturnValue([]), + addChannelCallback: jest.fn(), + removeChannelCallback: jest.fn(), + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([]), + }; + rootMessenger.registerActionHandler( - 'AccountsController:getSelectedAccount', - mockGetSelectedAccount, + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + mocks.getAccountsFromSelectedAccountGroup, ); + rootMessenger.registerActionHandler( 'BackendWebSocketService:connect', - mockConnect, + mocks.connect, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:forceReconnection', - mockForceReconnection, + mocks.forceReconnection, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:subscribe', - mockSubscribe, + mocks.subscribe, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:channelHasSubscription', - mockChannelHasSubscription, + mocks.channelHasSubscription, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:getSubscriptionsByChannel', - mockGetSubscriptionsByChannel, + mocks.getSubscriptionsByChannel, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:findSubscriptionsByChannelPrefix', - mockFindSubscriptionsByChannelPrefix, + mocks.findSubscriptionsByChannelPrefix, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:addChannelCallback', - mockAddChannelCallback, + mocks.addChannelCallback, ); rootMessenger.registerActionHandler( 'BackendWebSocketService:removeChannelCallback', - mockRemoveChannelCallback, + mocks.removeChannelCallback, ); - return { - rootMessenger, - messenger, - mocks: { - getSelectedAccount: mockGetSelectedAccount, - connect: mockConnect, - forceReconnection: mockForceReconnection, - subscribe: mockSubscribe, - channelHasSubscription: mockChannelHasSubscription, - getSubscriptionsByChannel: mockGetSubscriptionsByChannel, - findSubscriptionsByChannelPrefix: mockFindSubscriptionsByChannelPrefix, - addChannelCallback: mockAddChannelCallback, - removeChannelCallback: mockRemoveChannelCallback, - }, - }; -}; - -/** - * Creates an independent AccountActivityService with its own messenger for tests that need isolation - * This is the primary way to create service instances in tests to ensure proper isolation - * - * @param options - Optional configuration for service creation - * @param options.subscriptionNamespace - Custom subscription namespace - * @returns Object containing the service, messenger, root messenger, and mock functions - */ -const createIndependentService = (options?: { - subscriptionNamespace?: string; -}): { - service: AccountActivityService; - messenger: AccountActivityServiceMessenger; - rootMessenger: RootMessenger; - mocks: { - getSelectedAccount: jest.Mock; - connect: jest.Mock; - subscribe: jest.Mock; - channelHasSubscription: jest.Mock; - getSubscriptionsByChannel: jest.Mock; - findSubscriptionsByChannelPrefix: jest.Mock; - forceReconnection: jest.Mock; - addChannelCallback: jest.Mock; - removeChannelCallback: jest.Mock; - }; - destroy: () => void; -} => { - const { subscriptionNamespace } = options ?? {}; - - const messengerSetup = getMessenger(); - - const service = new AccountActivityService({ - messenger: messengerSetup.messenger, - subscriptionNamespace, - }); - - return { - service, - messenger: messengerSetup.messenger, - rootMessenger: messengerSetup.rootMessenger, - mocks: messengerSetup.mocks, - // Convenience cleanup method - destroy: (): void => { - service.destroy(); - }, - }; -}; - -/** - * Creates a service setup for testing that includes common test account setup - * - * @param accountAddress - Address for the test account - * @returns Object containing the service, messenger, mocks, and mock account - */ -const createServiceWithTestAccount = ( - accountAddress: string = '0x1234567890123456789012345678901234567890', -): { - service: AccountActivityService; - messenger: AccountActivityServiceMessenger; - rootMessenger: RootMessenger; - mocks: { - getSelectedAccount: jest.Mock; - connect: jest.Mock; - subscribe: jest.Mock; - channelHasSubscription: jest.Mock; - getSubscriptionsByChannel: jest.Mock; - findSubscriptionsByChannelPrefix: jest.Mock; - forceReconnection: jest.Mock; - addChannelCallback: jest.Mock; - removeChannelCallback: jest.Mock; - }; - destroy: () => void; - mockSelectedAccount: InternalAccount; -} => { - const serviceSetup = createIndependentService(); - - // Create mock selected account - const mockSelectedAccount: InternalAccount = { - id: 'test-account-1', - address: accountAddress as Hex, - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, - options: {}, - methods: [], - scopes: ['eip155:1'], - type: 'eip155:eoa', - }; - - // Setup account-related mock implementations - serviceSetup.mocks.getSelectedAccount.mockReturnValue(mockSelectedAccount); - - return { - ...serviceSetup, - mockSelectedAccount, - }; + return { rootMessenger, messenger, mocks }; }; /** @@ -295,7 +188,6 @@ const createServiceWithTestAccount = ( */ type WithServiceOptions = { subscriptionNamespace?: string; - accountAddress?: string; }; /** @@ -305,19 +197,7 @@ type WithServiceCallback = (payload: { service: AccountActivityService; messenger: AccountActivityServiceMessenger; rootMessenger: RootMessenger; - mocks: { - getSelectedAccount: jest.Mock; - connect: jest.Mock; - forceReconnection: jest.Mock; - subscribe: jest.Mock; - channelHasSubscription: jest.Mock; - getSubscriptionsByChannel: jest.Mock; - findSubscriptionsByChannelPrefix: jest.Mock; - addChannelCallback: jest.Mock; - removeChannelCallback: jest.Mock; - }; - mockSelectedAccount?: InternalAccount; - destroy: () => void; + mocks: Mocks; }) => Promise | ReturnValue; /** @@ -353,10 +233,8 @@ const getSystemNotificationCallback = (mocks: { * created ahead of time and then safely destroyed afterward as needed. * * @param args - Either a function, or an options bag + a function. The options - * bag contains arguments for the service constructor. All constructor - * arguments are optional and will be filled in with defaults as needed - * (including `messenger`). The function is called with the new - * service, root messenger, and service messenger. + * bag contains arguments for the service constructor. The function is called + * with the new service, root messenger, and service messenger. * @returns The same return value as the given function. */ async function withService( @@ -364,35 +242,20 @@ async function withService( | [WithServiceCallback] | [WithServiceOptions, WithServiceCallback] ): Promise { - const [{ subscriptionNamespace, accountAddress }, testFunction] = - args.length === 2 - ? args - : [ - { - subscriptionNamespace: undefined, - accountAddress: undefined, - }, - args[0], - ]; + const [{ subscriptionNamespace }, testFunction] = + args.length === 2 ? args : [{ subscriptionNamespace: undefined }, args[0]]; + + const { messenger, rootMessenger, mocks } = getMessenger(); - const setup = accountAddress - ? createServiceWithTestAccount(accountAddress) - : createIndependentService({ subscriptionNamespace }); + const service = new AccountActivityService({ + messenger, + subscriptionNamespace, + }); try { - return await testFunction({ - service: setup.service, - messenger: setup.messenger, - rootMessenger: setup.rootMessenger, - mocks: setup.mocks, - mockSelectedAccount: - 'mockSelectedAccount' in setup - ? (setup.mockSelectedAccount as InternalAccount) - : undefined, - destroy: setup.destroy, - }); + return await testFunction({ service, messenger, rootMessenger, mocks }); } finally { - setup.destroy(); + service.destroy(); } } @@ -401,12 +264,12 @@ describe('AccountActivityService', () => { // CONSTRUCTOR TESTS // ============================================================================= describe('constructor', () => { - it('should create AccountActivityService with comprehensive initialization and verify service properties', async () => { + it('creates AccountActivityService and registers the system notification callback', async () => { await withService(async ({ service, messenger, mocks }) => { expect(service).toBeInstanceOf(AccountActivityService); expect(service.name).toBe('AccountActivityService'); - // Status changed event is only published when WebSocket connects + // Status changed event is only published when WebSocket disconnects const statusChangedEventListener = jest.fn(); messenger.subscribe( 'AccountActivityService:statusChanged', @@ -436,190 +299,353 @@ describe('AccountActivityService', () => { }, ); }); + + it('does not subscribe to any account activity on construction', async () => { + await withService(async ({ mocks }) => { + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); }); // ============================================================================= - // SUBSCRIBE ACCOUNTS TESTS + // SUBSCRIBE TESTS // ============================================================================= describe('subscribe', () => { const mockSubscription: SubscriptionOptions = { - address: 'eip155:1:0x1234567890123456789012345678901234567890', + address: 'eip155:0:0x1234567890123456789012345678901234567890', }; - it('should handle account activity messages by processing transactions and balance updates and publishing events', async () => { - await withService( - { accountAddress: '0x1234567890123456789012345678901234567890' }, - async ({ service, mocks, messenger, mockSelectedAccount }) => { - let capturedCallback: ( - notification: ServerNotificationMessage, - ) => void = jest.fn(); - - // Mock the subscribe call to capture the callback - mocks.subscribe.mockImplementation((options) => { - // Capture the callback from the subscription options - capturedCallback = options.callback; - return Promise.resolve({ - subscriptionId: 'sub-123', - unsubscribe: () => Promise.resolve(), - }); - }); - mocks.getSelectedAccount.mockReturnValue(mockSelectedAccount); - - await service.subscribe(mockSubscription); - - // Simulate receiving account activity message - const activityMessage: AccountActivityMessage = { - address: '0x1234567890123456789012345678901234567890', - tx: { - id: '0xabc123', - chain: 'eip155:1', - status: 'confirmed', - timestamp: Date.now(), - from: '0x1234567890123456789012345678901234567890', - to: '0x9876543210987654321098765432109876543210', - }, - updates: [ - { - asset: { - fungible: true, - type: 'eip155:1/slip44:60', - unit: 'ETH', - decimals: 18, - }, - postBalance: { - amount: '1000000000000000000', // 1 ETH - }, - transfers: [ - { - from: '0x1234567890123456789012345678901234567890', - to: '0x9876543210987654321098765432109876543210', - amount: '500000000000000000', // 0.5 ETH - }, - ], - }, - ], - }; + it('subscribes to a single channel for the address', async () => { + await withService(async ({ service, mocks }) => { + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'sub-123', + unsubscribe: jest.fn(), + }); + + await service.subscribe(mockSubscription); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channelType: 'account-activity.v1', + channels: [ + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + ], + callback: expect.any(Function), + }); + }); + }); + + it('does not call subscribe when the address is already subscribed', async () => { + await withService(async ({ service, mocks }) => { + mocks.channelHasSubscription.mockReturnValue(true); - const notificationMessage = { - event: 'notification', + await service.subscribe(mockSubscription); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('handles account activity messages by publishing transaction and balance events', async () => { + await withService(async ({ service, mocks, messenger }) => { + let capturedCallback: ( + notification: ServerNotificationMessage, + ) => void = jest.fn(); + + mocks.subscribe.mockImplementation((options) => { + capturedCallback = options.callback; + return Promise.resolve({ subscriptionId: 'sub-123', - channel: - 'account-activity.v1.eip155:1:0x1234567890123456789012345678901234567890', - data: activityMessage, - timestamp: 1760344704595, - }; + unsubscribe: () => Promise.resolve(), + }); + }); - // Subscribe to events to verify they are published - const receivedTransactionEvents: Transaction[] = []; - const receivedBalanceEvents: { - address: string; - chain: string; - updates: BalanceUpdate[]; - }[] = []; + await service.subscribe(mockSubscription); - messenger.subscribe( - 'AccountActivityService:transactionUpdated', - (data) => { - receivedTransactionEvents.push(data); + const activityMessage: AccountActivityMessage = { + address: '0x1234567890123456789012345678901234567890', + tx: { + id: '0xabc123', + chain: 'eip155:1', + status: 'confirmed', + timestamp: Date.now(), + from: '0x1234567890123456789012345678901234567890', + to: '0x9876543210987654321098765432109876543210', + }, + updates: [ + { + asset: { + fungible: true, + type: 'eip155:1/slip44:60', + unit: 'ETH', + decimals: 18, + }, + postBalance: { + amount: '1000000000000000000', + }, + transfers: [ + { + from: '0x1234567890123456789012345678901234567890', + to: '0x9876543210987654321098765432109876543210', + amount: '500000000000000000', + }, + ], }, - ); + ], + }; + + const notificationMessage = { + event: 'notification', + subscriptionId: 'sub-123', + channel: + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + data: activityMessage, + timestamp: 1760344704595, + }; + + const receivedTransactionEvents: Transaction[] = []; + const receivedBalanceEvents: { + address: string; + chain: string; + updates: BalanceUpdate[]; + }[] = []; - messenger.subscribe( - 'AccountActivityService:balanceUpdated', - (data) => { - receivedBalanceEvents.push(data); - }, - ); + messenger.subscribe( + 'AccountActivityService:transactionUpdated', + (data) => { + receivedTransactionEvents.push(data); + }, + ); - // Call the captured callback - capturedCallback(notificationMessage); + messenger.subscribe('AccountActivityService:balanceUpdated', (data) => { + receivedBalanceEvents.push(data); + }); - // Should receive transaction and balance events - expect(receivedTransactionEvents).toHaveLength(1); - expect(receivedTransactionEvents[0]).toStrictEqual( - activityMessage.tx, - ); + capturedCallback(notificationMessage); - expect(receivedBalanceEvents).toHaveLength(1); - expect(receivedBalanceEvents[0]).toStrictEqual({ - address: '0x1234567890123456789012345678901234567890', - chain: 'eip155:1', - updates: activityMessage.updates, - }); - }, - ); + expect(receivedTransactionEvents).toHaveLength(1); + expect(receivedTransactionEvents[0]).toStrictEqual(activityMessage.tx); + + expect(receivedBalanceEvents).toHaveLength(1); + expect(receivedBalanceEvents[0]).toStrictEqual({ + address: '0x1234567890123456789012345678901234567890', + chain: 'eip155:1', + updates: activityMessage.updates, + }); + }); }); - it('should handle subscription failure by calling forceReconnection', async () => { + it('handles subscription failure by calling forceReconnection', async () => { await withService(async ({ service, mocks }) => { - // Mock subscribe to fail mocks.subscribe.mockRejectedValue(new Error('Subscription failed')); - // Should handle subscription failure gracefully - should not throw const result = await service.subscribe({ address: '0x123abc' }); expect(result).toBeUndefined(); - // Verify the subscription was attempted expect(mocks.subscribe).toHaveBeenCalledTimes(1); - - // Verify forceReconnection was called (lines 289-290) expect(mocks.forceReconnection).toHaveBeenCalledTimes(1); + expect(mocks.connect).toHaveBeenCalledTimes(1); + }); + }); + }); + + // ============================================================================= + // SUBSCRIBE MANY TESTS + // ============================================================================= + describe('subscribeMany', () => { + it('returns early without connecting when addresses are empty', async () => { + await withService(async ({ service, mocks }) => { + await service.subscribeMany({ addresses: [] }); + + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + + it('subscribes to a single channel per address', async () => { + await withService(async ({ service, mocks }) => { + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'sub-123', + unsubscribe: jest.fn(), + }); + + await service.subscribeMany({ + addresses: [ + 'eip155:0:0x1234567890123456789012345678901234567890', + 'solana:0:ABC123', + ], + }); - // Connect is only called once at the start expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channelType: 'account-activity.v1', + channels: [ + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + 'account-activity.v1.solana:0:ABC123', + ], + callback: expect.any(Function), + }); + }); + }); + + it('is idempotent and skips addresses that already have a subscription', async () => { + await withService(async ({ service, mocks }) => { + // First address already subscribed, second not + mocks.channelHasSubscription.mockImplementation((channel: string) => + channel.includes('0x1234567890123456789012345678901234567890'), + ); + mocks.subscribe.mockResolvedValue({ + subscriptionId: 'sub-123', + unsubscribe: jest.fn(), + }); + + await service.subscribeMany({ + addresses: [ + 'eip155:0:0x1234567890123456789012345678901234567890', + 'solana:0:ABC123', + ], + }); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: ['account-activity.v1.solana:0:ABC123'], + }), + ); + }); + }); + + it('does not call subscribe when all addresses are already subscribed', async () => { + await withService(async ({ service, mocks }) => { + mocks.channelHasSubscription.mockReturnValue(true); + + await service.subscribeMany({ + addresses: [ + 'eip155:0:0x1234567890123456789012345678901234567890', + ], + }); + + expect(mocks.subscribe).not.toHaveBeenCalled(); }); }); }); // ============================================================================= - // UNSUBSCRIBE ACCOUNTS TESTS + // UNSUBSCRIBE TESTS // ============================================================================= describe('unsubscribe', () => { const mockSubscription: SubscriptionOptions = { address: 'eip155:1:0x1234567890123456789012345678901234567890', }; - it('should handle unsubscribe when not subscribed by returning early without errors', async () => { + it('returns without errors when there are no active subscriptions', async () => { await withService(async ({ service, mocks }) => { - // Mock the messenger call to return empty array (no active subscription) mocks.getSubscriptionsByChannel.mockReturnValue([]); - // This should trigger the early return on line 302 await service.unsubscribe(mockSubscription); - // Verify the messenger call was made but early return happened expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledWith( expect.any(String), ); }); }); - it('should handle unsubscribe errors by forcing WebSocket reconnection instead of throwing', async () => { - await withService( - { accountAddress: '0x1234567890123456789012345678901234567890' }, - async ({ service, mocks, mockSelectedAccount }) => { - const error = new Error('Unsubscribe failed'); - const mockUnsubscribeError = jest.fn().mockRejectedValue(error); + it('unsubscribes each matching subscription for the address', async () => { + await withService(async ({ service, mocks }) => { + const unsubscribeA = jest.fn().mockResolvedValue(undefined); + const unsubscribeB = jest.fn().mockResolvedValue(undefined); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: unsubscribeA }, + { unsubscribe: unsubscribeB }, + ]); - // Mock getSubscriptionsByChannel to return subscription with failing unsubscribe function - mocks.getSubscriptionsByChannel.mockReturnValue([ - { - subscriptionId: 'sub-123', - channels: [ - 'account-activity.v1.eip155:1:0x1234567890123456789012345678901234567890', - ], - unsubscribe: mockUnsubscribeError, - }, - ]); - mocks.getSelectedAccount.mockReturnValue(mockSelectedAccount); + await service.unsubscribe(mockSubscription); - // unsubscribe catches errors and forces reconnection instead of throwing - await service.unsubscribe(mockSubscription); + expect(unsubscribeA).toHaveBeenCalledTimes(1); + expect(unsubscribeB).toHaveBeenCalledTimes(1); + }); + }); - // Should have attempted to force reconnection - expect(mocks.forceReconnection).toHaveBeenCalledTimes(1); - }, - ); + it('forces WebSocket reconnection when unsubscribe fails', async () => { + await withService(async ({ service, mocks }) => { + const error = new Error('Unsubscribe failed'); + const mockUnsubscribeError = jest.fn().mockRejectedValue(error); + + mocks.getSubscriptionsByChannel.mockReturnValue([ + { + subscriptionId: 'sub-123', + channels: [ + 'account-activity.v1.eip155:1:0x1234567890123456789012345678901234567890', + ], + unsubscribe: mockUnsubscribeError, + }, + ]); + + await service.unsubscribe(mockSubscription); + + expect(mocks.forceReconnection).toHaveBeenCalledTimes(1); + }); + }); + }); + + // ============================================================================= + // UNSUBSCRIBE MANY TESTS + // ============================================================================= + describe('unsubscribeMany', () => { + it('unsubscribes each matching subscription for every address', async () => { + await withService(async ({ service, mocks }) => { + const unsubscribeA = jest.fn().mockResolvedValue(undefined); + const unsubscribeB = jest.fn().mockResolvedValue(undefined); + mocks.getSubscriptionsByChannel.mockImplementation( + (channel: string) => { + if (channel.includes('ABC123')) { + return [{ unsubscribe: unsubscribeB }]; + } + return [{ unsubscribe: unsubscribeA }]; + }, + ); + + await service.unsubscribeMany({ + addresses: [ + 'eip155:0:0x1234567890123456789012345678901234567890', + 'solana:0:ABC123', + ], + }); + + expect(unsubscribeA).toHaveBeenCalledTimes(1); + expect(unsubscribeB).toHaveBeenCalledTimes(1); + }); + }); + + it('returns without errors when there are no active subscriptions', async () => { + await withService(async ({ service, mocks }) => { + mocks.getSubscriptionsByChannel.mockReturnValue([]); + + await service.unsubscribeMany({ + addresses: [ + 'eip155:0:0x1234567890123456789012345678901234567890', + ], + }); + + expect(mocks.forceReconnection).not.toHaveBeenCalled(); + }); + }); + + it('forces WebSocket reconnection when unsubscribe fails', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsubscribeError = jest + .fn() + .mockRejectedValue(new Error('Unsubscribe failed')); + mocks.getSubscriptionsByChannel.mockReturnValue([ + { unsubscribe: mockUnsubscribeError }, + ]); + + await service.unsubscribeMany({ + addresses: [ + 'eip155:0:0x1234567890123456789012345678901234567890', + ], + }); + + expect(mocks.forceReconnection).toHaveBeenCalledTimes(1); + }); }); }); @@ -628,26 +654,24 @@ describe('AccountActivityService', () => { // ============================================================================= describe('event handlers', () => { describe('handleSystemNotification', () => { - it('should handle invalid system notifications by throwing error for missing required fields', async () => { + it('throws for invalid system notifications missing required fields', async () => { await withService(async ({ mocks }) => { const systemCallback = getSystemNotificationCallback(mocks); - // Simulate invalid system notification const invalidNotification = { event: 'system-notification', channel: 'system', - data: { invalid: true }, // Missing required fields + data: { invalid: true }, timestamp: Date.now(), }; - // The callback should throw an error for invalid data expect(() => systemCallback(invalidNotification)).toThrow( 'Invalid system notification data: missing chainIds or status', ); }); }); - it('should track chains as up and down based on system notifications', async () => { + it('tracks chains as up and down based on system notifications', async () => { await withService(async ({ messenger, mocks }) => { const statusChangedEventListener = jest.fn(); messenger.subscribe( @@ -656,7 +680,6 @@ describe('AccountActivityService', () => { ); const systemCallback = getSystemNotificationCallback(mocks); - // Simulate chains coming up const timestamp1 = 1760344704595; systemCallback({ event: 'system-notification', @@ -674,7 +697,6 @@ describe('AccountActivityService', () => { timestamp: timestamp1, }); - // Simulate one chain going down const timestamp2 = 1760344704696; systemCallback({ event: 'system-notification', @@ -696,7 +718,7 @@ describe('AccountActivityService', () => { }); describe('handleWebSocketStateChange', () => { - it('should handle WebSocket ERROR state by publishing tracked chains as down', async () => { + it('publishes tracked chains as down on DISCONNECTED', async () => { await withService(async ({ messenger, rootMessenger, mocks }) => { const statusChangedEventListener = jest.fn(); messenger.subscribe( @@ -704,9 +726,7 @@ describe('AccountActivityService', () => { statusChangedEventListener, ); - mocks.getSelectedAccount.mockReturnValue(null); - - // First, simulate receiving a system notification with chains up + // First, mark chains up via a system notification const systemCallback = getSystemNotificationCallback(mocks); systemCallback({ event: 'system-notification', @@ -718,7 +738,6 @@ describe('AccountActivityService', () => { timestamp: 1760344704595, }); - // Publish WebSocket DISCONNECTED state event - should flush tracked chains as down rootMessenger.publish( 'BackendWebSocketService:connectionStateChanged', { @@ -733,7 +752,6 @@ describe('AccountActivityService', () => { ); await completeAsyncOperations(100); - // Verify that the DISCONNECTED state triggered the status change for tracked chains expect(statusChangedEventListener).toHaveBeenCalledWith({ chainIds: ['eip155:1', 'eip155:137', 'eip155:56'], status: 'down', @@ -742,17 +760,14 @@ describe('AccountActivityService', () => { }); }); - it('should not publish status change on disconnect when no chains are tracked', async () => { - await withService(async ({ messenger, rootMessenger, mocks }) => { + it('does not publish status change on disconnect when no chains are tracked', async () => { + await withService(async ({ messenger, rootMessenger }) => { const statusChangedEventListener = jest.fn(); messenger.subscribe( 'AccountActivityService:statusChanged', statusChangedEventListener, ); - mocks.getSelectedAccount.mockReturnValue(null); - - // Publish WebSocket DISCONNECTED state event without any tracked chains rootMessenger.publish( 'BackendWebSocketService:connectionStateChanged', { @@ -767,236 +782,206 @@ describe('AccountActivityService', () => { ); await completeAsyncOperations(100); - // Verify that no status change was published since no chains were tracked expect(statusChangedEventListener).not.toHaveBeenCalled(); }); }); - }); - describe('handleSelectedAccountChange', () => { - it('should handle valid account scope conversion by processing account change events without errors', async () => { - await withService(async ({ service, rootMessenger }) => { - // Publish valid account change event - const validAccount = createMockInternalAccount({ - address: '0x123abc', - }); + it('resubscribes to the selected account group when the WebSocket connects', async () => { + await withService(async ({ rootMessenger, mocks }) => { + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount(), + ]); + rootMessenger.publish( - 'AccountsController:selectedAccountChange', - validAccount, + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.CONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, ); + await completeAsyncOperations(); - // Verify service remains functional after processing valid account - expect(service).toBeInstanceOf(AccountActivityService); - expect(service.name).toBe('AccountActivityService'); + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: [ + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + ], + }), + ); }); }); - it('should handle Solana account scope conversion by subscribing to Solana-specific channels', async () => { - await withService(async ({ mocks, rootMessenger }) => { - const solanaAccount = createMockInternalAccount({ - address: 'SolanaAddress123abc', - }); - solanaAccount.scopes = ['solana:mainnet-beta']; + it('does not subscribe on connect when the selected account group is empty', async () => { + await withService(async ({ rootMessenger, mocks }) => { + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); + + rootMessenger.publish( + 'BackendWebSocketService:connectionStateChanged', + { + state: WebSocketState.CONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10000, + reconnectDelay: 500, + maxReconnectDelay: 5000, + requestTimeout: 30000, + }, + ); + await completeAsyncOperations(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + }); + + describe('handleSelectedAccountGroupChange', () => { + it('subscribes only to EVM accounts in the selected group (Solana/Tron not yet supported in prod)', async () => { + await withService(async ({ rootMessenger, mocks }) => { mocks.subscribe.mockResolvedValue({ - subscriptionId: 'solana-sub-123', + subscriptionId: 'sub-123', unsubscribe: jest.fn(), }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount(), + createMockInternalAccount({ + id: 'solana-account-id', + address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + type: 'solana:data-account', + scopes: ['solana:0'], + }), + createMockInternalAccount({ + id: 'tron-account-id', + address: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5Kcbic7', + type: 'tron:eoa', + scopes: ['tron:728126428'], + }), + ]); - // Publish account change event - will be picked up by controller subscription rootMessenger.publish( - 'AccountsController:selectedAccountChange', - solanaAccount, + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:wallet/1', + 'entropy:wallet/0', ); - // Wait for async handler to complete await completeAsyncOperations(); + // Only the EVM channel is subscribed; Solana and Tron are filtered out. expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: expect.arrayContaining([ - expect.stringContaining('solana:0:solanaaddress123abc'), - ]), + channels: [ + 'account-activity.v1.eip155:0:0x1234567890123456789012345678901234567890', + ], }), ); }); }); - it('should handle unknown scope fallback by subscribing to channels with fallback naming convention', async () => { - await withService(async ({ mocks, rootMessenger }) => { - const unknownAccount = createMockInternalAccount({ - address: 'UnknownChainAddress456def', - }); - unknownAccount.scopes = ['bitcoin:mainnet', 'unknown:chain']; + it('does not subscribe when the selected group has no EVM accounts', async () => { + await withService(async ({ rootMessenger, mocks }) => { + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ + id: 'solana-account-id', + address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', + type: 'solana:data-account', + scopes: ['solana:0'], + }), + ]); + + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:wallet/1', + 'entropy:wallet/0', + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + }); + it('lowercases EVM addresses so the channel matches other consumers', async () => { + await withService(async ({ rootMessenger, mocks }) => { mocks.subscribe.mockResolvedValue({ - subscriptionId: 'unknown-sub-456', + subscriptionId: 'sub-123', unsubscribe: jest.fn(), }); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount({ + address: '0xB5D1D02A7ADB2E72A562A1C18C50216159E5C99C', + }), + ]); - // Publish account change event - will be picked up by controller subscription rootMessenger.publish( - 'AccountsController:selectedAccountChange', - unknownAccount, + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:wallet/1', + 'entropy:wallet/0', ); - // Wait for async handler to complete await completeAsyncOperations(); expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: expect.arrayContaining([ - expect.stringContaining('unknownchainaddress456def'), - ]), + channels: [ + 'account-activity.v1.eip155:0:0xb5d1d02a7adb2e72a562a1c18c50216159e5c99c', + ], }), ); }); }); - it('should handle WebSocket connection when no selected account exists by attempting to get selected account', async () => { + it('unsubscribes existing account activity before subscribing to the new group', async () => { await withService(async ({ rootMessenger, mocks }) => { - mocks.getSelectedAccount.mockReturnValue(null); + const unsubscribe = jest.fn().mockResolvedValue(undefined); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([ + { unsubscribe }, + ]); + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([ + createMockInternalAccount(), + ]); - // Publish WebSocket connection event - will be picked up by controller subscription rootMessenger.publish( - 'BackendWebSocketService:connectionStateChanged', - { - state: WebSocketState.CONNECTED, - url: 'ws://test', - reconnectAttempts: 0, - timeout: 10000, - reconnectDelay: 500, - maxReconnectDelay: 5000, - requestTimeout: 30000, - }, + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:wallet/1', + 'entropy:wallet/0', ); - // Wait for async handler to complete await completeAsyncOperations(); - // Should attempt to get selected account even when none exists - expect(mocks.getSelectedAccount).toHaveBeenCalledTimes(1); - expect(mocks.getSelectedAccount).toHaveReturnedWith(null); + expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalledWith( + 'account-activity.v1', + ); + expect(unsubscribe).toHaveBeenCalledTimes(1); }); }); - it('should skip resubscription when already subscribed to new account by not calling subscribe again', async () => { - await withService( - { accountAddress: '0x123abc' }, - async ({ mocks, rootMessenger }) => { - // Set up mocks - mocks.getSelectedAccount.mockReturnValue( - createMockInternalAccount({ address: '0x123abc' }), - ); - mocks.channelHasSubscription.mockReturnValue(true); // Already subscribed - mocks.subscribe.mockResolvedValue({ - unsubscribe: jest.fn(), - }); - - // Create a new account - const newAccount = createMockInternalAccount({ - address: '0x123abc', - }); - - // Publish account change event on root messenger - rootMessenger.publish( - 'AccountsController:selectedAccountChange', - newAccount, - ); - await completeAsyncOperations(); - - // Verify that subscribe was not called since already subscribed - expect(mocks.subscribe).not.toHaveBeenCalled(); - }, - ); - }); + it('does not subscribe when the selected group has no accounts', async () => { + await withService(async ({ rootMessenger, mocks }) => { + mocks.getAccountsFromSelectedAccountGroup.mockReturnValue([]); - it('should handle errors during account change processing by gracefully handling unsubscribe failures', async () => { - await withService( - { accountAddress: '0x123abc' }, - async ({ service, mocks, rootMessenger }) => { - // Set up mocks to cause an error in the unsubscribe step - mocks.getSelectedAccount.mockReturnValue( - createMockInternalAccount({ address: '0x123abc' }), - ); - mocks.channelHasSubscription.mockReturnValue(false); - mocks.findSubscriptionsByChannelPrefix.mockReturnValue([ - { - unsubscribe: jest - .fn() - .mockRejectedValue(new Error('Unsubscribe failed')), - }, - ]); - mocks.subscribe.mockResolvedValue({ - unsubscribe: jest.fn(), - }); - - // Create a new account - const newAccount = createMockInternalAccount({ - address: '0x123abc', - }); - - // Publish account change event on root messenger - rootMessenger.publish( - 'AccountsController:selectedAccountChange', - newAccount, - ); - await completeAsyncOperations(); - - // Verify service handled the error gracefully and remains functional - expect(service).toBeInstanceOf(AccountActivityService); - expect(service.name).toBe('AccountActivityService'); - - // Verify unsubscribe was attempted despite failure - expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalled(); - }, - ); - }); + rootMessenger.publish( + 'AccountTreeController:selectedAccountGroupChange', + 'entropy:wallet/1', + 'entropy:wallet/0', + ); + await completeAsyncOperations(); - it('should handle error for account without address in selectedAccountChange by processing gracefully without throwing', async () => { - await withService(async ({ rootMessenger }) => { - // Test that account without address is handled gracefully when published via messenger - const accountWithoutAddress = createMockInternalAccount({ - address: '', - }); - expect(() => { - rootMessenger.publish( - 'AccountsController:selectedAccountChange', - accountWithoutAddress, - ); - }).not.toThrow(); + expect(mocks.subscribe).not.toHaveBeenCalled(); }); }); + }); + }); - it('should resubscribe to selected account when WebSocket connects', async () => { - await withService( - { accountAddress: '0x123abc' }, - async ({ mocks, rootMessenger }) => { - // Set up mocks - const testAccount = createMockInternalAccount({ - address: '0x123abc', - }); - mocks.getSelectedAccount.mockReturnValue(testAccount); - - // Publish WebSocket connection event - rootMessenger.publish( - 'BackendWebSocketService:connectionStateChanged', - { - state: WebSocketState.CONNECTED, - url: 'ws://test', - reconnectAttempts: 0, - timeout: 10000, - reconnectDelay: 500, - maxReconnectDelay: 5000, - requestTimeout: 30000, - }, - ); - await completeAsyncOperations(); - - // Verify it resubscribed to the selected account - expect(mocks.subscribe).toHaveBeenCalledWith({ - channelType: 'account-activity.v1', - channels: ['account-activity.v1.eip155:0:0x123abc'], - callback: expect.any(Function), - }); - }, + // ============================================================================= + // CLEANUP TESTS + // ============================================================================= + describe('destroy', () => { + it('removes the system notification channel callback', async () => { + await withService(async ({ service, mocks }) => { + service.destroy(); + + expect(mocks.removeChannelCallback).toHaveBeenCalledWith( + 'system-notifications.v1.account-activity.v1', ); }); }); diff --git a/packages/core-backend/src/ws/AccountActivityService.ts b/packages/core-backend/src/ws/AccountActivityService.ts index 88b97f1729..7ee4b6ca69 100644 --- a/packages/core-backend/src/ws/AccountActivityService.ts +++ b/packages/core-backend/src/ws/AccountActivityService.ts @@ -6,9 +6,9 @@ */ import type { - AccountsControllerGetSelectedAccountAction, - AccountsControllerSelectedAccountChangeEvent, -} from '@metamask/accounts-controller'; + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerSelectedAccountGroupChangeEvent, +} from '@metamask/account-tree-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; @@ -48,15 +48,29 @@ const SERVICE_NAME = 'AccountActivityService'; const log = createModuleLogger(projectLogger, SERVICE_NAME); -const MESSENGER_EXPOSED_METHODS = ['subscribe', 'unsubscribe'] as const; +const MESSENGER_EXPOSED_METHODS = [ + 'subscribe', + 'subscribeMany', + 'unsubscribe', + 'unsubscribeMany', +] as const; const SUBSCRIPTION_NAMESPACE = 'account-activity.v1'; /** - * Account subscription options + * Account subscription options for a single account. */ export type SubscriptionOptions = { - address: string; // Should be in CAIP-10 format, e.g., "eip155:0:0x1234..." or "solana:0:ABC123..." + // Address should be in CAIP-10 format, e.g., "eip155:0:0x1234..." or "solana:0:ABC123..." + address: string; +}; + +/** + * Account subscription options for multiple accounts. + */ +export type SubscriptionManyOptions = { + // Each address should be in CAIP-10 format, e.g., "eip155:0:0x1234..." or "solana:0:ABC123..." + addresses: string[]; }; /** @@ -78,7 +92,7 @@ export type AccountActivityServiceActions = AccountActivityServiceMethodActions; // Allowed actions that AccountActivityService can call on other controllers export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ - 'AccountsController:getSelectedAccount', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', 'BackendWebSocketService:connect', 'BackendWebSocketService:forceReconnection', 'BackendWebSocketService:subscribe', @@ -92,12 +106,12 @@ export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ // Allowed events that AccountActivityService can listen to export const ACCOUNT_ACTIVITY_SERVICE_ALLOWED_EVENTS = [ - 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', 'BackendWebSocketService:connectionStateChanged', ] as const; export type AllowedActions = - | AccountsControllerGetSelectedAccountAction + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | BackendWebSocketServiceMethodActions; // Event types for the messaging system @@ -135,7 +149,7 @@ export type AccountActivityServiceEvents = | AccountActivityServiceStatusChangedEvent; export type AllowedEvents = - | AccountsControllerSelectedAccountChangeEvent + | AccountTreeControllerSelectedAccountGroupChangeEvent | BackendWebSocketServiceConnectionStateChangedEvent; export type AccountActivityServiceMessenger = Messenger< @@ -151,21 +165,24 @@ export type AccountActivityServiceMessenger = Messenger< /** * High-performance service for real-time account activity monitoring using optimized * WebSocket subscriptions with direct callback routing. Automatically subscribes to - * the currently selected account and switches subscriptions when the selected account changes. - * Receives transactions and balance updates using the comprehensive AccountActivityMessage format. + * every account in the currently selected account group (EVM, Solana, Tron, etc.) and + * switches subscriptions when the selected account group changes. Also exposes an + * idempotent, multi-address `subscribe`/`subscribeMany` API so other consumers + * (e.g. data sources) can subscribe to additional accounts. Receives transactions and + * balance updates using the comprehensive AccountActivityMessage format. * * Performance Features: * - Direct callback routing (no EventEmitter overhead) * - Minimal subscription tracking (no duplication with BackendWebSocketService) * - Optimized cleanup for mobile environments - * - Single-account subscription (only selected account) + * - Multi-address, multichain subscriptions * - Comprehensive balance updates with transfer tracking * * Architecture: * - Uses messenger pattern to communicate with BackendWebSocketService - * - AccountActivityService tracks channel-to-subscriptionId mappings via messenger calls - * - Automatically subscribes to selected account on initialization - * - Switches subscriptions when selected account changes + * - Automatically subscribes to the selected account group on group change and on reconnect + * - Idempotent: `subscribe` skips channels that already have a subscription, so multiple + * callers (auto-subscription and explicit consumers) can call it safely * - No direct dependency on BackendWebSocketService (uses messenger instead) * * @example @@ -177,9 +194,11 @@ export type AccountActivityServiceMessenger = Messenger< * // Service automatically subscribes to the currently selected account * // When user switches accounts, service automatically resubscribes * + * // Consumers can also subscribe to additional accounts (CAIP-10 addresses) + * await service.subscribeMany({ addresses: ['eip155:0:0x1234...', 'solana:0:ABC123...'] }); + * * // All transactions and balance updates are received via optimized - * // WebSocket callbacks and processed with zero-allocation routing - * // Balance updates include comprehensive transfer details and post-transaction balances + * // WebSocket callbacks and published as messenger events * ``` */ export class AccountActivityService { @@ -230,11 +249,10 @@ export class AccountActivityService { MESSENGER_EXPOSED_METHODS, ); this.#messenger.subscribe( - 'AccountsController:selectedAccountChange', + 'AccountTreeController:selectedAccountGroupChange', // Promise result intentionally not awaited // eslint-disable-next-line @typescript-eslint/no-misused-promises - async (account: InternalAccount) => - await this.#handleSelectedAccountChange(account), + async () => await this.#handleSelectedAccountGroupChange(), ); this.#messenger.subscribe( 'BackendWebSocketService:connectionStateChanged', @@ -255,31 +273,58 @@ export class AccountActivityService { // ============================================================================= /** - * Subscribe to account activity (transactions and balance updates) - * Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + * Subscribe to account activity (transactions and balance updates) for a single + * account. Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or + * "solana:0:ABC123..."). + * + * The call is idempotent: if the address already has an active subscription it + * is skipped, so multiple callers can use it safely. * * @param subscription - Account subscription configuration with address */ async subscribe(subscription: SubscriptionOptions): Promise { + await this.subscribeMany({ addresses: [subscription.address] }); + } + + /** + * Subscribe to account activity (transactions and balance updates) for one or + * more accounts. Each address should be in CAIP-10 format (e.g., + * "eip155:0:0x1234..." or "solana:0:ABC123..."). + * + * The call is idempotent: addresses that already have an active subscription are + * skipped, so multiple consumers (e.g. data sources and the auto-subscription) + * can call this safely. + * + * @param subscription - Account subscription configuration with addresses + */ + async subscribeMany(subscription: SubscriptionManyOptions): Promise { + const { addresses } = subscription; + + if (addresses.length === 0) { + return; + } + try { await this.#messenger.call('BackendWebSocketService:connect'); - // Create channel name from address - const channel = `${this.#options.subscriptionNamespace}.${subscription.address}`; + // Build channels for addresses that are not already subscribed (idempotency) + const channels = addresses + .map((address) => `${this.#options.subscriptionNamespace}.${address}`) + .filter( + (channel) => + !this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ), + ); - // Check if already subscribed - if ( - this.#messenger.call( - 'BackendWebSocketService:channelHasSubscription', - channel, - ) - ) { + if (channels.length === 0) { return; } // Create subscription using the proper subscribe method (this will be stored in WebSocketService's internal tracking) await this.#messenger.call('BackendWebSocketService:subscribe', { - channels: [channel], + channels, channelType: this.#options.subscriptionNamespace, // e.g., 'account-activity.v1' callback: (notification: ServerNotificationMessage) => { this.#handleAccountActivityUpdate( @@ -294,29 +339,39 @@ export class AccountActivityService { } /** - * Unsubscribe from account activity for specified address - * Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + * Unsubscribe from account activity for the specified account. + * Address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or + * "solana:0:ABC123..."). * * @param subscription - Account subscription configuration with address to unsubscribe */ async unsubscribe(subscription: SubscriptionOptions): Promise { - const { address } = subscription; - try { - // Find channel for the specified address - const channel = `${this.#options.subscriptionNamespace}.${address}`; - const subscriptions = this.#messenger.call( - 'BackendWebSocketService:getSubscriptionsByChannel', - channel, - ); + await this.unsubscribeMany({ addresses: [subscription.address] }); + } - if (subscriptions.length === 0) { - return; - } + /** + * Unsubscribe from account activity for the specified accounts. + * Each address should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or + * "solana:0:ABC123..."). + * + * @param subscription - Account subscription configuration with addresses to unsubscribe + */ + async unsubscribeMany(subscription: SubscriptionManyOptions): Promise { + const { addresses } = subscription; + + try { + for (const address of addresses) { + // Find channel for the specified address + const channel = `${this.#options.subscriptionNamespace}.${address}`; + const subscriptions = this.#messenger.call( + 'BackendWebSocketService:getSubscriptionsByChannel', + channel, + ); - // Fast path: Direct unsubscribe using stored unsubscribe function - // Unsubscribe from all matching subscriptions - for (const subscriptionInfo of subscriptions) { - await subscriptionInfo.unsubscribe(); + // Unsubscribe from all matching subscriptions + for (const subscriptionInfo of subscriptions) { + await subscriptionInfo.unsubscribe(); + } } } catch (error) { log('Unsubscription failed, forcing reconnection', { error }); @@ -392,28 +447,19 @@ export class AccountActivityService { } /** - * Handle selected account change event - * - * @param newAccount - The newly selected account + * Handle selected account group change event by switching the + * auto-subscription to all accounts in the newly selected account group + * (EVM, Solana, Tron, etc.). */ - async #handleSelectedAccountChange( - newAccount: InternalAccount | null, - ): Promise { - if (!newAccount?.address) { - return; - } - + async #handleSelectedAccountGroupChange(): Promise { try { - // Convert new account to CAIP-10 format - const newAddress = this.#convertToCaip10Address(newAccount); - // First, unsubscribe from all current account activity subscriptions to avoid multiple subscriptions await this.#unsubscribeFromAllAccountActivity(); - // Then, subscribe to the new selected account - await this.subscribe({ address: newAddress }); + // Then, subscribe to all accounts in the newly selected group + await this.#subscribeToSelectedAccountGroup(); } catch (error) { - log('Account change failed', { error }); + log('Account group change failed', { error }); } } @@ -473,9 +519,8 @@ export class AccountActivityService { const { state } = connectionInfo; if (state === WebSocketState.CONNECTED) { - // WebSocket connected - resubscribe to selected account - // The system notification will automatically provide the list of chains that are up - await this.#subscribeToSelectedAccount(); + // WebSocket connected - resubscribe to the selected account group + await this.#subscribeToSelectedAccountGroup(); } else if (state === WebSocketState.DISCONNECTED) { // On disconnect, flush all tracked chains as down const chainsToMarkDown = Array.from(this.#chainsUp); @@ -503,20 +548,29 @@ export class AccountActivityService { // ============================================================================= /** - * Subscribe to the currently selected account only + * Subscribe to all accounts in the currently selected account group + * (EVM, Solana, Tron, etc.). */ - async #subscribeToSelectedAccount(): Promise { - const selectedAccount = this.#messenger.call( - 'AccountsController:getSelectedAccount', - ); - - if (!selectedAccount?.address) { + async #subscribeToSelectedAccountGroup(): Promise { + const accounts = + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ) ?? []; + + // Convert each account to its namespace-appropriate CAIP-10 address + const addresses = accounts + .filter((account) => account?.address) + .map((account) => this.#convertToCaip10Address(account)) + // TODO: Solana, Tron and Bitcoin account activity are not yet supported in + // production. Restrict the auto-subscription to EVM accounts for now; + // remove this filter once the other namespaces are supported by the backend. + .filter((address) => address.startsWith('eip155:')); + + if (addresses.length === 0) { return; } - // Convert to CAIP-10 format and subscribe - const address = this.#convertToCaip10Address(selectedAccount); - await this.subscribe({ address }); + await this.subscribeMany({ addresses }); } /** @@ -540,26 +594,25 @@ export class AccountActivityService { // ============================================================================= /** - * Convert an InternalAccount address to CAIP-10 format or raw address + * Convert an InternalAccount to a CAIP-10 account-activity address using the + * wildcard chain reference (`0`) so we subscribe to all chains in the + * account's namespace (e.g. `eip155:0:0x...`, `solana:0:ABC...`, + * `tron:0:T...`). EVM addresses are lowercased so the channel matches the + * one produced by other consumers (idempotency). * * @param account - The internal account to convert - * @returns The CAIP-10 formatted address or raw address + * @returns The CAIP-10 formatted account-activity address */ #convertToCaip10Address(account: InternalAccount): string { - // Check if account has EVM scopes - if (account.scopes.some((scope) => scope.startsWith('eip155:'))) { - // CAIP-10 format: eip155:0:address (subscribe to all EVM chains) - return `eip155:0:${account.address}`; - } + // Derive the namespace from the account's scopes (e.g. "eip155:0" -> + // "eip155"), falling back to the account type prefix (e.g. "solana:data-account"). + const reference = account.scopes?.[0] ?? account.type; + const [namespace] = reference.split(':'); - // Check if account has Solana scopes - if (account.scopes.some((scope) => scope.startsWith('solana:'))) { - // CAIP-10 format: solana:0:address (subscribe to all Solana chains) - return `solana:0:${account.address}`; - } + const address = + namespace === 'eip155' ? account.address.toLowerCase() : account.address; - // For other chains or unknown scopes, return raw address - return account.address; + return `${namespace}:0:${address}`; } /** diff --git a/packages/core-backend/tsconfig.build.json b/packages/core-backend/tsconfig.build.json index e386c7a5ec..6e86e4a709 100644 --- a/packages/core-backend/tsconfig.build.json +++ b/packages/core-backend/tsconfig.build.json @@ -6,7 +6,7 @@ "rootDir": "./src" }, "references": [ - { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../account-tree-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/core-backend/tsconfig.json b/packages/core-backend/tsconfig.json index b44a91d630..3cd185ef98 100644 --- a/packages/core-backend/tsconfig.json +++ b/packages/core-backend/tsconfig.json @@ -7,7 +7,7 @@ }, "references": [ { - "path": "../accounts-controller" + "path": "../account-tree-controller" }, { "path": "../controller-utils" diff --git a/yarn.lock b/yarn.lock index 46504f5abd..02fa0f6bfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6265,7 +6265,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" dependencies: - "@metamask/accounts-controller": "npm:^39.0.3" + "@metamask/account-tree-controller": "npm:^7.5.3" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-controller": "npm:^27.1.0"