diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 44131aa439f..5444a504222 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `doNotUpdateState` option to `ExecuteRequestOptions` to allow external consumers to use controller methods without updating state ([#7708](https://github.com/MetaMask/core/pull/7708)) - Add `hydrateState()` method to fetch providers and tokens for user region ([#7707](https://github.com/MetaMask/core/pull/7707)) - Add `countries` state to RampsController with 24 hour TTL caching ([#7707](https://github.com/MetaMask/core/pull/7707)) -- Add `SupportedActions` type for `{ buy: boolean; sell: boolean }` support info +- Add `SupportedActions` type for `{ buy: boolean; sell: boolean }` support info ([#7707](https://github.com/MetaMask/core/pull/7707)) ### Changed diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0a3158f618f..ebd3c821a89 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -23,7 +23,7 @@ import type { RampsServiceGetProvidersAction, RampsServiceGetPaymentMethodsAction, } from './RampsService-method-action-types'; -import { RequestStatus } from './RequestCache'; +import { RequestStatus, createCacheKey } from './RequestCache'; describe('RampsController', () => { describe('constructor', () => { @@ -37,6 +37,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -70,6 +71,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -365,6 +367,24 @@ describe('RampsController', () => { ); }); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => ({ providers: mockProviders }), + ); + + expect(controller.state.providers).toStrictEqual([]); + + const result = await controller.getProviders('us', { + doNotUpdateState: true, + }); + + expect(result.providers).toStrictEqual(mockProviders); + expect(controller.state.providers).toStrictEqual([]); + }); + }); }); describe('metadata', () => { @@ -384,6 +404,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -406,6 +427,7 @@ describe('RampsController', () => { "preferredProvider": null, "providers": Array [], "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -426,6 +448,7 @@ describe('RampsController', () => { "countries": Array [], "preferredProvider": null, "providers": Array [], + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -449,6 +472,7 @@ describe('RampsController', () => { "providers": Array [], "requests": Object {}, "selectedPaymentMethod": null, + "selectedToken": null, "tokens": null, "userRegion": null, } @@ -1028,6 +1052,42 @@ describe('RampsController', () => { expect(callCount).toBe(1); }); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + expect(controller.state.countries).toStrictEqual([]); + + const countries = await controller.getCountries({ + doNotUpdateState: true, + }); + + expect(countries).toStrictEqual(mockCountries); + expect(controller.state.countries).toStrictEqual([]); + }); + }); + + it('still updates request cache when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + + await controller.getCountries({ doNotUpdateState: true }); + + const cacheKey = createCacheKey('getCountries', []); + const requestState = controller.getRequestState(cacheKey); + + expect(requestState).toBeDefined(); + expect(requestState?.status).toBe(RequestStatus.SUCCESS); + expect(requestState?.data).toStrictEqual(mockCountries); + }); + }); }); describe('init', () => { @@ -1315,7 +1375,7 @@ describe('RampsController', () => { await controller.setUserRegion('US'); await new Promise((resolve) => setTimeout(resolve, 50)); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/test', }); @@ -2338,6 +2398,24 @@ describe('RampsController', () => { expect(callCount).toBe(2); }); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => mockTokens, + ); + + expect(controller.state.tokens).toBeNull(); + + const tokens = await controller.getTokens('us', 'buy', { + doNotUpdateState: true, + }); + + expect(tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens).toBeNull(); + }); + }); }); describe('getPaymentMethods', () => { @@ -2382,7 +2460,7 @@ describe('RampsController', () => { mockPaymentMethod1, ); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2428,7 +2506,7 @@ describe('RampsController', () => { removedPaymentMethod, ); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2462,7 +2540,7 @@ describe('RampsController', () => { expect(controller.state.selectedPaymentMethod).toBeNull(); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2494,7 +2572,7 @@ describe('RampsController', () => { expect(controller.state.paymentMethods).toStrictEqual([]); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2510,7 +2588,7 @@ describe('RampsController', () => { it('throws error when region is not provided and userRegion is not set', async () => { await withController(async ({ controller }) => { await expect( - controller.getPaymentMethods({ + controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }), @@ -2544,7 +2622,7 @@ describe('RampsController', () => { }, ); - await controller.getPaymentMethods({ + await controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2579,7 +2657,7 @@ describe('RampsController', () => { }, async ({ controller }) => { await expect( - controller.getPaymentMethods({ + controller.getPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }), @@ -2589,6 +2667,38 @@ describe('RampsController', () => { }, ); }); + + it('does not update state when doNotUpdateState is true', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getPaymentMethods', + async () => mockPaymentMethodsResponse, + ); + + expect(controller.state.paymentMethods).toStrictEqual([]); + + const response = await controller.getPaymentMethods(undefined, { + assetId: 'eip155:1/slip44:60', + provider: '/providers/stripe', + doNotUpdateState: true, + }); + + expect(response.payments).toStrictEqual([ + mockPaymentMethod1, + mockPaymentMethod2, + ]); + expect(controller.state.paymentMethods).toStrictEqual([]); + }, + ); + }); }); describe('setSelectedPaymentMethod', () => { @@ -2663,7 +2773,7 @@ describe('RampsController', () => { ); // Should not throw - controller.triggerGetPaymentMethods({ + controller.triggerGetPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); @@ -2682,7 +2792,7 @@ describe('RampsController', () => { await withController(async ({ controller }) => { // Should not throw even when getPaymentMethods would fail (no region) expect(() => { - controller.triggerGetPaymentMethods({ + controller.triggerGetPaymentMethods(undefined, { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 1ea76631fbd..6a436cf137f 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,6 +15,7 @@ import type { RampAction, PaymentMethod, PaymentMethodsResponse, + RampsToken, } from './RampsService'; import type { RampsServiceGetGeolocationAction, @@ -108,6 +109,11 @@ export type RampsControllerState = { * Can be manually set by the user. */ selectedPaymentMethod: PaymentMethod | null; + /** + * The user's selected token. + * When set, automatically fetches and sets payment methods for that token. + */ + selectedToken: RampsToken | null; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. @@ -161,6 +167,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + selectedToken: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, requests: { persist: false, includeInDebugSnapshot: true, @@ -186,6 +198,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { tokens: null, paymentMethods: [], selectedPaymentMethod: null, + selectedToken: null, requests: {}, }; } @@ -398,13 +411,11 @@ export class RampsController extends BaseController< ): Promise { const ttl = options?.ttl ?? this.#requestCacheTTL; - // Check for existing pending request - join it instead of making a duplicate const pending = this.#pendingRequests.get(cacheKey); if (pending) { return pending.promise as Promise; } - // Check cache validity (unless force refresh) if (!options?.forceRefresh) { const cached = this.state.requests[cacheKey]; if (cached && !isCacheExpired(cached, ttl)) { @@ -412,19 +423,15 @@ export class RampsController extends BaseController< } } - // Create abort controller for this request const abortController = new AbortController(); const lastFetchedAt = Date.now(); - // Update state to loading this.#updateRequestState(cacheKey, createLoadingState()); - // Create the fetch promise const promise = (async (): Promise => { try { const data = await fetcher(abortController.signal); - // Don't update state if aborted if (abortController.signal.aborted) { throw new Error('Request was aborted'); } @@ -435,7 +442,6 @@ export class RampsController extends BaseController< ); return data; } catch (error) { - // Don't update state if aborted if (abortController.signal.aborted) { throw error; } @@ -448,7 +454,6 @@ export class RampsController extends BaseController< ); throw error; } finally { - // Only delete if this is still our entry (not replaced by a new request) const currentPending = this.#pendingRequests.get(cacheKey); if (currentPending?.abortController === abortController) { this.#pendingRequests.delete(cacheKey); @@ -456,7 +461,6 @@ export class RampsController extends BaseController< } })(); - // Store pending request for deduplication this.#pendingRequests.set(cacheKey, { promise, abortController }); return promise; @@ -600,11 +604,9 @@ export class RampsController extends BaseController< ); } - // Only cleanup state if region is actually changing const regionChanged = normalizedRegion !== this.state.userRegion?.regionCode; - // Set the new region atomically with cleanup to avoid intermediate null state this.update((state) => { if (regionChanged) { state.preferredProvider = null; @@ -616,7 +618,6 @@ export class RampsController extends BaseController< state.userRegion = userRegion; }); - // Only trigger fetches if region changed or if data is missing if (regionChanged || !this.state.tokens) { this.triggerGetTokens(userRegion.regionCode, 'buy', options); } @@ -634,13 +635,30 @@ export class RampsController extends BaseController< /** * Sets the user's preferred provider. * This allows users to set their preferred ramp provider. + * If a token is already selected, automatically fetches payment methods for that token. * * @param provider - The provider object to set. */ setPreferredProvider(provider: Provider | null): void { + const hadProvider = this.state.preferredProvider !== null; this.update((state) => { state.preferredProvider = provider; }); + + // If token is selected and provider changed, fetch payment methods + if (this.state.selectedToken && provider) { + this.#fetchAndSetPaymentMethods( + provider.id, + this.state.selectedToken, + ).catch(() => { + // Error stored in state + }); + } else if (hadProvider && !provider && this.state.selectedToken) { + this.update((state) => { + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + } } /** @@ -699,9 +717,11 @@ export class RampsController extends BaseController< options, ); - this.update((state) => { - state.countries = countries; - }); + if (!options?.doNotUpdateState) { + this.update((state) => { + state.countries = countries; + }); + } return countries; } @@ -753,13 +773,18 @@ export class RampsController extends BaseController< options, ); - this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + if (!options?.doNotUpdateState) { + this.update((state) => { + const userRegionCode = state.userRegion?.regionCode; - if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.tokens = tokens; - } - }); + if ( + userRegionCode === undefined || + userRegionCode === normalizedRegion + ) { + state.tokens = tokens; + } + }); + } return tokens; } @@ -819,13 +844,18 @@ export class RampsController extends BaseController< options, ); - this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + if (!options?.doNotUpdateState) { + this.update((state) => { + const userRegionCode = state.userRegion?.regionCode; - if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.providers = providers; - } - }); + if ( + userRegionCode === undefined || + userRegionCode === normalizedRegion + ) { + state.providers = providers; + } + }); + } return { providers }; } @@ -834,25 +864,26 @@ export class RampsController extends BaseController< * Fetches the list of payment methods for a given context. * The payment methods are saved in the controller state once fetched. * + * @param region - User's region code. If not provided, uses the user's region from controller state. * @param options - Query parameters for filtering payment methods. - * @param options.region - User's region code. If not provided, uses the user's region from controller state. * @param options.fiat - Fiat currency code (e.g., "usd"). If not provided, uses the user's region currency. * @param options.assetId - CAIP-19 cryptocurrency identifier. * @param options.provider - Provider ID path. * @param options.forceRefresh - Whether to bypass cache. * @param options.ttl - Custom TTL for this request. + * @param options.doNotUpdateState - If true, does not update controller state with results. * @returns The payment methods response containing payments array. */ - async getPaymentMethods(options: { - region?: string; - fiat?: string; - assetId: string; - provider: string; - forceRefresh?: boolean; - ttl?: number; - }): Promise { - const regionToUse = options.region ?? this.state.userRegion?.regionCode; - const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency; + async getPaymentMethods( + region?: string, + options?: ExecuteRequestOptions & { + fiat?: string; + assetId: string; + provider: string; + }, + ): Promise { + const regionToUse = region ?? this.state.userRegion?.regionCode; + const fiatToUse = options?.fiat ?? this.state.userRegion?.country?.currency; if (!regionToUse) { throw new Error( @@ -866,6 +897,14 @@ export class RampsController extends BaseController< ); } + if (!options?.assetId) { + throw new Error('assetId is required.'); + } + + if (!options?.provider) { + throw new Error('provider is required.'); + } + const normalizedRegion = regionToUse.toLowerCase().trim(); const normalizedFiat = fiatToUse.toLowerCase().trim(); const cacheKey = createCacheKey('getPaymentMethods', [ @@ -885,24 +924,105 @@ export class RampsController extends BaseController< provider: options.provider, }); }, - { forceRefresh: options.forceRefresh, ttl: options.ttl }, + options, ); + if (!options?.doNotUpdateState) { + this.update((state) => { + state.paymentMethods = response.payments; + if ( + state.selectedPaymentMethod && + !response.payments.some( + (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, + ) + ) { + state.selectedPaymentMethod = null; + } + }); + } + + return response; + } + + /** + * Sets the user's selected token. + * Automatically fetches and sets payment methods for that token, and auto-selects the first one. + * + * @param token - The token object, or null to clear. + * @param options - Options for cache behavior. + */ + async setSelectedToken( + token: RampsToken | null, + options?: ExecuteRequestOptions, + ): Promise { this.update((state) => { - state.paymentMethods = response.payments; - // Only clear selected payment method if it's no longer in the new list - // This preserves the selection when cached data is returned (same context) - if ( - state.selectedPaymentMethod && - !response.payments.some( - (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, - ) - ) { - state.selectedPaymentMethod = null; - } + state.selectedToken = token; }); - return response; + if (!token) { + this.update((state) => { + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + return; + } + + // Automatically fetch payment methods for the selected token + const provider = this.state.preferredProvider ?? this.state.providers[0]; + + if (provider) { + await this.#fetchAndSetPaymentMethods(provider.id, token, options); + } else { + this.update((state) => { + state.paymentMethods = []; + state.selectedPaymentMethod = null; + }); + } + } + + /** + * Fetches payment methods for the given provider and token, then auto-selects the first one. + * + * @param providerId - The provider ID. + * @param token - The token object (optional, uses selectedToken if not provided). + * @param options - Options for cache behavior. + */ + async #fetchAndSetPaymentMethods( + providerId: string, + token?: RampsToken, + options?: ExecuteRequestOptions, + ): Promise { + const tokenToUse = token ?? this.state.selectedToken; + if (!tokenToUse) { + return; + } + + const { assetId } = tokenToUse; + + const regionCode = this.state.userRegion?.regionCode; + const fiatCurrency = this.state.userRegion?.country?.currency; + + if (!regionCode || !fiatCurrency) { + return; + } + + try { + const response = await this.getPaymentMethods(regionCode, { + assetId, + provider: providerId, + fiat: fiatCurrency, + ...options, + }); + + // Auto-select the first payment method + if (response.payments.length > 0) { + this.update((state) => { + state.selectedPaymentMethod = response.payments[0]; + }); + } + } catch { + // Error stored in state + } } /** @@ -985,23 +1105,24 @@ export class RampsController extends BaseController< /** * Triggers fetching payment methods without throwing. * + * @param region - User's region code. If not provided, uses userRegion from state. * @param options - Query parameters for filtering payment methods. - * @param options.region - User's region code. If not provided, uses userRegion from state. * @param options.fiat - Fiat currency code. If not provided, uses userRegion currency. * @param options.assetId - CAIP-19 cryptocurrency identifier. * @param options.provider - Provider ID path. * @param options.forceRefresh - Whether to bypass cache. * @param options.ttl - Custom TTL for this request. + * @param options.doNotUpdateState - If true, does not update controller state with results. */ - triggerGetPaymentMethods(options: { - region?: string; - fiat?: string; - assetId: string; - provider: string; - forceRefresh?: boolean; - ttl?: number; - }): void { - this.getPaymentMethods(options).catch(() => { + triggerGetPaymentMethods( + region?: string, + options?: ExecuteRequestOptions & { + fiat?: string; + assetId: string; + provider: string; + }, + ): void { + this.getPaymentMethods(region, options).catch(() => { // Error stored in state }); } diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index eab09c9fe33..3ce9a306993 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -94,6 +94,55 @@ describe('RampsService', () => { expect(geolocationResponse).toBe('us-tx'); }); + it('uses baseUrlOverride when provided', async () => { + nock('http://localhost:3000') + .get('/geolocation') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, 'local-test'); + const { rootMessenger } = getService({ + options: { baseUrlOverride: 'http://localhost:3000' }, + }); + + const geolocationPromise = rootMessenger.call( + 'RampsService:getGeolocation', + ); + await clock.runAllAsync(); + await flushPromises(); + const geolocationResponse = await geolocationPromise; + + expect(geolocationResponse).toBe('local-test'); + }); + + it('baseUrlOverride takes precedence over environment', async () => { + nock('http://localhost:4000') + .get('/geolocation') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, 'override-test'); + const { rootMessenger } = getService({ + options: { + environment: RampsEnvironment.Production, + baseUrlOverride: 'http://localhost:4000', + }, + }); + + const geolocationPromise = rootMessenger.call( + 'RampsService:getGeolocation', + ); + await clock.runAllAsync(); + await flushPromises(); + const geolocationResponse = await geolocationPromise; + + expect(geolocationResponse).toBe('override-test'); + }); + it('throws if the API returns an empty response', async () => { nock('https://on-ramp.uat-api.cx.metamask.io') .get('/geolocation') diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 5f9104b72a7..498ad60a481 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -326,12 +326,18 @@ export type RampsServiceMessenger = Messenger< * * @param environment - The environment to use. * @param service - The API service type (determines if cache URL is used). + * @param baseUrlOverride - Optional base URL override for local development. * @returns The base URL for API requests. */ function getBaseUrl( environment: RampsEnvironment, service: RampsApiService, + baseUrlOverride?: string, ): string { + if (baseUrlOverride) { + return baseUrlOverride; + } + const cache = service === RampsApiService.Regions ? '-cache' : ''; switch (environment) { @@ -356,6 +362,69 @@ function getApiPath(path: string, version: string = 'v2'): string { return `${version}/${path}`; } +const PAYMENT_METHOD_POOL: PaymentMethod[] = [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + disclaimer: "Credit card purchases may incur your bank's cash advance fees.", + delay: '5 to 10 minutes.', + pendingOrderDescription: 'Card purchases may take a few minutes to complete.', + }, + { + id: '/payments/bank-transfer', + paymentType: 'bank-transfer', + name: 'Bank Transfer', + score: 80, + icon: 'bank', + disclaimer: 'Bank transfers may take 1-3 business days.', + delay: '1-3 business days', + pendingOrderDescription: 'Bank transfers may take 1-3 business days to complete.', + }, + { + id: '/payments/apple-pay', + paymentType: 'apple-pay', + name: 'Apple Pay', + score: 95, + icon: 'apple', + delay: '5 to 10 minutes.', + pendingOrderDescription: 'Apple Pay purchases may take a few minutes to complete.', + }, + { + id: '/payments/google-pay', + paymentType: 'google-pay', + name: 'Google Pay', + score: 92, + icon: 'google', + delay: '5 to 10 minutes.', + pendingOrderDescription: 'Google Pay purchases may take a few minutes to complete.', + }, + { + id: '/payments/paypal', + paymentType: 'paypal', + name: 'PayPal', + score: 85, + icon: 'paypal', + disclaimer: 'PayPal transactions may have additional fees.', + delay: '10 to 15 minutes.', + pendingOrderDescription: 'PayPal purchases may take a few minutes to complete.', + }, +]; + +/** + * Generates a mock payment methods response with a random subset of payment methods. + * Returns 3 random payment methods from a pool of 5. + * + * @returns A PaymentMethodsResponse with 3 randomly selected payment methods. + */ +export function generatePaymentMethodMockResponse(): PaymentMethodsResponse { + const shuffled = [...PAYMENT_METHOD_POOL].sort(() => Math.random() - 0.5); + const selected = shuffled.slice(0, 3); + return { payments: selected }; +} + /** * This service object is responsible for interacting with the Ramps API. * @@ -433,6 +502,12 @@ export class RampsService { */ readonly #context: string; + /** + * Optional base URL override for local development. + * When set, this URL will be used instead of the environment-based URL. + */ + readonly #baseUrlOverride?: string; + /** * Constructs a new RampsService object. * @@ -446,6 +521,9 @@ export class RampsService { * `node-fetch`). * @param args.policyOptions - Options to pass to `createServicePolicy`, which * is used to wrap each request. See {@link CreateServicePolicyOptions}. + * @param args.baseUrlOverride - Optional base URL override for local development. + * When provided, this URL will be used for all API requests instead of the + * environment-based URLs. Useful for pointing to a local API server. */ constructor({ messenger, @@ -453,12 +531,14 @@ export class RampsService { context, fetch: fetchFunction, policyOptions = {}, + baseUrlOverride, }: { messenger: RampsServiceMessenger; environment?: RampsEnvironment; context: string; fetch: typeof fetch; policyOptions?: CreateServicePolicyOptions; + baseUrlOverride?: string; }) { this.name = serviceName; this.#messenger = messenger; @@ -466,6 +546,7 @@ export class RampsService { this.#policy = createServicePolicy(policyOptions); this.#environment = environment; this.#context = context; + this.#baseUrlOverride = baseUrlOverride; this.#messenger.registerMethodActionHandlers( this, @@ -561,7 +642,7 @@ export class RampsService { }, ): Promise { return this.#policy.execute(async () => { - const baseUrl = getBaseUrl(this.#environment, service); + const baseUrl = getBaseUrl(this.#environment, service, this.#baseUrlOverride); const url = new URL(path, baseUrl); this.#addCommonParams(url, options.action); @@ -610,7 +691,7 @@ export class RampsService { async getCountries(): Promise { const countries = await this.#request( RampsApiService.Regions, - getApiPath('regions/countries'), + getApiPath('regions-v2/countries'), { responseType: 'json' }, ); @@ -619,8 +700,8 @@ export class RampsService { } return countries.filter((country) => { - const isCountrySupported = - country.supported.buy || country.supported.sell; + const isCountrySupported = true + // country.supported.buy || country.supported.sell; if (country.states && country.states.length > 0) { const hasSupportedState = country.states.some( @@ -654,7 +735,7 @@ export class RampsService { const normalizedRegion = region.toLowerCase().trim(); const url = new URL( getApiPath(`regions/${normalizedRegion}/topTokens`), - getBaseUrl(this.#environment, RampsApiService.Regions), + getBaseUrl(this.#environment, RampsApiService.Regions, this.#baseUrlOverride), ); this.#addCommonParams(url, action); @@ -711,13 +792,21 @@ export class RampsService { payments?: string | string[]; }, ): Promise<{ providers: Provider[] }> { + console.log('[RampsService] getProviders called with:', { + regionCode, + options, + }); const normalizedRegion = regionCode.toLowerCase().trim(); const url = new URL( getApiPath(`regions/${normalizedRegion}/providers`), - getBaseUrl(this.#environment, RampsApiService.Regions), + getBaseUrl(this.#environment, RampsApiService.Regions, this.#baseUrlOverride), ); + console.log('[RampsService] getProviders - url:', { + url: url.toString(), + }); this.#addCommonParams(url); + if (options?.provider) { const providerIds = Array.isArray(options.provider) ? options.provider @@ -746,15 +835,81 @@ export class RampsService { paymentIds.forEach((id) => url.searchParams.append('payments', id)); } - const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); - if (!fetchResponse.ok) { - throw new HttpError( - fetchResponse.status, - `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, - ); - } - return fetchResponse.json() as Promise<{ providers: Provider[] }>; + // const response = await this.#policy.execute(async () => { + // const fetchResponse = await this.#fetch(url); + // if (!fetchResponse.ok) { + // throw new HttpError( + // fetchResponse.status, + // `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + // ); + // } + // return fetchResponse.json() as Promise<{ providers: Provider[] }>; + // }); + + const response: { providers: Provider[] } = { + providers: [ + { + id: '/providers/transak', + name: 'Transak', + environmentType: 'PRODUCTION', + description: + 'Per Transak: "The fastest and securest way to buy 100+ cryptocurrencies on 75+ blockchains. Pay via Apple Pay, UPI, bank transfer or use your debit or credit card."', + hqAddress: '35 Shearing Street, Bury St. Edmunds, IP32 6FE, United Kingdom', + links: [ + { name: 'Homepage', url: 'https://www.transak.com/' }, + { name: 'Privacy Policy', url: 'https://transak.com/privacy-policy' }, + { name: 'Support', url: 'https://support.transak.com/hc/en-us' }, + ], + logos: { + light: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/transak_light.png', + dark: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/transak_dark.png', + height: 24, + width: 90, + }, + }, + { + id: '/providers/moonpay', + name: 'Moonpay', + environmentType: 'PRODUCTION', + description: + 'Per MoonPay: "MoonPay provides a smooth experience for converting between fiat currencies and cryptocurrencies."', + hqAddress: '8 The Green, Dover, DE, 19901, USA', + links: [ + { name: 'Homepage', url: 'https://www.moonpay.com/' }, + { name: 'Privacy Policy', url: 'https://www.moonpay.com/legal/privacy_policy' }, + { name: 'Support', url: 'https://support.moonpay.com/hc/en-gb/categories/360001595097-Customer-Support-Help-Center' }, + ], + logos: { + light: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/moonpay_light.png', + dark: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/moonpay_dark.png', + height: 24, + width: 88, + }, + }, + { + id: '/providers/stripe', + name: 'Stripe', + environmentType: 'PRODUCTION', + description: + 'Per Stripe: "The Stripe crypto onramp gives web3 developers an easy and fast way for their consumers to purchase crypto with a credit card, debit card, or ACH."', + hqAddress: '354 Oyster Point Blvd, South San Francisco, CA 94080, USA', + links: [ + { name: 'Homepage', url: 'https://crypto.link.com' }, + { name: 'Support', url: 'https://support.link.com/topics/crypto' }, + { name: 'Privacy Policy', url: 'https://link.com/privacy' }, + ], + logos: { + light: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/stripe_light.png', + dark: 'https://on-ramp-content.api.cx.metamask.io/assets/providers/stripe_dark.png', + height: 24, + width: 100, + }, + }, + ], + }; + + console.log('[RampsService] getProviders - response:', { + response, }); if (!response || typeof response !== 'object') { @@ -786,7 +941,7 @@ export class RampsService { }): Promise { const url = new URL( getApiPath('paymentMethods'), - getBaseUrl(this.#environment, RampsApiService.Regions), + getBaseUrl(this.#environment, RampsApiService.Regions, this.#baseUrlOverride), ); this.#addCommonParams(url); @@ -795,16 +950,18 @@ export class RampsService { url.searchParams.set('assetId', options.assetId); url.searchParams.set('provider', options.provider); - const response = await this.#policy.execute(async () => { - const fetchResponse = await this.#fetch(url); - if (!fetchResponse.ok) { - throw new HttpError( - fetchResponse.status, - `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, - ); - } - return fetchResponse.json() as Promise; - }); + // const response = await this.#policy.execute(async () => { + // const fetchResponse = await this.#fetch(url); + // if (!fetchResponse.ok) { + // throw new HttpError( + // fetchResponse.status, + // `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + // ); + // } + // return fetchResponse.json() as Promise; + // }); + + const response = generatePaymentMethodMockResponse(); if (!response || typeof response !== 'object') { throw new Error('Malformed response received from paymentMethods API'); diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 7abcea71727..7c2030d7303 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -135,6 +135,8 @@ export type ExecuteRequestOptions = { forceRefresh?: boolean; /** Custom TTL for this request in milliseconds */ ttl?: number; + /** If true, skip updating controller state (but still update request cache for deduplication) */ + doNotUpdateState?: boolean; }; /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 7649f417d90..0f10731f7cc 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -32,6 +32,7 @@ export { RampsEnvironment, RampsApiService, RAMPS_SDK_VERSION, + generatePaymentMethodMockResponse, } from './RampsService'; export type { RampsServiceGetGeolocationAction,