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
79 changes: 66 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,43 @@ describe('RampsController', () => {
}),
);

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

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

expect(widgetUrl).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(controller.state.orders[0]?.providerOrderId).toBe(
'plain-order-id',
);
});
});
});
Expand Down
88 changes: 74 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,84 @@ 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch here swallows everything silently. From the caller's perspective it just returns null and there's no way to know if it was a missing buyURL or a failed network request. Worth at least logging the error before returning 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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split parsing can yield undefined orderCode silently

Low Severity

When orderId ends with /orders/ (e.g. "/providers/paypal/orders/"), the split('/orders/')[1] expression yields an empty string "", which is then assigned to providerOrderId. This creates a stub order that passes the addOrder call but will never be polled — #refreshOrder bails on !order.providerOrderId. The orphan stub persists in controller state indefinitely with no mechanism to clean it up. A guard on empty orderCode before calling addOrder would prevent this silent failure.

Fix in Cursor Fix in Web

const normalizedProviderCode = normalizeProviderCode(providerCode);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This providerCode normalization (stripping /providers/) is also done in BuildQuote.tsx before calling addPrecreatedOrder. Two copies of the same logic means they can drift apart. Worth extracting into a shared util.

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