From 34aa11351b0a20b4b39f2bbb1aecc18427878b36 Mon Sep 17 00:00:00 2001 From: mikesposito Date: Thu, 16 Jul 2026 00:04:22 +0200 Subject: [PATCH 1/2] feat!: update `subscribe` --- packages/core-backend/CHANGELOG.md | 2 + packages/core-backend/jest.config.js | 2 +- ...ountActivityService-method-action-types.ts | 6 ++- .../src/ws/AccountActivityService.test.ts | 39 +++++++++++---- .../src/ws/AccountActivityService.ts | 50 +++++++++++-------- 5 files changed, 65 insertions(+), 34 deletions(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 4e0eb31c93..2c0416d04b 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - EVM (`eip155`) subscriptions are always enabled. Solana, Tron, and Stellar subscriptions are enabled when the corresponding `networkAssetsSnapsMigrationSolana` / `networkAssetsSnapsMigrationTron` / `networkAssetsSnapsMigrationStellar` feature flag has `stage >= 1`, and disabled otherwise. - Accounts whose scopes do not match an enabled chain are no longer subscribed at all. - When the enabled chains change via a feature flag update while the websocket is connected, the service automatically resubscribes the selected account. +- **BREAKING:** `AccountActivityService.subscribe` now requires a `{ addresses: string[] }` argument ([#9531](https://github.com/MetaMask/core/pull/9531)) + - Previously, the service only allowed a single address to be subscribed at a time. - Add `@metamask/remote-feature-flag-controller` `^4.2.2` as a dependency ([#9379](https://github.com/MetaMask/core/pull/9379)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) - Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) diff --git a/packages/core-backend/jest.config.js b/packages/core-backend/jest.config.js index 9af4c97489..f819a20b45 100644 --- a/packages/core-backend/jest.config.js +++ b/packages/core-backend/jest.config.js @@ -22,7 +22,7 @@ module.exports = merge(baseConfig, { coverageThreshold: { global: { branches: 99.81, - functions: 99.26, + functions: 99.27, lines: 99.78, statements: 99.78, }, 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..18ba5e3c9b 100644 --- a/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts +++ b/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts @@ -7,9 +7,11 @@ 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...") + * Addresses should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") * - * @param subscription - Account subscription configuration with address + * @param subscription - The subscription configuration + * @param subscription.addresses - Array of addresses to subscribe to, each in CAIP-10 format + * or an `addresses` array for batch subscription */ export type AccountActivityServiceSubscribeAction = { type: `AccountActivityService:subscribe`; diff --git a/packages/core-backend/src/ws/AccountActivityService.test.ts b/packages/core-backend/src/ws/AccountActivityService.test.ts index 2db21d4e86..b6e3cd5f19 100644 --- a/packages/core-backend/src/ws/AccountActivityService.test.ts +++ b/packages/core-backend/src/ws/AccountActivityService.test.ts @@ -465,7 +465,7 @@ describe('AccountActivityService', () => { // ============================================================================= describe('subscribe', () => { const mockSubscription: SubscriptionOptions = { - address: 'eip155:1:0x1234567890123456789012345678901234567890', + addresses: ['eip155:1:0x1234567890123456789012345678901234567890'], }; it('should handle account activity messages by processing transactions and balance updates and publishing events', async () => { @@ -574,13 +574,31 @@ describe('AccountActivityService', () => { ); }); + it('should allow subscription to multiple addresses with a single subscription', async () => { + await withService(async ({ service, mocks }) => { + const addresses = ['eip155:1:0x123abc', 'eip155:1:0x456def']; + + await service.subscribe({ addresses }); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: [ + 'account-activity.v1.eip155:1:0x123abc', + 'account-activity.v1.eip155:1:0x456def', + ], + callback: expect.any(Function), + }), + ); + }); + }); + it('should handle 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' }); + const result = await service.subscribe({ addresses: ['0x123abc'] }); expect(result).toBeUndefined(); // Verify the subscription was attempted @@ -885,7 +903,10 @@ describe('AccountActivityService', () => { expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: ['account-activity.v1.tron:0:tronaddress123abc'], + channels: [ + 'account-activity.v1.eip155:0:0xevmaddress123abc', + 'account-activity.v1.tron:0:tronaddress123abc', + ], }), ); }); @@ -935,15 +956,13 @@ describe('AccountActivityService', () => { await completeAsyncOperations(); // Verify that subscribe was called for both accounts with the same entropy and group index - expect(mocks.subscribe).toHaveBeenCalledTimes(2); + expect(mocks.subscribe).toHaveBeenCalledTimes(1); expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: ['account-activity.v1.eip155:0:0xaccount1'], - }), - ); - expect(mocks.subscribe).toHaveBeenCalledWith( - expect.objectContaining({ - channels: ['account-activity.v1.eip155:0:0xaccount2'], + channels: [ + 'account-activity.v1.eip155:0:0xaccount1', + 'account-activity.v1.eip155:0:0xaccount2', + ], }), ); }); diff --git a/packages/core-backend/src/ws/AccountActivityService.ts b/packages/core-backend/src/ws/AccountActivityService.ts index 8245b6b9de..d3d82e203e 100644 --- a/packages/core-backend/src/ws/AccountActivityService.ts +++ b/packages/core-backend/src/ws/AccountActivityService.ts @@ -74,7 +74,10 @@ const CHAIN_PREFIX_FEATURE_FLAGS = { * Account subscription options */ export type SubscriptionOptions = { - address: string; // Should be in CAIP-10 format, e.g., "eip155:0:0x1234..." or "solana:0:ABC123..." + /** + * Array of addresses to subscribe to, each in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") + */ + addresses: string[]; }; /** @@ -289,30 +292,35 @@ 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...") + * Addresses should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") * - * @param subscription - Account subscription configuration with address + * @param subscription - The subscription configuration + * @param subscription.addresses - Array of addresses to subscribe to, each in CAIP-10 format + * or an `addresses` array for batch subscription */ - async subscribe(subscription: SubscriptionOptions): Promise { + async subscribe({ addresses }: SubscriptionOptions): Promise { try { await this.#messenger.call('BackendWebSocketService:connect'); - // Create channel name from address - const channel = `${this.#options.subscriptionNamespace}.${subscription.address}`; + // Derive new subscriptions to be created from the provided addresses, + // filtering out any channels that already have an active subscription + 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( @@ -436,10 +444,10 @@ export class AccountActivityService { // First, unsubscribe from all current account activity subscriptions to avoid multiple subscriptions await this.#unsubscribeFromAllAccountActivity(); - for (const address of this.#convertToCaip10Addresses(selectedAccounts)) { - // Subscribe to the new selected account in CAIP-10 format - await this.subscribe({ address }); - } + // Subscribe to the new selected accounts in CAIP-10 format + await this.subscribe({ + addresses: this.#convertToCaip10Addresses(selectedAccounts), + }); } catch (error) { log('Account change failed', { error }); } @@ -538,9 +546,9 @@ export class AccountActivityService { 'AccountTreeController:getAccountsFromSelectedAccountGroup', ); - for (const address of this.#convertToCaip10Addresses(selectedAccounts)) { - await this.subscribe({ address }); - } + await this.subscribe({ + addresses: this.#convertToCaip10Addresses(selectedAccounts), + }); } /** From e856aaf70c461144ff9a37d789ce7201c6645c0a Mon Sep 17 00:00:00 2001 From: mikesposito Date: Thu, 16 Jul 2026 01:06:50 +0200 Subject: [PATCH 2/2] feat!: update `unsubscribe` --- packages/core-backend/CHANGELOG.md | 4 +- ...ountActivityService-method-action-types.ts | 7 +-- .../src/ws/AccountActivityService.test.ts | 52 ++++++++++++++++++- .../src/ws/AccountActivityService.ts | 33 +++++++----- 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 2c0416d04b..e4007cd3f9 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -16,8 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - EVM (`eip155`) subscriptions are always enabled. Solana, Tron, and Stellar subscriptions are enabled when the corresponding `networkAssetsSnapsMigrationSolana` / `networkAssetsSnapsMigrationTron` / `networkAssetsSnapsMigrationStellar` feature flag has `stage >= 1`, and disabled otherwise. - Accounts whose scopes do not match an enabled chain are no longer subscribed at all. - When the enabled chains change via a feature flag update while the websocket is connected, the service automatically resubscribes the selected account. -- **BREAKING:** `AccountActivityService.subscribe` now requires a `{ addresses: string[] }` argument ([#9531](https://github.com/MetaMask/core/pull/9531)) - - Previously, the service only allowed a single address to be subscribed at a time. +- **BREAKING:** `AccountActivityService.subscribe` and `AccountActivityService.unsubscribe` now require a `{ addresses: string[] }` argument ([#9531](https://github.com/MetaMask/core/pull/9531)) + - Previously, the service only allowed a single address to be subscribed and unsubscribed at a time. - Add `@metamask/remote-feature-flag-controller` `^4.2.2` as a dependency ([#9379](https://github.com/MetaMask/core/pull/9379)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) - Bump `@metamask/profile-sync-controller` from `^28.2.0` to `^28.3.0` ([#9463](https://github.com/MetaMask/core/pull/9463)) 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 18ba5e3c9b..d27a35416a 100644 --- a/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts +++ b/packages/core-backend/src/ws/AccountActivityService-method-action-types.ts @@ -19,10 +19,11 @@ 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...") + * Unsubscribe from account activity for specified addresses + * Addresses should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") * - * @param subscription - Account subscription configuration with address to unsubscribe + * @param subscription - The subscription configuration + * @param subscription.addresses - Array of addresses to unsubscribe from, each in CAIP-10 format */ export type AccountActivityServiceUnsubscribeAction = { type: `AccountActivityService:unsubscribe`; diff --git a/packages/core-backend/src/ws/AccountActivityService.test.ts b/packages/core-backend/src/ws/AccountActivityService.test.ts index b6e3cd5f19..2ac18f69fb 100644 --- a/packages/core-backend/src/ws/AccountActivityService.test.ts +++ b/packages/core-backend/src/ws/AccountActivityService.test.ts @@ -618,7 +618,7 @@ describe('AccountActivityService', () => { // ============================================================================= describe('unsubscribe', () => { const mockSubscription: SubscriptionOptions = { - address: 'eip155:1:0x1234567890123456789012345678901234567890', + addresses: ['eip155:1:0x1234567890123456789012345678901234567890'], }; it('should handle unsubscribe when not subscribed by returning early without errors', async () => { @@ -636,6 +636,56 @@ describe('AccountActivityService', () => { }); }); + it('should unsubscribe from active subscriptions for multiple addresses', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsubscribe = jest.fn(); + mocks.getSubscriptionsByChannel.mockReturnValueOnce([ + { + subscriptionId: 'sub-123', + channels: ['account-activity.v1.eip155:1:0x123abc'], + unsubscribe: mockUnsubscribe, + }, + ]); + mocks.getSubscriptionsByChannel.mockReturnValueOnce([ + { + subscriptionId: 'sub-456', + channels: ['account-activity.v1.eip155:1:0x456def'], + unsubscribe: mockUnsubscribe, + }, + ]); + + await service.unsubscribe({ + addresses: ['eip155:1:0x123abc', 'eip155:1:0x456def'], + }); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(2); + }); + }); + + it('should not unsubscribe multiple times from the same subscription when multiple addresses share the same subscription', async () => { + await withService(async ({ service, mocks }) => { + const mockUnsubscribe = jest.fn().mockReturnValue(undefined); + const subscriptions = [ + { + subscriptionId: 'sub-123', + channels: [ + 'account-activity.v1.eip155:1:0x123abc', + 'account-activity.v1.eip155:1:0x456def', + ], + unsubscribe: mockUnsubscribe, + }, + ]; + mocks.getSubscriptionsByChannel.mockReturnValue(subscriptions); + + await service.unsubscribe({ + addresses: ['eip155:1:0x123abc', 'eip155:1:0x456def'], + }); + + expect(mocks.getSubscriptionsByChannel).toHaveBeenCalledTimes(2); + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + }); + it('should handle unsubscribe errors by forcing WebSocket reconnection instead of throwing', async () => { await withService( { accountAddress: '0x1234567890123456789012345678901234567890' }, diff --git a/packages/core-backend/src/ws/AccountActivityService.ts b/packages/core-backend/src/ws/AccountActivityService.ts index d3d82e203e..d753780b46 100644 --- a/packages/core-backend/src/ws/AccountActivityService.ts +++ b/packages/core-backend/src/ws/AccountActivityService.ts @@ -31,7 +31,10 @@ import type { BackendWebSocketServiceConnectionStateChangedEvent, ServerNotificationMessage, } from './BackendWebSocketService'; -import { WebSocketState } from './BackendWebSocketService'; +import { + WebSocketState, + WebSocketSubscription, +} from './BackendWebSocketService'; import type { BackendWebSocketServiceMethodActions } from './BackendWebSocketService-method-action-types'; // ============================================================================= @@ -335,26 +338,30 @@ 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 specified addresses + * Addresses should be in CAIP-10 format (e.g., "eip155:0:0x1234..." or "solana:0:ABC123...") * - * @param subscription - Account subscription configuration with address to unsubscribe + * @param subscription - The subscription configuration + * @param subscription.addresses - Array of addresses to unsubscribe from, each in CAIP-10 format */ - async unsubscribe(subscription: SubscriptionOptions): Promise { - const { address } = subscription; + async unsubscribe({ addresses }: SubscriptionOptions): Promise { try { - // Find channel for the specified address - const channel = `${this.#options.subscriptionNamespace}.${address}`; - const subscriptions = this.#messenger.call( - 'BackendWebSocketService:getSubscriptionsByChannel', - channel, + // Find all subscriptions for the specified addresses + const subscriptions = new Set( + addresses + .map((address) => + this.#messenger.call( + 'BackendWebSocketService:getSubscriptionsByChannel', + `${this.#options.subscriptionNamespace}.${address}`, + ), + ) + .flat(), ); - if (subscriptions.length === 0) { + if (subscriptions.size === 0) { return; } - // Fast path: Direct unsubscribe using stored unsubscribe function // Unsubscribe from all matching subscriptions for (const subscriptionInfo of subscriptions) { await subscriptionInfo.unsubscribe();