diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c3573e3e5c..2fc18d38e1 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `getPaymentMethodsForContext(options)` and `RampsController:getPaymentMethodsForContext` messenger action for context-scoped payment-method retrieval aligned with `getQuotes` provider resolution ([#9801](https://github.com/MetaMask/core/pull/9801)) + - Supports explicit `providers`, selected-provider (UB2) context, and headless auto-select / restrict paths, including `moneyHeadlessAllProviders` widening with allowlist pick-survivor intersection. + - Request-only by default (`updateState` unset/false): does not mutate Buy `paymentMethods.data` / `.selected`. + - Fans out per contributing provider, dedupes by canonical payment id, and merges collision metadata (conservative delay, best score, deterministic name/icon). + - Partial provider failures still return methods from successful fetches. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) ## [20.0.0] diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 3898e4fea1..6f7cccd031 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -207,6 +207,48 @@ export type RampsControllerGetPaymentMethodsAction = { handler: RampsController['getPaymentMethods']; }; +/** + * Fetches payment methods for a quoting context without coupling callers to + * the Buy flow's globally selected provider/token catalog. + * + * Provider contribution mirrors {@link getQuotes}: + * - explicit `providers` (optionally filtered when + * `restrictToKnownOrNativeProviders` is set) + * - auto-select / restrict path, including `moneyHeadlessAllProviders` + * widening: flag off uses the restricted/native resolver; flag on uses + * supporting providers, intersected with the flag allowlist when that + * allowlist is non-empty (pick-survivor set for picker methods) + * - when those resolution flags and `providers` are omitted, uses only + * `providers.selected` (UB2 selected-provider context) + * + * By default this is request-only: it does **not** mutate + * `paymentMethods.data` or `paymentMethods.selected`. Pass `updateState: + * true` only when the caller explicitly wants Buy-catalog write semantics + * (UB2). Headless / MM Pay selection stays TPC-owned. + * + * Methods are request-eligible for the resolved provider set; they are not + * guaranteed to produce a quote for every amount (provider fiat limits still + * apply at quote time). + * + * @param options - Context for the payment-method fetch. + * @param options.region - Region code. Defaults to `userRegion`. + * @param options.assetId - Required CAIP-19 quoting asset. + * @param options.providers - Explicit provider ids. + * @param options.autoSelectProvider - Resolve providers like `getQuotes`. + * @param options.preferredProviderIds - Preferred ids for auto-selection. + * @param options.restrictToKnownOrNativeProviders - Headless gating. + * @param options.updateState - When true, write `paymentMethods` state. + * @param options.preferPaymentMethodId - Preserve this id when still present. + * @param options.forceRefresh - Bypass request cache for provider fetches. + * @param options.ttl - Custom TTL for provider payment-method fetches. + * @returns Deduped methods, a request-only suggested selection, and the + * provider ids that contributed. + */ +export type RampsControllerGetPaymentMethodsForContextAction = { + type: `RampsController:getPaymentMethodsForContext`; + handler: RampsController['getPaymentMethodsForContext']; +}; + /** * Sets the user's selected payment method. * @@ -685,6 +727,7 @@ export type RampsControllerMethodActions = | RampsControllerSetSelectedTokenAction | RampsControllerGetProvidersAction | RampsControllerGetPaymentMethodsAction + | RampsControllerGetPaymentMethodsForContextAction | RampsControllerSetSelectedPaymentMethodAction | RampsControllerGetQuotesAction | RampsControllerAddOrderAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 54dca251d3..e4f636c894 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -6988,6 +6988,701 @@ describe('RampsController', () => { }); }); + describe('getPaymentMethodsForContext', () => { + const DEPOSIT_ASSET = 'eip155:1/erc20:0xmusd'; + const BUY_ASSET = 'eip155:1/slip44:60'; + const NATIVE = '/providers/transak-native'; + const MOONPAY = '/providers/moonpay'; + const REVOLUT = '/providers/revolut'; + + const buyOnlyMethod: PaymentMethod = { + id: '/payments/revolut-pay', + paymentType: 'revolut-pay', + name: 'Revolut Pay', + score: 50, + icon: 'revolut', + }; + const cardMethod: PaymentMethod = { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Card', + score: 90, + icon: 'card', + delay: [5, 10], + }; + const cardFromMoonpay: PaymentMethod = { + ...cardMethod, + score: 95, + delay: [5, 60], + name: 'Debit Card', + icon: 'moonpay-card', + }; + const applePayMethod: PaymentMethod = { + id: '/payments/apple-pay', + paymentType: 'apple-pay', + name: 'Apple Pay', + score: 80, + icon: 'apple', + }; + + const buySelectedMethod: PaymentMethod = { + id: '/payments/buy-selected', + paymentType: 'buy-selected', + name: 'Buy Selected', + score: 1, + icon: 'buy', + }; + + const buildProvider = ( + id: string, + type: 'native' | 'aggregator', + assets: string[], + ): Provider => ({ + id, + name: id, + type, + environmentType: 'STAGING', + description: '', + hqAddress: '', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + supportedCryptoCurrencies: Object.fromEntries( + assets.map((assetId) => [assetId, true]), + ), + }); + + const registerFeatureFlagState = ( + rootMessenger: RootMessenger, + flagState: Partial = { + remoteFeatureFlags: { [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: true }, + }, + ): void => { + rootMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + ...flagState, + }), + ); + }; + + const headlessOptions = { + assetId: DEPOSIT_ASSET, + region: 'us-ca', + autoSelectProvider: true, + restrictToKnownOrNativeProviders: true, + } as const; + + it('flag off: fetches methods only from the restricted/native resolved provider', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + const moonpayBuyOnly = buildProvider(MOONPAY, 'aggregator', [BUY_ASSET]); + const requestedProviders: string[] = []; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + // Selected Buy provider does not support the deposit asset, so + // the restricted resolver must fall through to native. + providers: createResourceState( + [native, moonpayBuyOnly], + moonpayBuyOnly, + ), + paymentMethods: createResourceState( + [buySelectedMethod], + buySelectedMethod, + ), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string }) => { + requestedProviders.push(params.provider ?? ''); + if (params.provider === NATIVE) { + return { payments: [cardMethod] }; + } + return { payments: [buyOnlyMethod] }; + }, + ); + + const result = + await controller.getPaymentMethodsForContext(headlessOptions); + + expect(requestedProviders).toStrictEqual([NATIVE]); + expect(result.providerIds).toStrictEqual([NATIVE]); + expect(result.methods.map((method) => method.id)).toStrictEqual([ + cardMethod.id, + ]); + expect( + result.methods.some((method) => method.id === buyOnlyMethod.id), + ).toBe(false); + // Buy catalog untouched. + expect(controller.state.paymentMethods.data).toStrictEqual([ + buySelectedMethod, + ]); + expect(controller.state.paymentMethods.selected).toStrictEqual( + buySelectedMethod, + ); + }, + ); + }); + + it('flag on without allowlist: fans out to all supporting providers and merges methods', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + const revolutBuyOnly = buildProvider(REVOLUT, 'aggregator', [BUY_ASSET]); + const requestedProviders: string[] = []; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState( + [native, moonpay, revolutBuyOnly], + revolutBuyOnly, + ), + paymentMethods: createResourceState( + [buySelectedMethod], + buySelectedMethod, + ), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string; assetId?: string }) => { + requestedProviders.push(params.provider ?? ''); + expect(params.assetId).toBe(DEPOSIT_ASSET); + if (params.provider === NATIVE) { + return { payments: [cardMethod, applePayMethod] }; + } + if (params.provider === MOONPAY) { + return { payments: [cardFromMoonpay, buyOnlyMethod] }; + } + return { payments: [buyOnlyMethod] }; + }, + ); + + const result = + await controller.getPaymentMethodsForContext(headlessOptions); + + expect(requestedProviders.sort()).toStrictEqual( + [NATIVE, MOONPAY].sort(), + ); + expect(result.providerIds.sort()).toStrictEqual( + [NATIVE, MOONPAY].sort(), + ); + expect( + result.methods.map((method) => method.id).sort(), + ).toStrictEqual( + [cardMethod.id, applePayMethod.id, buyOnlyMethod.id].sort(), + ); + const mergedCard = result.methods.find( + (method) => method.id === cardMethod.id, + ); + expect(mergedCard?.score).toBe(95); + expect(mergedCard?.delay).toStrictEqual([5, 60]); + expect(controller.state.paymentMethods.selected).toStrictEqual( + buySelectedMethod, + ); + }, + ); + }); + + it('flag on with allowlist: only supporting intersect allowlist contribute methods', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + const revolut = buildProvider(REVOLUT, 'aggregator', [DEPOSIT_ASSET]); + const requestedProviders: string[] = []; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([native, moonpay, revolut], null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: { + enabled: true, + featureVersion: '1', + providerIds: ['moonpay'], + }, + }, + }); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string }) => { + requestedProviders.push(params.provider ?? ''); + return { payments: [cardMethod] }; + }, + ); + + const result = + await controller.getPaymentMethodsForContext(headlessOptions); + + expect(requestedProviders).toStrictEqual([MOONPAY]); + expect(result.providerIds).toStrictEqual([MOONPAY]); + }, + ); + }); + + it('explicit providers path fetches only those providers', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + const revolut = buildProvider(REVOLUT, 'aggregator', [DEPOSIT_ASSET]); + const requestedProviders: string[] = []; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay, revolut], moonpay), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string }) => { + requestedProviders.push(params.provider ?? ''); + return { payments: [cardMethod] }; + }, + ); + + const result = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + region: 'us-ca', + providers: [REVOLUT], + }); + + expect(requestedProviders).toStrictEqual([REVOLUT]); + expect(result.providerIds).toStrictEqual([REVOLUT]); + }, + ); + }); + + it('selected-provider context uses providers.selected when resolution flags are omitted', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + const revolut = buildProvider(REVOLUT, 'aggregator', [DEPOSIT_ASSET]); + const requestedProviders: string[] = []; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay, revolut], moonpay), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string }) => { + requestedProviders.push(params.provider ?? ''); + return { payments: [applePayMethod] }; + }, + ); + + const result = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + region: 'us-ca', + }); + + expect(requestedProviders).toStrictEqual([MOONPAY]); + expect(result.providerIds).toStrictEqual([MOONPAY]); + expect(result.methods).toStrictEqual([applePayMethod]); + }, + ); + }); + + it('updateState true writes paymentMethods catalog and selection', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay], moonpay), + paymentMethods: createResourceState( + [buySelectedMethod], + buySelectedMethod, + ), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => ({ payments: [cardMethod, applePayMethod] }), + ); + + const result = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + region: 'us-ca', + providers: [MOONPAY], + updateState: true, + preferPaymentMethodId: applePayMethod.id, + }); + + expect(result.selected).toStrictEqual(applePayMethod); + expect(controller.state.paymentMethods.data).toStrictEqual( + result.methods, + ); + expect(controller.state.paymentMethods.selected).toStrictEqual( + applePayMethod, + ); + }, + ); + }); + + it('partial provider failure still returns methods from successful providers', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([native, moonpay], null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerFeatureFlagState(rootMessenger); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string }) => { + if (params.provider === MOONPAY) { + throw new Error('moonpay down'); + } + return { payments: [cardMethod] }; + }, + ); + + const result = + await controller.getPaymentMethodsForContext(headlessOptions); + + expect(result.methods).toStrictEqual([cardMethod]); + expect(result.providerIds.sort()).toStrictEqual( + [NATIVE, MOONPAY].sort(), + ); + }, + ); + }); + + it('throws when every provider fetch fails', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([native], native), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => { + throw new Error('native down'); + }, + ); + + await expect( + controller.getPaymentMethodsForContext(headlessOptions), + ).rejects.toThrow('native down'); + }, + ); + }); + + it('throws when assetId is empty', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + }, + }, + }, + async ({ controller }) => { + await expect( + controller.getPaymentMethodsForContext({ + assetId: ' ', + region: 'us-ca', + }), + ).rejects.toThrow('assetId is required.'); + }, + ); + }); + + it('returns empty methods when no providers resolve, and clears state when updateState', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([], null), + paymentMethods: createResourceState( + [buySelectedMethod], + buySelectedMethod, + ), + }, + }, + }, + async ({ controller }) => { + const withoutWrite = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + region: 'us-ca', + }); + expect(withoutWrite).toStrictEqual({ + methods: [], + selected: null, + providerIds: [], + }); + expect(controller.state.paymentMethods.selected).toStrictEqual( + buySelectedMethod, + ); + + const withWrite = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + region: 'us-ca', + updateState: true, + }); + expect(withWrite).toStrictEqual({ + methods: [], + selected: null, + providerIds: [], + }); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); + }, + ); + }); + + it('explicit providers with restrict filters to supporting providers only', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + const revolut = buildProvider(REVOLUT, 'aggregator', [BUY_ASSET]); + const requestedProviders: string[] = []; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay, revolut], null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { provider?: string }) => { + requestedProviders.push(params.provider ?? ''); + return { payments: [cardMethod] }; + }, + ); + + const result = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + region: 'us-ca', + providers: [MOONPAY, REVOLUT], + restrictToKnownOrNativeProviders: true, + }); + + expect(requestedProviders).toStrictEqual([MOONPAY]); + expect(result.providerIds).toStrictEqual([MOONPAY]); + }, + ); + }); + + it('preserves controller selection when updateState and selection remains valid', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay], moonpay), + paymentMethods: createResourceState([cardMethod], cardMethod), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => ({ payments: [cardMethod, applePayMethod] }), + ); + + const result = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + providers: [MOONPAY], + updateState: true, + }); + + expect(result.selected).toStrictEqual(cardMethod); + expect(controller.state.paymentMethods.selected).toStrictEqual( + cardMethod, + ); + }, + ); + }); + + it('falls back to first method when preferPaymentMethodId is absent from the list', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay], moonpay), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => ({ payments: [cardMethod, applePayMethod] }), + ); + + const result = await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + providers: [MOONPAY], + preferPaymentMethodId: '/payments/missing', + }); + + expect(result.selected).toStrictEqual(cardMethod); + }, + ); + }); + + it('throws a generic error when every provider fails with a non-Error reason', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([native], native), + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject('native down'); + }, + ); + + await expect( + controller.getPaymentMethodsForContext(headlessOptions), + ).rejects.toThrow('Failed to fetch payment methods for context.'); + }, + ); + }); + + it('uses userRegion when region is omitted', async () => { + const moonpay = buildProvider(MOONPAY, 'aggregator', [DEPOSIT_ASSET]); + let requestedRegion: string | undefined; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([moonpay], moonpay), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async (params: { region: string }) => { + requestedRegion = params.region; + return { payments: [cardMethod] }; + }, + ); + + await controller.getPaymentMethodsForContext({ + assetId: DEPOSIT_ASSET, + providers: [MOONPAY], + }); + + expect(requestedRegion).toBe('us-ca'); + }, + ); + }); + + it('exposes the method on the messenger', async () => { + const native = buildProvider(NATIVE, 'native', [DEPOSIT_ASSET]); + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState([native], native), + }, + }, + }, + async ({ messenger, rootMessenger }) => { + registerFeatureFlagState(rootMessenger, { + remoteFeatureFlags: { + [MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY]: false, + }, + }); + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => ({ payments: [cardMethod] }), + ); + + const result = await messenger.call( + 'RampsController:getPaymentMethodsForContext', + headlessOptions, + ); + + expect(result.methods).toStrictEqual([cardMethod]); + }, + ); + }); + }); + describe('setSelectedPaymentMethod', () => { const mockPaymentMethod: PaymentMethod = { id: '/payments/debit-credit-card', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index aac160d882..f958c5ab88 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -19,6 +19,7 @@ import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js'; +import { mergePaymentMethodsById } from './paymentMethodMerge.js'; import { getProvidersServingAsset, providerServesAsset, @@ -342,6 +343,30 @@ export type NativeProvidersState = { transak: TransakState; }; +/** + * Response from {@link RampsController.getPaymentMethodsForContext}. + * + * Methods are request-eligible for the resolved provider set; they are not a + * guarantee that every amount will produce a quote (provider fiat limits still + * apply at quote time). + */ +export type PaymentMethodsForContextResponse = { + /** + * Deduped payment methods contributed by the resolved provider set. + */ + methods: PaymentMethod[]; + /** + * Suggested selection for this request only. Written to controller state only + * when `updateState` was true on the call. + */ + selected: PaymentMethod | null; + /** + * Provider IDs whose methods were requested (after resolution / allowlist + * filtering). + */ + providerIds: string[]; +}; + /** * Describes the shape of the state object for {@link RampsController}. */ @@ -807,6 +832,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'setSelectedToken', 'getProviders', 'getPaymentMethods', + 'getPaymentMethodsForContext', 'setSelectedPaymentMethod', 'getQuotes', 'addOrder', @@ -1829,6 +1855,146 @@ export class RampsController extends BaseController< return response; } + /** + * Fetches payment methods for a quoting context without coupling callers to + * the Buy flow's globally selected provider/token catalog. + * + * Provider contribution mirrors {@link getQuotes}: + * - explicit `providers` (optionally filtered when + * `restrictToKnownOrNativeProviders` is set) + * - auto-select / restrict path, including `moneyHeadlessAllProviders` + * widening: flag off uses the restricted/native resolver; flag on uses + * supporting providers, intersected with the flag allowlist when that + * allowlist is non-empty (pick-survivor set for picker methods) + * - when those resolution flags and `providers` are omitted, uses only + * `providers.selected` (UB2 selected-provider context) + * + * By default this is request-only: it does **not** mutate + * `paymentMethods.data` or `paymentMethods.selected`. Pass `updateState: + * true` only when the caller explicitly wants Buy-catalog write semantics + * (UB2). Headless / MM Pay selection stays TPC-owned. + * + * Methods are request-eligible for the resolved provider set; they are not + * guaranteed to produce a quote for every amount (provider fiat limits still + * apply at quote time). + * + * @param options - Context for the payment-method fetch. + * @param options.region - Region code. Defaults to `userRegion`. + * @param options.assetId - Required CAIP-19 quoting asset. + * @param options.providers - Explicit provider ids. + * @param options.autoSelectProvider - Resolve providers like `getQuotes`. + * @param options.preferredProviderIds - Preferred ids for auto-selection. + * @param options.restrictToKnownOrNativeProviders - Headless gating. + * @param options.updateState - When true, write `paymentMethods` state. + * @param options.preferPaymentMethodId - Preserve this id when still present. + * @param options.forceRefresh - Bypass request cache for provider fetches. + * @param options.ttl - Custom TTL for provider payment-method fetches. + * @returns Deduped methods, a request-only suggested selection, and the + * provider ids that contributed. + */ + async getPaymentMethodsForContext(options: { + region?: string; + assetId: string; + providers?: string[]; + autoSelectProvider?: boolean; + preferredProviderIds?: string[]; + restrictToKnownOrNativeProviders?: boolean; + updateState?: boolean; + preferPaymentMethodId?: string; + forceRefresh?: boolean; + ttl?: number; + }): Promise { + const regionToUse = options.region ?? this.#requireRegion(); + const normalizedRegion = regionToUse.toLowerCase().trim(); + const assetId = options.assetId.trim(); + if (assetId === '') { + throw new Error('assetId is required.'); + } + + const providerIds = await this.#resolveProviderIdsForPaymentMethods({ + assetId, + region: normalizedRegion, + providers: options.providers, + autoSelectProvider: options.autoSelectProvider, + preferredProviderIds: options.preferredProviderIds, + restrictToKnownOrNativeProviders: + options.restrictToKnownOrNativeProviders, + }); + + if (providerIds.length === 0) { + if (options.updateState === true) { + this.update((state) => { + state.paymentMethods.data = []; + state.paymentMethods.selected = null; + }); + } + return { methods: [], selected: null, providerIds }; + } + + const settled = await Promise.allSettled( + providerIds.map(async (providerId) => { + const cacheKey = createCacheKey('getPaymentMethodsForContext', [ + normalizedRegion, + assetId, + providerId, + ]); + return this.executeRequest( + cacheKey, + async () => { + return this.messenger.call('RampsService:getPaymentMethods', { + region: normalizedRegion, + assetId, + provider: providerId, + }); + }, + { + forceRefresh: options.forceRefresh, + ttl: options.ttl, + // Intentionally omit resourceType / isResultCurrent so this + // request-only path never drives Buy paymentMethods loading or + // selection state unless `updateState` is explicitly set below. + }, + ); + }), + ); + + const successfulLists: PaymentMethod[][] = []; + const failures: unknown[] = []; + for (const result of settled) { + if (result.status === 'fulfilled') { + successfulLists.push(result.value.payments); + } else { + failures.push(result.reason); + } + } + + if (successfulLists.length === 0) { + const firstFailure = failures[0]; + throw firstFailure instanceof Error + ? firstFailure + : new Error('Failed to fetch payment methods for context.'); + } + + const methods = mergePaymentMethodsById(successfulLists); + const preferredId = + options.preferPaymentMethodId ?? this.state.paymentMethods.selected?.id; + const selected = + (preferredId + ? (methods.find((method) => method.id === preferredId) ?? null) + : null) ?? + methods[0] ?? + null; + + if (options.updateState === true) { + this.update((state) => { + state.paymentMethods.data = methods; + state.paymentMethods.selected = selected; + }); + } + + return { methods, selected, providerIds }; + } + /** * Sets the user's selected payment method. * @@ -2361,6 +2527,89 @@ export class RampsController extends BaseController< return [supporting[0].id]; } + /** + * Resolves provider IDs that should contribute payment methods for a + * quoting context. Mirrors {@link getQuotes} provider-set selection, with + * one intentional difference on the widened path: when the all-providers + * flag allowlist is non-empty, returns supporting providers intersected + * with that allowlist (pick survivors for the picker). Does not mutate + * state. + * + * @param options - Resolution inputs aligned with `getQuotes`. + * @param options.assetId - CAIP-19 asset type identifier to resolve for. + * @param options.region - Region to resolve providers for. + * @param options.providers - Explicit provider IDs, when provided. + * @param options.autoSelectProvider - Resolve providers like `getQuotes`. + * @param options.preferredProviderIds - Preferred provider IDs in order. + * @param options.restrictToKnownOrNativeProviders - Headless gating. + * @returns Provider IDs for this request only. + */ + async #resolveProviderIdsForPaymentMethods({ + assetId, + region, + providers, + autoSelectProvider, + preferredProviderIds, + restrictToKnownOrNativeProviders, + }: { + assetId: string; + region: string; + providers?: string[]; + autoSelectProvider?: boolean; + preferredProviderIds?: string[]; + restrictToKnownOrNativeProviders?: boolean; + }): Promise { + const wantsAutoSelection = + !providers && + (autoSelectProvider === true || + restrictToKnownOrNativeProviders === true); + const { enabled: allProvidersEnabled, allowlist: providerAllowlist } = + wantsAutoSelection + ? this.#resolveAllProvidersFlag() + : { enabled: false, allowlist: undefined }; + const widenToAllProviders = wantsAutoSelection && allProvidersEnabled; + + if (providers) { + return restrictToKnownOrNativeProviders + ? this.#filterProviderIdsBySupport({ + providerIds: providers, + assetId, + region, + }) + : providers; + } + + if (widenToAllProviders) { + const { supporting } = await this.#getSupportingProvidersForRegion({ + assetId, + region, + }); + if (providerAllowlist && providerAllowlist.length > 0) { + const allowedProviderIds = new Set( + providerAllowlist.map(normalizeHeadlessProviderId), + ); + return supporting + .filter((provider) => + allowedProviderIds.has(normalizeHeadlessProviderId(provider.id)), + ) + .map((provider) => provider.id); + } + return supporting.map((provider) => provider.id); + } + + if (autoSelectProvider || restrictToKnownOrNativeProviders) { + return this.#resolveProviderIdsForQuote({ + assetId, + region, + preferredProviderIds, + restrictToKnownOrNative: restrictToKnownOrNativeProviders, + }); + } + + const selectedId = this.state.providers.selected?.id; + return selectedId ? [selectedId] : []; + } + /** * Derives an ordered list of provider IDs from the user's completed-order * history, most recently completed first, with duplicates removed. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f1d1dcdbe6..993dbda5c8 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,6 +7,7 @@ export type { RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, RampsControllerOptions, + PaymentMethodsForContextResponse, UserRegion, ResourceState, TransakState, @@ -25,6 +26,7 @@ export type { RampsControllerSetSelectedTokenAction, RampsControllerGetProvidersAction, RampsControllerGetPaymentMethodsAction, + RampsControllerGetPaymentMethodsForContextAction, RampsControllerSetSelectedPaymentMethodAction, RampsControllerGetQuotesAction, RampsControllerAddOrderAction, diff --git a/packages/ramps-controller/src/paymentMethodMerge.test.ts b/packages/ramps-controller/src/paymentMethodMerge.test.ts new file mode 100644 index 0000000000..14acc1bde3 --- /dev/null +++ b/packages/ramps-controller/src/paymentMethodMerge.test.ts @@ -0,0 +1,130 @@ +import { + isMoreConservativeDelay, + mergePaymentMethodsById, +} from './paymentMethodMerge.js'; +import type { PaymentMethod } from './RampsService.js'; + +const card = (overrides: Partial = {}): PaymentMethod => ({ + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Card', + score: 80, + icon: 'card', + ...overrides, +}); + +const venmo = (overrides: Partial = {}): PaymentMethod => ({ + id: '/payments/venmo', + paymentType: 'bank-transfer', + name: 'Venmo', + score: 70, + icon: 'venmo', + ...overrides, +}); + +describe('isMoreConservativeDelay', () => { + it('treats a higher max as more conservative', () => { + expect(isMoreConservativeDelay([5, 60], [5, 10])).toBe(true); + expect(isMoreConservativeDelay([5, 10], [5, 60])).toBe(false); + }); + + it('uses min as a tie-breaker when max matches', () => { + expect(isMoreConservativeDelay([10, 30], [5, 30])).toBe(true); + expect(isMoreConservativeDelay([5, 30], [10, 30])).toBe(false); + }); + + it('treats missing delay as least conservative', () => { + expect(isMoreConservativeDelay([1, 2], undefined)).toBe(true); + expect(isMoreConservativeDelay(undefined, [1, 2])).toBe(false); + }); +}); + +describe('mergePaymentMethodsById', () => { + it('dedupes by id and preserves first-seen order', () => { + const merged = mergePaymentMethodsById([ + [card(), venmo()], + [card({ score: 99, name: 'Debit Card' })], + ]); + + expect(merged.map((method) => method.id)).toStrictEqual([ + '/payments/debit-credit-card', + '/payments/venmo', + ]); + }); + + it('keeps the more conservative delay on collision', () => { + const merged = mergePaymentMethodsById([ + [card({ delay: [5, 10] })], + [card({ delay: [5, 60] })], + ]); + + expect(merged[0]?.delay).toStrictEqual([5, 60]); + }); + + it('keeps the best score on collision', () => { + const merged = mergePaymentMethodsById([ + [card({ score: 80 })], + [card({ score: 95 })], + ]); + + expect(merged[0]?.score).toBe(95); + }); + + it('prefers the lexicographically smaller name and its icon when names differ', () => { + const merged = mergePaymentMethodsById([ + [card({ name: 'Zeta Card', icon: 'zeta' })], + [card({ name: 'Alpha Card', icon: 'alpha' })], + ]); + + expect(merged[0]?.name).toBe('Alpha Card'); + expect(merged[0]?.icon).toBe('alpha'); + }); + + it('keeps first-seen name when names are equal and fills a missing icon', () => { + const merged = mergePaymentMethodsById([ + [card({ name: 'Card', icon: '' })], + [card({ name: 'Card', icon: 'card-filled' })], + ]); + + expect(merged[0]?.name).toBe('Card'); + expect(merged[0]?.icon).toBe('card-filled'); + }); + + it('fills a missing name from the colliding entry', () => { + const merged = mergePaymentMethodsById([ + [card({ name: '', icon: 'kept' })], + [card({ name: 'Filled Card', icon: '' })], + ]); + + expect(merged[0]?.name).toBe('Filled Card'); + expect(merged[0]?.icon).toBe('kept'); + }); + + it('treats a single-element delay as both min and max', () => { + expect(isMoreConservativeDelay([30], [10])).toBe(true); + expect(isMoreConservativeDelay([], [1, 2])).toBe(false); + }); + + it('keeps first-seen optional fields when present', () => { + const merged = mergePaymentMethodsById([ + [ + card({ + disclaimer: 'first', + pendingOrderDescription: 'pending-a', + isManualBankTransfer: false, + }), + ], + [ + card({ + disclaimer: 'second', + pendingOrderDescription: 'pending-b', + isManualBankTransfer: true, + }), + ], + ]); + + expect(merged[0]?.disclaimer).toBe('first'); + expect(merged[0]?.pendingOrderDescription).toBe('pending-a'); + expect(merged[0]?.isManualBankTransfer).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/paymentMethodMerge.ts b/packages/ramps-controller/src/paymentMethodMerge.ts new file mode 100644 index 0000000000..b43699d3b7 --- /dev/null +++ b/packages/ramps-controller/src/paymentMethodMerge.ts @@ -0,0 +1,121 @@ +import type { PaymentMethod } from './RampsService.js'; + +/** + * Compares two delay intervals and returns whether `candidate` is strictly + * more conservative (worse / longer) than `current`. + * + * Conservativeness prefers a higher upper bound, then a higher lower bound. + * Missing or empty delay is treated as the least conservative (instant). + * + * @param candidate - Delay to compare. + * @param current - Delay already chosen. + * @returns Whether `candidate` should replace `current`. + */ +export function isMoreConservativeDelay( + candidate: number[] | undefined, + current: number[] | undefined, +): boolean { + const [candidateMax, candidateMin] = delayBounds(candidate); + const [currentMax, currentMin] = delayBounds(current); + if (candidateMax !== currentMax) { + return candidateMax > currentMax; + } + return candidateMin > currentMin; +} + +/** + * Merges payment-method lists from multiple providers into one list keyed by + * canonical payment method `id`. + * + * Collision rules (deterministic): + * - **delay:** keep the more conservative (longer) delay. + * - **score:** keep the best (highest) score. + * - **name / icon:** prefer the first non-empty name; when both names are + * present and differ, prefer the lexicographically smaller name and the + * icon from that same winning entry; when names are equal, keep first-seen + * name and fill a missing icon from the later entry. + * - Other optional fields: keep first-seen non-empty / defined values. + * + * Encounter order of first-seen ids is preserved (provider fan-out order). + * + * @param lists - Payment method arrays in provider contribution order. + * @returns Deduped payment methods. + */ +export function mergePaymentMethodsById( + lists: PaymentMethod[][], +): PaymentMethod[] { + const byId = new Map(); + const order: string[] = []; + + for (const list of lists) { + for (const method of list) { + const existing = byId.get(method.id); + if (!existing) { + byId.set(method.id, { ...method }); + order.push(method.id); + continue; + } + byId.set(method.id, mergePaymentMethodCollision(existing, method)); + } + } + + return order.map((id) => byId.get(id) as PaymentMethod); +} + +/** + * Merges two payment methods that share the same canonical id. + * + * @param current - First-seen method (or prior merge result). + * @param incoming - Later colliding method. + * @returns Merged payment method. + */ +function mergePaymentMethodCollision( + current: PaymentMethod, + incoming: PaymentMethod, +): PaymentMethod { + const delay = isMoreConservativeDelay(incoming.delay, current.delay) + ? incoming.delay + : current.delay; + const score = Math.max(current.score, incoming.score); + + let { name, icon } = current; + if (!name && incoming.name) { + name = incoming.name; + icon = incoming.icon || icon; + } else if (name && incoming.name && incoming.name !== name) { + if (incoming.name < name) { + name = incoming.name; + icon = incoming.icon; + } + } else if (!icon && incoming.icon) { + icon = incoming.icon; + } + + return { + ...current, + name, + icon, + score, + ...(delay === undefined ? {} : { delay }), + disclaimer: current.disclaimer ?? incoming.disclaimer, + pendingOrderDescription: + current.pendingOrderDescription ?? incoming.pendingOrderDescription, + isManualBankTransfer: + current.isManualBankTransfer ?? incoming.isManualBankTransfer, + }; +} + +/** + * Normalizes a delay array into `[max, min]` bounds for comparison. + * + * @param delay - Optional delay interval in minutes. + * @returns Upper then lower bound; `[0, 0]` when absent. + */ +function delayBounds(delay: number[] | undefined): [number, number] { + if (!delay || delay.length === 0) { + return [0, 0]; + } + const min = delay[0] ?? 0; + const max = delay[1] ?? min; + return [max, min]; +}