Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core-backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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))
Expand Down
2 changes: 1 addition & 1 deletion packages/core-backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ 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`;
handler: AccountActivityService['subscribe'];
};

/**
* 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`;
Expand Down
91 changes: 80 additions & 11 deletions packages/core-backend/src/ws/AccountActivityService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand All @@ -600,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 () => {
Expand All @@ -618,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' },
Expand Down Expand Up @@ -885,7 +953,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',
],
}),
);
});
Expand Down Expand Up @@ -935,15 +1006,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).toHaveBeenCalledWith(
expect.objectContaining({
channels: ['account-activity.v1.eip155:0:0xaccount1'],
}),
);
expect(mocks.subscribe).toHaveBeenCalledTimes(1);
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',
],
}),
);
});
Expand Down
83 changes: 49 additions & 34 deletions packages/core-backend/src/ws/AccountActivityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// =============================================================================
Expand Down Expand Up @@ -74,7 +77,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[];
};

/**
Expand Down Expand Up @@ -289,30 +295,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<void> {
async subscribe({ addresses }: SubscriptionOptions): Promise<void> {
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(
Expand All @@ -327,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<void> {
const { address } = subscription;
async unsubscribe({ addresses }: SubscriptionOptions): Promise<void> {
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<WebSocketSubscription>(
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();
Expand Down Expand Up @@ -436,10 +451,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 });
}
Expand Down Expand Up @@ -538,9 +553,9 @@ export class AccountActivityService {
'AccountTreeController:getAccountsFromSelectedAccountGroup',
);

for (const address of this.#convertToCaip10Addresses(selectedAccounts)) {
await this.subscribe({ address });
}
await this.subscribe({
addresses: this.#convertToCaip10Addresses(selectedAccounts),
});
}

/**
Expand Down
Loading