From 32ecac058a6e900670c8625f4505d767d431bc12 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Fri, 18 Sep 2026 16:21:58 -0500 Subject: [PATCH 1/9] feat: add AutorampActivityService for Gateway lifecycle signals Subscribe to autoramp-activity.v1 and emit eventReceived so clients can refresh authoritative NeoBank state. Co-authored-by: Cursor --- packages/core-backend/CHANGELOG.md | 4 + packages/core-backend/README.md | 31 ++ packages/core-backend/src/index.ts | 25 ++ .../src/ws/AutorampActivityService.test.ts | 406 ++++++++++++++++++ .../src/ws/AutorampActivityService.ts | 363 ++++++++++++++++ 5 files changed, 829 insertions(+) create mode 100644 packages/core-backend/src/ws/AutorampActivityService.test.ts create mode 100644 packages/core-backend/src/ws/AutorampActivityService.ts diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 172cb4987bd..1afde332136 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AutorampActivityService` for validated, profile-scoped Autoramp activity notifications over `BackendWebSocketService` + ### Changed - Bump `uuid` from `^9.0.1` to `^11.1.1` ([#10243](https://github.com/MetaMask/core/pull/10243)) diff --git a/packages/core-backend/README.md b/packages/core-backend/README.md index c42d3a7aaf9..b26712846f6 100644 --- a/packages/core-backend/README.md +++ b/packages/core-backend/README.md @@ -38,6 +38,7 @@ Core backend services for MetaMask, serving as the data layer between Backend se - [Constructor Options](#constructor-options-1) - [Methods](#methods-1) - [Events Published](#events-published) + - [AutorampActivityService](#autorampactivityservice) ## Installation @@ -655,3 +656,33 @@ interface AccountActivityServiceOptions { - `AccountActivityService:balanceUpdated` - Real-time balance changes - `AccountActivityService:transactionUpdated` - Transaction status updates - `AccountActivityService:statusChanged` - Chain/service status changes + +### AutorampActivityService + +Profile-scoped service for receiving Autoramp activity notifications through +`BackendWebSocketService`. It derives the +`autoramp-activity.v1.` channel from +`AuthenticationController:getSessionProfile`, preferring +`canonicalProfileId` (the same identity Ramps uses as MoonPay `external_id`) +and falling back to the per-SRP `profileId`. It validates every server event at +runtime, and automatically resubscribes after WebSocket reconnects, profile +changes, and wallet unlocks. + +```typescript +const autorampActivityService = new AutorampActivityService({ + messenger: autorampActivityServiceMessenger, +}); + +await autorampActivityService.init(); + +messenger.subscribe('AutorampActivityService:eventReceived', (event) => { + if (event.needsFetch) { + // Refresh Autoramp data using the owning HTTP service. + } +}); +``` + +Published events: + +- `AutorampActivityService:eventReceived` - A validated profile activity event +- `AutorampActivityService:statusChanged` - The backend WebSocket connection status diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index be6b1a8c32c..e5ad1fb40a0 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -64,6 +64,31 @@ export type { AccountActivityMessage, } from './types.js'; +// ============================================================================ +// AUTORAMP ACTIVITY SERVICE +// ============================================================================ + +export { + AutorampActivityService, + AUTORAMP_ACTIVITY_CATEGORIES, + AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS, + AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS, +} from './ws/AutorampActivityService.js'; + +export type { + AutorampActivityCategory, + AutorampActivityEntity, + AutorampActivityEvent, + AutorampActivityServiceOptions, + AutorampActivityServiceActions, + AutorampActivityServiceAllowedActions, + AutorampActivityServiceEventReceivedEvent, + AutorampActivityServiceStatusChangedEvent, + AutorampActivityServiceEvents, + AutorampActivityServiceAllowedEvents, + AutorampActivityServiceMessenger, +} from './ws/AutorampActivityService.js'; + // ============================================================================ // API PLATFORM CLIENT SERVICE // ============================================================================ diff --git a/packages/core-backend/src/ws/AutorampActivityService.test.ts b/packages/core-backend/src/ws/AutorampActivityService.test.ts new file mode 100644 index 00000000000..da18481d45d --- /dev/null +++ b/packages/core-backend/src/ws/AutorampActivityService.test.ts @@ -0,0 +1,406 @@ +import type { KeyringControllerUnlockEvent } from '@metamask/keyring-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises } from '../../../../tests/helpers.js'; +import { + AutorampActivityService, + AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS, + AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS, +} from './AutorampActivityService.js'; +import type { AutorampActivityServiceMessenger } from './AutorampActivityService.js'; +import type { ServerNotificationMessage } from './BackendWebSocketService.js'; +import { WebSocketState } from './BackendWebSocketService.js'; + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; +type RootMessenger = Messenger< + MockAnyNamespace, + AllActions, + AllEvents | KeyringControllerUnlockEvent +>; + +const CONNECTION_INFO = { + state: WebSocketState.CONNECTED, + url: 'ws://test', + reconnectAttempts: 0, + timeout: 10_000, + reconnectDelay: 500, + maxReconnectDelay: 5_000, + requestTimeout: 30_000, +}; + +const PROFILE = { + identifierId: 'identifier-id', + profileId: 'profile-id', + canonicalProfileId: 'canonical-profile-id', + metaMetricsId: 'metrics-id', +}; + +const VALID_EVENT = { + eventId: 'event-id', + type: 'transaction.updated', + category: 'transaction', + occurredAt: '2026-09-17T12:00:00.000Z', + entity: { + id: 'transaction-id', + kind: 'deposit', + status: 'completed', + transactionStatus: 'confirmed', + transactionHash: '0x123', + }, + needsFetch: true, +} as const; + +const completeAsyncOperations = async (): Promise => { + await flushPromises(); + await flushPromises(); + await flushPromises(); +}; + +type ServiceSetup = { + service: AutorampActivityService; + messenger: AutorampActivityServiceMessenger; + rootMessenger: RootMessenger; + mocks: { + getSessionProfile: jest.Mock; + connect: jest.Mock; + subscribe: jest.Mock; + getConnectionInfo: jest.Mock; + channelHasSubscription: jest.Mock; + findSubscriptionsByChannelPrefix: jest.Mock; + }; +}; + +const setupService = (profile = PROFILE): ServiceSetup => { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: AutorampActivityServiceMessenger = new Messenger({ + namespace: 'AutorampActivityService', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [...AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS], + events: [...AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS], + }); + + const getSessionProfile = jest.fn().mockResolvedValue(profile); + const connect = jest.fn().mockResolvedValue(undefined); + const subscribe = jest.fn().mockResolvedValue(undefined); + const getConnectionInfo = jest.fn().mockReturnValue(CONNECTION_INFO); + const channelHasSubscription = jest.fn().mockReturnValue(false); + const findSubscriptionsByChannelPrefix = jest.fn().mockReturnValue([]); + + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:connect', + connect, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:subscribe', + subscribe, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:getConnectionInfo', + getConnectionInfo, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:channelHasSubscription', + channelHasSubscription, + ); + rootMessenger.registerActionHandler( + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + findSubscriptionsByChannelPrefix, + ); + + const service = new AutorampActivityService({ messenger }); + + return { + service, + messenger, + rootMessenger, + mocks: { + getSessionProfile, + connect, + subscribe, + getConnectionInfo, + channelHasSubscription, + findSubscriptionsByChannelPrefix, + }, + }; +}; + +const getSubscriptionCallback = ( + subscribe: jest.Mock, +): ((notification: ServerNotificationMessage) => void) => + subscribe.mock.calls.at(-1)[0].callback; + +describe('AutorampActivityService', () => { + it('derives the channel from the session profile', async () => { + const { service, mocks } = setupService(); + + await service.init(); + + expect(mocks.connect).toHaveBeenCalledTimes(1); + expect(mocks.getSessionProfile).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenCalledWith({ + channels: ['autoramp-activity.v1.canonical-profile-id'], + channelType: 'autoramp-activity.v1', + callback: expect.any(Function), + }); + }); + + it('does not subscribe when no profile can be resolved', async () => { + const { service, mocks } = setupService(); + mocks.getSessionProfile.mockRejectedValue(new Error('No profile')); + + await service.init(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('publishes validated events without unsupported fields', async () => { + const { service, messenger, mocks } = setupService(); + const listener = jest.fn(); + messenger.subscribe('AutorampActivityService:eventReceived', listener); + await service.init(); + + getSubscriptionCallback(mocks.subscribe)({ + event: 'notification', + channel: 'autoramp-activity.v1.canonical-profile-id', + data: { ...VALID_EVENT, ignored: 'field' }, + timestamp: Date.now(), + } as ServerNotificationMessage); + + expect(listener).toHaveBeenCalledWith(VALID_EVENT); + }); + + it.each([ + { ...VALID_EVENT, eventId: 1 }, + { ...VALID_EVENT, category: 'invalid' }, + { ...VALID_EVENT, entity: { status: 'missing-id' } }, + { ...VALID_EVENT, needsFetch: 'yes' }, + { ...VALID_EVENT, payload: {} }, + { ...VALID_EVENT, customerId: 'customer-id' }, + { ...VALID_EVENT, userId: 'user-id' }, + ])('ignores malformed event data %#', async (data) => { + const { service, messenger, mocks } = setupService(); + const listener = jest.fn(); + messenger.subscribe('AutorampActivityService:eventReceived', listener); + await service.init(); + + getSubscriptionCallback(mocks.subscribe)({ + event: 'notification', + channel: 'autoramp-activity.v1.canonical-profile-id', + data, + timestamp: Date.now(), + } as unknown as ServerNotificationMessage); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('accepts a null entity', async () => { + const { service, messenger, mocks } = setupService(); + const listener = jest.fn(); + messenger.subscribe('AutorampActivityService:eventReceived', listener); + await service.init(); + + getSubscriptionCallback(mocks.subscribe)({ + event: 'notification', + channel: 'autoramp-activity.v1.canonical-profile-id', + data: { ...VALID_EVENT, entity: null }, + timestamp: Date.now(), + } as ServerNotificationMessage); + + expect(listener).toHaveBeenCalledWith({ ...VALID_EVENT, entity: null }); + }); + + it('resubscribes after reconnect', async () => { + const { service, rootMessenger, mocks } = setupService(); + await service.init(); + mocks.subscribe.mockClear(); + + rootMessenger.publish('BackendWebSocketService:connectionStateChanged', { + ...CONNECTION_INFO, + reconnectAttempts: 1, + }); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: ['autoramp-activity.v1.canonical-profile-id'], + }), + ); + }); + + it('unsubscribes the old profile before subscribing to a changed profile', async () => { + const { service, rootMessenger, mocks } = setupService(); + const unsubscribe = jest.fn().mockResolvedValue(undefined); + await service.init(); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([{ unsubscribe }]); + mocks.getSessionProfile.mockResolvedValue({ + ...PROFILE, + canonicalProfileId: 'new-canonical-profile-id', + }); + + rootMessenger.publish('AuthenticationController:profileSignIn', { + profileId: 'new-canonical-profile-id', + profileAliases: [], + profileIdChanged: true, + }); + await completeAsyncOperations(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).toHaveBeenLastCalledWith( + expect.objectContaining({ + channels: ['autoramp-activity.v1.new-canonical-profile-id'], + }), + ); + }); + + it('resubscribes after wallet unlock', async () => { + const { rootMessenger, mocks } = setupService(); + + rootMessenger.publish('KeyringController:unlock'); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: ['autoramp-activity.v1.canonical-profile-id'], + }), + ); + }); + + it('cleans up active subscriptions on destroy', async () => { + const { service, mocks } = setupService(); + const unsubscribe = jest.fn().mockResolvedValue(undefined); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([{ unsubscribe }]); + + await service.destroy(); + + expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalledWith( + 'autoramp-activity.v1', + ); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('publishes connection status changes', async () => { + const { messenger, rootMessenger } = setupService(); + const listener = jest.fn(); + messenger.subscribe('AutorampActivityService:statusChanged', listener); + + rootMessenger.publish('BackendWebSocketService:connectionStateChanged', { + ...CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + }); + await completeAsyncOperations(); + + expect(listener).toHaveBeenCalledWith({ + status: WebSocketState.DISCONNECTED, + }); + }); + + it('cleans up subscriptions after sign out', async () => { + const { rootMessenger, mocks } = setupService(); + const unsubscribe = jest.fn().mockResolvedValue(undefined); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([{ unsubscribe }]); + + rootMessenger.publish( + 'AuthenticationController:stateChange', + { isSignedIn: false }, + [], + ); + await completeAsyncOperations(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('falls back to the per-SRP profile id when canonical is empty', async () => { + const { service, mocks } = setupService({ + ...PROFILE, + canonicalProfileId: '', + }); + + await service.init(); + + expect(mocks.subscribe).toHaveBeenCalledWith( + expect.objectContaining({ + channels: ['autoramp-activity.v1.profile-id'], + }), + ); + }); + + it('subscribes when the wallet signs in', async () => { + const { rootMessenger, mocks } = setupService(); + + rootMessenger.publish( + 'AuthenticationController:stateChange', + { isSignedIn: true }, + [], + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).toHaveBeenCalledTimes(1); + }); + + it('does not resubscribe when sign-in state is unchanged', async () => { + const { rootMessenger, mocks } = setupService(); + rootMessenger.publish( + 'AuthenticationController:stateChange', + { isSignedIn: true }, + [], + ); + await completeAsyncOperations(); + mocks.subscribe.mockClear(); + + rootMessenger.publish( + 'AuthenticationController:stateChange', + { isSignedIn: true, sessionData: { token: 'rotated' } }, + [], + ); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('skips subscribe when the channel is already registered', async () => { + const { service, mocks } = setupService(); + mocks.channelHasSubscription.mockReturnValue(true); + + await service.init(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('does not subscribe after destroy on reconnect', async () => { + const { service, rootMessenger, mocks } = setupService(); + await service.destroy(); + mocks.subscribe.mockClear(); + + rootMessenger.publish('BackendWebSocketService:connectionStateChanged', { + ...CONNECTION_INFO, + }); + await completeAsyncOperations(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('does not throw from init when connect fails', async () => { + const { service, mocks } = setupService(); + mocks.connect.mockRejectedValue(new Error('connect failed')); + + await expect(service.init()).resolves.toBeUndefined(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core-backend/src/ws/AutorampActivityService.ts b/packages/core-backend/src/ws/AutorampActivityService.ts new file mode 100644 index 00000000000..e7d28878fc8 --- /dev/null +++ b/packages/core-backend/src/ws/AutorampActivityService.ts @@ -0,0 +1,363 @@ +import type { KeyringControllerUnlockEvent } from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; + +import { projectLogger, createModuleLogger } from '../logger.js'; +import type { BackendWebSocketServiceMethodActions } from './BackendWebSocketService-method-action-types.js'; +import type { + BackendWebSocketServiceConnectionStateChangedEvent, + ServerNotificationMessage, + WebSocketConnectionInfo, +} from './BackendWebSocketService.js'; +import { WebSocketState } from './BackendWebSocketService.js'; + +const SERVICE_NAME = 'AutorampActivityService'; +const SUBSCRIPTION_NAMESPACE = 'autoramp-activity.v1'; +const MESSENGER_EXPOSED_METHODS = [] as const; + +const log = createModuleLogger(projectLogger, SERVICE_NAME); + +export const AUTORAMP_ACTIVITY_CATEGORIES = [ + 'customer', + 'autoramp', + 'transaction', + 'identification', + 'fiat_address', + 'unknown', +] as const; + +export type AutorampActivityCategory = + (typeof AUTORAMP_ACTIVITY_CATEGORIES)[number]; + +export type AutorampActivityEntity = { + id: string; + kind?: string; + status?: string; + transactionStatus?: string; + transactionHash?: string; +}; + +export type AutorampActivityEvent = { + eventId: string; + type: string; + category: AutorampActivityCategory; + occurredAt: string; + entity: AutorampActivityEntity | null; + needsFetch: boolean; +}; + +export type AutorampActivityServiceOptions = { + messenger: AutorampActivityServiceMessenger; +}; + +export type AutorampActivityServiceActions = never; + +export const AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ + 'AuthenticationController:getSessionProfile', + 'BackendWebSocketService:connect', + 'BackendWebSocketService:subscribe', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', +] as const; + +export const AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS = [ + 'AuthenticationController:stateChange', + 'AuthenticationController:profileSignIn', + 'BackendWebSocketService:connectionStateChanged', + 'KeyringController:unlock', +] as const; + +export type AutorampActivityServiceAllowedActions = + | AuthenticationController.AuthenticationControllerGetSessionProfileAction + | BackendWebSocketServiceMethodActions; + +export type AutorampActivityServiceEventReceivedEvent = { + type: 'AutorampActivityService:eventReceived'; + payload: [AutorampActivityEvent]; +}; + +export type AutorampActivityServiceStatusChangedEvent = { + type: 'AutorampActivityService:statusChanged'; + payload: [{ status: WebSocketState }]; +}; + +export type AutorampActivityServiceEvents = + | AutorampActivityServiceEventReceivedEvent + | AutorampActivityServiceStatusChangedEvent; + +export type AutorampActivityServiceAllowedEvents = + | AuthenticationController.AuthenticationControllerStateChangeEvent + | AuthenticationController.AuthenticationControllerProfileSignInEvent + | BackendWebSocketServiceConnectionStateChangedEvent + | KeyringControllerUnlockEvent; + +export type AutorampActivityServiceMessenger = Messenger< + typeof SERVICE_NAME, + AutorampActivityServiceActions | AutorampActivityServiceAllowedActions, + AutorampActivityServiceEvents | AutorampActivityServiceAllowedEvents +>; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const getHasOwnProperty = ( + value: Record, + key: string, +): boolean => Object.prototype.hasOwnProperty.call(value, key); + +const getOptionalString = ( + value: Record, + key: string, +): string | undefined | false => { + const property = value[key]; + if (property === undefined) { + return undefined; + } + return typeof property === 'string' ? property : false; +}; + +const parseEntity = (value: unknown): AutorampActivityEntity | null | false => { + if (value === null) { + return null; + } + if (!isRecord(value) || typeof value.id !== 'string') { + return false; + } + + const kind = getOptionalString(value, 'kind'); + const status = getOptionalString(value, 'status'); + const transactionStatus = getOptionalString(value, 'transactionStatus'); + const transactionHash = getOptionalString(value, 'transactionHash'); + if ( + kind === false || + status === false || + transactionStatus === false || + transactionHash === false + ) { + return false; + } + + return { + id: value.id, + ...(kind === undefined ? {} : { kind }), + ...(status === undefined ? {} : { status }), + ...(transactionStatus === undefined ? {} : { transactionStatus }), + ...(transactionHash === undefined ? {} : { transactionHash }), + }; +}; + +const parseAutorampActivityEvent = ( + value: unknown, +): AutorampActivityEvent | undefined => { + if ( + !isRecord(value) || + typeof value.eventId !== 'string' || + typeof value.type !== 'string' || + !AUTORAMP_ACTIVITY_CATEGORIES.includes( + value.category as AutorampActivityCategory, + ) || + typeof value.occurredAt !== 'string' || + typeof value.needsFetch !== 'boolean' || + getHasOwnProperty(value, 'payload') || + getHasOwnProperty(value, 'customerId') || + getHasOwnProperty(value, 'userId') + ) { + return undefined; + } + + const entity = parseEntity(value.entity); + if (entity === false) { + return undefined; + } + + return { + eventId: value.eventId, + type: value.type, + category: value.category as AutorampActivityCategory, + occurredAt: value.occurredAt, + entity, + needsFetch: value.needsFetch, + }; +}; + +/** + * Subscribes to profile-scoped Autoramp activity through + * {@link BackendWebSocketService}. The service owns no domain state; it only + * tracks its subscription lifecycle and publishes validated notifications. + */ +export class AutorampActivityService { + readonly name = SERVICE_NAME; + + readonly #messenger: AutorampActivityServiceMessenger; + + #isDestroyed = false; + + constructor({ messenger }: AutorampActivityServiceOptions) { + this.#messenger = messenger; + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#messenger.subscribe( + 'BackendWebSocketService:connectionStateChanged', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (connectionInfo: WebSocketConnectionInfo) => + await this.#handleConnectionStateChange(connectionInfo), + ); + this.#messenger.subscribe( + 'AuthenticationController:stateChange', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (isSignedIn: boolean) => + await this.#handleAuthenticationStateChange(isSignedIn), + // Messenger compares selector results with `!==`. Return the boolean + // primitive so token/session writes do not resubscribe. + (state: AuthenticationController.AuthenticationControllerState) => + state.isSignedIn, + ); + this.#messenger.subscribe( + 'AuthenticationController:profileSignIn', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => await this.#replaceSubscription(), + ); + this.#messenger.subscribe( + 'KeyringController:unlock', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => await this.#replaceSubscription(), + ); + } + + /** + * Connect and subscribe for the current authentication profile. + */ + async init(): Promise { + await this.#replaceSubscription(); + } + + async #handleConnectionStateChange( + connectionInfo: WebSocketConnectionInfo, + ): Promise { + if (this.#isDestroyed) { + return; + } + + this.#messenger.publish('AutorampActivityService:statusChanged', { + status: connectionInfo.state, + }); + + if (connectionInfo.state === WebSocketState.CONNECTED) { + await this.#subscribeToCurrentProfile(); + } + } + + async #handleAuthenticationStateChange(isSignedIn: boolean): Promise { + if (isSignedIn) { + await this.#replaceSubscription(); + } else { + await this.#unsubscribeAll(); + } + } + + async #replaceSubscription(): Promise { + if (this.#isDestroyed) { + return; + } + + await this.#unsubscribeAll(); + await this.#subscribeToCurrentProfile(); + } + + async #subscribeToCurrentProfile(): Promise { + if (this.#isDestroyed) { + return; + } + + try { + await this.#messenger.call('BackendWebSocketService:connect'); + const profile = await this.#messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const profileId = this.#getChannelProfileId(profile); + if (!profileId || this.#isDestroyed) { + return; + } + + const channel = `${SUBSCRIPTION_NAMESPACE}.${profileId}`; + const connectionInfo = this.#messenger.call( + 'BackendWebSocketService:getConnectionInfo', + ); + if (connectionInfo.state !== WebSocketState.CONNECTED) { + return; + } + + if ( + this.#messenger.call( + 'BackendWebSocketService:channelHasSubscription', + channel, + ) + ) { + return; + } + + if (this.#isDestroyed) { + return; + } + + await this.#messenger.call('BackendWebSocketService:subscribe', { + channels: [channel], + channelType: SUBSCRIPTION_NAMESPACE, + callback: (notification: ServerNotificationMessage) => + this.#handleNotification(notification), + }); + } catch (error) { + log('Unable to subscribe to Autoramp activity', { error }); + } + } + + #getChannelProfileId(profile: { + canonicalProfileId?: string; + profileId?: string; + }): string | undefined { + const canonical = profile.canonicalProfileId; + if (typeof canonical === 'string' && canonical.length > 0) { + return canonical; + } + + const profileId = profile.profileId; + return typeof profileId === 'string' && profileId.length > 0 + ? profileId + : undefined; + } + + #handleNotification(notification: ServerNotificationMessage): void { + const event = parseAutorampActivityEvent(notification.data); + if (!event) { + log('Ignoring malformed Autoramp activity event', { + channel: notification.channel, + }); + return; + } + + this.#messenger.publish('AutorampActivityService:eventReceived', event); + } + + async #unsubscribeAll(): Promise { + const subscriptions = this.#messenger.call( + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + SUBSCRIPTION_NAMESPACE, + ); + + for (const subscription of subscriptions) { + await subscription.unsubscribe(); + } + } + + /** + * Stop future subscriptions and remove all active Autoramp subscriptions. + */ + async destroy(): Promise { + this.#isDestroyed = true; + await this.#unsubscribeAll(); + } +} From 92908886e6bb539ffbe17cff934f9d89c573568f Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Fri, 18 Sep 2026 16:28:44 -0500 Subject: [PATCH 2/9] fix: drop restricted Allowed type re-exports from core-backend Keep Autoramp messenger dependency unions package-internal so lint matches Core controller guidelines. Co-authored-by: Cursor --- packages/core-backend/src/index.ts | 6 ------ .../core-backend/src/ws/AutorampActivityService.test.ts | 2 +- packages/core-backend/src/ws/AutorampActivityService.ts | 2 +- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index e5ad1fb40a0..405737184a5 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -41,13 +41,11 @@ export type { SubscriptionOptions, AccountActivityServiceOptions, AccountActivityServiceActions, - AllowedActions as AccountActivityServiceAllowedActions, AccountActivityServiceTransactionUpdatedEvent, AccountActivityServiceBalanceUpdatedEvent, AccountActivityServiceSubscriptionErrorEvent, AccountActivityServiceStatusChangedEvent, AccountActivityServiceEvents, - AllowedEvents as AccountActivityServiceAllowedEvents, AccountActivityServiceMessenger, } from './ws/AccountActivityService.js'; @@ -81,11 +79,9 @@ export type { AutorampActivityEvent, AutorampActivityServiceOptions, AutorampActivityServiceActions, - AutorampActivityServiceAllowedActions, AutorampActivityServiceEventReceivedEvent, AutorampActivityServiceStatusChangedEvent, AutorampActivityServiceEvents, - AutorampActivityServiceAllowedEvents, AutorampActivityServiceMessenger, } from './ws/AutorampActivityService.js'; @@ -121,12 +117,10 @@ export type { OHLCVSystemNotificationData, OHLCVServiceOptions, OHLCVServiceActions, - OHLCVServiceAllowedActions, OHLCVServiceBarUpdatedEvent, OHLCVServiceChainStatusChangedEvent, OHLCVServiceSubscriptionErrorEvent, OHLCVServiceEvents, - OHLCVServiceAllowedEvents, OHLCVServiceMessenger, } from './ws/ohlcv/index.js'; diff --git a/packages/core-backend/src/ws/AutorampActivityService.test.ts b/packages/core-backend/src/ws/AutorampActivityService.test.ts index da18481d45d..da97b21acfb 100644 --- a/packages/core-backend/src/ws/AutorampActivityService.test.ts +++ b/packages/core-backend/src/ws/AutorampActivityService.test.ts @@ -400,7 +400,7 @@ describe('AutorampActivityService', () => { const { service, mocks } = setupService(); mocks.connect.mockRejectedValue(new Error('connect failed')); - await expect(service.init()).resolves.toBeUndefined(); + expect(await service.init()).toBeUndefined(); expect(mocks.subscribe).not.toHaveBeenCalled(); }); }); diff --git a/packages/core-backend/src/ws/AutorampActivityService.ts b/packages/core-backend/src/ws/AutorampActivityService.ts index e7d28878fc8..dc71548644b 100644 --- a/packages/core-backend/src/ws/AutorampActivityService.ts +++ b/packages/core-backend/src/ws/AutorampActivityService.ts @@ -324,7 +324,7 @@ export class AutorampActivityService { return canonical; } - const profileId = profile.profileId; + const { profileId } = profile; return typeof profileId === 'string' && profileId.length > 0 ? profileId : undefined; From 13feeb926c9a98dd25ea02e653b17055e506ccaf Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 03:18:34 -0500 Subject: [PATCH 3/9] feat: rename AutorampActivityService to RampsActivityService Subscribe to ramps-activity.v1 so Core matches the Gateway channel for all ramps lifecycle signals. Co-authored-by: Cursor --- packages/core-backend/CHANGELOG.md | 2 +- packages/core-backend/README.md | 22 ++--- packages/core-backend/src/index.ts | 32 +++---- ...e.test.ts => RampsActivityService.test.ts} | 58 ++++++------- ...vityService.ts => RampsActivityService.ts} | 86 +++++++++---------- 5 files changed, 100 insertions(+), 100 deletions(-) rename packages/core-backend/src/ws/{AutorampActivityService.test.ts => RampsActivityService.test.ts} (86%) rename packages/core-backend/src/ws/{AutorampActivityService.ts => RampsActivityService.ts} (78%) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 1afde332136..61ccd49db1f 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `AutorampActivityService` for validated, profile-scoped Autoramp activity notifications over `BackendWebSocketService` +- Add `RampsActivityService` for validated, profile-scoped ramps activity notifications over `BackendWebSocketService` (`ramps-activity.v1.`) ### Changed diff --git a/packages/core-backend/README.md b/packages/core-backend/README.md index b26712846f6..f6d9674952c 100644 --- a/packages/core-backend/README.md +++ b/packages/core-backend/README.md @@ -38,7 +38,7 @@ Core backend services for MetaMask, serving as the data layer between Backend se - [Constructor Options](#constructor-options-1) - [Methods](#methods-1) - [Events Published](#events-published) - - [AutorampActivityService](#autorampactivityservice) + - [RampsActivityService](#rampsactivityservice) ## Installation @@ -657,11 +657,11 @@ interface AccountActivityServiceOptions { - `AccountActivityService:transactionUpdated` - Transaction status updates - `AccountActivityService:statusChanged` - Chain/service status changes -### AutorampActivityService +### RampsActivityService -Profile-scoped service for receiving Autoramp activity notifications through +Profile-scoped service for receiving ramps activity notifications through `BackendWebSocketService`. It derives the -`autoramp-activity.v1.` channel from +`ramps-activity.v1.` channel from `AuthenticationController:getSessionProfile`, preferring `canonicalProfileId` (the same identity Ramps uses as MoonPay `external_id`) and falling back to the per-SRP `profileId`. It validates every server event at @@ -669,20 +669,20 @@ runtime, and automatically resubscribes after WebSocket reconnects, profile changes, and wallet unlocks. ```typescript -const autorampActivityService = new AutorampActivityService({ - messenger: autorampActivityServiceMessenger, +const rampsActivityService = new RampsActivityService({ + messenger: rampsActivityServiceMessenger, }); -await autorampActivityService.init(); +await rampsActivityService.init(); -messenger.subscribe('AutorampActivityService:eventReceived', (event) => { +messenger.subscribe('RampsActivityService:eventReceived', (event) => { if (event.needsFetch) { - // Refresh Autoramp data using the owning HTTP service. + // Refresh ramps data via RampsController (GET is source of truth). } }); ``` Published events: -- `AutorampActivityService:eventReceived` - A validated profile activity event -- `AutorampActivityService:statusChanged` - The backend WebSocket connection status +- `RampsActivityService:eventReceived` - A validated profile-scoped ramps activity event +- `RampsActivityService:statusChanged` - The backend WebSocket connection status diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index 405737184a5..c3a68b660c2 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -63,27 +63,27 @@ export type { } from './types.js'; // ============================================================================ -// AUTORAMP ACTIVITY SERVICE +// RAMPS ACTIVITY SERVICE // ============================================================================ export { - AutorampActivityService, - AUTORAMP_ACTIVITY_CATEGORIES, - AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS, - AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS, -} from './ws/AutorampActivityService.js'; + RampsActivityService, + RAMPS_ACTIVITY_CATEGORIES, + RAMPS_ACTIVITY_SERVICE_ALLOWED_ACTIONS, + RAMPS_ACTIVITY_SERVICE_ALLOWED_EVENTS, +} from './ws/RampsActivityService.js'; export type { - AutorampActivityCategory, - AutorampActivityEntity, - AutorampActivityEvent, - AutorampActivityServiceOptions, - AutorampActivityServiceActions, - AutorampActivityServiceEventReceivedEvent, - AutorampActivityServiceStatusChangedEvent, - AutorampActivityServiceEvents, - AutorampActivityServiceMessenger, -} from './ws/AutorampActivityService.js'; + RampsActivityCategory, + RampsActivityEntity, + RampsActivityEvent, + RampsActivityServiceOptions, + RampsActivityServiceActions, + RampsActivityServiceEventReceivedEvent, + RampsActivityServiceStatusChangedEvent, + RampsActivityServiceEvents, + RampsActivityServiceMessenger, +} from './ws/RampsActivityService.js'; // ============================================================================ // API PLATFORM CLIENT SERVICE diff --git a/packages/core-backend/src/ws/AutorampActivityService.test.ts b/packages/core-backend/src/ws/RampsActivityService.test.ts similarity index 86% rename from packages/core-backend/src/ws/AutorampActivityService.test.ts rename to packages/core-backend/src/ws/RampsActivityService.test.ts index da97b21acfb..ec5c3ceb542 100644 --- a/packages/core-backend/src/ws/AutorampActivityService.test.ts +++ b/packages/core-backend/src/ws/RampsActivityService.test.ts @@ -8,16 +8,16 @@ import type { import { flushPromises } from '../../../../tests/helpers.js'; import { - AutorampActivityService, - AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS, - AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS, -} from './AutorampActivityService.js'; -import type { AutorampActivityServiceMessenger } from './AutorampActivityService.js'; + RampsActivityService, + RAMPS_ACTIVITY_SERVICE_ALLOWED_ACTIONS, + RAMPS_ACTIVITY_SERVICE_ALLOWED_EVENTS, +} from './RampsActivityService.js'; +import type { RampsActivityServiceMessenger } from './RampsActivityService.js'; import type { ServerNotificationMessage } from './BackendWebSocketService.js'; import { WebSocketState } from './BackendWebSocketService.js'; -type AllActions = MessengerActions; -type AllEvents = MessengerEvents; +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; type RootMessenger = Messenger< MockAnyNamespace, AllActions, @@ -63,8 +63,8 @@ const completeAsyncOperations = async (): Promise => { }; type ServiceSetup = { - service: AutorampActivityService; - messenger: AutorampActivityServiceMessenger; + service: RampsActivityService; + messenger: RampsActivityServiceMessenger; rootMessenger: RootMessenger; mocks: { getSessionProfile: jest.Mock; @@ -80,15 +80,15 @@ const setupService = (profile = PROFILE): ServiceSetup => { const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, }); - const messenger: AutorampActivityServiceMessenger = new Messenger({ - namespace: 'AutorampActivityService', + const messenger: RampsActivityServiceMessenger = new Messenger({ + namespace: 'RampsActivityService', parent: rootMessenger, }); rootMessenger.delegate({ messenger, - actions: [...AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS], - events: [...AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS], + actions: [...RAMPS_ACTIVITY_SERVICE_ALLOWED_ACTIONS], + events: [...RAMPS_ACTIVITY_SERVICE_ALLOWED_EVENTS], }); const getSessionProfile = jest.fn().mockResolvedValue(profile); @@ -123,7 +123,7 @@ const setupService = (profile = PROFILE): ServiceSetup => { findSubscriptionsByChannelPrefix, ); - const service = new AutorampActivityService({ messenger }); + const service = new RampsActivityService({ messenger }); return { service, @@ -145,7 +145,7 @@ const getSubscriptionCallback = ( ): ((notification: ServerNotificationMessage) => void) => subscribe.mock.calls.at(-1)[0].callback; -describe('AutorampActivityService', () => { +describe('RampsActivityService', () => { it('derives the channel from the session profile', async () => { const { service, mocks } = setupService(); @@ -154,8 +154,8 @@ describe('AutorampActivityService', () => { expect(mocks.connect).toHaveBeenCalledTimes(1); expect(mocks.getSessionProfile).toHaveBeenCalledTimes(1); expect(mocks.subscribe).toHaveBeenCalledWith({ - channels: ['autoramp-activity.v1.canonical-profile-id'], - channelType: 'autoramp-activity.v1', + channels: ['ramps-activity.v1.canonical-profile-id'], + channelType: 'ramps-activity.v1', callback: expect.any(Function), }); }); @@ -172,12 +172,12 @@ describe('AutorampActivityService', () => { it('publishes validated events without unsupported fields', async () => { const { service, messenger, mocks } = setupService(); const listener = jest.fn(); - messenger.subscribe('AutorampActivityService:eventReceived', listener); + messenger.subscribe('RampsActivityService:eventReceived', listener); await service.init(); getSubscriptionCallback(mocks.subscribe)({ event: 'notification', - channel: 'autoramp-activity.v1.canonical-profile-id', + channel: 'ramps-activity.v1.canonical-profile-id', data: { ...VALID_EVENT, ignored: 'field' }, timestamp: Date.now(), } as ServerNotificationMessage); @@ -196,12 +196,12 @@ describe('AutorampActivityService', () => { ])('ignores malformed event data %#', async (data) => { const { service, messenger, mocks } = setupService(); const listener = jest.fn(); - messenger.subscribe('AutorampActivityService:eventReceived', listener); + messenger.subscribe('RampsActivityService:eventReceived', listener); await service.init(); getSubscriptionCallback(mocks.subscribe)({ event: 'notification', - channel: 'autoramp-activity.v1.canonical-profile-id', + channel: 'ramps-activity.v1.canonical-profile-id', data, timestamp: Date.now(), } as unknown as ServerNotificationMessage); @@ -212,12 +212,12 @@ describe('AutorampActivityService', () => { it('accepts a null entity', async () => { const { service, messenger, mocks } = setupService(); const listener = jest.fn(); - messenger.subscribe('AutorampActivityService:eventReceived', listener); + messenger.subscribe('RampsActivityService:eventReceived', listener); await service.init(); getSubscriptionCallback(mocks.subscribe)({ event: 'notification', - channel: 'autoramp-activity.v1.canonical-profile-id', + channel: 'ramps-activity.v1.canonical-profile-id', data: { ...VALID_EVENT, entity: null }, timestamp: Date.now(), } as ServerNotificationMessage); @@ -238,7 +238,7 @@ describe('AutorampActivityService', () => { expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: ['autoramp-activity.v1.canonical-profile-id'], + channels: ['ramps-activity.v1.canonical-profile-id'], }), ); }); @@ -263,7 +263,7 @@ describe('AutorampActivityService', () => { expect(unsubscribe).toHaveBeenCalledTimes(1); expect(mocks.subscribe).toHaveBeenLastCalledWith( expect.objectContaining({ - channels: ['autoramp-activity.v1.new-canonical-profile-id'], + channels: ['ramps-activity.v1.new-canonical-profile-id'], }), ); }); @@ -276,7 +276,7 @@ describe('AutorampActivityService', () => { expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: ['autoramp-activity.v1.canonical-profile-id'], + channels: ['ramps-activity.v1.canonical-profile-id'], }), ); }); @@ -289,7 +289,7 @@ describe('AutorampActivityService', () => { await service.destroy(); expect(mocks.findSubscriptionsByChannelPrefix).toHaveBeenCalledWith( - 'autoramp-activity.v1', + 'ramps-activity.v1', ); expect(unsubscribe).toHaveBeenCalledTimes(1); }); @@ -297,7 +297,7 @@ describe('AutorampActivityService', () => { it('publishes connection status changes', async () => { const { messenger, rootMessenger } = setupService(); const listener = jest.fn(); - messenger.subscribe('AutorampActivityService:statusChanged', listener); + messenger.subscribe('RampsActivityService:statusChanged', listener); rootMessenger.publish('BackendWebSocketService:connectionStateChanged', { ...CONNECTION_INFO, @@ -336,7 +336,7 @@ describe('AutorampActivityService', () => { expect(mocks.subscribe).toHaveBeenCalledWith( expect.objectContaining({ - channels: ['autoramp-activity.v1.profile-id'], + channels: ['ramps-activity.v1.profile-id'], }), ); }); diff --git a/packages/core-backend/src/ws/AutorampActivityService.ts b/packages/core-backend/src/ws/RampsActivityService.ts similarity index 78% rename from packages/core-backend/src/ws/AutorampActivityService.ts rename to packages/core-backend/src/ws/RampsActivityService.ts index dc71548644b..d9461ff4261 100644 --- a/packages/core-backend/src/ws/AutorampActivityService.ts +++ b/packages/core-backend/src/ws/RampsActivityService.ts @@ -11,13 +11,13 @@ import type { } from './BackendWebSocketService.js'; import { WebSocketState } from './BackendWebSocketService.js'; -const SERVICE_NAME = 'AutorampActivityService'; -const SUBSCRIPTION_NAMESPACE = 'autoramp-activity.v1'; +const SERVICE_NAME = 'RampsActivityService'; +const SUBSCRIPTION_NAMESPACE = 'ramps-activity.v1'; const MESSENGER_EXPOSED_METHODS = [] as const; const log = createModuleLogger(projectLogger, SERVICE_NAME); -export const AUTORAMP_ACTIVITY_CATEGORIES = [ +export const RAMPS_ACTIVITY_CATEGORIES = [ 'customer', 'autoramp', 'transaction', @@ -26,10 +26,10 @@ export const AUTORAMP_ACTIVITY_CATEGORIES = [ 'unknown', ] as const; -export type AutorampActivityCategory = - (typeof AUTORAMP_ACTIVITY_CATEGORIES)[number]; +export type RampsActivityCategory = + (typeof RAMPS_ACTIVITY_CATEGORIES)[number]; -export type AutorampActivityEntity = { +export type RampsActivityEntity = { id: string; kind?: string; status?: string; @@ -37,22 +37,22 @@ export type AutorampActivityEntity = { transactionHash?: string; }; -export type AutorampActivityEvent = { +export type RampsActivityEvent = { eventId: string; type: string; - category: AutorampActivityCategory; + category: RampsActivityCategory; occurredAt: string; - entity: AutorampActivityEntity | null; + entity: RampsActivityEntity | null; needsFetch: boolean; }; -export type AutorampActivityServiceOptions = { - messenger: AutorampActivityServiceMessenger; +export type RampsActivityServiceOptions = { + messenger: RampsActivityServiceMessenger; }; -export type AutorampActivityServiceActions = never; +export type RampsActivityServiceActions = never; -export const AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ +export const RAMPS_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'BackendWebSocketService:connect', 'BackendWebSocketService:subscribe', @@ -61,41 +61,41 @@ export const AUTORAMP_ACTIVITY_SERVICE_ALLOWED_ACTIONS = [ 'BackendWebSocketService:findSubscriptionsByChannelPrefix', ] as const; -export const AUTORAMP_ACTIVITY_SERVICE_ALLOWED_EVENTS = [ +export const RAMPS_ACTIVITY_SERVICE_ALLOWED_EVENTS = [ 'AuthenticationController:stateChange', 'AuthenticationController:profileSignIn', 'BackendWebSocketService:connectionStateChanged', 'KeyringController:unlock', ] as const; -export type AutorampActivityServiceAllowedActions = +export type RampsActivityServiceAllowedActions = | AuthenticationController.AuthenticationControllerGetSessionProfileAction | BackendWebSocketServiceMethodActions; -export type AutorampActivityServiceEventReceivedEvent = { - type: 'AutorampActivityService:eventReceived'; - payload: [AutorampActivityEvent]; +export type RampsActivityServiceEventReceivedEvent = { + type: 'RampsActivityService:eventReceived'; + payload: [RampsActivityEvent]; }; -export type AutorampActivityServiceStatusChangedEvent = { - type: 'AutorampActivityService:statusChanged'; +export type RampsActivityServiceStatusChangedEvent = { + type: 'RampsActivityService:statusChanged'; payload: [{ status: WebSocketState }]; }; -export type AutorampActivityServiceEvents = - | AutorampActivityServiceEventReceivedEvent - | AutorampActivityServiceStatusChangedEvent; +export type RampsActivityServiceEvents = + | RampsActivityServiceEventReceivedEvent + | RampsActivityServiceStatusChangedEvent; -export type AutorampActivityServiceAllowedEvents = +export type RampsActivityServiceAllowedEvents = | AuthenticationController.AuthenticationControllerStateChangeEvent | AuthenticationController.AuthenticationControllerProfileSignInEvent | BackendWebSocketServiceConnectionStateChangedEvent | KeyringControllerUnlockEvent; -export type AutorampActivityServiceMessenger = Messenger< +export type RampsActivityServiceMessenger = Messenger< typeof SERVICE_NAME, - AutorampActivityServiceActions | AutorampActivityServiceAllowedActions, - AutorampActivityServiceEvents | AutorampActivityServiceAllowedEvents + RampsActivityServiceActions | RampsActivityServiceAllowedActions, + RampsActivityServiceEvents | RampsActivityServiceAllowedEvents >; const isRecord = (value: unknown): value is Record => @@ -117,7 +117,7 @@ const getOptionalString = ( return typeof property === 'string' ? property : false; }; -const parseEntity = (value: unknown): AutorampActivityEntity | null | false => { +const parseEntity = (value: unknown): RampsActivityEntity | null | false => { if (value === null) { return null; } @@ -147,15 +147,15 @@ const parseEntity = (value: unknown): AutorampActivityEntity | null | false => { }; }; -const parseAutorampActivityEvent = ( +const parseRampsActivityEvent = ( value: unknown, -): AutorampActivityEvent | undefined => { +): RampsActivityEvent | undefined => { if ( !isRecord(value) || typeof value.eventId !== 'string' || typeof value.type !== 'string' || - !AUTORAMP_ACTIVITY_CATEGORIES.includes( - value.category as AutorampActivityCategory, + !RAMPS_ACTIVITY_CATEGORIES.includes( + value.category as RampsActivityCategory, ) || typeof value.occurredAt !== 'string' || typeof value.needsFetch !== 'boolean' || @@ -174,7 +174,7 @@ const parseAutorampActivityEvent = ( return { eventId: value.eventId, type: value.type, - category: value.category as AutorampActivityCategory, + category: value.category as RampsActivityCategory, occurredAt: value.occurredAt, entity, needsFetch: value.needsFetch, @@ -182,18 +182,18 @@ const parseAutorampActivityEvent = ( }; /** - * Subscribes to profile-scoped Autoramp activity through + * Subscribes to profile-scoped ramps activity through * {@link BackendWebSocketService}. The service owns no domain state; it only * tracks its subscription lifecycle and publishes validated notifications. */ -export class AutorampActivityService { +export class RampsActivityService { readonly name = SERVICE_NAME; - readonly #messenger: AutorampActivityServiceMessenger; + readonly #messenger: RampsActivityServiceMessenger; #isDestroyed = false; - constructor({ messenger }: AutorampActivityServiceOptions) { + constructor({ messenger }: RampsActivityServiceOptions) { this.#messenger = messenger; this.#messenger.registerMethodActionHandlers( this, @@ -242,7 +242,7 @@ export class AutorampActivityService { return; } - this.#messenger.publish('AutorampActivityService:statusChanged', { + this.#messenger.publish('RampsActivityService:statusChanged', { status: connectionInfo.state, }); @@ -311,7 +311,7 @@ export class AutorampActivityService { this.#handleNotification(notification), }); } catch (error) { - log('Unable to subscribe to Autoramp activity', { error }); + log('Unable to subscribe to ramps activity', { error }); } } @@ -331,15 +331,15 @@ export class AutorampActivityService { } #handleNotification(notification: ServerNotificationMessage): void { - const event = parseAutorampActivityEvent(notification.data); + const event = parseRampsActivityEvent(notification.data); if (!event) { - log('Ignoring malformed Autoramp activity event', { + log('Ignoring malformed ramps activity event', { channel: notification.channel, }); return; } - this.#messenger.publish('AutorampActivityService:eventReceived', event); + this.#messenger.publish('RampsActivityService:eventReceived', event); } async #unsubscribeAll(): Promise { @@ -354,7 +354,7 @@ export class AutorampActivityService { } /** - * Stop future subscriptions and remove all active Autoramp subscriptions. + * Stop future subscriptions and remove all active ramps subscriptions. */ async destroy(): Promise { this.#isDestroyed = true; From 53b1c339dd4d6ac303e3d113f7f7d17b4697b9ba Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 03:36:23 -0500 Subject: [PATCH 4/9] chore: prune stale ESLint suppressions Co-authored-by: Cursor --- eslint-suppressions.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 53965062ca9..ba225d7bd15 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -701,11 +701,6 @@ "count": 1 } }, - "packages/core-backend/src/index.ts": { - "no-restricted-syntax": { - "count": 4 - } - }, "packages/core-backend/src/ws/AccountActivityService.ts": { "no-restricted-syntax": { "count": 1 @@ -2080,4 +2075,4 @@ "count": 29 } } -} +} \ No newline at end of file From 3f2b794e796d0666cbc96ba95a40648e0233f414 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 03:37:50 -0500 Subject: [PATCH 5/9] test: cover ramps activity subscription lifecycle Co-authored-by: Cursor --- .../src/ws/RampsActivityService.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/packages/core-backend/src/ws/RampsActivityService.test.ts b/packages/core-backend/src/ws/RampsActivityService.test.ts index ec5c3ceb542..6ae8c96f6f4 100644 --- a/packages/core-backend/src/ws/RampsActivityService.test.ts +++ b/packages/core-backend/src/ws/RampsActivityService.test.ts @@ -188,7 +188,12 @@ describe('RampsActivityService', () => { it.each([ { ...VALID_EVENT, eventId: 1 }, { ...VALID_EVENT, category: 'invalid' }, + { ...VALID_EVENT, entity: [] }, { ...VALID_EVENT, entity: { status: 'missing-id' } }, + { ...VALID_EVENT, entity: { id: 'id', kind: 1 } }, + { ...VALID_EVENT, entity: { id: 'id', status: 1 } }, + { ...VALID_EVENT, entity: { id: 'id', transactionStatus: 1 } }, + { ...VALID_EVENT, entity: { id: 'id', transactionHash: 1 } }, { ...VALID_EVENT, needsFetch: 'yes' }, { ...VALID_EVENT, payload: {} }, { ...VALID_EVENT, customerId: 'customer-id' }, @@ -225,6 +230,23 @@ describe('RampsActivityService', () => { expect(listener).toHaveBeenCalledWith({ ...VALID_EVENT, entity: null }); }); + it('accepts an entity with only its required id', async () => { + const { service, messenger, mocks } = setupService(); + const listener = jest.fn(); + messenger.subscribe('RampsActivityService:eventReceived', listener); + await service.init(); + const event = { ...VALID_EVENT, entity: { id: 'entity-id' } }; + + getSubscriptionCallback(mocks.subscribe)({ + event: 'notification', + channel: 'ramps-activity.v1.canonical-profile-id', + data: event, + timestamp: Date.now(), + } as ServerNotificationMessage); + + expect(listener).toHaveBeenCalledWith(event); + }); + it('resubscribes after reconnect', async () => { const { service, rootMessenger, mocks } = setupService(); await service.init(); @@ -383,6 +405,98 @@ describe('RampsActivityService', () => { expect(mocks.subscribe).not.toHaveBeenCalled(); }); + it('skips subscribe while the WebSocket is disconnected', async () => { + const { service, mocks } = setupService(); + mocks.getConnectionInfo.mockReturnValue({ + ...CONNECTION_INFO, + state: WebSocketState.DISCONNECTED, + }); + + await service.init(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('skips subscribe when neither profile id is available', async () => { + const { service, mocks } = setupService({ + ...PROFILE, + canonicalProfileId: '', + profileId: '', + }); + + await service.init(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('does not subscribe when destroyed while resolving the profile', async () => { + const { service, mocks } = setupService(); + let resolveProfile: ((profile: typeof PROFILE) => void) | undefined; + mocks.getSessionProfile.mockReturnValue( + new Promise((resolve) => { + resolveProfile = resolve; + }), + ); + + const initPromise = service.init(); + await completeAsyncOperations(); + await service.destroy(); + resolveProfile?.(PROFILE); + await initPromise; + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('does not subscribe when destroyed while replacing a subscription', async () => { + const { service, mocks } = setupService(); + let resolveFirstUnsubscribe: (() => void) | undefined; + const unsubscribe = jest + .fn() + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveFirstUnsubscribe = resolve; + }), + ) + .mockResolvedValue(undefined); + mocks.findSubscriptionsByChannelPrefix.mockReturnValue([{ unsubscribe }]); + + const initPromise = service.init(); + await completeAsyncOperations(); + const destroyPromise = service.destroy(); + resolveFirstUnsubscribe?.(); + await Promise.all([initPromise, destroyPromise]); + + expect(mocks.connect).not.toHaveBeenCalled(); + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('does not subscribe when destroyed after checking the channel', async () => { + const { service, mocks } = setupService(); + mocks.channelHasSubscription.mockImplementation(() => { + void service.destroy(); + return false; + }); + + await service.init(); + + expect(mocks.subscribe).not.toHaveBeenCalled(); + }); + + it('does not replace a subscription after destroy', async () => { + const { service, rootMessenger, mocks } = setupService(); + await service.destroy(); + + rootMessenger.publish('AuthenticationController:profileSignIn', { + profileId: 'new-profile-id', + profileAliases: [], + profileIdChanged: true, + }); + await completeAsyncOperations(); + + expect(mocks.connect).not.toHaveBeenCalled(); + }); + it('does not subscribe after destroy on reconnect', async () => { const { service, rootMessenger, mocks } = setupService(); await service.destroy(); From 158c062ee85b7ea20ad5847078c05b96d33a6c03 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 03:43:42 -0500 Subject: [PATCH 6/9] test: satisfy ramps activity lint Co-authored-by: Cursor --- packages/core-backend/src/ws/RampsActivityService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-backend/src/ws/RampsActivityService.test.ts b/packages/core-backend/src/ws/RampsActivityService.test.ts index 6ae8c96f6f4..f3b4cbec453 100644 --- a/packages/core-backend/src/ws/RampsActivityService.test.ts +++ b/packages/core-backend/src/ws/RampsActivityService.test.ts @@ -474,7 +474,7 @@ describe('RampsActivityService', () => { it('does not subscribe when destroyed after checking the channel', async () => { const { service, mocks } = setupService(); mocks.channelHasSubscription.mockImplementation(() => { - void service.destroy(); + service.destroy().catch(() => undefined); return false; }); From 4d369a6b3992d2e54d9ea228a8631a8100641c0e Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 03:48:58 -0500 Subject: [PATCH 7/9] chore: format ramps activity service Co-authored-by: Cursor --- eslint-suppressions.json | 2 +- packages/core-backend/src/ws/RampsActivityService.test.ts | 4 ++-- packages/core-backend/src/ws/RampsActivityService.ts | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ba225d7bd15..1798924e10b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2075,4 +2075,4 @@ "count": 29 } } -} \ No newline at end of file +} diff --git a/packages/core-backend/src/ws/RampsActivityService.test.ts b/packages/core-backend/src/ws/RampsActivityService.test.ts index f3b4cbec453..c6c44ffe432 100644 --- a/packages/core-backend/src/ws/RampsActivityService.test.ts +++ b/packages/core-backend/src/ws/RampsActivityService.test.ts @@ -7,14 +7,14 @@ import type { } from '@metamask/messenger'; import { flushPromises } from '../../../../tests/helpers.js'; +import type { ServerNotificationMessage } from './BackendWebSocketService.js'; +import { WebSocketState } from './BackendWebSocketService.js'; import { RampsActivityService, RAMPS_ACTIVITY_SERVICE_ALLOWED_ACTIONS, RAMPS_ACTIVITY_SERVICE_ALLOWED_EVENTS, } from './RampsActivityService.js'; import type { RampsActivityServiceMessenger } from './RampsActivityService.js'; -import type { ServerNotificationMessage } from './BackendWebSocketService.js'; -import { WebSocketState } from './BackendWebSocketService.js'; type AllActions = MessengerActions; type AllEvents = MessengerEvents; diff --git a/packages/core-backend/src/ws/RampsActivityService.ts b/packages/core-backend/src/ws/RampsActivityService.ts index d9461ff4261..1e92885a2b6 100644 --- a/packages/core-backend/src/ws/RampsActivityService.ts +++ b/packages/core-backend/src/ws/RampsActivityService.ts @@ -26,8 +26,7 @@ export const RAMPS_ACTIVITY_CATEGORIES = [ 'unknown', ] as const; -export type RampsActivityCategory = - (typeof RAMPS_ACTIVITY_CATEGORIES)[number]; +export type RampsActivityCategory = (typeof RAMPS_ACTIVITY_CATEGORIES)[number]; export type RampsActivityEntity = { id: string; From 9324e334417df60764306dd80bf07bd40f59b0cd Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 03:58:38 -0500 Subject: [PATCH 8/9] docs: link ramps activity changelog to PR Co-authored-by: Cursor --- packages/core-backend/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index 61ccd49db1f..059655e6277 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `RampsActivityService` for validated, profile-scoped ramps activity notifications over `BackendWebSocketService` (`ramps-activity.v1.`) +- Add `RampsActivityService` for validated, profile-scoped ramps activity notifications over `BackendWebSocketService` (`ramps-activity.v1.`) ([#10304](https://github.com/MetaMask/core/pull/10304)) ### Changed From d1cca62ff08071651511c0682fd0ff9385f71406 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Sun, 20 Sep 2026 04:14:01 -0500 Subject: [PATCH 9/9] refactor: keep ramps activity categories extensible Co-authored-by: Cursor --- packages/core-backend/src/index.ts | 2 -- .../src/ws/RampsActivityService.test.ts | 20 ++++++++++++++++++- .../src/ws/RampsActivityService.ts | 20 ++++--------------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index c3a68b660c2..fd1f1fc2c50 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -68,13 +68,11 @@ export type { export { RampsActivityService, - RAMPS_ACTIVITY_CATEGORIES, RAMPS_ACTIVITY_SERVICE_ALLOWED_ACTIONS, RAMPS_ACTIVITY_SERVICE_ALLOWED_EVENTS, } from './ws/RampsActivityService.js'; export type { - RampsActivityCategory, RampsActivityEntity, RampsActivityEvent, RampsActivityServiceOptions, diff --git a/packages/core-backend/src/ws/RampsActivityService.test.ts b/packages/core-backend/src/ws/RampsActivityService.test.ts index c6c44ffe432..c8bf1709879 100644 --- a/packages/core-backend/src/ws/RampsActivityService.test.ts +++ b/packages/core-backend/src/ws/RampsActivityService.test.ts @@ -187,7 +187,8 @@ describe('RampsActivityService', () => { it.each([ { ...VALID_EVENT, eventId: 1 }, - { ...VALID_EVENT, category: 'invalid' }, + { ...VALID_EVENT, category: 1 }, + { ...VALID_EVENT, category: '' }, { ...VALID_EVENT, entity: [] }, { ...VALID_EVENT, entity: { status: 'missing-id' } }, { ...VALID_EVENT, entity: { id: 'id', kind: 1 } }, @@ -214,6 +215,23 @@ describe('RampsActivityService', () => { expect(listener).not.toHaveBeenCalled(); }); + it('accepts future ramps categories without a Core release', async () => { + const { service, messenger, mocks } = setupService(); + const listener = jest.fn(); + messenger.subscribe('RampsActivityService:eventReceived', listener); + await service.init(); + const event = { ...VALID_EVENT, category: 'cex_deposit' }; + + getSubscriptionCallback(mocks.subscribe)({ + event: 'notification', + channel: 'ramps-activity.v1.canonical-profile-id', + data: event, + timestamp: Date.now(), + } as ServerNotificationMessage); + + expect(listener).toHaveBeenCalledWith(event); + }); + it('accepts a null entity', async () => { const { service, messenger, mocks } = setupService(); const listener = jest.fn(); diff --git a/packages/core-backend/src/ws/RampsActivityService.ts b/packages/core-backend/src/ws/RampsActivityService.ts index 1e92885a2b6..64f6da1efb2 100644 --- a/packages/core-backend/src/ws/RampsActivityService.ts +++ b/packages/core-backend/src/ws/RampsActivityService.ts @@ -17,17 +17,6 @@ const MESSENGER_EXPOSED_METHODS = [] as const; const log = createModuleLogger(projectLogger, SERVICE_NAME); -export const RAMPS_ACTIVITY_CATEGORIES = [ - 'customer', - 'autoramp', - 'transaction', - 'identification', - 'fiat_address', - 'unknown', -] as const; - -export type RampsActivityCategory = (typeof RAMPS_ACTIVITY_CATEGORIES)[number]; - export type RampsActivityEntity = { id: string; kind?: string; @@ -39,7 +28,7 @@ export type RampsActivityEntity = { export type RampsActivityEvent = { eventId: string; type: string; - category: RampsActivityCategory; + category: string; occurredAt: string; entity: RampsActivityEntity | null; needsFetch: boolean; @@ -153,9 +142,8 @@ const parseRampsActivityEvent = ( !isRecord(value) || typeof value.eventId !== 'string' || typeof value.type !== 'string' || - !RAMPS_ACTIVITY_CATEGORIES.includes( - value.category as RampsActivityCategory, - ) || + typeof value.category !== 'string' || + value.category.length === 0 || typeof value.occurredAt !== 'string' || typeof value.needsFetch !== 'boolean' || getHasOwnProperty(value, 'payload') || @@ -173,7 +161,7 @@ const parseRampsActivityEvent = ( return { eventId: value.eventId, type: value.type, - category: value.category as RampsActivityCategory, + category: value.category, occurredAt: value.occurredAt, entity, needsFetch: value.needsFetch,