Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Stop persisting the countries catalog in `RampsController` state and refetch it on every app startup via `init()` ([#9261](https://github.com/MetaMask/core/pull/9261))
- Re-sync `userRegion` preset amounts from the countries catalog after each `getCountries()` call ([#9261](https://github.com/MetaMask/core/pull/9261))
- On startup, `init()` now preserves an already-persisted `userRegion` when the refreshed countries catalog is momentarily empty or no longer lists that region, instead of clearing it ([#9261](https://github.com/MetaMask/core/pull/9261))

## [15.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export type RampsControllerSetSelectedProviderAction = {
* This should be called once at app startup to set up the initial region.
*
* Idempotent: subsequent calls return the same promise unless forceRefresh is set.
* Skips getCountries when countries are already loaded; skips geolocation when
* Always refetches the countries catalog on startup. Skips geolocation when
* userRegion already exists.
*
* @param options - Options for cache behavior. forceRefresh bypasses idempotency and re-runs the full flow.
Expand Down
245 changes: 237 additions & 8 deletions packages/ramps-controller/src/RampsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,12 +921,6 @@ describe('RampsController', () => {
),
).toMatchInlineSnapshot(`
{
"countries": {
"data": [],
"error": null,
"isLoading": false,
"selected": null,
},
"orders": [],
"providerAutoSelected": false,
"userRegion": null,
Expand Down Expand Up @@ -1880,6 +1874,166 @@ describe('RampsController', () => {
});
});

it('re-syncs userRegion preset amounts after getCountries', async () => {
const staleCountries: Country[] = [
{
isoCode: 'CR',
id: '/regions/cr',
flag: '🇨🇷',
name: 'Costa Rica',
phone: {
prefix: '+506',
placeholder: '8312 3456',
template: 'XXXX XXXX',
},
currency: 'CRC',
supported: { buy: true, sell: true },
defaultAmount: 100,
quickAmounts: [20, 50, 100],
},
];
const freshCountries: Country[] = [
{
...staleCountries[0],
defaultAmount: 25000,
quickAmounts: [10000, 25000, 50000],
},
];

await withController(
{
options: {
state: {
userRegion: {
country: staleCountries[0],
state: null,
regionCode: 'cr',
},
},
},
},
async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getCountries',
async () => freshCountries,
);

await rootMessenger.call('RampsController:getCountries');

expect(controller.state.userRegion?.country.defaultAmount).toBe(
25000,
);
expect(
controller.state.userRegion?.country.quickAmounts,
).toStrictEqual([10000, 25000, 50000]);
},
);
});

it('leaves userRegion unchanged when the refreshed catalog is empty', async () => {
const userRegionCountry: Country = {
isoCode: 'CR',
id: '/regions/cr',
flag: '🇨🇷',
name: 'Costa Rica',
phone: {
prefix: '+506',
placeholder: '8312 3456',
template: 'XXXX XXXX',
},
currency: 'CRC',
supported: { buy: true, sell: true },
defaultAmount: 100,
quickAmounts: [20, 50, 100],
};

await withController(
{
options: {
state: {
userRegion: {
country: userRegionCountry,
state: null,
regionCode: 'cr',
},
},
},
},
async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getCountries',
async () => [],
);

await rootMessenger.call('RampsController:getCountries');

expect(controller.state.countries.data).toStrictEqual([]);
expect(controller.state.userRegion?.country.defaultAmount).toBe(100);
},
);
});

it('leaves userRegion unchanged when its region is absent from the refreshed catalog', async () => {
const userRegionCountry: Country = {
isoCode: 'CR',
id: '/regions/cr',
flag: '🇨🇷',
name: 'Costa Rica',
phone: {
prefix: '+506',
placeholder: '8312 3456',
template: 'XXXX XXXX',
},
currency: 'CRC',
supported: { buy: true, sell: true },
defaultAmount: 100,
quickAmounts: [20, 50, 100],
};
const otherCountries: Country[] = [
{
isoCode: 'US',
id: '/regions/us',
flag: '🇺🇸',
name: 'United States',
phone: {
prefix: '+1',
placeholder: '201 555 0123',
template: 'XXX XXX XXXX',
},
currency: 'USD',
supported: { buy: true, sell: true },
defaultAmount: 100,
quickAmounts: [100, 300, 1000],
},
];

await withController(
{
options: {
state: {
userRegion: {
country: userRegionCountry,
state: null,
regionCode: 'cr',
},
},
},
},
async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getCountries',
async () => otherCountries,
);

await rootMessenger.call('RampsController:getCountries');

expect(controller.state.countries.data).toStrictEqual(otherCountries);
expect(controller.state.userRegion?.country.isoCode).toBe('CR');
expect(controller.state.userRegion?.country.defaultAmount).toBe(100);
},
);
});

it('throws when updating resource field and resource is null', async () => {
const stateWithNullCountries = {
...getDefaultRampsControllerState(),
Expand Down Expand Up @@ -2110,7 +2264,7 @@ describe('RampsController', () => {
});
});

it('skips getCountries and geolocation when userRegion and countries exist', async () => {
it('refetches countries on init but skips geolocation when userRegion exists', async () => {
let getCountriesCalled = false;
let getGeolocationCalled = false;
await withController(
Expand Down Expand Up @@ -2148,7 +2302,7 @@ describe('RampsController', () => {

await rootMessenger.call('RampsController:init');

expect(getCountriesCalled).toBe(false);
expect(getCountriesCalled).toBe(true);
expect(getGeolocationCalled).toBe(false);
expect(controller.state.userRegion?.regionCode).toBe('us-ca');
},
Expand Down Expand Up @@ -2229,6 +2383,81 @@ describe('RampsController', () => {
},
);
});

it('preserves a persisted userRegion when the startup catalog refresh is empty', async () => {
let getGeolocationCalled = false;
await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us-ca'),
},
},
},
async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getCountries',
async () => [],
);
rootMessenger.registerActionHandler(
'RampsService:getGeolocation',
async () => {
getGeolocationCalled = true;
return 'us-ca';
},
);

expect(
await rootMessenger.call('RampsController:init'),
).toBeUndefined();

// A transient empty catalog must not fall back to geolocation nor
// wipe the persisted region via setUserRegion's cleanup.
expect(getGeolocationCalled).toBe(false);
expect(controller.state.countries.data).toStrictEqual([]);
expect(controller.state.userRegion?.regionCode).toBe('us-ca');
},
);
});

it('preserves a persisted userRegion when the startup catalog no longer lists the region', async () => {
const catalogWithoutUs: Country[] = [
{
isoCode: 'FR',
name: 'France',
flag: '🇫🇷',
currency: 'EUR',
phone: { prefix: '+33', placeholder: '', template: '' },
supported: { buy: true, sell: true },
},
];
await withController(
{
options: {
state: {
userRegion: createMockUserRegion('us-ca'),
},
},
},
async ({ controller, rootMessenger }) => {
rootMessenger.registerActionHandler(
'RampsService:getCountries',
async () => catalogWithoutUs,
);

expect(
await rootMessenger.call('RampsController:init'),
).toBeUndefined();

// The region is absent from the refreshed catalog, but the previously
// valid region must be preserved rather than wiped.
expect(controller.state.countries.data).toStrictEqual(
catalogWithoutUs,
);
expect(controller.state.userRegion?.regionCode).toBe('us-ca');
},
);
});
});

describe('setUserRegion', () => {
Expand Down
55 changes: 45 additions & 10 deletions packages/ramps-controller/src/RampsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ const rampsControllerMetadata = {
usedInUi: true,
},
countries: {
persist: true,
persist: false,
includeInDebugSnapshot: true,
includeInStateLogs: true,
usedInUi: true,
Expand Down Expand Up @@ -1383,7 +1383,7 @@ export class RampsController extends BaseController<
* This should be called once at app startup to set up the initial region.
*
* Idempotent: subsequent calls return the same promise unless forceRefresh is set.
* Skips getCountries when countries are already loaded; skips geolocation when
* Always refetches the countries catalog on startup. Skips geolocation when
* userRegion already exists.
*
* @param options - Options for cache behavior. forceRefresh bypasses idempotency and re-runs the full flow.
Expand Down Expand Up @@ -1412,28 +1412,61 @@ export class RampsController extends BaseController<
}

async #runInit(options?: ExecuteRequestOptions): Promise<void> {
const forceRefresh = options?.forceRefresh === true;
const hasCountries = this.state.countries.data.length > 0;

if (forceRefresh || !hasCountries) {
await this.getCountries(options);
}
Comment thread
cursor[bot] marked this conversation as resolved.
await this.getCountries({ ...options, forceRefresh: true });

// Always prefer the user's persisted region. Geolocation is only used to
// seed the initial value; once the user (or a prior init) has set a region
// we must respect that choice — even on forceRefresh.
let regionCode: string | undefined = this.state.userRegion?.regionCode;
regionCode ??= await this.messenger.call('RampsService:getGeolocation');
const persistedRegionCode = this.state.userRegion?.regionCode;
const regionCode =
persistedRegionCode ??
(await this.messenger.call('RampsService:getGeolocation'));

if (!regionCode) {
throw new Error(
'Failed to fetch geolocation. Cannot initialize controller without valid region information.',
);
}

// For an already-persisted region, getCountries() has already re-synced it
// from the fresh catalog (see #syncUserRegionFromCountriesCatalog). Calling
// setUserRegion here would re-validate against that catalog and, if it is
// momentarily empty or no longer lists the region (e.g. a transient/partial
// catalog response or a region with no current provider coverage), throw and
// wipe the persisted region via #cleanupState. Preserve the existing region
// instead; only resolve a brand-new region (from geolocation) strictly.
if (persistedRegionCode) {
return;
}

await this.setUserRegion(regionCode, options);
}

/**
* Re-applies `userRegion` from the current countries catalog so preset
* amounts and support flags stay in sync after a catalog refresh.
*/
#syncUserRegionFromCountriesCatalog(): void {
const regionCode = this.state.userRegion?.regionCode;
if (!regionCode) {
return;
}

const countriesData = this.state.countries.data;
if (!countriesData.length) {
return;
}

const userRegion = findRegionFromCode(regionCode, countriesData);
if (!userRegion) {
return;
}

this.update((state) => {
state.userRegion = userRegion;
});
}

/**
* Fetches the list of supported countries.
* The API returns countries with support information for both buy and sell actions.
Expand All @@ -1457,6 +1490,8 @@ export class RampsController extends BaseController<
state.countries.data = Array.isArray(countries) ? [...countries] : [];
});

this.#syncUserRegionFromCountriesCatalog();

return countries;
}

Expand Down
Loading