@metamask-previews/ramps-controller 5.1.0-preview-40468f94 → 6.0.0-preview-fb7aa07ff

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,15 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
1
6
  var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
7
  if (kind === "m") throw new TypeError("Private method is not writable");
3
8
  if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
10
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
11
  };
7
- var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
- return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
- };
12
- var _RampsController_instances, _RampsController_requestCacheTTL, _RampsController_requestCacheMaxSize, _RampsController_pendingRequests, _RampsController_removeRequestState, _RampsController_cleanupState, _RampsController_updateRequestState;
12
+ var _RampsController_instances, _RampsController_requestCacheTTL, _RampsController_requestCacheMaxSize, _RampsController_pendingRequests, _RampsController_pendingResourceCount, _RampsController_quotePollingInterval, _RampsController_quotePollingOptions, _RampsController_clearPendingResourceCountForDependentResources, _RampsController_removeRequestState, _RampsController_cleanupState, _RampsController_fireAndForget, _RampsController_restartPollingIfActive, _RampsController_updateResourceField, _RampsController_setResourceLoading, _RampsController_setResourceError, _RampsController_updateRequestState;
13
13
  import { BaseController } from "@metamask/base-controller";
14
14
  import { DEFAULT_REQUEST_CACHE_TTL, DEFAULT_REQUEST_CACHE_MAX_SIZE, createCacheKey, isCacheExpired, createLoadingState, createSuccessState, createErrorState, RequestStatus } from "./RequestCache.mjs";
15
15
  // === GENERAL ===
