diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 72b29347f15..ba07f053a7a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add quotes functionality to RampsController ([#7747](https://github.com/MetaMask/core/pull/7747)) +- Add `quotesLoading` and `quotesError` state properties for quotes resource loading/error tracking ([#7779](https://github.com/MetaMask/core/pull/7779)) ## [5.0.0] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 33c7d42cf93..0fc28d8d29b 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -35,16 +35,43 @@ describe('RampsController', () => { 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, - "userRegion": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -52,15 +79,15 @@ describe('RampsController', () => { it('accepts initial state', async () => { const givenState = { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }; await withController( { options: { state: givenState } }, ({ controller }) => { - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.selectedProvider).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); expect(controller.state.requests).toStrictEqual({}); }, ); @@ -70,16 +97,43 @@ 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, - "userRegion": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -87,7 +141,7 @@ describe('RampsController', () => { it('always resets requests cache on initialization', async () => { const givenState = { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), requests: { someKey: { status: RequestStatus.SUCCESS, @@ -152,12 +206,12 @@ describe('RampsController', () => { async (_regionCode: string) => ({ providers: mockProviders }), ); - expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.providers.data).toStrictEqual([]); const result = await controller.getProviders('us-ca'); expect(result.providers).toStrictEqual(mockProviders); - expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.providers.data).toStrictEqual(mockProviders); }); }); @@ -218,7 +272,7 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -238,7 +292,7 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -258,7 +312,7 @@ describe('RampsController', () => { it('updates providers when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')) } } }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getProviders', @@ -268,12 +322,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.providers).toStrictEqual([]); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.providers.data).toStrictEqual([]); await controller.getProviders('US-ca'); - expect(controller.state.providers).toStrictEqual(mockProviders); + expect(controller.state.providers.data).toStrictEqual(mockProviders); }, ); }); @@ -300,8 +354,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: existingProviders, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState(existingProviders, null), }, }, }, @@ -314,12 +368,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.providers).toStrictEqual(existingProviders); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.providers.data).toStrictEqual(existingProviders); await controller.getProviders('fr'); - expect(controller.state.providers).toStrictEqual(existingProviders); + expect(controller.state.providers.data).toStrictEqual(existingProviders); }, ); }); @@ -386,16 +440,43 @@ 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, - "userRegion": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -411,14 +492,36 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "paymentMethods": Array [], - "providers": Array [], - "selectedPaymentMethod": null, - "selectedProvider": null, - "selectedToken": null, - "tokens": null, - "userRegion": null, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "paymentMethods": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -434,10 +537,30 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` Object { - "countries": Array [], - "providers": Array [], - "tokens": null, - "userRegion": null, + "countries": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "providers": Object { + "data": Array [], + "error": null, + "isLoading": false, + "selected": null, + }, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -453,16 +576,43 @@ 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, - "userRegion": null, + "tokens": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userRegion": Object { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -824,146 +974,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 +1010,7 @@ describe('RampsController', () => { async () => mockCountries, ); - expect(controller.state.countries).toStrictEqual([]); + expect(controller.state.countries.data).toStrictEqual([]); const countries = await controller.getCountries(); @@ -1039,7 +1049,7 @@ describe('RampsController', () => { }, ] `); - expect(controller.state.countries).toStrictEqual(mockCountries); + expect(controller.state.countries.data).toStrictEqual(mockCountries); }); }); }); @@ -1058,8 +1068,8 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries).toStrictEqual(createMockCountries()); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.countries.data).toStrictEqual(createMockCountries()); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); }); }); @@ -1069,7 +1079,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: existingRegion, + userRegion: createResourceState(existingRegion), }, }, }, @@ -1081,10 +1091,10 @@ describe('RampsController', () => { await controller.init(); - expect(controller.state.countries).toStrictEqual( + expect(controller.state.countries.data).toStrictEqual( createMockCountries(), ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); }, ); }); @@ -1129,11 +1139,10 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, + countries: createResourceState(createMockCountries()), + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokens, null), + providers: createResourceState(mockProviders, mockSelectedProvider), }, }, }, @@ -1154,10 +1163,10 @@ describe('RampsController', () => { await controller.init(); // Verify persisted state is preserved - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - expect(controller.state.selectedProvider).toStrictEqual( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.providers.selected).toStrictEqual( mockSelectedProvider, ); }, @@ -1195,6 +1204,27 @@ describe('RampsController', () => { ); }); }); + + it('sets userRegionError to Unknown error when error has no message', async () => { + await withController(async ({ controller, rootMessenger }) => { + const errorWithoutMessage = Object.assign(new Error(), { + code: 'ERR_NO_MESSAGE', + message: undefined, + }) as Error & { code: string }; + + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => { + throw errorWithoutMessage; + }, + ); + + await expect(controller.init()).rejects.toMatchObject({ + code: 'ERR_NO_MESSAGE', + }); + expect(controller.state.userRegion.error).toBe('Unknown error'); + }); + }); }); describe('hydrateState', () => { @@ -1203,7 +1233,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -1251,7 +1281,7 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), }, }, }, @@ -1267,9 +1297,9 @@ describe('RampsController', () => { await controller.setUserRegion('US-CA'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.stateId).toBe('CA'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state?.stateId).toBe('CA'); }, ); }); @@ -1302,7 +1332,7 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), + countries: createResourceState(createMockCountries()), }, }, }, @@ -1332,22 +1362,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 +1422,10 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, + countries: createResourceState(createMockCountries()), + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokens, null), + providers: createResourceState(mockProviders, mockSelectedProvider), }, }, }, @@ -1414,10 +1443,10 @@ describe('RampsController', () => { await controller.setUserRegion('US-ca'); // Verify persisted state is preserved - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); - expect(controller.state.providers).toStrictEqual(mockProviders); - expect(controller.state.selectedProvider).toStrictEqual( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); + expect(controller.state.providers.data).toStrictEqual(mockProviders); + expect(controller.state.providers.selected).toStrictEqual( mockSelectedProvider, ); }, @@ -1473,12 +1502,10 @@ describe('RampsController', () => { { options: { state: { - countries: createMockCountries(), - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokens, - providers: mockProviders, - selectedProvider: mockSelectedProvider, - selectedToken: mockSelectedToken, + countries: createResourceState(createMockCountries()), + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokens, mockSelectedToken), + providers: createResourceState(mockProviders, mockSelectedProvider), }, }, }, @@ -1496,11 +1523,11 @@ describe('RampsController', () => { await controller.setUserRegion('FR'); // Verify persisted state is cleared - expect(controller.state.userRegion?.regionCode).toBe('fr'); - expect(controller.state.tokens).toBeNull(); - expect(controller.state.providers).toStrictEqual([]); - expect(controller.state.selectedProvider).toBeNull(); - expect(controller.state.selectedToken).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('fr'); + expect(controller.state.tokens.data).toBeNull(); + expect(controller.state.providers.data).toStrictEqual([]); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.tokens.selected).toBeNull(); }, ); }); @@ -1529,7 +1556,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1545,8 +1572,8 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.name).toBe( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.name).toBe( 'United States', ); }, @@ -1570,7 +1597,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1586,8 +1613,8 @@ describe('RampsController', () => { await controller.setUserRegion('fr'); - expect(controller.state.userRegion?.regionCode).toBe('fr'); - expect(controller.state.userRegion?.country.name).toBe('France'); + expect(controller.state.userRegion.data?.regionCode).toBe('fr'); + expect(controller.state.userRegion.data?.country.name).toBe('France'); }, ); }); @@ -1616,7 +1643,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithId, + countries: createResourceState(countriesWithId), }, }, }, @@ -1632,8 +1659,8 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.name).toBe( + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.name).toBe( 'United States', ); }, @@ -1656,7 +1683,7 @@ describe('RampsController', () => { { options: { state: { - countries, + countries: createResourceState(countries), }, }, }, @@ -1665,7 +1692,7 @@ describe('RampsController', () => { 'Region "xx" not found in countries data', ); - expect(controller.state.userRegion).toBeNull(); + expect(controller.state.userRegion.data).toBeNull(); }, ); }); @@ -1676,8 +1703,8 @@ describe('RampsController', () => { 'No countries found. Cannot set user region without valid country information.', ); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); }); }); @@ -1686,8 +1713,8 @@ describe('RampsController', () => { { options: { state: { - countries: [], - userRegion: createMockUserRegion('us-ca'), + countries: createResourceState([]), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -1696,8 +1723,8 @@ describe('RampsController', () => { 'No countries found. Cannot set user region without valid country information.', ); - expect(controller.state.userRegion).toBeNull(); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data).toBeNull(); + expect(controller.state.tokens.data).toBeNull(); }, ); }); @@ -1725,7 +1752,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStateId, + countries: createResourceState(countriesWithStateId), }, }, }, @@ -1741,9 +1768,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-ny'); - expect(controller.state.userRegion?.regionCode).toBe('us-ny'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe('New York'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ny'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state?.name).toBe('New York'); }, ); }); @@ -1771,7 +1798,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStateId, + countries: createResourceState(countriesWithStateId), }, }, }, @@ -1787,9 +1814,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-ca'); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state?.name).toBe('California'); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state?.name).toBe('California'); }, ); }); @@ -1822,7 +1849,7 @@ describe('RampsController', () => { { options: { state: { - countries: countriesWithStates, + countries: createResourceState(countriesWithStates), }, }, }, @@ -1838,9 +1865,9 @@ describe('RampsController', () => { await controller.setUserRegion('us-xx'); - expect(controller.state.userRegion?.regionCode).toBe('us-xx'); - expect(controller.state.userRegion?.country.isoCode).toBe('US'); - expect(controller.state.userRegion?.state).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('us-xx'); + expect(controller.state.userRegion.data?.country.isoCode).toBe('US'); + expect(controller.state.userRegion.data?.state).toBeNull(); }, ); }); @@ -1880,8 +1907,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider], null), }, }, }, @@ -1891,11 +1918,11 @@ describe('RampsController', () => { async () => ({ payments: [] }), ); - expect(controller.state.selectedProvider).toBeNull(); + expect(controller.state.providers.selected).toBeNull(); controller.setSelectedProvider(mockProvider.id); - expect(controller.state.selectedProvider).toStrictEqual(mockProvider); + expect(controller.state.providers.selected).toStrictEqual(mockProvider); }, ); }); @@ -1913,28 +1940,26 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], - selectedProvider: mockProvider, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider], mockProvider), + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, ({ controller }) => { - expect(controller.state.selectedProvider).toStrictEqual(mockProvider); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.providers.selected).toStrictEqual(mockProvider); + expect(controller.state.paymentMethods.data).toStrictEqual([ mockPaymentMethod, ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); controller.setSelectedProvider(null); - expect(controller.state.selectedProvider).toBeNull(); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.providers.selected).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -1944,7 +1969,7 @@ describe('RampsController', () => { { options: { state: { - providers: [mockProvider], + providers: createResourceState([mockProvider], null), }, }, }, @@ -1963,7 +1988,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -1982,8 +2007,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider], + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider], null), }, }, }, @@ -2016,11 +2041,9 @@ describe('RampsController', () => { { options: { state: { - selectedProvider: mockProvider, - userRegion: createMockUserRegion('us-ca'), - providers: [mockProvider, newProvider], - paymentMethods: [existingPaymentMethod], - selectedPaymentMethod: existingPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([mockProvider, newProvider], mockProvider), + paymentMethods: createResourceState([existingPaymentMethod], existingPaymentMethod), }, }, }, @@ -2030,21 +2053,21 @@ describe('RampsController', () => { async () => ({ payments: [] }), ); - expect(controller.state.paymentMethods).toStrictEqual([ + expect(controller.state.paymentMethods.data).toStrictEqual([ existingPaymentMethod, ]); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( existingPaymentMethod, ); controller.setSelectedProvider(newProvider.id); - expect(controller.state.selectedProvider).toStrictEqual(newProvider); - expect(controller.state.selectedProvider?.id).toBe( + expect(controller.state.providers.selected).toStrictEqual(newProvider); + expect(controller.state.providers.selected?.id).toBe( '/providers/ramp-network-staging', ); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -2079,8 +2102,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2090,11 +2113,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); }, ); }); @@ -2104,24 +2127,22 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, - selectedToken: mockToken, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, mockToken), + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, ({ controller }) => { - expect(controller.state.selectedToken).toStrictEqual(mockToken); - expect(controller.state.paymentMethods).toHaveLength(1); - expect(controller.state.selectedPaymentMethod).not.toBeNull(); + expect(controller.state.tokens.selected).toStrictEqual(mockToken); + expect(controller.state.paymentMethods.data).toHaveLength(1); + expect(controller.state.paymentMethods.selected).not.toBeNull(); controller.setSelectedToken(undefined); - expect(controller.state.selectedToken).toBeNull(); - expect(controller.state.paymentMethods).toStrictEqual([]); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.tokens.selected).toBeNull(); + expect(controller.state.paymentMethods.data).toStrictEqual([]); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -2131,7 +2152,7 @@ describe('RampsController', () => { { options: { state: { - tokens: mockTokensResponse, + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2148,7 +2169,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -2165,8 +2186,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2185,8 +2206,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: mockTokensResponse, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(mockTokensResponse, null), }, }, }, @@ -2230,11 +2251,9 @@ describe('RampsController', () => { { options: { state: { - selectedToken: mockToken, - userRegion: createMockUserRegion('us-ca'), - tokens: tokensWithBoth, - paymentMethods: [mockPaymentMethod], - selectedPaymentMethod: mockPaymentMethod, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(tokensWithBoth, mockToken), + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, @@ -2244,18 +2263,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 +2326,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 +2365,7 @@ describe('RampsController', () => { ], } `); - expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); }); }); @@ -2459,7 +2478,7 @@ describe('RampsController', () => { it('uses userRegion from state when region is not provided', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2491,7 +2510,7 @@ describe('RampsController', () => { it('prefers provided region over userRegion in state', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('fr') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('fr')) } } }, async ({ controller, rootMessenger }) => { let receivedRegion: string | undefined; rootMessenger.registerActionHandler( @@ -2515,7 +2534,7 @@ describe('RampsController', () => { it('updates tokens when userRegion matches the requested region', async () => { await withController( - { options: { state: { userRegion: createMockUserRegion('us-ca') } } }, + { options: { state: { userRegion: createResourceState(createMockUserRegion('us-ca')) } } }, async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'RampsService:getTokens', @@ -2529,12 +2548,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toBeNull(); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toBeNull(); await controller.getTokens('US-ca'); - expect(controller.state.tokens).toStrictEqual(mockTokens); + expect(controller.state.tokens.data).toStrictEqual(mockTokens); }, ); }); @@ -2569,8 +2588,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - tokens: existingTokens, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(existingTokens, null), }, }, }, @@ -2587,12 +2606,12 @@ describe('RampsController', () => { }, ); - expect(controller.state.userRegion?.regionCode).toBe('us-ca'); - expect(controller.state.tokens).toStrictEqual(existingTokens); + expect(controller.state.userRegion.data?.regionCode).toBe('us-ca'); + expect(controller.state.tokens.data).toStrictEqual(existingTokens); await controller.getTokens('fr'); - expect(controller.state.tokens).toStrictEqual(existingTokens); + expect(controller.state.tokens.data).toStrictEqual(existingTokens); }, ); }); @@ -2714,11 +2733,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: mockPaymentMethod1, - paymentMethods: [mockPaymentMethod1, mockPaymentMethod2], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([mockPaymentMethod1, mockPaymentMethod2], mockPaymentMethod1), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2728,7 +2746,7 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod1, ); @@ -2737,10 +2755,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, ]); @@ -2761,11 +2779,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: removedPaymentMethod, - paymentMethods: [removedPaymentMethod], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([removedPaymentMethod], removedPaymentMethod), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2775,7 +2792,7 @@ describe('RampsController', () => { async () => mockPaymentMethodsResponse, ); - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( removedPaymentMethod, ); @@ -2784,10 +2801,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, ]); @@ -2800,11 +2817,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: null, - paymentMethods: [], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([], null), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2814,17 +2830,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, ]); @@ -2837,9 +2853,9 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -2849,14 +2865,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, ]); @@ -2869,7 +2885,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), + userRegion: createResourceState(createMockUserRegion('us-ca')), }, }, }, @@ -2916,7 +2932,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: regionWithoutCurrency, + userRegion: createResourceState(regionWithoutCurrency), }, }, }, @@ -2948,8 +2964,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: mockToken, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, mockToken), }, }, }, @@ -2997,8 +3013,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedProvider: testProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + providers: createResourceState([], testProvider), }, }, }, @@ -3031,7 +3047,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('fr'), + userRegion: createResourceState(createMockUserRegion('fr')), }, }, }, @@ -3073,11 +3089,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedPaymentMethod: removedPaymentMethod, - paymentMethods: [removedPaymentMethod], - selectedToken: mockSelectedToken, - selectedProvider: mockSelectedProvider, + userRegion: createResourceState(createMockUserRegion('us-ca')), + paymentMethods: createResourceState([removedPaymentMethod], removedPaymentMethod), + tokens: createResourceState(null, mockSelectedToken), + providers: createResourceState([], mockSelectedProvider), }, }, }, @@ -3092,8 +3107,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([]); }, ); }); @@ -3116,9 +3131,9 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: null, - selectedProvider: null, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, null), + providers: createResourceState([], null), }, }, }, @@ -3192,14 +3207,16 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: tokenA, - selectedProvider: null, - paymentMethods: [], - tokens: { - topTokens: [tokenA, tokenB], - allTokens: [tokenA, tokenB], - }, + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState( + { + topTokens: [tokenA, tokenB], + allTokens: [tokenA, tokenB], + }, + tokenA, + ), + providers: createResourceState([], null), + paymentMethods: createResourceState([], null), }, }, }, @@ -3240,8 +3257,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); @@ -3304,11 +3321,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: null, - selectedProvider: providerA, - paymentMethods: [], - providers: [providerA, providerB], + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, null), + providers: createResourceState([providerA, providerB], providerA), + paymentMethods: createResourceState([], null), }, }, }, @@ -3349,8 +3365,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); @@ -3398,10 +3414,10 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us-ca'), - selectedToken: token, - selectedProvider: provider, - paymentMethods: [], + userRegion: createResourceState(createMockUserRegion('us-ca')), + tokens: createResourceState(null, token), + providers: createResourceState([], provider), + paymentMethods: createResourceState([], null), }, }, }, @@ -3416,9 +3432,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 +3456,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 +3477,18 @@ describe('RampsController', () => { { options: { state: { - selectedPaymentMethod: mockPaymentMethod, - paymentMethods: [mockPaymentMethod], + paymentMethods: createResourceState([mockPaymentMethod], mockPaymentMethod), }, }, }, ({ controller }) => { - expect(controller.state.selectedPaymentMethod).toStrictEqual( + expect(controller.state.paymentMethods.selected).toStrictEqual( mockPaymentMethod, ); controller.setSelectedPaymentMethod(undefined); - expect(controller.state.selectedPaymentMethod).toBeNull(); + expect(controller.state.paymentMethods.selected).toBeNull(); }, ); }); @@ -3493,7 +3508,7 @@ describe('RampsController', () => { { options: { state: { - paymentMethods: [mockPaymentMethod], + paymentMethods: createResourceState([mockPaymentMethod], null), }, }, }, @@ -3508,89 +3523,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: [ @@ -3627,8 +3559,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3636,7 +3568,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3646,7 +3578,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 +3588,7 @@ describe('RampsController', () => { expect(result.success).toHaveLength(1); expect(result.success[0]?.provider).toBe('/providers/moonpay'); - expect(controller.state.quotes).toStrictEqual(mockQuotesResponse); + expect(controller.state.quotes.data).toStrictEqual(mockQuotesResponse); }, ); }); @@ -3666,8 +3598,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3675,7 +3607,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3716,7 +3648,7 @@ describe('RampsController', () => { { options: { state: { - userRegion: { + userRegion: createResourceState({ country: { isoCode: 'US', name: 'United States', @@ -3727,7 +3659,7 @@ describe('RampsController', () => { }, state: null, regionCode: 'us', - }, + }), }, }, }, @@ -3749,8 +3681,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [], + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([], null), }, }, }, @@ -3771,8 +3703,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3780,7 +3712,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3817,8 +3749,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3826,7 +3758,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3855,8 +3787,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3864,7 +3796,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3893,8 +3825,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - paymentMethods: [ + userRegion: createResourceState(createMockUserRegion('us')), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3902,7 +3834,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -3959,8 +3891,8 @@ describe('RampsController', () => { { options: { state: { - userRegion: createMockUserRegion('us'), - countries: [ + userRegion: createResourceState(createMockUserRegion('us')), + countries: createResourceState([ { isoCode: 'US', flag: '🇺🇸', @@ -3977,8 +3909,8 @@ describe('RampsController', () => { currency: 'EUR', supported: { buy: true, sell: true }, }, - ], - paymentMethods: [ + ]), + paymentMethods: createResourceState([ { id: '/payments/debit-credit-card', paymentType: 'debit-credit-card', @@ -3986,7 +3918,7 @@ describe('RampsController', () => { score: 90, icon: 'card', }, - ], + ], null), }, }, }, @@ -4021,7 +3953,7 @@ describe('RampsController', () => { await quotesPromise; // Quotes should not be updated because region changed - expect(controller.state.quotes).toBeNull(); + expect(controller.state.quotes.data).toBeNull(); }, ); }); @@ -4075,80 +4007,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)); - }); - }); - }); }); /** @@ -4240,6 +4098,25 @@ function createMockCountries(): Country[] { ]; } +/** + * Creates a ResourceState object for testing. + * + * @param data - The resource data. + * @param selected - The selected item (optional). + * @returns A ResourceState object. + */ +function createResourceState( + data: TData, + selected: TSelected = null as TSelected, +) { + return { + data, + selected, + isLoading: false, + error: null, + }; +} + /** * The type of the messenger populated with all external actions and events * required by the controller under test. diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 943d23f1150..27b5b623564 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -33,6 +33,7 @@ import type { RequestState, ExecuteRequestOptions, PendingRequest, + ResourceType, } from './RequestCache'; import { DEFAULT_REQUEST_CACHE_TTL, @@ -81,54 +82,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 = { /** - * Tokens fetched for the current region and action. - * Contains topTokens and allTokens arrays. + * The user's region state with data, loading, and error. + * Data contains the full country and state objects. + * Initially set via geolocation fetch, but can be manually changed by the user. */ - tokens: TokensResponse | null; + userRegion: ResourceState; /** - * The user's selected token. - * When set, automatically fetches and sets payment methods for that token. + * Countries resource state with data, loading, and error. + * Data contains the list of countries available for ramp actions. */ - selectedToken: RampsToken | null; + countries: ResourceState; /** - * Payment methods available for the current context. - * Filtered by region, fiat, asset, and provider. + * Providers resource state with data, selected, loading, and error. + * Data contains the list of providers available for the current region. */ - paymentMethods: PaymentMethod[]; + providers: ResourceState; /** - * The user's selected payment method. - * Can be manually set by the user. + * Tokens resource state with data, selected, loading, and error. + * Data contains topTokens and allTokens arrays. */ - selectedPaymentMethod: PaymentMethod | null; + tokens: ResourceState; /** - * Quotes fetched for the current context. - * Contains quotes from multiple providers for the given parameters. + * Payment methods resource state with data, selected, loading, and error. + * Data contains payment methods filtered by region, fiat, asset, and provider. */ - quotes: QuotesResponse | null; + paymentMethods: ResourceState; + /** + * Quotes resource state with data, loading, and error. + * Data contains quotes from multiple providers for the given parameters. + */ + quotes: ResourceState; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. @@ -146,12 +157,6 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, - selectedProvider: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, countries: { persist: true, includeInDebugSnapshot: true, @@ -170,24 +175,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, - selectedToken: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, paymentMethods: { persist: false, includeInDebugSnapshot: true, includeInStateLogs: true, usedInUi: true, }, - selectedPaymentMethod: { - persist: false, - includeInDebugSnapshot: true, - includeInStateLogs: true, - usedInUi: true, - }, quotes: { persist: false, includeInDebugSnapshot: true, @@ -202,6 +195,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 @@ -212,15 +226,21 @@ const rampsControllerMetadata = { */ export function getDefaultRampsControllerState(): RampsControllerState { return { - userRegion: null, - selectedProvider: null, - countries: [], - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + userRegion: createDefaultResourceState(null), + countries: createDefaultResourceState([]), + providers: createDefaultResourceState( + [], + null, + ), + tokens: createDefaultResourceState( + null, + null, + ), + paymentMethods: createDefaultResourceState< + PaymentMethod[], + PaymentMethod | null + >([], null), + quotes: createDefaultResourceState(null), requests: {}, }; } @@ -450,10 +470,16 @@ export class RampsController extends BaseController< // Create abort controller for this request const abortController = new AbortController(); const lastFetchedAt = Date.now(); + const { resourceType } = options ?? {}; // Update state to loading this.#updateRequestState(cacheKey, createLoadingState()); + // Set resource-level loading state (only on cache miss) + if (resourceType) { + this.#setResourceLoading(resourceType, true); + } + // Create the fetch promise const promise = (async (): Promise => { try { @@ -468,6 +494,12 @@ export class RampsController extends BaseController< cacheKey, createSuccessState(data as Json, lastFetchedAt), ); + + // Clear error on success + if (resourceType) { + this.#setResourceError(resourceType, null); + } + return data; } catch (error) { // Don't update state if aborted @@ -475,12 +507,18 @@ export class RampsController extends BaseController< throw error; } - const errorMessage = (error as Error)?.message; + const errorMessage = (error as Error)?.message ?? 'Unknown error'; this.#updateRequestState( cacheKey, - createErrorState(errorMessage ?? 'Unknown error', lastFetchedAt), + createErrorState(errorMessage, lastFetchedAt), ); + + // Set resource-level error + if (resourceType) { + this.#setResourceError(resourceType, errorMessage); + } + throw error; } finally { // Only delete if this is still our entry (not replaced by a new request) @@ -488,6 +526,11 @@ export class RampsController extends BaseController< if (currentPending?.abortController === abortController) { this.#pendingRequests.delete(cacheKey); } + + // Clear resource-level loading state + if (resourceType) { + this.#setResourceLoading(resourceType, false); + } } })(); @@ -531,14 +574,92 @@ export class RampsController extends BaseController< #cleanupState(): void { this.update((state) => { - state.userRegion = null; - state.selectedProvider = null; - state.selectedToken = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - state.quotes = null; + state.userRegion.data = null; + state.providers.selected = null; + state.tokens.selected = null; + state.tokens.data = null; + state.providers.data = []; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; + state.quotes.data = null; + }); + } + + /** + * Executes a promise without awaiting, swallowing errors. + * Errors are stored in state via executeRequest. + * + * @param promise - The promise to execute. + */ + #fireAndForget(promise: Promise): void { + promise.catch((_error: unknown) => undefined); + } + + /** + * Sets the loading state for a resource type. + * + * @param resourceType - The type of resource. + * @param loading - Whether the resource is loading. + */ + #setResourceLoading(resourceType: ResourceType, loading: boolean): void { + this.update((state) => { + switch (resourceType) { + case 'userRegion': + state.userRegion.isLoading = loading; + break; + case 'countries': + state.countries.isLoading = loading; + break; + case 'providers': + state.providers.isLoading = loading; + break; + case 'tokens': + state.tokens.isLoading = loading; + break; + case 'paymentMethods': + state.paymentMethods.isLoading = loading; + break; + case 'quotes': + state.quotes.isLoading = loading; + break; + /* istanbul ignore next: exhaustive switch */ + default: + break; + } + }); + } + + /** + * Sets the error state for a resource type. + * + * @param resourceType - The type of resource. + * @param error - The error message, or null to clear. + */ + #setResourceError(resourceType: ResourceType, error: string | null): void { + this.update((state) => { + switch (resourceType) { + case 'userRegion': + state.userRegion.error = error; + break; + case 'countries': + state.countries.error = error; + break; + case 'providers': + state.providers.error = error; + break; + case 'tokens': + state.tokens.error = error; + break; + case 'paymentMethods': + state.paymentMethods.error = error; + break; + case 'quotes': + state.quotes.error = error; + break; + /* istanbul ignore next: exhaustive switch */ + default: + break; + } }); } @@ -620,15 +741,15 @@ export class RampsController extends BaseController< const normalizedRegion = region.toLowerCase().trim(); try { - const { countries } = this.state; - if (!countries || countries.length === 0) { + const countriesData = this.state.countries.data; + if (!countriesData || countriesData.length === 0) { this.#cleanupState(); throw new Error( 'No countries found. Cannot set user region without valid country information.', ); } - const userRegion = findRegionFromCode(normalizedRegion, countries); + const userRegion = findRegionFromCode(normalizedRegion, countriesData); if (!userRegion) { this.#cleanupState(); @@ -639,28 +760,29 @@ export class RampsController extends BaseController< // Only cleanup state if region is actually changing const regionChanged = - normalizedRegion !== this.state.userRegion?.regionCode; + normalizedRegion !== this.state.userRegion.data?.regionCode; // Set the new region atomically with cleanup to avoid intermediate null state this.update((state) => { if (regionChanged) { - state.selectedProvider = null; - state.selectedToken = null; - state.tokens = null; - state.providers = []; - state.paymentMethods = []; - state.selectedPaymentMethod = null; - state.quotes = null; + state.providers.selected = null; + state.tokens.selected = null; + state.tokens.data = null; + state.providers.data = []; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; + state.quotes.data = null; } - state.userRegion = userRegion; + state.userRegion.data = userRegion; }); - // 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.tokens.data) { + this.#fireAndForget( + this.getTokens(userRegion.regionCode, 'buy', options), + ); } - if (regionChanged || this.state.providers.length === 0) { - this.triggerGetProviders(userRegion.regionCode, options); + if (regionChanged || this.state.providers.data.length === 0) { + this.#fireAndForget(this.getProviders(userRegion.regionCode, options)); } return userRegion; @@ -681,21 +803,21 @@ export class RampsController extends BaseController< setSelectedProvider(providerId: string | null): void { if (providerId === null) { this.update((state) => { - state.selectedProvider = null; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + state.providers.selected = null; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; }); return; } - const regionCode = this.state.userRegion?.regionCode; + const regionCode = this.state.userRegion.data?.regionCode; if (!regionCode) { throw new Error( 'Region is required. Cannot set selected provider without valid region information.', ); } - const { providers } = this.state; + const providers = this.state.providers.data; if (!providers || providers.length === 0) { throw new Error( 'Providers not loaded. Cannot set selected provider before providers are fetched.', @@ -710,17 +832,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 }), + ); } /** @@ -734,30 +853,43 @@ export class RampsController extends BaseController< * @returns Promise that resolves when initialization is complete. */ async init(options?: ExecuteRequestOptions): Promise { - await this.getCountries(options); + this.#setResourceLoading('userRegion', true); - let regionCode = this.state.userRegion?.regionCode; - regionCode ??= await this.messenger.call('RampsService:getGeolocation'); + try { + await this.getCountries(options); - if (!regionCode) { - throw new Error( - 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + let regionCode = this.state.userRegion.data?.regionCode; + regionCode ??= await this.messenger.call('RampsService:getGeolocation'); + + if (!regionCode) { + throw new Error( + 'Failed to fetch geolocation. Cannot initialize controller without valid region information.', + ); + } + + await this.setUserRegion(regionCode, options); + this.#setResourceError('userRegion', null); + } catch (error) { + this.#setResourceError( + 'userRegion', + (error as Error)?.message ?? 'Unknown error', ); + throw error; + } finally { + this.#setResourceLoading('userRegion', false); } - - await this.setUserRegion(regionCode, options); } hydrateState(options?: ExecuteRequestOptions): void { - const regionCode = this.state.userRegion?.regionCode; + const regionCode = this.state.userRegion.data?.regionCode; if (!regionCode) { throw new Error( 'Region code is required. Cannot hydrate state without valid region information.', ); } - this.triggerGetTokens(regionCode, 'buy', options); - this.triggerGetProviders(regionCode, options); + this.#fireAndForget(this.getTokens(regionCode, 'buy', options)); + this.#fireAndForget(this.getProviders(regionCode, options)); } /** @@ -776,11 +908,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; @@ -803,7 +935,7 @@ export class RampsController extends BaseController< provider?: string | string[]; }, ): Promise { - const regionToUse = region ?? this.state.userRegion?.regionCode; + const regionToUse = region ?? this.state.userRegion.data?.regionCode; if (!regionToUse) { throw new Error( @@ -830,14 +962,14 @@ export class RampsController extends BaseController< }, ); }, - options, + { ...options, resourceType: 'tokens' }, ); this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + const userRegionCode = state.userRegion.data?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.tokens = tokens; + state.tokens.data = tokens; } }); @@ -855,21 +987,21 @@ export class RampsController extends BaseController< setSelectedToken(assetId?: string): void { if (!assetId) { this.update((state) => { - state.selectedToken = null; - state.paymentMethods = []; - state.selectedPaymentMethod = null; + state.tokens.selected = null; + state.paymentMethods.data = []; + state.paymentMethods.selected = null; }); return; } - const regionCode = this.state.userRegion?.regionCode; + const regionCode = this.state.userRegion.data?.regionCode; if (!regionCode) { throw new Error( 'Region is required. Cannot set selected token without valid region information.', ); } - const { tokens } = this.state; + const tokens = this.state.tokens.data; if (!tokens) { throw new Error( 'Tokens not loaded. Cannot set selected token before tokens are fetched.', @@ -887,14 +1019,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 }), + ); } /** @@ -918,7 +1050,7 @@ export class RampsController extends BaseController< payments?: string | string[]; }, ): Promise<{ providers: Provider[] }> { - const regionToUse = region ?? this.state.userRegion?.regionCode; + const regionToUse = region ?? this.state.userRegion.data?.regionCode; if (!regionToUse) { throw new Error( @@ -949,14 +1081,14 @@ export class RampsController extends BaseController< }, ); }, - options, + { ...options, resourceType: 'providers' }, ); this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + const userRegionCode = state.userRegion.data?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.providers = providers; + state.providers.data = providers; } }); @@ -982,13 +1114,13 @@ export class RampsController extends BaseController< provider?: string; }, ): Promise { - const regionCode = region ?? this.state.userRegion?.regionCode ?? null; + const regionCode = region ?? this.state.userRegion.data?.regionCode ?? null; const fiatToUse = - options?.fiat ?? this.state.userRegion?.country?.currency ?? null; + options?.fiat ?? this.state.userRegion.data?.country?.currency ?? null; const assetIdToUse = - options?.assetId ?? this.state.selectedToken?.assetId ?? ''; + options?.assetId ?? this.state.tokens.selected?.assetId ?? ''; const providerToUse = - options?.provider ?? this.state.selectedProvider?.id ?? ''; + options?.provider ?? this.state.providers.selected?.id ?? ''; if (!regionCode) { throw new Error( @@ -1021,12 +1153,12 @@ export class RampsController extends BaseController< provider: providerToUse, }); }, - options, + { ...options, resourceType: 'paymentMethods' }, ); 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 +1167,14 @@ export class RampsController extends BaseController< // ex: if the user rapidly changes the token or provider, the in-flight payment methods might not be valid // so this check will ensure that the payment methods are still valid for the token and provider that were requested if (tokenSelectionUnchanged && providerSelectionUnchanged) { - state.paymentMethods = response.payments; + state.paymentMethods.data = response.payments; // this will auto-select the first payment method if the selected payment method is not in the new payment methods const currentSelectionStillValid = response.payments.some( - (pm: PaymentMethod) => pm.id === state.selectedPaymentMethod?.id, + (pm: PaymentMethod) => pm.id === state.paymentMethods.selected?.id, ); if (!currentSelectionStillValid) { - state.selectedPaymentMethod = response.payments[0] ?? null; + state.paymentMethods.selected = response.payments[0] ?? null; } } }); @@ -1060,12 +1192,12 @@ export class RampsController extends BaseController< setSelectedPaymentMethod(paymentMethodId?: string): void { if (!paymentMethodId) { this.update((state) => { - state.selectedPaymentMethod = null; + state.paymentMethods.selected = null; }); return; } - const { paymentMethods } = this.state; + const paymentMethods = this.state.paymentMethods.data; if (!paymentMethods || paymentMethods.length === 0) { throw new Error( 'Payment methods not loaded. Cannot set selected payment method before payment methods are fetched.', @@ -1082,7 +1214,7 @@ export class RampsController extends BaseController< } this.update((state) => { - state.selectedPaymentMethod = paymentMethod; + state.paymentMethods.selected = paymentMethod; }); } @@ -1117,11 +1249,11 @@ export class RampsController extends BaseController< forceRefresh?: boolean; ttl?: number; }): Promise { - const regionToUse = options.region ?? this.state.userRegion?.regionCode; - const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency; + const regionToUse = options.region ?? this.state.userRegion.data?.regionCode; + const fiatToUse = options.fiat ?? this.state.userRegion.data?.country?.currency; const paymentMethodsToUse = options.paymentMethods ?? - this.state.paymentMethods.map((pm: PaymentMethod) => pm.id); + this.state.paymentMethods.data.map((pm: PaymentMethod) => pm.id); const action = options.action ?? 'buy'; if (!regionToUse) { @@ -1189,14 +1321,15 @@ export class RampsController extends BaseController< { forceRefresh: options.forceRefresh, ttl: options.ttl ?? DEFAULT_QUOTES_TTL, + resourceType: 'quotes', }, ); this.update((state) => { - const userRegionCode = state.userRegion?.regionCode; + const userRegionCode = state.userRegion.data?.regionCode; if (userRegionCode === undefined || userRegionCode === normalizedRegion) { - state.quotes = response; + state.quotes.data = response; } }); @@ -1213,126 +1346,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..6a4eff06a04 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -1,5 +1,16 @@ import type { Json } from '@metamask/utils'; +/** + * Types of resources that can have loading/error states. + */ +export type ResourceType = + | 'userRegion' + | 'countries' + | 'providers' + | 'tokens' + | 'paymentMethods' + | 'quotes'; + /** * Status of a cached request. */ @@ -135,6 +146,8 @@ export type ExecuteRequestOptions = { forceRefresh?: boolean; /** Custom TTL for this request in milliseconds */ ttl?: number; + /** Resource type to update loading/error states for */ + resourceType?: ResourceType; }; /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 65acb614b10..00089017be8 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,6 +7,7 @@ export type { RampsControllerStateChangeEvent, RampsControllerOptions, UserRegion, + ResourceState, } from './RampsController'; export { RampsController, @@ -34,6 +35,8 @@ export type { QuoteCustomAction, QuotesResponse, GetQuotesParams, + RampsToken, + TokensResponse, } from './RampsService'; export { RampsService, @@ -52,6 +55,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..6a99cab073b 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -10,6 +10,33 @@ type TestRootState = { ramps: RampsControllerState; }; +function createDefaultResourceState( + data: TData, + selected: TSelected = null as TSelected, +) { + return { + data, + selected, + isLoading: false, + error: null, + }; +} + +function createMockRampsState( + overrides: Partial = {}, +): RampsControllerState { + return { + userRegion: createDefaultResourceState(null), + countries: createDefaultResourceState([]), + providers: createDefaultResourceState([], null), + tokens: createDefaultResourceState(null, null), + paymentMethods: createDefaultResourceState([], null), + quotes: createDefaultResourceState(null), + requests: {}, + ...overrides, + }; +} + describe('createRequestSelector', () => { const getState = (state: TestRootState): RampsControllerState => state.ramps; @@ -23,20 +50,11 @@ describe('createRequestSelector', () => { const loadingRequest = createLoadingState(); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, - }, + }), }; const result = selector(state); @@ -59,20 +77,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH', 'BTC'], Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result = selector(state); @@ -98,20 +107,11 @@ describe('createRequestSelector', () => { const errorRequest = createErrorState('Network error', Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, - }, + }), }; const result = selector(state); @@ -133,18 +133,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 +180,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH', 'BTC'], Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -222,40 +202,22 @@ describe('createRequestSelector', () => { const successRequest1 = createSuccessState(['ETH'], Date.now()); const state1: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + 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 +236,11 @@ describe('createRequestSelector', () => { const largeArray = Array.from({ length: 1000 }, (_, i) => `item-${i}`); const successRequest = createSuccessState(largeArray, Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -309,20 +262,11 @@ describe('createRequestSelector', () => { }; const successRequest = createSuccessState(complexData, Date.now()); const state: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getData:[]': successRequest, }, - }, + }), }; const result1 = selector(state); @@ -343,20 +287,11 @@ describe('createRequestSelector', () => { const loadingRequest = createLoadingState(); const loadingState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': loadingRequest, }, - }, + }), }; const loadingResult = selector(loadingState); @@ -365,20 +300,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH'], Date.now()); const successState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const successResult = selector(successState); @@ -395,20 +321,11 @@ describe('createRequestSelector', () => { const successRequest = createSuccessState(['ETH'], Date.now()); const successState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': successRequest, }, - }, + }), }; const successResult = selector(successState); @@ -416,20 +333,11 @@ describe('createRequestSelector', () => { const errorRequest = createErrorState('Failed to fetch', Date.now()); const errorState: TestRootState = { - ramps: { - userRegion: null, - countries: [], - selectedProvider: null, - providers: [], - tokens: null, - selectedToken: null, - paymentMethods: [], - selectedPaymentMethod: null, - quotes: null, + ramps: createMockRampsState({ requests: { 'getCryptoCurrencies:["US"]': errorRequest, }, - }, + }), }; const errorResult = selector(errorState); @@ -452,16 +360,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 +368,7 @@ describe('createRequestSelector', () => { ), 'getPrice:["US"]': createSuccessState(100, Date.now()), }, - }, + }), }; const result1 = selector1(state); @@ -492,16 +391,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 +402,7 @@ describe('createRequestSelector', () => { Date.now(), ), }, - }, + }), }; const result1 = selector1(state);