diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index fe5293619ec..5b7fa2d3071 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [17.1.0] + +### Added + +- Subscribe pending Transak Native orders (`transak-native` / `transak-native-staging`) to Transak's public Pusher order-ID channels for real-time status wake-ups; each event triggers an immediate `#refreshOrder` through the MetaMask on-ramp API so status normalization stays server-side, with HTTP polling retained as fallback when disconnected ([#9541](https://github.com/MetaMask/core/pull/9541)) +- Add `pusher-js` dependency and export `isTransakNativeProvider`, `createPusherTransakOrderUpdatesClient`, and related Transak order-update types/constants ([#9541](https://github.com/MetaMask/core/pull/9541)) +- Add optional `transakOrderUpdatesClient` to `RampsControllerOptions` for injecting a mock client in tests ([#9541](https://github.com/MetaMask/core/pull/9541)) + ## [17.0.0] ### Added diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 2b24eadd235..00523cee7b2 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/ramps-controller", - "version": "17.0.0", + "version": "17.1.0", "description": "A controller for managing cryptocurrency on/off ramps functionality", "keywords": [ "Ethereum", @@ -60,7 +60,8 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^28.3.0", - "@metamask/remote-feature-flag-controller": "^4.2.2" + "@metamask/remote-feature-flag-controller": "^4.2.2", + "pusher-js": "^8.4.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 517ff9b7e7e..996c586780a 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -62,6 +62,18 @@ import type { TransakOrderPaymentMethod, PatchUserRequestBody, } from './TransakService'; +import type { + TransakOrderUpdateListener, + TransakOrderUpdatesClient, +} from './transakOrderUpdates'; +import Pusher from 'pusher-js'; + +jest.mock('pusher-js', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const MockedPusher = Pusher as unknown as jest.Mock; describe('RampsController', () => { const circuitBreakerOpenErrorMessage = @@ -9392,6 +9404,516 @@ describe('RampsController', () => { }); }); + describe('Transak native order websocket updates', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const createMockTransakOrderUpdatesClient = (): TransakOrderUpdatesClient & { + listeners: Map; + connected: boolean; + } => { + const listeners = new Map(); + let connected = true; + return { + listeners, + get connected() { + return connected; + }, + set connected(value: boolean) { + connected = value; + }, + subscribeOrder: jest.fn((orderId, onUpdate) => { + listeners.set(orderId, onUpdate); + }), + unsubscribeOrder: jest.fn((orderId) => { + listeners.delete(orderId); + }), + unsubscribeAll: jest.fn(() => { + listeners.clear(); + }), + isConnected: jest.fn(() => connected), + destroy: jest.fn(() => { + listeners.clear(); + connected = false; + }), + }; + }; + + it('subscribes pending transak-native orders on addOrder', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + const pendingOrder = createMockOrder({ + id: '/providers/transak-native/orders/ws-1', + providerOrderId: 'ws-1', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }); + + rootMessenger.call('RampsController:addOrder', pendingOrder); + + expect(transakOrderUpdatesClient.subscribeOrder).toHaveBeenCalledWith( + 'ws-1', + expect.any(Function), + ); + }, + ); + }); + + it('does not subscribe non-native providers', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + providerOrderId: 'agg-1', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: '/providers/transak', + name: 'Transak', + }), + }), + ); + + expect( + transakOrderUpdatesClient.subscribeOrder, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('refreshes order via RampsService when a Pusher event arrives', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ controller, rootMessenger, messenger }) => { + const pendingOrder = createMockOrder({ + id: '/providers/transak-native/orders/ws-refresh-1', + providerOrderId: 'ws-refresh-1', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', pendingOrder); + + const updatedOrder = { + ...pendingOrder, + status: RampsOrderStatus.Completed, + }; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => updatedOrder, + ); + + const statusChangedListener = jest.fn(); + messenger.subscribe( + 'RampsController:orderStatusChanged', + statusChangedListener, + ); + + const listener = + transakOrderUpdatesClient.listeners.get('ws-refresh-1'); + expect(listener).toBeDefined(); + listener?.({ + orderId: 'ws-refresh-1', + eventName: 'ORDER_COMPLETED', + status: 'COMPLETED', + }); + + await jest.advanceTimersByTimeAsync(250); + + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Completed, + ); + expect(statusChangedListener).toHaveBeenCalledWith({ + order: { + ...updatedOrder, + providerOrderId: 'ws-refresh-1', + }, + previousStatus: RampsOrderStatus.Pending, + }); + expect( + transakOrderUpdatesClient.unsubscribeOrder, + ).toHaveBeenCalledWith('ws-refresh-1'); + }, + ); + }); + + it('skips HTTP polling for subscribed native orders while connected', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + const pendingOrder = createMockOrder({ + id: '/providers/transak-native/orders/ws-skip-poll', + providerOrderId: 'ws-skip-poll', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', pendingOrder); + + const handler = jest.fn(async () => pendingOrder); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + rootMessenger.call('RampsController:startOrderPolling'); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).not.toHaveBeenCalled(); + + rootMessenger.call('RampsController:stopOrderPolling'); + }, + ); + }); + + it('falls back to HTTP polling when Pusher is disconnected', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + transakOrderUpdatesClient.connected = false; + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + const pendingOrder = createMockOrder({ + id: '/providers/transak-native/orders/ws-fallback', + providerOrderId: 'ws-fallback', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', pendingOrder); + + const updatedOrder = { + ...pendingOrder, + status: RampsOrderStatus.Completed, + }; + const handler = jest.fn(async () => updatedOrder); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + rootMessenger.call('RampsController:startOrderPolling'); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).toHaveBeenCalled(); + + rootMessenger.call('RampsController:stopOrderPolling'); + }, + ); + }); + + it('unsubscribes on removeOrder and stopOrderPolling', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-remove', + providerOrderId: 'ws-remove', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: '/providers/transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + + rootMessenger.call('RampsController:removeOrder', 'ws-remove'); + expect( + transakOrderUpdatesClient.unsubscribeOrder, + ).toHaveBeenCalledWith('ws-remove'); + + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-stop', + providerOrderId: 'ws-stop', + status: RampsOrderStatus.Created, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + + rootMessenger.call('RampsController:stopOrderPolling'); + expect(transakOrderUpdatesClient.unsubscribeAll).toHaveBeenCalled(); + }, + ); + }); + + it('does not subscribe terminal native orders', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-done', + providerOrderId: 'ws-done', + status: RampsOrderStatus.Completed, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + }), + ); + + expect( + transakOrderUpdatesClient.subscribeOrder, + ).not.toHaveBeenCalled(); + }, + ); + }); + + it('debounces burst events and clears pending debounce on unsubscribe', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + const pendingOrder = createMockOrder({ + id: '/providers/transak-native/orders/ws-debounce', + providerOrderId: 'ws-debounce', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', pendingOrder); + + const handler = jest.fn(async () => ({ + ...pendingOrder, + status: RampsOrderStatus.Pending, + })); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + const listener = + transakOrderUpdatesClient.listeners.get('ws-debounce'); + listener?.({ + orderId: 'ws-debounce', + eventName: 'ORDER_PROCESSING', + }); + listener?.({ + orderId: 'ws-debounce', + eventName: 'ORDER_PROCESSING', + }); + + await jest.advanceTimersByTimeAsync(250); + expect(handler).toHaveBeenCalledTimes(1); + + listener?.({ + orderId: 'ws-debounce', + eventName: 'ORDER_PROCESSING', + }); + rootMessenger.call('RampsController:removeOrder', 'ws-debounce'); + await jest.advanceTimersByTimeAsync(250); + expect(handler).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('unsubscribes when a wake-up finds a missing or terminal order', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-gone', + providerOrderId: 'ws-gone', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + + const listener = transakOrderUpdatesClient.listeners.get('ws-gone'); + rootMessenger.call('RampsController:removeOrder', 'ws-gone'); + // Re-add subscription bookkeeping only via listener inject: fire for unknown id + listener?.({ + orderId: 'ws-gone', + eventName: 'ORDER_COMPLETED', + }); + await jest.advanceTimersByTimeAsync(250); + + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-terminal', + providerOrderId: 'ws-terminal', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + // Force state to terminal without going through refresh + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-terminal', + providerOrderId: 'ws-terminal', + status: RampsOrderStatus.Failed, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + + const terminalListener = + transakOrderUpdatesClient.listeners.get('ws-terminal'); + terminalListener?.({ + orderId: 'ws-terminal', + eventName: 'ORDER_FAILED', + }); + await jest.advanceTimersByTimeAsync(250); + + expect( + transakOrderUpdatesClient.unsubscribeOrder, + ).toHaveBeenCalledWith('ws-terminal'); + }, + ); + }); + + it('resubscribes restored pending native orders on startOrderPolling', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { + options: { + transakOrderUpdatesClient, + state: { + orders: [ + createMockOrder({ + id: '/providers/transak-native/orders/ws-restored', + providerOrderId: 'ws-restored', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ], + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.call('RampsController:startOrderPolling'); + expect(transakOrderUpdatesClient.subscribeOrder).toHaveBeenCalledWith( + 'ws-restored', + expect.any(Function), + ); + rootMessenger.call('RampsController:stopOrderPolling'); + }, + ); + }); + + it('creates a default Pusher client when none is injected and destroys it on stop', async () => { + const disconnect = jest.fn(); + MockedPusher.mockReset(); + MockedPusher.mockImplementation(() => ({ + subscribe: jest.fn(() => ({ + bind_global: jest.fn(), + unbind_all: jest.fn(), + })), + unsubscribe: jest.fn(), + disconnect, + connection: { state: 'connected' }, + })); + + await withController(async ({ rootMessenger }) => { + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-lazy', + providerOrderId: 'ws-lazy', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + + expect(MockedPusher).toHaveBeenCalled(); + rootMessenger.call('RampsController:stopOrderPolling'); + expect(disconnect).toHaveBeenCalled(); + }); + }); + + it('clears pending wake-up timers when stopping order polling', async () => { + const transakOrderUpdatesClient = createMockTransakOrderUpdatesClient(); + await withController( + { options: { transakOrderUpdatesClient } }, + async ({ rootMessenger }) => { + rootMessenger.call( + 'RampsController:addOrder', + createMockOrder({ + id: '/providers/transak-native/orders/ws-timer', + providerOrderId: 'ws-timer', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: 'transak-native', + name: 'Transak Native', + }), + walletAddress: '0xabc', + }), + ); + + const handler = jest.fn(async () => + createMockOrder({ + providerOrderId: 'ws-timer', + status: RampsOrderStatus.Pending, + }), + ); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + transakOrderUpdatesClient.listeners.get('ws-timer')?.({ + orderId: 'ws-timer', + eventName: 'ORDER_PROCESSING', + }); + rootMessenger.call('RampsController:stopOrderPolling'); + await jest.advanceTimersByTimeAsync(250); + + expect(handler).not.toHaveBeenCalled(); + }, + ); + }); + }); + describe('Transak methods', () => { describe('transakSetApiKey', () => { it('calls messenger with the api key', async () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 52a14fa12e8..d5416074c75 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -77,6 +77,11 @@ import type { TransakOrder, } from './TransakService'; import type { TransakServiceActions } from './TransakService'; +import type { TransakOrderUpdatesClient } from './transakOrderUpdates'; +import { + createPusherTransakOrderUpdatesClient, + isTransakNativeProvider, +} from './transakOrderUpdates'; import type { TransakServiceSetApiKeyAction, TransakServiceSetAccessTokenAction, @@ -685,6 +690,12 @@ export type RampsControllerOptions = { * `undefined` when omitted. */ getDefaultRedirectUrl?: () => string | undefined; + /** + * Optional Transak order-updates client (Pusher). When omitted, a default + * Pusher-backed client is created lazily on first native-order subscription. + * Inject a mock in tests. + */ + transakOrderUpdatesClient?: TransakOrderUpdatesClient; }; // === HELPER FUNCTIONS === @@ -794,6 +805,7 @@ const PENDING_ORDER_STATUSES = new Set([ const DEFAULT_POLLING_INTERVAL_MS = 30_000; const MAX_ERROR_COUNT = 5; +const TRANSAK_ORDER_UPDATE_DEBOUNCE_MS = 250; type OrderPollingMetadata = { lastTimeFetched: number; @@ -903,6 +915,29 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + /** + * Lazily created (or injected) client for Transak native order Pusher updates. + */ + #transakOrderUpdates: TransakOrderUpdatesClient | null; + + /** + * True when the Transak order-updates client was supplied via constructor options. + */ + readonly #hasInjectedTransakOrderUpdatesClient: boolean; + + /** + * Order IDs currently subscribed on the Transak Pusher client. + */ + readonly #subscribedTransakOrderIds: Set = new Set(); + + /** + * Debounce timers keyed by Transak order ID for wake-up refreshes. + */ + readonly #transakOrderUpdateDebounceTimers: Map< + string, + ReturnType + > = new Map(); + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -953,6 +988,7 @@ export class RampsController extends BaseController< requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, getDefaultRedirectUrl, + transakOrderUpdatesClient, }: RampsControllerOptions) { super({ messenger, @@ -970,6 +1006,9 @@ export class RampsController extends BaseController< this.#requestCacheMaxSize = requestCacheMaxSize; this.#getDefaultRedirectUrl = getDefaultRedirectUrl ?? ((): string | undefined => undefined); + this.#hasInjectedTransakOrderUpdatesClient = + transakOrderUpdatesClient !== undefined; + this.#transakOrderUpdates = transakOrderUpdatesClient ?? null; this.messenger.registerMethodActionHandlers( this, @@ -2346,6 +2385,8 @@ export class RampsController extends BaseController< } as Draft; } }); + + this.#maybeSubscribeTransakOrder(healedOrder); } /** @@ -2361,6 +2402,7 @@ export class RampsController extends BaseController< }); this.#orderPollingMeta.delete(providerOrderId); + this.#unsubscribeTransakOrder(providerOrderId); } /** @@ -2410,6 +2452,7 @@ export class RampsController extends BaseController< if (TERMINAL_ORDER_STATUSES.has(updatedOrder.status)) { this.#orderPollingMeta.delete(order.providerOrderId); + this.#unsubscribeTransakOrder(order.providerOrderId); } } catch { const meta = this.#orderPollingMeta.get(order.providerOrderId) ?? { @@ -2426,8 +2469,12 @@ export class RampsController extends BaseController< * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, * respects pollingSecondsMinimum and backoff from error count. + * Also ensures Transak native pending orders are subscribed for + * real-time Pusher wake-ups (including orders restored from persisted state). */ startOrderPolling(): void { + this.#ensureTransakOrderSubscriptions(); + if (this.#orderPollingTimer) { return; } @@ -2441,12 +2488,14 @@ export class RampsController extends BaseController< /** * Stops order polling and clears the interval. + * Also tears down Transak Pusher subscriptions. */ stopOrderPolling(): void { if (this.#orderPollingTimer) { clearInterval(this.#orderPollingTimer); this.#orderPollingTimer = null; } + this.#teardownTransakOrderUpdates(); } async #pollPendingOrders(): Promise { @@ -2460,9 +2509,18 @@ export class RampsController extends BaseController< ); const now = Date.now(); + const transakUpdatesConnected = + this.#transakOrderUpdates?.isConnected() ?? false; await Promise.allSettled( pendingOrders.map(async (order) => { + if ( + transakUpdatesConnected && + this.#subscribedTransakOrderIds.has(order.providerOrderId) + ) { + return; + } + const meta = this.#orderPollingMeta.get(order.providerOrderId); if (meta) { @@ -2491,6 +2549,99 @@ export class RampsController extends BaseController< } } + #getTransakOrderUpdates(): TransakOrderUpdatesClient { + if (!this.#transakOrderUpdates) { + this.#transakOrderUpdates = createPusherTransakOrderUpdatesClient(); + } + return this.#transakOrderUpdates; + } + + #maybeSubscribeTransakOrder(order: RampsOrder): void { + if (!isTransakNativeProvider(order.provider?.id)) { + return; + } + if (!PENDING_ORDER_STATUSES.has(order.status)) { + return; + } + + const orderId = getInternalOrderCode(order); + if (!orderId || this.#subscribedTransakOrderIds.has(orderId)) { + return; + } + + this.#subscribedTransakOrderIds.add(orderId); + this.#getTransakOrderUpdates().subscribeOrder(orderId, (event) => { + this.#scheduleTransakOrderRefresh(event.orderId); + }); + } + + #unsubscribeTransakOrder(orderId: string): void { + const debounceTimer = this.#transakOrderUpdateDebounceTimers.get(orderId); + if (debounceTimer) { + clearTimeout(debounceTimer); + this.#transakOrderUpdateDebounceTimers.delete(orderId); + } + + if (!this.#subscribedTransakOrderIds.has(orderId)) { + return; + } + + this.#subscribedTransakOrderIds.delete(orderId); + this.#transakOrderUpdates?.unsubscribeOrder(orderId); + } + + #ensureTransakOrderSubscriptions(): void { + const orders = this.state.orders; + if (!Array.isArray(orders)) { + return; + } + for (const order of orders) { + this.#maybeSubscribeTransakOrder(order); + } + } + + #scheduleTransakOrderRefresh(orderId: string): void { + const existingTimer = this.#transakOrderUpdateDebounceTimers.get(orderId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.#transakOrderUpdateDebounceTimers.delete(orderId); + const order = this.state.orders.find( + (candidate) => getInternalOrderCode(candidate) === orderId, + ); + if (!order || TERMINAL_ORDER_STATUSES.has(order.status)) { + this.#unsubscribeTransakOrder(orderId); + return; + } + // #refreshOrder swallows fetch errors internally. + void this.#refreshOrder(order); + }, TRANSAK_ORDER_UPDATE_DEBOUNCE_MS); + + this.#transakOrderUpdateDebounceTimers.set(orderId, timer); + } + + #teardownTransakOrderUpdates(): void { + for (const timer of this.#transakOrderUpdateDebounceTimers.values()) { + clearTimeout(timer); + } + this.#transakOrderUpdateDebounceTimers.clear(); + this.#subscribedTransakOrderIds.clear(); + + if (!this.#transakOrderUpdates) { + return; + } + + if (this.#hasInjectedTransakOrderUpdatesClient) { + this.#transakOrderUpdates.unsubscribeAll(); + return; + } + + this.#transakOrderUpdates.destroy(); + this.#transakOrderUpdates = null; + } + /** * Cleans up controller resources. * Should be called when the controller is no longer needed. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 3ebe6f39e43..0b33a412961 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -198,6 +198,20 @@ export { getTransakApiMessage, isTransakPhoneRegisteredError, } from './transakApiErrorUtils'; +export type { + TransakOrderUpdateEvent, + TransakOrderUpdateListener, + TransakOrderUpdatesClient, + CreatePusherTransakOrderUpdatesClientOptions, +} from './transakOrderUpdates'; +export { + TRANSAK_PUSHER_KEY, + TRANSAK_PUSHER_CLUSTER, + TRANSAK_NATIVE_PROVIDER_IDS, + isTransakNativeProvider, + createDefaultPusher, + createPusherTransakOrderUpdatesClient, +} from './transakOrderUpdates'; export type { TransakServiceMethodActions, TransakServiceSendUserOtpAction, diff --git a/packages/ramps-controller/src/transakOrderUpdates.test.ts b/packages/ramps-controller/src/transakOrderUpdates.test.ts new file mode 100644 index 00000000000..14f9d4fe4e8 --- /dev/null +++ b/packages/ramps-controller/src/transakOrderUpdates.test.ts @@ -0,0 +1,221 @@ +import type { Channel } from 'pusher-js'; +import Pusher from 'pusher-js'; + +import { + TRANSAK_PUSHER_CLUSTER, + TRANSAK_PUSHER_KEY, + createDefaultPusher, + createPusherTransakOrderUpdatesClient, + isTransakNativeProvider, +} from './transakOrderUpdates'; + +jest.mock('pusher-js', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockPusherConstructor = Pusher as unknown as jest.Mock; + +describe('isTransakNativeProvider', () => { + it.each([ + ['transak-native', true], + ['transak-native-staging', true], + ['/providers/transak-native', true], + ['/providers/transak-native-staging', true], + ['transak', false], + ['/providers/transak', false], + ['moonpay', false], + [undefined, false], + ['', false], + ])('%s -> %s', (providerId, expected) => { + expect(isTransakNativeProvider(providerId)).toBe(expected); + }); +}); + +describe('createDefaultPusher', () => { + beforeEach(() => { + mockPusherConstructor.mockReset(); + }); + + it('constructs a Pusher instance with the Transak key and cluster', () => { + const instance = { connection: { state: 'connected' } }; + mockPusherConstructor.mockImplementation(() => instance); + + expect(createDefaultPusher(TRANSAK_PUSHER_KEY, { cluster: 'ap2' })).toBe( + instance, + ); + expect(mockPusherConstructor).toHaveBeenCalledWith(TRANSAK_PUSHER_KEY, { + cluster: 'ap2', + }); + }); +}); + +describe('createPusherTransakOrderUpdatesClient', () => { + const createMockChannel = () => { + return { + bind_global: jest.fn(), + unbind_all: jest.fn(), + } as unknown as Channel & { + bind_global: jest.Mock; + unbind_all: jest.Mock; + }; + }; + + beforeEach(() => { + mockPusherConstructor.mockReset(); + }); + + it('uses createDefaultPusher when no factory is provided', () => { + const channel = createMockChannel(); + const subscribe = jest.fn(() => channel); + const unsubscribe = jest.fn(); + const disconnect = jest.fn(); + mockPusherConstructor.mockImplementation(() => ({ + subscribe, + unsubscribe, + disconnect, + connection: { state: 'connected' }, + })); + + const client = createPusherTransakOrderUpdatesClient(); + client.subscribeOrder('order-default', jest.fn()); + + expect(mockPusherConstructor).toHaveBeenCalledWith(TRANSAK_PUSHER_KEY, { + cluster: TRANSAK_PUSHER_CLUSTER, + }); + expect(subscribe).toHaveBeenCalledWith('order-default'); + }); + + it('subscribes to the order-ID channel and forwards non-pusher events', () => { + const channel = createMockChannel(); + const subscribe = jest.fn(() => channel); + const createPusher = jest.fn(() => ({ + subscribe, + unsubscribe: jest.fn(), + disconnect: jest.fn(), + connection: { state: 'connected' }, + })); + + const client = createPusherTransakOrderUpdatesClient({ createPusher }); + const onUpdate = jest.fn(); + + client.subscribeOrder('order-123', onUpdate); + + expect(createPusher).toHaveBeenCalledWith(TRANSAK_PUSHER_KEY, { + cluster: TRANSAK_PUSHER_CLUSTER, + }); + expect(subscribe).toHaveBeenCalledWith('order-123'); + expect(channel.bind_global).toHaveBeenCalledTimes(1); + + const handler = channel.bind_global.mock.calls[0][0] as ( + eventName: string, + data: unknown, + ) => void; + + handler('pusher:subscription_succeeded', {}); + expect(onUpdate).not.toHaveBeenCalled(); + + handler('ORDER_PROCESSING', { status: 'PROCESSING' }); + expect(onUpdate).toHaveBeenCalledWith({ + orderId: 'order-123', + eventName: 'ORDER_PROCESSING', + status: 'PROCESSING', + }); + + handler('ORDER_FAILED', null); + expect(onUpdate).toHaveBeenCalledWith({ + orderId: 'order-123', + eventName: 'ORDER_FAILED', + status: undefined, + }); + + handler('ORDER_FAILED', { status: 123 }); + expect(onUpdate).toHaveBeenLastCalledWith({ + orderId: 'order-123', + eventName: 'ORDER_FAILED', + status: undefined, + }); + }); + + it('ignores empty order IDs and duplicate subscriptions', () => { + const subscribe = jest.fn(() => createMockChannel()); + const client = createPusherTransakOrderUpdatesClient({ + createPusher: () => ({ + subscribe, + unsubscribe: jest.fn(), + disconnect: jest.fn(), + connection: { state: 'connecting' }, + }), + }); + + client.subscribeOrder('', jest.fn()); + client.subscribeOrder('order-1', jest.fn()); + client.subscribeOrder('order-1', jest.fn()); + + expect(subscribe).toHaveBeenCalledTimes(1); + }); + + it('no-ops unsubscribeOrder when the order is not subscribed', () => { + const unsubscribe = jest.fn(); + const client = createPusherTransakOrderUpdatesClient({ + createPusher: () => ({ + subscribe: jest.fn(() => createMockChannel()), + unsubscribe, + disconnect: jest.fn(), + connection: { state: 'connected' }, + }), + }); + + client.unsubscribeOrder('missing'); + expect(unsubscribe).not.toHaveBeenCalled(); + }); + + it('disconnects when the last subscription is removed', () => { + const channel = createMockChannel(); + const unsubscribe = jest.fn(); + const disconnect = jest.fn(); + const client = createPusherTransakOrderUpdatesClient({ + createPusher: () => ({ + subscribe: jest.fn(() => channel), + unsubscribe, + disconnect, + connection: { state: 'connected' }, + }), + }); + + client.subscribeOrder('order-1', jest.fn()); + expect(client.isConnected()).toBe(true); + + client.unsubscribeOrder('order-1'); + + expect(channel.unbind_all).toHaveBeenCalledTimes(1); + expect(unsubscribe).toHaveBeenCalledWith('order-1'); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(client.isConnected()).toBe(false); + }); + + it('unsubscribeAll and destroy remove every subscription', () => { + const subscribe = jest + .fn() + .mockReturnValueOnce(createMockChannel()) + .mockReturnValueOnce(createMockChannel()); + const unsubscribe = jest.fn(); + const disconnect = jest.fn(); + const client = createPusherTransakOrderUpdatesClient({ + createPusher: () => ({ + subscribe, + unsubscribe, + disconnect, + connection: { state: 'connected' }, + }), + }); + + client.subscribeOrder('a', jest.fn()); + client.subscribeOrder('b', jest.fn()); + client.destroy(); + + expect(unsubscribe).toHaveBeenCalledWith('a'); + expect(unsubscribe).toHaveBeenCalledWith('b'); + expect(disconnect).toHaveBeenCalled(); + }); +}); diff --git a/packages/ramps-controller/src/transakOrderUpdates.ts b/packages/ramps-controller/src/transakOrderUpdates.ts new file mode 100644 index 00000000000..6321b905f03 --- /dev/null +++ b/packages/ramps-controller/src/transakOrderUpdates.ts @@ -0,0 +1,192 @@ +import Pusher, { type Channel } from 'pusher-js'; + +/** + * Transak Pusher Channels app credentials (public; see Transak WebSockets docs). + */ +export const TRANSAK_PUSHER_KEY = '1d9ffac87de599c61283'; +export const TRANSAK_PUSHER_CLUSTER = 'ap2'; + +export const TRANSAK_NATIVE_PROVIDER_IDS = new Set([ + 'transak-native', + 'transak-native-staging', + '/providers/transak-native', + '/providers/transak-native-staging', +]); + +/** + * Returns whether a provider ID refers to Transak Native (prod or staging). + * + * Accepts both canonical IDs (`transak-native`) and path-prefixed IDs + * (`/providers/transak-native`). + * + * @param providerId - Provider id from a ramps order or providers list. + * @returns True when the provider is Transak Native. + */ +export function isTransakNativeProvider(providerId?: string): boolean { + if (!providerId) { + return false; + } + if (TRANSAK_NATIVE_PROVIDER_IDS.has(providerId)) { + return true; + } + const segment = providerId.startsWith('/providers/') + ? providerId.slice('/providers/'.length) + : providerId; + return ( + segment === 'transak-native' || segment === 'transak-native-staging' + ); +} + +export type TransakOrderUpdateEvent = { + orderId: string; + eventName: string; + status?: string; +}; + +export type TransakOrderUpdateListener = ( + event: TransakOrderUpdateEvent, +) => void; + +/** + * Client that subscribes to Transak public order-ID Pusher channels. + */ +export type TransakOrderUpdatesClient = { + subscribeOrder: ( + orderId: string, + onUpdate: TransakOrderUpdateListener, + ) => void; + unsubscribeOrder: (orderId: string) => void; + unsubscribeAll: () => void; + isConnected: () => boolean; + destroy: () => void; +}; + +type ChannelSubscription = { + channel: Channel; + listener: TransakOrderUpdateListener; +}; + +type PusherLike = { + subscribe: (channelName: string) => Channel; + unsubscribe: (channelName: string) => void; + disconnect: () => void; + connection: { state: string }; +}; + +export type CreatePusherTransakOrderUpdatesClientOptions = { + /** + * Optional factory for injecting a mock Pusher in tests. + */ + createPusher?: (key: string, options: { cluster: string }) => PusherLike; +}; + +/** + * Default Pusher factory used when no test inject is provided. + * + * @param key - Pusher app key. + * @param options - Pusher client options. + * @returns A Pusher-like client instance. + */ +export function createDefaultPusher( + key: string, + options: { cluster: string }, +): PusherLike { + return new Pusher(key, options) as unknown as PusherLike; +} + +/** + * Creates a Transak order-updates client backed by Pusher Channels. + * + * Subscribes to the public channel named with the Transak order UUID. + * Payloads are treated as wake-up signals; callers should refresh via the + * MetaMask on-ramp API for normalized status. + * + * @param options - Optional overrides (e.g. mock Pusher factory for tests). + * @returns A {@link TransakOrderUpdatesClient}. + */ +export function createPusherTransakOrderUpdatesClient( + options: CreatePusherTransakOrderUpdatesClientOptions = {}, +): TransakOrderUpdatesClient { + const createPusher = options.createPusher ?? createDefaultPusher; + + let pusher: PusherLike | null = null; + const subscriptions = new Map(); + + const ensurePusher = (): PusherLike => { + if (!pusher) { + pusher = createPusher(TRANSAK_PUSHER_KEY, { + cluster: TRANSAK_PUSHER_CLUSTER, + }); + } + return pusher; + }; + + const subscribeOrder = ( + orderId: string, + onUpdate: TransakOrderUpdateListener, + ): void => { + if (!orderId || subscriptions.has(orderId)) { + return; + } + + const instance = ensurePusher(); + const channel = instance.subscribe(orderId); + + const handler = (eventName: string, data: unknown): void => { + if (eventName.startsWith('pusher:')) { + return; + } + const status = + data && + typeof data === 'object' && + 'status' in data && + typeof (data as { status: unknown }).status === 'string' + ? (data as { status: string }).status + : undefined; + onUpdate({ orderId, eventName, status }); + }; + + channel.bind_global(handler); + subscriptions.set(orderId, { channel, listener: onUpdate }); + }; + + const unsubscribeOrder = (orderId: string): void => { + const subscription = subscriptions.get(orderId); + if (!subscription || !pusher) { + return; + } + subscription.channel.unbind_all(); + pusher.unsubscribe(orderId); + subscriptions.delete(orderId); + + if (subscriptions.size === 0) { + pusher.disconnect(); + pusher = null; + } + }; + + const unsubscribeAll = (): void => { + for (const orderId of [...subscriptions.keys()]) { + unsubscribeOrder(orderId); + } + }; + + const isConnected = (): boolean => { + if (!pusher) { + return false; + } + return pusher.connection.state === 'connected'; + }; + + const destroy = (): void => { + unsubscribeAll(); + }; + + return { + subscribeOrder, + unsubscribeOrder, + unsubscribeAll, + isConnected, + destroy, + }; +} diff --git a/yarn.lock b/yarn.lock index 1e4085d18fb..8d77cdc6f60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8299,6 +8299,7 @@ __metadata: deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" nock: "npm:^13.3.1" + pusher-js: "npm:^8.4.0" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" @@ -22590,6 +22591,15 @@ __metadata: languageName: node linkType: hard +"pusher-js@npm:^8.4.0": + version: 8.5.0 + resolution: "pusher-js@npm:8.5.0" + dependencies: + tweetnacl: "npm:^1.0.3" + checksum: 10/99e6c102aa0d35c7cdd4341770c69b9b63f3bf3552f2238b105745a164630c94c84caa485758a7c147d1c9523e5e372678080e1caad6b8c2b05af8bd42a7414e + languageName: node + linkType: hard + "pvtsutils@npm:^1.3.6": version: 1.3.6 resolution: "pvtsutils@npm:1.3.6"