diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 6b8a78ef484..0f4dec72ae5 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `RampsController:orderStatusChanged` event, published when a polled order's status transitions ([#8045](https://github.com/MetaMask/core/pull/8045)) - Add messenger actions for `RampsController:setSelectedToken`, `RampsController:getQuotes`, and `RampsController:getOrder`, register their handlers in `RampsController`, and export the action types from the package index ([#8081](https://github.com/MetaMask/core/pull/8081)) +### Changed + +- **BREAKING:** Replace `getWidgetUrl` with `getBuyWidgetData` (returns `BuyWidget | null`); add `addPrecreatedOrder` for custom-action ramp flows (e.g., PayPal) ([#8100](https://github.com/MetaMask/core/pull/8100)) + ## [10.0.0] ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index e9391e4070c..f46d49facb3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -15,6 +15,7 @@ import type { UserRegion, } from './RampsController'; import { + normalizeProviderCode, RampsController, getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, @@ -60,6 +61,20 @@ import type { } from './TransakService'; describe('RampsController', () => { + describe('normalizeProviderCode', () => { + it('strips /providers/ prefix', () => { + expect(normalizeProviderCode('/providers/transak')).toBe('transak'); + expect(normalizeProviderCode('/providers/transak-staging')).toBe( + 'transak-staging', + ); + }); + + it('returns string unchanged when no prefix', () => { + expect(normalizeProviderCode('transak')).toBe('transak'); + expect(normalizeProviderCode('')).toBe(''); + }); + }); + describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { it('includes every RampsService action that RampsController calls', async () => { expect.hasAssertions(); @@ -4773,7 +4788,7 @@ describe('RampsController', () => { }); }); - describe('getWidgetUrl', () => { + describe('getBuyWidgetData', () => { it('fetches and returns widget URL via RampsService messenger', async () => { await withController(async ({ controller, rootMessenger }) => { const quote: Quote = { @@ -4796,9 +4811,13 @@ describe('RampsController', () => { }), ); - const widgetUrl = await controller.getWidgetUrl(quote); + const buyWidget = await controller.getBuyWidgetData(quote); - expect(widgetUrl).toBe('https://global.transak.com/?apiKey=test'); + expect(buyWidget).toStrictEqual({ + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER', + orderId: null, + }); }); }); @@ -4813,9 +4832,9 @@ describe('RampsController', () => { }, }; - const widgetUrl = await controller.getWidgetUrl(quote); + const buyWidget = await controller.getBuyWidgetData(quote); - expect(widgetUrl).toBeNull(); + expect(buyWidget).toBeNull(); }); }); @@ -4825,13 +4844,13 @@ describe('RampsController', () => { provider: '/providers/moonpay', } as unknown as Quote; - const widgetUrl = await controller.getWidgetUrl(quote); + const buyWidget = await controller.getBuyWidgetData(quote); - expect(widgetUrl).toBeNull(); + expect(buyWidget).toBeNull(); }); }); - it('returns null when service call throws an error', async () => { + it('propagates error when service call throws', async () => { await withController(async ({ controller, rootMessenger }) => { const quote: Quote = { provider: '/providers/transak-staging', @@ -4851,9 +4870,9 @@ describe('RampsController', () => { }, ); - const widgetUrl = await controller.getWidgetUrl(quote); - - expect(widgetUrl).toBeNull(); + await expect(controller.getBuyWidgetData(quote)).rejects.toThrow( + 'Network error', + ); }); }); @@ -4879,9 +4898,55 @@ describe('RampsController', () => { }), ); - const widgetUrl = await controller.getWidgetUrl(quote); + const buyWidget = await controller.getBuyWidgetData(quote); + + expect(buyWidget).toBeNull(); + }); + }); + }); + + describe('addPrecreatedOrder', () => { + it('adds a stub order with Precreated status for polling', async () => { + await withController(({ controller }) => { + controller.addPrecreatedOrder({ + orderId: '/providers/paypal/orders/abc123', + providerCode: 'paypal', + walletAddress: '0xabc', + chainId: '1', + }); - expect(widgetUrl).toBeNull(); + expect(controller.state.orders).toHaveLength(1); + const stub = controller.state.orders[0]; + expect(stub?.providerOrderId).toBe('abc123'); + expect(stub?.provider?.id).toBe('/providers/paypal'); + expect(stub?.walletAddress).toBe('0xabc'); + expect(stub?.status).toBe(RampsOrderStatus.Precreated); + }); + }); + + it('parses orderCode when orderId has no /orders/ segment', async () => { + await withController(({ controller }) => { + controller.addPrecreatedOrder({ + orderId: 'plain-order-id', + providerCode: 'transak', + walletAddress: '0xdef', + }); + + expect(controller.state.orders[0]?.providerOrderId).toBe( + 'plain-order-id', + ); + }); + }); + + it('skips addOrder when orderId ends with /orders/ (empty orderCode)', async () => { + await withController(({ controller }) => { + controller.addPrecreatedOrder({ + orderId: '/providers/paypal/orders/', + providerCode: 'paypal', + walletAddress: '0xabc', + }); + + expect(controller.state.orders).toHaveLength(0); }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 23a4b10fad6..71c2aa41e69 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -9,6 +9,7 @@ import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; import type { + BuyWidget, Country, TokensResponse, Provider, @@ -259,9 +260,8 @@ export type RampsControllerState = { */ 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. + * and persists them. */ orders: RampsOrder[]; }; @@ -623,6 +623,10 @@ function findRegionFromCode( }; } +export function normalizeProviderCode(providerCode: string): string { + return providerCode.replace(/^\/providers\//u, ''); +} + // === ORDER POLLING CONSTANTS === const TERMINAL_ORDER_STATUSES = new Set([ @@ -1707,7 +1711,7 @@ export class RampsController extends BaseController< return; } - const providerCodeSegment = providerCode.replace('/providers/', ''); + const providerCodeSegment = normalizeProviderCode(providerCode); const previousStatus = order.status; try { @@ -1834,28 +1838,87 @@ export class RampsController extends BaseController< } /** - * Fetches the widget URL from a quote for redirect providers. + * Fetches the widget data from a quote for redirect providers. * Makes a request to the buyURL endpoint via the RampsService to get the - * actual provider widget URL. + * actual provider widget URL and optional order ID for polling. * * @param quote - The quote to fetch the widget URL from. - * @returns Promise resolving to the widget URL string, or null if not available. + * @returns Promise resolving to the full BuyWidget (url, browser, orderId), or null if not available (missing buyURL or empty url in response). + * @throws Rethrows errors from the RampsService (e.g. HttpError, network failures) so clients can react to fetch failures. */ - async getWidgetUrl(quote: Quote): Promise { + async getBuyWidgetData(quote: Quote): Promise { const buyUrl = quote.quote?.buyURL; if (!buyUrl) { return null; } - try { - const buyWidget = await this.messenger.call( - 'RampsService:getBuyWidgetUrl', - buyUrl, - ); - return buyWidget.url ?? null; - } catch { + const buyWidget = await this.messenger.call( + 'RampsService:getBuyWidgetUrl', + buyUrl, + ); + if (!buyWidget?.url) { return null; } + return buyWidget; + } + + /** + * Registers an order ID for polling until the order is created or resolved. + * Adds a minimal stub order to controller state; the existing order polling + * will fetch the full order when the provider has created it. + * + * @param params - Object containing order identifiers and wallet info. + * @param params.orderId - Full order ID (e.g. "/providers/paypal/orders/abc123") or order code. + * @param params.providerCode - Provider code (e.g. "paypal", "transak"), with or without /providers/ prefix. + * @param params.walletAddress - Wallet address for the order. + * @param params.chainId - Optional chain ID for the order. + */ + addPrecreatedOrder(params: { + orderId: string; + providerCode: string; + walletAddress: string; + chainId?: string; + }): void { + const { orderId, providerCode, walletAddress, chainId } = params; + + const orderCode = orderId.includes('/orders/') + ? orderId.split('/orders/')[1] + : orderId; + if (!orderCode?.trim()) { + return; + } + const normalizedProviderCode = normalizeProviderCode(providerCode); + + const stubOrder: RampsOrder = { + providerOrderId: orderCode, + provider: { + id: `/providers/${normalizedProviderCode}`, + name: '', + environmentType: '', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 0, width: 0 }, + }, + walletAddress, + status: RampsOrderStatus.Precreated, + orderType: 'buy', + createdAt: Date.now(), + isOnlyLink: false, + success: false, + cryptoAmount: 0, + fiatAmount: 0, + providerOrderLink: '', + totalFeesFiat: 0, + txHash: '', + network: chainId ? { chainId, name: '' } : { chainId: '', name: '' }, + canBeUpdated: true, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + }; + + this.addOrder(stubOrder); } /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 88328801755..251ff917a1b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -18,6 +18,7 @@ export type { export { RampsController, getDefaultRampsControllerState, + normalizeProviderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController'; export type {