@@ -19,6 +19,19 @@ import { DEFAULT_REQUEST_CACHE_TTL, DEFAULT_REQUEST_CACHE_MAX_SIZE, createCacheK
19
19
  * when composed with other controllers.
20
20
  */
21
21
  export const controllerName = 'RampsController';
22
+ /**
23
+ * RampsService action types that RampsController calls via the messenger.
24
+ * Any host (e.g. mobile) that creates a RampsController messenger must delegate
25
+ * these actions from the root messenger so the controller can function.
26
+ */
27
+ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [
28
+ 'RampsService:getGeolocation',
29
+ 'RampsService:getCountries',
30
+ 'RampsService:getTokens',
31
+ 'RampsService:getProviders',
32
+ 'RampsService:getPaymentMethods',
33
+ 'RampsService:getQuotes',
34
+ ];
22
35
  /**
23
36
  * Default TTL for quotes requests (15 seconds).
24
37
  * Quotes are time-sensitive and should have a shorter cache duration.
@@ -34,12 +47,6 @@ const rampsControllerMetadata = {
34
47
  includeInStateLogs: true,
35
48
  usedInUi: true,
36
49
  },
37
- selectedProvider: {
38
- persist: false,
39
- includeInDebugSnapshot: true,
40
- includeInStateLogs: true,
41
- usedInUi: true,
42
- },
43
50
  countries: {
44
51
  persist: true,
45
52
  includeInDebugSnapshot: true,
@@ -58,24 +65,12 @@ const rampsControllerMetadata = {
58
65
  includeInStateLogs: true,
59
66
  usedInUi: true,
60
67
  },
61
- selectedToken: {
62
- persist: false,
63
- includeInDebugSnapshot: true,
64
- includeInStateLogs: true,
65
- usedInUi: true,
66
- },
67
68
  paymentMethods: {
68
69
  persist: false,
69
70
  includeInDebugSnapshot: true,
70
71
  includeInStateLogs: true,
71
72
  usedInUi: true,
72
73
  },
73
- selectedPaymentMethod: {
74
- persist: false,
75
- includeInDebugSnapshot: true,
76
- includeInStateLogs: true,
77
- usedInUi: true,
78
- },
79
74
  quotes: {
80
75
  persist: false,
81
76
  includeInDebugSnapshot: true,
@@ -89,6 +84,23 @@ const rampsControllerMetadata = {
89
84
  usedInUi: true,
90
85
  },
91
86
  };
87
+ /**
88
+ * Creates a default resource state object.
89
+ *
90
+ * @template TData - The type of the resource data.
91
+ * @template TSelected - The type of the selected item.
92
+ * @param data - The initial data value.
93
+ * @param selected - The initial selected value.
94
+ * @returns A ResourceState object with default loading and error values.
95
+ */
96
+ function createDefaultResourceState(data, selected = null) {
97
+ return {
98
+ data,
99
+ selected,
100
+ isLoading: false,
101
+ error: null,
102
+ };
103
+ }
92
104
  /**
93
105
  * Constructs the default {@link RampsController} state. This allows
94
106
  * consumers to provide a partial state object when initializing the controller
@@ -100,17 +112,43 @@ const rampsControllerMetadata = {
100
112
  export function getDefaultRampsControllerState() {
101
113
  return {
102
114
  userRegion: null,
103
- selectedProvider: null,
104
- countries: [],
105
- providers: [],
106
- tokens: null,
107
- selectedToken: null,
108
- paymentMethods: [],
109
- selectedPaymentMethod: null,
110
- quotes: null,
115
+ countries: createDefaultResourceState([]),
116
+ providers: createDefaultResourceState([], null),
117
+ tokens: createDefaultResourceState(null, null),
118
+ paymentMethods: createDefaultResourceState([], null),
119
+ quotes: createDefaultResourceState(null, null),
111
120
  requests: {},
112
121
  };
113
122
  }
123
+ /**
124
+ * Resets region-dependent resources (userRegion, providers, tokens, paymentMethods, quotes).
125
+ * Mutates state in place; use from within controller update() for atomic updates.
126
+ *
127
+ * @param state - The state object to mutate.
128
+ * @param options - Options for the reset.
129
+ * @param options.clearUserRegionData - When true, sets userRegion to null (e.g. for full cleanup).
130
+ */
131
+ function resetDependentResources(state, options) {
132
+ if (options?.clearUserRegionData) {
133
+ state.userRegion = null;
134
+ }
135
+ state.providers.selected = null;
136
+ state.providers.data = [];
137
+ state.providers.isLoading = false;
138
+ state.providers.error = null;
139
+ state.tokens.selected = null;
140
+ state.tokens.data = null;
141
+ state.tokens.isLoading = false;
142
+ state.tokens.error = null;
143
+ state.paymentMethods.data = [];
144
+ state.paymentMethods.selected = null;
145
+ state.paymentMethods.isLoading = false;
146
+ state.paymentMethods.error = null;
147
+ state.quotes.data = null;
148
+ state.quotes.selected = null;
149
+ state.quotes.isLoading = false;
150
+ state.quotes.error = null;
151
+ }
114
152
  // === HELPER FUNCTIONS ===
115
153
  /**
116
154
  * Finds a country and state from a region code string.
@@ -169,6 +207,15 @@ function findRegionFromCode(regionCode, countries) {
169
207
  * Manages cryptocurrency on/off ramps functionality.
170
208
  */
171
209
  export class RampsController extends BaseController {
210
+ /**
211
+ * Clears the pending resource count map. Used only in tests to exercise the
212
+ * defensive path when get() returns undefined in the finally block.
213
+ *
214
+ * @internal
215
+ */
216
+ clearPendingResourceCountForTest() {
217
+ __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").clear();
218
+ }
172
219
  /**
173
220
  * Constructs a new {@link RampsController}.
174
221
  *
@@ -205,6 +252,21 @@ export class RampsController extends BaseController {
205
252
  * Key is the cache key, value is the pending request with abort controller.
206
253
  */
207
254
  _RampsController_pendingRequests.set(this, new Map());
255
+ /**
256
+ * Count of in-flight requests per resource type.
257
+ * Used so isLoading is only cleared when the last request for that resource finishes.
258
+ */
259
+ _RampsController_pendingResourceCount.set(this, new Map());
260
+ /**
261
+ * Interval ID for automatic quote polling.
262
+ * Set when startQuotePolling() is called, cleared when stopQuotePolling() is called.
263
+ */
264
+ _RampsController_quotePollingInterval.set(this, null);
265
+ /**
266
+ * Options used for quote polling (walletAddress, amount, redirectUrl).
267
+ * Stored so polling can be restarted when dependencies change.
268
+ */
269
+ _RampsController_quotePollingOptions.set(this, null);
208
270
  __classPrivateFieldSet(this, _RampsController_requestCacheTTL, requestCacheTTL, "f");
209
271
  __classPrivateFieldSet(this, _RampsController_requestCacheMaxSize, requestCacheMaxSize, "f");
210
272
  }
@@ -236,8 +298,18 @@ export class RampsController extends BaseController {
236
298
  // Create abort controller for this request
237
299
  const abortController = new AbortController();
238
300
  const lastFetchedAt = Date.now();
301
+ const { resourceType } = options ?? {};
239
302
  // Update state to loading
240
303
  __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_updateRequestState).call(this, cacheKey, createLoadingState());
304
+ // Set resource-level loading state (only on cache miss). Ref-count so concurrent
305
+ // requests for the same resource type (different cache keys) keep isLoading true.
306
+ if (resourceType) {
307
+ const count = __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").get(resourceType) ?? 0;
308
+ __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").set(resourceType, count + 1);
309
+ if (count === 0) {
310
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_setResourceLoading).call(this, resourceType, true);
311
+ }
312
+ }
241
313
  // Create the fetch promise
242
314
  const promise = (async () => {
243
315
  try {
@@ -247,6 +319,15 @@ export class RampsController extends BaseController {
247
319
  throw new Error('Request was aborted');
248
320
  }
249
321
  __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_updateRequestState).call(this, cacheKey, createSuccessState(data, lastFetchedAt));
322
+ if (resourceType) {
323
+ // We need the extra logic because there are two situations where we’re allowed to clear the error:
324
+ // No callback → always clear
325
+ // Callback present → clear only when isResultCurrent() returns true.
326
+ const isCurrent = !options?.isResultCurrent || options.isResultCurrent();
327
+ if (isCurrent) {
328
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_setResourceError).call(this, resourceType, null);
329
+ }
330
+ }
250
331
  return data;
251
332
  }
