@metamask/ramps-controller 21.0.0 → 22.0.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/RampsController-method-action-types.d.ts +16 -1
  3. package/dist/RampsController-method-action-types.d.ts.map +1 -1
  4. package/dist/RampsController-method-action-types.js.map +1 -1
  5. package/dist/RampsController.d.ts +69 -6
  6. package/dist/RampsController.d.ts.map +1 -1
  7. package/dist/RampsController.js +251 -21
  8. package/dist/RampsController.js.map +1 -1
  9. package/dist/RampsService.d.ts +6 -0
  10. package/dist/RampsService.d.ts.map +1 -1
  11. package/dist/RampsService.js.map +1 -1
  12. package/dist/index.d.ts +3 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/order-syncing/constants.d.ts +23 -0
  17. package/dist/order-syncing/constants.d.ts.map +1 -0
  18. package/dist/order-syncing/constants.js +23 -0
  19. package/dist/order-syncing/constants.js.map +1 -0
  20. package/dist/order-syncing/controller-integration.d.ts +91 -0
  21. package/dist/order-syncing/controller-integration.d.ts.map +1 -0
  22. package/dist/order-syncing/controller-integration.js +364 -0
  23. package/dist/order-syncing/controller-integration.js.map +1 -0
  24. package/dist/order-syncing/index.d.ts +7 -0
  25. package/dist/order-syncing/index.d.ts.map +1 -0
  26. package/dist/order-syncing/index.js +5 -0
  27. package/dist/order-syncing/index.js.map +1 -0
  28. package/dist/order-syncing/sync-utils.d.ts +9 -0
  29. package/dist/order-syncing/sync-utils.d.ts.map +1 -0
  30. package/dist/order-syncing/sync-utils.js +27 -0
  31. package/dist/order-syncing/sync-utils.js.map +1 -0
  32. package/dist/order-syncing/types.d.ts +66 -0
  33. package/dist/order-syncing/types.d.ts.map +1 -0
  34. package/dist/order-syncing/types.js +2 -0
  35. package/dist/order-syncing/types.js.map +1 -0
  36. package/dist/order-syncing/utils.d.ts +83 -0
  37. package/dist/order-syncing/utils.d.ts.map +1 -0
  38. package/dist/order-syncing/utils.js +144 -0
  39. package/dist/order-syncing/utils.js.map +1 -0
  40. package/package.json +4 -3
@@ -2,6 +2,7 @@ import { BaseController } from '@metamask/base-controller';
2
2
  import { BrokenCircuitError } from '@metamask/controller-utils';
