From e039edc31b2ad297836d67b1a0e3a9a6b081d3a3 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 25 Feb 2026 13:43:50 -0700 Subject: [PATCH 1/7] feat: adds ramp orders to controller state --- .../ramps-controller/src/RampsController.ts | 219 +++++++++++++++++- packages/ramps-controller/src/index.ts | 1 + 2 files changed, 218 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 2eec0ca8899..c4989e4c671 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -22,6 +22,7 @@ import type { RampsServiceActions, RampsOrder, } from './RampsService'; +import { RampsOrderStatus } from './RampsService'; import type { RampsServiceGetGeolocationAction, RampsServiceGetCountriesAction, @@ -257,6 +258,12 @@ export type RampsControllerState = { * user details, quote, and KYC data. */ nativeProviders: NativeProvidersState; + /** + * V2 orders stored directly as RampsOrder[]. + * The controller is the authority for V2 orders — it polls, updates, + * and persists them. No FiatOrder wrapper needed. + */ + orders: RampsOrder[]; }; /** @@ -305,6 +312,12 @@ const rampsControllerMetadata = { includeInStateLogs: false, usedInUi: true, }, + orders: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, } satisfies StateMetadata; /** @@ -364,6 +377,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { createDefaultResourceState(null), }, }, + orders: [], }; } @@ -472,10 +486,21 @@ export type RampsControllerStateChangeEvent = ControllerStateChangeEvent< RampsControllerState >; +/** + * Published when a V2 order's status transitions. + * Consumed by mobile's init layer for notifications and analytics. + */ +export type RampsControllerOrderStatusChangedEvent = { + type: `${typeof controllerName}:orderStatusChanged`; + payload: [{ order: RampsOrder; previousStatus: RampsOrderStatus }]; +}; + /** * Events that {@link RampsControllerMessenger} exposes to other consumers. */ -export type RampsControllerEvents = RampsControllerStateChangeEvent; +export type RampsControllerEvents = + | RampsControllerStateChangeEvent + | RampsControllerOrderStatusChangedEvent; /** * Events from other messengers that {@link RampsController} subscribes to. @@ -570,6 +595,29 @@ function findRegionFromCode( }; } +// === ORDER POLLING CONSTANTS === + +const TERMINAL_ORDER_STATUSES = new Set([ + RampsOrderStatus.Completed, + RampsOrderStatus.Failed, + RampsOrderStatus.Cancelled, +]); + +const PENDING_ORDER_STATUSES = new Set([ + RampsOrderStatus.Pending, + RampsOrderStatus.Created, + RampsOrderStatus.Unknown, + RampsOrderStatus.Precreated, +]); + +const DEFAULT_POLLING_INTERVAL_MS = 30_000; +const MAX_ERROR_COUNT = 5; + +type OrderPollingMetadata = { + lastTimeFetched: number; + errorCount: number; +}; + // === CONTROLLER DEFINITION === /** @@ -602,6 +650,10 @@ export class RampsController extends BaseController< */ readonly #pendingResourceCount: Map = new Map(); + readonly #orderPollingMeta: Map = new Map(); + + #orderPollingTimer: ReturnType | null = null; + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -1554,11 +1606,163 @@ export class RampsController extends BaseController< ); } + // === ORDER MANAGEMENT === + + /** + * Adds a V2 order to controller state. + * If the order's provider is Transak and a WebSocket subscription callback + * is wired up in the future, it will be subscribed here. + * + * @param order - The RampsOrder to add. + */ + addOrder(order: RampsOrder): void { + this.update((state) => { + state.orders.push(order as Draft); + }); + } + + /** + * Removes a V2 order from controller state by providerOrderId. + * + * @param providerOrderId - The provider order ID to remove. + */ + removeOrder(providerOrderId: string): void { + this.update((state) => { + state.orders = state.orders.filter( + (order) => order.providerOrderId !== providerOrderId, + ); + }); + + this.#orderPollingMeta.delete(providerOrderId); + } + + /** + * Refreshes a single order via the V2 API and updates it in state. + * Publishes orderStatusChanged if the status transitioned. + * + * @param order - The order to refresh (needs provider and providerOrderId). + */ + async #refreshOrder(order: RampsOrder): Promise { + const providerCode = order.provider?.id ?? ''; + if (!providerCode || !order.providerOrderId || !order.walletAddress) { + return; + } + + const providerCodeSegment = providerCode.replace('/providers/', ''); + const previousStatus = order.status; + + try { + const updatedOrder = await this.getOrder( + providerCodeSegment, + order.providerOrderId, + order.walletAddress, + ); + + const meta = this.#orderPollingMeta.get(order.providerOrderId) ?? { + lastTimeFetched: 0, + errorCount: 0, + }; + + if (updatedOrder.status === RampsOrderStatus.Unknown) { + meta.errorCount = Math.min(meta.errorCount + 1, MAX_ERROR_COUNT); + } else { + meta.errorCount = 0; + } + + meta.lastTimeFetched = Date.now(); + this.#orderPollingMeta.set(order.providerOrderId, meta); + + if ( + previousStatus !== updatedOrder.status && + previousStatus !== undefined + ) { + this.messenger.publish('RampsController:orderStatusChanged', { + order: updatedOrder, + previousStatus, + }); + } + + if (TERMINAL_ORDER_STATUSES.has(updatedOrder.status)) { + this.#orderPollingMeta.delete(order.providerOrderId); + } + } catch { + const meta = this.#orderPollingMeta.get(order.providerOrderId) ?? { + lastTimeFetched: 0, + errorCount: 0, + }; + meta.errorCount = Math.min(meta.errorCount + 1, MAX_ERROR_COUNT); + meta.lastTimeFetched = Date.now(); + this.#orderPollingMeta.set(order.providerOrderId, meta); + } + } + + /** + * 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. + */ + startOrderPolling(): void { + if (this.#orderPollingTimer) { + return; + } + + this.#orderPollingTimer = setInterval(() => { + this.#pollPendingOrders().catch(() => undefined); + }, DEFAULT_POLLING_INTERVAL_MS); + + this.#pollPendingOrders().catch(() => undefined); + } + + /** + * Stops order polling and clears the interval. + */ + stopOrderPolling(): void { + if (this.#orderPollingTimer) { + clearInterval(this.#orderPollingTimer); + this.#orderPollingTimer = null; + } + } + + async #pollPendingOrders(): Promise { + const pendingOrders = this.state.orders.filter((order) => + PENDING_ORDER_STATUSES.has(order.status), + ); + + const now = Date.now(); + + await Promise.allSettled( + pendingOrders.map(async (order) => { + const meta = this.#orderPollingMeta.get(order.providerOrderId); + + if (meta) { + const backoffMs = + meta.errorCount > 0 + ? Math.min( + DEFAULT_POLLING_INTERVAL_MS * + Math.pow(2, meta.errorCount - 1), + 5 * 60 * 1000, + ) + : 0; + + const pollingMinMs = (order.pollingSecondsMinimum ?? 0) * 1000; + const minWait = Math.max(backoffMs, pollingMinMs); + + if (now - meta.lastTimeFetched < minWait) { + return; + } + } + + await this.#refreshOrder(order); + }), + ); + } + /** * Cleans up controller resources. * Should be called when the controller is no longer needed. */ override destroy(): void { + this.stopOrderPolling(); super.destroy(); } @@ -1601,12 +1805,23 @@ export class RampsController extends BaseController< orderCode: string, wallet: string, ): Promise { - return await this.messenger.call( + const order = await this.messenger.call( 'RampsService:getOrder', providerCode, orderCode, wallet, ); + + this.update((state) => { + const idx = state.orders.findIndex( + (existing) => existing.providerOrderId === orderCode, + ); + if (idx !== -1) { + state.orders[idx] = order as Draft; + } + }); + + return order; } /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 364a57ce811..2b08ed39c25 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -5,6 +5,7 @@ export type { RampsControllerMessenger, RampsControllerState, RampsControllerStateChangeEvent, + RampsControllerOrderStatusChangedEvent, RampsControllerOptions, UserRegion, ResourceState, From b233a25ac72b03942ac9a8647ffbb3cb150f6b08 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 26 Feb 2026 10:33:00 -0700 Subject: [PATCH 2/7] feat: adds payment details to order interface --- packages/ramps-controller/src/RampsService.ts | 11 +++++++++++ packages/ramps-controller/src/index.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 4f22e42ae0c..a75317eca2d 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -542,6 +542,16 @@ export type RampsOrderPaymentMethod = { isManualBankTransfer?: boolean; }; +/** + * Bank transfer instruction fields attached to an order by providers + * that require manual payment (e.g. SEPA, wire transfer). + */ +export type OrderPaymentDetail = { + fiatCurrency: string; + paymentMethod: string; + fields: { name: string; id: string; value: string }[]; +}; + /** * Fiat currency information associated with an order. */ @@ -589,6 +599,7 @@ export type RampsOrder = { statusDescription?: string; partnerFees?: number; networkFees?: number; + paymentDetails?: OrderPaymentDetail[]; }; /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 2b08ed39c25..71c700b1c13 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -48,6 +48,7 @@ export type { RampsOrderCryptoCurrency, RampsOrderFiatCurrency, RampsOrderPaymentMethod, + OrderPaymentDetail, } from './RampsService'; export { RampsService, From b3c272a6a7609ba9cd99549f0ac6ce5739f5f21b Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 26 Feb 2026 18:58:41 -0700 Subject: [PATCH 3/7] chore: changelog --- packages/ramps-controller/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index ed409f829d0..3c2f0e46620 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,9 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added `orders: RampsOrder[]` to controller state with persistence, along with `addOrder()`, `removeOrder()`, `startOrderPolling()`, and `stopOrderPolling()` methods ([#8045](https://github.com/MetaMask/core/pull/8045)) +- Added `orders: RampsOrder[]` to controller state with persistence, along with crud methods([#8045](https://github.com/MetaMask/core/pull/8045)) - Added `RampsController:orderStatusChanged` event, published when a polled order's status transitions ([#8045](https://github.com/MetaMask/core/pull/8045)) -- Added `OrderPaymentDetail` type and optional `paymentDetails` field to `RampsOrder` for bank transfer instruction data ([#8045](https://github.com/MetaMask/core/pull/8045)) ### Changed From 9872f4e9d9108892a7c9748c901cc2aa80cb5343 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 26 Feb 2026 19:15:52 -0700 Subject: [PATCH 4/7] chore: bugbot fixes --- packages/ramps-controller/CHANGELOG.md | 4 -- .../src/RampsController.test.ts | 68 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 18 +++-- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 3c2f0e46620..48e3aba5c18 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -12,10 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `orders: RampsOrder[]` to controller state with persistence, along with crud methods([#8045](https://github.com/MetaMask/core/pull/8045)) - Added `RampsController:orderStatusChanged` event, published when a polled order's status transitions ([#8045](https://github.com/MetaMask/core/pull/8045)) -### Changed - -- `getOrder()` now updates the order in `state.orders` if it already exists there ([#8045](https://github.com/MetaMask/core/pull/8045)) - ## [10.0.0] ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 5220ad5b70c..f5591fdabdb 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -4751,6 +4751,74 @@ describe('RampsController', () => { }); }); + describe('addOrder', () => { + const mockOrder = { + id: '/providers/transak-staging/orders/abc-123', + isOnlyLink: false, + provider: { + id: '/providers/transak-staging', + name: 'Transak (Staging)', + environmentType: 'STAGING', + description: 'Test provider description', + hqAddress: '123 Test St', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + }, + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + exchangeRate: 2000, + }; + + it('adds a new order to state', async () => { + await withController(({ controller }) => { + controller.addOrder(mockOrder); + expect(controller.state.orders).toHaveLength(1); + expect(controller.state.orders[0]).toStrictEqual(mockOrder); + }); + }); + + it('replaces an existing order with the same providerOrderId', async () => { + await withController(({ controller }) => { + controller.addOrder(mockOrder); + + const updatedOrder = { ...mockOrder, fiatAmount: 200 }; + controller.addOrder(updatedOrder); + + expect(controller.state.orders).toHaveLength(1); + expect(controller.state.orders[0]?.fiatAmount).toBe(200); + }); + }); + + it('adds orders with different providerOrderIds independently', async () => { + await withController(({ controller }) => { + controller.addOrder(mockOrder); + controller.addOrder({ + ...mockOrder, + providerOrderId: 'def-456', + }); + + expect(controller.state.orders).toHaveLength(2); + }); + }); + }); + describe('getOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c4989e4c671..ad1b9c5fea5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -601,6 +601,7 @@ const TERMINAL_ORDER_STATUSES = new Set([ RampsOrderStatus.Completed, RampsOrderStatus.Failed, RampsOrderStatus.Cancelled, + RampsOrderStatus.IdExpired, ]); const PENDING_ORDER_STATUSES = new Set([ @@ -1609,15 +1610,22 @@ export class RampsController extends BaseController< // === ORDER MANAGEMENT === /** - * Adds a V2 order to controller state. - * If the order's provider is Transak and a WebSocket subscription callback - * is wired up in the future, it will be subscribed here. + * Adds or updates a V2 order in controller state. + * If an order with the same providerOrderId already exists, it is replaced + * to prevent duplicate entries that would cause redundant polling and events. * - * @param order - The RampsOrder to add. + * @param order - The RampsOrder to add or update. */ addOrder(order: RampsOrder): void { this.update((state) => { - state.orders.push(order as Draft); + const idx = state.orders.findIndex( + (existing) => existing.providerOrderId === order.providerOrderId, + ); + if (idx === -1) { + state.orders.push(order as Draft); + } else { + state.orders[idx] = order as Draft; + } }); } From 1b55d171185499f033e637aa886c9368d4a25e0a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 26 Feb 2026 21:49:50 -0700 Subject: [PATCH 5/7] chore: 100 percent test cov --- .../src/RampsController.test.ts | 574 ++++++++++++++++++ 1 file changed, 574 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index f5591fdabdb..88a4f77063d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -29,6 +29,7 @@ import type { QuotesResponse, Quote, RampsToken, + RampsOrder, } from './RampsService'; import { RampsOrderStatus } from './RampsService'; import type { @@ -115,6 +116,7 @@ describe('RampsController', () => { }, }, }, + "orders": [], "paymentMethods": { "data": [], "error": null, @@ -189,6 +191,7 @@ describe('RampsController', () => { }, }, }, + "orders": [], "paymentMethods": { "data": [], "error": null, @@ -621,6 +624,7 @@ describe('RampsController', () => { }, }, }, + "orders": [], "paymentMethods": { "data": [], "error": null, @@ -662,6 +666,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "orders": [], "paymentMethods": { "data": [], "error": null, @@ -702,6 +707,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "orders": [], "providers": { "data": [], "error": null, @@ -759,6 +765,7 @@ describe('RampsController', () => { }, }, }, + "orders": [], "paymentMethods": { "data": [], "error": null, @@ -4819,6 +4826,29 @@ describe('RampsController', () => { }); }); + describe('removeOrder', () => { + it('removes an order from state by providerOrderId', async () => { + await withController(({ controller }) => { + const order = createMockOrder({ providerOrderId: 'abc-123' }); + controller.addOrder(order); + expect(controller.state.orders).toHaveLength(1); + + controller.removeOrder('abc-123'); + expect(controller.state.orders).toHaveLength(0); + }); + }); + + it('does nothing when providerOrderId is not found', async () => { + await withController(({ controller }) => { + const order = createMockOrder({ providerOrderId: 'abc-123' }); + controller.addOrder(order); + + controller.removeOrder('nonexistent'); + expect(controller.state.orders).toHaveLength(1); + }); + }); + }); + describe('getOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', @@ -4870,6 +4900,32 @@ describe('RampsController', () => { expect(order).toStrictEqual(mockOrder); }); }); + + it('updates an existing order in state when providerOrderId matches', async () => { + await withController(async ({ controller, rootMessenger }) => { + const existingOrder = createMockOrder({ + providerOrderId: 'abc-123', + status: RampsOrderStatus.Pending, + }); + controller.addOrder(existingOrder); + + const updatedOrder = { + ...mockOrder, + status: RampsOrderStatus.Completed, + }; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => updatedOrder, + ); + + await controller.getOrder('transak-staging', 'abc-123', '0xabc'); + + expect(controller.state.orders).toHaveLength(1); + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Completed, + ); + }); + }); }); describe('getOrderFromCallback', () => { @@ -4925,6 +4981,492 @@ describe('RampsController', () => { }); }); + describe('order polling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('startOrderPolling polls pending orders immediately and on interval', async () => { + await withController(async ({ controller, rootMessenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'poll-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + const updatedOrder = { + ...pendingOrder, + status: RampsOrderStatus.Completed, + }; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => updatedOrder, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Completed, + ); + + controller.stopOrderPolling(); + }); + }); + + it('startOrderPolling is idempotent', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn(async () => + createMockOrder({ + providerOrderId: 'p1', + status: RampsOrderStatus.Completed, + }), + ); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + controller.startOrderPolling(); + controller.startOrderPolling(); + + expect(handler).not.toHaveBeenCalled(); + + controller.stopOrderPolling(); + }); + }); + + it('stopOrderPolling clears the polling timer', async () => { + await withController(async ({ controller }) => { + controller.startOrderPolling(); + controller.stopOrderPolling(); + controller.stopOrderPolling(); + + expect(controller.state.orders).toStrictEqual([]); + }); + }); + + it('destroy stops order polling', async () => { + await withController(async ({ controller }) => { + controller.startOrderPolling(); + controller.destroy(); + + expect(controller.state.orders).toStrictEqual([]); + }); + }); + + it('publishes orderStatusChanged when order status transitions', async () => { + await withController(async ({ controller, rootMessenger, messenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'status-change-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + const updatedOrder = { + ...pendingOrder, + status: RampsOrderStatus.Completed, + }; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => updatedOrder, + ); + + const statusChangedListener = jest.fn(); + messenger.subscribe( + 'RampsController:orderStatusChanged', + statusChangedListener, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(statusChangedListener).toHaveBeenCalledWith({ + order: updatedOrder, + previousStatus: RampsOrderStatus.Pending, + }); + + controller.stopOrderPolling(); + }); + }); + + it('does not publish orderStatusChanged when status stays the same', async () => { + await withController(async ({ controller, rootMessenger, messenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'no-change-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => pendingOrder, + ); + + const statusChangedListener = jest.fn(); + messenger.subscribe( + 'RampsController:orderStatusChanged', + statusChangedListener, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(statusChangedListener).not.toHaveBeenCalled(); + + controller.stopOrderPolling(); + }); + }); + + it('removes polling metadata when order reaches terminal status', async () => { + await withController(async ({ controller, rootMessenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'terminal-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + const completedOrder = { + ...pendingOrder, + status: RampsOrderStatus.Completed, + }; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => completedOrder, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Completed, + ); + + controller.stopOrderPolling(); + }); + }); + + it('increments error count on Unknown status and respects backoff', async () => { + await withController(async ({ controller, rootMessenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'unknown-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + const unknownOrder = { + ...pendingOrder, + status: RampsOrderStatus.Unknown, + }; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => unknownOrder, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Unknown, + ); + + controller.stopOrderPolling(); + }); + }); + + it('handles fetch errors gracefully and increments error count', async () => { + await withController(async ({ controller, rootMessenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'error-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => { + throw new Error('Network error'); + }, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Pending, + ); + + controller.stopOrderPolling(); + }); + }); + + it('skips orders without provider code or wallet address', async () => { + await withController(async ({ controller, rootMessenger }) => { + const orderWithoutProvider = createMockOrder({ + providerOrderId: 'no-provider-1', + status: RampsOrderStatus.Pending, + provider: undefined, + walletAddress: '0xabc', + }); + controller.addOrder(orderWithoutProvider); + + const handler = jest.fn(); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).not.toHaveBeenCalled(); + + controller.stopOrderPolling(); + }); + }); + + it('skips orders without providerOrderId', async () => { + await withController(async ({ controller, rootMessenger }) => { + const orderNoId = createMockOrder({ + providerOrderId: '', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(orderNoId); + + const handler = jest.fn(); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).not.toHaveBeenCalled(); + + controller.stopOrderPolling(); + }); + }); + + it('skips orders without wallet address', async () => { + await withController(async ({ controller, rootMessenger }) => { + const orderNoWallet = createMockOrder({ + providerOrderId: 'no-wallet-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '', + }); + controller.addOrder(orderNoWallet); + + const handler = jest.fn(); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).not.toHaveBeenCalled(); + + controller.stopOrderPolling(); + }); + }); + + it('strips /providers/ prefix from provider id', async () => { + await withController(async ({ controller, rootMessenger }) => { + const order = createMockOrder({ + providerOrderId: 'strip-prefix-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(order); + + const handler = jest.fn(async () => ({ + ...order, + status: RampsOrderStatus.Completed, + })); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).toHaveBeenCalledWith( + 'transak', + 'strip-prefix-1', + '0xabc', + ); + + controller.stopOrderPolling(); + }); + }); + + it('skips polling orders that have not waited long enough (backoff)', async () => { + await withController(async ({ controller, rootMessenger }) => { + const order = createMockOrder({ + providerOrderId: 'backoff-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(order); + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => { + callCount += 1; + throw new Error('fail'); + }, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + await jest.advanceTimersByTimeAsync(30_000); + expect(callCount).toBe(2); + + controller.stopOrderPolling(); + }); + }); + + it('respects pollingSecondsMinimum on orders', async () => { + await withController(async ({ controller, rootMessenger }) => { + const order = createMockOrder({ + providerOrderId: 'poll-min-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + pollingSecondsMinimum: 120, + }); + controller.addOrder(order); + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => { + callCount += 1; + return { ...order, status: RampsOrderStatus.Pending }; + }, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + await jest.advanceTimersByTimeAsync(30_000); + expect(callCount).toBe(1); + + await jest.advanceTimersByTimeAsync(90_000); + expect(callCount).toBe(2); + + controller.stopOrderPolling(); + }); + }); + + it('catches errors if poll cycle throws synchronously on initial call', async () => { + await withController(async ({ controller }) => { + const realState = controller.state; + jest.spyOn(controller, 'state', 'get').mockReturnValue({ + ...realState, + orders: null as unknown as RampsOrder[], + }); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + jest.restoreAllMocks(); + expect(controller.state.orders).toStrictEqual([]); + controller.stopOrderPolling(); + }); + }); + + it('catches errors if poll cycle throws on interval tick', async () => { + await withController(async ({ controller }) => { + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + const realState = controller.state; + jest.spyOn(controller, 'state', 'get').mockReturnValueOnce({ + ...realState, + orders: null as unknown as RampsOrder[], + }); + + await jest.advanceTimersByTimeAsync(30_000); + + jest.restoreAllMocks(); + expect(controller.state.orders).toStrictEqual([]); + controller.stopOrderPolling(); + }); + }); + + it('does not poll orders with terminal statuses', async () => { + await withController(async ({ controller, rootMessenger }) => { + const completedOrder = createMockOrder({ + providerOrderId: 'completed-1', + status: RampsOrderStatus.Completed, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(completedOrder); + + const handler = jest.fn(); + rootMessenger.registerActionHandler('RampsService:getOrder', handler); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + expect(handler).not.toHaveBeenCalled(); + + controller.stopOrderPolling(); + }); + }); + + it('resets error count when order returns non-Unknown status', async () => { + await withController(async ({ controller, rootMessenger }) => { + const order = createMockOrder({ + providerOrderId: 'reset-err-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(order); + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => { + callCount += 1; + if (callCount === 1) { + return { ...order, status: RampsOrderStatus.Unknown }; + } + return { ...order, status: RampsOrderStatus.Pending }; + }, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + await jest.advanceTimersByTimeAsync(30_000); + expect(callCount).toBe(2); + + await jest.advanceTimersByTimeAsync(30_000); + expect(callCount).toBe(3); + + controller.stopOrderPolling(); + }); + }); + }); + describe('Transak methods', () => { describe('transakSetApiKey', () => { it('calls messenger with the api key', async () => { @@ -6308,6 +6850,38 @@ function createMockDepositOrder(): TransakDepositOrder { }; } +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak-staging/orders/abc-123', + isOnlyLink: false, + provider: { + id: '/providers/transak-staging', + name: 'Transak (Staging)', + }, + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + exchangeRate: 2000, + ...overrides, + }; +} + /** * The type of the messenger populated with all external actions and events * required by the controller under test. From 225638478de1bc67a266b8ee292bf445314e580c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 26 Feb 2026 22:00:38 -0700 Subject: [PATCH 6/7] feat: fixes possible polling race condition issue --- .../src/RampsController.test.ts | 53 +++++++++++++++++ .../ramps-controller/src/RampsController.ts | 58 +++++++++++-------- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 88a4f77063d..0c6c2519e7c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -5465,6 +5465,59 @@ describe('RampsController', () => { controller.stopOrderPolling(); }); }); + + it('does not run concurrent polls when stop+start is called while a poll is in-flight', async () => { + await withController(async ({ controller, rootMessenger, messenger }) => { + const pendingOrder = createMockOrder({ + providerOrderId: 'race-1', + status: RampsOrderStatus.Pending, + provider: { id: '/providers/transak', name: 'Transak' }, + walletAddress: '0xabc', + }); + controller.addOrder(pendingOrder); + + const updatedOrder = { + ...pendingOrder, + status: RampsOrderStatus.Completed, + }; + + let resolveFirst!: (value: RampsOrder) => void; + let callCount = 0; + + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + () => + new Promise((resolve) => { + callCount += 1; + if (callCount === 1) { + resolveFirst = resolve; + } else { + resolve(updatedOrder); + } + }), + ); + + const statusChangedListener = jest.fn(); + messenger.subscribe( + 'RampsController:orderStatusChanged', + statusChangedListener, + ); + + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + controller.stopOrderPolling(); + controller.startOrderPolling(); + await jest.advanceTimersByTimeAsync(0); + + resolveFirst(updatedOrder); + await jest.advanceTimersByTimeAsync(0); + + expect(statusChangedListener).toHaveBeenCalledTimes(1); + + controller.stopOrderPolling(); + }); + }); }); describe('Transak methods', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index ad1b9c5fea5..6428693f2fe 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -655,6 +655,8 @@ export class RampsController extends BaseController< #orderPollingTimer: ReturnType | null = null; + #isPolling = false; + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -1732,37 +1734,45 @@ export class RampsController extends BaseController< } async #pollPendingOrders(): Promise { - const pendingOrders = this.state.orders.filter((order) => - PENDING_ORDER_STATUSES.has(order.status), - ); + if (this.#isPolling) { + return; + } + this.#isPolling = true; + try { + const pendingOrders = this.state.orders.filter((order) => + PENDING_ORDER_STATUSES.has(order.status), + ); - const now = Date.now(); + const now = Date.now(); - await Promise.allSettled( - pendingOrders.map(async (order) => { - const meta = this.#orderPollingMeta.get(order.providerOrderId); + await Promise.allSettled( + pendingOrders.map(async (order) => { + const meta = this.#orderPollingMeta.get(order.providerOrderId); - if (meta) { - const backoffMs = - meta.errorCount > 0 - ? Math.min( - DEFAULT_POLLING_INTERVAL_MS * - Math.pow(2, meta.errorCount - 1), - 5 * 60 * 1000, - ) - : 0; + if (meta) { + const backoffMs = + meta.errorCount > 0 + ? Math.min( + DEFAULT_POLLING_INTERVAL_MS * + Math.pow(2, meta.errorCount - 1), + 5 * 60 * 1000, + ) + : 0; - const pollingMinMs = (order.pollingSecondsMinimum ?? 0) * 1000; - const minWait = Math.max(backoffMs, pollingMinMs); + const pollingMinMs = (order.pollingSecondsMinimum ?? 0) * 1000; + const minWait = Math.max(backoffMs, pollingMinMs); - if (now - meta.lastTimeFetched < minWait) { - return; + if (now - meta.lastTimeFetched < minWait) { + return; + } } - } - await this.#refreshOrder(order); - }), - ); + await this.#refreshOrder(order); + }), + ); + } finally { + this.#isPolling = false; + } } /** From 8469a69c3b5be49913c4038d48e410702deec42d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Fri, 27 Feb 2026 10:22:27 -0700 Subject: [PATCH 7/7] feat: updates orders in a spread to avoid clobbering payment details --- .../src/RampsController.test.ts | 80 ++++++++++++++++++- .../ramps-controller/src/RampsController.ts | 15 +++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0c6c2519e7c..2de72cbfe11 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -4801,7 +4801,7 @@ describe('RampsController', () => { }); }); - it('replaces an existing order with the same providerOrderId', async () => { + it('merges an existing order with the same providerOrderId', async () => { await withController(({ controller }) => { controller.addOrder(mockOrder); @@ -4813,6 +4813,42 @@ describe('RampsController', () => { }); }); + it('preserves existing fields not present in the update', async () => { + await withController(({ controller }) => { + const orderWithPaymentDetails = createMockOrder({ + providerOrderId: 'abc-123', + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'bank_transfer', + fields: [ + { name: 'Account Number', id: 'account', value: '12345' }, + ], + }, + ], + }); + controller.addOrder(orderWithPaymentDetails); + + const apiUpdate = createMockOrder({ + providerOrderId: 'abc-123', + status: RampsOrderStatus.Pending, + }); + controller.addOrder(apiUpdate); + + expect(controller.state.orders).toHaveLength(1); + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Pending, + ); + expect(controller.state.orders[0]?.paymentDetails).toStrictEqual([ + { + fiatCurrency: 'USD', + paymentMethod: 'bank_transfer', + fields: [{ name: 'Account Number', id: 'account', value: '12345' }], + }, + ]); + }); + }); + it('adds orders with different providerOrderIds independently', async () => { await withController(({ controller }) => { controller.addOrder(mockOrder); @@ -4926,6 +4962,48 @@ describe('RampsController', () => { ); }); }); + + it('preserves existing fields not present in the API response', async () => { + await withController(async ({ controller, rootMessenger }) => { + const existingOrder = createMockOrder({ + providerOrderId: 'abc-123', + status: RampsOrderStatus.Created, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'bank_transfer', + fields: [ + { name: 'Account Number', id: 'account', value: '12345' }, + ], + }, + ], + }); + controller.addOrder(existingOrder); + + const apiResponse = createMockOrder({ + providerOrderId: 'abc-123', + status: RampsOrderStatus.Pending, + }); + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => apiResponse, + ); + + await controller.getOrder('transak-staging', 'abc-123', '0xabc'); + + expect(controller.state.orders).toHaveLength(1); + expect(controller.state.orders[0]?.status).toBe( + RampsOrderStatus.Pending, + ); + expect(controller.state.orders[0]?.paymentDetails).toStrictEqual([ + { + fiatCurrency: 'USD', + paymentMethod: 'bank_transfer', + fields: [{ name: 'Account Number', id: 'account', value: '12345' }], + }, + ]); + }); + }); }); describe('getOrderFromCallback', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6428693f2fe..7cc43f6c1f0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -1613,8 +1613,9 @@ export class RampsController extends BaseController< /** * Adds or updates a V2 order in controller state. - * If an order with the same providerOrderId already exists, it is replaced - * to prevent duplicate entries that would cause redundant polling and events. + * If an order with the same providerOrderId already exists, the incoming + * fields are merged on top of the existing order so that fields not present + * in the update (e.g. paymentDetails from the Transak API) are preserved. * * @param order - The RampsOrder to add or update. */ @@ -1626,7 +1627,10 @@ export class RampsController extends BaseController< if (idx === -1) { state.orders.push(order as Draft); } else { - state.orders[idx] = order as Draft; + state.orders[idx] = { + ...state.orders[idx], + ...order, + } as Draft; } }); } @@ -1835,7 +1839,10 @@ export class RampsController extends BaseController< (existing) => existing.providerOrderId === orderCode, ); if (idx !== -1) { - state.orders[idx] = order as Draft; + state.orders[idx] = { + ...state.orders[idx], + ...order, + } as Draft; } });