252
333
  catch (error) {
@@ -254,8 +335,14 @@ export class RampsController extends BaseController {
254
335
  if (abortController.signal.aborted) {
255
336
  throw error;
256
337
  }
257
- const errorMessage = error?.message;
258
- __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_updateRequestState).call(this, cacheKey, createErrorState(errorMessage ?? 'Unknown error', lastFetchedAt));
338
+ const errorMessage = error?.message ?? 'Unknown error';
339
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_updateRequestState).call(this, cacheKey, createErrorState(errorMessage, lastFetchedAt));
340
+ if (resourceType) {
341
+ const isCurrent = !options?.isResultCurrent || options.isResultCurrent();
342
+ if (isCurrent) {
343
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_setResourceError).call(this, resourceType, errorMessage);
344
+ }
345
+ }
259
346
  throw error;
260
347
  }
261
348
  finally {
@@ -264,6 +351,18 @@ export class RampsController extends BaseController {
264
351
  if (currentPending?.abortController === abortController) {
265
352
  __classPrivateFieldGet(this, _RampsController_pendingRequests, "f").delete(cacheKey);
266
353
  }
354
+ // Clear resource-level loading state only when no requests for this resource remain
355
+ if (resourceType) {
356
+ const count = __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").get(resourceType) ?? 0;
357
+ const next = Math.max(0, count - 1);
358
+ if (next === 0) {
359
+ __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").delete(resourceType);
360
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_setResourceLoading).call(this, resourceType, false);
361
+ }
362
+ else {
363
+ __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").set(resourceType, next);
364
+ }
365
+ }
267
366
  }
268
367
  })();
269
368
  // Store pending request for deduplication
@@ -306,37 +405,43 @@ export class RampsController extends BaseController {
306
405
  async setUserRegion(region, options) {
307
406
  const normalizedRegion = region.toLowerCase().trim();
308
407
  try {
309
- const { countries } = this.state;
310
- if (!countries || countries.length === 0) {
408
+ const countriesData = this.state.countries.data;
409
+ if (!countriesData || countriesData.length === 0) {
311
410
  __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_cleanupState).call(this);
312
411
  throw new Error('No countries found. Cannot set user region without valid country information.');
313
412
  }
314
- const userRegion = findRegionFromCode(normalizedRegion, countries);
413
+ const userRegion = findRegionFromCode(normalizedRegion, countriesData);
315
414
  if (!userRegion) {
316
415
  __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_cleanupState).call(this);
317
416
  throw new Error(`Region "${normalizedRegion}" not found in countries data. Cannot set user region without valid country information.`);
318
417
  }
319
- // Only cleanup state if region is actually changing
320
418
  const regionChanged = normalizedRegion !== this.state.userRegion?.regionCode;
321
- // Set the new region atomically with cleanup to avoid intermediate null state
419
+ const needsRefetch = regionChanged ||
420
+ !this.state.tokens.data ||
421
+ this.state.providers.data.length === 0;
422
+ if (regionChanged) {
423
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_clearPendingResourceCountForDependentResources).call(this);
424
+ }
425
+ if (regionChanged) {
426
+ this.stopQuotePolling();
427
+ }
322
428
  this.update((state) => {
323
429
  if (regionChanged) {
324
- state.selectedProvider = null;
325
- state.selectedToken = null;
326
- state.tokens = null;
327
- state.providers = [];
328
- state.paymentMethods = [];
329
- state.selectedPaymentMethod = null;
330
- state.quotes = null;
430
+ resetDependentResources(state);
331
431
  }
332
432
  state.userRegion = userRegion;
333
433
  });
334
- // Only trigger fetches if region changed or if data is missing
335
- if (regionChanged || !this.state.tokens) {
336
- this.triggerGetTokens(userRegion.regionCode, 'buy', options);
337
- }
338
- if (regionChanged || this.state.providers.length === 0) {
339
- this.triggerGetProviders(userRegion.regionCode, options);
434
+ if (needsRefetch) {
435
+ const refetchPromises = [];
436
+ if (regionChanged || !this.state.tokens.data) {
437
+ refetchPromises.push(this.getTokens(userRegion.regionCode, 'buy', options));
438
+ }
439
+ if (regionChanged || this.state.providers.data.length === 0) {
440
+ refetchPromises.push(this.getProviders(userRegion.regionCode, options));
441
+ }
442
+ if (refetchPromises.length > 0) {
443
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_fireAndForget).call(this, Promise.all(refetchPromises));
444
+ }
340
445
  }
341
446
  return userRegion;
342
447
  }
