Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 78 additions & 13 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
UserRegion,
} from './RampsController';
import {
normalizeProviderCode,
RampsController,
getDefaultRampsControllerState,
RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
});
});
});

Expand All @@ -4813,9 +4832,9 @@ describe('RampsController', () => {
},
};

const widgetUrl = await controller.getWidgetUrl(quote);
const buyWidget = await controller.getBuyWidgetData(quote);

expect(widgetUrl).toBeNull();
expect(buyWidget).toBeNull();
});
});

Expand All @@ -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',
Expand All @@ -4851,9 +4870,9 @@ describe('RampsController', () => {
},
);

const widgetUrl = await controller.getWidgetUrl(quote);

expect(widgetUrl).toBeNull();
await expect(controller.getBuyWidgetData(quote)).rejects.toThrow(
'Network error',
);
});
});

Expand All @@ -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);
});
});
});
Expand Down
91 changes: 77 additions & 14 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Json } from '@metamask/utils';
import type { Draft } from 'immer';

import type {
BuyWidget,
Country,
TokensResponse,
Provider,
Expand Down Expand Up @@ -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[];
};
Expand Down Expand Up @@ -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<RampsOrderStatus>([
Expand Down Expand Up @@ -1707,7 +1711,7 @@ export class RampsController extends BaseController<
return;
}

const providerCodeSegment = providerCode.replace('/providers/', '');
const providerCodeSegment = normalizeProviderCode(providerCode);
const previousStatus = order.status;

try {
Expand Down Expand Up @@ -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<string | null> {
async getBuyWidgetData(quote: Quote): Promise<BuyWidget | null> {
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);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/ramps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type {
export {
RampsController,
getDefaultRampsControllerState,
normalizeProviderCode,
RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
} from './RampsController';
export type {
Expand Down
Loading