diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 88c98435331..7590b66c166 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ 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 diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 39c6d71bb15..76ed13c196a 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -5,9 +5,18 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import * as fs from 'fs'; +import * as path from 'path'; -import type { RampsControllerMessenger, UserRegion } from './RampsController'; -import { RampsController } from './RampsController'; +import type { + RampsControllerMessenger, + ResourceState, + UserRegion, +} from './RampsController'; +import { + RampsController, + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, +} from './RampsController'; import type { Country, TokensResponse, @@ -30,20 +39,64 @@ import type { import { RequestStatus } from './RequestCache'; describe('RampsController', () => { + describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { + it('includes every RampsService action that RampsController calls', async () => { + expect.hasAssertions(); + const controllerPath = path.join(__dirname, 'RampsController.ts'); + const source = await fs.promises.readFile(controllerPath, 'utf-8'); + const callPattern = + /messenger\.call\s*\(\s*['"](RampsService:[^'"]+)['"]/gu; + 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)); + expect(missing).toHaveLength(0); + expect(extra).toHaveLength(0); + }); + }); + describe('constructor', () => { it('uses default state when no state is provided', async () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "quotes": 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, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "userRegion": null, } `); @@ -59,8 +112,8 @@ describe('RampsController', () => { { 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.providers.selected).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); expect(controller.state.requests).toStrictEqual({}); }, ); @@ -70,15 +123,37 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "quotes": 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, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "userRegion": null, } `); @@ -152,12 +227,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); }); }); @@ -218,7 +293,13 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { + options: { + state: { + userRegion: createMockUserRegion('fr'), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -238,7 +319,13 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { + options: { + state: { + userRegion: createMockUserRegion('fr'), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -258,7 +345,13 @@ describe('RampsController', () => { it('updates providers when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + }, + }, + }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getProviders', @@ -269,11 +362,11 @@ describe('RampsController', () => { ); expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.providers.data).toStrictEqual([]); await controller.getProviders('US-ca'); - expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.providers.data).toStrictEqual(mockProviders); }, ); }); @@ -301,7 +394,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - providers: existingProviders, + providers: createResourceState(existingProviders, null), }, }, }, @@ -315,11 +408,15 @@ describe('RampsController', () => { ); expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.providers).toStrictEqual(existingProviders); + expect(controller.state.providers.data).toStrictEqual( + existingProviders, + ); await controller.getProviders('fr'); - expect(controller.state.providers).toStrictEqual(existingProviders); + expect(controller.state.providers.data).toStrictEqual( + existingProviders, + ); }, ); }); @@ -386,15 +483,37 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "quotes": 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, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "userRegion": null, } `); @@ -411,13 +530,30 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": 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": null, } `); @@ -434,9 +570,24 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "providers": Array [], - "tokens": 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": null, } `); @@ -453,15 +604,37 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "quotes": 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, + }, + "quotes": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "requests": Object {}, - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, "userRegion": null, } `); @@ -571,6 +744,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', () => { @@ -824,146 +1066,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[] = [ { @@ -1000,7 +1102,7 @@ describe('RampsController', () => { async () => mockCountries, ); - expect(controller.state.countries).toStrictEqual([]); + expect(controller.state.countries.data).toStrictEqual([]); const countries = await controller.getCountries(); @@ -1039,7 +1141,7 @@ describe('RampsController', () => { }, ] `); - expect(controller.state.countries).toStrictEqual(mockCountries); + expect(controller.state.countries.data).toStrictEqual(mockCountries); }); }); }); @@ -1058,7 +1160,9 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries).toStrictEqual(createMockCountries()); + expect(controller.state.countries.data).toStrictEqual( + createMockCountries(), + ); expect(controller.state.userRegion?.regionCode).toBe('us-ca'); }); }); @@ -1081,7 +1185,7 @@ 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'); @@ -1129,11 +1233,13 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, + tokens: createResourceState(mockTokens, null), + providers: createResourceState( + mockProviders, + mockSelectedProvider, + ), }, }, }, @@ -1155,9 +1261,9 @@ describe('RampsController', () => { // 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.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.providers.selected).toStrictEqual( mockSelectedProvider, ); }, @@ -1195,6 +1301,26 @@ describe('RampsController', () => { ); }); }); + + 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', + message: undefined, + }) as Error & { code: string }; + + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => { + throw errorWithoutMessage; + }, + ); + + await expect(controller.init()).rejects.toMatchObject({ + code: 'ERR_NO_MESSAGE', + }); + }); + }); }); describe('hydrateState', () => { @@ -1251,7 +1377,7 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), }, }, }, @@ -1302,7 +1428,7 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), }, }, }, @@ -1332,22 +1458,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(); }, ); }); @@ -1392,11 +1518,13 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, + tokens: createResourceState(mockTokens, null), + providers: createResourceState( + mockProviders, + mockSelectedProvider, + ), }, }, }, @@ -1415,9 +1543,9 @@ describe('RampsController', () => { // 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.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.providers.selected).toStrictEqual( mockSelectedProvider, ); }, @@ -1473,12 +1601,13 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, - selectedToken: mockSelectedToken, + tokens: createResourceState(mockTokens, mockSelectedToken), + providers: createResourceState( + mockProviders, + mockSelectedProvider, + ), }, }, }, @@ -1497,10 +1626,10 @@ describe('RampsController', () => { // 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.tokens.data).toBeNull(); + expect(controller.state.providers.data).toStrictEqual([]); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.tokens.selected).toBeNull(); }, ); }); @@ -1529,7 +1658,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1570,7 +1699,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1616,7 +1745,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1656,7 +1785,7 @@ describe('RampsController', () => { { options: { state: { - countries, + countries: createResourceState(countries), }, }, }, @@ -1677,7 +1806,7 @@ describe('RampsController', () => { ); expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); }); }); @@ -1686,7 +1815,7 @@ describe('RampsController', () => { { options: { state: { - countries: [], + countries: createResourceState([]), userRegion: createMockUserRegion('us-ca'), }, }, @@ -1697,7 +1826,7 @@ describe('RampsController', () => { ); expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); }, ); }); @@ -1725,7 +1854,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStateId, + countries: createResourceState(countriesWithStateId), }, }, }, @@ -1771,7 +1900,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStateId, + countries: createResourceState(countriesWithStateId), }, }, }, @@ -1822,7 +1951,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStates, + countries: createResourceState(countriesWithStates), }, }, }, @@ -1881,7 +2010,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], + providers: createResourceState([mockProvider], null), }, }, }, @@ -1891,11 +2020,13 @@ 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, + ); }, ); }); @@ -1914,27 +2045,30 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], - selectedProvider: mockProvider, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + 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(); }, ); }); @@ -1944,7 +2078,7 @@ describe('RampsController', () => { { options: { state: { - providers: [mockProvider], + providers: createResourceState([mockProvider], null), }, }, }, @@ -1983,7 +2117,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], + providers: createResourceState([mockProvider], null), }, }, }, @@ -2016,11 +2150,15 @@ describe('RampsController', () => { { options: { state: { - selectedProvider: mockProvider, userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider, newProvider], - paymentMethods: [existingPaymentMethod], - selectedPaymentMethod: existingPaymentMethod, + providers: createResourceState( + [mockProvider, newProvider], + mockProvider, + ), + paymentMethods: createResourceState( + [existingPaymentMethod], + existingPaymentMethod, + ), }, }, }, @@ -2030,21 +2168,23 @@ 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(); }, ); }); @@ -2080,7 +2220,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2090,11 +2230,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); }, ); }); @@ -2105,23 +2245,24 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, - selectedToken: mockToken, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + 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(); }, ); }); @@ -2131,7 +2272,7 @@ describe('RampsController', () => { { options: { state: { - tokens: mockTokensResponse, + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2166,7 +2307,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2186,7 +2327,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2230,11 +2371,12 @@ describe('RampsController', () => { { options: { state: { - selectedToken: mockToken, userRegion: createMockUserRegion('us-ca'), - tokens: tokensWithBoth, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + tokens: createResourceState(tokensWithBoth, mockToken), + paymentMethods: createResourceState( + [mockPaymentMethod], + mockPaymentMethod, + ), }, }, }, @@ -2244,18 +2386,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(); }, ); }); @@ -2307,7 +2449,7 @@ describe('RampsController', () => { ) => mockTokens, ); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); const tokens = await controller.getTokens('us-ca', 'buy'); @@ -2346,7 +2488,7 @@ describe('RampsController', () => { ], } `); - expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); }); }); @@ -2459,7 +2601,13 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { + options: { + state: { + userRegion: createMockUserRegion('fr'), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2491,7 +2639,13 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { + options: { + state: { + userRegion: createMockUserRegion('fr'), + }, + }, + }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2515,7 +2669,13 @@ describe('RampsController', () => { it('updates tokens when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + }, + }, + }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', @@ -2530,11 +2690,11 @@ describe('RampsController', () => { ); expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); await controller.getTokens('US-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); }, ); }); @@ -2570,7 +2730,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - tokens: existingTokens, + tokens: createResourceState(existingTokens, null), }, }, }, @@ -2588,11 +2748,11 @@ describe('RampsController', () => { ); expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(existingTokens); + expect(controller.state.tokens.data).toStrictEqual(existingTokens); await controller.getTokens('fr'); - expect(controller.state.tokens).toStrictEqual(existingTokens); + expect(controller.state.tokens.data).toStrictEqual(existingTokens); }, ); }); @@ -2715,10 +2875,12 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: mockPaymentMethod1, - paymentMethods: [mockPaymentMethod1, mockPaymentMethod2], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + paymentMethods: createResourceState( + [mockPaymentMethod1, mockPaymentMethod2], + mockPaymentMethod1, + ), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2728,7 +2890,7 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod1, ); @@ -2737,10 +2899,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, ]); @@ -2762,10 +2924,12 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: removedPaymentMethod, - paymentMethods: [removedPaymentMethod], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + paymentMethods: createResourceState( + [removedPaymentMethod], + removedPaymentMethod, + ), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2775,7 +2939,7 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( removedPaymentMethod, ); @@ -2784,10 +2948,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, ]); @@ -2801,10 +2965,9 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: null, - paymentMethods: [], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + paymentMethods: createResourceState([], null), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2814,17 +2977,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, ]); @@ -2838,8 +3001,8 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2849,14 +3012,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, ]); @@ -2949,7 +3112,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedToken: mockToken, + tokens: createResourceState(null, mockToken), }, }, }, @@ -2998,7 +3161,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedProvider: testProvider, + providers: createResourceState([], testProvider), }, }, }, @@ -3074,10 +3237,12 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: removedPaymentMethod, - paymentMethods: [removedPaymentMethod], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + paymentMethods: createResourceState( + [removedPaymentMethod], + removedPaymentMethod, + ), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -3092,8 +3257,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([]); }, ); }); @@ -3117,8 +3282,8 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedToken: null, - selectedProvider: null, + tokens: createResourceState(null, null), + providers: createResourceState([], null), }, }, }, @@ -3193,13 +3358,15 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedToken: tokenA, - selectedProvider: null, - paymentMethods: [], - tokens: { - topTokens: [tokenA, tokenB], - allTokens: [tokenA, tokenB], - }, + tokens: createResourceState( + { + topTokens: [tokenA, tokenB], + allTokens: [tokenA, tokenB], + }, + tokenA, + ), + providers: createResourceState([], null), + paymentMethods: createResourceState([], null), }, }, }, @@ -3240,8 +3407,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); @@ -3305,10 +3472,9 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedToken: null, - selectedProvider: providerA, - paymentMethods: [], - providers: [providerA, providerB], + tokens: createResourceState(null, null), + providers: createResourceState([providerA, providerB], providerA), + paymentMethods: createResourceState([], null), }, }, }, @@ -3349,8 +3515,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); @@ -3399,9 +3565,9 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us-ca'), - selectedToken: token, - selectedProvider: provider, - paymentMethods: [], + tokens: createResourceState(null, token), + providers: createResourceState([], provider), + paymentMethods: createResourceState([], null), }, }, }, @@ -3416,9 +3582,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, ); }, @@ -3440,16 +3606,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, ); }, @@ -3461,19 +3627,21 @@ 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(); }, ); }); @@ -3493,7 +3661,7 @@ describe('RampsController', () => { { options: { state: { - paymentMethods: [mockPaymentMethod], + paymentMethods: createResourceState([mockPaymentMethod], null), }, }, }, @@ -3508,89 +3676,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)); - }); - }); - }); - describe('getQuotes', () => { const mockQuotesResponse: QuotesResponse = { success: [ @@ -3628,15 +3713,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3646,7 +3734,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', @@ -3656,7 +3744,9 @@ 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, + ); }, ); }); @@ -3667,15 +3757,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3721,7 +3814,7 @@ describe('RampsController', () => { isoCode: 'US', name: 'United States', flag: '๐Ÿ‡บ๐Ÿ‡ธ', - currency: '', // No currency + currency: '', phone: { prefix: '+1', placeholder: '', template: '' }, supported: { buy: true, sell: true }, }, @@ -3750,7 +3843,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us'), - paymentMethods: [], + paymentMethods: createResourceState([], null), }, }, }, @@ -3772,15 +3865,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3818,15 +3914,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3856,15 +3955,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3894,15 +3996,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3960,15 +4065,18 @@ 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', - }, - ], + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -3999,7 +4107,7 @@ describe('RampsController', () => { options: { state: { userRegion: createMockUserRegion('us'), - countries: [ + countries: createResourceState([ { isoCode: 'US', flag: '๐Ÿ‡บ๐Ÿ‡ธ', @@ -4016,16 +4124,19 @@ describe('RampsController', () => { currency: 'EUR', supported: { buy: true, sell: true }, }, - ], - paymentMethods: [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], + ]), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), }, }, }, @@ -4060,7 +4171,7 @@ describe('RampsController', () => { await quotesPromise; // Quotes should not be updated because region changed - expect(controller.state.quotes).toBeNull(); + expect(controller.state.quotes.data).toBeNull(); }, ); }); @@ -4114,80 +4225,6 @@ describe('RampsController', () => { }); }); }); - - describe('triggerGetQuotes', () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; - - it('calls getQuotes without throwing', async () => { - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], - }, - }, - }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => mockQuotesResponse, - ); - - // Should not throw - controller.triggerGetQuotes({ - assetId: 'eip155:1/slip44:60', - amount: 100, - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - }); - - // Wait for the async operation to complete - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(controller.state.quotes).toStrictEqual(mockQuotesResponse); - }, - ); - }); - - it('does not throw when getQuotes fails', async () => { - await withController(async ({ controller }) => { - // Should not throw even when getQuotes would fail (no region) - expect(() => { - controller.triggerGetQuotes({ - assetId: 'eip155:1/slip44:60', - amount: 100, - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - paymentMethods: ['/payments/debit-credit-card'], - }); - }).not.toThrow(); - - // Wait for the async operation to complete - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - }); - }); }); /** @@ -4279,6 +4316,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, +): ResourceState { + 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. @@ -4335,14 +4391,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 8570492df2e..f72c6b2df7d 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, @@ -33,6 +34,7 @@ import type { RequestState, ExecuteRequestOptions, PendingRequest, + ResourceType, } from './RequestCache'; import { DEFAULT_REQUEST_CACHE_TTL, @@ -54,6 +56,21 @@ 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: readonly 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. @@ -81,54 +98,64 @@ 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 (full country and state objects). + * Initially set via geolocation fetch, but can be manually changed by the user. + */ + userRegion: UserRegion | null; /** - * 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. @@ -146,12 +173,6 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, - selectedProvider: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, countries: { persist: true, includeInDebugSnapshot: true, @@ -170,24 +191,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, @@ -202,6 +211,27 @@ const rampsControllerMetadata = { }, } 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 @@ -213,18 +243,56 @@ const rampsControllerMetadata = { export function getDefaultRampsControllerState(): RampsControllerState { return { userRegion: null, - selectedProvider: null, - countries: [], - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + countries: createDefaultResourceState([]), + providers: createDefaultResourceState( + [], + null, + ), + tokens: createDefaultResourceState< + TokensResponse | null, + RampsToken | null + >(null, null), + paymentMethods: createDefaultResourceState< + PaymentMethod[], + PaymentMethod | null + >([], null), + quotes: createDefaultResourceState(null), requests: {}, }; } +/** + * 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 - Options for the reset. + * @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 = 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 === /** @@ -383,6 +451,34 @@ 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. + */ + readonly #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(); + } + + #clearPendingResourceCountForDependentResources(): void { + const types: ResourceType[] = [ + 'providers', + 'tokens', + 'paymentMethods', + 'quotes', + ]; + for (const resourceType of types) { + this.#pendingResourceCount.delete(resourceType); + } + } + /** * Constructs a new {@link RampsController}. * @@ -450,10 +546,21 @@ 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). Ref-count so concurrent + // requests for the same resource type (different cache keys) keep isLoading true. + if (resourceType) { + const count = this.#pendingResourceCount.get(resourceType) ?? 0; + this.#pendingResourceCount.set(resourceType, count + 1); + if (count === 0) { + this.#setResourceLoading(resourceType, true); + } + } + // Create the fetch promise const promise = (async (): Promise => { try { @@ -468,6 +575,18 @@ export class RampsController extends BaseController< cacheKey, createSuccessState(data as Json, lastFetchedAt), ); + + 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) { + this.#setResourceError(resourceType, null); + } + } + return data; } catch (error) { // Don't update state if aborted @@ -475,12 +594,21 @@ 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), ); + + if (resourceType) { + const isCurrent = + !options?.isResultCurrent || options.isResultCurrent(); + if (isCurrent) { + this.#setResourceError(resourceType, errorMessage); + } + } + throw error; } finally { // Only delete if this is still our entry (not replaced by a new request) @@ -488,6 +616,18 @@ export class RampsController extends BaseController< if (currentPending?.abortController === abortController) { this.#pendingRequests.delete(cacheKey); } + + // Clear resource-level loading state only when no requests for this resource remain + if (resourceType) { + 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); + } + } } })(); @@ -530,18 +670,66 @@ export class RampsController extends BaseController< } #cleanupState(): void { + this.#clearPendingResourceCountForDependentResources(); + this.update((state) => + resetDependentResources(state as unknown as RampsControllerState, { + clearUserRegionData: true, + }), + ); + } + + /** + * 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); + } + + /** + * 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 field - The field to update ('isLoading' or 'error'). + * @param value - The value to set. + */ + #updateResourceField( + resourceType: ResourceType, + field: 'isLoading' | 'error', + value: boolean | string | null, + ): 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; + 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. + * + * @param resourceType - The type of resource. + * @param error - The error message, or null to clear. + */ + #setResourceError(resourceType: ResourceType, error: string | null): void { + this.#updateResourceField(resourceType, 'error', error); + } + /** * Gets the state of a specific cached request. * @@ -620,15 +808,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(); @@ -637,30 +825,39 @@ export class RampsController extends BaseController< ); } - // Only cleanup state if region is actually changing const regionChanged = normalizedRegion !== this.state.userRegion?.regionCode; - // Set the new region atomically with cleanup to avoid intermediate null state + const needsRefetch = + regionChanged || + !this.state.tokens.data || + this.state.providers.data.length === 0; + + if (regionChanged) { + this.#clearPendingResourceCountForDependentResources(); + } this.update((state) => { if (regionChanged) { - state.selectedProvider = null; - state.selectedToken = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - state.quotes = null; + resetDependentResources(state as unknown as RampsControllerState); } 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); - } - if (regionChanged || this.state.providers.length === 0) { - this.triggerGetProviders(userRegion.regionCode, options); + if (needsRefetch) { + 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; @@ -681,9 +878,9 @@ 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; } @@ -695,7 +892,7 @@ export class RampsController extends BaseController< ); } - 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.', @@ -710,17 +907,14 @@ 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; }); - // 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 }), + ); } /** @@ -756,8 +950,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)); } /** @@ -776,11 +970,11 @@ export class RampsController extends BaseController< async () => { return this.messenger.call('RampsService:getCountries'); }, - options, + { ...options, resourceType: 'countries' }, ); this.update((state) => { - state.countries = countries; + state.countries.data = countries; }); return countries; @@ -830,14 +1024,20 @@ export class RampsController extends BaseController< }, ); }, - options, + { + ...options, + resourceType: 'tokens', + isResultCurrent: () => + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion, + }, ); this.update((state) => { const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.tokens = tokens; + state.tokens.data = tokens; } }); @@ -855,9 +1055,9 @@ 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; } @@ -869,7 +1069,7 @@ export class RampsController extends BaseController< ); } - 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.', @@ -887,14 +1087,14 @@ 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.triggerGetPaymentMethods(regionCode, { - assetId: token.assetId, - }); + this.#fireAndForget( + this.getPaymentMethods(regionCode, { assetId: token.assetId }), + ); } /** @@ -949,14 +1149,20 @@ export class RampsController extends BaseController< }, ); }, - options, + { + ...options, + resourceType: 'providers', + isResultCurrent: () => + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion, + }, ); this.update((state) => { const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.providers = providers; + state.providers.data = providers; } }); @@ -986,9 +1192,9 @@ export class RampsController extends BaseController< const fiatToUse = options?.fiat ?? this.state.userRegion?.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( @@ -1021,12 +1227,25 @@ export class RampsController extends BaseController< provider: providerToUse, }); }, - options, + { + ...options, + resourceType: 'paymentMethods', + isResultCurrent: () => { + const regionMatch = + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.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) => { - 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; @@ -1035,14 +1254,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; } } }); @@ -1060,12 +1279,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.', @@ -1082,7 +1301,7 @@ export class RampsController extends BaseController< } this.update((state) => { - state.selectedPaymentMethod = paymentMethod; + state.paymentMethods.selected = paymentMethod; }); } @@ -1121,7 +1340,7 @@ export class RampsController extends BaseController< const fiatToUse = options.fiat ?? this.state.userRegion?.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) { @@ -1191,6 +1410,10 @@ export class RampsController extends BaseController< { forceRefresh: options.forceRefresh, ttl: options.ttl ?? DEFAULT_QUOTES_TTL, + resourceType: 'quotes', + isResultCurrent: () => + this.state.userRegion?.regionCode === undefined || + this.state.userRegion?.regionCode === normalizedRegion, }, ); @@ -1198,7 +1421,7 @@ export class RampsController extends BaseController< const userRegionCode = state.userRegion?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.quotes = response; + state.quotes.data = response; } }); @@ -1215,126 +1438,4 @@ export class RampsController extends BaseController< getWidgetUrl(quote: Quote): string | null { return quote.quote?.widgetUrl ?? null; } - - // ============================================================ - // 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 - }); - } - - /** - * Triggers fetching quotes without throwing. - * - * @param options - The parameters for fetching quotes. - * @param options.region - User's region code. If not provided, uses userRegion from state. - * @param options.fiat - Fiat currency code. If not provided, uses userRegion currency. - * @param options.assetId - CAIP-19 cryptocurrency identifier. - * @param options.amount - The amount (in fiat for buy, crypto for sell). - * @param options.walletAddress - The destination wallet address. - * @param options.paymentMethods - Array of payment method IDs. If not provided, uses paymentMethods from state. - * @param options.provider - Optional provider ID to filter quotes. - * @param options.redirectUrl - Optional redirect URL after order completion. - * @param options.action - The ramp action type. Defaults to 'buy'. - * @param options.forceRefresh - Whether to bypass cache. - * @param options.ttl - Custom TTL for this request. - */ - triggerGetQuotes(options: { - region?: string; - fiat?: string; - assetId: string; - amount: number; - walletAddress: string; - paymentMethods?: string[]; - provider?: string; - redirectUrl?: string; - action?: RampAction; - forceRefresh?: boolean; - ttl?: number; - }): void { - this.getQuotes(options).catch(() => { - // Error stored in state - }); - } } diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 7abcea71727..08b1c1bcaf7 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 = + | 'countries' + | 'providers' + | 'tokens' + | 'paymentMethods' + | 'quotes'; + /** * Status of a cached request. */ @@ -135,6 +145,13 @@ export type ExecuteRequestOptions = { forceRefresh?: boolean; /** Custom TTL for this request in milliseconds */ 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 65acb614b10..1d357a4aaea 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,10 +7,12 @@ export type { RampsControllerStateChangeEvent, RampsControllerOptions, UserRegion, + ResourceState, } from './RampsController'; export { RampsController, getDefaultRampsControllerState, + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController'; export type { RampsServiceActions, @@ -34,6 +36,8 @@ export type { QuoteCustomAction, QuotesResponse, GetQuotesParams, + RampsToken, + TokensResponse, } from './RampsService'; export { RampsService, @@ -52,6 +56,7 @@ export type { RequestState, ExecuteRequestOptions, PendingRequest, + ResourceType, } from './RequestCache'; export { RequestStatus, diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 30d21ada5eb..2030574fc16 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -10,6 +10,38 @@ type TestRootState = { ramps: RampsControllerState; }; +function createDefaultResourceState( + data: TData, + selected: TSelected = null as TSelected, +): { + data: TData; + selected: TSelected; + isLoading: boolean; + error: null; +} { + return { + data, + selected, + isLoading: false, + error: null, + }; +} + +function createMockRampsState( + overrides: Partial = {}, +): RampsControllerState { + return { + userRegion: 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,20 +55,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, - }, + }), }; const result = selector(state); @@ -59,20 +82,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result = selector(state); @@ -98,20 +112,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, - }, + }), }; const result = selector(state); @@ -133,18 +138,7 @@ describe('createRequestSelector', () => { ); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, - requests: {}, - }, + ramps: createMockRampsState(), }; const result = selector(state); @@ -191,20 +185,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -222,40 +207,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, + 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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest2, }, - }, + }), }; const result2 = selector(state2); @@ -274,20 +241,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -309,20 +267,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, + ramps: createMockRampsState({ requests: { 'getData:[]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -343,20 +292,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, - }, + }), }; const loadingResult = selector(loadingState); @@ -365,20 +305,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const successResult = selector(successState); @@ -395,20 +326,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const successResult = selector(successState); @@ -416,20 +338,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, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, - }, + }), }; const errorResult = selector(errorState); @@ -452,16 +365,7 @@ describe('createRequestSelector', () => { ); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -469,7 +373,7 @@ describe('createRequestSelector', () => { ), 'getPrice:["US"]': createSuccessState(100, Date.now()), }, - }, + }), }; const result1 = selector1(state); @@ -492,16 +396,7 @@ describe('createRequestSelector', () => { ); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': createSuccessState( ['ETH'], @@ -512,7 +407,7 @@ describe('createRequestSelector', () => { Date.now(), ), }, - }, + }), }; const result1 = selector1(state);