@@ -355,10 +460,11 @@ export class RampsController extends BaseController {
355
460
  */
356
461
  setSelectedProvider(providerId) {
357
462
  if (providerId === null) {
463
+ this.stopQuotePolling();
358
464
  this.update((state) => {
359
- state.selectedProvider = null;
360
- state.paymentMethods = [];
361
- state.selectedPaymentMethod = null;
465
+ state.providers.selected = null;
466
+ state.paymentMethods.data = [];
467
+ state.paymentMethods.selected = null;
362
468
  });
363
469
  return;
364
470
  }
@@ -366,7 +472,7 @@ export class RampsController extends BaseController {
366
472
  if (!regionCode) {
367
473
  throw new Error('Region is required. Cannot set selected provider without valid region information.');
368
474
  }
369
- const { providers } = this.state;
475
+ const providers = this.state.providers.data;
370
476
  if (!providers || providers.length === 0) {
371
477
  throw new Error('Providers not loaded. Cannot set selected provider before providers are fetched.');
372
478
  }
@@ -375,16 +481,16 @@ export class RampsController extends BaseController {
375
481
  throw new Error(`Provider with ID "${providerId}" not found in available providers.`);
376
482
  }
377
483
  this.update((state) => {
378
- state.selectedProvider = provider;
379
- state.paymentMethods = [];
380
- state.selectedPaymentMethod = null;
381
- });
382
- // fetch payment methods for the new provider
383
- // this is needed because you can change providers without changing the token
384
- // (getPaymentMethods will use state as its default)
385
- this.triggerGetPaymentMethods(regionCode, {
386
- provider: provider.id,
484
+ state.providers.selected = provider;
485
+ state.paymentMethods.data = [];
486
+ state.paymentMethods.selected = null;
487
+ state.quotes.selected = null;
387
488
  });
489
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_fireAndForget).call(this, this.getPaymentMethods(regionCode, { provider: provider.id }).then(() => {
490
+ // Restart quote polling after payment methods are fetched
491
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_restartPollingIfActive).call(this);
492
+ return undefined;
493
+ }));
388
494
  }
389
495
  /**
390
496
  * Initializes the controller by fetching the user's region from geolocation.
@@ -410,8 +516,8 @@ export class RampsController extends BaseController {
410
516
  if (!regionCode) {
411
517
  throw new Error('Region code is required. Cannot hydrate state without valid region information.');
412
518
  }
413
- this.triggerGetTokens(regionCode, 'buy', options);
414
- this.triggerGetProviders(regionCode, options);
519
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_fireAndForget).call(this, this.getTokens(regionCode, 'buy', options));
520
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_fireAndForget).call(this, this.getProviders(regionCode, options));
415
521
  }
416
522
  /**
417
523
  * Fetches the list of supported countries.
@@ -425,9 +531,9 @@ export class RampsController extends BaseController {
425
531
  const cacheKey = createCacheKey('getCountries', []);
426
532
  const countries = await this.executeRequest(cacheKey, async () => {
427
533
  return this.messenger.call('RampsService:getCountries');
428
- }, options);
534
+ }, { ...options, resourceType: 'countries' });
429
535
  this.update((state) => {
430
- state.countries = countries;
536
+ state.countries.data = countries;
431
537
  });
432
538
  return countries;
433
539
  }
@@ -456,11 +562,16 @@ export class RampsController extends BaseController {
456
562
  return this.messenger.call('RampsService:getTokens', normalizedRegion, action, {
457
563
  provider: options?.provider,
458
564
  });
459
- }, options);
565
+ }, {
566
+ ...options,
567
+ resourceType: 'tokens',
568
+ isResultCurrent: () => this.state.userRegion?.regionCode === undefined ||
569
+ this.state.userRegion?.regionCode === normalizedRegion,
570
+ });
460
571
  this.update((state) => {
461
572
  const userRegionCode = state.userRegion?.regionCode;
462
573
  if (userRegionCode === undefined || userRegionCode === normalizedRegion) {
463
- state.tokens = tokens;
574
+ state.tokens.data = tokens;
464
575
  }
465
576
  });
466
577
  return tokens;
@@ -475,10 +586,11 @@ export class RampsController extends BaseController {
475
586
  */