3
3
  import { applyAutorampRemoteStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js';
4
4
  import { getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js';
5
+ import { areOrdersEqual, deleteOrderInUserStorage, syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, updateOrderInUserStorage, } from './order-syncing/index.js';
5
6
  import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js';
6
7
  import { buildOwnershipMessage } from './ownership-message.js';
7
8
  import { mergePaymentMethodsById, pickPaymentMethod, } from './paymentMethodMerge.js';
@@ -71,12 +72,17 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [
71
72
  * `AuthenticationController:getSessionProfile` resolves the vendor customer
72
73
  * identity from Profile Sync, and `KeyringController:signPersonalMessage` signs
73
74
  * the EIP-191 ownership proof for Money Account self-hosted wallet
74
- * registration; both are only exercised by the autoramp paths.
75
+ * registration; both are only exercised by the autoramp paths. User Storage
76
+ * and authentication actions support cross-client order syncing.
75
77
  */
76
78
  export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [
77
79
  'AuthenticationController:getSessionProfile',
80
+ 'AuthenticationController:isSignedIn',
78
81
  'KeyringController:signPersonalMessage',
79
82
  'RemoteFeatureFlagController:getState',
83
+ 'UserStorageController:getState',
84
+ 'UserStorageController:performGetStorageAllFeatureEntries',
85
+ 'UserStorageController:performBatchSetStorage',
80
86
  ];
81
87
  /**
82
88
  * Distinguishes an already-materialized {@link AutorampAccount} from the
@@ -369,15 +375,19 @@ function findRegionFromCode(regionCode, countries) {
369
375
  */
370
376
  export function getInternalOrderCode(orderOrId) {
371
377
  if (typeof orderOrId === 'string') {
372
- return orderOrId.includes('/orders/')
373
- ? orderOrId.split('/orders/')[1]
374
- : orderOrId;
378
+ if (orderOrId.includes('/orders/')) {
379
+ return orderOrId.split('/orders/')[1]?.trim() || '';
380
+ }
381
+ return orderOrId.trim();
375
382
  }
376
383
  const { id, providerOrderId } = orderOrId;
377
384
  if (id?.includes('/orders/')) {
378
- return id.split('/orders/')[1];
385
+ const code = id.split('/orders/')[1]?.trim();
386
+ if (code) {
387
+ return code;
388
+ }
379
389
  }
380
- return providerOrderId;
390
+ return providerOrderId?.trim() ?? '';
381
391
  }
382
392
  // === ORDER POLLING CONSTANTS ===
383
393
  const DEFAULT_POLLING_INTERVAL_MS = 30_000;
@@ -442,6 +452,7 @@ const MESSENGER_EXPOSED_METHODS = [
442
452
  'transakCancelOrder',
443
453
  'transakCancelAllActiveOrders',
444
454
  'transakGetActiveOrders',
455
+ 'syncOrdersWithUserStorage',
445
456
  ];
446
457
  /**
447
458
  * Whether controller state still describes the context a payment-method
@@ -472,6 +483,8 @@ export class RampsController extends BaseController {
472
483
  * Maximum number of entries in the request cache.
473
484
  */
474
485
  #requestCacheMaxSize;
486
+ #onOrderSyncErroneousSituation;
487
+ #trace;
475
488
  /**
476
489
  * Map of pending requests for deduplication.
477
490
  * Key is the cache key, value is the pending request with abort controller.
@@ -491,6 +504,75 @@ export class RampsController extends BaseController {
491
504
  #orderPollingTimer = null;
492
505
  #isPolling = false;
493
506
  #initPromise = null;
507
+ /**
508
+ * Semaphore that prevents sync feedback loops while applying remote order changes.
509
+ */
510
+ #isOrderSyncingInProgress = false;
511
+ /**
512
+ * Whether the full sync is applying its own local state changes.
513
+ */
514
+ #isApplyingOrderSyncChanges = false;
515
+ /**
516
+ * Orders deleted locally while a full sync held the semaphore.
517
+ */
518
+ #pendingRemoteDeletes = new Map();
519
+ /**
520
+ * Coalesces overlapping `syncOrdersWithUserStorage` calls into a follow-up run.
521
+ */
522
+ #orderSyncQueued = false;
523
+ #orderSyncPromise = null;
524
+ /**
525
+ * Whether a full order sync is currently applying remote changes.
526
+ *
527
+ * @returns Whether order sync is in progress.
528
+ */
529
+ get isOrderSyncingInProgress() {
530
+ return this.#isOrderSyncingInProgress;
531
+ }
532
+ /**
533
+ * Sets the order-syncing-in-progress semaphore.
534
+ * Used by the order-syncing module; hosts should not call this.
535
+ *
536
+ * @param value - Whether sync is in progress.
537
+ * @internal
538
+ */
539
+ setIsOrderSyncingInProgress(value) {
540
+ this.#isOrderSyncingInProgress = value;
541
+ }
542
+ /**
543
+ * Distinguishes full sync's own state changes from external mutations.
544
+ *
545
+ * @param value - Whether sync changes are being applied.
546
+ * @internal
547
+ */
548
+ setIsApplyingOrderSyncChanges(value) {
549
+ this.#isApplyingOrderSyncChanges = value;
550
+ }
551
+ /**
552
+ * Returns orders deleted while a full sync was in progress.
553
+ * Used by the order-syncing module.
554
+ *
555
+ * @returns Pending deletes for remote tombstone upload.
556
+ * @internal
557
+ */
558
+ getPendingRemoteDeletes() {
559
+ return [...this.#pendingRemoteDeletes.values()];
560
+ }
561
+ /**
562
+ * Clears deletes whose tombstones were successfully persisted. A delete
563
+ * replaced while a write was in flight remains pending.
564
+ *
565
+ * @param orders - Deletes included in a successful remote write.
566
+ * @internal
567
+ */
568
+ acknowledgePendingRemoteDeletes(orders) {
569
+ for (const order of orders) {
570
+ const key = getInternalOrderCode(order);
571
+ if (key && this.#pendingRemoteDeletes.get(key) === order) {
572
+ this.#pendingRemoteDeletes.delete(key);
573
+ }
574
+ }
575
+ }
494
576
  /**
495
577
  * Clears the pending resource count map. Used only in tests to exercise the
496
578
  * defensive path when get() returns undefined in the finally block.
@@ -526,8 +608,10 @@ export class RampsController extends BaseController {
526
608
  * controller. Missing properties will be filled in with defaults.
527
609
  * @param args.requestCacheTTL - Time to live for cached requests in milliseconds.
528
610
  * @param args.requestCacheMaxSize - Maximum number of entries in the request cache.
611
+ * @param args.onOrderSyncErroneousSituation - Optional order-sync error reporter.
612
+ * @param args.trace - Optional performance tracing callback for order sync.
529
613
  */
530
- constructor({ messenger, state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, }) {
614
+ constructor({ messenger, state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, onOrderSyncErroneousSituation, trace, }) {
531
615
  super({
532
616
  messenger,
533
617
  metadata: rampsControllerMetadata,
@@ -541,6 +625,8 @@ export class RampsController extends BaseController {
541
625
  });
542
626
  this.#requestCacheTTL = requestCacheTTL;
543
627
  this.#requestCacheMaxSize = requestCacheMaxSize;
628
+ this.#onOrderSyncErroneousSituation = onOrderSyncErroneousSituation;
629
+ this.#trace = trace;
544
630
  this.messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS);
545
631
  }
546
632
  /**
@@ -1841,17 +1927,59 @@ export class RampsController extends BaseController {
1841
1927
  * If an order with the same internal order code already exists, the incoming
1842
1928
  * fields are merged on top of the existing order so that fields not present
1843
1929
  * in the update (e.g. paymentDetails from the Transak API) are preserved.
1930
+ * Unchanged syncable payloads (including unchanged poll results) are ignored
1931
+ * so `lastUpdatedAt` is not bumped and User Storage is not rewritten.
1844
1932
  *
1845
1933
  * @param order - The RampsOrder to add or update.
1846
1934
  */
1847
1935
  addOrder(order) {
1848
1936
  const internalOrderCode = getInternalOrderCode(order);
1937
+ if (!internalOrderCode) {
1938
+ this.#onOrderSyncErroneousSituation?.('Unable to derive internal order code for addOrder', {});
1939
+ return;
1940
+ }
1941
+ const existing = this.state.orders.find((existingOrder) => getInternalOrderCode(existingOrder) === internalOrderCode);
1942
+ if (existing &&
1943
+ !this.#isApplyingOrderSyncChanges &&
1944
+ areOrdersEqual(existing, order)) {
1945
+ // Syncable payload unchanged; check if paymentDetails differ.
1946
+ // paymentDetails is local-only (never synced remotely), so if it's the
1947
+ // only change we merge it without bumping lastUpdatedAt or writing remotely.
1948
+ if (order.paymentDetails &&
1949
+ JSON.stringify(existing.paymentDetails) !==
1950
+ JSON.stringify(order.paymentDetails)) {
1951
+ this.update((state) => {
1952
+ const idx = state.orders.findIndex((stateOrder) => getInternalOrderCode(stateOrder) === internalOrderCode);
1953
+ if (idx !== -1) {
1954
+ state.orders[idx] = {
1955
+ ...state.orders[idx],
1956
+ paymentDetails: order.paymentDetails,
1957
+ };
1958
+ }
1959
+ });
1960
+ }
1961
+ return;
1962
+ }
1963
+ const incomingLastUpdatedAt = order.lastUpdatedAt;
1964
+ // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them
1965
+ // over stale remote copies when an incremental push was skipped/failed.
1966
+ // This includes external edits mid-sync (e.g. polling via `getOrder`),
1967
+ // which the queued follow-up sync must not lose under LWW.
1968
+ // Only when sync applies its own imported orders do we preserve the remote
1969
+ // `lu` / `createdAt` (never invent "now" for missing `lu`, or stale remotes
1970
+ // win later LWW comparisons).
1849
1971
  const healedOrder = {
1850
1972
  ...order,
1851
1973
  providerOrderId: internalOrderCode,
1974
+ lastUpdatedAt: this.#isApplyingOrderSyncChanges
1975
+ ? (incomingLastUpdatedAt ?? order.createdAt ?? 0)
1976
+ : Date.now(),
1852
1977
  };
1978
+ if (!this.#isApplyingOrderSyncChanges) {
1979
+ this.#pendingRemoteDeletes.delete(internalOrderCode);
1980
+ }
1853
1981
  this.update((state) => {
1854
- const idx = state.orders.findIndex((existing) => getInternalOrderCode(existing) === internalOrderCode);
1982
+ const idx = state.orders.findIndex((stateOrder) => getInternalOrderCode(stateOrder) === internalOrderCode);
1855
1983
  if (idx === -1) {
1856
1984
  state.orders.push(healedOrder);
1857
1985
  }
@@ -1862,6 +1990,22 @@ export class RampsController extends BaseController {
1862
1990
  };
1863
1991
  }
1864
1992
  });
1993
+ if (this.#isOrderSyncingInProgress && !this.#isApplyingOrderSyncChanges) {
1994
+ // Incremental push is suppressed during full sync; queue another full
1995
+ // sync pass so mutations during the upload await are not dropped.
1996
+ this.#orderSyncQueued = true;
1997
+ }
1998
+ else if (!this.#isOrderSyncingInProgress) {
1999
+ updateOrderInUserStorage(healedOrder, {
2000
+ getRampsControllerInstance: () => this,
2001
+ getMessenger: () => this.messenger,
2002
+ }, {
2003
+ onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation,
2004
+ }).catch((error) => {
2005
+ console.error('Error updating ramps order in remote storage:', error);
2006
+ this.#onOrderSyncErroneousSituation?.('Error updating ramps order in remote storage', { error });
2007
+ });
2008
+ }
1865
2009
  }
1866
2010
  /**
1867
2011
  * Removes a V2 order from controller state by providerOrderId.
@@ -1869,10 +2013,101 @@ export class RampsController extends BaseController {
1869
2013
  * @param providerOrderId - The provider order ID to remove.
1870
2014
  */
1871
2015
  removeOrder(providerOrderId) {
2016
+ const orderToRemove = this.state.orders.find((order) => order.providerOrderId === providerOrderId ||
2017
+ getInternalOrderCode(order) === providerOrderId);
1872
2018
  this.update((state) => {
1873
- state.orders = state.orders.filter((order) => order.providerOrderId !== providerOrderId);
2019
+ state.orders = state.orders.filter((order) => order.providerOrderId !== providerOrderId &&
2020
+ getInternalOrderCode(order) !== providerOrderId);
1874
2021
  });
1875
2022
  this.#orderPollingMeta.delete(providerOrderId);
2023
+ if (orderToRemove) {
2024
+ if (orderToRemove.providerOrderId) {
2025
+ this.#orderPollingMeta.delete(orderToRemove.providerOrderId);
2026
+ }
2027
+ const internalOrderCode = getInternalOrderCode(orderToRemove);
2028
+ this.#orderPollingMeta.delete(internalOrderCode);
2029
+ }
2030
+ if (orderToRemove) {
2031
+ const deleteKey = getInternalOrderCode(orderToRemove);
2032
+ const isLocalDeletion = !this.#isApplyingOrderSyncChanges;
2033
+ if (isLocalDeletion && deleteKey) {
2034
+ // Retain the delete until a full sync confirms its tombstone was
2035
+ // persisted. This prevents a failed incremental write from allowing
2036
+ // the still-active remote copy to be imported again.
2037
+ this.#pendingRemoteDeletes.set(deleteKey, orderToRemove);
2038
+ }
2039
+ if (this.#isOrderSyncingInProgress) {
2040
+ if (isLocalDeletion) {
2041
+ // Incremental remote deletes are gated off during full sync; queue a
2042
+ // tombstone write and another full sync pass so deletes during the
2043
+ // upload await are not dropped.
2044
+ this.#orderSyncQueued = true;
2045
+ }
2046
+ }
2047
+ else if (isLocalDeletion) {
2048
+ deleteOrderInUserStorage(orderToRemove, {
2049
+ getRampsControllerInstance: () => this,
2050
+ getMessenger: () => this.messenger,
2051
+ }, {
2052
+ onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation,
2053
+ }).catch((error) => {
2054
+ console.error('Error deleting ramps order from remote storage:', error);
2055
+ this.#onOrderSyncErroneousSituation?.('Error deleting ramps order from remote storage', { error });
2056
+ });
2057
+ }
2058
+ }
2059
+ }
2060
+ /**
2061
+ * Bidirectionally syncs V2 ramps orders with User Storage.
2062
+ * Hosts should call this on unlock / when ramps syncing is enabled.
2063
+ *
2064
+ * Overlapping calls are coalesced into the in-flight worker. After the worker
2065
+ * settles, this method loops when `#orderSyncQueued` is still set so a
2066
+ * request that arrived between the worker's last loop check and promise
2067
+ * resolution is not dropped.
2068
+ */
2069
+ async syncOrdersWithUserStorage() {
2070
+ this.#orderSyncQueued = true;
2071
+ let syncError;
2072
+ while (this.#orderSyncQueued || this.#orderSyncPromise) {
2073
+ if (this.#orderSyncPromise) {
2074
+ try {
2075
+ await this.#orderSyncPromise;
2076
+ }
2077
+ catch (error) {
2078
+ syncError ??=
2079
+ error instanceof Error ? error : new Error(String(error));
2080
+ }
2081
+ continue;
2082
+ }
2083
+ this.#orderSyncPromise = (async () => {
2084
+ while (this.#orderSyncQueued) {
2085
+ this.#orderSyncQueued = false;
2086
+ await syncOrdersWithUserStorageInternal({
2087
+ onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation,
2088
+ }, {
2089
+ getRampsControllerInstance: () => this,
2090
+ getMessenger: () => this.messenger,
2091
+ trace: this.#trace,
2092
+ });
2093
+ }
2094
+ // Yield so a caller can set `#orderSyncQueued` after the inner while
2095
+ // check and still be observed by the outer loop.
2096
+ await Promise.resolve();
2097
+ })();
2098
+ try {
2099
+ await this.#orderSyncPromise;
2100
+ }
2101
+ catch (error) {
2102
+ syncError ??= error instanceof Error ? error : new Error(String(error));
2103
+ }
2104
+ finally {
2105
+ this.#orderSyncPromise = null;
2106
+ }
2107
+ }
2108
+ if (syncError) {
2109
+ throw syncError;
2110
+ }
1876
2111
  }
1877
2112
  /**
1878
2113
  * Adds or updates a local autoramp last-seen cursor (e.g. after create).
@@ -2393,6 +2628,7 @@ export class RampsController extends BaseController {
2393
2628
  * @returns The unified order data.
2394
2629
  */
2395
2630
  async getOrder(providerCode, orderCode, wallet) {
2631
+ const hadOrderAtRequestStart = this.state.orders.some((existingOrder) => getInternalOrderCode(existingOrder) === orderCode);
2396
2632
  const order = await this.messenger.call('RampsService:getOrder', providerCode, orderCode, wallet);
2397
2633
  const healedWalletAddress = order.walletAddress || wallet;
2398
2634
  const internalOrderCode = getInternalOrderCode({
@@ -2404,18 +2640,12 @@ export class RampsController extends BaseController {
2404
2640
  walletAddress: healedWalletAddress,
2405
2641
  providerOrderId: internalOrderCode,
2406
2642
  };
2407
- this.update((state) => {
2408
- const idx = state.orders.findIndex((existing) => getInternalOrderCode(existing) === internalOrderCode);
2409
- if (idx === -1) {
2410
- state.orders.push(healedOrder);
2411
- }
2412
- else {
2413
- state.orders[idx] = {
2414
- ...state.orders[idx],
2415
- ...healedOrder,
2416
- };
2417
- }
2418
- });
2643
+ const orderStillExists = this.state.orders.some((existingOrder) => getInternalOrderCode(existingOrder) === internalOrderCode);
2644
+ // A polling request can finish after removeOrder. Do not let that stale
2645
+ // response recreate the local order and overwrite its remote tombstone.
2646
+ if (!hadOrderAtRequestStart || orderStillExists) {
2647
+ this.addOrder(healedOrder);
2648
+ }
2419
2649
  return healedOrder;
2420
2650
  }
2421
2651
  /**