From 24add32acb13a3f776b0b6a14124c8cb0341c51d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 13:01:21 -0700 Subject: [PATCH 01/20] feat: adds loading state to ramps controller --- .../ramps-controller/src/RampsController.ts | 231 ++++++++++++++++-- packages/ramps-controller/src/RequestCache.ts | 12 + packages/ramps-controller/src/index.ts | 1 + 3 files changed, 230 insertions(+), 14 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c9953fbdfaf..903e3827a83 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -29,6 +29,7 @@ import type { RequestState, ExecuteRequestOptions, PendingRequest, + ResourceType, } from './RequestCache'; import { DEFAULT_REQUEST_CACHE_TTL, @@ -119,6 +120,46 @@ export type RampsControllerState = { * This stores loading, success, and error states for API requests. */ requests: RequestCacheType; + /** + * Whether user region is currently being fetched. + */ + userRegionLoading: boolean; + /** + * Error message if the user region fetch failed, or null. + */ + userRegionError: string | null; + /** + * Whether countries are currently being fetched. + */ + countriesLoading: boolean; + /** + * Error message if the countries fetch failed, or null. + */ + countriesError: string | null; + /** + * Whether providers are currently being fetched. + */ + providersLoading: boolean; + /** + * Error message if the providers fetch failed, or null. + */ + providersError: string | null; + /** + * Whether tokens are currently being fetched. + */ + tokensLoading: boolean; + /** + * Error message if the tokens fetch failed, or null. + */ + tokensError: string | null; + /** + * Whether payment methods are currently being fetched. + */ + paymentMethodsLoading: boolean; + /** + * Error message if the payment methods fetch failed, or null. + */ + paymentMethodsError: string | null; }; /** @@ -179,6 +220,66 @@ const rampsControllerMetadata = { includeInStateLogs: false, usedInUi: true, }, + userRegionLoading: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + userRegionError: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + countriesLoading: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + countriesError: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + providersLoading: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + providersError: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + tokensLoading: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + tokensError: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + paymentMethodsLoading: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, + paymentMethodsError: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, } satisfies StateMetadata; /** @@ -200,6 +301,16 @@ export function getDefaultRampsControllerState(): RampsControllerState { paymentMethods: [], selectedPaymentMethod: null, requests: {}, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, }; } @@ -427,10 +538,16 @@ export class RampsController extends BaseController< // Create abort controller for this request const abortController = new AbortController(); const lastFetchedAt = Date.now(); + const { resourceType } = options ?? {}; // Update state to loading this.#updateRequestState(cacheKey, createLoadingState()); + // Set resource-level loading state (only on cache miss) + if (resourceType) { + this.#setResourceLoading(resourceType, true); + } + // Create the fetch promise const promise = (async (): Promise => { try { @@ -445,6 +562,12 @@ export class RampsController extends BaseController< cacheKey, createSuccessState(data as Json, lastFetchedAt), ); + + // Clear error on success + if (resourceType) { + this.#setResourceError(resourceType, null); + } + return data; } catch (error) { // Don't update state if aborted @@ -452,12 +575,18 @@ export class RampsController extends BaseController< throw error; } - const errorMessage = (error as Error)?.message; + const errorMessage = (error as Error)?.message ?? 'Unknown error'; this.#updateRequestState( cacheKey, - createErrorState(errorMessage ?? 'Unknown error', lastFetchedAt), + createErrorState(errorMessage, lastFetchedAt), ); + + // Set resource-level error + if (resourceType) { + this.#setResourceError(resourceType, errorMessage); + } + throw error; } finally { // Only delete if this is still our entry (not replaced by a new request) @@ -465,6 +594,11 @@ export class RampsController extends BaseController< if (currentPending?.abortController === abortController) { this.#pendingRequests.delete(cacheKey); } + + // Clear resource-level loading state + if (resourceType) { + this.#setResourceLoading(resourceType, false); + } } })(); @@ -518,6 +652,62 @@ export class RampsController extends BaseController< }); } + /** + * Sets the loading state for a resource type. + * + * @param resourceType - The type of resource. + * @param loading - Whether the resource is loading. + */ + #setResourceLoading(resourceType: ResourceType, loading: boolean): void { + this.update((state) => { + switch (resourceType) { + case 'userRegion': + state.userRegionLoading = loading; + break; + case 'countries': + state.countriesLoading = loading; + break; + case 'providers': + state.providersLoading = loading; + break; + case 'tokens': + state.tokensLoading = loading; + break; + case 'paymentMethods': + state.paymentMethodsLoading = loading; + break; + } + }); + } + + /** + * Sets the error state for a resource type. + * + * @param resourceType - The type of resource. + * @param error - The error message, or null to clear. + */ + #setResourceError(resourceType: ResourceType, error: string | null): void { + this.update((state) => { + switch (resourceType) { + case 'userRegion': + state.userRegionError = error; + break; + case 'countries': + state.countriesError = error; + break; + case 'providers': + state.providersError = error; + break; + case 'tokens': + state.tokensError = error; + break; + case 'paymentMethods': + state.paymentMethodsError = error; + break; + } + }); + } + /** * Gets the state of a specific cached request. * @@ -709,18 +899,31 @@ export class RampsController extends BaseController< * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - await this.getCountries(options); + this.#setResourceLoading('userRegion', true); - let regionCode = this.state.userRegion?.regionCode; - regionCode ??= await this.messenger.call('RampsService:getGeolocation'); + try { + await this.getCountries(options); - if (!regionCode) { - throw new Error( - 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + let regionCode = this.state.userRegion?.regionCode; + regionCode ??= await this.messenger.call('RampsService:getGeolocation'); + + if (!regionCode) { + throw new Error( + 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + ); + } + + await this.setUserRegion(regionCode, options); + this.#setResourceError('userRegion', null); + } catch (error) { + this.#setResourceError( + 'userRegion', + (error as Error)?.message ?? 'Unknown error', ); + throw error; + } finally { + this.#setResourceLoading('userRegion', false); } - - await this.setUserRegion(regionCode, options); } hydrateState(options?: ExecuteRequestOptions): void { @@ -751,7 +954,7 @@ export class RampsController extends BaseController< async () => { return this.messenger.call('RampsService:getCountries'); }, - options, + { ...options, resourceType: 'countries' }, ); this.update((state) => { @@ -805,7 +1008,7 @@ export class RampsController extends BaseController< }, ); }, - options, + { ...options, resourceType: 'tokens' }, ); this.update((state) => { @@ -924,7 +1127,7 @@ export class RampsController extends BaseController< }, ); }, - options, + { ...options, resourceType: 'providers' }, ); this.update((state) => { @@ -996,7 +1199,7 @@ export class RampsController extends BaseController< provider: providerToUse, }); }, - options, + { ...options, resourceType: 'paymentMethods' }, ); this.update((state) => { diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 7abcea71727..0e2cf9a0208 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -1,5 +1,15 @@ import type { Json } from '@metamask/utils'; +/** + * Types of resources that can have loading/error states. + */ +export type ResourceType = + | 'userRegion' + | 'countries' + | 'providers' + | 'tokens' + | 'paymentMethods'; + /** * Status of a cached request. */ @@ -135,6 +145,8 @@ export type ExecuteRequestOptions = { forceRefresh?: boolean; /** Custom TTL for this request in milliseconds */ ttl?: number; + /** Resource type to update loading/error states for */ + resourceType?: ResourceType; }; /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 7649f417d90..2c741e3f3bc 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -43,6 +43,7 @@ export type { RequestState, ExecuteRequestOptions, PendingRequest, + ResourceType, } from './RequestCache'; export { RequestStatus, From dbdbdfcfe9be27ddfca219f4dc0cd9ef534c5f2a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 13:15:42 -0700 Subject: [PATCH 02/20] feat: adds loading and error state to ramps controller --- .../src/RampsController.test.ts | 278 ++++-------------- .../ramps-controller/src/RampsController.ts | 121 ++------ 2 files changed, 76 insertions(+), 323 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index bf304f041ab..d390a575f41 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -33,14 +33,24 @@ describe('RampsController', () => { expect(controller.state).toMatchInlineSnapshot(` Object { "countries": Array [], + "countriesError": null, + "countriesLoading": false, "paymentMethods": Array [], + "paymentMethodsError": null, + "paymentMethodsLoading": false, "providers": Array [], + "providersError": null, + "providersLoading": false, "requests": Object {}, "selectedPaymentMethod": null, "selectedProvider": null, "selectedToken": null, "tokens": null, + "tokensError": null, + "tokensLoading": false, "userRegion": null, + "userRegionError": null, + "userRegionLoading": false, } `); }); @@ -67,14 +77,24 @@ describe('RampsController', () => { expect(controller.state).toMatchInlineSnapshot(` Object { "countries": Array [], + "countriesError": null, + "countriesLoading": false, "paymentMethods": Array [], + "paymentMethodsError": null, + "paymentMethodsLoading": false, "providers": Array [], + "providersError": null, + "providersLoading": false, "requests": Object {}, "selectedPaymentMethod": null, "selectedProvider": null, "selectedToken": null, "tokens": null, + "tokensError": null, + "tokensLoading": false, "userRegion": null, + "userRegionError": null, + "userRegionLoading": false, } `); }); @@ -382,14 +402,24 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "countries": Array [], + "countriesError": null, + "countriesLoading": false, "paymentMethods": Array [], + "paymentMethodsError": null, + "paymentMethodsLoading": false, "providers": Array [], + "providersError": null, + "providersLoading": false, "requests": Object {}, "selectedPaymentMethod": null, "selectedProvider": null, "selectedToken": null, "tokens": null, + "tokensError": null, + "tokensLoading": false, "userRegion": null, + "userRegionError": null, + "userRegionLoading": false, } `); }); @@ -448,14 +478,24 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` Object { "countries": Array [], + "countriesError": null, + "countriesLoading": false, "paymentMethods": Array [], + "paymentMethodsError": null, + "paymentMethodsLoading": false, "providers": Array [], + "providersError": null, + "providersLoading": false, "requests": Object {}, "selectedPaymentMethod": null, "selectedProvider": null, "selectedToken": null, "tokens": null, + "tokensError": null, + "tokensLoading": false, "userRegion": null, + "userRegionError": null, + "userRegionLoading": false, } `); }); @@ -817,146 +857,6 @@ describe('RampsController', () => { }); }); - describe('sync trigger methods', () => { - describe('triggerSetUserRegion', () => { - it('triggers set user region and returns void', async () => { - await withController( - { - options: { - state: { - countries: createMockCountries(), - }, - }, - }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getTokens', - async () => ({ topTokens: [], allTokens: [] }), - ); - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async () => ({ providers: [] }), - ); - - const result = controller.triggerSetUserRegion('us-ca'); - expect(result).toBeUndefined(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - }, - ); - }); - - it('does not throw when set fails', async () => { - await withController(async ({ controller }) => { - expect(() => controller.triggerSetUserRegion('us-ca')).not.toThrow(); - }); - }); - }); - - describe('triggerGetCountries', () => { - it('triggers get countries and returns void', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => createMockCountries(), - ); - - const result = controller.triggerGetCountries(); - expect(result).toBeUndefined(); - }); - }); - - it('does not throw when fetch fails', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getCountries', - async () => { - throw new Error('countries failed'); - }, - ); - - expect(() => controller.triggerGetCountries()).not.toThrow(); - }); - }); - }); - - describe('triggerGetTokens', () => { - it('triggers get tokens and returns void', async () => { - await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getTokens', - async () => ({ topTokens: [], allTokens: [] }), - ); - - const result = controller.triggerGetTokens(); - expect(result).toBeUndefined(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(controller.state.tokens).toStrictEqual({ - topTokens: [], - allTokens: [], - }); - }, - ); - }); - - it('does not throw when fetch fails', async () => { - await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getTokens', - async () => { - throw new Error('tokens failed'); - }, - ); - - expect(() => controller.triggerGetTokens()).not.toThrow(); - }, - ); - }); - }); - - describe('triggerGetProviders', () => { - it('triggers get providers and returns void', async () => { - await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async () => ({ providers: [] }), - ); - - const result = controller.triggerGetProviders(); - expect(result).toBeUndefined(); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(controller.state.providers).toStrictEqual([]); - }, - ); - }); - - it('does not throw when fetch fails', async () => { - await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getProviders', - async () => { - throw new Error('providers failed'); - }, - ); - - expect(() => controller.triggerGetProviders()).not.toThrow(); - }, - ); - }); - }); - }); - describe('getCountries', () => { const mockCountries: Country[] = [ { @@ -1188,6 +1088,22 @@ describe('RampsController', () => { ); }); }); + + it('sets userRegionError to Unknown error when error has no message', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => { + throw { code: 'ERR_NO_MESSAGE' }; + }, + ); + + await expect(controller.init()).rejects.toMatchObject({ + code: 'ERR_NO_MESSAGE', + }); + expect(controller.state.userRegionError).toBe('Unknown error'); + }); + }); }); describe('hydrateState', () => { @@ -3501,88 +3417,6 @@ describe('RampsController', () => { }); }); - describe('triggerGetPaymentMethods', () => { - const mockPaymentMethod: PaymentMethod = { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }; - - const mockPaymentMethodsResponse: PaymentMethodsResponse = { - payments: [mockPaymentMethod], - }; - - const mockSelectedToken: RampsToken = { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }; - - const mockSelectedProvider: Provider = { - id: '/providers/stripe', - name: 'Stripe', - environmentType: 'PRODUCTION', - description: 'Stripe payment provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/stripe_light.png', - dark: '/assets/stripe_dark.png', - height: 24, - width: 77, - }, - }; - - it('calls getPaymentMethods without throwing', async () => { - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, - }, - }, - }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getPaymentMethods', - async () => mockPaymentMethodsResponse, - ); - - controller.triggerGetPaymentMethods('us-ca', { - assetId: 'eip155:1/slip44:60', - provider: '/providers/stripe', - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(controller.state.paymentMethods).toStrictEqual([ - mockPaymentMethod, - ]); - }, - ); - }); - - it('does not throw when getPaymentMethods fails', async () => { - await withController(async ({ controller }) => { - expect(() => { - controller.triggerGetPaymentMethods('us-ca', { - assetId: 'eip155:1/slip44:60', - provider: '/providers/stripe', - }); - }).not.toThrow(); - - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - }); - }); }); /** diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 903e3827a83..38bf9775f46 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -652,6 +652,16 @@ export class RampsController extends BaseController< }); } + /** + * Executes a promise without awaiting, swallowing errors. + * Errors are stored in state via executeRequest. + * + * @param promise - The promise to execute. + */ + #fireAndForget(promise: Promise): void { + promise.catch((_error: unknown) => undefined); + } + /** * Sets the loading state for a resource type. * @@ -820,12 +830,11 @@ 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); + this.#fireAndForget(this.getTokens(userRegion.regionCode, 'buy', options)); } if (regionChanged || this.state.providers.length === 0) { - this.triggerGetProviders(userRegion.regionCode, options); + this.#fireAndForget(this.getProviders(userRegion.regionCode, options)); } return userRegion; @@ -880,12 +889,9 @@ export class RampsController extends BaseController< state.selectedPaymentMethod = null; }); - // fetch payment methods for the new provider - // this is needed because you can change providers without changing the token - // (getPaymentMethods will use state as its default) - this.triggerGetPaymentMethods(regionCode, { - provider: provider.id, - }); + this.#fireAndForget( + this.getPaymentMethods(regionCode, { provider: provider.id }), + ); } /** @@ -934,8 +940,8 @@ export class RampsController extends BaseController< ); } - this.triggerGetTokens(regionCode, 'buy', options); - this.triggerGetProviders(regionCode, options); + this.#fireAndForget(this.getTokens(regionCode, 'buy', options)); + this.#fireAndForget(this.getProviders(regionCode, options)); } /** @@ -1070,9 +1076,9 @@ export class RampsController extends BaseController< state.selectedPaymentMethod = null; }); - this.triggerGetPaymentMethods(regionCode, { - assetId: token.assetId, - }); + this.#fireAndForget( + this.getPaymentMethods(regionCode, { assetId: token.assetId }), + ); } /** @@ -1264,91 +1270,4 @@ export class RampsController extends BaseController< }); } - // ============================================================ - // Sync Trigger Methods - // These fire-and-forget methods are for use in React effects. - // Errors are stored in state and available via selectors. - // ============================================================ - - /** - * Triggers setting the user region without throwing. - * - * @param region - The region code to set (e.g., "US-CA"). - * @param options - Options for cache behavior. - */ - triggerSetUserRegion(region: string, options?: ExecuteRequestOptions): void { - this.setUserRegion(region, options).catch(() => { - // Error stored in state - }); - } - - /** - * Triggers fetching countries without throwing. - * - * @param options - Options for cache behavior. - */ - triggerGetCountries(options?: ExecuteRequestOptions): void { - this.getCountries(options).catch(() => { - // Error stored in state - }); - } - - /** - * Triggers fetching tokens without throwing. - * - * @param region - The region code. If not provided, uses userRegion from state. - * @param action - The ramp action type ('buy' or 'sell'). - * @param options - Options for cache behavior. - */ - triggerGetTokens( - region?: string, - action: 'buy' | 'sell' = 'buy', - options?: ExecuteRequestOptions, - ): void { - this.getTokens(region, action, options).catch(() => { - // Error stored in state - }); - } - - /** - * Triggers fetching providers without throwing. - * - * @param region - The region code. If not provided, uses userRegion from state. - * @param options - Options for cache behavior and query filters. - */ - triggerGetProviders( - region?: string, - options?: ExecuteRequestOptions & { - provider?: string | string[]; - crypto?: string | string[]; - fiat?: string | string[]; - payments?: string | string[]; - }, - ): void { - this.getProviders(region, options).catch(() => { - // Error stored in state - }); - } - - /** - * Triggers fetching payment methods without throwing. - * - * @param region - User's region code (e.g., "us", "fr", "us-ny"). - * @param options - Query parameters for filtering payment methods. - * @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. - */ - triggerGetPaymentMethods( - region?: string, - options?: ExecuteRequestOptions & { - fiat?: string; - assetId?: string; - provider?: string; - }, - ): void { - this.getPaymentMethods(region, options).catch(() => { - // Error stored in state - }); - } } From f9e4f089dea8ca9d0e7af814bd25826af160341b Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 13:53:08 -0700 Subject: [PATCH 03/20] chore: changelog update --- packages/ramps-controller/CHANGELOG.md | 1 + .../ramps-controller/src/selectors.test.ts | 180 ++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 72b29347f15..ba07f053a7a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add quotes functionality to RampsController ([#7747](https://github.com/MetaMask/core/pull/7747)) +- Add `quotesLoading` and `quotesError` state properties for quotes resource loading/error tracking ([#7779](https://github.com/MetaMask/core/pull/7779)) ## [5.0.0] diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 30d21ada5eb..eaf033e59a3 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -33,6 +33,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -69,6 +81,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -108,6 +132,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -143,6 +179,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: {}, }, }; @@ -201,6 +249,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -232,6 +292,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest1, }, @@ -252,6 +324,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest2, }, @@ -284,6 +368,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -319,6 +415,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getData:[]': successRequest, }, @@ -353,6 +461,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, @@ -375,6 +495,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -405,6 +537,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': successRequest, }, @@ -426,6 +570,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, @@ -462,6 +618,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -502,6 +670,18 @@ describe('createRequestSelector', () => { paymentMethods: [], selectedPaymentMethod: null, quotes: null, + userRegionLoading: false, + userRegionError: null, + countriesLoading: false, + countriesError: null, + providersLoading: false, + providersError: null, + tokensLoading: false, + tokensError: null, + paymentMethodsLoading: false, + paymentMethodsError: null, + quotesLoading: false, + quotesError: null, requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], From 1dfb0447d0a420982bf05007f89250f8d148702b Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 14:01:52 -0700 Subject: [PATCH 04/20] chore: lint and 100 test coverage --- .../ramps-controller/src/RampsController.test.ts | 7 ++++++- packages/ramps-controller/src/RampsController.ts | 12 ++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4df702512dc..5d78d6f5a0a 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1106,10 +1106,15 @@ describe('RampsController', () => { it('sets userRegionError to Unknown error when error has no message', async () => { await withController(async ({ controller, rootMessenger }) => { + const errorWithoutMessage = Object.assign(new Error(), { + code: 'ERR_NO_MESSAGE', + message: undefined, + }) as Error & { code: string }; + rootMessenger.registerActionHandler( 'RampsService:getCountries', async () => { - throw { code: 'ERR_NO_MESSAGE' }; + throw errorWithoutMessage; }, ); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 829592484a7..b38c64fc7fa 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -704,7 +704,7 @@ export class RampsController extends BaseController< * * @param promise - The promise to execute. */ - #fireAndForget(promise: Promise): void { + #fireAndForget(promise: Promise): void { promise.catch((_error: unknown) => undefined); } @@ -735,6 +735,9 @@ export class RampsController extends BaseController< case 'quotes': state.quotesLoading = loading; break; + /* istanbul ignore next: exhaustive switch */ + default: + break; } }); } @@ -766,6 +769,9 @@ export class RampsController extends BaseController< case 'quotes': state.quotesError = error; break; + /* istanbul ignore next: exhaustive switch */ + default: + break; } }); } @@ -884,7 +890,9 @@ export class RampsController extends BaseController< }); if (regionChanged || !this.state.tokens) { - this.#fireAndForget(this.getTokens(userRegion.regionCode, 'buy', options)); + this.#fireAndForget( + this.getTokens(userRegion.regionCode, 'buy', options), + ); } if (regionChanged || this.state.providers.length === 0) { this.#fireAndForget(this.getProviders(userRegion.regionCode, options)); From b00fa9bd0d8686d62babe9eaf48d9ac2d699abb2 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 15:01:37 -0700 Subject: [PATCH 05/20] feat: update ramp controller state to use nested resource objects --- .../ramps-controller/src/RampsController.ts | 415 +++++++----------- packages/ramps-controller/src/index.ts | 3 + 2 files changed, 154 insertions(+), 264 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b38c64fc7fa..27b5b623564 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -82,107 +82,69 @@ export type UserRegion = { }; /** - * Describes the shape of the state object for {@link RampsController}. + * Generic type for resource state that bundles data with loading/error states. + * @template TData - The type of the resource data + * @template TSelected - The type of the selected item (defaults to null for resources without selection) */ -export type RampsControllerState = { +export type ResourceState = { /** - * The user's selected region with full country and state objects. - * Initially set via geolocation fetch, but can be manually changed by the user. - * Once set (either via geolocation or manual selection), it will not be overwritten - * by subsequent geolocation fetches. + * The resource data. */ - userRegion: UserRegion | null; + data: TData; /** - * The user's selected provider. - * Can be manually set by the user. + * The currently selected item, or null if none selected. */ - selectedProvider: Provider | null; + selected: TSelected; /** - * List of countries available for ramp actions. + * Whether the resource is currently being fetched. */ - countries: Country[]; + isLoading: boolean; /** - * List of providers available for the current region. + * Error message if the fetch failed, or null. */ - providers: Provider[]; + error: string | null; +}; + +/** + * Describes the shape of the state object for {@link RampsController}. + */ +export type RampsControllerState = { + /** + * The user's region state with data, loading, and error. + * Data contains the full country and state objects. + * Initially set via geolocation fetch, but can be manually changed by the user. + */ + userRegion: ResourceState; /** - * Tokens fetched for the current region and action. - * Contains topTokens and allTokens arrays. + * Countries resource state with data, loading, and error. + * Data contains the list of countries available for ramp actions. */ - tokens: TokensResponse | null; + countries: ResourceState; /** - * The user's selected token. - * When set, automatically fetches and sets payment methods for that token. + * Providers resource state with data, selected, loading, and error. + * Data contains the list of providers available for the current region. */ - selectedToken: RampsToken | null; + providers: ResourceState; /** - * Payment methods available for the current context. - * Filtered by region, fiat, asset, and provider. + * Tokens resource state with data, selected, loading, and error. + * Data contains topTokens and allTokens arrays. */ - paymentMethods: PaymentMethod[]; + tokens: ResourceState; /** - * The user's selected payment method. - * Can be manually set by the user. + * Payment methods resource state with data, selected, loading, and error. + * Data contains payment methods filtered by region, fiat, asset, and provider. */ - selectedPaymentMethod: PaymentMethod | null; + paymentMethods: ResourceState; /** - * Quotes fetched for the current context. - * Contains quotes from multiple providers for the given parameters. + * Quotes resource state with data, loading, and error. + * Data contains quotes from multiple providers for the given parameters. */ - quotes: QuotesResponse | null; + quotes: ResourceState; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. */ requests: RequestCacheType; - /** - * Whether user region is currently being fetched. - */ - userRegionLoading: boolean; - /** - * Error message if the user region fetch failed, or null. - */ - userRegionError: string | null; - /** - * Whether countries are currently being fetched. - */ - countriesLoading: boolean; - /** - * Error message if the countries fetch failed, or null. - */ - countriesError: string | null; - /** - * Whether providers are currently being fetched. - */ - providersLoading: boolean; - /** - * Error message if the providers fetch failed, or null. - */ - providersError: string | null; - /** - * Whether tokens are currently being fetched. - */ - tokensLoading: boolean; - /** - * Error message if the tokens fetch failed, or null. - */ - tokensError: string | null; - /** - * Whether payment methods are currently being fetched. - */ - paymentMethodsLoading: boolean; - /** - * Error message if the payment methods fetch failed, or null. - */ - paymentMethodsError: string | null; - /** - * Whether quotes are currently being fetched. - */ - quotesLoading: boolean; - /** - * Error message if the quotes fetch failed, or null. - */ - quotesError: string | null; }; /** @@ -195,12 +157,6 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, - selectedProvider: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, countries: { persist: true, includeInDebugSnapshot: true, @@ -219,24 +175,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, - selectedToken: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, paymentMethods: { persist: false, includeInDebugSnapshot: true, includeInStateLogs: true, usedInUi: true, }, - selectedPaymentMethod: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, quotes: { persist: false, includeInDebugSnapshot: true, @@ -249,80 +193,29 @@ const rampsControllerMetadata = { includeInStateLogs: false, usedInUi: true, }, - userRegionLoading: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - userRegionError: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - countriesLoading: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - countriesError: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - providersLoading: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - providersError: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - tokensLoading: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - tokensError: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - paymentMethodsLoading: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - paymentMethodsError: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - quotesLoading: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, - quotesError: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: false, - usedInUi: true, - }, } satisfies StateMetadata; +/** + * Creates a default resource state object. + * + * @template TData - The type of the resource data. + * @template TSelected - The type of the selected item. + * @param data - The initial data value. + * @param selected - The initial selected value. + * @returns A ResourceState object with default loading and error values. + */ +function createDefaultResourceState( + data: TData, + selected: TSelected = null as TSelected, +): ResourceState { + return { + data, + selected, + isLoading: false, + error: null, + }; +} + /** * Constructs the default {@link RampsController} state. This allows * consumers to provide a partial state object when initializing the controller @@ -333,28 +226,22 @@ const rampsControllerMetadata = { */ export function getDefaultRampsControllerState(): RampsControllerState { return { - userRegion: null, - selectedProvider: null, - countries: [], - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + userRegion: createDefaultResourceState(null), + countries: createDefaultResourceState([]), + providers: createDefaultResourceState( + [], + null, + ), + tokens: createDefaultResourceState( + null, + null, + ), + paymentMethods: createDefaultResourceState< + PaymentMethod[], + PaymentMethod | null + >([], null), + quotes: createDefaultResourceState(null), requests: {}, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, }; } @@ -687,14 +574,14 @@ export class RampsController extends BaseController< #cleanupState(): void { this.update((state) => { - state.userRegion = null; - state.selectedProvider = null; - state.selectedToken = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - state.quotes = null; + state.userRegion.data = null; + state.providers.selected = null; + state.tokens.selected = null; + state.tokens.data = null; + state.providers.data = []; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; + state.quotes.data = null; }); } @@ -718,22 +605,22 @@ export class RampsController extends BaseController< this.update((state) => { switch (resourceType) { case 'userRegion': - state.userRegionLoading = loading; + state.userRegion.isLoading = loading; break; case 'countries': - state.countriesLoading = loading; + state.countries.isLoading = loading; break; case 'providers': - state.providersLoading = loading; + state.providers.isLoading = loading; break; case 'tokens': - state.tokensLoading = loading; + state.tokens.isLoading = loading; break; case 'paymentMethods': - state.paymentMethodsLoading = loading; + state.paymentMethods.isLoading = loading; break; case 'quotes': - state.quotesLoading = loading; + state.quotes.isLoading = loading; break; /* istanbul ignore next: exhaustive switch */ default: @@ -752,22 +639,22 @@ export class RampsController extends BaseController< this.update((state) => { switch (resourceType) { case 'userRegion': - state.userRegionError = error; + state.userRegion.error = error; break; case 'countries': - state.countriesError = error; + state.countries.error = error; break; case 'providers': - state.providersError = error; + state.providers.error = error; break; case 'tokens': - state.tokensError = error; + state.tokens.error = error; break; case 'paymentMethods': - state.paymentMethodsError = error; + state.paymentMethods.error = error; break; case 'quotes': - state.quotesError = error; + state.quotes.error = error; break; /* istanbul ignore next: exhaustive switch */ default: @@ -854,15 +741,15 @@ export class RampsController extends BaseController< const normalizedRegion = region.toLowerCase().trim(); try { - const { countries } = this.state; - if (!countries || countries.length === 0) { + const countriesData = this.state.countries.data; + if (!countriesData || countriesData.length === 0) { this.#cleanupState(); throw new Error( 'No countries found. Cannot set user region without valid country information.', ); } - const userRegion = findRegionFromCode(normalizedRegion, countries); + const userRegion = findRegionFromCode(normalizedRegion, countriesData); if (!userRegion) { this.#cleanupState(); @@ -873,28 +760,28 @@ export class RampsController extends BaseController< // Only cleanup state if region is actually changing const regionChanged = - normalizedRegion !== this.state.userRegion?.regionCode; + normalizedRegion !== this.state.userRegion.data?.regionCode; // Set the new region atomically with cleanup to avoid intermediate null state this.update((state) => { if (regionChanged) { - state.selectedProvider = null; - state.selectedToken = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - state.quotes = null; + state.providers.selected = null; + state.tokens.selected = null; + state.tokens.data = null; + state.providers.data = []; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; + state.quotes.data = null; } - state.userRegion = userRegion; + state.userRegion.data = userRegion; }); - if (regionChanged || !this.state.tokens) { + if (regionChanged || !this.state.tokens.data) { this.#fireAndForget( this.getTokens(userRegion.regionCode, 'buy', options), ); } - if (regionChanged || this.state.providers.length === 0) { + if (regionChanged || this.state.providers.data.length === 0) { this.#fireAndForget(this.getProviders(userRegion.regionCode, options)); } @@ -916,21 +803,21 @@ export class RampsController extends BaseController< setSelectedProvider(providerId: string | null): void { if (providerId === null) { this.update((state) => { - state.selectedProvider = null; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + state.providers.selected = null; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; }); return; } - const regionCode = this.state.userRegion?.regionCode; + const regionCode = this.state.userRegion.data?.regionCode; if (!regionCode) { throw new Error( 'Region is required. Cannot set selected provider without valid region information.', ); } - const { providers } = this.state; + const providers = this.state.providers.data; if (!providers || providers.length === 0) { throw new Error( 'Providers not loaded. Cannot set selected provider before providers are fetched.', @@ -945,9 +832,9 @@ export class RampsController extends BaseController< } this.update((state) => { - state.selectedProvider = provider; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + state.providers.selected = provider; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; }); this.#fireAndForget( @@ -971,7 +858,7 @@ export class RampsController extends BaseController< try { await this.getCountries(options); - let regionCode = this.state.userRegion?.regionCode; + let regionCode = this.state.userRegion.data?.regionCode; regionCode ??= await this.messenger.call('RampsService:getGeolocation'); if (!regionCode) { @@ -994,7 +881,7 @@ export class RampsController extends BaseController< } hydrateState(options?: ExecuteRequestOptions): void { - const regionCode = this.state.userRegion?.regionCode; + const regionCode = this.state.userRegion.data?.regionCode; if (!regionCode) { throw new Error( 'Region code is required. Cannot hydrate state without valid region information.', @@ -1025,7 +912,7 @@ export class RampsController extends BaseController< ); this.update((state) => { - state.countries = countries; + state.countries.data = countries; }); return countries; @@ -1048,7 +935,7 @@ export class RampsController extends BaseController< provider?: string | string[]; }, ): Promise { - const regionToUse = region ?? this.state.userRegion?.regionCode; + const regionToUse = region ?? this.state.userRegion.data?.regionCode; if (!regionToUse) { throw new Error( @@ -1079,10 +966,10 @@ export class RampsController extends BaseController< ); this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + const userRegionCode = state.userRegion.data?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.tokens = tokens; + state.tokens.data = tokens; } }); @@ -1100,21 +987,21 @@ export class RampsController extends BaseController< setSelectedToken(assetId?: string): void { if (!assetId) { this.update((state) => { - state.selectedToken = null; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + state.tokens.selected = null; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; }); return; } - const regionCode = this.state.userRegion?.regionCode; + const regionCode = this.state.userRegion.data?.regionCode; if (!regionCode) { throw new Error( 'Region is required. Cannot set selected token without valid region information.', ); } - const { tokens } = this.state; + const tokens = this.state.tokens.data; if (!tokens) { throw new Error( 'Tokens not loaded. Cannot set selected token before tokens are fetched.', @@ -1132,9 +1019,9 @@ export class RampsController extends BaseController< } this.update((state) => { - state.selectedToken = token; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + state.tokens.selected = token; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; }); this.#fireAndForget( @@ -1163,7 +1050,7 @@ export class RampsController extends BaseController< payments?: string | string[]; }, ): Promise<{ providers: Provider[] }> { - const regionToUse = region ?? this.state.userRegion?.regionCode; + const regionToUse = region ?? this.state.userRegion.data?.regionCode; if (!regionToUse) { throw new Error( @@ -1198,10 +1085,10 @@ export class RampsController extends BaseController< ); this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + const userRegionCode = state.userRegion.data?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.providers = providers; + state.providers.data = providers; } }); @@ -1227,13 +1114,13 @@ export class RampsController extends BaseController< provider?: string; }, ): Promise { - const regionCode = region ?? this.state.userRegion?.regionCode ?? null; + const regionCode = region ?? this.state.userRegion.data?.regionCode ?? null; const fiatToUse = - options?.fiat ?? this.state.userRegion?.country?.currency ?? null; + options?.fiat ?? this.state.userRegion.data?.country?.currency ?? null; const assetIdToUse = - options?.assetId ?? this.state.selectedToken?.assetId ?? ''; + options?.assetId ?? this.state.tokens.selected?.assetId ?? ''; const providerToUse = - options?.provider ?? this.state.selectedProvider?.id ?? ''; + options?.provider ?? this.state.providers.selected?.id ?? ''; if (!regionCode) { throw new Error( @@ -1270,8 +1157,8 @@ export class RampsController extends BaseController< ); this.update((state) => { - const currentAssetId = state.selectedToken?.assetId ?? ''; - const currentProviderId = state.selectedProvider?.id ?? ''; + const currentAssetId = state.tokens.selected?.assetId ?? ''; + const currentProviderId = state.providers.selected?.id ?? ''; const tokenSelectionUnchanged = assetIdToUse === currentAssetId; const providerSelectionUnchanged = providerToUse === currentProviderId; @@ -1280,14 +1167,14 @@ export class RampsController extends BaseController< // ex: if the user rapidly changes the token or provider, the in-flight payment methods might not be valid // so this check will ensure that the payment methods are still valid for the token and provider that were requested if (tokenSelectionUnchanged && providerSelectionUnchanged) { - state.paymentMethods = response.payments; + state.paymentMethods.data = response.payments; // this will auto-select the first payment method if the selected payment method is not in the new payment methods const currentSelectionStillValid = response.payments.some( - (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, + (pm: PaymentMethod) => pm.id === state.paymentMethods.selected?.id, ); if (!currentSelectionStillValid) { - state.selectedPaymentMethod = response.payments[0] ?? null; + state.paymentMethods.selected = response.payments[0] ?? null; } } }); @@ -1305,12 +1192,12 @@ export class RampsController extends BaseController< setSelectedPaymentMethod(paymentMethodId?: string): void { if (!paymentMethodId) { this.update((state) => { - state.selectedPaymentMethod = null; + state.paymentMethods.selected = null; }); return; } - const { paymentMethods } = this.state; + const paymentMethods = this.state.paymentMethods.data; if (!paymentMethods || paymentMethods.length === 0) { throw new Error( 'Payment methods not loaded. Cannot set selected payment method before payment methods are fetched.', @@ -1327,7 +1214,7 @@ export class RampsController extends BaseController< } this.update((state) => { - state.selectedPaymentMethod = paymentMethod; + state.paymentMethods.selected = paymentMethod; }); } @@ -1362,11 +1249,11 @@ export class RampsController extends BaseController< forceRefresh?: boolean; ttl?: number; }): Promise { - const regionToUse = options.region ?? this.state.userRegion?.regionCode; - const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency; + const regionToUse = options.region ?? this.state.userRegion.data?.regionCode; + const fiatToUse = options.fiat ?? this.state.userRegion.data?.country?.currency; const paymentMethodsToUse = options.paymentMethods ?? - this.state.paymentMethods.map((pm: PaymentMethod) => pm.id); + this.state.paymentMethods.data.map((pm: PaymentMethod) => pm.id); const action = options.action ?? 'buy'; if (!regionToUse) { @@ -1439,10 +1326,10 @@ export class RampsController extends BaseController< ); this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + const userRegionCode = state.userRegion.data?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.quotes = response; + state.quotes.data = response; } }); diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 5d7a84d9d94..00089017be8 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,6 +7,7 @@ export type { RampsControllerStateChangeEvent, RampsControllerOptions, UserRegion, + ResourceState, } from './RampsController'; export { RampsController, @@ -34,6 +35,8 @@ export type { QuoteCustomAction, QuotesResponse, GetQuotesParams, + RampsToken, + TokensResponse, } from './RampsService'; export { RampsService, From 1f4cfbe81889aeddb316f87da85ffcd0d2039fca Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 15:18:25 -0700 Subject: [PATCH 06/20] chore: controller test update --- .../src/RampsController.test.ts | 677 +++++++++--------- 1 file changed, 355 insertions(+), 322 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 5d78d6f5a0a..2d347d6fcc6 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -35,28 +35,43 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { - "countries": Array [], - "countriesError": null, - "countriesLoading": false, - "paymentMethods": Array [], - "paymentMethodsError": null, - "paymentMethodsLoading": false, - "providers": Array [], - "providersError": null, - "providersLoading": false, - "quotes": null, - "quotesError": null, - "quotesLoading": false, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "paymentMethods": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, - "tokensError": null, - "tokensLoading": false, - "userRegion": null, - "userRegionError": null, - "userRegionLoading": false, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -64,15 +79,15 @@ describe('RampsController', () => { it('accepts initial state', async () => { const givenState = { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }; await withController( { options: { state: givenState } }, ({ controller }) => { - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.selectedProvider).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); expect(controller.state.requests).toStrictEqual({}); }, ); @@ -82,28 +97,43 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { - "countries": Array [], - "countriesError": null, - "countriesLoading": false, - "paymentMethods": Array [], - "paymentMethodsError": null, - "paymentMethodsLoading": false, - "providers": Array [], - "providersError": null, - "providersLoading": false, - "quotes": null, - "quotesError": null, - "quotesLoading": false, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "paymentMethods": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, - "tokensError": null, - "tokensLoading": false, - "userRegion": null, - "userRegionError": null, - "userRegionLoading": false, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -111,7 +141,7 @@ describe('RampsController', () => { it('always resets requests cache on initialization', async () => { const givenState = { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), requests: { someKey: { status: RequestStatus.SUCCESS, @@ -176,12 +206,12 @@ describe('RampsController', () => { async (_regionCode: string) => ({ providers: mockProviders }), ); - expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.providers.data).toStrictEqual([]); const result = await controller.getProviders('us-ca'); expect(result.providers).toStrictEqual(mockProviders); - expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.providers.data).toStrictEqual(mockProviders); }); }); @@ -242,7 +272,7 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -262,7 +292,7 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -282,7 +312,7 @@ describe('RampsController', () => { it('updates providers when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')) } } }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getProviders', @@ -292,12 +322,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.providers.data).toStrictEqual([]); await controller.getProviders('US-ca'); - expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.providers.data).toStrictEqual(mockProviders); }, ); }); @@ -324,8 +354,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: existingProviders, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState(existingProviders, null), }, }, }, @@ -338,12 +368,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.providers).toStrictEqual(existingProviders); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.providers.data).toStrictEqual(existingProviders); await controller.getProviders('fr'); - expect(controller.state.providers).toStrictEqual(existingProviders); + expect(controller.state.providers.data).toStrictEqual(existingProviders); }, ); }); @@ -908,7 +938,7 @@ describe('RampsController', () => { async () => mockCountries, ); - expect(controller.state.countries).toStrictEqual([]); + expect(controller.state.countries.data).toStrictEqual([]); const countries = await controller.getCountries(); @@ -947,7 +977,7 @@ describe('RampsController', () => { }, ] `); - expect(controller.state.countries).toStrictEqual(mockCountries); + expect(controller.state.countries.data).toStrictEqual(mockCountries); }); }); }); @@ -966,8 +996,8 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries).toStrictEqual(createMockCountries()); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.countries.data).toStrictEqual(createMockCountries()); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); }); }); @@ -977,7 +1007,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: existingRegion, + userRegion: createResourceState(existingRegion), }, }, }, @@ -989,10 +1019,10 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries).toStrictEqual( + expect(controller.state.countries.data).toStrictEqual( createMockCountries(), ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); }, ); }); @@ -1037,11 +1067,10 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, + countries: createResourceState(createMockCountries()), + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokens, null), + providers: createResourceState(mockProviders, mockSelectedProvider), }, }, }, @@ -1062,10 +1091,10 @@ describe('RampsController', () => { await controller.init(); // Verify persisted state is preserved - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - expect(controller.state.selectedProvider).toStrictEqual( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.providers.selected).toStrictEqual( mockSelectedProvider, ); }, @@ -1121,7 +1150,7 @@ describe('RampsController', () => { await expect(controller.init()).rejects.toMatchObject({ code: 'ERR_NO_MESSAGE', }); - expect(controller.state.userRegionError).toBe('Unknown error'); + expect(controller.state.userRegion.error).toBe('Unknown error'); }); }); }); @@ -1132,7 +1161,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -1180,7 +1209,7 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), }, }, }, @@ -1196,9 +1225,9 @@ describe('RampsController', () => { await controller.setUserRegion('US-CA'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.stateId).toBe('CA'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state?.stateId).toBe('CA'); }, ); }); @@ -1231,7 +1260,7 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), }, }, }, @@ -1261,22 +1290,22 @@ describe('RampsController', () => { await controller.getPaymentMethods('us-ca'); controller.setSelectedPaymentMethod(mockPaymentMethod.id); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod, ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); providersToReturn = []; await controller.setUserRegion('FR'); await new Promise((resolve) => setTimeout(resolve, 50)); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual([]); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual([]); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -1321,11 +1350,10 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, + countries: createResourceState(createMockCountries()), + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokens, null), + providers: createResourceState(mockProviders, mockSelectedProvider), }, }, }, @@ -1343,10 +1371,10 @@ describe('RampsController', () => { await controller.setUserRegion('US-ca'); // Verify persisted state is preserved - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - expect(controller.state.selectedProvider).toStrictEqual( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.providers.selected).toStrictEqual( mockSelectedProvider, ); }, @@ -1402,12 +1430,10 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, - selectedToken: mockSelectedToken, + countries: createResourceState(createMockCountries()), + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokens, mockSelectedToken), + providers: createResourceState(mockProviders, mockSelectedProvider), }, }, }, @@ -1425,11 +1451,11 @@ describe('RampsController', () => { await controller.setUserRegion('FR'); // Verify persisted state is cleared - expect(controller.state.userRegion?.regionCode).toBe('fr'); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); - expect(controller.state.selectedProvider).toBeNull(); - expect(controller.state.selectedToken).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('fr'); + expect(controller.state.tokens.data).toBeNull(); + expect(controller.state.providers.data).toStrictEqual([]); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.tokens.selected).toBeNull(); }, ); }); @@ -1458,7 +1484,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1474,8 +1500,8 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.name).toBe( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.name).toBe( 'United States', ); }, @@ -1499,7 +1525,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1515,8 +1541,8 @@ describe('RampsController', () => { await controller.setUserRegion('fr'); - expect(controller.state.userRegion?.regionCode).toBe('fr'); - expect(controller.state.userRegion?.country.name).toBe('France'); + expect(controller.state.userRegion.data?.regionCode).toBe('fr'); + expect(controller.state.userRegion.data?.country.name).toBe('France'); }, ); }); @@ -1545,7 +1571,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1561,8 +1587,8 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.name).toBe( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.name).toBe( 'United States', ); }, @@ -1585,7 +1611,7 @@ describe('RampsController', () => { { options: { state: { - countries, + countries: createResourceState(countries), }, }, }, @@ -1594,7 +1620,7 @@ describe('RampsController', () => { 'Region "xx" not found in countries data', ); - expect(controller.state.userRegion).toBeNull(); + expect(controller.state.userRegion.data).toBeNull(); }, ); }); @@ -1605,8 +1631,8 @@ describe('RampsController', () => { 'No countries found. Cannot set user region without valid country information.', ); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); }); }); @@ -1615,8 +1641,8 @@ describe('RampsController', () => { { options: { state: { - countries: [], - userRegion: createMockUserRegion('us-ca'), + countries: createResourceState([]), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -1625,8 +1651,8 @@ describe('RampsController', () => { 'No countries found. Cannot set user region without valid country information.', ); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); }, ); }); @@ -1654,7 +1680,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStateId, + countries: createResourceState(countriesWithStateId), }, }, }, @@ -1670,9 +1696,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-ny'); - expect(controller.state.userRegion?.regionCode).toBe('us-ny'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe('New York'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ny'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state?.name).toBe('New York'); }, ); }); @@ -1700,7 +1726,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStateId, + countries: createResourceState(countriesWithStateId), }, }, }, @@ -1716,9 +1742,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe('California'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state?.name).toBe('California'); }, ); }); @@ -1751,7 +1777,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStates, + countries: createResourceState(countriesWithStates), }, }, }, @@ -1767,9 +1793,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-xx'); - expect(controller.state.userRegion?.regionCode).toBe('us-xx'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('us-xx'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state).toBeNull(); }, ); }); @@ -1809,8 +1835,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider], null), }, }, }, @@ -1820,11 +1846,11 @@ describe('RampsController', () => { async () => ({ payments: [] }), ); - expect(controller.state.selectedProvider).toBeNull(); + expect(controller.state.providers.selected).toBeNull(); controller.setSelectedProvider(mockProvider.id); - expect(controller.state.selectedProvider).toStrictEqual(mockProvider); + expect(controller.state.providers.selected).toStrictEqual(mockProvider); }, ); }); @@ -1842,28 +1868,26 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], - selectedProvider: mockProvider, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider], mockProvider), + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, ({ controller }) => { - expect(controller.state.selectedProvider).toStrictEqual(mockProvider); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.providers.selected).toStrictEqual(mockProvider); + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod, ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); controller.setSelectedProvider(null); - expect(controller.state.selectedProvider).toBeNull(); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -1873,7 +1897,7 @@ describe('RampsController', () => { { options: { state: { - providers: [mockProvider], + providers: createResourceState([mockProvider], null), }, }, }, @@ -1892,7 +1916,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -1911,8 +1935,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider], null), }, }, }, @@ -1945,11 +1969,9 @@ describe('RampsController', () => { { options: { state: { - selectedProvider: mockProvider, - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider, newProvider], - paymentMethods: [existingPaymentMethod], - selectedPaymentMethod: existingPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider, newProvider], mockProvider), + paymentMethods: createResourceState([existingPaymentMethod], existingPaymentMethod), }, }, }, @@ -1959,21 +1981,21 @@ describe('RampsController', () => { async () => ({ payments: [] }), ); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ existingPaymentMethod, ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( existingPaymentMethod, ); controller.setSelectedProvider(newProvider.id); - expect(controller.state.selectedProvider).toStrictEqual(newProvider); - expect(controller.state.selectedProvider?.id).toBe( + expect(controller.state.providers.selected).toStrictEqual(newProvider); + expect(controller.state.providers.selected?.id).toBe( '/providers/ramp-network-staging', ); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -2008,8 +2030,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2019,11 +2041,11 @@ describe('RampsController', () => { async () => ({ payments: [] }), ); - expect(controller.state.selectedToken).toBeNull(); + expect(controller.state.tokens.selected).toBeNull(); controller.setSelectedToken(mockToken.assetId); - expect(controller.state.selectedToken).toStrictEqual(mockToken); + expect(controller.state.tokens.selected).toStrictEqual(mockToken); }, ); }); @@ -2033,24 +2055,22 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, - selectedToken: mockToken, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, mockToken), + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, ({ controller }) => { - expect(controller.state.selectedToken).toStrictEqual(mockToken); - expect(controller.state.paymentMethods).toHaveLength(1); - expect(controller.state.selectedPaymentMethod).not.toBeNull(); + expect(controller.state.tokens.selected).toStrictEqual(mockToken); + expect(controller.state.paymentMethods.data).toHaveLength(1); + expect(controller.state.paymentMethods.selected).not.toBeNull(); controller.setSelectedToken(undefined); - expect(controller.state.selectedToken).toBeNull(); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.tokens.selected).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -2060,7 +2080,7 @@ describe('RampsController', () => { { options: { state: { - tokens: mockTokensResponse, + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2077,7 +2097,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -2094,8 +2114,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2114,8 +2134,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2159,11 +2179,9 @@ describe('RampsController', () => { { options: { state: { - selectedToken: mockToken, - userRegion: createMockUserRegion('us-ca'), - tokens: tokensWithBoth, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(tokensWithBoth, mockToken), + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, @@ -2173,18 +2191,18 @@ describe('RampsController', () => { async () => ({ payments: [] }), ); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod, ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); controller.setSelectedToken(newToken.assetId); - expect(controller.state.selectedToken).toStrictEqual(newToken); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.tokens.selected).toStrictEqual(newToken); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -2236,7 +2254,7 @@ describe('RampsController', () => { ) => mockTokens, ); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); const tokens = await controller.getTokens('us-ca', 'buy'); @@ -2275,7 +2293,7 @@ describe('RampsController', () => { ], } `); - expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); }); }); @@ -2388,7 +2406,7 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2420,7 +2438,7 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2444,7 +2462,7 @@ describe('RampsController', () => { it('updates tokens when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')) } } }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', @@ -2458,12 +2476,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toBeNull(); await controller.getTokens('US-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); }, ); }); @@ -2498,8 +2516,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: existingTokens, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(existingTokens, null), }, }, }, @@ -2516,12 +2534,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(existingTokens); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toStrictEqual(existingTokens); await controller.getTokens('fr'); - expect(controller.state.tokens).toStrictEqual(existingTokens); + expect(controller.state.tokens.data).toStrictEqual(existingTokens); }, ); }); @@ -2643,11 +2661,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: mockPaymentMethod1, - paymentMethods: [mockPaymentMethod1, mockPaymentMethod2], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([mockPaymentMethod1, mockPaymentMethod2], mockPaymentMethod1), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2657,7 +2674,7 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod1, ); @@ -2666,10 +2683,10 @@ describe('RampsController', () => { provider: '/providers/stripe', }); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod1, ); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod1, mockPaymentMethod2, ]); @@ -2690,11 +2707,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: removedPaymentMethod, - paymentMethods: [removedPaymentMethod], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([removedPaymentMethod], removedPaymentMethod), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2704,7 +2720,7 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( removedPaymentMethod, ); @@ -2713,10 +2729,10 @@ describe('RampsController', () => { provider: '/providers/stripe', }); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod1, ); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod1, mockPaymentMethod2, ]); @@ -2729,11 +2745,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: null, - paymentMethods: [], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([], null), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2743,17 +2758,17 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.paymentMethods.selected).toBeNull(); await controller.getPaymentMethods('us-ca', { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod1, ); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod1, mockPaymentMethod2, ]); @@ -2766,9 +2781,9 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2778,14 +2793,14 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.paymentMethods).toStrictEqual([]); + expect(controller.state.paymentMethods.data).toStrictEqual([]); await controller.getPaymentMethods('us-ca', { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod1, mockPaymentMethod2, ]); @@ -2798,7 +2813,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -2845,7 +2860,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: regionWithoutCurrency, + userRegion: createResourceState(regionWithoutCurrency), }, }, }, @@ -2877,8 +2892,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: mockToken, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, mockToken), }, }, }, @@ -2926,8 +2941,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedProvider: testProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([], testProvider), }, }, }, @@ -2960,7 +2975,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('fr'), + userRegion: createResourceState(createMockUserRegion('fr')), }, }, }, @@ -3002,11 +3017,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: removedPaymentMethod, - paymentMethods: [removedPaymentMethod], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([removedPaymentMethod], removedPaymentMethod), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -3021,8 +3035,8 @@ describe('RampsController', () => { provider: '/providers/stripe', }); - expect(controller.state.selectedPaymentMethod).toBeNull(); - expect(controller.state.paymentMethods).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); }, ); }); @@ -3045,9 +3059,9 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: null, - selectedProvider: null, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, null), + providers: createResourceState([], null), }, }, }, @@ -3121,14 +3135,16 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: tokenA, - selectedProvider: null, - paymentMethods: [], - tokens: { - topTokens: [tokenA, tokenB], - allTokens: [tokenA, tokenB], - }, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState( + { + topTokens: [tokenA, tokenB], + allTokens: [tokenA, tokenB], + }, + tokenA, + ), + providers: createResourceState([], null), + paymentMethods: createResourceState([], null), }, }, }, @@ -3169,8 +3185,8 @@ describe('RampsController', () => { resolveTokenARequest({ payments: paymentMethodsForTokenA }); await tokenAPaymentMethodsPromise; - expect(controller.state.selectedToken).toStrictEqual(tokenB); - expect(controller.state.paymentMethods).toStrictEqual( + expect(controller.state.tokens.selected).toStrictEqual(tokenB); + expect(controller.state.paymentMethods.data).toStrictEqual( paymentMethodsForTokenB, ); expect(callCount).toBe(2); @@ -3233,11 +3249,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: null, - selectedProvider: providerA, - paymentMethods: [], - providers: [providerA, providerB], + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, null), + providers: createResourceState([providerA, providerB], providerA), + paymentMethods: createResourceState([], null), }, }, }, @@ -3278,8 +3293,8 @@ describe('RampsController', () => { resolveProviderARequest({ payments: paymentMethodsForProviderA }); await providerAPaymentMethodsPromise; - expect(controller.state.selectedProvider).toStrictEqual(providerB); - expect(controller.state.paymentMethods).toStrictEqual( + expect(controller.state.providers.selected).toStrictEqual(providerB); + expect(controller.state.paymentMethods.data).toStrictEqual( paymentMethodsForProviderB, ); expect(callCount).toBe(2); @@ -3327,10 +3342,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: token, - selectedProvider: provider, - paymentMethods: [], + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, token), + providers: createResourceState([], provider), + paymentMethods: createResourceState([], null), }, }, }, @@ -3345,9 +3360,9 @@ describe('RampsController', () => { provider: provider.id, }); - expect(controller.state.selectedToken).toStrictEqual(token); - expect(controller.state.selectedProvider).toStrictEqual(provider); - expect(controller.state.paymentMethods).toStrictEqual( + expect(controller.state.tokens.selected).toStrictEqual(token); + expect(controller.state.providers.selected).toStrictEqual(provider); + expect(controller.state.paymentMethods.data).toStrictEqual( newPaymentMethods, ); }, @@ -3369,16 +3384,16 @@ describe('RampsController', () => { { options: { state: { - paymentMethods: [mockPaymentMethod], + paymentMethods: createResourceState([mockPaymentMethod], null), }, }, }, ({ controller }) => { - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.paymentMethods.selected).toBeNull(); controller.setSelectedPaymentMethod(mockPaymentMethod.id); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); }, @@ -3390,19 +3405,18 @@ describe('RampsController', () => { { options: { state: { - selectedPaymentMethod: mockPaymentMethod, - paymentMethods: [mockPaymentMethod], + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, ({ controller }) => { - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); controller.setSelectedPaymentMethod(undefined); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -3422,7 +3436,7 @@ describe('RampsController', () => { { options: { state: { - paymentMethods: [mockPaymentMethod], + paymentMethods: createResourceState([mockPaymentMethod], null), }, }, }, @@ -3473,8 +3487,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3482,7 +3496,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3492,7 +3506,7 @@ describe('RampsController', () => { async () => mockQuotesResponse, ); - expect(controller.state.quotes).toBeNull(); + expect(controller.state.quotes.data).toBeNull(); const result = await controller.getQuotes({ assetId: 'eip155:1/slip44:60', @@ -3502,7 +3516,7 @@ describe('RampsController', () => { expect(result.success).toHaveLength(1); expect(result.success[0]?.provider).toBe('/providers/moonpay'); - expect(controller.state.quotes).toStrictEqual(mockQuotesResponse); + expect(controller.state.quotes.data).toStrictEqual(mockQuotesResponse); }, ); }); @@ -3512,8 +3526,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3521,7 +3535,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3562,7 +3576,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: { + userRegion: createResourceState({ country: { isoCode: 'US', name: 'United States', @@ -3573,7 +3587,7 @@ describe('RampsController', () => { }, state: null, regionCode: 'us', - }, + }), }, }, }, @@ -3595,8 +3609,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [], + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([], null), }, }, }, @@ -3617,8 +3631,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3626,7 +3640,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3663,8 +3677,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3672,7 +3686,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3701,8 +3715,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3710,7 +3724,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3739,8 +3753,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3748,7 +3762,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3805,8 +3819,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - countries: [ + userRegion: createResourceState(createMockUserRegion('us')), + countries: createResourceState([ { isoCode: 'US', flag: '๐Ÿ‡บ๐Ÿ‡ธ', @@ -3823,8 +3837,8 @@ describe('RampsController', () => { currency: 'EUR', supported: { buy: true, sell: true }, }, - ], - paymentMethods: [ + ]), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3832,7 +3846,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3867,7 +3881,7 @@ describe('RampsController', () => { await quotesPromise; // Quotes should not be updated because region changed - expect(controller.state.quotes).toBeNull(); + expect(controller.state.quotes.data).toBeNull(); }, ); }); @@ -4012,6 +4026,25 @@ function createMockCountries(): Country[] { ]; } +/** + * Creates a ResourceState object for testing. + * + * @param data - The resource data. + * @param selected - The selected item (optional). + * @returns A ResourceState object. + */ +function createResourceState( + data: TData, + selected: TSelected = null as TSelected, +) { + return { + data, + selected, + isLoading: false, + error: null, + }; +} + /** * The type of the messenger populated with all external actions and events * required by the controller under test. From a0d8a41b462f186051c9b629d867cb025e05bb89 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 15:26:35 -0700 Subject: [PATCH 07/20] chore: controller test update --- .../src/RampsController.test.ts | 180 ++++++++++++------ 1 file changed, 126 insertions(+), 54 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 2d347d6fcc6..0fc28d8d29b 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -440,28 +440,43 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "countriesError": null, - "countriesLoading": false, - "paymentMethods": Array [], - "paymentMethodsError": null, - "paymentMethodsLoading": false, - "providers": Array [], - "providersError": null, - "providersLoading": false, - "quotes": null, - "quotesError": null, - "quotesLoading": false, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "paymentMethods": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, - "tokensError": null, - "tokensLoading": false, - "userRegion": null, - "userRegionError": null, - "userRegionLoading": false, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -477,14 +492,36 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, - "userRegion": null, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "paymentMethods": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -500,10 +537,30 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "providers": Array [], - "tokens": null, - "userRegion": null, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -519,28 +576,43 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "countriesError": null, - "countriesLoading": false, - "paymentMethods": Array [], - "paymentMethodsError": null, - "paymentMethodsLoading": false, - "providers": Array [], - "providersError": null, - "providersLoading": false, - "quotes": null, - "quotesError": null, - "quotesLoading": false, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "paymentMethods": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, - "tokensError": null, - "tokensLoading": false, - "userRegion": null, - "userRegionError": null, - "userRegionLoading": false, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); From 9b4cf0899c7b8c073ae432787c69d4d812b8b1dd Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 29 Jan 2026 15:34:52 -0700 Subject: [PATCH 08/20] chore: selectors test update --- .../ramps-controller/src/selectors.test.ts | 402 +++--------------- 1 file changed, 56 insertions(+), 346 deletions(-) diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index eaf033e59a3..6a99cab073b 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -10,6 +10,33 @@ type TestRootState = { ramps: RampsControllerState; }; +function createDefaultResourceState( + data: TData, + selected: TSelected = null as TSelected, +) { + return { + data, + selected, + isLoading: false, + error: null, + }; +} + +function createMockRampsState( + overrides: Partial = {}, +): RampsControllerState { + return { + userRegion: createDefaultResourceState(null), + countries: createDefaultResourceState([]), + providers: createDefaultResourceState([], null), + tokens: createDefaultResourceState(null, null), + paymentMethods: createDefaultResourceState([], null), + quotes: createDefaultResourceState(null), + requests: {}, + ...overrides, + }; +} + describe('createRequestSelector', () => { const getState = (state: TestRootState): RampsControllerState => state.ramps; @@ -23,32 +50,11 @@ describe('createRequestSelector', () => { const loadingRequest = createLoadingState(); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, - }, + }), }; const result = selector(state); @@ -71,32 +77,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH', 'BTC'], Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result = selector(state); @@ -122,32 +107,11 @@ describe('createRequestSelector', () => { const errorRequest = createErrorState('Network error', Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, - }, + }), }; const result = selector(state); @@ -169,30 +133,7 @@ describe('createRequestSelector', () => { ); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, - requests: {}, - }, + ramps: createMockRampsState(), }; const result = selector(state); @@ -239,32 +180,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH', 'BTC'], Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -282,64 +202,22 @@ describe('createRequestSelector', () => { const successRequest1 = createSuccessState(['ETH'], Date.now()); const state1: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest1, }, - }, + }), }; const result1 = selector(state1); const successRequest2 = createSuccessState(['ETH', 'BTC'], Date.now()); const state2: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest2, }, - }, + }), }; const result2 = selector(state2); @@ -358,32 +236,11 @@ describe('createRequestSelector', () => { const largeArray = Array.from({ length: 1000 }, (_, i) => `item-${i}`); const successRequest = createSuccessState(largeArray, Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -405,32 +262,11 @@ describe('createRequestSelector', () => { }; const successRequest = createSuccessState(complexData, Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getData:[]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -451,32 +287,11 @@ describe('createRequestSelector', () => { const loadingRequest = createLoadingState(); const loadingState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, - }, + }), }; const loadingResult = selector(loadingState); @@ -485,32 +300,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH'], Date.now()); const successState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const successResult = selector(successState); @@ -527,32 +321,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH'], Date.now()); const successState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const successResult = selector(successState); @@ -560,32 +333,11 @@ describe('createRequestSelector', () => { const errorRequest = createErrorState('Failed to fetch', Date.now()); const errorState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, - }, + }), }; const errorResult = selector(errorState); @@ -608,28 +360,7 @@ describe('createRequestSelector', () => { ); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -637,7 +368,7 @@ describe('createRequestSelector', () => { ), 'getPrice:["US"]': createSuccessState(100, Date.now()), }, - }, + }), }; const result1 = selector1(state); @@ -660,28 +391,7 @@ describe('createRequestSelector', () => { ); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - userRegionLoading: false, - userRegionError: null, - countriesLoading: false, - countriesError: null, - providersLoading: false, - providersError: null, - tokensLoading: false, - tokensError: null, - paymentMethodsLoading: false, - paymentMethodsError: null, - quotesLoading: false, - quotesError: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -692,7 +402,7 @@ describe('createRequestSelector', () => { Date.now(), ), }, - }, + }), }; const result1 = selector1(state); From 6e7c9d281b2d662a9151b3e1088246e089ced188 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 08:38:35 -0700 Subject: [PATCH 09/20] chore: formatting --- .../src/RampsController.test.ts | 291 ++++++++++++------ .../ramps-controller/src/RampsController.ts | 15 +- .../ramps-controller/src/selectors.test.ts | 7 +- 3 files changed, 216 insertions(+), 97 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 94d433fbebe..581d4ab5e4c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -272,7 +272,13 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, + { + options: { + state: { + userRegion: createResourceState(createMockUserRegion('fr')), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -292,7 +298,13 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, + { + options: { + state: { + userRegion: createResourceState(createMockUserRegion('fr')), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -312,7 +324,13 @@ describe('RampsController', () => { it('updates providers when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')) } } }, + { + options: { + state: { + userRegion: createResourceState(createMockUserRegion('us-ca')), + }, + }, + }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getProviders', @@ -369,11 +387,15 @@ describe('RampsController', () => { ); expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); - expect(controller.state.providers.data).toStrictEqual(existingProviders); + expect(controller.state.providers.data).toStrictEqual( + existingProviders, + ); await controller.getProviders('fr'); - expect(controller.state.providers.data).toStrictEqual(existingProviders); + expect(controller.state.providers.data).toStrictEqual( + existingProviders, + ); }, ); }); @@ -1068,7 +1090,9 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries.data).toStrictEqual(createMockCountries()); + expect(controller.state.countries.data).toStrictEqual( + createMockCountries(), + ); expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); }); }); @@ -1142,7 +1166,10 @@ describe('RampsController', () => { countries: createResourceState(createMockCountries()), userRegion: createResourceState(createMockUserRegion('us-ca')), tokens: createResourceState(mockTokens, null), - providers: createResourceState(mockProviders, mockSelectedProvider), + providers: createResourceState( + mockProviders, + mockSelectedProvider, + ), }, }, }, @@ -1425,7 +1452,10 @@ describe('RampsController', () => { countries: createResourceState(createMockCountries()), userRegion: createResourceState(createMockUserRegion('us-ca')), tokens: createResourceState(mockTokens, null), - providers: createResourceState(mockProviders, mockSelectedProvider), + providers: createResourceState( + mockProviders, + mockSelectedProvider, + ), }, }, }, @@ -1505,7 +1535,10 @@ describe('RampsController', () => { countries: createResourceState(createMockCountries()), userRegion: createResourceState(createMockUserRegion('us-ca')), tokens: createResourceState(mockTokens, mockSelectedToken), - providers: createResourceState(mockProviders, mockSelectedProvider), + providers: createResourceState( + mockProviders, + mockSelectedProvider, + ), }, }, }, @@ -1770,7 +1803,9 @@ describe('RampsController', () => { expect(controller.state.userRegion.data?.regionCode).toBe('us-ny'); expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); - expect(controller.state.userRegion.data?.state?.name).toBe('New York'); + expect(controller.state.userRegion.data?.state?.name).toBe( + 'New York', + ); }, ); }); @@ -1816,7 +1851,9 @@ describe('RampsController', () => { expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); - expect(controller.state.userRegion.data?.state?.name).toBe('California'); + expect(controller.state.userRegion.data?.state?.name).toBe( + 'California', + ); }, ); }); @@ -1922,7 +1959,9 @@ describe('RampsController', () => { controller.setSelectedProvider(mockProvider.id); - expect(controller.state.providers.selected).toStrictEqual(mockProvider); + expect(controller.state.providers.selected).toStrictEqual( + mockProvider, + ); }, ); }); @@ -1942,12 +1981,17 @@ describe('RampsController', () => { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), providers: createResourceState([mockProvider], mockProvider), - paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), + paymentMethods: createResourceState( + [mockPaymentMethod], + mockPaymentMethod, + ), }, }, }, ({ controller }) => { - expect(controller.state.providers.selected).toStrictEqual(mockProvider); + expect(controller.state.providers.selected).toStrictEqual( + mockProvider, + ); expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod, ]); @@ -2042,8 +2086,14 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), - providers: createResourceState([mockProvider, newProvider], mockProvider), - paymentMethods: createResourceState([existingPaymentMethod], existingPaymentMethod), + providers: createResourceState( + [mockProvider, newProvider], + mockProvider, + ), + paymentMethods: createResourceState( + [existingPaymentMethod], + existingPaymentMethod, + ), }, }, }, @@ -2062,7 +2112,9 @@ describe('RampsController', () => { controller.setSelectedProvider(newProvider.id); - expect(controller.state.providers.selected).toStrictEqual(newProvider); + expect(controller.state.providers.selected).toStrictEqual( + newProvider, + ); expect(controller.state.providers.selected?.id).toBe( '/providers/ramp-network-staging', ); @@ -2129,7 +2181,10 @@ describe('RampsController', () => { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), tokens: createResourceState(mockTokensResponse, mockToken), - paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), + paymentMethods: createResourceState( + [mockPaymentMethod], + mockPaymentMethod, + ), }, }, }, @@ -2253,7 +2308,10 @@ describe('RampsController', () => { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), tokens: createResourceState(tokensWithBoth, mockToken), - paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), + paymentMethods: createResourceState( + [mockPaymentMethod], + mockPaymentMethod, + ), }, }, }, @@ -2478,7 +2536,13 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, + { + options: { + state: { + userRegion: createResourceState(createMockUserRegion('fr')), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2510,7 +2574,13 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, + { + options: { + state: { + userRegion: createResourceState(createMockUserRegion('fr')), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2534,7 +2604,13 @@ describe('RampsController', () => { it('updates tokens when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')) } } }, + { + options: { + state: { + userRegion: createResourceState(createMockUserRegion('us-ca')), + }, + }, + }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', @@ -2734,7 +2810,10 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), - paymentMethods: createResourceState([mockPaymentMethod1, mockPaymentMethod2], mockPaymentMethod1), + paymentMethods: createResourceState( + [mockPaymentMethod1, mockPaymentMethod2], + mockPaymentMethod1, + ), tokens: createResourceState(null, mockSelectedToken), providers: createResourceState([], mockSelectedProvider), }, @@ -2780,7 +2859,10 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), - paymentMethods: createResourceState([removedPaymentMethod], removedPaymentMethod), + paymentMethods: createResourceState( + [removedPaymentMethod], + removedPaymentMethod, + ), tokens: createResourceState(null, mockSelectedToken), providers: createResourceState([], mockSelectedProvider), }, @@ -3090,7 +3172,10 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')), - paymentMethods: createResourceState([removedPaymentMethod], removedPaymentMethod), + paymentMethods: createResourceState( + [removedPaymentMethod], + removedPaymentMethod, + ), tokens: createResourceState(null, mockSelectedToken), providers: createResourceState([], mockSelectedProvider), }, @@ -3477,7 +3562,10 @@ describe('RampsController', () => { { options: { state: { - paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), + paymentMethods: createResourceState( + [mockPaymentMethod], + mockPaymentMethod, + ), }, }, }, @@ -3560,15 +3648,18 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us')), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3588,7 +3679,9 @@ describe('RampsController', () => { expect(result.success).toHaveLength(1); expect(result.success[0]?.provider).toBe('/providers/moonpay'); - expect(controller.state.quotes.data).toStrictEqual(mockQuotesResponse); + expect(controller.state.quotes.data).toStrictEqual( + mockQuotesResponse, + ); }, ); }); @@ -3599,15 +3692,18 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us')), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3704,15 +3800,18 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us')), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3750,15 +3849,18 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us')), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3788,15 +3890,18 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us')), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3826,15 +3931,18 @@ describe('RampsController', () => { options: { state: { userRegion: createResourceState(createMockUserRegion('us')), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3949,15 +4057,18 @@ describe('RampsController', () => { supported: { buy: true, sell: true }, }, ]), - paymentMethods: createResourceState([ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], null), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 44d4fd0893b..414ddc55710 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -83,6 +83,7 @@ export type UserRegion = { /** * Generic type for resource state that bundles data with loading/error states. + * * @template TData - The type of the resource data * @template TSelected - The type of the selected item (defaults to null for resources without selection) */ @@ -232,10 +233,10 @@ export function getDefaultRampsControllerState(): RampsControllerState { [], null, ), - tokens: createDefaultResourceState( - null, - null, - ), + tokens: createDefaultResourceState< + TokensResponse | null, + RampsToken | null + >(null, null), paymentMethods: createDefaultResourceState< PaymentMethod[], PaymentMethod | null @@ -1249,8 +1250,10 @@ export class RampsController extends BaseController< forceRefresh?: boolean; ttl?: number; }): Promise { - const regionToUse = options.region ?? this.state.userRegion.data?.regionCode; - const fiatToUse = options.fiat ?? this.state.userRegion.data?.country?.currency; + const regionToUse = + options.region ?? this.state.userRegion.data?.regionCode; + const fiatToUse = + options.fiat ?? this.state.userRegion.data?.country?.currency; const paymentMethodsToUse = options.paymentMethods ?? this.state.paymentMethods.data.map((pm: PaymentMethod) => pm.id); diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 6a99cab073b..ce2d07b9ed6 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -13,7 +13,12 @@ type TestRootState = { function createDefaultResourceState( data: TData, selected: TSelected = null as TSelected, -) { +): { + data: TData; + selected: TSelected; + isLoading: boolean; + error: null; +} { return { data, selected, From ee50872916377342f4e0aa1fbae3729e7815fd21 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 08:43:04 -0700 Subject: [PATCH 10/20] chore: changelog update --- packages/ramps-controller/CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 40d442aa44c..fb544342934 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,13 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Restructure `RampsControllerState` to use nested `ResourceState` objects for each resource with `data`, `selected`, `isLoading`, and `error` ([#7779](https://github.com/MetaMask/core/pull/7779)) + ## [5.1.0] ### Added - Add quotes functionality to RampsController ([#7747](https://github.com/MetaMask/core/pull/7747)) -- Add `quotesLoading` and `quotesError` state properties for quotes resource loading/error tracking ([#7779](https://github.com/MetaMask/core/pull/7779)) - ### Fixed - Fix `getQuotes()` to trim `assetId` and `walletAddress` parameters before use ([#7793](https://github.com/MetaMask/core/pull/7793)) From 6229c0edb49c5e2eb5b97095b929379bc0063855 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 09:13:15 -0700 Subject: [PATCH 11/20] chore: lint and refactor --- .../src/RampsController.test.ts | 31 +++++--- .../ramps-controller/src/RampsController.ts | 74 ++++++------------- 2 files changed, 43 insertions(+), 62 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 581d4ab5e4c..a388ec2d63e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -6,7 +6,11 @@ import type { MessengerEvents, } from '@metamask/messenger'; -import type { RampsControllerMessenger, UserRegion } from './RampsController'; +import type { + RampsControllerMessenger, + ResourceState, + UserRegion, +} from './RampsController'; import { RampsController } from './RampsController'; import type { Country, @@ -3999,16 +4003,19 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -4258,7 +4265,7 @@ function createMockCountries(): Country[] { function createResourceState( data: TData, selected: TSelected = null as TSelected, -) { +): ResourceState { return { data, selected, diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 414ddc55710..25af893d4d4 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -597,39 +597,37 @@ export class RampsController extends BaseController< } /** - * Sets the loading state for a resource type. + * Updates a single field (isLoading or error) on a resource state. + * All resources share the same ResourceState structure, so we use + * dynamic property access to avoid duplicating switch statements. * * @param resourceType - The type of resource. - * @param loading - Whether the resource is loading. + * @param field - The field to update ('isLoading' or 'error'). + * @param value - The value to set. */ - #setResourceLoading(resourceType: ResourceType, loading: boolean): void { + #updateResourceField( + resourceType: ResourceType, + field: 'isLoading' | 'error', + value: boolean | string | null, + ): void { this.update((state) => { - switch (resourceType) { - case 'userRegion': - state.userRegion.isLoading = loading; - break; - case 'countries': - state.countries.isLoading = loading; - break; - case 'providers': - state.providers.isLoading = loading; - break; - case 'tokens': - state.tokens.isLoading = loading; - break; - case 'paymentMethods': - state.paymentMethods.isLoading = loading; - break; - case 'quotes': - state.quotes.isLoading = loading; - break; - /* istanbul ignore next: exhaustive switch */ - default: - break; + const resource = state[resourceType]; + if (resource) { + (resource as Record)[field] = value; } }); } + /** + * Sets the loading state for a resource type. + * + * @param resourceType - The type of resource. + * @param loading - Whether the resource is loading. + */ + #setResourceLoading(resourceType: ResourceType, loading: boolean): void { + this.#updateResourceField(resourceType, 'isLoading', loading); + } + /** * Sets the error state for a resource type. * @@ -637,31 +635,7 @@ export class RampsController extends BaseController< * @param error - The error message, or null to clear. */ #setResourceError(resourceType: ResourceType, error: string | null): void { - this.update((state) => { - switch (resourceType) { - case 'userRegion': - state.userRegion.error = error; - break; - case 'countries': - state.countries.error = error; - break; - case 'providers': - state.providers.error = error; - break; - case 'tokens': - state.tokens.error = error; - break; - case 'paymentMethods': - state.paymentMethods.error = error; - break; - case 'quotes': - state.quotes.error = error; - break; - /* istanbul ignore next: exhaustive switch */ - default: - break; - } - }); + this.#updateResourceField(resourceType, 'error', error); } /** From 5740835036d0bb1492220709b8aae0c09ba04a3e Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 09:15:41 -0700 Subject: [PATCH 12/20] chore: changelog --- packages/ramps-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index fb544342934..7590b66c166 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add quotes functionality to RampsController ([#7747](https://github.com/MetaMask/core/pull/7747)) + ### Fixed - Fix `getQuotes()` to trim `assetId` and `walletAddress` parameters before use ([#7793](https://github.com/MetaMask/core/pull/7793)) From 7aa95e9680482eaa55dcf1965112417622b391f0 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 11:20:00 -0700 Subject: [PATCH 13/20] feat: race condition fixes from code review --- .../src/RampsController.test.ts | 69 ++++++++++++++ .../ramps-controller/src/RampsController.ts | 90 ++++++++++++++++--- 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index a388ec2d63e..b957c532af1 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -747,6 +747,75 @@ describe('RampsController', () => { ); }); }); + + it('keeps resource isLoading true until last concurrent request (different cache keys) finishes', async () => { + await withController(async ({ controller }) => { + let resolveFirst: (value: string) => void; + let resolveSecond: (value: string) => void; + const fetcherA = async (): Promise => { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + }; + const fetcherB = async (): Promise => { + return new Promise((resolve) => { + resolveSecond = resolve; + }); + }; + + const promiseA = controller.executeRequest( + 'providers-key-a', + fetcherA, + { resourceType: 'providers' }, + ); + const promiseB = controller.executeRequest( + 'providers-key-b', + fetcherB, + { resourceType: 'providers' }, + ); + + expect(controller.state.providers.isLoading).toBe(true); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolveFirst!('result-a'); + await promiseA; + + expect(controller.state.providers.isLoading).toBe(true); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolveSecond!('result-b'); + await promiseB; + + expect(controller.state.providers.isLoading).toBe(false); + }); + }); + + it('clears resource loading when ref-count hits zero even if map was cleared (defensive)', async () => { + await withController(async ({ controller }) => { + let resolveFetcher: (value: string) => void; + const fetcher = async (): Promise => { + return new Promise((resolve) => { + resolveFetcher = resolve; + }); + }; + + const promise = controller.executeRequest( + 'providers-defensive-key', + fetcher, + { resourceType: 'providers' }, + ); + + expect(controller.state.providers.isLoading).toBe(true); + + controller.clearPendingResourceCountForTest(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolveFetcher!('result'); + await promise; + + expect(controller.state.providers.isLoading).toBe(false); + }); + }); }); describe('abortRequest', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 25af893d4d4..6e1ffa89edb 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -404,6 +404,22 @@ export class RampsController extends BaseController< */ readonly #pendingRequests: Map = new Map(); + /** + * Count of in-flight requests per resource type. + * Used so isLoading is only cleared when the last request for that resource finishes. + */ + #pendingResourceCount: Map = new Map(); + + /** + * Clears the pending resource count map. Used only in tests to exercise the + * defensive path when get() returns undefined in the finally block. + * + * @internal + */ + clearPendingResourceCountForTest(): void { + this.#pendingResourceCount.clear(); + } + /** * Constructs a new {@link RampsController}. * @@ -476,9 +492,14 @@ export class RampsController extends BaseController< // Update state to loading this.#updateRequestState(cacheKey, createLoadingState()); - // Set resource-level loading state (only on cache miss) + // Set resource-level loading state (only on cache miss). Ref-count so concurrent + // requests for the same resource type (different cache keys) keep isLoading true. if (resourceType) { - this.#setResourceLoading(resourceType, true); + const count = this.#pendingResourceCount.get(resourceType) ?? 0; + this.#pendingResourceCount.set(resourceType, count + 1); + if (count === 0) { + this.#setResourceLoading(resourceType, true); + } } // Create the fetch promise @@ -528,9 +549,16 @@ export class RampsController extends BaseController< this.#pendingRequests.delete(cacheKey); } - // Clear resource-level loading state + // Clear resource-level loading state only when no requests for this resource remain if (resourceType) { - this.#setResourceLoading(resourceType, false); + const count = this.#pendingResourceCount.get(resourceType) ?? 0; + const next = Math.max(0, count - 1); + if (next === 0) { + this.#pendingResourceCount.delete(resourceType); + this.#setResourceLoading(resourceType, false); + } else { + this.#pendingResourceCount.set(resourceType, next); + } } } })(); @@ -576,13 +604,23 @@ export class RampsController extends BaseController< #cleanupState(): void { this.update((state) => { state.userRegion.data = null; + state.userRegion.isLoading = false; + state.userRegion.error = null; state.providers.selected = null; + state.providers.data = []; + state.providers.isLoading = false; + state.providers.error = null; state.tokens.selected = null; state.tokens.data = null; - state.providers.data = []; + state.tokens.isLoading = false; + state.tokens.error = null; state.paymentMethods.data = []; state.paymentMethods.selected = null; + state.paymentMethods.isLoading = false; + state.paymentMethods.error = null; state.quotes.data = null; + state.quotes.isLoading = false; + state.quotes.error = null; }); } @@ -705,6 +743,12 @@ export class RampsController extends BaseController< * Sets the user's region manually (without fetching geolocation). * This allows users to override the detected region. * + * Sets userRegion.isLoading to true while the region is being applied and + * tokens/providers are refetched (when the region actually changes), so + * the UI can show a loading indicator when called directly (e.g. from a + * region selector). Clears loading when refetches complete or when no + * refetch is needed. + * * @param region - The region code to set (e.g., "US-CA"). * @param options - Options for cache behavior. * @returns The user region object. @@ -733,31 +777,57 @@ export class RampsController extends BaseController< ); } - // Only cleanup state if region is actually changing const regionChanged = normalizedRegion !== this.state.userRegion.data?.regionCode; - // Set the new region atomically with cleanup to avoid intermediate null state + const needsRefetch = + regionChanged || + !this.state.tokens.data || + this.state.providers.data.length === 0; + + if (needsRefetch) { + this.#setResourceLoading('userRegion', true); + } + this.update((state) => { if (regionChanged) { + state.userRegion.error = null; state.providers.selected = null; + state.providers.data = []; + state.providers.isLoading = false; + state.providers.error = null; state.tokens.selected = null; state.tokens.data = null; - state.providers.data = []; + state.tokens.isLoading = false; + state.tokens.error = null; state.paymentMethods.data = []; state.paymentMethods.selected = null; + state.paymentMethods.isLoading = false; + state.paymentMethods.error = null; state.quotes.data = null; + state.quotes.isLoading = false; + state.quotes.error = null; } state.userRegion.data = userRegion; }); + const refetchPromises: Promise[] = []; if (regionChanged || !this.state.tokens.data) { - this.#fireAndForget( + refetchPromises.push( this.getTokens(userRegion.regionCode, 'buy', options), ); } if (regionChanged || this.state.providers.data.length === 0) { - this.#fireAndForget(this.getProviders(userRegion.regionCode, options)); + refetchPromises.push( + this.getProviders(userRegion.regionCode, options), + ); + } + if (refetchPromises.length > 0) { + this.#fireAndForget( + Promise.all(refetchPromises).finally(() => { + this.#setResourceLoading('userRegion', false); + }), + ); } return userRegion; From 6bac09714815fbf1f5b11ad508af117dca60cf4e Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 11:41:01 -0700 Subject: [PATCH 14/20] feat: race condition fixes from code review --- .../ramps-controller/src/RampsController.ts | 111 +++++++++++------- 1 file changed, 68 insertions(+), 43 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6e1ffa89edb..dbd4d314ae5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -246,6 +246,39 @@ export function getDefaultRampsControllerState(): RampsControllerState { }; } +/** + * Resets region-dependent resources (userRegion, providers, tokens, paymentMethods, quotes). + * Mutates state in place; use from within controller update() for atomic updates. + * + * @param state - The state object to mutate. + * @param options - When clearUserRegionData is true, sets userRegion.data to null (e.g. for full cleanup). + */ +function resetDependentResources( + state: RampsControllerState, + options?: { clearUserRegionData?: boolean }, +): void { + if (options?.clearUserRegionData) { + state.userRegion.data = null; + } + state.userRegion.isLoading = false; + state.userRegion.error = null; + state.providers.selected = null; + state.providers.data = []; + state.providers.isLoading = false; + state.providers.error = null; + state.tokens.selected = null; + state.tokens.data = null; + state.tokens.isLoading = false; + state.tokens.error = null; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; + state.paymentMethods.isLoading = false; + state.paymentMethods.error = null; + state.quotes.data = null; + state.quotes.isLoading = false; + state.quotes.error = null; +} + // === MESSENGER === /** @@ -408,7 +441,13 @@ export class RampsController extends BaseController< * Count of in-flight requests per resource type. * Used so isLoading is only cleared when the last request for that resource finishes. */ - #pendingResourceCount: Map = new Map(); + readonly #pendingResourceCount: Map = new Map(); + + /** + * Count of in-flight setUserRegion refetch batches. + * Used so userRegion.isLoading is only cleared when the last batch's refetches finish (avoids race when region is changed rapidly or when init() clears loading before refetches complete). + */ + #setUserRegionRefetchCount = 0; /** * Clears the pending resource count map. Used only in tests to exercise the @@ -602,26 +641,12 @@ export class RampsController extends BaseController< } #cleanupState(): void { - this.update((state) => { - state.userRegion.data = null; - state.userRegion.isLoading = false; - state.userRegion.error = null; - state.providers.selected = null; - state.providers.data = []; - state.providers.isLoading = false; - state.providers.error = null; - state.tokens.selected = null; - state.tokens.data = null; - state.tokens.isLoading = false; - state.tokens.error = null; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; - state.paymentMethods.isLoading = false; - state.paymentMethods.error = null; - state.quotes.data = null; - state.quotes.isLoading = false; - state.quotes.error = null; - }); + this.update((state) => + resetDependentResources( + state as unknown as RampsControllerState, + { clearUserRegionData: true }, + ), + ); } /** @@ -786,31 +811,21 @@ export class RampsController extends BaseController< this.state.providers.data.length === 0; if (needsRefetch) { - this.#setResourceLoading('userRegion', true); + this.#setUserRegionRefetchCount += 1; + if (this.#setUserRegionRefetchCount === 1) { + this.#setResourceLoading('userRegion', true); + } } this.update((state) => { if (regionChanged) { - state.userRegion.error = null; - state.providers.selected = null; - state.providers.data = []; - state.providers.isLoading = false; - state.providers.error = null; - state.tokens.selected = null; - state.tokens.data = null; - state.tokens.isLoading = false; - state.tokens.error = null; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; - state.paymentMethods.isLoading = false; - state.paymentMethods.error = null; - state.quotes.data = null; - state.quotes.isLoading = false; - state.quotes.error = null; + resetDependentResources(state as unknown as RampsControllerState); } state.userRegion.data = userRegion; }); + + // this code is needed to prevent race conditions in the unlikely event that the user's region is changed rapidly const refetchPromises: Promise[] = []; if (regionChanged || !this.state.tokens.data) { refetchPromises.push( @@ -818,16 +833,22 @@ export class RampsController extends BaseController< ); } if (regionChanged || this.state.providers.data.length === 0) { - refetchPromises.push( - this.getProviders(userRegion.regionCode, options), - ); + refetchPromises.push(this.getProviders(userRegion.regionCode, options)); } if (refetchPromises.length > 0) { this.#fireAndForget( Promise.all(refetchPromises).finally(() => { - this.#setResourceLoading('userRegion', false); + this.#setUserRegionRefetchCount = Math.max( + 0, + this.#setUserRegionRefetchCount - 1, + ); + if (this.#setUserRegionRefetchCount === 0) { + this.#setResourceLoading('userRegion', false); + } }), ); + } else { + this.#setResourceLoading('userRegion', false); } return userRegion; @@ -900,6 +921,7 @@ export class RampsController extends BaseController< async init(options?: ExecuteRequestOptions): Promise { this.#setResourceLoading('userRegion', true); + let setUserRegionCompleted = false; try { await this.getCountries(options); @@ -913,6 +935,7 @@ export class RampsController extends BaseController< } await this.setUserRegion(regionCode, options); + setUserRegionCompleted = true; this.#setResourceError('userRegion', null); } catch (error) { this.#setResourceError( @@ -921,7 +944,9 @@ export class RampsController extends BaseController< ); throw error; } finally { - this.#setResourceLoading('userRegion', false); + if (!setUserRegionCompleted) { + this.#setResourceLoading('userRegion', false); + } } } From 596b80267539b05edf7649f1a9d6de803357e4d5 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 12:05:36 -0700 Subject: [PATCH 15/20] chore: bugbot --- .../ramps-controller/src/RampsController.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index dbd4d314ae5..c94e16b9a77 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -251,7 +251,8 @@ export function getDefaultRampsControllerState(): RampsControllerState { * Mutates state in place; use from within controller update() for atomic updates. * * @param state - The state object to mutate. - * @param options - When clearUserRegionData is true, sets userRegion.data to null (e.g. for full cleanup). + * @param options - Options for the reset. + * @param options.clearUserRegionData - When true, sets userRegion.data to null (e.g. for full cleanup). */ function resetDependentResources( state: RampsControllerState, @@ -642,10 +643,9 @@ export class RampsController extends BaseController< #cleanupState(): void { this.update((state) => - resetDependentResources( - state as unknown as RampsControllerState, - { clearUserRegionData: true }, - ), + resetDependentResources(state as unknown as RampsControllerState, { + clearUserRegionData: true, + }), ); } @@ -812,9 +812,6 @@ export class RampsController extends BaseController< if (needsRefetch) { this.#setUserRegionRefetchCount += 1; - if (this.#setUserRegionRefetchCount === 1) { - this.#setResourceLoading('userRegion', true); - } } this.update((state) => { @@ -824,7 +821,9 @@ export class RampsController extends BaseController< state.userRegion.data = userRegion; }); - + if (needsRefetch && this.#setUserRegionRefetchCount === 1) { + this.#setResourceLoading('userRegion', true); + } // this code is needed to prevent race conditions in the unlikely event that the user's region is changed rapidly const refetchPromises: Promise[] = []; if (regionChanged || !this.state.tokens.data) { From 9239cb2c0e4d73df1a5816b7a6eddd031596502f Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 13:02:37 -0700 Subject: [PATCH 16/20] chore: bugbot --- .../ramps-controller/src/RampsController.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c94e16b9a77..b8ed339d853 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -460,6 +460,19 @@ export class RampsController extends BaseController< this.#pendingResourceCount.clear(); } + #clearPendingResourceCountForDependentResources(): void { + const types: ResourceType[] = [ + 'userRegion', + 'providers', + 'tokens', + 'paymentMethods', + 'quotes', + ]; + for (const resourceType of types) { + this.#pendingResourceCount.delete(resourceType); + } + } + /** * Constructs a new {@link RampsController}. * @@ -642,6 +655,7 @@ export class RampsController extends BaseController< } #cleanupState(): void { + this.#clearPendingResourceCountForDependentResources(); this.update((state) => resetDependentResources(state as unknown as RampsControllerState, { clearUserRegionData: true, @@ -814,6 +828,9 @@ export class RampsController extends BaseController< this.#setUserRegionRefetchCount += 1; } + if (regionChanged) { + this.#clearPendingResourceCountForDependentResources(); + } this.update((state) => { if (regionChanged) { resetDependentResources(state as unknown as RampsControllerState); From 3f1beec2a834eda4c6586b4e21b9473050d90dd0 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 15:33:24 -0700 Subject: [PATCH 17/20] chore: bugbot --- .../src/RampsController.test.ts | 46 ++++++++++--- .../ramps-controller/src/RampsController.ts | 67 ++++++++++++++++--- packages/ramps-controller/src/RequestCache.ts | 5 ++ packages/ramps-controller/src/index.ts | 1 + 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index b957c532af1..09fabd22018 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'fs'; +import * as path from 'path'; + import { deriveStateFromMetadata } from '@metamask/base-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -11,7 +14,10 @@ import type { ResourceState, UserRegion, } from './RampsController'; -import { RampsController } from './RampsController'; +import { + RampsController, + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, +} from './RampsController'; import type { Country, TokensResponse, @@ -34,6 +40,35 @@ import type { import { RequestStatus } from './RequestCache'; describe('RampsController', () => { + describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { + it('includes every RampsService action that RampsController calls', () => { + const controllerPath = path.join(__dirname, 'RampsController.ts'); + const source = fs.readFileSync(controllerPath, 'utf-8'); + const callPattern = + /messenger\.call\s*\(\s*['"](RampsService:[^'"]+)['"]/g; + const calledActions = new Set(); + let match: RegExpExecArray | null; + while ((match = callPattern.exec(source)) !== null) { + calledActions.add(match[1]); + } + const requiredSet = new Set( + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS as readonly string[], + ); + const missing = [...calledActions].filter((a) => !requiredSet.has(a)); + const extra = [...requiredSet].filter((a) => !calledActions.has(a)); + if (missing.length > 0) { + throw new Error( + `RampsController calls these actions but they are not in RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: ${missing.join(', ')}. Add them so hosts (e.g. mobile) delegate them.`, + ); + } + if (extra.length > 0) { + throw new Error( + `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS contains actions not called by RampsController: ${extra.join(', ')}. Remove them or use them.`, + ); + } + }); + }); + describe('constructor', () => { it('uses default state when no state is provided', async () => { await withController(({ controller }) => { @@ -4399,14 +4434,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { }); rootMessenger.delegate({ messenger, - actions: [ - 'RampsService:getGeolocation', - 'RampsService:getCountries', - 'RampsService:getTokens', - 'RampsService:getProviders', - 'RampsService:getPaymentMethods', - 'RampsService:getQuotes', - ], + actions: [...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS], }); return messenger; } diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b8ed339d853..66f575013e4 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -19,6 +19,7 @@ import type { Quote, GetQuotesParams, RampsToken, + RampsServiceActions, } from './RampsService'; import type { RampsServiceGetGeolocationAction, @@ -55,6 +56,22 @@ import { */ export const controllerName = 'RampsController'; +/** + * RampsService action types that RampsController calls via the messenger. + * Any host (e.g. mobile) that creates a RampsController messenger must delegate + * these actions from the root messenger so the controller can function. + */ +export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: ReadonlyArray< + RampsServiceActions['type'] +> = [ + 'RampsService:getGeolocation', + 'RampsService:getCountries', + 'RampsService:getTokens', + 'RampsService:getProviders', + 'RampsService:getPaymentMethods', + 'RampsService:getQuotes', +]; + /** * Default TTL for quotes requests (15 seconds). * Quotes are time-sensitive and should have a shorter cache duration. @@ -570,9 +587,12 @@ export class RampsController extends BaseController< createSuccessState(data as Json, lastFetchedAt), ); - // Clear error on success if (resourceType) { - this.#setResourceError(resourceType, null); + const isCurrent = + !options?.isResultCurrent || options.isResultCurrent(); + if (isCurrent) { + this.#setResourceError(resourceType, null); + } } return data; @@ -589,9 +609,12 @@ export class RampsController extends BaseController< createErrorState(errorMessage, lastFetchedAt), ); - // Set resource-level error if (resourceType) { - this.#setResourceError(resourceType, errorMessage); + const isCurrent = + !options?.isResultCurrent || options.isResultCurrent(); + if (isCurrent) { + this.#setResourceError(resourceType, errorMessage); + } } throw error; @@ -838,7 +861,7 @@ export class RampsController extends BaseController< state.userRegion.data = userRegion; }); - if (needsRefetch && this.#setUserRegionRefetchCount === 1) { + if (needsRefetch) { this.#setResourceLoading('userRegion', true); } // this code is needed to prevent race conditions in the unlikely event that the user's region is changed rapidly @@ -1048,7 +1071,13 @@ export class RampsController extends BaseController< }, ); }, - { ...options, resourceType: 'tokens' }, + { + ...options, + resourceType: 'tokens', + isResultCurrent: () => + this.state.userRegion.data?.regionCode === undefined || + this.state.userRegion.data?.regionCode === normalizedRegion, + }, ); this.update((state) => { @@ -1167,7 +1196,13 @@ export class RampsController extends BaseController< }, ); }, - { ...options, resourceType: 'providers' }, + { + ...options, + resourceType: 'providers', + isResultCurrent: () => + this.state.userRegion.data?.regionCode === undefined || + this.state.userRegion.data?.regionCode === normalizedRegion, + }, ); this.update((state) => { @@ -1239,7 +1274,20 @@ export class RampsController extends BaseController< provider: providerToUse, }); }, - { ...options, resourceType: 'paymentMethods' }, + { + ...options, + resourceType: 'paymentMethods', + isResultCurrent: () => { + const regionMatch = + this.state.userRegion.data?.regionCode === undefined || + this.state.userRegion.data?.regionCode === normalizedRegion; + const tokenMatch = + (this.state.tokens.selected?.assetId ?? '') === assetIdToUse; + const providerMatch = + (this.state.providers.selected?.id ?? '') === providerToUse; + return regionMatch && tokenMatch && providerMatch; + }, + }, ); this.update((state) => { @@ -1412,6 +1460,9 @@ export class RampsController extends BaseController< forceRefresh: options.forceRefresh, ttl: options.ttl ?? DEFAULT_QUOTES_TTL, resourceType: 'quotes', + isResultCurrent: () => + this.state.userRegion.data?.regionCode === undefined || + this.state.userRegion.data?.regionCode === normalizedRegion, }, ); diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 6a4eff06a04..cf8839a7c51 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -148,6 +148,11 @@ export type ExecuteRequestOptions = { ttl?: number; /** Resource type to update loading/error states for */ resourceType?: ResourceType; + /** + * When provided, resource-level error is only set/cleared if this returns true. + * Used to avoid applying stale errors after e.g. region or selection changes. + */ + isResultCurrent?: () => boolean; }; /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 00089017be8..1d357a4aaea 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -12,6 +12,7 @@ export type { export { RampsController, getDefaultRampsControllerState, + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController'; export type { RampsServiceActions, From 8a28108792e8fbcb17ed94115aaf89b72d74e0c9 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 18:38:52 -0700 Subject: [PATCH 18/20] chore: lint and fix test --- .../src/RampsController.test.ts | 24 +++++++------------ .../ramps-controller/src/RampsController.ts | 22 +++++++++-------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 09fabd22018..cbfbcfee061 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1,6 +1,3 @@ -import * as fs from 'fs'; -import * as path from 'path'; - import { deriveStateFromMetadata } from '@metamask/base-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -8,6 +5,8 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import * as fs from 'fs'; +import * as path from 'path'; import type { RampsControllerMessenger, @@ -41,11 +40,12 @@ import { RequestStatus } from './RequestCache'; describe('RampsController', () => { describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { - it('includes every RampsService action that RampsController calls', () => { + it('includes every RampsService action that RampsController calls', async () => { + expect.hasAssertions(); const controllerPath = path.join(__dirname, 'RampsController.ts'); - const source = fs.readFileSync(controllerPath, 'utf-8'); + const source = await fs.promises.readFile(controllerPath, 'utf-8'); const callPattern = - /messenger\.call\s*\(\s*['"](RampsService:[^'"]+)['"]/g; + /messenger\.call\s*\(\s*['"](RampsService:[^'"]+)['"]/gu; const calledActions = new Set(); let match: RegExpExecArray | null; while ((match = callPattern.exec(source)) !== null) { @@ -56,16 +56,8 @@ describe('RampsController', () => { ); const missing = [...calledActions].filter((a) => !requiredSet.has(a)); const extra = [...requiredSet].filter((a) => !calledActions.has(a)); - if (missing.length > 0) { - throw new Error( - `RampsController calls these actions but they are not in RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: ${missing.join(', ')}. Add them so hosts (e.g. mobile) delegate them.`, - ); - } - if (extra.length > 0) { - throw new Error( - `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS contains actions not called by RampsController: ${extra.join(', ')}. Remove them or use them.`, - ); - } + expect(missing).toHaveLength(0); + expect(extra).toHaveLength(0); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 66f575013e4..670dd445134 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -61,16 +61,15 @@ export const controllerName = 'RampsController'; * Any host (e.g. mobile) that creates a RampsController messenger must delegate * these actions from the root messenger so the controller can function. */ -export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: ReadonlyArray< - RampsServiceActions['type'] -> = [ - 'RampsService:getGeolocation', - 'RampsService:getCountries', - 'RampsService:getTokens', - 'RampsService:getProviders', - 'RampsService:getPaymentMethods', - 'RampsService:getQuotes', -]; +export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly RampsServiceActions['type'][] = + [ + 'RampsService:getGeolocation', + 'RampsService:getCountries', + 'RampsService:getTokens', + 'RampsService:getProviders', + 'RampsService:getPaymentMethods', + 'RampsService:getQuotes', + ]; /** * Default TTL for quotes requests (15 seconds). @@ -588,6 +587,9 @@ export class RampsController extends BaseController< ); if (resourceType) { + // We need the extra logic because there are two situations where weโ€™re allowed to clear the error: + // No callback โ†’ always clear + // Callback present โ†’ clear only when isResultCurrent() returns true. const isCurrent = !options?.isResultCurrent || options.isResultCurrent(); if (isCurrent) { From 00ec4cb4cb40c5ea733a0a75c1c96f111a92d4c1 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 19:28:55 -0700 Subject: [PATCH 19/20] feat: removes loading error state from user regino --- .../src/RampsController.test.ts | 213 ++++++++---------- .../ramps-controller/src/RampsController.ts | 147 ++++-------- packages/ramps-controller/src/RequestCache.ts | 1 - .../ramps-controller/src/selectors.test.ts | 2 +- 4 files changed, 141 insertions(+), 222 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index cbfbcfee061..9ecaaf72051 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -97,12 +97,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, - "userRegion": Object { - "data": null, - "error": null, - "isLoading": false, - "selected": null, - }, + "userRegion": null, } `); }); @@ -110,13 +105,13 @@ describe('RampsController', () => { it('accepts initial state', async () => { const givenState = { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }; await withController( { options: { state: givenState } }, ({ controller }) => { - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.providers.selected).toBeNull(); expect(controller.state.tokens.data).toBeNull(); expect(controller.state.requests).toStrictEqual({}); @@ -159,12 +154,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, - "userRegion": Object { - "data": null, - "error": null, - "isLoading": false, - "selected": null, - }, + "userRegion": null, } `); }); @@ -172,7 +162,7 @@ describe('RampsController', () => { it('always resets requests cache on initialization', async () => { const givenState = { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), requests: { someKey: { status: RequestStatus.SUCCESS, @@ -306,7 +296,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('fr')), + userRegion: createMockUserRegion('fr'), }, }, }, @@ -332,7 +322,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('fr')), + userRegion: createMockUserRegion('fr'), }, }, }, @@ -358,7 +348,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -371,7 +361,7 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.providers.data).toStrictEqual([]); await controller.getProviders('US-ca'); @@ -403,7 +393,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), providers: createResourceState(existingProviders, null), }, }, @@ -417,7 +407,7 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.providers.data).toStrictEqual( existingProviders, ); @@ -524,12 +514,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, - "userRegion": Object { - "data": null, - "error": null, - "isLoading": false, - "selected": null, - }, + "userRegion": null, } `); }); @@ -569,12 +554,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, - "userRegion": Object { - "data": null, - "error": null, - "isLoading": false, - "selected": null, - }, + "userRegion": null, } `); }); @@ -608,12 +588,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, - "userRegion": Object { - "data": null, - "error": null, - "isLoading": false, - "selected": null, - }, + "userRegion": null, } `); }); @@ -660,12 +635,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, - "userRegion": Object { - "data": null, - "error": null, - "isLoading": false, - "selected": null, - }, + "userRegion": null, } `); }); @@ -1193,7 +1163,7 @@ describe('RampsController', () => { expect(controller.state.countries.data).toStrictEqual( createMockCountries(), ); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); }); }); @@ -1203,7 +1173,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(existingRegion), + userRegion: existingRegion, }, }, }, @@ -1218,7 +1188,7 @@ describe('RampsController', () => { expect(controller.state.countries.data).toStrictEqual( createMockCountries(), ); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); }, ); }); @@ -1264,7 +1234,7 @@ describe('RampsController', () => { options: { state: { countries: createResourceState(createMockCountries()), - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokens, null), providers: createResourceState( mockProviders, @@ -1290,7 +1260,7 @@ describe('RampsController', () => { await controller.init(); // Verify persisted state is preserved - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.tokens.data).toStrictEqual(mockTokens); expect(controller.state.providers.data).toStrictEqual(mockProviders); expect(controller.state.providers.selected).toStrictEqual( @@ -1332,7 +1302,7 @@ describe('RampsController', () => { }); }); - it('sets userRegionError to Unknown error when error has no message', async () => { + it('rejects when init fails with error that has no message', async () => { await withController(async ({ controller, rootMessenger }) => { const errorWithoutMessage = Object.assign(new Error(), { code: 'ERR_NO_MESSAGE', @@ -1349,7 +1319,6 @@ describe('RampsController', () => { await expect(controller.init()).rejects.toMatchObject({ code: 'ERR_NO_MESSAGE', }); - expect(controller.state.userRegion.error).toBe('Unknown error'); }); }); }); @@ -1360,7 +1329,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -1424,9 +1393,9 @@ describe('RampsController', () => { await controller.setUserRegion('US-CA'); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); - expect(controller.state.userRegion.data?.state?.stateId).toBe('CA'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state?.stateId).toBe('CA'); }, ); }); @@ -1550,7 +1519,7 @@ describe('RampsController', () => { options: { state: { countries: createResourceState(createMockCountries()), - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokens, null), providers: createResourceState( mockProviders, @@ -1573,7 +1542,7 @@ describe('RampsController', () => { await controller.setUserRegion('US-ca'); // Verify persisted state is preserved - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.tokens.data).toStrictEqual(mockTokens); expect(controller.state.providers.data).toStrictEqual(mockProviders); expect(controller.state.providers.selected).toStrictEqual( @@ -1633,7 +1602,7 @@ describe('RampsController', () => { options: { state: { countries: createResourceState(createMockCountries()), - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokens, mockSelectedToken), providers: createResourceState( mockProviders, @@ -1656,7 +1625,7 @@ describe('RampsController', () => { await controller.setUserRegion('FR'); // Verify persisted state is cleared - expect(controller.state.userRegion.data?.regionCode).toBe('fr'); + expect(controller.state.userRegion?.regionCode).toBe('fr'); expect(controller.state.tokens.data).toBeNull(); expect(controller.state.providers.data).toStrictEqual([]); expect(controller.state.providers.selected).toBeNull(); @@ -1705,8 +1674,8 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion.data?.country.name).toBe( + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.country.name).toBe( 'United States', ); }, @@ -1746,8 +1715,8 @@ describe('RampsController', () => { await controller.setUserRegion('fr'); - expect(controller.state.userRegion.data?.regionCode).toBe('fr'); - expect(controller.state.userRegion.data?.country.name).toBe('France'); + expect(controller.state.userRegion?.regionCode).toBe('fr'); + expect(controller.state.userRegion?.country.name).toBe('France'); }, ); }); @@ -1792,8 +1761,8 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion.data?.country.name).toBe( + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.country.name).toBe( 'United States', ); }, @@ -1825,7 +1794,7 @@ describe('RampsController', () => { 'Region "xx" not found in countries data', ); - expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.userRegion).toBeNull(); }, ); }); @@ -1836,7 +1805,7 @@ describe('RampsController', () => { 'No countries found. Cannot set user region without valid country information.', ); - expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.userRegion).toBeNull(); expect(controller.state.tokens.data).toBeNull(); }); }); @@ -1847,7 +1816,7 @@ describe('RampsController', () => { options: { state: { countries: createResourceState([]), - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -1856,7 +1825,7 @@ describe('RampsController', () => { 'No countries found. Cannot set user region without valid country information.', ); - expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.userRegion).toBeNull(); expect(controller.state.tokens.data).toBeNull(); }, ); @@ -1901,9 +1870,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-ny'); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ny'); - expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); - expect(controller.state.userRegion.data?.state?.name).toBe( + expect(controller.state.userRegion?.regionCode).toBe('us-ny'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state?.name).toBe( 'New York', ); }, @@ -1949,9 +1918,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); - expect(controller.state.userRegion.data?.state?.name).toBe( + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state?.name).toBe( 'California', ); }, @@ -2002,9 +1971,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-xx'); - expect(controller.state.userRegion.data?.regionCode).toBe('us-xx'); - expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); - expect(controller.state.userRegion.data?.state).toBeNull(); + expect(controller.state.userRegion?.regionCode).toBe('us-xx'); + expect(controller.state.userRegion?.country.isoCode).toBe('US'); + expect(controller.state.userRegion?.state).toBeNull(); }, ); }); @@ -2044,7 +2013,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), providers: createResourceState([mockProvider], null), }, }, @@ -2079,7 +2048,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), providers: createResourceState([mockProvider], mockProvider), paymentMethods: createResourceState( [mockPaymentMethod], @@ -2132,7 +2101,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -2151,7 +2120,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), providers: createResourceState([mockProvider], null), }, }, @@ -2185,7 +2154,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), providers: createResourceState( [mockProvider, newProvider], mockProvider, @@ -2254,7 +2223,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokensResponse, null), }, }, @@ -2279,7 +2248,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokensResponse, mockToken), paymentMethods: createResourceState( [mockPaymentMethod], @@ -2324,7 +2293,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -2341,7 +2310,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokensResponse, null), }, }, @@ -2361,7 +2330,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(mockTokensResponse, null), }, }, @@ -2406,7 +2375,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(tokensWithBoth, mockToken), paymentMethods: createResourceState( [mockPaymentMethod], @@ -2639,7 +2608,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('fr')), + userRegion: createMockUserRegion('fr'), }, }, }, @@ -2677,7 +2646,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('fr')), + userRegion: createMockUserRegion('fr'), }, }, }, @@ -2707,7 +2676,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -2724,7 +2693,7 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.tokens.data).toBeNull(); await controller.getTokens('US-ca'); @@ -2764,7 +2733,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(existingTokens, null), }, }, @@ -2782,7 +2751,7 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.tokens.data).toStrictEqual(existingTokens); await controller.getTokens('fr'); @@ -2909,7 +2878,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), paymentMethods: createResourceState( [mockPaymentMethod1, mockPaymentMethod2], mockPaymentMethod1, @@ -2958,7 +2927,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), paymentMethods: createResourceState( [removedPaymentMethod], removedPaymentMethod, @@ -2999,7 +2968,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), paymentMethods: createResourceState([], null), tokens: createResourceState(null, mockSelectedToken), providers: createResourceState([], mockSelectedProvider), @@ -3035,7 +3004,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(null, mockSelectedToken), providers: createResourceState([], mockSelectedProvider), }, @@ -3067,7 +3036,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), }, }, }, @@ -3114,7 +3083,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(regionWithoutCurrency), + userRegion: regionWithoutCurrency, }, }, }, @@ -3146,7 +3115,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(null, mockToken), }, }, @@ -3195,7 +3164,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), providers: createResourceState([], testProvider), }, }, @@ -3229,7 +3198,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('fr')), + userRegion: createMockUserRegion('fr'), }, }, }, @@ -3271,7 +3240,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), paymentMethods: createResourceState( [removedPaymentMethod], removedPaymentMethod, @@ -3316,7 +3285,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(null, null), providers: createResourceState([], null), }, @@ -3392,7 +3361,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState( { topTokens: [tokenA, tokenB], @@ -3506,7 +3475,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(null, null), providers: createResourceState([providerA, providerB], providerA), paymentMethods: createResourceState([], null), @@ -3599,7 +3568,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us-ca')), + userRegion: createMockUserRegion('us-ca'), tokens: createResourceState(null, token), providers: createResourceState([], provider), paymentMethods: createResourceState([], null), @@ -3747,7 +3716,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -3791,7 +3760,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -3844,18 +3813,18 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState({ + userRegion: { country: { isoCode: 'US', name: 'United States', flag: '๐Ÿ‡บ๐Ÿ‡ธ', - currency: '', // No currency + currency: '', phone: { prefix: '+1', placeholder: '', template: '' }, supported: { buy: true, sell: true }, }, state: null, regionCode: 'us', - }), + }, }, }, }, @@ -3877,7 +3846,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState([], null), }, }, @@ -3899,7 +3868,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -3948,7 +3917,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -3989,7 +3958,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -4030,7 +3999,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -4099,7 +4068,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), paymentMethods: createResourceState( [ { @@ -4141,7 +4110,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createResourceState(createMockUserRegion('us')), + userRegion: createMockUserRegion('us'), countries: createResourceState([ { isoCode: 'US', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 670dd445134..146fd236d59 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -127,11 +127,10 @@ export type ResourceState = { */ export type RampsControllerState = { /** - * The user's region state with data, loading, and error. - * Data contains the full country and state objects. + * The user's region (full country and state objects). * Initially set via geolocation fetch, but can be manually changed by the user. */ - userRegion: ResourceState; + userRegion: UserRegion | null; /** * Countries resource state with data, loading, and error. * Data contains the list of countries available for ramp actions. @@ -243,7 +242,7 @@ function createDefaultResourceState( */ export function getDefaultRampsControllerState(): RampsControllerState { return { - userRegion: createDefaultResourceState(null), + userRegion: null, countries: createDefaultResourceState([]), providers: createDefaultResourceState( [], @@ -268,17 +267,15 @@ export function getDefaultRampsControllerState(): RampsControllerState { * * @param state - The state object to mutate. * @param options - Options for the reset. - * @param options.clearUserRegionData - When true, sets userRegion.data to null (e.g. for full cleanup). + * @param options.clearUserRegionData - When true, sets userRegion to null (e.g. for full cleanup). */ function resetDependentResources( state: RampsControllerState, options?: { clearUserRegionData?: boolean }, ): void { if (options?.clearUserRegionData) { - state.userRegion.data = null; + state.userRegion = null; } - state.userRegion.isLoading = false; - state.userRegion.error = null; state.providers.selected = null; state.providers.data = []; state.providers.isLoading = false; @@ -460,12 +457,6 @@ export class RampsController extends BaseController< */ readonly #pendingResourceCount: Map = new Map(); - /** - * Count of in-flight setUserRegion refetch batches. - * Used so userRegion.isLoading is only cleared when the last batch's refetches finish (avoids race when region is changed rapidly or when init() clears loading before refetches complete). - */ - #setUserRegionRefetchCount = 0; - /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -478,7 +469,6 @@ export class RampsController extends BaseController< #clearPendingResourceCountForDependentResources(): void { const types: ResourceType[] = [ - 'userRegion', 'providers', 'tokens', 'paymentMethods', @@ -807,12 +797,6 @@ export class RampsController extends BaseController< * Sets the user's region manually (without fetching geolocation). * This allows users to override the detected region. * - * Sets userRegion.isLoading to true while the region is being applied and - * tokens/providers are refetched (when the region actually changes), so - * the UI can show a loading indicator when called directly (e.g. from a - * region selector). Clears loading when refetches complete or when no - * refetch is needed. - * * @param region - The region code to set (e.g., "US-CA"). * @param options - Options for cache behavior. * @returns The user region object. @@ -842,17 +826,13 @@ export class RampsController extends BaseController< } const regionChanged = - normalizedRegion !== this.state.userRegion.data?.regionCode; + normalizedRegion !== this.state.userRegion?.regionCode; const needsRefetch = regionChanged || !this.state.tokens.data || this.state.providers.data.length === 0; - if (needsRefetch) { - this.#setUserRegionRefetchCount += 1; - } - if (regionChanged) { this.#clearPendingResourceCountForDependentResources(); } @@ -860,36 +840,24 @@ export class RampsController extends BaseController< if (regionChanged) { resetDependentResources(state as unknown as RampsControllerState); } - state.userRegion.data = userRegion; + state.userRegion = userRegion; }); if (needsRefetch) { - this.#setResourceLoading('userRegion', true); - } - // this code is needed to prevent race conditions in the unlikely event that the user's region is changed rapidly - const refetchPromises: Promise[] = []; - if (regionChanged || !this.state.tokens.data) { - refetchPromises.push( - this.getTokens(userRegion.regionCode, 'buy', options), - ); - } - if (regionChanged || this.state.providers.data.length === 0) { - refetchPromises.push(this.getProviders(userRegion.regionCode, options)); - } - if (refetchPromises.length > 0) { - this.#fireAndForget( - Promise.all(refetchPromises).finally(() => { - this.#setUserRegionRefetchCount = Math.max( - 0, - this.#setUserRegionRefetchCount - 1, - ); - if (this.#setUserRegionRefetchCount === 0) { - this.#setResourceLoading('userRegion', false); - } - }), - ); - } else { - this.#setResourceLoading('userRegion', false); + const refetchPromises: Promise[] = []; + if (regionChanged || !this.state.tokens.data) { + refetchPromises.push( + this.getTokens(userRegion.regionCode, 'buy', options), + ); + } + if (regionChanged || this.state.providers.data.length === 0) { + refetchPromises.push( + this.getProviders(userRegion.regionCode, options), + ); + } + if (refetchPromises.length > 0) { + this.#fireAndForget(Promise.all(refetchPromises)); + } } return userRegion; @@ -917,7 +885,7 @@ export class RampsController extends BaseController< return; } - const regionCode = this.state.userRegion.data?.regionCode; + const regionCode = this.state.userRegion?.regionCode; if (!regionCode) { throw new Error( 'Region is required. Cannot set selected provider without valid region information.', @@ -960,39 +928,22 @@ export class RampsController extends BaseController< * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - this.#setResourceLoading('userRegion', true); - - let setUserRegionCompleted = false; - try { - await this.getCountries(options); - - let regionCode = this.state.userRegion.data?.regionCode; - regionCode ??= await this.messenger.call('RampsService:getGeolocation'); + await this.getCountries(options); - if (!regionCode) { - throw new Error( - 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', - ); - } + let regionCode = this.state.userRegion?.regionCode; + regionCode ??= await this.messenger.call('RampsService:getGeolocation'); - await this.setUserRegion(regionCode, options); - setUserRegionCompleted = true; - this.#setResourceError('userRegion', null); - } catch (error) { - this.#setResourceError( - 'userRegion', - (error as Error)?.message ?? 'Unknown error', + if (!regionCode) { + throw new Error( + 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', ); - throw error; - } finally { - if (!setUserRegionCompleted) { - this.#setResourceLoading('userRegion', false); - } } + + await this.setUserRegion(regionCode, options); } hydrateState(options?: ExecuteRequestOptions): void { - const regionCode = this.state.userRegion.data?.regionCode; + const regionCode = this.state.userRegion?.regionCode; if (!regionCode) { throw new Error( 'Region code is required. Cannot hydrate state without valid region information.', @@ -1046,7 +997,7 @@ export class RampsController extends BaseController< provider?: string | string[]; }, ): Promise { - const regionToUse = region ?? this.state.userRegion.data?.regionCode; + const regionToUse = region ?? this.state.userRegion?.regionCode; if (!regionToUse) { throw new Error( @@ -1077,13 +1028,13 @@ export class RampsController extends BaseController< ...options, resourceType: 'tokens', isResultCurrent: () => - this.state.userRegion.data?.regionCode === undefined || - this.state.userRegion.data?.regionCode === normalizedRegion, + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion, }, ); this.update((state) => { - const userRegionCode = state.userRegion.data?.regionCode; + const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { state.tokens.data = tokens; @@ -1111,7 +1062,7 @@ export class RampsController extends BaseController< return; } - const regionCode = this.state.userRegion.data?.regionCode; + const regionCode = this.state.userRegion?.regionCode; if (!regionCode) { throw new Error( 'Region is required. Cannot set selected token without valid region information.', @@ -1167,7 +1118,7 @@ export class RampsController extends BaseController< payments?: string | string[]; }, ): Promise<{ providers: Provider[] }> { - const regionToUse = region ?? this.state.userRegion.data?.regionCode; + const regionToUse = region ?? this.state.userRegion?.regionCode; if (!regionToUse) { throw new Error( @@ -1202,13 +1153,13 @@ export class RampsController extends BaseController< ...options, resourceType: 'providers', isResultCurrent: () => - this.state.userRegion.data?.regionCode === undefined || - this.state.userRegion.data?.regionCode === normalizedRegion, + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion, }, ); this.update((state) => { - const userRegionCode = state.userRegion.data?.regionCode; + const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { state.providers.data = providers; @@ -1237,9 +1188,9 @@ export class RampsController extends BaseController< provider?: string; }, ): Promise { - const regionCode = region ?? this.state.userRegion.data?.regionCode ?? null; + const regionCode = region ?? this.state.userRegion?.regionCode ?? null; const fiatToUse = - options?.fiat ?? this.state.userRegion.data?.country?.currency ?? null; + options?.fiat ?? this.state.userRegion?.country?.currency ?? null; const assetIdToUse = options?.assetId ?? this.state.tokens.selected?.assetId ?? ''; const providerToUse = @@ -1281,8 +1232,8 @@ export class RampsController extends BaseController< resourceType: 'paymentMethods', isResultCurrent: () => { const regionMatch = - this.state.userRegion.data?.regionCode === undefined || - this.state.userRegion.data?.regionCode === normalizedRegion; + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion; const tokenMatch = (this.state.tokens.selected?.assetId ?? '') === assetIdToUse; const providerMatch = @@ -1386,9 +1337,9 @@ export class RampsController extends BaseController< ttl?: number; }): Promise { const regionToUse = - options.region ?? this.state.userRegion.data?.regionCode; + options.region ?? this.state.userRegion?.regionCode; const fiatToUse = - options.fiat ?? this.state.userRegion.data?.country?.currency; + options.fiat ?? this.state.userRegion?.country?.currency; const paymentMethodsToUse = options.paymentMethods ?? this.state.paymentMethods.data.map((pm: PaymentMethod) => pm.id); @@ -1463,13 +1414,13 @@ export class RampsController extends BaseController< ttl: options.ttl ?? DEFAULT_QUOTES_TTL, resourceType: 'quotes', isResultCurrent: () => - this.state.userRegion.data?.regionCode === undefined || - this.state.userRegion.data?.regionCode === normalizedRegion, + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion, }, ); this.update((state) => { - const userRegionCode = state.userRegion.data?.regionCode; + const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { state.quotes.data = response; diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index cf8839a7c51..08b1c1bcaf7 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -4,7 +4,6 @@ import type { Json } from '@metamask/utils'; * Types of resources that can have loading/error states. */ export type ResourceType = - | 'userRegion' | 'countries' | 'providers' | 'tokens' diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index ce2d07b9ed6..2030574fc16 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -31,7 +31,7 @@ function createMockRampsState( overrides: Partial = {}, ): RampsControllerState { return { - userRegion: createDefaultResourceState(null), + userRegion: null, countries: createDefaultResourceState([]), providers: createDefaultResourceState([], null), tokens: createDefaultResourceState(null, null), From 2aeb120485c20818bc734b1d135c26223224b9f8 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 2 Feb 2026 19:55:15 -0700 Subject: [PATCH 20/20] chore: formatting --- packages/ramps-controller/src/RampsController.test.ts | 8 ++------ packages/ramps-controller/src/RampsController.ts | 6 ++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 9ecaaf72051..76ed13c196a 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -1872,9 +1872,7 @@ describe('RampsController', () => { expect(controller.state.userRegion?.regionCode).toBe('us-ny'); expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe( - 'New York', - ); + expect(controller.state.userRegion?.state?.name).toBe('New York'); }, ); }); @@ -1920,9 +1918,7 @@ describe('RampsController', () => { expect(controller.state.userRegion?.regionCode).toBe('us-ca'); expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe( - 'California', - ); + expect(controller.state.userRegion?.state?.name).toBe('California'); }, ); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 146fd236d59..f72c6b2df7d 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -1336,10 +1336,8 @@ export class RampsController extends BaseController< forceRefresh?: boolean; ttl?: number; }): Promise { - const regionToUse = - options.region ?? this.state.userRegion?.regionCode; - const fiatToUse = - options.fiat ?? this.state.userRegion?.country?.currency; + const regionToUse = options.region ?? this.state.userRegion?.regionCode; + const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency; const paymentMethodsToUse = options.paymentMethods ?? this.state.paymentMethods.data.map((pm: PaymentMethod) => pm.id);