476
587
  setSelectedToken(assetId) {
477
588
  if (!assetId) {
589
+ this.stopQuotePolling();
478
590
  this.update((state) => {
479
- state.selectedToken = null;
480
- state.paymentMethods = [];
481
- state.selectedPaymentMethod = null;
591
+ state.tokens.selected = null;
592
+ state.paymentMethods.data = [];
593
+ state.paymentMethods.selected = null;
482
594
  });
483
595
  return;
484
596
  }
@@ -486,7 +598,7 @@ export class RampsController extends BaseController {
486
598
  if (!regionCode) {
487
599
  throw new Error('Region is required. Cannot set selected token without valid region information.');
488
600
  }
489
- const { tokens } = this.state;
601
+ const tokens = this.state.tokens.data;
490
602
  if (!tokens) {
491
603
  throw new Error('Tokens not loaded. Cannot set selected token before tokens are fetched.');
492
604
  }
@@ -496,13 +608,16 @@ export class RampsController extends BaseController {
496
608
  throw new Error(`Token with asset ID "${assetId}" not found in available tokens.`);
497
609
  }
498
610
  this.update((state) => {
499
- state.selectedToken = token;
500
- state.paymentMethods = [];
501
- state.selectedPaymentMethod = null;
502
- });
503
- this.triggerGetPaymentMethods(regionCode, {
504
- assetId: token.assetId,
611
+ state.tokens.selected = token;
612
+ state.paymentMethods.data = [];
613
+ state.paymentMethods.selected = null;
614
+ state.quotes.selected = null;
505
615
  });
616
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_fireAndForget).call(this, this.getPaymentMethods(regionCode, { assetId: token.assetId }).then(() => {
617
+ // Restart quote polling after payment methods are fetched
618
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_restartPollingIfActive).call(this);
619
+ return undefined;
620
+ }));
506
621
  }
