From 7a84ae220e71b6442396caf82cb9fae817de2ce7 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 12:55:42 +0200 Subject: [PATCH 1/7] refactor: adapters are in fact services + rename them --- packages/snap/snap.manifest.json | 10 +- .../subscriptions/ConnectionManagerPort.ts | 30 ----- .../ports/subscriptions/SubscriberPort.ts | 65 ---------- .../src/core/ports/subscriptions/index.ts | 2 - .../src/core/{ports => services}/index.ts | 0 .../SubscriptionRepository.test.ts | 8 +- .../subscriptions/SubscriptionRepository.ts | 6 +- .../SubscriptionService.test.ts} | 104 ++++++--------- .../subscriptions/SubscriptionService.ts} | 40 +++--- .../WebSocketConnectionRepository.test.ts} | 30 ++--- .../WebSocketConnectionRepository.ts} | 22 ++-- .../WebSocketConnectionService.test.ts} | 118 +++++++++--------- .../WebSocketConnectionService.ts} | 37 ++++-- .../src/core/services/subscriptions/index.ts | 4 + packages/snap/src/entities/subscriptions.ts | 43 ++++++- packages/snap/src/infrastructure/index.ts | 1 - .../src/infrastructure/subscriptions/index.ts | 4 - packages/snap/src/snapContext.ts | 39 +++--- 18 files changed, 240 insertions(+), 323 deletions(-) delete mode 100644 packages/snap/src/core/ports/subscriptions/ConnectionManagerPort.ts delete mode 100644 packages/snap/src/core/ports/subscriptions/SubscriberPort.ts delete mode 100644 packages/snap/src/core/ports/subscriptions/index.ts rename packages/snap/src/core/{ports => services}/index.ts (100%) rename packages/snap/src/{infrastructure => core/services}/subscriptions/SubscriptionRepository.test.ts (93%) rename packages/snap/src/{infrastructure => core/services}/subscriptions/SubscriptionRepository.ts (91%) rename packages/snap/src/{infrastructure/subscriptions/SubscriberAdapter.test.ts => core/services/subscriptions/SubscriptionService.test.ts} (90%) rename packages/snap/src/{infrastructure/subscriptions/SubscriberAdapter.ts => core/services/subscriptions/SubscriptionService.ts} (93%) rename packages/snap/src/{infrastructure/subscriptions/ConnectionRepository.test.ts => core/services/subscriptions/WebSocketConnectionRepository.test.ts} (71%) rename packages/snap/src/{infrastructure/subscriptions/ConnectionRepository.ts => core/services/subscriptions/WebSocketConnectionRepository.ts} (69%) rename packages/snap/src/{infrastructure/subscriptions/ConnectionManagerAdapter.test.ts => core/services/subscriptions/WebSocketConnectionService.test.ts} (61%) rename packages/snap/src/{infrastructure/subscriptions/ConnectionManagerAdapter.ts => core/services/subscriptions/WebSocketConnectionService.ts} (88%) create mode 100644 packages/snap/src/core/services/subscriptions/index.ts delete mode 100644 packages/snap/src/infrastructure/subscriptions/index.ts diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 22a0322c9..0ff855c26 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-solana-wallet.git" }, "source": { - "shasum": "XSE/gfpj/gSu5aA9IihnjJ+IczHvXAdJN1Z/EegXk7w=", + "shasum": "cXuI2V4c1TpiEXZ3o44HV+qoSEGR4pz1DvHCxTtz9xo=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -19,7 +19,8 @@ "locales": ["locales/en.json"] }, "initialConnections": { - "https://portfolio.metamask.io": {} + "https://portfolio.metamask.io": {}, + "http://localhost:3000": {} }, "initialPermissions": { "endowment:rpc": { @@ -27,7 +28,10 @@ "snaps": false }, "endowment:keyring": { - "allowedOrigins": ["https://portfolio.metamask.io"] + "allowedOrigins": [ + "https://portfolio.metamask.io", + "http://localhost:3000" + ] }, "snap_getBip32Entropy": [ { diff --git a/packages/snap/src/core/ports/subscriptions/ConnectionManagerPort.ts b/packages/snap/src/core/ports/subscriptions/ConnectionManagerPort.ts deleted file mode 100644 index 0c88aa2cb..000000000 --- a/packages/snap/src/core/ports/subscriptions/ConnectionManagerPort.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Network } from '../../constants/solana'; - -/** - * Port interface for managing the lifecycle of JSON-RPC subscription connections. - * It is responsible for opening and closing connections for each network, - * and for re-opening connections when they are dropped. - */ -export type ConnectionManagerPort = { - /** - * Sets up connections for all networks. - * - Opens the connections for all enabled networks that are not already open. - * - Closes the connections for all disabled networks. - * @returns A promise that resolves when the connections are setup. - */ - setupAllConnections(): Promise; - - /** - * Registers a callback to be called when connection is recovered. - * @param network - The network to register the callback for. - * @param callback - The callback function to register. - */ - onConnectionRecovery(network: Network, callback: () => Promise): void; - - /** - * Gets the connection ID for the specified network. - * @param network - The network to get the connection ID for. - * @returns The connection ID, or null if no connection exists for the network. - */ - getConnectionIdByNetwork(network: Network): Promise; -}; diff --git a/packages/snap/src/core/ports/subscriptions/SubscriberPort.ts b/packages/snap/src/core/ports/subscriptions/SubscriberPort.ts deleted file mode 100644 index b189419e6..000000000 --- a/packages/snap/src/core/ports/subscriptions/SubscriberPort.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { SubscriptionRequest } from '../../../entities'; - -export type SubscriptionCallbacks = { - /** - * A callback that will be called when a notification is received. - * For instance, if the subscription is for the `accountSubscribe` method, the callback will be called every time the account changes. - */ - onNotification: (message: any) => Promise; - /** - * A callback that will be called when the subscription fails. - */ - onSubscriptionFailed?: (error: any) => Promise; - /** - * A callback that will be called: - * - when the connection is opened (in case subscription was requested before it was opened). - * - when the connection is re-opened after it was lost. - * - * This is the "message gap recovery", that allows us to compensate for potential missed messages. - * When a connection is lost unexpectedly, any messages we miss while disconnected can result in the UI falling behind or becoming corrupt. - * This callback should typically contain an HTTP fetch to catch-up with the latest state. - * - * @example - * ```ts - * // Connection is open... - * - * const subscriber = new SubscriberAdapter(...); - * await subscriber.subscribe({method: "accountSubscribe", ...}, { - * onConnectionRecovery: async () => { - * // Fetch the latest account state from the server. - * const account = await fetchAccount(accountAddress); - * // Update the state - * }, - * }); - * - * // Connection is lost... - * // Some changes happen on the account, that are missed while disconnected. - * // Connection is re-opened... - * // The onConnectionRecovery is called, ensuring we update the state with the latest account state. - * // Subscription is re-established, and we receive again the future changes. - * ``` - */ - onConnectionRecovery?: () => Promise; -}; - -/** - * A port for subscribing / unsubscribing to JSON-RPC subscriptions. - */ -export type SubscriberPort = { - /** - * Subscribes to a JSON-RPC subscription. - * @param request - The subscription request. - * @param onNotification - The callback to be called when a notification is received. - * @returns The subscription ID. - */ - subscribe( - request: SubscriptionRequest, - callbacks: SubscriptionCallbacks, - ): Promise; - - /** - * Unsubscribes from a JSON-RPC subscription. - * @param subscriptionId - The subscription ID. - */ - unsubscribe(subscriptionId: string): Promise; -}; diff --git a/packages/snap/src/core/ports/subscriptions/index.ts b/packages/snap/src/core/ports/subscriptions/index.ts deleted file mode 100644 index 7c97f1c80..000000000 --- a/packages/snap/src/core/ports/subscriptions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ConnectionManagerPort'; -export * from './SubscriberPort'; diff --git a/packages/snap/src/core/ports/index.ts b/packages/snap/src/core/services/index.ts similarity index 100% rename from packages/snap/src/core/ports/index.ts rename to packages/snap/src/core/services/index.ts diff --git a/packages/snap/src/infrastructure/subscriptions/SubscriptionRepository.test.ts b/packages/snap/src/core/services/subscriptions/SubscriptionRepository.test.ts similarity index 93% rename from packages/snap/src/infrastructure/subscriptions/SubscriptionRepository.test.ts rename to packages/snap/src/core/services/subscriptions/SubscriptionRepository.test.ts index f2430cf54..31aab4963 100644 --- a/packages/snap/src/infrastructure/subscriptions/SubscriptionRepository.test.ts +++ b/packages/snap/src/core/services/subscriptions/SubscriptionRepository.test.ts @@ -1,7 +1,7 @@ -import { Network } from '../../core/constants/solana'; -import type { IStateManager } from '../../core/services/state/IStateManager'; -import type { UnencryptedStateValue } from '../../core/services/state/State'; -import type { Subscription } from '../../entities'; +import type { Subscription } from '../../../entities'; +import { Network } from '../../constants/solana'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; import { SubscriptionRepository } from './SubscriptionRepository'; const createMockSubscription = (id: string): Subscription => ({ diff --git a/packages/snap/src/infrastructure/subscriptions/SubscriptionRepository.ts b/packages/snap/src/core/services/subscriptions/SubscriptionRepository.ts similarity index 91% rename from packages/snap/src/infrastructure/subscriptions/SubscriptionRepository.ts rename to packages/snap/src/core/services/subscriptions/SubscriptionRepository.ts index ef7b1c978..95fc216b7 100644 --- a/packages/snap/src/infrastructure/subscriptions/SubscriptionRepository.ts +++ b/packages/snap/src/core/services/subscriptions/SubscriptionRepository.ts @@ -1,10 +1,10 @@ -import type { IStateManager } from '../../core/services/state/IStateManager'; -import type { UnencryptedStateValue } from '../../core/services/state/State'; import type { ConfirmedSubscription, PendingSubscription, Subscription, -} from '../../entities'; +} from '../../../entities'; +import type { IStateManager } from '../state/IStateManager'; +import type { UnencryptedStateValue } from '../state/State'; export class SubscriptionRepository { readonly #state: IStateManager; diff --git a/packages/snap/src/infrastructure/subscriptions/SubscriberAdapter.test.ts b/packages/snap/src/core/services/subscriptions/SubscriptionService.test.ts similarity index 90% rename from packages/snap/src/infrastructure/subscriptions/SubscriberAdapter.test.ts rename to packages/snap/src/core/services/subscriptions/SubscriptionService.test.ts index 929cccb4e..2fdbd3b90 100644 --- a/packages/snap/src/infrastructure/subscriptions/SubscriberAdapter.test.ts +++ b/packages/snap/src/core/services/subscriptions/SubscriptionService.test.ts @@ -1,19 +1,17 @@ import type { WebSocketMessage } from '@metamask/snaps-sdk'; -import { Network } from '../../core/constants/solana'; -import type { - ConnectionManagerPort, - SubscriptionCallbacks, -} from '../../core/ports'; -import { mockLogger } from '../../core/services/mocks/logger'; import type { ConfirmedSubscription, PendingSubscription, + SubscriptionCallbacks, SubscriptionRequest, -} from '../../entities'; -import { EventEmitter } from '../event-emitter'; -import { SubscriberAdapter } from './SubscriberAdapter'; +} from '../../../entities'; +import { EventEmitter } from '../../../infrastructure'; +import { Network } from '../../constants/solana'; +import { mockLogger } from '../mocks/logger'; import type { SubscriptionRepository } from './SubscriptionRepository'; +import { SubscriptionService } from './SubscriptionService'; +import type { WebSocketConnectionService } from './WebSocketConnectionService'; const createMockSubscriptionRequest = ( method = 'some-method', @@ -124,9 +122,9 @@ const triggerConnectionRecoveryCallbacks = async ( ); }; -describe('SubscriberAdapter', () => { - let subscriptionManager: SubscriberAdapter; - let mockConnectionManager: ConnectionManagerPort; +describe('SubscriptionService', () => { + let service: SubscriptionService; + let mockWebSocketConnectionService: WebSocketConnectionService; let mockSubscriptionRepository: SubscriptionRepository; let mockEventEmitter: EventEmitter; let loggerScope: string; @@ -142,11 +140,11 @@ describe('SubscriberAdapter', () => { }; (globalThis as any).snap = snap; - mockConnectionManager = { + mockWebSocketConnectionService = { getConnectionIdByNetwork: jest.fn().mockReturnValue(mockConnectionId), onConnectionRecovery: jest.fn(), handleConnectionEvent: jest.fn(), - } as unknown as ConnectionManagerPort; + } as unknown as WebSocketConnectionService; mockSubscriptionRepository = { getAll: jest.fn(), @@ -159,14 +157,14 @@ describe('SubscriberAdapter', () => { mockEventEmitter = new EventEmitter(mockLogger); - subscriptionManager = new SubscriberAdapter( - mockConnectionManager, + service = new SubscriptionService( + mockWebSocketConnectionService, mockSubscriptionRepository, mockEventEmitter, mockLogger, ); - loggerScope = subscriptionManager.loggerPrefix; + loggerScope = service.loggerPrefix; }); describe('subscribe', () => { @@ -174,7 +172,7 @@ describe('SubscriberAdapter', () => { const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - await subscriptionManager.subscribe(request, callbacks); + await service.subscribe(request, callbacks); expect(mockSubscriptionRepository.save).toHaveBeenCalledWith( expect.objectContaining({ @@ -187,12 +185,11 @@ describe('SubscriberAdapter', () => { const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - await subscriptionManager.subscribe(request, callbacks); + await service.subscribe(request, callbacks); - expect(mockConnectionManager.onConnectionRecovery).toHaveBeenCalledWith( - request.network, - callbacks.onConnectionRecovery, - ); + expect( + mockWebSocketConnectionService.onConnectionRecovery, + ).toHaveBeenCalledWith(request.network, callbacks.onConnectionRecovery); }); describe('when the connection is open', () => { @@ -201,10 +198,7 @@ describe('SubscriberAdapter', () => { const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - const subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + const subscriptionId = await service.subscribe(request, callbacks); expect(snap.request).toHaveBeenCalledWith({ method: 'snap_sendWebSocketMessage', @@ -226,7 +220,7 @@ describe('SubscriberAdapter', () => { describe('unsubscribe', () => { it('does nothing when the subscription does not exist', async () => { - await subscriptionManager.unsubscribe('some-inexistent-id'); + await service.unsubscribe('some-inexistent-id'); // There was no subscription so there shouldn't be a call to unsubscribe. expect(snap.request).not.toHaveBeenCalled(); @@ -248,7 +242,7 @@ describe('SubscriberAdapter', () => { .mockResolvedValue(mockConfirmedSubscription); // Unsubscribe - await subscriptionManager.unsubscribe('some-subscription-id'); + await service.unsubscribe('some-subscription-id'); // Verify unsubscribe was called expect(snap.request).toHaveBeenCalledWith({ @@ -289,10 +283,7 @@ describe('SubscriberAdapter', () => { request = createMockSubscriptionRequest(); callbacks = createMockSubscriptionCallbacks(); - const subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + const subscriptionId = await service.subscribe(request, callbacks); const confirmationMessage = createMockConfirmationMessage( subscriptionId, @@ -377,10 +368,7 @@ describe('SubscriberAdapter', () => { beforeEach(async () => { request = createMockSubscriptionRequest(); callbacks = createMockSubscriptionCallbacks(); - subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + subscriptionId = await service.subscribe(request, callbacks); pendingSubscription = { ...request, @@ -455,10 +443,7 @@ describe('SubscriberAdapter', () => { beforeEach(async () => { request = createMockSubscriptionRequest(); // request ID is 2 (request ID 1 was for opening the connection), hence why we createMockFailure with 2 as first argument callbacks = createMockSubscriptionCallbacks(); - subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + subscriptionId = await service.subscribe(request, callbacks); const pendingSubscription: PendingSubscription = { ...request, @@ -555,7 +540,7 @@ describe('SubscriberAdapter', () => { connectionRecoveryCallbacks = new Map(); jest - .spyOn(mockConnectionManager, 'onConnectionRecovery') + .spyOn(mockWebSocketConnectionService, 'onConnectionRecovery') .mockImplementation((network, callback) => { connectionRecoveryCallbacks.set(network, [ ...(connectionRecoveryCallbacks.get(network) ?? []), @@ -568,17 +553,14 @@ describe('SubscriberAdapter', () => { it('saves the subscription request, and sends it once the connection is opened', async () => { // Mock the getConnectionIdByNetwork to simulate connection state changes jest - .spyOn(mockConnectionManager, 'getConnectionIdByNetwork') + .spyOn(mockWebSocketConnectionService, 'getConnectionIdByNetwork') .mockResolvedValue(null) .mockResolvedValueOnce(null) // First call returns null (no connection) .mockResolvedValueOnce(mockConnectionId); // Second call returns connection ID const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - const subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + const subscriptionId = await service.subscribe(request, callbacks); const pendingSubscription: PendingSubscription = { ...request, @@ -595,10 +577,9 @@ describe('SubscriberAdapter', () => { expect(snap.request).not.toHaveBeenCalled(); // Verify that the connection recovery callback was registered - expect(mockConnectionManager.onConnectionRecovery).toHaveBeenCalledWith( - mockNetwork, - expect.any(Function), - ); + expect( + mockWebSocketConnectionService.onConnectionRecovery, + ).toHaveBeenCalledWith(mockNetwork, expect.any(Function)); expect(connectionRecoveryCallbacks.get(mockNetwork)).toHaveLength(2); // 1 for the subscription's connection recovery callback, 1 for retrying the subscription request expect(connectionRecoveryCallbacks.get(mockNetwork)?.[0]).toBe( callbacks.onConnectionRecovery, @@ -606,7 +587,7 @@ describe('SubscriberAdapter', () => { // Now, let's establish the connection jest - .spyOn(mockConnectionManager, 'getConnectionIdByNetwork') + .spyOn(mockWebSocketConnectionService, 'getConnectionIdByNetwork') .mockResolvedValue(mockConnectionId); // Send the connection event @@ -640,12 +621,11 @@ describe('SubscriberAdapter', () => { const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - await subscriptionManager.subscribe(request, callbacks); + await service.subscribe(request, callbacks); - expect(mockConnectionManager.onConnectionRecovery).toHaveBeenCalledWith( - mockNetwork, - callbacks.onConnectionRecovery, - ); + expect( + mockWebSocketConnectionService.onConnectionRecovery, + ).toHaveBeenCalledWith(mockNetwork, callbacks.onConnectionRecovery); }); }); @@ -653,10 +633,7 @@ describe('SubscriberAdapter', () => { it('re-sends the subscription request when the connection is reestablished', async () => { const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - const subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + const subscriptionId = await service.subscribe(request, callbacks); const pendingSubscription: PendingSubscription = { ...request, @@ -722,10 +699,7 @@ describe('SubscriberAdapter', () => { it('re-sends the subscription request when the connection is reestablished', async () => { const request = createMockSubscriptionRequest(); const callbacks = createMockSubscriptionCallbacks(); - const subscriptionId = await subscriptionManager.subscribe( - request, - callbacks, - ); + const subscriptionId = await service.subscribe(request, callbacks); const pendingSubscription: PendingSubscription = { ...request, diff --git a/packages/snap/src/infrastructure/subscriptions/SubscriberAdapter.ts b/packages/snap/src/core/services/subscriptions/SubscriptionService.ts similarity index 93% rename from packages/snap/src/infrastructure/subscriptions/SubscriberAdapter.ts rename to packages/snap/src/core/services/subscriptions/SubscriptionService.ts index b1cd47887..006cb0206 100644 --- a/packages/snap/src/infrastructure/subscriptions/SubscriberAdapter.ts +++ b/packages/snap/src/core/services/subscriptions/SubscriptionService.ts @@ -2,16 +2,16 @@ import type { WebSocketEvent } from '@metamask/snaps-sdk'; import type { JsonRpcFailure } from '@metamask/utils'; import { isJsonRpcFailure, type JsonRpcRequest } from '@metamask/utils'; -import { Network } from '../../core/constants/solana'; import type { - ConnectionManagerPort, - SubscriberPort, + PendingSubscription, SubscriptionCallbacks, -} from '../../core/ports'; -import type { ILogger } from '../../core/utils/logger'; -import type { PendingSubscription, SubscriptionRequest } from '../../entities'; -import type { EventEmitter } from '../event-emitter'; + SubscriptionRequest, +} from '../../../entities'; +import type { EventEmitter } from '../../../infrastructure'; +import { Network } from '../../constants/solana'; +import type { ILogger } from '../../utils/logger'; import type { SubscriptionRepository } from './SubscriptionRepository'; +import type { WebSocketConnectionService } from './WebSocketConnectionService'; /** * A message that we receive from the RPC WebSocket server after a subscription request, @@ -41,30 +41,30 @@ type JsonRpcWebSocketNotification = { * * @example * ```ts - * const subscriber = new SubscriberAdapter(...); - * await subscriber.subscribe(request, callbacks); - * await subscriber.unsubscribe(subscriptionId); + * const service = new SubscriptionService(...); + * await service.subscribe(request, callbacks); + * await service.unsubscribe(subscriptionId); * ``` */ -export class SubscriberAdapter implements SubscriberPort { - readonly #connectionManager: ConnectionManagerPort; +export class SubscriptionService { + readonly #connectionService: WebSocketConnectionService; readonly #subscriptionRepository: SubscriptionRepository; readonly #logger: ILogger; - readonly loggerPrefix = '[🔔 SubscriberAdapter]'; + readonly loggerPrefix = '[🔔 SubscriptionService]'; // TODO: This is problematic because the subscriptions are persisted in the state, but not the callbacks. readonly #callbacks: Map = new Map(); // subscription ID -> callbacks constructor( - connectionManager: ConnectionManagerPort, + connectionService: WebSocketConnectionService, subscriptionRepository: SubscriptionRepository, eventEmitter: EventEmitter, logger: ILogger, ) { - this.#connectionManager = connectionManager; + this.#connectionService = connectionService; this.#subscriptionRepository = subscriptionRepository; this.#logger = logger; @@ -121,14 +121,14 @@ export class SubscriberAdapter implements SubscriberPort { // If the subscription has a connection recovery callback, register it with the connection manager. if (onConnectionRecovery) { - this.#connectionManager.onConnectionRecovery( + this.#connectionService.onConnectionRecovery( network, onConnectionRecovery, ); } const connectionId = - await this.#connectionManager.getConnectionIdByNetwork(network); + await this.#connectionService.getConnectionIdByNetwork(network); const sendSubscriptionMessage = async (_connectionId: string) => { const message: JsonRpcRequest = { @@ -147,9 +147,9 @@ export class SubscriberAdapter implements SubscriberPort { * - The connection was lost then re-established -> we need to re-subscribe. * - The connection was not yet established, and we need to re-subscribe when it is established. */ - this.#connectionManager.onConnectionRecovery(network, async () => { + this.#connectionService.onConnectionRecovery(network, async () => { const futureConnectionId = - await this.#connectionManager.getConnectionIdByNetwork(network); + await this.#connectionService.getConnectionIdByNetwork(network); if (futureConnectionId) { await sendSubscriptionMessage(futureConnectionId); } @@ -183,7 +183,7 @@ export class SubscriberAdapter implements SubscriberPort { // If the subscription is active, we need to unsubscribe from the RPC and remove it from the active map. if (subscription.status === 'confirmed') { const connectionId = - await this.#connectionManager.getConnectionIdByNetwork(network); + await this.#connectionService.getConnectionIdByNetwork(network); if (connectionId) { await this.#sendMessage(connectionId, { diff --git a/packages/snap/src/infrastructure/subscriptions/ConnectionRepository.test.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionRepository.test.ts similarity index 71% rename from packages/snap/src/infrastructure/subscriptions/ConnectionRepository.test.ts rename to packages/snap/src/core/services/subscriptions/WebSocketConnectionRepository.test.ts index 5c437e7f9..0b89278b6 100644 --- a/packages/snap/src/infrastructure/subscriptions/ConnectionRepository.test.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionRepository.test.ts @@ -1,10 +1,10 @@ -import { ConnectionRepository } from './ConnectionRepository'; +import { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; -describe('ConnectionRepository', () => { - let connectionRepository: ConnectionRepository; +describe('WebSocketConnectionRepository', () => { + let repository: WebSocketConnectionRepository; beforeEach(() => { - connectionRepository = new ConnectionRepository(); + repository = new WebSocketConnectionRepository(); const snap = { request: jest.fn(), @@ -16,7 +16,7 @@ describe('ConnectionRepository', () => { it('returns empty array when there are no connections', async () => { jest.spyOn(snap, 'request').mockResolvedValue([]); - const connections = await connectionRepository.getAll(); + const connections = await repository.getAll(); expect(connections).toStrictEqual([]); }); @@ -26,7 +26,7 @@ describe('ConnectionRepository', () => { .spyOn(snap, 'request') .mockResolvedValue([{ id: '1', url: 'ws://localhost:8080' }]); - const connections = await connectionRepository.getAll(); + const connections = await repository.getAll(); expect(connections).toStrictEqual([ { id: '1', url: 'ws://localhost:8080' }, ]); @@ -37,7 +37,7 @@ describe('ConnectionRepository', () => { it('returns null when the connection does not exist', async () => { jest.spyOn(snap, 'request').mockResolvedValue([]); - const connection = await connectionRepository.getById('1'); + const connection = await repository.getById('1'); expect(connection).toBeNull(); }); @@ -47,7 +47,7 @@ describe('ConnectionRepository', () => { .spyOn(snap, 'request') .mockResolvedValue([{ id: '1', url: 'ws://localhost:8080' }]); - const connection = await connectionRepository.getById('1'); + const connection = await repository.getById('1'); expect(connection).toStrictEqual({ id: '1', url: 'ws://localhost:8080' }); }); @@ -57,9 +57,7 @@ describe('ConnectionRepository', () => { it('returns null when the connection does not exist', async () => { jest.spyOn(snap, 'request').mockResolvedValue([]); - const connection = await connectionRepository.findByUrl( - 'ws://localhost:8080', - ); + const connection = await repository.findByUrl('ws://localhost:8080'); expect(connection).toBeNull(); }); @@ -69,9 +67,7 @@ describe('ConnectionRepository', () => { .spyOn(snap, 'request') .mockResolvedValue([{ id: '1', url: 'ws://localhost:8080' }]); - const connection = await connectionRepository.findByUrl( - 'ws://localhost:8080', - ); + const connection = await repository.findByUrl('ws://localhost:8080'); expect(connection).toStrictEqual({ id: '1', url: 'ws://localhost:8080' }); }); @@ -81,9 +77,7 @@ describe('ConnectionRepository', () => { it('saves the connection', async () => { jest.spyOn(snap, 'request').mockResolvedValue('1'); - const connectionId = await connectionRepository.save( - 'ws://localhost:8080', - ); + const connectionId = await repository.save('ws://localhost:8080'); expect(connectionId).toBe('1'); }); @@ -93,7 +87,7 @@ describe('ConnectionRepository', () => { it('deletes the connection', async () => { jest.spyOn(snap, 'request').mockResolvedValue(null); - await connectionRepository.delete('1'); + await repository.delete('1'); expect(snap.request).toHaveBeenCalledWith({ method: 'snap_closeWebSocket', diff --git a/packages/snap/src/infrastructure/subscriptions/ConnectionRepository.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionRepository.ts similarity index 69% rename from packages/snap/src/infrastructure/subscriptions/ConnectionRepository.ts rename to packages/snap/src/core/services/subscriptions/WebSocketConnectionRepository.ts index 4c4094a4e..34f64ea9c 100644 --- a/packages/snap/src/infrastructure/subscriptions/ConnectionRepository.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionRepository.ts @@ -1,4 +1,4 @@ -import type { GetWebSocketsResult } from '@metamask/snaps-sdk'; +import type { WebSocketConnection } from '../../../entities'; /** * Repository that is treating the Snap's WebSocket storage as a persistent data store, where: @@ -8,25 +8,21 @@ import type { GetWebSocketsResult } from '@metamask/snaps-sdk'; * * It also tracks bidirectional mappings between the connection ID and the URL to perform fast lookups. */ -export class ConnectionRepository { - async getAll(): Promise { +export class WebSocketConnectionRepository { + async getAll(): Promise { return snap.request({ method: 'snap_getWebSockets', }); } - async getById( - connectionId: string, - ): Promise { + async getById(id: string): Promise { const existingConnections = await this.getAll(); return ( - existingConnections.find( - (connection) => connection.id === connectionId, - ) ?? null + existingConnections.find((connection) => connection.id === id) ?? null ); } - async findByUrl(url: string): Promise { + async findByUrl(url: string): Promise { const existingConnections = await this.getAll(); return ( @@ -54,12 +50,12 @@ export class ConnectionRepository { /** * Closes the connection with the specified ID. - * @param connectionId - The ID of the connection to close. + * @param id - The ID of the connection to close. */ - async delete(connectionId: string) { + async delete(id: string) { await snap.request({ method: 'snap_closeWebSocket', - params: { id: connectionId }, + params: { id }, }); } } diff --git a/packages/snap/src/infrastructure/subscriptions/ConnectionManagerAdapter.test.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts similarity index 61% rename from packages/snap/src/infrastructure/subscriptions/ConnectionManagerAdapter.test.ts rename to packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts index 331e72a87..d0c01381d 100644 --- a/packages/snap/src/infrastructure/subscriptions/ConnectionManagerAdapter.test.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts @@ -1,29 +1,27 @@ -import { Network } from '../../core/constants/solana'; -import type { ConfigProvider } from '../../core/services/config'; -import type { - Config, - NetworkWithRpcUrls, -} from '../../core/services/config/ConfigProvider'; -import { mockLogger } from '../../core/services/mocks/logger'; -import { EventEmitter } from '../event-emitter/EventEmitter'; -import { ConnectionManagerAdapter } from './ConnectionManagerAdapter'; -import type { ConnectionRepository } from './ConnectionRepository'; +import type { WebSocketConnection } from '../../../entities'; +import { EventEmitter } from '../../../infrastructure'; +import { Network } from '../../constants/solana'; +import type { ConfigProvider } from '../config'; +import type { Config, NetworkWithRpcUrls } from '../config/ConfigProvider'; +import { mockLogger } from '../mocks/logger'; +import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; +import { WebSocketConnectionService } from './WebSocketConnectionService'; const mockWebSocketUrl = 'wss://some-mock-url.com/ws/v3/some-id'; const mockConnectionId = 'mock-connection-id'; -const createMockConnection = ( +const createMockWebSocketConnection = ( id = mockConnectionId, url = mockWebSocketUrl, -) => ({ +): WebSocketConnection => ({ id, url, protocols: [], }); -describe('ConnectionManagerAdapter', () => { - let connectionManager: ConnectionManagerAdapter; - let mockConnectionRepository: ConnectionRepository; +describe('WebSocketConnectionService', () => { + let service: WebSocketConnectionService; + let mockWebSocketConnectionRepository: WebSocketConnectionRepository; let mockConfigProvider: ConfigProvider; let mockEventEmitter: EventEmitter; @@ -49,7 +47,7 @@ describe('ConnectionManagerAdapter', () => { beforeEach(() => { jest.clearAllMocks(); - mockConnectionRepository = { + mockWebSocketConnectionRepository = { getAll: jest.fn(), getById: jest.fn(), findByUrl: jest.fn(), @@ -57,7 +55,7 @@ describe('ConnectionManagerAdapter', () => { delete: jest.fn(), getIdByUrl: jest.fn(), getUrlById: jest.fn(), - } as unknown as ConnectionRepository; + } as unknown as WebSocketConnectionRepository; mockConfigProvider = { get: jest.fn().mockReturnValue({ @@ -76,8 +74,8 @@ describe('ConnectionManagerAdapter', () => { mockEventEmitter = new EventEmitter(mockLogger); - connectionManager = new ConnectionManagerAdapter( - mockConnectionRepository, + service = new WebSocketConnectionService( + mockWebSocketConnectionRepository, mockConfigProvider, mockEventEmitter, mockLogger, @@ -91,20 +89,20 @@ describe('ConnectionManagerAdapter', () => { } as unknown as Config); // Init with an existing connection for Mainnet. We expect to only open the connection for Devnet. - const mockConnectionMainnet = createMockConnection(); + const mockConnectionMainnet = createMockWebSocketConnection(); jest - .spyOn(mockConnectionRepository, 'getAll') + .spyOn(mockWebSocketConnectionRepository, 'getAll') .mockResolvedValueOnce([mockConnectionMainnet]); jest - .spyOn(mockConnectionRepository, 'save') + .spyOn(mockWebSocketConnectionRepository, 'save') .mockResolvedValueOnce(mockConnectionId); - await connectionManager.setupAllConnections(); + await service.setupAllConnections(); - expect(mockConnectionRepository.save).toHaveBeenCalledTimes(1); - expect(mockConnectionRepository.save).toHaveBeenCalledWith( + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(1); + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledWith( 'wss://some-mock-url2.com/ws/v3/some-id', ); }); @@ -114,15 +112,15 @@ describe('ConnectionManagerAdapter', () => { activeNetworks: [Network.Mainnet], } as unknown as Config); - const mockConnection = createMockConnection(); + const mockConnection = createMockWebSocketConnection(); jest - .spyOn(mockConnectionRepository, 'getAll') + .spyOn(mockWebSocketConnectionRepository, 'getAll') .mockResolvedValueOnce([mockConnection]); - await connectionManager.setupAllConnections(); + await service.setupAllConnections(); - expect(mockConnectionRepository.save).toHaveBeenCalledTimes(0); + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(0); }); it('closes the connections for the inactive networks that are open', async () => { @@ -130,19 +128,19 @@ describe('ConnectionManagerAdapter', () => { activeNetworks: [], } as unknown as Config); - const openConnection = createMockConnection(); + const openConnection = createMockWebSocketConnection(); jest - .spyOn(mockConnectionRepository, 'getAll') + .spyOn(mockWebSocketConnectionRepository, 'getAll') .mockResolvedValueOnce([openConnection]); jest - .spyOn(mockConnectionRepository, 'findByUrl') + .spyOn(mockWebSocketConnectionRepository, 'findByUrl') .mockResolvedValueOnce(openConnection); - await connectionManager.setupAllConnections(); + await service.setupAllConnections(); - expect(mockConnectionRepository.delete).toHaveBeenCalledTimes(1); + expect(mockWebSocketConnectionRepository.delete).toHaveBeenCalledTimes(1); }); it('does nothing for inactive networks that are not open', async () => { @@ -150,11 +148,13 @@ describe('ConnectionManagerAdapter', () => { activeNetworks: [], } as unknown as Config); - jest.spyOn(mockConnectionRepository, 'getAll').mockResolvedValueOnce([]); + jest + .spyOn(mockWebSocketConnectionRepository, 'getAll') + .mockResolvedValueOnce([]); - await connectionManager.setupAllConnections(); + await service.setupAllConnections(); - expect(mockConnectionRepository.delete).not.toHaveBeenCalled(); + expect(mockWebSocketConnectionRepository.delete).not.toHaveBeenCalled(); }); describe('when the connection fails', () => { @@ -164,29 +164,29 @@ describe('ConnectionManagerAdapter', () => { } as unknown as Config); jest - .spyOn(mockConnectionRepository, 'getAll') + .spyOn(mockWebSocketConnectionRepository, 'getAll') .mockResolvedValueOnce([]); }); it('retries until it succeeds, when attempts < 5', async () => { jest - .spyOn(mockConnectionRepository, 'save') + .spyOn(mockWebSocketConnectionRepository, 'save') .mockRejectedValueOnce(new Error('Connection failed')) // 1st call is the fail attempt .mockResolvedValueOnce(mockConnectionId); // 2nd call is the success attempt - await connectionManager.setupAllConnections(); + await service.setupAllConnections(); - expect(mockConnectionRepository.save).toHaveBeenCalledTimes(2); + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(2); }); it('retries up to the max number of attempts', async () => { jest - .spyOn(mockConnectionRepository, 'save') + .spyOn(mockWebSocketConnectionRepository, 'save') .mockRejectedValue(new Error('Connection failed')); - await connectionManager.setupAllConnections(); + await service.setupAllConnections(); - expect(mockConnectionRepository.save).toHaveBeenCalledTimes(5); + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(5); }); }); }); @@ -194,14 +194,14 @@ describe('ConnectionManagerAdapter', () => { describe('#handleWebSocketEvent', () => { describe('when the event is a connect', () => { beforeEach(async () => { - const mockConnection = createMockConnection(); + const mockConnection = createMockWebSocketConnection(); jest - .spyOn(mockConnectionRepository, 'getById') + .spyOn(mockWebSocketConnectionRepository, 'getById') .mockResolvedValue(mockConnection); jest - .spyOn(mockConnectionRepository, 'findByUrl') + .spyOn(mockWebSocketConnectionRepository, 'findByUrl') .mockResolvedValueOnce(mockConnection); }); @@ -209,14 +209,8 @@ describe('ConnectionManagerAdapter', () => { const recoveryCallback0 = jest.fn(); const recoveryCallback1 = jest.fn(); - connectionManager.onConnectionRecovery( - Network.Mainnet, - recoveryCallback0, - ); - connectionManager.onConnectionRecovery( - Network.Mainnet, - recoveryCallback1, - ); + service.onConnectionRecovery(Network.Mainnet, recoveryCallback0); + service.onConnectionRecovery(Network.Mainnet, recoveryCallback1); // Send the connect event await mockEventEmitter.emitSync('onWebSocketEvent', { @@ -231,14 +225,14 @@ describe('ConnectionManagerAdapter', () => { describe('when the event is a disconnect', () => { beforeEach(async () => { - const mockConnection = createMockConnection(); + const mockConnection = createMockWebSocketConnection(); jest - .spyOn(mockConnectionRepository, 'getAll') + .spyOn(mockWebSocketConnectionRepository, 'getAll') .mockResolvedValueOnce([mockConnection]); jest - .spyOn(mockConnectionRepository, 'getById') + .spyOn(mockWebSocketConnectionRepository, 'getById') .mockResolvedValueOnce(mockConnection); }); @@ -249,19 +243,19 @@ describe('ConnectionManagerAdapter', () => { type: 'close', }); - expect(mockConnectionRepository.save).toHaveBeenCalledTimes(1); + expect(mockWebSocketConnectionRepository.save).toHaveBeenCalledTimes(1); }); }); }); describe('getConnectionIdByNetwork', () => { it('returns the connection ID for the network', async () => { - const mockConnection = createMockConnection(); + const mockConnection = createMockWebSocketConnection(); jest - .spyOn(mockConnectionRepository, 'findByUrl') + .spyOn(mockWebSocketConnectionRepository, 'findByUrl') .mockResolvedValueOnce(mockConnection); - const connectionId = await connectionManager.getConnectionIdByNetwork( + const connectionId = await service.getConnectionIdByNetwork( Network.Mainnet, ); diff --git a/packages/snap/src/infrastructure/subscriptions/ConnectionManagerAdapter.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts similarity index 88% rename from packages/snap/src/infrastructure/subscriptions/ConnectionManagerAdapter.ts rename to packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts index ce8617070..63df7d1f1 100644 --- a/packages/snap/src/infrastructure/subscriptions/ConnectionManagerAdapter.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts @@ -1,13 +1,12 @@ import type { WebSocketEvent } from '@metamask/snaps-sdk'; import { difference } from 'lodash'; -import { Network } from '../../core/constants/solana'; -import type { ConnectionManagerPort } from '../../core/ports'; -import type { ConfigProvider } from '../../core/services/config'; -import type { NetworkWithRpcUrls } from '../../core/services/config/ConfigProvider'; -import type { ILogger } from '../../core/utils/logger'; -import type { EventEmitter } from '../event-emitter'; -import type { ConnectionRepository } from './ConnectionRepository'; +import type { EventEmitter } from '../../../infrastructure'; +import { Network } from '../../constants/solana'; +import type { ILogger } from '../../utils/logger'; +import type { ConfigProvider } from '../config'; +import type { NetworkWithRpcUrls } from '../config/ConfigProvider'; +import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; /** * Manages WebSocket connections for different Solana networks, providing robust connection @@ -21,14 +20,14 @@ import type { ConnectionRepository } from './ConnectionRepository'; * - Processes WebSocket connection events (connect, disconnect, error) and triggers appropriate recovery mechanisms * - Converts HTTP RPC URLs to WebSocket URLs for subscription endpoints */ -export class ConnectionManagerAdapter implements ConnectionManagerPort { +export class WebSocketConnectionService { readonly #configProvider: ConfigProvider; - readonly #connectionRepository: ConnectionRepository; + readonly #connectionRepository: WebSocketConnectionRepository; readonly #logger: ILogger; - readonly #loggerPrefix = '[🔌 ConnectionManagerAdapter]'; + readonly #loggerPrefix = '[🔌 WebSocketConnectionService]'; readonly #maxReconnectAttempts: number; @@ -38,7 +37,7 @@ export class ConnectionManagerAdapter implements ConnectionManagerPort { new Map(); constructor( - connectionRepository: ConnectionRepository, + connectionRepository: WebSocketConnectionRepository, configProvider: ConfigProvider, eventEmitter: EventEmitter, logger: ILogger, @@ -66,6 +65,12 @@ export class ConnectionManagerAdapter implements ConnectionManagerPort { ); } + /** + * Sets up connections for all networks. + * - Opens the connections for all enabled networks that are not already open. + * - Closes the connections for all disabled networks. + * @returns A promise that resolves when the connections are setup. + */ async setupAllConnections(): Promise { this.#logger.info(this.#loggerPrefix, `Setting up all connections`); @@ -201,6 +206,11 @@ export class ConnectionManagerAdapter implements ConnectionManagerPort { } } + /** + * Registers a callback to be called when connection is recovered. + * @param network - The network to register the callback for. + * @param callback - The callback function to register. + */ onConnectionRecovery(network: Network, callback: () => Promise): void { const existingCallbacks = this.#connectionRecoveryCallbacks.get(network) ?? []; @@ -211,6 +221,11 @@ export class ConnectionManagerAdapter implements ConnectionManagerPort { ]); } + /** + * Gets the connection ID for the specified network. + * @param network - The network to get the connection ID for. + * @returns The connection ID, or null if no connection exists for the network. + */ async getConnectionIdByNetwork(network: Network): Promise { const wsUrl = this.#getWebSocketUrl(network); const connection = await this.#connectionRepository.findByUrl(wsUrl); diff --git a/packages/snap/src/core/services/subscriptions/index.ts b/packages/snap/src/core/services/subscriptions/index.ts new file mode 100644 index 000000000..7cc689584 --- /dev/null +++ b/packages/snap/src/core/services/subscriptions/index.ts @@ -0,0 +1,4 @@ +export * from './SubscriptionRepository'; +export * from './SubscriptionService'; +export * from './WebSocketConnectionRepository'; +export * from './WebSocketConnectionService'; diff --git a/packages/snap/src/entities/subscriptions.ts b/packages/snap/src/entities/subscriptions.ts index 088716b25..912b086ed 100644 --- a/packages/snap/src/entities/subscriptions.ts +++ b/packages/snap/src/entities/subscriptions.ts @@ -13,7 +13,7 @@ export type SubscriptionRequest = { network: Network; }; -export type Connection = GetWebSocketsResult[number]; +export type WebSocketConnection = GetWebSocketsResult[number]; /** * Once the Subscriber acknowledges the subscription request, @@ -35,3 +35,44 @@ export type ConfirmedSubscription = Omit & { // Union type for all states export type Subscription = PendingSubscription | ConfirmedSubscription; +export type SubscriptionCallbacks = { + /** + * A callback that will be called when a notification is received. + * For instance, if the subscription is for the `accountSubscribe` method, the callback will be called every time the account changes. + */ + onNotification: (message: any) => Promise; + /** + * A callback that will be called when the subscription fails. + */ + onSubscriptionFailed?: (error: any) => Promise; + /** + * A callback that will be called: + * - when the connection is opened (in case subscription was requested before it was opened). + * - when the connection is re-opened after it was lost. + * + * This is the "message gap recovery", that allows us to compensate for potential missed messages. + * When a connection is lost unexpectedly, any messages we miss while disconnected can result in the UI falling behind or becoming corrupt. + * This callback should typically contain an HTTP fetch to catch-up with the latest state. + * + * @example + * ```ts + * // Connection is open... + * + * const service = new SubscriptionService(...); + * await service.subscribe({method: "accountSubscribe", ...}, { + * onConnectionRecovery: async () => { + * // Fetch the latest account state from the server. + * const account = await fetchAccount(accountAddress); + * // Update the state + * }, + * }); + * + * // Connection is lost... + * // Some changes happen on the account, that are missed while disconnected. + * // Connection is re-opened... + * // The onConnectionRecovery is called, ensuring we update the state with the latest account state. + * // Subscription is re-established, and we receive again the future changes. + * ``` + */ + onConnectionRecovery?: () => Promise; +}; diff --git a/packages/snap/src/infrastructure/index.ts b/packages/snap/src/infrastructure/index.ts index 630f30508..decf40ba2 100644 --- a/packages/snap/src/infrastructure/index.ts +++ b/packages/snap/src/infrastructure/index.ts @@ -1,2 +1 @@ export * from './event-emitter'; -export * from './subscriptions'; diff --git a/packages/snap/src/infrastructure/subscriptions/index.ts b/packages/snap/src/infrastructure/subscriptions/index.ts deleted file mode 100644 index a8d7390a9..000000000 --- a/packages/snap/src/infrastructure/subscriptions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './ConnectionManagerAdapter'; -export * from './ConnectionRepository'; -export * from './SubscriberAdapter'; -export * from './SubscriptionRepository'; diff --git a/packages/snap/src/snapContext.ts b/packages/snap/src/snapContext.ts index c2ed693b2..077b597f7 100644 --- a/packages/snap/src/snapContext.ts +++ b/packages/snap/src/snapContext.ts @@ -6,8 +6,13 @@ import { SecurityAlertsApiClient } from './core/clients/security-alerts-api/Secu import { TokenMetadataClient } from './core/clients/token-metadata-client/TokenMetadataClient'; import { ClientRequestHandler } from './core/handlers'; import { SolanaKeyring } from './core/handlers/onKeyringRequest/Keyring'; -import type { ConnectionManagerPort, SubscriberPort } from './core/ports'; import type { Serializable } from './core/serialization/types'; +import { + SubscriptionRepository, + SubscriptionService, + WebSocketConnectionRepository, + WebSocketConnectionService, +} from './core/services'; import { AnalyticsService } from './core/services/analytics/AnalyticsService'; import { AssetsService } from './core/services/assets/AssetsService'; import { ConfigProvider } from './core/services/config'; @@ -26,13 +31,7 @@ import { WalletService } from './core/services/wallet/WalletService'; import logger from './core/utils/logger'; import { SendSolBuilder } from './features/send/transactions/SendSolBuilder'; import { SendSplTokenBuilder } from './features/send/transactions/SendSplTokenBuilder'; -import { - ConnectionManagerAdapter, - ConnectionRepository, - EventEmitter, - SubscriberAdapter, - SubscriptionRepository, -} from './infrastructure'; +import { EventEmitter } from './infrastructure'; /** * Initializes all the services using dependency injection. @@ -57,9 +56,8 @@ export type SnapExecutionContext = { cache: ICache; nftService: NftService; clientRequestHandler: ClientRequestHandler; - subscriptionConnectionManager: ConnectionManagerPort; - subscriber: SubscriberPort; - subscriptionRepository: SubscriptionRepository; + webSocketConnectionService: WebSocketConnectionService; + subscriptionService: SubscriptionService; eventEmitter: EventEmitter; }; @@ -120,10 +118,10 @@ const transactionScanService = new TransactionScanService( const confirmationHandler = new ConfirmationHandler(); -const subscriptionConnectionRepository = new ConnectionRepository(); +const webSocketConnectionRepository = new WebSocketConnectionRepository(); -const subscriptionConnectionManager = new ConnectionManagerAdapter( - subscriptionConnectionRepository, +const webSocketConnectionService = new WebSocketConnectionService( + webSocketConnectionRepository, configProvider, eventEmitter, logger, @@ -131,8 +129,8 @@ const subscriptionConnectionManager = new ConnectionManagerAdapter( const subscriptionRepository = new SubscriptionRepository(state); -const subscriber = new SubscriberAdapter( - subscriptionConnectionManager, +const subscriptionService = new SubscriptionService( + webSocketConnectionService, subscriptionRepository, eventEmitter, logger, @@ -177,9 +175,8 @@ const snapContext: SnapExecutionContext = { confirmationHandler, nftService, clientRequestHandler, - subscriptionConnectionManager, - subscriber, - subscriptionRepository, + webSocketConnectionService, + subscriptionService, eventEmitter, }; @@ -197,9 +194,8 @@ export { sendSolBuilder, sendSplTokenBuilder, state, - subscriber, - subscriptionConnectionManager, subscriptionRepository, + subscriptionService, tokenMetadataClient, tokenMetadataService, tokenPricesService, @@ -207,6 +203,7 @@ export { transactionScanService, transactionsService, walletService, + webSocketConnectionService, }; export default snapContext; From f771f3b549c3163f3876dabc14a4ffd0d311e487 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 12:56:22 +0200 Subject: [PATCH 2/7] chore: shasum --- packages/snap/snap.manifest.json | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 0ff855c26..a55a2dfac 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -16,11 +16,12 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": ["locales/en.json"] + "locales": [ + "locales/en.json" + ] }, "initialConnections": { - "https://portfolio.metamask.io": {}, - "http://localhost:3000": {} + "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:rpc": { @@ -29,13 +30,16 @@ }, "endowment:keyring": { "allowedOrigins": [ - "https://portfolio.metamask.io", - "http://localhost:3000" + "https://portfolio.metamask.io" ] }, "snap_getBip32Entropy": [ { - "path": ["m", "44'", "501'"], + "path": [ + "m", + "44'", + "501'" + ], "curve": "ed25519" } ], From 300a6cbcb3a7b75f201b8745705a41cc193a79d6 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 12:56:53 +0200 Subject: [PATCH 3/7] chore: shasum --- packages/snap/snap.manifest.json | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index a55a2dfac..9c148a7f4 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-solana-wallet.git" }, "source": { - "shasum": "cXuI2V4c1TpiEXZ3o44HV+qoSEGR4pz1DvHCxTtz9xo=", + "shasum": "g/jfpUCvmwu6piUwA21tuyxrmaZZnZl3JdLeVJysYFM=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -16,9 +16,7 @@ "registry": "https://registry.npmjs.org/" } }, - "locales": [ - "locales/en.json" - ] + "locales": ["locales/en.json"] }, "initialConnections": { "https://portfolio.metamask.io": {} @@ -29,17 +27,11 @@ "snaps": false }, "endowment:keyring": { - "allowedOrigins": [ - "https://portfolio.metamask.io" - ] + "allowedOrigins": ["https://portfolio.metamask.io"] }, "snap_getBip32Entropy": [ { - "path": [ - "m", - "44'", - "501'" - ], + "path": ["m", "44'", "501'"], "curve": "ed25519" } ], From 3ed42ecadecbf6eb041d885a77f90ab654dc25e1 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 12:59:56 +0200 Subject: [PATCH 4/7] refactor: reorder things --- packages/snap/src/entities/subscriptions.ts | 45 +++++++++++---------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/snap/src/entities/subscriptions.ts b/packages/snap/src/entities/subscriptions.ts index 912b086ed..66969bf38 100644 --- a/packages/snap/src/entities/subscriptions.ts +++ b/packages/snap/src/entities/subscriptions.ts @@ -13,28 +13,6 @@ export type SubscriptionRequest = { network: Network; }; -export type WebSocketConnection = GetWebSocketsResult[number]; - -/** - * Once the Subscriber acknowledges the subscription request, - * it generates a subscrption ID, and the subscription is pending (waiting for the confirmation message). - */ -export type PendingSubscription = SubscriptionRequest & { - readonly id: string; - readonly status: 'pending'; - readonly requestId: string; // Same a the field `id` - readonly createdAt: string; // ISO string -}; - -// After server confirms the subscription -export type ConfirmedSubscription = Omit & { - readonly status: 'confirmed'; - readonly rpcSubscriptionId: number; // Server's confirmation ID - readonly confirmedAt: string; // ISO string -}; - -// Union type for all states -export type Subscription = PendingSubscription | ConfirmedSubscription; export type SubscriptionCallbacks = { /** * A callback that will be called when a notification is received. @@ -76,3 +54,26 @@ export type SubscriptionCallbacks = { */ onConnectionRecovery?: () => Promise; }; + +export type WebSocketConnection = GetWebSocketsResult[number]; + +/** + * Once the Subscriber acknowledges the subscription request, + * it generates a subscrption ID, and the subscription is pending (waiting for the confirmation message). + */ +export type PendingSubscription = SubscriptionRequest & { + readonly id: string; + readonly status: 'pending'; + readonly requestId: string; // Same a the field `id` + readonly createdAt: string; // ISO string +}; + +// After server confirms the subscription +export type ConfirmedSubscription = Omit & { + readonly status: 'confirmed'; + readonly rpcSubscriptionId: number; // Server's confirmation ID + readonly confirmedAt: string; // ISO string +}; + +// Union type for all states +export type Subscription = PendingSubscription | ConfirmedSubscription; From f4c1fe2325ab3fe2161608f907f497901ffe1889 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 14:26:56 +0200 Subject: [PATCH 5/7] refactor: rename NetworkWithRpcUrls as NetworkConfig --- packages/snap/snap.manifest.json | 10 +++++++--- .../snap/src/core/services/config/ConfigProvider.ts | 9 +++------ .../subscriptions/WebSocketConnectionService.test.ts | 6 +++--- .../subscriptions/WebSocketConnectionService.ts | 4 ++-- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 9c148a7f4..0ff855c26 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-solana-wallet.git" }, "source": { - "shasum": "g/jfpUCvmwu6piUwA21tuyxrmaZZnZl3JdLeVJysYFM=", + "shasum": "cXuI2V4c1TpiEXZ3o44HV+qoSEGR4pz1DvHCxTtz9xo=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -19,7 +19,8 @@ "locales": ["locales/en.json"] }, "initialConnections": { - "https://portfolio.metamask.io": {} + "https://portfolio.metamask.io": {}, + "http://localhost:3000": {} }, "initialPermissions": { "endowment:rpc": { @@ -27,7 +28,10 @@ "snaps": false }, "endowment:keyring": { - "allowedOrigins": ["https://portfolio.metamask.io"] + "allowedOrigins": [ + "https://portfolio.metamask.io", + "http://localhost:3000" + ] }, "snap_getBip32Entropy": [ { diff --git a/packages/snap/src/core/services/config/ConfigProvider.ts b/packages/snap/src/core/services/config/ConfigProvider.ts index 9a9a0d536..a76e7de8f 100644 --- a/packages/snap/src/core/services/config/ConfigProvider.ts +++ b/packages/snap/src/core/services/config/ConfigProvider.ts @@ -50,14 +50,14 @@ const EnvStruct = object({ export type Env = Infer; -export type NetworkWithRpcUrls = (typeof Networks)[Network] & { +export type NetworkConfig = (typeof Networks)[Network] & { rpcUrls: string[]; webSocketUrl: string; }; export type Config = { environment: string; - networks: NetworkWithRpcUrls[]; + networks: NetworkConfig[]; activeNetworks: Network[]; explorerBaseUrl: string; priceApi: { @@ -188,10 +188,7 @@ export class ConfigProvider { return this.#config; } - public getNetworkBy( - key: keyof NetworkWithRpcUrls, - value: string, - ): NetworkWithRpcUrls { + public getNetworkBy(key: keyof NetworkConfig, value: string): NetworkConfig { const network = this.get().networks.find((item) => item[key] === value); if (!network) { throw new Error(`Network ${key} not found`); diff --git a/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts index d0c01381d..e7dca9a38 100644 --- a/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.test.ts @@ -2,7 +2,7 @@ import type { WebSocketConnection } from '../../../entities'; import { EventEmitter } from '../../../infrastructure'; import { Network } from '../../constants/solana'; import type { ConfigProvider } from '../config'; -import type { Config, NetworkWithRpcUrls } from '../config/ConfigProvider'; +import type { Config, NetworkConfig } from '../config/ConfigProvider'; import { mockLogger } from '../mocks/logger'; import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; import { WebSocketConnectionService } from './WebSocketConnectionService'; @@ -42,7 +42,7 @@ describe('WebSocketConnectionService', () => { caip2Id: Network.Localnet, webSocketUrl: 'wss://some-mock-url4.com/ws/v3/some-id', }, - ] as NetworkWithRpcUrls[]; + ] as NetworkConfig[]; beforeEach(() => { jest.clearAllMocks(); @@ -67,7 +67,7 @@ describe('WebSocketConnectionService', () => { }), getNetworkBy: jest.fn().mockImplementation((key, value) => { return mockNetworksConfig.find( - (item) => item[key as keyof NetworkWithRpcUrls] === value, + (item) => item[key as keyof NetworkConfig] === value, ); }), } as unknown as ConfigProvider; diff --git a/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts index 63df7d1f1..945a48368 100644 --- a/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts @@ -5,7 +5,7 @@ import type { EventEmitter } from '../../../infrastructure'; import { Network } from '../../constants/solana'; import type { ILogger } from '../../utils/logger'; import type { ConfigProvider } from '../config'; -import type { NetworkWithRpcUrls } from '../config/ConfigProvider'; +import type { NetworkConfig } from '../config/ConfigProvider'; import type { WebSocketConnectionRepository } from './WebSocketConnectionRepository'; /** @@ -331,7 +331,7 @@ export class WebSocketConnectionService { * @param webSocketUrl - The WebSocket URL to get the network for. * @returns The network, or null if no network is associated with the connection ID. */ - #findNetworkByWebSocketUrl(webSocketUrl: string): NetworkWithRpcUrls | null { + #findNetworkByWebSocketUrl(webSocketUrl: string): NetworkConfig | null { return this.#configProvider.getNetworkBy('webSocketUrl', webSocketUrl); } } From 5bfe23dd6c08068408d1b7dc3c4b31c819a16385 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 14:29:43 +0200 Subject: [PATCH 6/7] chore: shasum --- packages/snap/snap.manifest.json | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index 0ff855c26..9c148a7f4 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-solana-wallet.git" }, "source": { - "shasum": "cXuI2V4c1TpiEXZ3o44HV+qoSEGR4pz1DvHCxTtz9xo=", + "shasum": "g/jfpUCvmwu6piUwA21tuyxrmaZZnZl3JdLeVJysYFM=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -19,8 +19,7 @@ "locales": ["locales/en.json"] }, "initialConnections": { - "https://portfolio.metamask.io": {}, - "http://localhost:3000": {} + "https://portfolio.metamask.io": {} }, "initialPermissions": { "endowment:rpc": { @@ -28,10 +27,7 @@ "snaps": false }, "endowment:keyring": { - "allowedOrigins": [ - "https://portfolio.metamask.io", - "http://localhost:3000" - ] + "allowedOrigins": ["https://portfolio.metamask.io"] }, "snap_getBip32Entropy": [ { From 6ab9bd002790c36cedb553773fa037388251c4a7 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Tue, 1 Jul 2025 14:34:56 +0200 Subject: [PATCH 7/7] fix: typo --- packages/snap/src/entities/subscriptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/snap/src/entities/subscriptions.ts b/packages/snap/src/entities/subscriptions.ts index 66969bf38..15ae59b3c 100644 --- a/packages/snap/src/entities/subscriptions.ts +++ b/packages/snap/src/entities/subscriptions.ts @@ -59,7 +59,7 @@ export type WebSocketConnection = GetWebSocketsResult[number]; /** * Once the Subscriber acknowledges the subscription request, - * it generates a subscrption ID, and the subscription is pending (waiting for the confirmation message). + * it generates a subscription ID, and the subscription is pending (waiting for the confirmation message). */ export type PendingSubscription = SubscriptionRequest & { readonly id: string;