From 58ba27389f18ed7f19b5e5a9aa59e5671fdeb8d3 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 3 Mar 2026 13:25:23 -0700 Subject: [PATCH 1/6] feat: adds support for custom actions --- .../src/RampsController.test.ts | 58 +++++++++++++--- .../ramps-controller/src/RampsController.ts | 69 +++++++++++++++++-- packages/ramps-controller/src/RampsService.ts | 6 ++ 3 files changed, 115 insertions(+), 18 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index e9391e4070c..aaea139e14d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -4773,7 +4773,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 +4796,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 +4817,9 @@ describe('RampsController', () => { }, }; - const widgetUrl = await controller.getWidgetUrl(quote); + const buyWidget = await controller.getBuyWidgetData(quote); - expect(widgetUrl).toBeNull(); + expect(buyWidget).toBeNull(); }); }); @@ -4825,9 +4829,9 @@ 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(); }); }); @@ -4851,9 +4855,9 @@ describe('RampsController', () => { }, ); - const widgetUrl = await controller.getWidgetUrl(quote); + const buyWidget = await controller.getBuyWidgetData(quote); - expect(widgetUrl).toBeNull(); + expect(buyWidget).toBeNull(); }); }); @@ -4879,9 +4883,41 @@ 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(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(widgetUrl).toBeNull(); + expect(controller.state.orders[0]?.providerOrderId).toBe('plain-order-id'); }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 23a4b10fad6..097ab5d9484 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[]; }; @@ -1834,14 +1834,14 @@ 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. */ - async getWidgetUrl(quote: Quote): Promise { + async getBuyWidgetData(quote: Quote): Promise { const buyUrl = quote.quote?.buyURL; if (!buyUrl) { return null; @@ -1852,12 +1852,67 @@ export class RampsController extends BaseController< 'RampsService:getBuyWidgetUrl', buyUrl, ); - return buyWidget.url ?? null; + if (!buyWidget?.url) { + return null; + } + return buyWidget; } catch { return null; } } + /** + * 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 - orderId (e.g. "/providers/paypal/orders/abc123"), providerCode, walletAddress, chainId (optional). + */ + 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; + const normalizedProviderCode = providerCode.replace('/providers/', ''); + + 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); + } + /** * Fetches an order from the unified V2 API endpoint. * Returns a normalized RampsOrder for all provider types (aggregator and native). diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index a75317eca2d..120e5b57e1f 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -255,6 +255,11 @@ export type Quote = { * Contains the widget URL, browser type, and optional pre-order tracking ID. */ buyWidget?: BuyWidget; + /** + * When true, this is a synthetic quote for custom-action-only providers (e.g. PayPal). + * UI should skip displaying amounts for such quotes. + */ + isCustomAction?: boolean; }; /** * Metadata about the quote. @@ -697,6 +702,7 @@ function getBaseUrl( environment: RampsEnvironment, service: RampsApiService, ): string { + return "http://localhost:3000" const cache = service === RampsApiService.Regions ? '-cache' : ''; switch (environment) { From 292cc297fc49aefffeb884b877d9b02f00d5d074 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 3 Mar 2026 13:26:41 -0700 Subject: [PATCH 2/6] chore: removes dev setup --- packages/ramps-controller/src/RampsService.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 120e5b57e1f..a75317eca2d 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -255,11 +255,6 @@ export type Quote = { * Contains the widget URL, browser type, and optional pre-order tracking ID. */ buyWidget?: BuyWidget; - /** - * When true, this is a synthetic quote for custom-action-only providers (e.g. PayPal). - * UI should skip displaying amounts for such quotes. - */ - isCustomAction?: boolean; }; /** * Metadata about the quote. @@ -702,7 +697,6 @@ function getBaseUrl( environment: RampsEnvironment, service: RampsApiService, ): string { - return "http://localhost:3000" const cache = service === RampsApiService.Regions ? '-cache' : ''; switch (environment) { From 0e5806f7e5980dbb8557a5248b368f66e9aa63c5 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 4 Mar 2026 06:13:10 -0700 Subject: [PATCH 3/6] chore: cleanup and utils --- .../src/RampsController.test.ts | 27 ++++++++++++--- .../ramps-controller/src/RampsController.ts | 33 +++++++++++-------- packages/ramps-controller/src/RampsService.ts | 1 + packages/ramps-controller/src/index.ts | 1 + 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index aaea139e14d..127b73ceb5a 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(); @@ -4835,7 +4850,7 @@ describe('RampsController', () => { }); }); - 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', @@ -4855,9 +4870,9 @@ describe('RampsController', () => { }, ); - const buyWidget = await controller.getBuyWidgetData(quote); - - expect(buyWidget).toBeNull(); + await expect(controller.getBuyWidgetData(quote)).rejects.toThrow( + 'Network error', + ); }); }); @@ -4917,7 +4932,9 @@ describe('RampsController', () => { walletAddress: '0xdef', }); - expect(controller.state.orders[0]?.providerOrderId).toBe('plain-order-id'); + expect(controller.state.orders[0]?.providerOrderId).toBe( + 'plain-order-id', + ); }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 097ab5d9484..4de3edd025d 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -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 { @@ -1839,7 +1843,8 @@ export class RampsController extends BaseController< * 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 full BuyWidget (url, browser, orderId), 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 getBuyWidgetData(quote: Quote): Promise { const buyUrl = quote.quote?.buyURL; @@ -1847,18 +1852,14 @@ export class RampsController extends BaseController< return null; } - try { - const buyWidget = await this.messenger.call( - 'RampsService:getBuyWidgetUrl', - buyUrl, - ); - if (!buyWidget?.url) { - return null; - } - return buyWidget; - } catch { + const buyWidget = await this.messenger.call( + 'RampsService:getBuyWidgetUrl', + buyUrl, + ); + if (!buyWidget?.url) { return null; } + return buyWidget; } /** @@ -1866,7 +1867,11 @@ export class RampsController extends BaseController< * 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 - orderId (e.g. "/providers/paypal/orders/abc123"), providerCode, walletAddress, chainId (optional). + * @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; @@ -1879,7 +1884,7 @@ export class RampsController extends BaseController< const orderCode = orderId.includes('/orders/') ? orderId.split('/orders/')[1] : orderId; - const normalizedProviderCode = providerCode.replace('/providers/', ''); + const normalizedProviderCode = normalizeProviderCode(providerCode); const stubOrder: RampsOrder = { providerOrderId: orderCode, diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index a75317eca2d..c5f18957df1 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -697,6 +697,7 @@ function getBaseUrl( environment: RampsEnvironment, service: RampsApiService, ): string { + return 'http://localhost:3000'; const cache = service === RampsApiService.Regions ? '-cache' : ''; switch (environment) { 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 { From c8b29e761df2baec95c63bcdfb3590d00978b5c8 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 4 Mar 2026 06:41:52 -0700 Subject: [PATCH 4/6] chore: revert dev change --- packages/ramps-controller/src/RampsService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index c5f18957df1..a75317eca2d 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -697,7 +697,6 @@ function getBaseUrl( environment: RampsEnvironment, service: RampsApiService, ): string { - return 'http://localhost:3000'; const cache = service === RampsApiService.Regions ? '-cache' : ''; switch (environment) { From 77032d80be9eac9802b48714edc1b57caff49160 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 4 Mar 2026 06:49:17 -0700 Subject: [PATCH 5/6] chore: changelog entry --- packages/ramps-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From 685dbf46b99b67eed8c2e73faacd57d9768dd288 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 4 Mar 2026 08:28:23 -0700 Subject: [PATCH 6/6] chore: defensive bugbot fix --- .../ramps-controller/src/RampsController.test.ts | 12 ++++++++++++ packages/ramps-controller/src/RampsController.ts | 3 +++ 2 files changed, 15 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 127b73ceb5a..f46d49facb3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -4937,6 +4937,18 @@ describe('RampsController', () => { ); }); }); + + 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); + }); + }); }); describe('destroy', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 4de3edd025d..71c2aa41e69 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -1884,6 +1884,9 @@ export class RampsController extends BaseController< const orderCode = orderId.includes('/orders/') ? orderId.split('/orders/')[1] : orderId; + if (!orderCode?.trim()) { + return; + } const normalizedProviderCode = normalizeProviderCode(providerCode); const stubOrder: RampsOrder = {