507
622
  /**
508
623
  * Fetches the list of providers for a given region.
@@ -536,11 +651,16 @@ export class RampsController extends BaseController {
536
651
  fiat: options?.fiat,
537
652
  payments: options?.payments,
538
653
  });
539
- }, options);
654
+ }, {
655
+ ...options,
656
+ resourceType: 'providers',
657
+ isResultCurrent: () => this.state.userRegion?.regionCode === undefined ||
658
+ this.state.userRegion?.regionCode === normalizedRegion,
659
+ });
540
660
  this.update((state) => {
541
661
  const userRegionCode = state.userRegion?.regionCode;
542
662
  if (userRegionCode === undefined || userRegionCode === normalizedRegion) {
543
- state.providers = providers;
663
+ state.providers.data = providers;
544
664
  }
545
665
  });
546
666
  return { providers };
@@ -559,8 +679,8 @@ export class RampsController extends BaseController {
559
679
  async getPaymentMethods(region, options) {
560
680
  const regionCode = region ?? this.state.userRegion?.regionCode ?? null;
561
681
  const fiatToUse = options?.fiat ?? this.state.userRegion?.country?.currency ?? null;
562
- const assetIdToUse = options?.assetId ?? this.state.selectedToken?.assetId ?? '';
563
- const providerToUse = options?.provider ?? this.state.selectedProvider?.id ?? '';
682
+ const assetIdToUse = options?.assetId ?? this.state.tokens.selected?.assetId ?? '';
683
+ const providerToUse = options?.provider ?? this.state.providers.selected?.id ?? '';
564
684
  if (!regionCode) {
565
685
  throw new Error('Region is required. Either provide a region parameter or ensure userRegion is set in controller state.');
566
686
  }
@@ -582,21 +702,31 @@ export class RampsController extends BaseController {
582
702
  assetId: assetIdToUse,
583
703
  provider: providerToUse,
584
704
  });
585
- }, options);
705
+ }, {
706
+ ...options,
707
+ resourceType: 'paymentMethods',
708
+ isResultCurrent: () => {
709
+ const regionMatch = this.state.userRegion?.regionCode === undefined ||
710
+ this.state.userRegion?.regionCode === normalizedRegion;
711
+ const tokenMatch = (this.state.tokens.selected?.assetId ?? '') === assetIdToUse;
712
+ const providerMatch = (this.state.providers.selected?.id ?? '') === providerToUse;
713
+ return regionMatch && tokenMatch && providerMatch;
714
+ },
715
+ });
586
716
  this.update((state) => {
587
- const currentAssetId = state.selectedToken?.assetId ?? '';
588
- const currentProviderId = state.selectedProvider?.id ?? '';
717
+ const currentAssetId = state.tokens.selected?.assetId ?? '';
718
+ const currentProviderId = state.providers.selected?.id ?? '';
589
719
  const tokenSelectionUnchanged = assetIdToUse === currentAssetId;
590
720
  const providerSelectionUnchanged = providerToUse === currentProviderId;
591
721
  // this is a race condition check to ensure that the selected token and provider in state are the same as the tokens we're requesting for
592
722
  // ex: if the user rapidly changes the token or provider, the in-flight payment methods might not be valid
593
723
  // so this check will ensure that the payment methods are still valid for the token and provider that were requested
594
724
  if (tokenSelectionUnchanged && providerSelectionUnchanged) {
595
- state.paymentMethods = response.payments;
725
+ state.paymentMethods.data = response.payments;
596
726
  // this will auto-select the first payment method if the selected payment method is not in the new payment methods
597
- const currentSelectionStillValid = response.payments.some((pm) => pm.id === state.selectedPaymentMethod?.id);
727
+ const currentSelectionStillValid = response.payments.some((pm) => pm.id === state.paymentMethods.selected?.id);
598
728
  if (!currentSelectionStillValid) {
599
- state.selectedPaymentMethod = response.payments[0] ?? null;
729
+ state.paymentMethods.selected = response.payments[0] ?? null;
600
730
  }
601
731
  }
602
732
  });
@@ -612,11 +742,11 @@ export class RampsController extends BaseController {
612
742
  setSelectedPaymentMethod(paymentMethodId) {
613
743
  if (!paymentMethodId) {
614
744
  this.update((state) => {
615
- state.selectedPaymentMethod = null;
745
+ state.paymentMethods.selected = null;
616
746
  });
617
747
  return;
618
748
  }
619
- const { paymentMethods } = this.state;
749
+ const paymentMethods = this.state.paymentMethods.data;
620
750
  if (!paymentMethods || paymentMethods.length === 0) {
621
751
  throw new Error('Payment methods not loaded. Cannot set selected payment method before payment methods are fetched.');
622
752
  }
@@ -625,8 +755,10 @@ export class RampsController extends BaseController {
625
755
  throw new Error(`Payment method with ID "${paymentMethodId}" not found in available payment methods.`);
626
756
  }
627
757
  this.update((state) => {
628
- state.selectedPaymentMethod = paymentMethod;
758
+ state.paymentMethods.selected = paymentMethod;
629
759
  });
760
+ // Restart quote polling if active
761
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_restartPollingIfActive).call(this);
630
762
  }
631
763
  /**
632
764
  * Fetches quotes from all providers for a given set of parameters.
@@ -650,7 +782,7 @@ export class RampsController extends BaseController {
650
782
  const regionToUse = options.region ?? this.state.userRegion?.regionCode;
651
783
  const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency;
652
784
  const paymentMethodsToUse = options.paymentMethods ??
653
- this.state.paymentMethods.map((pm) => pm.id);
785
+ this.state.paymentMethods.data.map((pm) => pm.id);
654
786
  const action = options.action ?? 'buy';
655
787
  if (!regionToUse) {
656
788
  throw new Error('Region is required. Either provide a region parameter or ensure userRegion is set in controller state.');
@@ -701,126 +833,170 @@ export class RampsController extends BaseController {
701
833
  }, {
702
834
  forceRefresh: options.forceRefresh,
703
835
  ttl: options.ttl ?? DEFAULT_QUOTES_TTL,
836
+ resourceType: 'quotes',
837
+ isResultCurrent: () => this.state.userRegion?.regionCode === undefined ||
838
+ this.state.userRegion?.regionCode === normalizedRegion,
704
839
  });
705
840
  this.update((state) => {
706
841
  const userRegionCode = state.userRegion?.regionCode;
707
842
  if (userRegionCode === undefined || userRegionCode === normalizedRegion) {
708
- state.quotes = response;
843
+ state.quotes.data = response;
709
844
  }
710
845
  });
711
846
  return response;
712
847
  }
713
848
  /**
714
- * Extracts the widget URL from a quote for redirect providers.
715
- * Returns the widget URL if available, or null if the quote doesn't have one.
716
- *
717
- * @param quote - The quote to extract the widget URL from.
718
- * @returns The widget URL string, or null if not available.
719
- */
720
- getWidgetUrl(quote) {
721
- return quote.quote?.widgetUrl ?? null;
722
- }
723
- // ============================================================
724
- // Sync Trigger Methods
725
- // These fire-and-forget methods are for use in React effects.
726
- // Errors are stored in state and available via selectors.
727
- // ============================================================
728
- /**
729
- * Triggers setting the user region without throwing.
730
- *
731
- * @param region - The region code to set (e.g., "US-CA").
732
- * @param options - Options for cache behavior.
733
- */
734
- triggerSetUserRegion(region, options) {
735
- this.setUserRegion(region, options).catch(() => {
736
- // Error stored in state
737
- });
738
- }
739
- /**
740
- * Triggers fetching countries without throwing.
849
+ * Starts automatic quote polling with a 15-second refresh interval.
850
+ * Fetches quotes immediately and then every 15 seconds.
851
+ * If the response contains exactly one quote, it is auto-selected.
852
+ * If multiple quotes are returned, the existing selection is preserved if still valid.
741
853
  *
742
- * @param options - Options for cache behavior.
854
+ * @param options - Parameters for fetching quotes.
855
+ * @param options.walletAddress - The destination wallet address.
856
+ * @param options.amount - The amount (in fiat for buy, crypto for sell).
857
+ * @param options.redirectUrl - Optional redirect URL after order completion.
858
+ * @throws If required dependencies (region, token, payment method) are not set.
743
859
  */
744
- triggerGetCountries(options) {
745
- this.getCountries(options).catch(() => {
746
- // Error stored in state
747
- });
860
+ startQuotePolling(options) {
861
+ // Validate required dependencies
862
+ const regionCode = this.state.userRegion?.regionCode;
863
+ const token = this.state.tokens.selected;
864
+ const paymentMethod = this.state.paymentMethods.selected;
865
+ if (!regionCode) {
866
+ throw new Error('Region is required. Cannot start quote polling without valid region information.');
867
+ }
868
+ if (!token) {
869
+ throw new Error('Token is required. Cannot start quote polling without a selected token.');
870
+ }
871
+ if (!paymentMethod) {
872
+ throw new Error('Payment method is required. Cannot start quote polling without a selected payment method.');
873
+ }
874
+ // Stop any existing polling first
875
+ this.stopQuotePolling();
876
+ // Store options for restarts (must be after stop to avoid being cleared)
877
+ __classPrivateFieldSet(this, _RampsController_quotePollingOptions, options, "f");
878
+ // Define the fetch function
879
+ const fetchQuotes = () => {
880
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_fireAndForget).call(this, this.getQuotes({
881
+ assetId: token.assetId,
882
+ amount: options.amount,
883
+ walletAddress: options.walletAddress,
884
+ redirectUrl: options.redirectUrl,
885
+ paymentMethods: [paymentMethod.id],
886
+ forceRefresh: true,
887
+ }).then((response) => {
888
+ // Auto-select logic: only when exactly one quote is returned
889
+ this.update((state) => {
890
+ if (response.success.length === 1) {
891
+ state.quotes.selected = response.success[0];
892
+ }
893
+ else {
894
+ // Keep existing selection if still valid, but update with fresh data
895
+ const currentSelection = state.quotes.selected;
896
+ if (currentSelection) {
897
+ const freshQuote = response.success.find((quote) => quote.provider === currentSelection.provider &&
898
+ quote.quote.paymentMethod ===
899
+ currentSelection.quote.paymentMethod);
900
+ // Update with fresh quote data, or clear if no longer valid
901
+ state.quotes.selected = freshQuote ?? null;
902
+ }
903
+ }
904
+ });
905
+ return undefined;
906
+ }));
907
+ };
908
+ // Fetch immediately
909
+ fetchQuotes();
910
+ // Set up 15-second polling
911
+ __classPrivateFieldSet(this, _RampsController_quotePollingInterval, setInterval(fetchQuotes, 15000), "f");
748
912
  }
749
913
  /**
750
- * Triggers fetching tokens without throwing.
751
- *
752
- * @param region - The region code. If not provided, uses userRegion from state.
753
- * @param action - The ramp action type ('buy' or 'sell').
754
- * @param options - Options for cache behavior.
914
+ * Stops automatic quote polling.
915
+ * Does not clear quotes data or selection, only stops the interval.
755
916
  */
756
- triggerGetTokens(region, action = 'buy', options) {
757
- this.getTokens(region, action, options).catch(() => {
758
- // Error stored in state
759
- });
917
+ stopQuotePolling() {
918
+ if (__classPrivateFieldGet(this, _RampsController_quotePollingInterval, "f") !== null) {
919
+ clearInterval(__classPrivateFieldGet(this, _RampsController_quotePollingInterval, "f"));
920
+ __classPrivateFieldSet(this, _RampsController_quotePollingInterval, null, "f");
921
+ }
922
+ __classPrivateFieldSet(this, _RampsController_quotePollingOptions, null, "f");
760
923
  }
761
924
  /**
762
- * Triggers fetching providers without throwing.
925
+ * Manually sets the selected quote.
763
926
  *
764
- * @param region - The region code. If not provided, uses userRegion from state.
765
- * @param options - Options for cache behavior and query filters.
927
+ * @param quote - The quote to select, or null to clear the selection.
766
928
  */
767
- triggerGetProviders(region, options) {
768
- this.getProviders(region, options).catch(() => {
769
- // Error stored in state
929
+ setSelectedQuote(quote) {
930
+ this.update((state) => {
931
+ state.quotes.selected = quote;
770
932
  });
771
933
  }
772
934
  /**
773
- * Triggers fetching payment methods without throwing.
774
- *
775
- * @param region - User's region code (e.g., "us", "fr", "us-ny").
776
- * @param options - Query parameters for filtering payment methods.
777
- * @param options.fiat - Fiat currency code. If not provided, uses userRegion currency.
778
- * @param options.assetId - CAIP-19 cryptocurrency identifier.
779
- * @param options.provider - Provider ID path.
935
+ * Cleans up controller resources.
936
+ * Stops any active quote polling to prevent memory leaks.
937
+ * Should be called when the controller is no longer needed.
780
938
  */
781
- triggerGetPaymentMethods(region, options) {
782
- this.getPaymentMethods(region, options).catch(() => {
783
- // Error stored in state
784
- });
939
+ destroy() {
940
+ this.stopQuotePolling();
941
+ super.destroy();
785
942
  }
786
943
  /**
787
- * Triggers fetching quotes without throwing.
944
+ * Extracts the widget URL from a quote for redirect providers.
945
+ * Returns the widget URL if available, or null if the quote doesn't have one.
788
946
  *
789
- * @param options - The parameters for fetching quotes.
790
- * @param options.region - User's region code. If not provided, uses userRegion from state.
791
- * @param options.fiat - Fiat currency code. If not provided, uses userRegion currency.
792
- * @param options.assetId - CAIP-19 cryptocurrency identifier.
793
- * @param options.amount - The amount (in fiat for buy, crypto for sell).
794
- * @param options.walletAddress - The destination wallet address.
795
- * @param options.paymentMethods - Array of payment method IDs. If not provided, uses paymentMethods from state.
796
- * @param options.provider - Optional provider ID to filter quotes.
797
- * @param options.redirectUrl - Optional redirect URL after order completion.
798
- * @param options.action - The ramp action type. Defaults to 'buy'.
799
- * @param options.forceRefresh - Whether to bypass cache.
800
- * @param options.ttl - Custom TTL for this request.
947
+ * @param quote - The quote to extract the widget URL from.
948
+ * @returns The widget URL string, or null if not available.
801
949
  */
802
- triggerGetQuotes(options) {
803
- this.getQuotes(options).catch(() => {
804
- // Error stored in state
805
- });
950
+ getWidgetUrl(quote) {
951
+ return quote.quote?.widgetUrl ?? null;
806
952
  }
807
953
  }
808
- _RampsController_requestCacheTTL = new WeakMap(), _RampsController_requestCacheMaxSize = new WeakMap(), _RampsController_pendingRequests = new WeakMap(), _RampsController_instances = new WeakSet(), _RampsController_removeRequestState = function _RampsController_removeRequestState(cacheKey) {
954
+ _RampsController_requestCacheTTL = new WeakMap(), _RampsController_requestCacheMaxSize = new WeakMap(), _RampsController_pendingRequests = new WeakMap(), _RampsController_pendingResourceCount = new WeakMap(), _RampsController_quotePollingInterval = new WeakMap(), _RampsController_quotePollingOptions = new WeakMap(), _RampsController_instances = new WeakSet(), _RampsController_clearPendingResourceCountForDependentResources = function _RampsController_clearPendingResourceCountForDependentResources() {
955
+ const types = [
956
+ 'providers',
957
+ 'tokens',
958
+ 'paymentMethods',
959
+ 'quotes',
960
+ ];
961
+ for (const resourceType of types) {
962
+ __classPrivateFieldGet(this, _RampsController_pendingResourceCount, "f").delete(resourceType);
963
+ }
964
+ }, _RampsController_removeRequestState = function _RampsController_removeRequestState(cacheKey) {
809
965
  this.update((state) => {
810
966
  const requests = state.requests;
811
967
  delete requests[cacheKey];
812
968
  });
813
969
  }, _RampsController_cleanupState = function _RampsController_cleanupState() {
970
+ this.stopQuotePolling();
971
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_clearPendingResourceCountForDependentResources).call(this);
972
+ this.update((state) => resetDependentResources(state, {
973
+ clearUserRegionData: true,
974
+ }));
975
+ }, _RampsController_fireAndForget = function _RampsController_fireAndForget(promise) {
976
+ promise.catch((_error) => undefined);
977
+ }, _RampsController_restartPollingIfActive = function _RampsController_restartPollingIfActive() {
978
+ if (__classPrivateFieldGet(this, _RampsController_quotePollingInterval, "f") !== null && __classPrivateFieldGet(this, _RampsController_quotePollingOptions, "f")) {
979
+ const options = __classPrivateFieldGet(this, _RampsController_quotePollingOptions, "f");
980
+ this.stopQuotePolling();
981
+ try {
982
+ this.startQuotePolling(options);
983
+ }
984
+ catch {
985
+ // Dependencies not met yet, polling will need to be manually restarted
986
+ // when dependencies are available
987
+ }
988
+ }
989
+ }, _RampsController_updateResourceField = function _RampsController_updateResourceField(resourceType, field, value) {
814
990
  this.update((state) => {
815
- state.userRegion = null;
816
- state.selectedProvider = null;
817
- state.selectedToken = null;
818
- state.tokens = null;
819
- state.providers = [];
820
- state.paymentMethods = [];
821
- state.selectedPaymentMethod = null;
822
- state.quotes = null;
991
+ const resource = state[resourceType];
992
+ if (resource) {
993
+ resource[field] = value;
994
+ }
823
995
  });
996
+ }, _RampsController_setResourceLoading = function _RampsController_setResourceLoading(resourceType, loading) {
997
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_updateResourceField).call(this, resourceType, 'isLoading', loading);
998
+ }, _RampsController_setResourceError = function _RampsController_setResourceError(resourceType, error) {
999
+ __classPrivateFieldGet(this, _RampsController_instances, "m", _RampsController_updateResourceField).call(this, resourceType, 'error', error);
824
1000
  }, _RampsController_updateRequestState = function _RampsController_updateRequestState(cacheKey, requestState) {
825
1001
  const maxSize = __classPrivateFieldGet(this, _RampsController_requestCacheMaxSize, "f");
826
1002
  const ttl = __classPrivateFieldGet(this, _RampsController_requestCacheTTL, "f");