From 36b3e2b6564af861ae88a7b2739351bcd7c4b1c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 09:13:44 +0000 Subject: [PATCH 01/31] feat(ramps-controller): add V2 ramps order syncing via User Storage (#9474) - Add order-syncing module storing RampsOrder objects (without paymentDetails) as per-order User Storage entries under rampsOrders feature - RampsController.syncOrdersWithUserStorage() performs bidirectional sync with timestamp-based conflict resolution and soft deletes - addOrder/removeOrder incrementally push when Backup & Sync and isRampsSyncingEnabled are on - Optional onOrderSyncErroneousSituation and trace callbacks for host observability BREAKING CHANGES: - RampsControllerMessenger requires UserStorageController storage actions and AuthenticationController:isSignedIn Fixes and improvements: - Default isRampsSyncingEnabled to true when absent from state - Route incremental writes through performBatchSetStorage for IDs with hyphens - Re-read local orders before upload so mid-sync additions sync correctly - Queue mid-sync deletes for tombstone writes at sync end - Coalesce overlapping sync calls - Preserve remote createdAt as lastUpdatedAt when importing - Apply LWW rules to tombstones - Prefer freshest copy across entropy profiles - Trim providerOrderId in key derivation - Clear polling metadata on removeOrder Co-authored-by: George Weiler --- packages/profile-sync-controller/CHANGELOG.md | 8 + .../UserStorageController.test.ts | 30 + .../user-storage/UserStorageController.ts | 15 + .../src/controllers/user-storage/constants.ts | 1 + .../RampsController-method-action-types.ts | 10 + .../src/RampsController.order-syncing.test.ts | 377 ++++++ .../src/RampsController.test.ts | 86 +- packages/ramps-controller/src/index.ts | 25 + .../src/order-syncing/constants.ts | 25 + .../controller-integration.test.ts | 1197 +++++++++++++++++ .../order-syncing/controller-integration.ts | 526 ++++++++ .../src/order-syncing/index.ts | 29 + .../src/order-syncing/sync-utils.test.ts | 83 ++ .../src/order-syncing/sync-utils.ts | 39 + .../src/order-syncing/types.ts | 67 + .../src/order-syncing/utils.test.ts | 257 ++++ .../src/order-syncing/utils.ts | 162 +++ 17 files changed, 2936 insertions(+), 1 deletion(-) create mode 100644 packages/ramps-controller/src/RampsController.order-syncing.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/constants.ts create mode 100644 packages/ramps-controller/src/order-syncing/controller-integration.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/controller-integration.ts create mode 100644 packages/ramps-controller/src/order-syncing/index.ts create mode 100644 packages/ramps-controller/src/order-syncing/sync-utils.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/sync-utils.ts create mode 100644 packages/ramps-controller/src/order-syncing/types.ts create mode 100644 packages/ramps-controller/src/order-syncing/utils.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/utils.ts diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 94f88ea6a8d..48ffcacedde 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `BACKUPANDSYNC_FEATURES.rampsSyncing` and `UserStorageController.isRampsSyncingEnabled` ([#9474](https://github.com/MetaMask/core/pull/9474)) + +### Changed + +- **BREAKING:** `UserStorageControllerState` now includes `isRampsSyncingEnabled`. Consumers that construct full state objects must include this field. ([#9474](https://github.com/MetaMask/core/pull/9474)) + ## [28.3.0] ### Added diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index 829c450ef21..2032f6d860a 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -655,6 +655,7 @@ describe('UserStorageController', () => { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, }, }); @@ -666,6 +667,31 @@ describe('UserStorageController', () => { expect(controller.state.isAccountSyncingEnabled).toBe(false); expect(controller.state.isBackupAndSyncEnabled).toBe(true); }); + + it('should not disable backup and sync when disabling ramps syncing', async () => { + const { messengerMocks } = await arrangeMocks(); + messengerMocks.mockAuthIsSignedIn.mockReturnValue(false); + + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + state: { + isBackupAndSyncEnabled: true, + isBackupAndSyncUpdateLoading: false, + isAccountSyncingEnabled: true, + isContactSyncingEnabled: true, + isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, + }, + }); + + await controller.setIsBackupAndSyncFeatureEnabled( + BACKUPANDSYNC_FEATURES.rampsSyncing, + false, + ); + + expect(controller.state.isRampsSyncingEnabled).toBe(false); + expect(controller.state.isBackupAndSyncEnabled).toBe(true); + }); }); describe('error handling edge cases', () => { @@ -909,6 +935,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -929,6 +956,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -945,6 +973,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -967,6 +996,7 @@ describe('metadata', () => { "isBackupAndSyncUpdateLoading": false, "isContactSyncingEnabled": true, "isContactSyncingInProgress": false, + "isRampsSyncingEnabled": true, } `); }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index ae4d54ac654..319277fe903 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -70,6 +70,10 @@ export type UserStorageControllerState = { * Condition used by UI to determine if contact syncing is in progress. */ isContactSyncingInProgress: boolean; + /** + * Condition used by UI to determine if ramps order syncing is enabled. + */ + isRampsSyncingEnabled: boolean; }; export const defaultState: UserStorageControllerState = { @@ -78,6 +82,7 @@ export const defaultState: UserStorageControllerState = { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, }; const metadata: StateMetadata = { @@ -111,6 +116,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + isRampsSyncingEnabled: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; type ControllerConfig = { @@ -646,6 +657,10 @@ export class UserStorageController extends BaseController< if (feature === BACKUPANDSYNC_FEATURES.contactSyncing) { state.isContactSyncingEnabled = enabled; } + + if (feature === BACKUPANDSYNC_FEATURES.rampsSyncing) { + state.isRampsSyncingEnabled = enabled; + } }); } catch (e) { // istanbul ignore next diff --git a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts index 4b3efff235b..4ed73b30975 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts @@ -2,6 +2,7 @@ export const BACKUPANDSYNC_FEATURES = { main: 'main', accountSyncing: 'accountSyncing', contactSyncing: 'contactSyncing', + rampsSyncing: 'rampsSyncing', } as const; /** diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index e8bf7133789..beac69dd3b5 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -250,6 +250,15 @@ export type RampsControllerRemoveOrderAction = { handler: RampsController['removeOrder']; }; +/** + * Bidirectionally syncs V2 ramps orders with User Storage. + * Hosts should call this on unlock / when ramps syncing is enabled. + */ +export type RampsControllerSyncOrdersWithUserStorageAction = { + type: `RampsController:syncOrdersWithUserStorage`; + handler: RampsController['syncOrdersWithUserStorage']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -658,6 +667,7 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerSyncOrdersWithUserStorageAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts new file mode 100644 index 00000000000..0d245a0b96c --- /dev/null +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -0,0 +1,377 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import type { RampsControllerMessenger } from './RampsController.js'; +import { + RampsController, + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, +} from './RampsController.js'; +import { RampsOrderStatus } from './RampsService.js'; +import type { RampsOrder } from './RampsService.js'; + +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak/orders/abc-123', + isOnlyLink: false, + provider: { id: 'transak', name: 'Transak' } as RampsOrder['provider'], + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + ...overrides, + }; +} + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function setupControllerWithOrderSyncingMocks(): { + controller: RampsController; + performBatchSetStorage: jest.Mock; + performGetStorageAllFeatureEntries: jest.Mock; +} { + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue([] as string[]); + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler('UserStorageController:getState', () => ({ + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + })); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + performGetStorageAllFeatureEntries, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + performBatchSetStorage, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + + const messenger: RampsControllerMessenger = new Messenger({ + namespace: 'RampsController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', + ], + }); + + const controller = new RampsController({ messenger }); + + return { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + }; +} + +describe('RampsController order syncing', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('exposes the order syncing semaphore getter and setter', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + expect(controller.isOrderSyncingInProgress).toBe(false); + controller.setIsOrderSyncingInProgress(true); + expect(controller.isOrderSyncingInProgress).toBe(true); + controller.setIsOrderSyncingInProgress(false); + expect(controller.isOrderSyncingInProgress).toBe(false); + }); + + it('delegates syncOrdersWithUserStorage to the order-syncing module', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + performGetStorageAllFeatureEntries.mockResolvedValue([]); + + await controller.syncOrdersWithUserStorage(); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalled(); + }); + + it('stamps lastUpdatedAt on local addOrder edits', () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.addOrder({ + providerOrderId: 'order-1', + status: 'PENDING', + createdAt: 1, + } as never); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'order-1', + lastUpdatedAt: 1_700_000_000_000, + }), + ); + }); + + it('logs incremental addOrder remote sync failures without throwing', async () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + performBatchSetStorage.mockRejectedValue(new Error('remote write failed')); + + controller.addOrder(createMockOrder()); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error updating ramps order in remote storage:', + expect.any(Error), + ); + }); + + it('logs incremental removeOrder remote sync failures without throwing', async () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + performBatchSetStorage.mockRejectedValue(new Error('remote delete failed')); + + controller.addOrder(createMockOrder()); + controller.removeOrder('abc-123'); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error deleting ramps order from remote storage:', + expect.any(Error), + ); + }); + + it('skips incremental remote writes while full sync is in progress', async () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder(createMockOrder()); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('queues mid-sync deletes and drains them after sync', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_700); + const { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = setupControllerWithOrderSyncingMocks(); + + const order = createMockOrder({ + providerOrderId: 'mid-sync-delete', + id: '/providers/transak/orders/mid-sync-delete', + }); + controller.addOrder(order); + performBatchSetStorage.mockClear(); + + performGetStorageAllFeatureEntries.mockImplementation(async () => { + controller.removeOrder('mid-sync-delete'); + return []; + }); + + await controller.syncOrdersWithUserStorage(); + + expect(controller.state.orders).toHaveLength(0); + expect(performBatchSetStorage).toHaveBeenCalledWith( + 'rampsOrders', + expect.arrayContaining([ + expect.arrayContaining(['mid-sync-delete', expect.any(String)]), + ]), + ); + const tombstone = JSON.parse( + (performBatchSetStorage.mock.calls[0][1] as [string, string][]).find( + ([key]) => key === 'mid-sync-delete', + )?.[1] as string, + ) as { dt?: number }; + expect(tombstone.dt).toBe(1_700_000_000_700); + }); + + it('coalesces overlapping syncOrdersWithUserStorage calls', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let releaseFirstFetch: (() => void) | undefined; + const firstFetchStarted = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + + performGetStorageAllFeatureEntries + .mockImplementationOnce(async () => { + await firstFetchStarted; + return []; + }) + .mockResolvedValue([]); + + const firstSync = controller.syncOrdersWithUserStorage(); + const secondSync = controller.syncOrdersWithUserStorage(); + + releaseFirstFetch?.(); + await Promise.all([firstSync, secondSync]); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(2); + }); + + it('handles sync requests that arrive after worker checks flag but before completion', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let resolveLateRequest: (() => void) | undefined; + const lateRequestQueued = new Promise((resolve) => { + resolveLateRequest = resolve; + }); + + let firstSyncNearCompletion = false; + performGetStorageAllFeatureEntries.mockImplementation(async () => { + if (firstSyncNearCompletion) { + return []; + } + firstSyncNearCompletion = true; + await lateRequestQueued; + return []; + }); + + const firstSync = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const lateSync = controller.syncOrdersWithUserStorage(); + resolveLateRequest?.(); + + await Promise.all([firstSync, lateSync]); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(2); + }); + + it('recursively calls syncOrdersWithUserStorage when flag is still set after waiting', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let unblockFirst: (() => void) | undefined; + const firstBlocked = new Promise((resolve) => { + unblockFirst = resolve; + }); + + let callCount = 0; + performGetStorageAllFeatureEntries.mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + await firstBlocked; + } + return []; + }); + + const firstSync = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + const secondSync = controller.syncOrdersWithUserStorage(); + + unblockFirst?.(); + await Promise.all([firstSync, secondSync]); + + expect(callCount).toBe(2); + }); + + it('preserves createdAt as lastUpdatedAt when syncing remotes without lu', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder({ + ...createMockOrder({ + providerOrderId: 'no-lu', + id: '/providers/transak/orders/no-lu', + createdAt: 1_111, + }), + }); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'no-lu', + lastUpdatedAt: 1_111, + }), + ); + }); + + it('uses 0 lastUpdatedAt when syncing remotes without lu or createdAt', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder({ + providerOrderId: 'no-timestamps', + id: '/providers/transak/orders/no-timestamps', + } as never); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'no-timestamps', + lastUpdatedAt: 0, + }), + ); + }); + + it('ignores addOrder calls that cannot derive a storage key', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.addOrder({ + id: '/providers/transak/orders/', + providerOrderId: '', + } as never); + + expect(controller.state.orders).toHaveLength(0); + }); +}); diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 1bb8099f86a..587f103ef14 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -21,6 +21,7 @@ import type { import { RampsController, getDefaultRampsControllerState, + getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; @@ -8610,9 +8611,13 @@ describe('RampsController', () => { it('adds a new order to state', async () => { await withController(({ controller, rootMessenger }) => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_100); rootMessenger.call('RampsController:addOrder', mockOrder); expect(controller.state.orders).toHaveLength(1); - expect(controller.state.orders[0]).toStrictEqual(mockOrder); + expect(controller.state.orders[0]).toStrictEqual({ + ...mockOrder, + lastUpdatedAt: 1_700_000_000_100, + }); }); }); @@ -8725,6 +8730,60 @@ describe('RampsController', () => { expect(controller.state.orders).toHaveLength(1); }); }); + + it('clears polling metadata when removing by internal order code', async () => { + await withController(async ({ rootMessenger }) => { + jest.useFakeTimers(); + + const legacyOrder = createMockOrder({ + id: '/providers/transak/orders/internal-order-456', + providerOrderId: 'legacy-provider-id', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: '/providers/transak', + name: 'Transak', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', legacyOrder); + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => { + callCount += 1; + throw new Error('fail'); + }, + ); + + rootMessenger.call('RampsController:startOrderPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + rootMessenger.call('RampsController:removeOrder', 'internal-order-456'); + rootMessenger.call('RampsController:stopOrderPolling'); + + const replacementOrder = createMockOrder({ + id: '/providers/transak/orders/internal-order-456', + providerOrderId: 'legacy-provider-id', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: '/providers/transak', + name: 'Transak', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', replacementOrder); + + callCount = 0; + rootMessenger.call('RampsController:startOrderPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + rootMessenger.call('RampsController:stopOrderPolling'); + jest.useRealTimers(); + }); + }); }); describe('getOrder', () => { @@ -11187,6 +11246,31 @@ describe('RampsController', () => { }); }); +describe('getInternalOrderCode', () => { + it('returns empty string when object has no /orders/ id and no providerOrderId', () => { + expect(getInternalOrderCode({ id: 'plain-id' })).toBe(''); + }); + + it('trims providerOrderId when id has no /orders/ path', () => { + expect( + getInternalOrderCode({ id: 'plain-id', providerOrderId: ' abc ' }), + ).toBe('abc'); + }); + + it('falls back to providerOrderId when /orders/ segment is empty', () => { + expect( + getInternalOrderCode({ + id: '/providers/transak/orders/', + providerOrderId: 'real-id', + }), + ).toBe('real-id'); + }); + + it('returns empty string for a string id with an empty /orders/ segment', () => { + expect(getInternalOrderCode('/providers/transak/orders/')).toBe(''); + }); +}); + /** * Creates a mock UserRegion object for testing. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 5d258a257e4..e92df04b0ba 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -60,6 +60,7 @@ export type { RampsControllerTransakCancelOrderAction, RampsControllerTransakCancelAllActiveOrdersAction, RampsControllerTransakGetActiveOrdersAction, + RampsControllerSyncOrdersWithUserStorageAction, } from './RampsController-method-action-types.js'; export { RampsController, @@ -165,6 +166,30 @@ export { extractExplicitTypedError, normalizeToTypedError, } from './errorNormalization.js'; +export type { + UserStorageRampsOrderEntry, + SyncRampsOrder, + OrderSyncingController, + OrderSyncingOptions, + SyncOrdersWithUserStorageConfig, +} from './order-syncing/index.js'; +export { + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripPaymentDetailsForRemoteStorage, + stripSyncMetadata, + stripDeletedAt, + areOrdersEqual, + canPerformOrderSyncing, + syncOrdersWithUserStorage, + updateOrderInRemoteStorage, + deleteOrderInRemoteStorage, +} from './order-syncing/index.js'; export type { TransakServiceActions, TransakServiceEvents, diff --git a/packages/ramps-controller/src/order-syncing/constants.ts b/packages/ramps-controller/src/order-syncing/constants.ts new file mode 100644 index 00000000000..071ae5efc7a --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/constants.ts @@ -0,0 +1,25 @@ +/** + * User Storage feature key for V2 ramps orders. + * Each order is stored as a separate entry under this feature. + */ +export const USER_STORAGE_RAMPS_ORDERS_FEATURE = 'rampsOrders'; + +/** + * Key for version in User Storage schema. + */ +export const USER_STORAGE_VERSION_KEY = 'v'; + +/** + * Current version of the ramps order User Storage schema. + */ +export const USER_STORAGE_VERSION = '1'; + +/** + * Trace names for ramps order syncing operations. + */ +export const TraceName = { + RampsOrderSyncFull: 'Ramps Order Sync Full', + RampsOrderSyncSaveBatch: 'Ramps Order Sync Save Batch', + RampsOrderSyncUpdateRemote: 'Ramps Order Sync Update Remote', + RampsOrderSyncDeleteRemote: 'Ramps Order Sync Delete Remote', +} as const; diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts new file mode 100644 index 00000000000..23636a5fa88 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -0,0 +1,1197 @@ +import { RampsOrderStatus } from '../RampsService.js'; +import type { RampsOrder } from '../RampsService.js'; +import { + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + deleteOrderInRemoteStorage, + orderSyncingTestExports, + syncOrdersWithUserStorage, + updateOrderInRemoteStorage, +} from './controller-integration.js'; +import type { OrderSyncingOptions, SyncRampsOrder } from './types.js'; +import { mapRampsOrderToUserStorageEntry } from './utils.js'; + +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak/orders/abc-123', + isOnlyLink: false, + provider: { id: 'transak', name: 'Transak' } as RampsOrder['provider'], + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + ...overrides, + }; +} + +describe('order-syncing/controller-integration', () => { + const arrangeMocks = ({ + localOrders = [] as RampsOrder[], + remoteEntries = null as string[] | null, + isBackupAndSyncEnabled = true, + isRampsSyncingEnabled = true, + isSignedIn = true, + } = {}): { + options: OrderSyncingOptions; + addOrder: jest.Mock; + removeOrder: jest.Mock; + setIsOrderSyncingInProgress: jest.Mock; + performBatchSetStorage: jest.Mock; + performSetStorage: jest.Mock; + performGetStorage: jest.Mock; + performGetStorageAllFeatureEntries: jest.Mock; + } => { + const removeOrder = jest.fn(); + const setIsOrderSyncingInProgress = jest.fn(); + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorage = jest.fn().mockResolvedValue(null); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue(remoteEntries); + + const controller = { + state: { orders: localOrders }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress, + addOrder: jest.fn(), + removeOrder, + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }; + + const addOrder = jest.fn((order: RampsOrder) => { + const existingIndex = controller.state.orders.findIndex( + (existing) => existing.providerOrderId === order.providerOrderId, + ); + + if (existingIndex === -1) { + controller.state.orders.push(order); + return; + } + + controller.state.orders[existingIndex] = { + ...controller.state.orders[existingIndex], + ...order, + }; + }); + controller.addOrder = addOrder; + + const messengerCall = jest + .fn() + .mockImplementation((action: string, ...args: unknown[]) => { + switch (action) { + case 'UserStorageController:getState': + return { isBackupAndSyncEnabled, isRampsSyncingEnabled }; + case 'AuthenticationController:isSignedIn': + return isSignedIn; + case 'UserStorageController:listEntropySources': + return ['entropy-primary']; + case 'UserStorageController:performGetStorageAllFeatureEntries': + return performGetStorageAllFeatureEntries(...args); + case 'UserStorageController:performBatchSetStorage': + return performBatchSetStorage(...args); + case 'UserStorageController:performSetStorage': + return performSetStorage(...args); + case 'UserStorageController:performGetStorage': + return performGetStorage(...args); + default: + return null; + } + }); + + const options: OrderSyncingOptions = { + getRampsControllerInstance: () => controller, + getMessenger: () => + ({ call: messengerCall }) as ReturnType< + OrderSyncingOptions['getMessenger'] + >, + }; + + return { + options, + addOrder, + removeOrder, + setIsOrderSyncingInProgress, + performBatchSetStorage, + performSetStorage, + performGetStorage, + performGetStorageAllFeatureEntries, + }; + }; + + describe('syncOrdersWithUserStorage', () => { + it('no-ops when syncing conditions are not met', async () => { + const { options, performGetStorageAllFeatureEntries } = arrangeMocks({ + isRampsSyncingEnabled: false, + localOrders: [createMockOrder()], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(performGetStorageAllFeatureEntries).not.toHaveBeenCalled(); + }); + + it('uploads local-only orders on first sync', async () => { + const localOrder = createMockOrder(); + const { options, performBatchSetStorage, setIsOrderSyncingInProgress } = + arrangeMocks({ + localOrders: [localOrder], + remoteEntries: null, + }); + + await syncOrdersWithUserStorage({}, options); + + expect(setIsOrderSyncingInProgress).toHaveBeenCalledWith(true); + expect(setIsOrderSyncingInProgress).toHaveBeenCalledWith(false); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + }); + + it('imports remote-only orders on new device sync', async () => { + const remoteOrder = createMockOrder({ + providerOrderId: 'remote-1', + id: '/providers/transak/orders/remote-1', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerOrderId: 'remote-1' }), + ); + }); + + it('keeps the freshest remote entry when duplicate storage keys are returned', async () => { + const olderRemote = createMockOrder({ + providerOrderId: 'dup-key', + id: '/providers/transak/orders/dup-key', + fiatAmount: 100, + }); + const newerRemote = createMockOrder({ + providerOrderId: 'dup-key', + id: '/providers/transak/orders/dup-key', + fiatAmount: 999, + }); + const olderEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...olderRemote, + lastUpdatedAt: 1_700_000_000_000, + }), + ); + const newerEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...newerRemote, + lastUpdatedAt: 1_700_000_000_100, + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [olderEntry, newerEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledTimes(1); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ + providerOrderId: 'dup-key', + fiatAmount: 999, + }), + ); + }); + + it('keeps an earlier fresher remote entry over a later stale duplicate', async () => { + const newerRemote = createMockOrder({ + providerOrderId: 'dup-key-2', + id: '/providers/transak/orders/dup-key-2', + fiatAmount: 500, + }); + const olderRemote = createMockOrder({ + providerOrderId: 'dup-key-2', + id: '/providers/transak/orders/dup-key-2', + fiatAmount: 50, + }); + const newerEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...newerRemote, + lastUpdatedAt: 1_700_000_000_200, + }), + ); + const olderEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...olderRemote, + lastUpdatedAt: 1_700_000_000_000, + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [newerEntry, olderEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledTimes(1); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ + providerOrderId: 'dup-key-2', + fiatAmount: 500, + }), + ); + }); + + it('prefers a newer remote tombstone over an older live duplicate key', async () => { + const liveRemote = createMockOrder({ + providerOrderId: 'dup-tombstone', + id: '/providers/transak/orders/dup-tombstone', + fiatAmount: 100, + }); + const tombstoneRemote: SyncRampsOrder = { + ...liveRemote, + deletedAt: 1_700_000_000_300, + lastUpdatedAt: 1_700_000_000_300, + }; + const liveEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...liveRemote, + lastUpdatedAt: 1_700_000_000_000, + }), + ); + const tombstoneEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(tombstoneRemote), + ); + + const { options, addOrder, removeOrder } = arrangeMocks({ + localOrders: [liveRemote], + remoteEntries: [liveEntry, tombstoneEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(removeOrder).toHaveBeenCalledWith('dup-tombstone'); + }); + + it('applies remote soft-deletes locally when the tombstone is newer', async () => { + const localOrder = createMockOrder(); + const deletedRemote: SyncRampsOrder = { + ...localOrder, + deletedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(deletedRemote), + ); + + const { options, removeOrder } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(removeOrder).toHaveBeenCalledWith('abc-123'); + }); + + it('restores a newer local order over an older remote tombstone', async () => { + const localOrder = createMockOrder({ + fiatAmount: 250, + createdAt: Date.now(), + }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: 1700000000000, + lastUpdatedAt: 1700000000000, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(deletedRemote), + ); + + const { options, removeOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(removeOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + }); + + it('does not persist tombstone metadata when uploading active local orders', async () => { + const localOrder = createMockOrder({ fiatAmount: 250 }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: 1700000000000, + lastUpdatedAt: 1700000000000, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(deletedRemote), + ); + + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { dt?: number }; + expect(saved.dt).toBeUndefined(); + }); + + it('uploads locally newer conflicting orders to remote', async () => { + const localOrder = createMockOrder({ + fiatAmount: 300, + createdAt: Date.now(), + }); + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + lastUpdatedAt: 1, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(remoteOrder), + ); + + const { options, addOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalled(); + }); + + it('skips remote upload when local and remote orders are identical', async () => { + const order = createMockOrder(); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...order, + lastUpdatedAt: Date.now(), + }), + ); + + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [order], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('replaces stale queued uploads with fresher local orders', async () => { + const localOrder = createMockOrder({ fiatAmount: 100 }); + const updatedLocalOrder = createMockOrder({ fiatAmount: 500 }); + + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue(null); + + const controller = { + state: { orders: [localOrder] }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress: jest.fn((value: boolean) => { + if (value) { + controller.state.orders = [updatedLocalOrder]; + } + }), + addOrder: jest.fn(), + removeOrder: jest.fn(), + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }; + + const messengerCall = jest + .fn() + .mockImplementation((action: string, ...callArgs: unknown[]) => { + if (action === 'UserStorageController:getState') { + return { + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if (action === 'UserStorageController:listEntropySources') { + return ['entropy-primary']; + } + if ( + action === + 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return performGetStorageAllFeatureEntries(); + } + if (action === 'UserStorageController:performBatchSetStorage') { + return performBatchSetStorage(...callArgs); + } + return null; + }); + + const options: OrderSyncingOptions = { + getRampsControllerInstance: () => controller, + getMessenger: () => + ({ call: messengerCall }) as ReturnType< + OrderSyncingOptions['getMessenger'] + >, + }; + + await syncOrdersWithUserStorage({}, options); + + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { o: { fiatAmount: number } }; + expect(saved.o.fiatAmount).toBe(500); + }); + + it('keeps the newer side when content conflicts', async () => { + const localOrder = createMockOrder({ fiatAmount: 100 }); + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 200 }), + lastUpdatedAt: Date.now() + 10_000, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(remoteOrder), + ); + + const { options, addOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ fiatAmount: 200 }), + ); + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('prefers local edits with newer lastUpdatedAt over stale remote content', async () => { + const localOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 500, createdAt: 1 }), + lastUpdatedAt: Date.now(), + }; + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100, createdAt: 1 }), + lastUpdatedAt: 10, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(remoteOrder), + ); + + const { options, addOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalled(); + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { o: { fiatAmount: number } }; + expect(saved.o.fiatAmount).toBe(500); + }); + + it('uploads local-only orders when remote fetch returns an empty array', async () => { + const localOrder = createMockOrder(); + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).toHaveBeenCalled(); + }); + + it('aborts sync when remote fetch fails instead of treating it as empty', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { + options, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = arrangeMocks({ + localOrders: [createMockOrder()], + }); + + performGetStorageAllFeatureEntries.mockRejectedValue( + new Error('network down'), + ); + + await expect( + syncOrdersWithUserStorage({ onOrderSyncErroneousSituation }, options), + ).rejects.toThrow('network down'); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to fetch remote ramps orders', + expect.objectContaining({ error: expect.any(Error) }), + ); + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('skips corrupt remote entries while still importing valid ones', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const remoteOrder = createMockOrder({ + providerOrderId: 'valid-remote', + id: '/providers/transak/orders/valid-remote', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: ['{invalid-json', remoteEntry], + }); + + await syncOrdersWithUserStorage( + { onOrderSyncErroneousSituation }, + options, + ); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to parse remote ramps order entry', + expect.objectContaining({ + error: expect.any(SyntaxError), + entryLength: expect.any(Number), + }), + ); + expect(onOrderSyncErroneousSituation.mock.calls[0][1]).not.toHaveProperty( + 'orderJson', + ); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerOrderId: 'valid-remote' }), + ); + }); + + it('skips remote entries with unsupported version or missing payload', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const remoteOrder = createMockOrder({ + providerOrderId: 'valid-remote', + id: '/providers/transak/orders/valid-remote', + }); + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: '999', + o: remoteOrder, + }), + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + }), + JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ), + ], + }); + + await syncOrdersWithUserStorage( + { onOrderSyncErroneousSituation }, + options, + ); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Unsupported ramps order storage version', + expect.objectContaining({ version: '999' }), + ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Remote ramps order entry missing order payload', + {}, + ); + expect(addOrder).toHaveBeenCalledTimes(1); + }); + + it('skips remote entries that cannot derive a storage key', async () => { + const remoteOrder = createMockOrder({ + providerOrderId: 'valid-remote-2', + id: '/providers/transak/orders/valid-remote-2', + }); + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + ...remoteOrder, + id: '/providers/transak/orders/', + providerOrderId: '', + }, + lu: Date.now(), + }), + JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ), + ], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledTimes(1); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerOrderId: 'valid-remote-2' }), + ); + }); + + it('reports and skips empty storage keys when uploading', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { options, performBatchSetStorage } = arrangeMocks(); + + await orderSyncingTestExports.saveOrdersToUserStorage( + [ + createMockOrder({ + id: '/providers/transak/orders/', + providerOrderId: '', + }), + ], + options, + { onOrderSyncErroneousSituation }, + ); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Skipping ramps order remote write with empty storage key', + expect.objectContaining({ + hasId: true, + hasProviderOrderId: false, + }), + ); + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('uploads tombstones for orders deleted while sync is in progress', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_500); + const localOrder = createMockOrder(); + const deletedDuringSync = createMockOrder({ + providerOrderId: 'deleted-mid-sync', + id: '/providers/transak/orders/deleted-mid-sync', + }); + + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [], + }); + + const controller = options.getRampsControllerInstance(); + (controller.drainPendingRemoteDeletes as jest.Mock).mockReturnValue([ + deletedDuringSync, + ]); + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + expect.arrayContaining(['deleted-mid-sync', expect.any(String)]), + ]), + ); + + const tombstoneEntry = JSON.parse( + (performBatchSetStorage.mock.calls[0][1] as [string, string][]).find( + ([key]) => key === 'deleted-mid-sync', + )?.[1] as string, + ) as { dt?: number; o: { paymentDetails?: unknown } }; + expect(tombstoneEntry.dt).toBe(1_700_000_000_500); + expect(tombstoneEntry.o.paymentDetails).toBeUndefined(); + }); + + it('reports sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [createMockOrder()], + remoteEntries: [], + }); + + performBatchSetStorage.mockRejectedValue(new Error('batch failed')); + + await expect( + syncOrdersWithUserStorage({ onOrderSyncErroneousSituation }, options), + ).rejects.toThrow('batch failed'); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error synchronizing ramps orders', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('wraps full sync in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const { options } = arrangeMocks({ + localOrders: [createMockOrder()], + remoteEntries: [], + }); + + await syncOrdersWithUserStorage( + {}, + { + ...options, + trace, + }, + ); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Full' }), + expect.any(Function), + ); + }); + + it('records new-device sync metadata in trace', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const remoteOrder = createMockOrder({ + providerOrderId: 'remote-only', + id: '/providers/transak/orders/remote-only', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ); + + const { options } = arrangeMocks({ + localOrders: [], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage( + {}, + { + ...options, + trace, + }, + ); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isNewDeviceSync: true }), + }), + expect.any(Function), + ); + }); + + it('uploads orders added while sync is in progress', async () => { + const localOrder = createMockOrder(); + const orderAddedDuringSync = createMockOrder({ + providerOrderId: 'during-sync-1', + id: '/providers/transak/orders/during-sync-1', + }); + + const addOrder = jest.fn(); + const removeOrder = jest.fn(); + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue(null); + + const controller = { + state: { orders: [localOrder] }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress: jest.fn((value: boolean) => { + if (value) { + controller.state.orders = [localOrder, orderAddedDuringSync]; + } + }), + addOrder, + removeOrder, + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }; + + const messengerCall = jest + .fn() + .mockImplementation((action: string, ...callArgs: unknown[]) => { + if (action === 'UserStorageController:getState') { + return { + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if (action === 'UserStorageController:listEntropySources') { + return ['entropy-primary']; + } + if ( + action === + 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return performGetStorageAllFeatureEntries(); + } + if (action === 'UserStorageController:performBatchSetStorage') { + return performBatchSetStorage(...callArgs); + } + return null; + }); + + const options: OrderSyncingOptions = { + getRampsControllerInstance: () => controller, + getMessenger: () => + ({ call: messengerCall }) as ReturnType< + OrderSyncingOptions['getMessenger'] + >, + }; + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + expect.arrayContaining(['during-sync-1', expect.any(String)]), + ]), + ); + }); + }); + + describe('updateOrderInRemoteStorage', () => { + it('writes a single order entry via batch storage', async () => { + const order = createMockOrder(); + const { options, performBatchSetStorage } = arrangeMocks(); + + await updateOrderInRemoteStorage(order, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + }); + + it('supports provider order IDs that are invalid for performSetStorage paths', async () => { + const order = createMockOrder({ + providerOrderId: '550e8400-e29b-41d4-a716-446655440000', + id: '/providers/transak/orders/550e8400-e29b-41d4-a716-446655440000', + }); + const { options, performBatchSetStorage, performSetStorage } = + arrangeMocks(); + + await updateOrderInRemoteStorage(order, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining([ + '550e8400-e29b-41d4-a716-446655440000', + expect.any(String), + ]), + ]), + ); + expect(performSetStorage).not.toHaveBeenCalled(); + }); + + it('no-ops when syncing is disabled', async () => { + const { options, performBatchSetStorage } = arrangeMocks({ + isBackupAndSyncEnabled: false, + }); + + await updateOrderInRemoteStorage(createMockOrder(), options); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('wraps incremental updates in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const order = createMockOrder(); + const { options } = arrangeMocks(); + + await updateOrderInRemoteStorage(order, { + ...options, + trace, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Update Remote' }), + expect.any(Function), + ); + }); + }); + + describe('deleteOrderInRemoteStorage', () => { + it('soft-deletes via batch storage using the local order payload', async () => { + const order = createMockOrder(); + const { options, performBatchSetStorage, performGetStorage } = + arrangeMocks(); + + await deleteOrderInRemoteStorage(order, options); + + expect(performGetStorage).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { dt?: number }; + expect(saved.dt).toStrictEqual(expect.any(Number)); + }); + + it('no-ops when deleting a non-syncable order', async () => { + const { options, performBatchSetStorage } = arrangeMocks(); + + await deleteOrderInRemoteStorage( + createMockOrder({ id: '', providerOrderId: '' }), + options, + ); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('wraps incremental deletes in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const order = createMockOrder(); + const { options } = arrangeMocks(); + + await deleteOrderInRemoteStorage(order, { + ...options, + trace, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Delete Remote' }), + expect.any(Function), + ); + }); + + it('wraps batch saves in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const { options } = arrangeMocks({ + localOrders: [createMockOrder()], + remoteEntries: [], + }); + + await syncOrdersWithUserStorage( + {}, + { + ...options, + trace, + }, + ); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Save Batch' }), + expect.any(Function), + ); + }); + }); + + describe('merge helpers', () => { + const { + computeMergePlan, + reconcileOrdersForRemoteUpload, + getOrderTimestamp, + } = orderSyncingTestExports; + + it('resolves timestamps from sync metadata, createdAt, or zero', () => { + expect( + getOrderTimestamp({ + ...createMockOrder(), + lastUpdatedAt: 10, + createdAt: 5, + }), + ).toBe(10); + expect(getOrderTimestamp(createMockOrder({ createdAt: 5 }))).toBe(5); + expect( + getOrderTimestamp({ + providerOrderId: 'x', + } as RampsOrder), + ).toBe(0); + }); + + it('prefers newer local timestamps over older remote tombstones', () => { + const localOrder = createMockOrder({ createdAt: Date.now() }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder(), + deletedAt: 1, + lastUpdatedAt: 1, + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToUpdateRemotely).toHaveLength(1); + expect(plan.ordersToDeleteLocally).toHaveLength(0); + }); + + it('restores local orders that changed after an older remote tombstone', () => { + const localOrder = createMockOrder({ fiatAmount: 300 }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: 1, + lastUpdatedAt: 1, + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToUpdateRemotely).toHaveLength(1); + }); + + it('applies remote tombstones when deletion is newer and content matches', () => { + const localOrder = createMockOrder(); + const deletedRemote: SyncRampsOrder = { + ...localOrder, + deletedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToDeleteLocally).toHaveLength(1); + expect(plan.ordersToUpdateRemotely).toHaveLength(0); + }); + + it('applies newer remote tombstones when content differs', () => { + const localOrder = createMockOrder({ + fiatAmount: 300, + createdAt: 1, + }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToDeleteLocally).toHaveLength(1); + expect(plan.ordersToUpdateRemotely).toHaveLength(0); + }); + + it('prefers local lastUpdatedAt over older remote content during merge', () => { + const localOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 500, createdAt: 1 }), + lastUpdatedAt: 1000, + }; + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100, createdAt: 1 }), + lastUpdatedAt: 10, + }; + + const plan = computeMergePlan([localOrder], [remoteOrder]); + + expect(plan.ordersToUpdateRemotely).toHaveLength(1); + expect(plan.ordersToAddOrUpdateLocally).toHaveLength(0); + }); + + it('ignores remote tombstones when no matching local order exists', () => { + const deletedRemote: SyncRampsOrder = { + ...createMockOrder(), + deletedAt: Date.now(), + }; + + const plan = computeMergePlan([], [deletedRemote]); + + expect(plan.ordersToDeleteLocally).toHaveLength(0); + expect(plan.ordersToUpdateRemotely).toHaveLength(0); + }); + + it('keeps queued uploads when the live local payload is unchanged', () => { + const localOrder = createMockOrder(); + const remoteOrdersMap = new Map(); + + const uploads = reconcileOrdersForRemoteUpload( + [localOrder], + remoteOrdersMap, + [localOrder], + ); + + expect(uploads).toHaveLength(1); + expect(uploads[0]).toStrictEqual(localOrder); + }); + + it('replaces queued uploads with fresher local payloads', () => { + const staleOrder = createMockOrder({ fiatAmount: 100 }); + const freshOrder = createMockOrder({ fiatAmount: 500 }); + + const uploads = reconcileOrdersForRemoteUpload([staleOrder], new Map(), [ + freshOrder, + ]); + + expect(uploads[0]?.fiatAmount).toBe(500); + }); + + it('uploads live local orders missing from the remote map', () => { + const localOrder = createMockOrder(); + + const uploads = reconcileOrdersForRemoteUpload([], new Map(), [ + localOrder, + ]); + + expect(uploads).toHaveLength(1); + }); + + it('skips uploads when the live local payload already matches remote', () => { + const localOrder = createMockOrder(); + const remoteOrder: SyncRampsOrder = { + ...localOrder, + lastUpdatedAt: Date.now(), + }; + const remoteOrdersMap = new Map([ + ['abc-123', remoteOrder], + ]); + + const uploads = reconcileOrdersForRemoteUpload([], remoteOrdersMap, [ + localOrder, + ]); + + expect(uploads).toHaveLength(0); + }); + }); +}); diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts new file mode 100644 index 00000000000..82c94970b35 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -0,0 +1,526 @@ +import type { RampsOrder } from '../RampsService.js'; +import { + TraceName, + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { canPerformOrderSyncing } from './sync-utils.js'; +import type { + OrderSyncingOptions, + SyncRampsOrder, + UserStorageRampsOrderEntry, +} from './types.js'; +import { + areOrdersEqual, + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripDeletedAt, + stripSyncMetadata, +} from './utils.js'; + +export type SyncOrdersWithUserStorageConfig = { + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}; + +type MergePlan = { + ordersToAddOrUpdateLocally: SyncRampsOrder[]; + ordersToDeleteLocally: SyncRampsOrder[]; + ordersToUpdateRemotely: RampsOrder[]; +}; + +/** + * Returns the timestamp used for ramps order conflict resolution. + * + * Content conflicts use `local >= remote` (local wins ties). Tombstones use + * `local > deletedAt` (tie deletes locally) so equal-time deletes still apply. + * + * @param order - A ramps order that may include sync metadata. + * @returns The best available last-updated timestamp. + */ +function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { + return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; +} + +/** + * Builds the local/remote merge plan from the current local snapshot and remote + * entries. + * + * @param localOrders - Syncable orders currently in {@link RampsController} state. + * @param validRemoteOrders - Syncable orders fetched from User Storage. + * @returns Lists of local mutations and remote uploads to apply. + */ +function computeMergePlan( + localOrders: RampsOrder[], + validRemoteOrders: SyncRampsOrder[], +): MergePlan & { remoteOrdersMap: Map } { + const localOrdersMap = new Map(); + const remoteOrdersMap = new Map(); + + localOrders.forEach((order) => { + localOrdersMap.set(createOrderStorageKey(order), order); + }); + + validRemoteOrders.forEach((order) => { + remoteOrdersMap.set(createOrderStorageKey(order), order); + }); + + const ordersToAddOrUpdateLocally: SyncRampsOrder[] = []; + const ordersToDeleteLocally: SyncRampsOrder[] = []; + const ordersToUpdateRemotely: RampsOrder[] = []; + + for (const remoteOrder of validRemoteOrders) { + const key = createOrderStorageKey(remoteOrder); + const localOrder = localOrdersMap.get(key); + + if (remoteOrder.deletedAt) { + if (localOrder) { + const localTimestamp = getOrderTimestamp(localOrder); + const remoteTimestamp = remoteOrder.deletedAt; + + if (localTimestamp > remoteTimestamp) { + ordersToUpdateRemotely.push(localOrder); + } else { + ordersToDeleteLocally.push(remoteOrder); + } + } + } else if (!localOrder) { + ordersToAddOrUpdateLocally.push(remoteOrder); + } else if (!areOrdersEqual(localOrder, remoteOrder)) { + const localTimestamp = getOrderTimestamp(localOrder); + const remoteTimestamp = getOrderTimestamp(remoteOrder); + + if (localTimestamp >= remoteTimestamp) { + ordersToUpdateRemotely.push(localOrder); + } else { + ordersToAddOrUpdateLocally.push(remoteOrder); + } + } + } + + for (const localOrder of localOrders) { + const key = createOrderStorageKey(localOrder); + if (!remoteOrdersMap.has(key)) { + ordersToUpdateRemotely.push(localOrder); + } + } + + return { + remoteOrdersMap, + ordersToAddOrUpdateLocally, + ordersToDeleteLocally, + ordersToUpdateRemotely, + }; +} + +/** + * Re-reads live local orders after merge mutations so orders added or updated + * while `isOrderSyncingInProgress` is true are still uploaded remotely. + * + * @param plannedRemoteUploads - Orders already queued for remote upload. + * @param remoteOrdersMap - Remote orders keyed by storage key. + * @param currentLocalOrders - Latest syncable local orders. + * @returns Remote uploads using the freshest local payloads. + */ +function reconcileOrdersForRemoteUpload( + plannedRemoteUploads: RampsOrder[], + remoteOrdersMap: Map, + currentLocalOrders: RampsOrder[], +): RampsOrder[] { + const keyedUploads = new Map( + plannedRemoteUploads.map((order) => [createOrderStorageKey(order), order]), + ); + + for (const localOrder of currentLocalOrders) { + const key = createOrderStorageKey(localOrder); + const existingUpload = keyedUploads.get(key); + const remoteOrder = remoteOrdersMap.get(key); + + if (existingUpload) { + if (!areOrdersEqual(existingUpload, localOrder)) { + keyedUploads.set(key, localOrder); + } + continue; + } + + if (!remoteOrder || !areOrdersEqual(localOrder, remoteOrder)) { + keyedUploads.set(key, localOrder); + } + } + + return [...keyedUploads.values()]; +} + +/** + * Syncs V2 ramps orders between local {@link RampsController} state and User Storage. + * + * Handles first sync, new-device sync, merges, timestamp-based conflict resolution, + * and remote soft-deletes. + * + * @param config - Optional callbacks for sync errors. + * @param options - Parameters used for syncing operations. + */ +export async function syncOrdersWithUserStorage( + config: SyncOrdersWithUserStorageConfig, + options: OrderSyncingOptions, +): Promise { + const { getRampsControllerInstance, trace } = options; + const { onOrderSyncErroneousSituation } = config; + + if (!canPerformOrderSyncing(options)) { + return; + } + + const controller = getRampsControllerInstance(); + // Set the semaphore before the remote fetch so overlapping sync calls and + // incremental addOrder/removeOrder pushes cannot race the merge. + controller.setIsOrderSyncingInProgress(true); + + try { + const validRemoteOrders = (await getRemoteOrders(options, config)).filter( + isSyncableOrder, + ); + + const performSync = async (): Promise => { + const getLocalOrders = (): RampsOrder[] => + controller.state.orders.filter(isSyncableOrder); + + const { + remoteOrdersMap, + ordersToAddOrUpdateLocally, + ordersToDeleteLocally, + ordersToUpdateRemotely, + } = computeMergePlan(getLocalOrders(), validRemoteOrders); + + for (const order of ordersToDeleteLocally) { + controller.removeOrder(createOrderStorageKey(order)); + } + + for (const order of ordersToAddOrUpdateLocally) { + if (!order.deletedAt) { + controller.addOrder(stripDeletedAt(order)); + } + } + + const ordersToUpload = reconcileOrdersForRemoteUpload( + ordersToUpdateRemotely, + remoteOrdersMap, + getLocalOrders(), + ); + + const localKeys = new Set( + getLocalOrders().map((order) => createOrderStorageKey(order)), + ); + const pendingDeletes = controller + .drainPendingRemoteDeletes() + .filter((order) => { + const key = createOrderStorageKey(order); + return key.length > 0 && !localKeys.has(key); + }); + const pendingDeleteKeys = new Set( + pendingDeletes.map((order) => createOrderStorageKey(order)), + ); + + const now = Date.now(); + const syncedUploads: SyncRampsOrder[] = [ + ...ordersToUpload + .filter( + (localOrder) => + !pendingDeleteKeys.has(createOrderStorageKey(localOrder)), + ) + .map((localOrder) => ({ + ...stripSyncMetadata(localOrder), + lastUpdatedAt: (localOrder as SyncRampsOrder).lastUpdatedAt ?? now, + })), + ...pendingDeletes.map((order) => ({ + ...stripSyncMetadata(order), + deletedAt: now, + lastUpdatedAt: now, + })), + ]; + + if (syncedUploads.length > 0) { + await saveOrdersToUserStorage(syncedUploads, options, config); + } + }; + + if (trace) { + const localOrderCount = + controller.state.orders.filter(isSyncableOrder).length; + + await trace( + { + name: TraceName.RampsOrderSyncFull, + data: { + localOrderCount, + remoteOrderCount: validRemoteOrders.length, + isFirstSync: validRemoteOrders.length === 0 && localOrderCount > 0, + isNewDeviceSync: + localOrderCount === 0 && validRemoteOrders.length > 0, + }, + }, + performSync, + ); + return; + } + + await performSync(); + } catch (error) { + onOrderSyncErroneousSituation?.('Error synchronizing ramps orders', { + error, + }); + throw error; + } finally { + controller.setIsOrderSyncingInProgress(false); + } +} + +/** + * Retrieves remote ramps orders from User Storage. + * + * @param options - Parameters used for retrieving remote orders. + * @param config - Optional sync callbacks for error reporting. + * @returns Parsed sync-aware orders. Returns an empty array when none exist. + */ +async function getRemoteOrders( + options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig, +): Promise { + const { getMessenger } = options; + const { onOrderSyncErroneousSituation } = config; + + try { + // Multi-SRP: Portfolio uploads under the connected account's HD entropy + // profile. Reading only the primary SRP misses secondary-wallet orders. + let entropySourceIds: (string | undefined)[] = [undefined]; + try { + const listed = await getMessenger().call( + 'UserStorageController:listEntropySources', + ); + if (listed.length > 0) { + entropySourceIds = listed; + } + } catch { + // Wallet locked / action missing — fall back to primary (omit id). + } + + const remoteOrdersJsonArray: string[] = []; + + for (const entropySourceId of entropySourceIds) { + const entries = await getMessenger().call( + 'UserStorageController:performGetStorageAllFeatureEntries', + USER_STORAGE_RAMPS_ORDERS_FEATURE, + entropySourceId, + ); + if (entries?.length) { + remoteOrdersJsonArray.push(...entries); + } + } + + if (remoteOrdersJsonArray.length === 0) { + return []; + } + + const ordersByKey = new Map(); + + for (const orderJson of remoteOrdersJsonArray) { + try { + const entry = JSON.parse(orderJson) as UserStorageRampsOrderEntry; + if (entry[USER_STORAGE_VERSION_KEY] !== USER_STORAGE_VERSION) { + onOrderSyncErroneousSituation?.( + 'Unsupported ramps order storage version', + { + version: entry[USER_STORAGE_VERSION_KEY], + expectedVersion: USER_STORAGE_VERSION, + }, + ); + continue; + } + if (!entry.o || typeof entry.o !== 'object') { + onOrderSyncErroneousSituation?.( + 'Remote ramps order entry missing order payload', + {}, + ); + continue; + } + const mapped = mapUserStorageEntryToRampsOrder(entry); + const key = createOrderStorageKey(mapped); + if (key) { + const existing = ordersByKey.get(key); + if (existing) { + const existingTimestamp = getOrderTimestamp(existing); + const mappedTimestamp = getOrderTimestamp(mapped); + if (mappedTimestamp > existingTimestamp) { + ordersByKey.set(key, mapped); + } + } else { + ordersByKey.set(key, mapped); + } + } + } catch (error) { + // Do not attach raw order JSON — it can include wallet/PII fields. + onOrderSyncErroneousSituation?.( + 'Failed to parse remote ramps order entry', + { + error, + entryLength: orderJson.length, + }, + ); + } + } + + return [...ordersByKey.values()]; + } catch (error) { + onOrderSyncErroneousSituation?.('Failed to fetch remote ramps orders', { + error, + }); + throw error; + } +} + +/** + * Saves orders to User Storage in a batch. + * + * @param orders - The orders to save. + * @param options - Parameters used for saving orders. + * @param config - Optional sync callbacks for error reporting. + * @returns Resolves when the batch write completes. + */ +async function saveOrdersToUserStorage( + orders: SyncRampsOrder[], + options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, +): Promise { + const { getMessenger, trace } = options; + const { onOrderSyncErroneousSituation } = config; + + const saveOrders = async (): Promise => { + const storageEntries: [string, string][] = []; + + for (const order of orders) { + const key = createOrderStorageKey(order); + if (!key) { + onOrderSyncErroneousSituation?.( + 'Skipping ramps order remote write with empty storage key', + { + hasId: Boolean(order.id), + hasProviderOrderId: Boolean(order.providerOrderId), + }, + ); + continue; + } + const storageEntry = mapRampsOrderToUserStorageEntry(order); + storageEntries.push([key, JSON.stringify(storageEntry)]); + } + + if (storageEntries.length === 0) { + return; + } + + await getMessenger().call( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_ORDERS_FEATURE, + storageEntries, + ); + }; + + return trace + ? await trace( + { + name: TraceName.RampsOrderSyncSaveBatch, + data: { + orderCount: orders.length, + hasBatchOperations: orders.length > 1, + }, + }, + saveOrders, + ) + : await saveOrders(); +} + +/** + * Updates a single order in remote storage without a full sync. + * + * @param order - The order that was updated locally. + * @param options - Parameters used for syncing operations. + * @returns Resolves when the remote update completes or no-ops. + */ +export async function updateOrderInRemoteStorage( + order: RampsOrder, + options: OrderSyncingOptions, +): Promise { + const { trace } = options; + + const updateOrder = async (): Promise => { + if (!canPerformOrderSyncing(options) || !isSyncableOrder(order)) { + return; + } + + const updatedEntry: SyncRampsOrder = { + ...order, + lastUpdatedAt: Date.now(), + }; + + await saveOrdersToUserStorage([updatedEntry], options); + }; + + if (trace) { + return await trace( + { + name: TraceName.RampsOrderSyncUpdateRemote, + data: { + orderStatus: order.status, + hasProviderOrderId: Boolean(order.providerOrderId), + }, + }, + updateOrder, + ); + } + + return await updateOrder(); +} + +/** + * Marks a single order as deleted in remote storage (soft delete). + * + * @param order - The order that was deleted locally (needs id / providerOrderId). + * @param options - Parameters used for syncing operations. + * @returns Resolves when the remote soft-delete completes or no-ops. + */ +export async function deleteOrderInRemoteStorage( + order: RampsOrder, + options: OrderSyncingOptions, +): Promise { + const { trace } = options; + + const deleteOrder = async (): Promise => { + if (!canPerformOrderSyncing(options) || !isSyncableOrder(order)) { + return; + } + + const now = Date.now(); + const deletedOrder: SyncRampsOrder = { + ...order, + deletedAt: now, + lastUpdatedAt: now, + }; + + await saveOrdersToUserStorage([deletedOrder], options); + }; + + return trace + ? await trace({ name: TraceName.RampsOrderSyncDeleteRemote }, deleteOrder) + : await deleteOrder(); +} + +export const orderSyncingTestExports = { + computeMergePlan, + reconcileOrdersForRemoteUpload, + getOrderTimestamp, + saveOrdersToUserStorage, +}; diff --git a/packages/ramps-controller/src/order-syncing/index.ts b/packages/ramps-controller/src/order-syncing/index.ts new file mode 100644 index 00000000000..238e193cce5 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/index.ts @@ -0,0 +1,29 @@ +export { + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +export type { + UserStorageRampsOrderEntry, + SyncRampsOrder, + OrderSyncingController, + OrderSyncingOptions, +} from './types.js'; +export { + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripPaymentDetailsForRemoteStorage, + stripSyncMetadata, + stripDeletedAt, + areOrdersEqual, +} from './utils.js'; +export { canPerformOrderSyncing } from './sync-utils.js'; +export type { SyncOrdersWithUserStorageConfig } from './controller-integration.js'; +export { + syncOrdersWithUserStorage, + updateOrderInRemoteStorage, + deleteOrderInRemoteStorage, +} from './controller-integration.js'; diff --git a/packages/ramps-controller/src/order-syncing/sync-utils.test.ts b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts new file mode 100644 index 00000000000..a474767a8db --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts @@ -0,0 +1,83 @@ +import { canPerformOrderSyncing } from './sync-utils.js'; +import type { OrderSyncingOptions } from './types.js'; + +describe('order-syncing/sync-utils', () => { + describe('canPerformOrderSyncing', () => { + const arrangeMocks = ({ + isBackupAndSyncEnabled = true, + isRampsSyncingEnabled = true, + isOrderSyncingInProgress = false, + isSignedIn = true, + throwOnMessengerCall = false, + } = {}): { options: OrderSyncingOptions } => { + const options: OrderSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest.fn().mockImplementation((action: string) => { + if (throwOnMessengerCall) { + throw new Error('action not registered'); + } + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled, isRampsSyncingEnabled }; + } + if (action === 'AuthenticationController:isSignedIn') { + return isSignedIn; + } + return null; + }), + }), + getRampsControllerInstance: jest.fn().mockReturnValue({ + isOrderSyncingInProgress, + state: { orders: [] }, + setIsOrderSyncingInProgress: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }), + }; + + return { options }; + }; + + it.each([ + ['backup and sync is disabled', { isBackupAndSyncEnabled: false }], + ['ramps syncing is disabled', { isRampsSyncingEnabled: false }], + ['order syncing is in progress', { isOrderSyncingInProgress: true }], + ['user is not signed in', { isSignedIn: false }], + ['messenger actions are unavailable', { throwOnMessengerCall: true }], + ] as const)('returns false if %s', (_message, mocks) => { + const { options } = arrangeMocks(mocks); + expect(canPerformOrderSyncing(options)).toBe(false); + }); + + it('returns true if all conditions are met', () => { + const { options } = arrangeMocks(); + expect(canPerformOrderSyncing(options)).toBe(true); + }); + + it('defaults isRampsSyncingEnabled to true when absent from User Storage state', () => { + const options: OrderSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest.fn().mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + return null; + }), + }), + getRampsControllerInstance: jest.fn().mockReturnValue({ + isOrderSyncingInProgress: false, + state: { orders: [] }, + setIsOrderSyncingInProgress: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }), + }; + + expect(canPerformOrderSyncing(options)).toBe(true); + }); + }); +}); diff --git a/packages/ramps-controller/src/order-syncing/sync-utils.ts b/packages/ramps-controller/src/order-syncing/sync-utils.ts new file mode 100644 index 00000000000..eb37e2ee81f --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/sync-utils.ts @@ -0,0 +1,39 @@ +import type { OrderSyncingOptions } from './types.js'; + +/** + * Check if we can perform ramps order syncing. + * + * @param options - Parameters used for checking if we can perform order syncing. + * @returns Whether we can perform order syncing. + */ +export function canPerformOrderSyncing(options: OrderSyncingOptions): boolean { + const { getMessenger, getRampsControllerInstance } = options; + + try { + const { + isBackupAndSyncEnabled, + isRampsSyncingEnabled: rawIsRampsSyncingEnabled, + } = getMessenger().call('UserStorageController:getState'); + const isRampsSyncingEnabled = rawIsRampsSyncingEnabled ?? true; + + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + + const { isOrderSyncingInProgress } = getRampsControllerInstance(); + + if ( + !isBackupAndSyncEnabled || + !isRampsSyncingEnabled || + isOrderSyncingInProgress || + !isAuthEnabled + ) { + return false; + } + + return true; + } catch { + // Host has not delegated User Storage / auth actions yet + return false; + } +} diff --git a/packages/ramps-controller/src/order-syncing/types.ts b/packages/ramps-controller/src/order-syncing/types.ts new file mode 100644 index 00000000000..8ff1dfccf39 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/types.ts @@ -0,0 +1,67 @@ +import type { TraceCallback } from '@metamask/controller-utils'; + +import type { RampsControllerMessenger } from '../RampsController.js'; +import type { RampsOrder } from '../RampsService.js'; +import type { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; + +/** + * Compact User Storage entry wrapping a full {@link RampsOrder}. + */ +export type UserStorageRampsOrderEntry = { + /** + * Schema version — allows upgrade/downgrade handling in the future. + */ + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + /** + * Full V2 ramps order payload. + */ + o: RampsOrder; + /** + * Last-updated timestamp used for conflict resolution. + */ + lu?: number; + /** + * Soft-delete tombstone timestamp. When set, the order should be removed locally. + */ + dt?: number; +}; + +/** + * {@link RampsOrder} extended with sync metadata used for conflict resolution. + * + * `lastUpdatedAt` is persisted on local controller orders so last-write-wins can + * compare against remote `lu` after local edits. `deletedAt` is remote-only and + * must be stripped before writing into {@link RampsController} state. + */ +export type SyncRampsOrder = RampsOrder & { + lastUpdatedAt?: number; + deletedAt?: number; +}; + +/** + * Minimal controller surface required by order syncing. + */ +export type OrderSyncingController = { + state: { orders: RampsOrder[] }; + readonly isOrderSyncingInProgress: boolean; + setIsOrderSyncingInProgress: (value: boolean) => void; + addOrder: (order: RampsOrder) => void; + removeOrder: (providerOrderId: string) => void; + /** + * Returns and clears orders deleted locally while a full sync held the + * semaphore, so remote tombstones can still be written at sync end. + */ + drainPendingRemoteDeletes: () => RampsOrder[]; +}; + +/** + * Options for ramps order syncing operations. + */ +export type OrderSyncingOptions = { + getRampsControllerInstance: () => OrderSyncingController; + getMessenger: () => RampsControllerMessenger; + trace?: TraceCallback; +}; diff --git a/packages/ramps-controller/src/order-syncing/utils.test.ts b/packages/ramps-controller/src/order-syncing/utils.test.ts new file mode 100644 index 00000000000..409fa1d8871 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -0,0 +1,257 @@ +import { RampsOrderStatus } from '../RampsService.js'; +import type { RampsOrder } from '../RampsService.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; +import { + areOrdersEqual, + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripDeletedAt, + stripPaymentDetailsForRemoteStorage, + stripSyncMetadata, +} from './utils.js'; + +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak/orders/abc-123', + isOnlyLink: false, + provider: { + id: 'transak', + name: 'Transak', + environmentType: 'PRODUCTION', + logos: { light: {}, dark: {} }, + links: [], + deliveryTime: 5, + orderFrequency: null, + } as RampsOrder['provider'], + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + exchangeRate: 2000, + ...overrides, + }; +} + +describe('order-syncing/utils', () => { + describe('createOrderStorageKey', () => { + it('prefers the code embedded in the order id path', () => { + expect( + createOrderStorageKey({ + id: '/providers/transak/orders/order-1', + providerOrderId: 'provider-native-id', + }), + ).toBe('order-1'); + }); + + it('falls back to providerOrderId', () => { + expect( + createOrderStorageKey({ + providerOrderId: 'provider-native-id', + }), + ).toBe('provider-native-id'); + }); + + it('trims whitespace from providerOrderId keys', () => { + expect( + createOrderStorageKey({ + providerOrderId: ' provider-native-id ', + }), + ).toBe('provider-native-id'); + }); + + it('returns an empty key when providerOrderId is missing', () => { + expect(createOrderStorageKey({})).toBe(''); + }); + + it('falls back to providerOrderId when /orders/ segment is empty', () => { + expect( + createOrderStorageKey({ + id: '/providers/transak/orders/', + providerOrderId: 'real-id', + }), + ).toBe('real-id'); + }); + }); + + describe('isSyncableOrder', () => { + it('returns true when providerOrderId is present', () => { + expect(isSyncableOrder(createMockOrder())).toBe(true); + }); + + it('returns true when only the order id path is present', () => { + expect( + isSyncableOrder( + createMockOrder({ + providerOrderId: '', + id: '/providers/transak/orders/order-from-id', + }), + ), + ).toBe(true); + }); + + it('returns false when both id and providerOrderId are missing', () => { + expect( + isSyncableOrder( + createMockOrder({ id: undefined, providerOrderId: '' }), + ), + ).toBe(false); + }); + + it('accepts minimal id/providerOrderId picks', () => { + expect( + isSyncableOrder({ + id: '/providers/transak/orders/order-from-id', + providerOrderId: 'provider-native-id', + }), + ).toBe(true); + }); + }); + + describe('mapRampsOrderToUserStorageEntry / mapUserStorageEntryToRampsOrder', () => { + it('defaults lastUpdatedAt when mapping to storage', () => { + const entry = mapRampsOrderToUserStorageEntry(createMockOrder()); + + expect(entry.lu).toStrictEqual(expect.any(Number)); + }); + + it('maps storage entries without lastUpdatedAt metadata', () => { + const entry: UserStorageRampsOrderEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder(), + }; + + const mapped = mapUserStorageEntryToRampsOrder(entry); + expect(mapped.lastUpdatedAt).toBeUndefined(); + }); + + it('round-trips a full order with sync metadata', () => { + const order: SyncRampsOrder = { + ...createMockOrder(), + lastUpdatedAt: 1700000001000, + }; + + const entry = mapRampsOrderToUserStorageEntry(order); + + expect(entry[USER_STORAGE_VERSION_KEY]).toBe(USER_STORAGE_VERSION); + expect(entry.o.providerOrderId).toBe('abc-123'); + expect(entry.lu).toBe(1700000001000); + expect(entry.dt).toBeUndefined(); + + const mapped = mapUserStorageEntryToRampsOrder(entry); + expect(mapped.providerOrderId).toBe('abc-123'); + expect(mapped.lastUpdatedAt).toBe(1700000001000); + expect(mapped.deletedAt).toBeUndefined(); + }); + + it('preserves soft-delete tombstones', () => { + const entry: UserStorageRampsOrderEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder(), + lu: 1700000002000, + dt: 1700000002000, + }; + + const mapped = mapUserStorageEntryToRampsOrder(entry); + expect(mapped.deletedAt).toBe(1700000002000); + }); + + it('strips paymentDetails from remote storage payloads', () => { + const entry = mapRampsOrderToUserStorageEntry({ + ...createMockOrder(), + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa', + fields: [{ name: 'IBAN', id: 'iban', value: 'DE00' }], + }, + ], + }); + + expect(entry.o.paymentDetails).toBeUndefined(); + }); + }); + + describe('stripPaymentDetailsForRemoteStorage', () => { + it('omits paymentDetails while keeping other fields', () => { + const stripped = stripPaymentDetailsForRemoteStorage({ + ...createMockOrder(), + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa', + fields: [{ name: 'IBAN', id: 'iban', value: 'DE00' }], + }, + ], + }); + + expect(stripped.paymentDetails).toBeUndefined(); + expect(stripped.providerOrderId).toBe('abc-123'); + }); + }); + + describe('stripSyncMetadata', () => { + it('removes lastUpdatedAt and deletedAt', () => { + const stripped = stripSyncMetadata({ + ...createMockOrder(), + lastUpdatedAt: 1, + deletedAt: 2, + }); + + expect(stripped).not.toHaveProperty('lastUpdatedAt'); + expect(stripped).not.toHaveProperty('deletedAt'); + expect(stripped.providerOrderId).toBe('abc-123'); + }); + }); + + describe('stripDeletedAt', () => { + it('removes deletedAt while preserving lastUpdatedAt', () => { + const stripped = stripDeletedAt({ + ...createMockOrder(), + lastUpdatedAt: 1, + deletedAt: 2, + }); + + expect(stripped.lastUpdatedAt).toBe(1); + expect(stripped).not.toHaveProperty('deletedAt'); + expect(stripped.providerOrderId).toBe('abc-123'); + }); + }); + + describe('areOrdersEqual', () => { + it('returns true for identical order bodies ignoring sync metadata', () => { + const a = { ...createMockOrder(), lastUpdatedAt: 1 }; + const b = { ...createMockOrder(), lastUpdatedAt: 999 }; + expect(areOrdersEqual(a, b)).toBe(true); + }); + + it('returns false when order content differs', () => { + const a = createMockOrder({ fiatAmount: 100 }); + const b = createMockOrder({ fiatAmount: 200 }); + expect(areOrdersEqual(a, b)).toBe(false); + }); + + it('ignores object key insertion order', () => { + const a = { fiatAmount: 100, providerOrderId: 'x' } as RampsOrder; + const b = { providerOrderId: 'x', fiatAmount: 100 } as RampsOrder; + expect(areOrdersEqual(a, b)).toBe(true); + }); + }); +}); diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts new file mode 100644 index 00000000000..4d8d8192fcc --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -0,0 +1,162 @@ +import type { RampsOrder } from '../RampsService.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; + +/** + * Creates a unique storage key for a ramps order. + * Mirrors {@link getInternalOrderCode} without importing the controller module + * (avoids circular dependencies). + * + * Prefers a non-empty `/orders/` suffix; otherwise falls back to a trimmed + * `providerOrderId`. An empty `/orders/` segment does not win over a real + * provider order id. + * + * @param order - Order fields used to derive the internal order code. + * @returns Storage entry key under the rampsOrders feature. + */ +export function createOrderStorageKey( + order: Pick, +): string { + const { id, providerOrderId } = order; + if (id?.includes('/orders/')) { + const code = id.split('/orders/')[1]?.trim(); + if (code) { + return code; + } + } + return providerOrderId?.trim() ?? ''; +} + +/** + * Whether an order has the minimum fields required for syncing. + * + * @param order - The order to validate. + * @returns True when the order can be synced. + */ +export function isSyncableOrder( + order: Pick, +): boolean { + return createOrderStorageKey(order).length > 0; +} + +/** + * Strips bank-transfer / PII-heavy payment details before persisting to User + * Storage. Local controller state may still keep `paymentDetails`. + * + * @param order - Order that may include payment details. + * @returns Order body safe for remote sync payloads. + */ +export function stripPaymentDetailsForRemoteStorage( + order: RampsOrder, +): RampsOrder { + const { paymentDetails: _paymentDetails, ...safeOrder } = order; + return safeOrder; +} + +/** + * Maps a ramps order (with optional sync metadata) to a User Storage entry. + * + * @param order - The order to map. + * @returns A User Storage entry ready to be JSON-stringified. + */ +export function mapRampsOrderToUserStorageEntry( + order: SyncRampsOrder, +): UserStorageRampsOrderEntry { + const { lastUpdatedAt, deletedAt, ...rampsOrder } = order; + const now = Date.now(); + + return { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: stripPaymentDetailsForRemoteStorage(rampsOrder), + lu: lastUpdatedAt ?? now, + ...(deletedAt ? { dt: deletedAt } : {}), + }; +} + +/** + * Maps a User Storage entry back to a sync-aware ramps order. + * + * @param entry - The User Storage entry. + * @returns A {@link SyncRampsOrder} for merge logic. + */ +export function mapUserStorageEntryToRampsOrder( + entry: UserStorageRampsOrderEntry, +): SyncRampsOrder { + return { + ...entry.o, + ...(entry.lu ? { lastUpdatedAt: entry.lu } : {}), + ...(entry.dt ? { deletedAt: entry.dt } : {}), + }; +} + +/** + * Strips sync metadata for equality checks and remote payload shaping. + * + * @param order - Order that may include sync metadata. + * @returns A plain {@link RampsOrder} without sync fields. + */ +export function stripSyncMetadata(order: SyncRampsOrder): RampsOrder { + const { + lastUpdatedAt: _lastUpdatedAt, + deletedAt: _deletedAt, + ...rampsOrder + } = order; + return rampsOrder; +} + +/** + * Strips remote tombstone metadata while preserving `lastUpdatedAt` so local + * controller state can participate in last-write-wins conflict resolution. + * + * @param order - Order that may include sync metadata. + * @returns Order safe to persist locally (no `deletedAt`). + */ +export function stripDeletedAt(order: SyncRampsOrder): SyncRampsOrder { + const { deletedAt: _deletedAt, ...rampsOrder } = order; + return rampsOrder; +} + +/** + * JSON-stringifies values with object keys sorted so equality is stable across + * key insertion order. + * + * @param value - Value to serialize. + * @returns Stable JSON string. + */ +function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, nested) => { + if ( + nested && + typeof nested === 'object' && + !Array.isArray(nested) && + !(nested instanceof Date) + ) { + const record = nested as Record; + return Object.keys(record) + .sort() + .reduce>((acc, key) => { + acc[key] = record[key]; + return acc; + }, {}); + } + return nested; + }); +} + +/** + * Deep-compares two ramps orders by stable JSON serialization of their order + * bodies (sync metadata excluded). + * + * @param a - First order. + * @param b - Second order. + * @returns True when the order payloads are equal. + */ +export function areOrdersEqual( + a: SyncRampsOrder | RampsOrder, + b: SyncRampsOrder | RampsOrder, +): boolean { + return ( + stableStringify(stripSyncMetadata(a as SyncRampsOrder)) === + stableStringify(stripSyncMetadata(b as SyncRampsOrder)) + ); +} From 82adc611e17b8ab2070f7b924fb4550a1b7ce42c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 05:54:33 -0600 Subject: [PATCH 02/31] fix(ramps-controller): restore controller wiring dropped in squash The force-pushed squash left order-syncing modules/tests but omitted RampsController messenger AllowedActions, sync APIs, and changelog. Restore those, keep freshest remote (incl. tombstones) across entropy profiles, and harden sync coalescing. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 37 +++ .../RampsController-method-action-types.ts | 5 + .../src/RampsController.order-syncing.test.ts | 96 ++++++++ .../ramps-controller/src/RampsController.ts | 222 +++++++++++++++++- .../order-syncing/controller-integration.ts | 40 +++- 5 files changed, 381 insertions(+), 19 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index d76121f7636..0f6d474d254 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add V2 ramps order syncing with User Storage ([#9474](https://github.com/MetaMask/core/pull/9474)) + - New `order-syncing/` module stores `RampsOrder` objects (without `paymentDetails`) as per-order User Storage entries under the `rampsOrders` feature + - `RampsController.syncOrdersWithUserStorage()` performs bidirectional sync with timestamp-based conflict resolution and soft deletes + - `addOrder` / `removeOrder` incrementally push local changes to User Storage when Backup & Sync and `isRampsSyncingEnabled` are on + - Messenger action `RampsController:syncOrdersWithUserStorage` for hosts to trigger a full sync on unlock / feature enable + - Optional constructor `onOrderSyncErroneousSituation` and `trace` callbacks for host observability + +### Changed + +- **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) + +### Fixed + +- Default `isRampsSyncingEnabled` to `true` in `canPerformOrderSyncing` when the field is absent from persisted User Storage state, matching controller default state and UI selectors ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Route incremental `addOrder` / `removeOrder` remote writes through `performBatchSetStorage` so provider order IDs with hyphens or other non-`\w` characters sync correctly; single-entry `performSetStorage` paths are rejected by User Storage's `feature.key` validator ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Re-read live local orders before remote upload so orders added while `isOrderSyncingInProgress` suppresses incremental writes are still uploaded during full sync ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Queue local deletes that occur during full sync and write remote tombstones when sync finishes, so mid-sync deletes are not resurrected ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Coalesce overlapping `syncOrdersWithUserStorage` calls into a follow-up run instead of silently no-oping ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Fall back to trimmed `providerOrderId` when an order `id` has an empty `/orders/` segment, and reject empty storage keys on remote write ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Strip `paymentDetails` from User Storage payloads; keep them in local controller state only ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Preserve remote `createdAt` as `lastUpdatedAt` when importing entries without `lu`, instead of inventing `Date.now()` ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Clear tombstone metadata when uploading active local orders during full sync ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Apply last-write-wins rules to remote soft-deletes so newer local orders can restore over older tombstones ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Surface remote fetch/parse failures via `onOrderSyncErroneousSituation` without attaching raw order JSON ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Honor newer remote tombstones during full sync even when local order content differs ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Trim `providerOrderId` when deriving User Storage keys so syncability checks and storage keys stay aligned ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Clear order polling metadata for stored `providerOrderId` and internal order codes when `removeOrder` is called with either identifier ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Persist `lastUpdatedAt` on local orders and bump it on every non-sync `addOrder` so last-write-wins prefers fresher local edits over stale remote copies ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Set `isOrderSyncingInProgress` before the remote fetch so overlapping full syncs cannot race merge/upload ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Trim `providerOrderId` in `getInternalOrderCode` to match User Storage key derivation ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Prefer the freshest remote copy when the same order key appears under multiple entropy profiles during order sync ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Prefer newer remote tombstones over older live duplicates across entropy profiles ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) + ## [17.2.0] ### Added diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index beac69dd3b5..1acb187ed59 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -253,6 +253,11 @@ export type RampsControllerRemoveOrderAction = { /** * Bidirectionally syncs V2 ramps orders with User Storage. * Hosts should call this on unlock / when ramps syncing is enabled. + * + * Overlapping calls are coalesced into the in-flight worker. After the worker + * settles, this method loops when `#orderSyncQueued` is still set so a + * request that arrived between the worker's last loop check and promise + * resolution is not dropped. */ export type RampsControllerSyncOrdersWithUserStorageAction = { type: `RampsController:syncOrdersWithUserStorage`; diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index 0d245a0b96c..aa5476a202e 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -327,6 +327,46 @@ describe('RampsController order syncing', () => { expect(callCount).toBe(2); }); + it('restarts sync when a request arrives after the worker exits its loop', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let releaseFirstFetch: (() => void) | undefined; + const firstFetchBlocked = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + let firstFetchResolved: (() => void) | undefined; + const firstFetchResolvedPromise = new Promise((resolve) => { + firstFetchResolved = resolve; + }); + + let fetchCount = 0; + performGetStorageAllFeatureEntries.mockImplementation(async () => { + fetchCount += 1; + if (fetchCount === 1) { + await firstFetchBlocked; + firstFetchResolved?.(); + } + return []; + }); + + const firstSync = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + // Unblock the in-flight fetch. The worker then exits its while-loop and + // yields; attach as a waiter in that window so the post-await re-check + // starts a follow-up sync. + releaseFirstFetch?.(); + await firstFetchResolvedPromise; + const lateSync = controller.syncOrdersWithUserStorage(); + + await Promise.all([firstSync, lateSync]); + + expect(fetchCount).toBe(2); + }); + it('preserves createdAt as lastUpdatedAt when syncing remotes without lu', () => { const { controller } = setupControllerWithOrderSyncingMocks(); @@ -374,4 +414,60 @@ describe('RampsController order syncing', () => { expect(controller.state.orders).toHaveLength(0); }); + + it('reports when addOrder cannot derive an internal order code', () => { + const onOrderSyncErroneousSituation = jest.fn(); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => ({ + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + }), + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + async () => undefined, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + const messenger: RampsControllerMessenger = new Messenger({ + namespace: 'RampsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', + ], + }); + + const controller = new RampsController({ + messenger, + onOrderSyncErroneousSituation, + }); + + controller.addOrder({ + id: '/providers/transak/orders/', + providerOrderId: '', + } as never); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Unable to derive internal order code for addOrder', + {}, + ); + expect(controller.state.orders).toHaveLength(0); + }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 3f388e457f3..e858942dcb0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -4,8 +4,13 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import type { + AuthenticationController, + UserStorageController, +} from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; @@ -15,6 +20,11 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import { + deleteOrderInRemoteStorage, + syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, + updateOrderInRemoteStorage, +} from './order-syncing/index.js'; import { getProvidersServingAsset } from './providerAvailability.js'; import type { RampsControllerMethodActions } from './RampsController-method-action-types.js'; import type { RampsErrorCode } from './rampsErrorCodes.js'; @@ -628,7 +638,12 @@ type AllowedActions = | TransakServiceGetIdProofStatusAction | TransakServiceCancelOrderAction | TransakServiceCancelAllActiveOrdersAction - | TransakServiceGetActiveOrdersAction; + | TransakServiceGetActiveOrdersAction + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction + | UserStorageController.UserStorageControllerPerformBatchSetStorageAction + | UserStorageController.UserStorageControllerListEntropySourcesAction + | AuthenticationController.AuthenticationControllerIsSignedInAction; /** * Published when the state of {@link RampsController} changes. @@ -692,6 +707,18 @@ export type RampsControllerOptions = { * `undefined` when omitted. */ getDefaultRedirectUrl?: () => string | undefined; + /** + * Optional callback for order-sync parse/fetch/merge failures (e.g. Sentry). + * Context never includes full order JSON. + */ + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; + /** + * Optional performance tracing callback used by order sync operations. + */ + trace?: TraceCallback; }; // === HELPER FUNCTIONS === @@ -770,17 +797,21 @@ export function getInternalOrderCode( orderOrId: Pick | string, ): string { if (typeof orderOrId === 'string') { - return orderOrId.includes('/orders/') - ? orderOrId.split('/orders/')[1] - : orderOrId; + if (orderOrId.includes('/orders/')) { + return orderOrId.split('/orders/')[1]?.trim() || ''; + } + return orderOrId.trim(); } const { id, providerOrderId } = orderOrId; if (id?.includes('/orders/')) { - return id.split('/orders/')[1]; + const code = id.split('/orders/')[1]?.trim(); + if (code) { + return code; + } } - return providerOrderId; + return providerOrderId?.trim() ?? ''; } // === ORDER POLLING CONSTANTS === @@ -858,6 +889,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'transakCancelOrder', 'transakCancelAllActiveOrders', 'transakGetActiveOrders', + 'syncOrdersWithUserStorage', ] as const; /** @@ -885,6 +917,13 @@ export class RampsController extends BaseController< */ readonly #getDefaultRedirectUrl: () => string | undefined; + readonly #onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; + + readonly #trace?: TraceCallback; + /** * Map of pending requests for deduplication. * Key is the cache key, value is the pending request with abort controller. @@ -911,6 +950,57 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + /** + * Semaphore that prevents sync feedback loops while applying remote order changes. + */ + #isOrderSyncingInProgress = false; + + /** + * Orders deleted locally while a full sync held the semaphore. Drained at sync + * end so remote tombstones are still written. + */ + readonly #pendingRemoteDeletes: Map = new Map(); + + /** + * Coalesces overlapping `syncOrdersWithUserStorage` calls into a follow-up run. + */ + #orderSyncQueued = false; + + #orderSyncPromise: Promise | null = null; + + /** + * Whether a full order sync is currently applying remote changes. + * + * @returns Whether order sync is in progress. + */ + get isOrderSyncingInProgress(): boolean { + return this.#isOrderSyncingInProgress; + } + + /** + * Sets the order-syncing-in-progress semaphore. + * Used by the order-syncing module; hosts should not call this. + * + * @param value - Whether sync is in progress. + * @internal + */ + setIsOrderSyncingInProgress(value: boolean): void { + this.#isOrderSyncingInProgress = value; + } + + /** + * Returns and clears orders deleted while a full sync was in progress. + * Used by the order-syncing module. + * + * @returns Pending deletes for remote tombstone upload. + * @internal + */ + drainPendingRemoteDeletes(): RampsOrder[] { + const pending = [...this.#pendingRemoteDeletes.values()]; + this.#pendingRemoteDeletes.clear(); + return pending; + } + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -954,6 +1044,8 @@ export class RampsController extends BaseController< * @param args.getDefaultRedirectUrl - Optional callback returning the default * redirect URL used for the widened quote fetch when the caller omits * `redirectUrl`. Defaults to a callback returning `undefined`. + * @param args.onOrderSyncErroneousSituation - Optional order-sync error reporter. + * @param args.trace - Optional performance tracing callback for order sync. */ constructor({ messenger, @@ -961,6 +1053,8 @@ export class RampsController extends BaseController< requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, getDefaultRedirectUrl, + onOrderSyncErroneousSituation, + trace, }: RampsControllerOptions) { super({ messenger, @@ -978,6 +1072,8 @@ export class RampsController extends BaseController< this.#requestCacheMaxSize = requestCacheMaxSize; this.#getDefaultRedirectUrl = getDefaultRedirectUrl ?? ((): string | undefined => undefined); + this.#onOrderSyncErroneousSituation = onOrderSyncErroneousSituation; + this.#trace = trace; this.messenger.registerMethodActionHandlers( this, @@ -2368,11 +2464,31 @@ export class RampsController extends BaseController< */ addOrder(order: RampsOrder): void { const internalOrderCode = getInternalOrderCode(order); + if (!internalOrderCode) { + this.#onOrderSyncErroneousSituation?.( + 'Unable to derive internal order code for addOrder', + {}, + ); + return; + } + + const incomingLastUpdatedAt = (order as { lastUpdatedAt?: number }) + .lastUpdatedAt; + // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them + // over stale remote copies when an incremental push was skipped/failed. + // During full sync, preserve remote `lu` / `createdAt` (never invent "now" + // for missing `lu`, or stale remotes win later LWW comparisons). const healedOrder = { ...order, providerOrderId: internalOrderCode, + lastUpdatedAt: this.#isOrderSyncingInProgress + ? (incomingLastUpdatedAt ?? order.createdAt ?? 0) + : Date.now(), }; + // A mid-sync add cancels a pending delete for the same key. + this.#pendingRemoteDeletes.delete(internalOrderCode); + this.update((state) => { const idx = state.orders.findIndex( (existing) => getInternalOrderCode(existing) === internalOrderCode, @@ -2386,6 +2502,15 @@ export class RampsController extends BaseController< } as Draft; } }); + + if (!this.#isOrderSyncingInProgress) { + updateOrderInRemoteStorage(healedOrder, { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }).catch((error) => { + console.error('Error updating ramps order in remote storage:', error); + }); + } } /** @@ -2394,13 +2519,96 @@ export class RampsController extends BaseController< * @param providerOrderId - The provider order ID to remove. */ removeOrder(providerOrderId: string): void { + const orderToRemove = this.state.orders.find( + (order) => + order.providerOrderId === providerOrderId || + getInternalOrderCode(order) === providerOrderId, + ); + this.update((state) => { state.orders = state.orders.filter( - (order) => order.providerOrderId !== providerOrderId, + (order) => + order.providerOrderId !== providerOrderId && + getInternalOrderCode(order) !== providerOrderId, ); }); this.#orderPollingMeta.delete(providerOrderId); + + if (orderToRemove) { + if (orderToRemove.providerOrderId) { + this.#orderPollingMeta.delete(orderToRemove.providerOrderId); + } + + const internalOrderCode = getInternalOrderCode(orderToRemove); + this.#orderPollingMeta.delete(internalOrderCode); + } + + if (orderToRemove) { + const deleteKey = getInternalOrderCode(orderToRemove); + if (this.#isOrderSyncingInProgress) { + // Incremental remote deletes are gated off during full sync; queue a + // tombstone write for the end of the sync instead. + if (deleteKey) { + this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + } + } else { + deleteOrderInRemoteStorage(orderToRemove, { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }).catch((error) => { + console.error( + 'Error deleting ramps order from remote storage:', + error, + ); + }); + } + } + } + + /** + * Bidirectionally syncs V2 ramps orders with User Storage. + * Hosts should call this on unlock / when ramps syncing is enabled. + * + * Overlapping calls are coalesced into the in-flight worker. After the worker + * settles, this method loops when `#orderSyncQueued` is still set so a + * request that arrived between the worker's last loop check and promise + * resolution is not dropped. + */ + async syncOrdersWithUserStorage(): Promise { + this.#orderSyncQueued = true; + + while (this.#orderSyncQueued || this.#orderSyncPromise) { + if (this.#orderSyncPromise) { + await this.#orderSyncPromise; + continue; + } + + this.#orderSyncPromise = (async (): Promise => { + while (this.#orderSyncQueued) { + this.#orderSyncQueued = false; + await syncOrdersWithUserStorageInternal( + { + onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + }, + { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + trace: this.#trace, + }, + ); + } + // Yield so a caller can set `#orderSyncQueued` after the inner while + // check and still be observed by the outer loop. + await Promise.resolve(); + })(); + + try { + await this.#orderSyncPromise; + } finally { + this.#orderSyncPromise = null; + } + } } /** diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 82c94970b35..7ffc3bf406b 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -47,6 +47,19 @@ function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; } +/** + * Returns the conflict-resolution timestamp for a remote entry when the same + * order key appears under multiple entropy profiles. + * + * Soft-deletes use `deletedAt`; live entries use {@link getOrderTimestamp}. + * + * @param order - A remote ramps order that may include sync metadata. + * @returns The timestamp used to pick the freshest remote copy. + */ +function getRemoteConflictTimestamp(order: SyncRampsOrder): number { + return order.deletedAt ?? getOrderTimestamp(order); +} + /** * Builds the local/remote merge plan from the current local snapshot and remote * entries. @@ -326,6 +339,9 @@ async function getRemoteOrders( return []; } + // Multi-SRP can return the same order key from more than one entropy + // profile. Keep the freshest copy so secondary-profile updates are not + // discarded just because an older primary-profile entry was parsed first. const ordersByKey = new Map(); for (const orderJson of remoteOrdersJsonArray) { @@ -350,17 +366,16 @@ async function getRemoteOrders( } const mapped = mapUserStorageEntryToRampsOrder(entry); const key = createOrderStorageKey(mapped); - if (key) { - const existing = ordersByKey.get(key); - if (existing) { - const existingTimestamp = getOrderTimestamp(existing); - const mappedTimestamp = getOrderTimestamp(mapped); - if (mappedTimestamp > existingTimestamp) { - ordersByKey.set(key, mapped); - } - } else { - ordersByKey.set(key, mapped); - } + if (!key) { + continue; + } + const existing = ordersByKey.get(key); + if ( + !existing || + getRemoteConflictTimestamp(mapped) > + getRemoteConflictTimestamp(existing) + ) { + ordersByKey.set(key, mapped); } } catch (error) { // Do not attach raw order JSON — it can include wallet/PII fields. @@ -374,7 +389,7 @@ async function getRemoteOrders( } } - return [...ordersByKey.values()]; + return Array.from(ordersByKey.values()); } catch (error) { onOrderSyncErroneousSituation?.('Failed to fetch remote ramps orders', { error, @@ -522,5 +537,6 @@ export const orderSyncingTestExports = { computeMergePlan, reconcileOrdersForRemoteUpload, getOrderTimestamp, + getRemoteConflictTimestamp, saveOrdersToUserStorage, }; From 7017f31d714becab58d926fcc0cac5b0acc851af Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 05:56:31 -0600 Subject: [PATCH 03/31] style(ramps-controller): oxfmt RampsController sync wiring Co-authored-by: Cursor --- packages/ramps-controller/src/RampsController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e858942dcb0..8c92e763063 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -2589,7 +2589,8 @@ export class RampsController extends BaseController< this.#orderSyncQueued = false; await syncOrdersWithUserStorageInternal( { - onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + onOrderSyncErroneousSituation: + this.#onOrderSyncErroneousSituation, }, { getRampsControllerInstance: () => this, From 0cd9222470c4c0e4047442f2030e1bd40897e89f Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:00:25 -0600 Subject: [PATCH 04/31] fix(ramps-controller): ignore local-only paymentDetails in areOrdersEqual Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + .../src/order-syncing/utils.test.ts | 11 ++++++++++ .../src/order-syncing/utils.ts | 22 ++++++++++++++----- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 0f6d474d254..ac18f08294a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prefer newer remote tombstones over older live duplicates across entropy profiles ([#9474](https://github.com/MetaMask/core/pull/9474)) - Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) - Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Exclude local-only `paymentDetails` from `areOrdersEqual` so orders carrying local payment details are not re-uploaded on every sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [17.2.0] diff --git a/packages/ramps-controller/src/order-syncing/utils.test.ts b/packages/ramps-controller/src/order-syncing/utils.test.ts index 409fa1d8871..03978275425 100644 --- a/packages/ramps-controller/src/order-syncing/utils.test.ts +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -253,5 +253,16 @@ describe('order-syncing/utils', () => { const b = { providerOrderId: 'x', fiatAmount: 100 } as RampsOrder; expect(areOrdersEqual(a, b)).toBe(true); }); + + it('ignores local-only paymentDetails so remote copies compare equal', () => { + const local = { + ...createMockOrder(), + paymentDetails: [ + { label: 'IBAN', value: 'DE00 0000 0000 0000 0000 00' }, + ], + } as RampsOrder; + const remote = createMockOrder(); + expect(areOrdersEqual(local, remote)).toBe(true); + }); }); }); diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts index 4d8d8192fcc..34d15864613 100644 --- a/packages/ramps-controller/src/order-syncing/utils.ts +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -144,19 +144,31 @@ function stableStringify(value: unknown): string { } /** - * Deep-compares two ramps orders by stable JSON serialization of their order - * bodies (sync metadata excluded). + * Deep-compares two ramps orders by stable JSON serialization of their + * syncable bodies. Sync metadata (`lastUpdatedAt` / `deletedAt`) and + * local-only `paymentDetails` are excluded: `paymentDetails` is never persisted + * remotely, so a local order that carries it must still compare equal to its + * remote copy. Otherwise such orders would look changed on every sync and, with + * local-wins-on-tie conflict resolution, be re-uploaded indefinitely. * * @param a - First order. * @param b - Second order. - * @returns True when the order payloads are equal. + * @returns True when the syncable order payloads are equal. */ export function areOrdersEqual( a: SyncRampsOrder | RampsOrder, b: SyncRampsOrder | RampsOrder, ): boolean { return ( - stableStringify(stripSyncMetadata(a as SyncRampsOrder)) === - stableStringify(stripSyncMetadata(b as SyncRampsOrder)) + stableStringify( + stripPaymentDetailsForRemoteStorage( + stripSyncMetadata(a as SyncRampsOrder), + ), + ) === + stableStringify( + stripPaymentDetailsForRemoteStorage( + stripSyncMetadata(b as SyncRampsOrder), + ), + ) ); } From 1c0291a6094fc2e008d7054b0096bc0371951c1f Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:11:23 -0600 Subject: [PATCH 05/31] refactor(ramps-controller): drop cross-SRP order aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order sync is same-SRP only (Extension ↔ Mobile). Reading every entropy profile added complexity without a product requirement. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 3 +- .../ramps-controller/src/RampsController.ts | 1 - .../controller-integration.test.ts | 120 ------------------ .../order-syncing/controller-integration.ts | 62 ++------- 4 files changed, 10 insertions(+), 176 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index ac18f08294a..182d9c823c6 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -39,11 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist `lastUpdatedAt` on local orders and bump it on every non-sync `addOrder` so last-write-wins prefers fresher local edits over stale remote copies ([#9474](https://github.com/MetaMask/core/pull/9474)) - Set `isOrderSyncingInProgress` before the remote fetch so overlapping full syncs cannot race merge/upload ([#9474](https://github.com/MetaMask/core/pull/9474)) - Trim `providerOrderId` in `getInternalOrderCode` to match User Storage key derivation ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Prefer the freshest remote copy when the same order key appears under multiple entropy profiles during order sync ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Prefer newer remote tombstones over older live duplicates across entropy profiles ([#9474](https://github.com/MetaMask/core/pull/9474)) - Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) - Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) - Exclude local-only `paymentDetails` from `areOrdersEqual` so orders carrying local payment details are not re-uploaded on every sync ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Scope remote order fetches to the active/primary SRP profile only; cross-SRP aggregation is out of scope for same-SRP Extension ↔ Mobile sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [17.2.0] diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 8c92e763063..5a727c6529a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -642,7 +642,6 @@ type AllowedActions = | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction - | UserStorageController.UserStorageControllerListEntropySourcesAction | AuthenticationController.AuthenticationControllerIsSignedInAction; /** diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts index 23636a5fa88..5575096b100 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -102,8 +102,6 @@ describe('order-syncing/controller-integration', () => { return { isBackupAndSyncEnabled, isRampsSyncingEnabled }; case 'AuthenticationController:isSignedIn': return isSignedIn; - case 'UserStorageController:listEntropySources': - return ['entropy-primary']; case 'UserStorageController:performGetStorageAllFeatureEntries': return performGetStorageAllFeatureEntries(...args); case 'UserStorageController:performBatchSetStorage': @@ -193,118 +191,6 @@ describe('order-syncing/controller-integration', () => { ); }); - it('keeps the freshest remote entry when duplicate storage keys are returned', async () => { - const olderRemote = createMockOrder({ - providerOrderId: 'dup-key', - id: '/providers/transak/orders/dup-key', - fiatAmount: 100, - }); - const newerRemote = createMockOrder({ - providerOrderId: 'dup-key', - id: '/providers/transak/orders/dup-key', - fiatAmount: 999, - }); - const olderEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...olderRemote, - lastUpdatedAt: 1_700_000_000_000, - }), - ); - const newerEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...newerRemote, - lastUpdatedAt: 1_700_000_000_100, - }), - ); - - const { options, addOrder } = arrangeMocks({ - localOrders: [], - remoteEntries: [olderEntry, newerEntry], - }); - - await syncOrdersWithUserStorage({}, options); - - expect(addOrder).toHaveBeenCalledTimes(1); - expect(addOrder).toHaveBeenCalledWith( - expect.objectContaining({ - providerOrderId: 'dup-key', - fiatAmount: 999, - }), - ); - }); - - it('keeps an earlier fresher remote entry over a later stale duplicate', async () => { - const newerRemote = createMockOrder({ - providerOrderId: 'dup-key-2', - id: '/providers/transak/orders/dup-key-2', - fiatAmount: 500, - }); - const olderRemote = createMockOrder({ - providerOrderId: 'dup-key-2', - id: '/providers/transak/orders/dup-key-2', - fiatAmount: 50, - }); - const newerEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...newerRemote, - lastUpdatedAt: 1_700_000_000_200, - }), - ); - const olderEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...olderRemote, - lastUpdatedAt: 1_700_000_000_000, - }), - ); - - const { options, addOrder } = arrangeMocks({ - localOrders: [], - remoteEntries: [newerEntry, olderEntry], - }); - - await syncOrdersWithUserStorage({}, options); - - expect(addOrder).toHaveBeenCalledTimes(1); - expect(addOrder).toHaveBeenCalledWith( - expect.objectContaining({ - providerOrderId: 'dup-key-2', - fiatAmount: 500, - }), - ); - }); - - it('prefers a newer remote tombstone over an older live duplicate key', async () => { - const liveRemote = createMockOrder({ - providerOrderId: 'dup-tombstone', - id: '/providers/transak/orders/dup-tombstone', - fiatAmount: 100, - }); - const tombstoneRemote: SyncRampsOrder = { - ...liveRemote, - deletedAt: 1_700_000_000_300, - lastUpdatedAt: 1_700_000_000_300, - }; - const liveEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...liveRemote, - lastUpdatedAt: 1_700_000_000_000, - }), - ); - const tombstoneEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry(tombstoneRemote), - ); - - const { options, addOrder, removeOrder } = arrangeMocks({ - localOrders: [liveRemote], - remoteEntries: [liveEntry, tombstoneEntry], - }); - - await syncOrdersWithUserStorage({}, options); - - expect(addOrder).not.toHaveBeenCalled(); - expect(removeOrder).toHaveBeenCalledWith('dup-tombstone'); - }); - it('applies remote soft-deletes locally when the tombstone is newer', async () => { const localOrder = createMockOrder(); const deletedRemote: SyncRampsOrder = { @@ -457,9 +343,6 @@ describe('order-syncing/controller-integration', () => { if (action === 'AuthenticationController:isSignedIn') { return true; } - if (action === 'UserStorageController:listEntropySources') { - return ['entropy-primary']; - } if ( action === 'UserStorageController:performGetStorageAllFeatureEntries' @@ -864,9 +747,6 @@ describe('order-syncing/controller-integration', () => { if (action === 'AuthenticationController:isSignedIn') { return true; } - if (action === 'UserStorageController:listEntropySources') { - return ['entropy-primary']; - } if ( action === 'UserStorageController:performGetStorageAllFeatureEntries' diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 7ffc3bf406b..a17ab952f7d 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -47,19 +47,6 @@ function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; } -/** - * Returns the conflict-resolution timestamp for a remote entry when the same - * order key appears under multiple entropy profiles. - * - * Soft-deletes use `deletedAt`; live entries use {@link getOrderTimestamp}. - * - * @param order - A remote ramps order that may include sync metadata. - * @returns The timestamp used to pick the freshest remote copy. - */ -function getRemoteConflictTimestamp(order: SyncRampsOrder): number { - return order.deletedAt ?? getOrderTimestamp(order); -} - /** * Builds the local/remote merge plan from the current local snapshot and remote * entries. @@ -308,41 +295,19 @@ async function getRemoteOrders( const { onOrderSyncErroneousSituation } = config; try { - // Multi-SRP: Portfolio uploads under the connected account's HD entropy - // profile. Reading only the primary SRP misses secondary-wallet orders. - let entropySourceIds: (string | undefined)[] = [undefined]; - try { - const listed = await getMessenger().call( - 'UserStorageController:listEntropySources', - ); - if (listed.length > 0) { - entropySourceIds = listed; - } - } catch { - // Wallet locked / action missing — fall back to primary (omit id). - } - - const remoteOrdersJsonArray: string[] = []; - - for (const entropySourceId of entropySourceIds) { - const entries = await getMessenger().call( + // Reads the active/primary SRP profile only. Order sync is same-SRP: hosts + // sync Extension ↔ Mobile for a given SRP, not across secondary wallets. + const remoteOrdersJsonArray = + (await getMessenger().call( 'UserStorageController:performGetStorageAllFeatureEntries', USER_STORAGE_RAMPS_ORDERS_FEATURE, - entropySourceId, - ); - if (entries?.length) { - remoteOrdersJsonArray.push(...entries); - } - } + )) ?? []; if (remoteOrdersJsonArray.length === 0) { return []; } - // Multi-SRP can return the same order key from more than one entropy - // profile. Keep the freshest copy so secondary-profile updates are not - // discarded just because an older primary-profile entry was parsed first. - const ordersByKey = new Map(); + const remoteOrders: SyncRampsOrder[] = []; for (const orderJson of remoteOrdersJsonArray) { try { @@ -365,18 +330,10 @@ async function getRemoteOrders( continue; } const mapped = mapUserStorageEntryToRampsOrder(entry); - const key = createOrderStorageKey(mapped); - if (!key) { + if (!createOrderStorageKey(mapped)) { continue; } - const existing = ordersByKey.get(key); - if ( - !existing || - getRemoteConflictTimestamp(mapped) > - getRemoteConflictTimestamp(existing) - ) { - ordersByKey.set(key, mapped); - } + remoteOrders.push(mapped); } catch (error) { // Do not attach raw order JSON — it can include wallet/PII fields. onOrderSyncErroneousSituation?.( @@ -389,7 +346,7 @@ async function getRemoteOrders( } } - return Array.from(ordersByKey.values()); + return remoteOrders; } catch (error) { onOrderSyncErroneousSituation?.('Failed to fetch remote ramps orders', { error, @@ -537,6 +494,5 @@ export const orderSyncingTestExports = { computeMergePlan, reconcileOrdersForRemoteUpload, getOrderTimestamp, - getRemoteConflictTimestamp, saveOrdersToUserStorage, }; From 7e8b9865726d1d6c8deda49e87ce592d4226681a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:22:16 -0600 Subject: [PATCH 06/31] refactor(ramps-controller): use fast-deep-equal for order equality Replace the custom stableStringify helper with the monorepo-standard deep-equality dependency. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + packages/ramps-controller/package.json | 3 +- .../src/order-syncing/utils.ts | 55 ++++--------------- 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 182d9c823c6..6a6d88406b0 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Add dependency `fast-deep-equal` for order equality checks during sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ### Fixed diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 71746ab05d4..156a8e5f75e 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -60,7 +60,8 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^28.3.0", - "@metamask/remote-feature-flag-controller": "^4.2.2" + "@metamask/remote-feature-flag-controller": "^4.2.2", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts index 34d15864613..a30d35db755 100644 --- a/packages/ramps-controller/src/order-syncing/utils.ts +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -1,3 +1,5 @@ +import deepEqual from 'fast-deep-equal'; + import type { RampsOrder } from '../RampsService.js'; import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; @@ -117,39 +119,12 @@ export function stripDeletedAt(order: SyncRampsOrder): SyncRampsOrder { } /** - * JSON-stringifies values with object keys sorted so equality is stable across - * key insertion order. - * - * @param value - Value to serialize. - * @returns Stable JSON string. - */ -function stableStringify(value: unknown): string { - return JSON.stringify(value, (_key, nested) => { - if ( - nested && - typeof nested === 'object' && - !Array.isArray(nested) && - !(nested instanceof Date) - ) { - const record = nested as Record; - return Object.keys(record) - .sort() - .reduce>((acc, key) => { - acc[key] = record[key]; - return acc; - }, {}); - } - return nested; - }); -} - -/** - * Deep-compares two ramps orders by stable JSON serialization of their - * syncable bodies. Sync metadata (`lastUpdatedAt` / `deletedAt`) and - * local-only `paymentDetails` are excluded: `paymentDetails` is never persisted - * remotely, so a local order that carries it must still compare equal to its - * remote copy. Otherwise such orders would look changed on every sync and, with - * local-wins-on-tie conflict resolution, be re-uploaded indefinitely. + * Deep-compares two ramps orders by their syncable bodies. Sync metadata + * (`lastUpdatedAt` / `deletedAt`) and local-only `paymentDetails` are excluded: + * `paymentDetails` is never persisted remotely, so a local order that carries + * it must still compare equal to its remote copy. Otherwise such orders would + * look changed on every sync and, with local-wins-on-tie conflict resolution, + * be re-uploaded indefinitely. * * @param a - First order. * @param b - Second order. @@ -159,16 +134,8 @@ export function areOrdersEqual( a: SyncRampsOrder | RampsOrder, b: SyncRampsOrder | RampsOrder, ): boolean { - return ( - stableStringify( - stripPaymentDetailsForRemoteStorage( - stripSyncMetadata(a as SyncRampsOrder), - ), - ) === - stableStringify( - stripPaymentDetailsForRemoteStorage( - stripSyncMetadata(b as SyncRampsOrder), - ), - ) + return deepEqual( + stripPaymentDetailsForRemoteStorage(stripSyncMetadata(a as SyncRampsOrder)), + stripPaymentDetailsForRemoteStorage(stripSyncMetadata(b as SyncRampsOrder)), ); } From fd1c7ee62fb6fe307e9eae208f42b959b5d7256a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:29:08 -0600 Subject: [PATCH 07/31] docs(ramps-controller): consolidate order sync changelog Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 32 ++------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 6a6d88406b0..95fef7dc168 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,40 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add V2 ramps order syncing with User Storage ([#9474](https://github.com/MetaMask/core/pull/9474)) - - New `order-syncing/` module stores `RampsOrder` objects (without `paymentDetails`) as per-order User Storage entries under the `rampsOrders` feature - - `RampsController.syncOrdersWithUserStorage()` performs bidirectional sync with timestamp-based conflict resolution and soft deletes - - `addOrder` / `removeOrder` incrementally push local changes to User Storage when Backup & Sync and `isRampsSyncingEnabled` are on - - Messenger action `RampsController:syncOrdersWithUserStorage` for hosts to trigger a full sync on unlock / feature enable - - Optional constructor `onOrderSyncErroneousSituation` and `trace` callbacks for host observability + - Synchronize orders across clients for the same SRP using timestamp-based conflict resolution, soft deletes, and incremental updates + - Add `RampsController:syncOrdersWithUserStorage` and optional error-reporting and tracing callbacks ### Changed - **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Add dependency `fast-deep-equal` for order equality checks during sync ([#9474](https://github.com/MetaMask/core/pull/9474)) - -### Fixed - -- Default `isRampsSyncingEnabled` to `true` in `canPerformOrderSyncing` when the field is absent from persisted User Storage state, matching controller default state and UI selectors ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Route incremental `addOrder` / `removeOrder` remote writes through `performBatchSetStorage` so provider order IDs with hyphens or other non-`\w` characters sync correctly; single-entry `performSetStorage` paths are rejected by User Storage's `feature.key` validator ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Re-read live local orders before remote upload so orders added while `isOrderSyncingInProgress` suppresses incremental writes are still uploaded during full sync ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Queue local deletes that occur during full sync and write remote tombstones when sync finishes, so mid-sync deletes are not resurrected ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Coalesce overlapping `syncOrdersWithUserStorage` calls into a follow-up run instead of silently no-oping ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Fall back to trimmed `providerOrderId` when an order `id` has an empty `/orders/` segment, and reject empty storage keys on remote write ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Strip `paymentDetails` from User Storage payloads; keep them in local controller state only ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Preserve remote `createdAt` as `lastUpdatedAt` when importing entries without `lu`, instead of inventing `Date.now()` ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Clear tombstone metadata when uploading active local orders during full sync ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Apply last-write-wins rules to remote soft-deletes so newer local orders can restore over older tombstones ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Surface remote fetch/parse failures via `onOrderSyncErroneousSituation` without attaching raw order JSON ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Honor newer remote tombstones during full sync even when local order content differs ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Trim `providerOrderId` when deriving User Storage keys so syncability checks and storage keys stay aligned ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Clear order polling metadata for stored `providerOrderId` and internal order codes when `removeOrder` is called with either identifier ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Persist `lastUpdatedAt` on local orders and bump it on every non-sync `addOrder` so last-write-wins prefers fresher local edits over stale remote copies ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Set `isOrderSyncingInProgress` before the remote fetch so overlapping full syncs cannot race merge/upload ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Trim `providerOrderId` in `getInternalOrderCode` to match User Storage key derivation ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Exclude local-only `paymentDetails` from `areOrdersEqual` so orders carrying local payment details are not re-uploaded on every sync ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Scope remote order fetches to the active/primary SRP profile only; cross-SRP aggregation is out of scope for same-SRP Extension ↔ Mobile sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [17.2.0] From bcbcdc150b59ef5fac990e61b262c379a6a7326e Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:43:15 -0600 Subject: [PATCH 08/31] chore(ramps-controller): update yarn.lock for fast-deep-equal Co-authored-by: Cursor --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 414dbc97704..14749d728f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8557,6 +8557,7 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^30.4.2" nock: "npm:^13.3.1" ts-jest: "npm:^29.4.11" From 29505d94dad250c07fb5ca0501471f9c92107383 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:23:02 +0000 Subject: [PATCH 09/31] fix(ramps-controller): bump lastUpdatedAt and trigger sync on polling updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes High Severity Bugbot finding: "Polling updates skip sync timestamps" Problem: When getOrder() is called via order polling, it was directly updating state without bumping lastUpdatedAt or triggering incremental sync. This caused LWW conflicts where a stale remote timestamp could overwrite newer local status changes (e.g., COMPLETED → PENDING). Solution: Refactor getOrder() to use addOrder() internally, which: - Sets lastUpdatedAt to Date.now() for local updates - Triggers incremental sync push to User Storage - Ensures polling updates participate in LWW properly Impact: All paths that call getOrder() (direct API calls, order polling via #refreshOrder, getOrderFromCallback) now correctly maintain sync timestamps and trigger incremental sync when orders are updated. Co-authored-by: George Weiler --- packages/ramps-controller/src/RampsController.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 5a727c6529a..821c4f554be 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -2860,20 +2860,8 @@ export class RampsController extends BaseController< providerOrderId: internalOrderCode, }; - this.update((state) => { - const idx = state.orders.findIndex( - (existing: RampsOrder) => - getInternalOrderCode(existing) === internalOrderCode, - ); - if (idx === -1) { - state.orders.push(healedOrder as Draft); - } else { - state.orders[idx] = { - ...state.orders[idx], - ...healedOrder, - } as Draft; - } - }); + // Use addOrder to ensure lastUpdatedAt is bumped and incremental sync is triggered + this.addOrder(healedOrder); return healedOrder; } From 8c482076b98f5db8ca50e45afa102a554af8281d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 11 Aug 2026 11:42:44 -0600 Subject: [PATCH 10/31] fix(ramps-controller): harden order sync mid-upload and incremental errors Queue a follow-up full sync when local orders mutate during an in-flight sync, report incremental remote write failures via onOrderSyncErroneousSituation, and type lastUpdatedAt on RampsOrder for LWW. Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 3 + packages/ramps-controller/CHANGELOG.md | 16 +++- .../src/RampsController.order-syncing.test.ts | 83 +++++++++++++++++-- .../ramps-controller/src/RampsController.ts | 53 ++++++++---- packages/ramps-controller/src/RampsService.ts | 6 ++ .../order-syncing/controller-integration.ts | 8 +- 6 files changed, 144 insertions(+), 25 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 6204db322b7..a4d755e91a4 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -10,10 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `BACKUPANDSYNC_FEATURES.rampsSyncing` and `UserStorageController.isRampsSyncingEnabled` ([#9474](https://github.com/MetaMask/core/pull/9474)) + - Defaults to enabled (`true`); hosts can toggle Buy & sell order sync independently of account/contact sync + - Disabling ramps syncing alone does not disable main Backup & Sync ### Changed - **BREAKING:** `UserStorageControllerState` now includes `isRampsSyncingEnabled`. Consumers that construct full state objects must include this field. ([#9474](https://github.com/MetaMask/core/pull/9474)) + - Prefer `isRampsSyncingEnabled ?? true` in selectors so wallets upgraded before this field existed keep ramps syncing on by default - Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 1b446b86a85..f21766d86c0 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,13 +10,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add V2 ramps order syncing with User Storage ([#9474](https://github.com/MetaMask/core/pull/9474)) - - Synchronize orders across clients for the same SRP using timestamp-based conflict resolution, soft deletes, and incremental updates - - Add `RampsController:syncOrdersWithUserStorage` and optional error-reporting and tracing callbacks + - Synchronize orders across clients for the same SRP using timestamp-based last-write-wins conflict resolution, soft-delete tombstones, and incremental add/update/delete pushes + - Feature key: `rampsOrders`; hosts call `RampsController:syncOrdersWithUserStorage` on unlock when Backup & Sync + ramps syncing are enabled + - Persist optional `lastUpdatedAt` on local `RampsOrder` entries for LWW (not returned by the V2 API) + - Strip `paymentDetails` from remote payloads (PII stays local-only) + - Soft deletes use remote tombstones; retention matches contact sync (no remote purge/compaction) + - Mid-sync local mutations coalesce into a follow-up full sync pass so uploads are not dropped during `performBatchSetStorage` + - Polling via `getOrder` → `addOrder` stamps `lastUpdatedAt` so status refreshes participate in LWW + - Optional `onOrderSyncErroneousSituation` (full sync and incremental push/delete) and `trace` callbacks - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) ### Changed -- **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) +- **BREAKING:** `RampsControllerMessenger` now requires these actions to be delegated for order syncing ([#9474](https://github.com/MetaMask/core/pull/9474)): + - `UserStorageController:getState` + - `UserStorageController:performGetStorageAllFeatureEntries` + - `UserStorageController:performBatchSetStorage` + - `AuthenticationController:isSignedIn` ## [20.0.0] diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index aa5476a202e..52ff8e766b7 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -47,7 +47,12 @@ type RootMessenger = Messenger< MessengerEvents >; -function setupControllerWithOrderSyncingMocks(): { +function setupControllerWithOrderSyncingMocks(args?: { + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}): { controller: RampsController; performBatchSetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; @@ -94,7 +99,10 @@ function setupControllerWithOrderSyncingMocks(): { ], }); - const controller = new RampsController({ messenger }); + const controller = new RampsController({ + messenger, + onOrderSyncErroneousSituation: args?.onOrderSyncErroneousSituation, + }); return { controller, @@ -147,9 +155,10 @@ describe('RampsController order syncing', () => { ); }); - it('logs incremental addOrder remote sync failures without throwing', async () => { + it('reports incremental addOrder remote sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); const { controller, performBatchSetStorage } = - setupControllerWithOrderSyncingMocks(); + setupControllerWithOrderSyncingMocks({ onOrderSyncErroneousSituation }); const consoleErrorSpy = jest .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -166,11 +175,16 @@ describe('RampsController order syncing', () => { 'Error updating ramps order in remote storage:', expect.any(Error), ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error updating ramps order in remote storage', + expect.objectContaining({ error: expect.any(Error) }), + ); }); - it('logs incremental removeOrder remote sync failures without throwing', async () => { + it('reports incremental removeOrder remote sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); const { controller, performBatchSetStorage } = - setupControllerWithOrderSyncingMocks(); + setupControllerWithOrderSyncingMocks({ onOrderSyncErroneousSituation }); const consoleErrorSpy = jest .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -188,6 +202,10 @@ describe('RampsController order syncing', () => { 'Error deleting ramps order from remote storage:', expect.any(Error), ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error deleting ramps order from remote storage', + expect.objectContaining({ error: expect.any(Error) }), + ); }); it('skips incremental remote writes while full sync is in progress', async () => { @@ -204,6 +222,59 @@ describe('RampsController order syncing', () => { expect(performBatchSetStorage).not.toHaveBeenCalled(); }); + it('re-runs sync when local orders change during remote upload', async () => { + const { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = setupControllerWithOrderSyncingMocks(); + + controller.addOrder( + createMockOrder({ + providerOrderId: 'seed-order', + id: '/providers/transak/orders/seed-order', + }), + ); + performBatchSetStorage.mockClear(); + performGetStorageAllFeatureEntries.mockResolvedValue([]); + + let releaseFirstUpload: (() => void) | undefined; + const firstUploadGate = new Promise((resolve) => { + releaseFirstUpload = resolve; + }); + let uploadCount = 0; + + performBatchSetStorage.mockImplementation(async () => { + uploadCount += 1; + if (uploadCount === 1) { + controller.addOrder( + createMockOrder({ + providerOrderId: 'during-upload', + id: '/providers/transak/orders/during-upload', + }), + ); + await firstUploadGate; + } + }); + + const syncPromise = controller.syncOrdersWithUserStorage(); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(uploadCount).toBe(1); + releaseFirstUpload?.(); + await syncPromise; + + expect(uploadCount).toBeGreaterThanOrEqual(2); + expect(performBatchSetStorage).toHaveBeenCalledWith( + 'rampsOrders', + expect.arrayContaining([ + expect.arrayContaining(['during-upload', expect.any(String)]), + ]), + ); + }); + it('queues mid-sync deletes and drains them after sync', async () => { jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_700); const { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 7f873ef8598..b9274498c91 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -706,8 +706,8 @@ export type RampsControllerOptions = { /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; /** - * Optional callback for order-sync parse/fetch/merge failures (e.g. Sentry). - * Context never includes full order JSON. + * Optional callback for order-sync failures (full sync parse/fetch/merge and + * incremental remote push/delete). Context never includes full order JSON. */ onOrderSyncErroneousSituation?: ( errorMessage: string, @@ -2505,8 +2505,7 @@ export class RampsController extends BaseController< return; } - const incomingLastUpdatedAt = (order as { lastUpdatedAt?: number }) - .lastUpdatedAt; + const incomingLastUpdatedAt = order.lastUpdatedAt; // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them // over stale remote copies when an incremental push was skipped/failed. // During full sync, preserve remote `lu` / `createdAt` (never invent "now" @@ -2536,12 +2535,26 @@ export class RampsController extends BaseController< } }); - if (!this.#isOrderSyncingInProgress) { - updateOrderInRemoteStorage(healedOrder, { - getRampsControllerInstance: () => this, - getMessenger: () => this.messenger, - }).catch((error) => { + if (this.#isOrderSyncingInProgress) { + // Incremental push is suppressed during full sync; queue another full + // sync pass so mutations during the upload await are not dropped. + this.#orderSyncQueued = true; + } else { + updateOrderInRemoteStorage( + healedOrder, + { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }, + { + onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + }, + ).catch((error) => { console.error('Error updating ramps order in remote storage:', error); + this.#onOrderSyncErroneousSituation?.( + 'Error updating ramps order in remote storage', + { error }, + ); }); } } @@ -2581,19 +2594,31 @@ export class RampsController extends BaseController< const deleteKey = getInternalOrderCode(orderToRemove); if (this.#isOrderSyncingInProgress) { // Incremental remote deletes are gated off during full sync; queue a - // tombstone write for the end of the sync instead. + // tombstone write and another full sync pass so deletes during the + // upload await are not dropped. + this.#orderSyncQueued = true; if (deleteKey) { this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); } } else { - deleteOrderInRemoteStorage(orderToRemove, { - getRampsControllerInstance: () => this, - getMessenger: () => this.messenger, - }).catch((error) => { + deleteOrderInRemoteStorage( + orderToRemove, + { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }, + { + onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + }, + ).catch((error) => { console.error( 'Error deleting ramps order from remote storage:', error, ); + this.#onOrderSyncErroneousSituation?.( + 'Error deleting ramps order from remote storage', + { error }, + ); }); } } diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 14c6c8b45bd..37cd9b30e92 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -630,6 +630,12 @@ export type RampsOrder = { partnerFees?: number; networkFees?: number; paymentDetails?: OrderPaymentDetail[]; + /** + * Last-updated timestamp used by User Storage order sync for LWW conflict + * resolution. Set locally by {@link RampsController}; not returned by the V2 + * ramps API. + */ + lastUpdatedAt?: number; }; /** diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index a17ab952f7d..538f62e8fb7 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -420,11 +420,13 @@ async function saveOrdersToUserStorage( * * @param order - The order that was updated locally. * @param options - Parameters used for syncing operations. + * @param config - Optional sync callbacks for error reporting. * @returns Resolves when the remote update completes or no-ops. */ export async function updateOrderInRemoteStorage( order: RampsOrder, options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, ): Promise { const { trace } = options; @@ -438,7 +440,7 @@ export async function updateOrderInRemoteStorage( lastUpdatedAt: Date.now(), }; - await saveOrdersToUserStorage([updatedEntry], options); + await saveOrdersToUserStorage([updatedEntry], options, config); }; if (trace) { @@ -462,11 +464,13 @@ export async function updateOrderInRemoteStorage( * * @param order - The order that was deleted locally (needs id / providerOrderId). * @param options - Parameters used for syncing operations. + * @param config - Optional sync callbacks for error reporting. * @returns Resolves when the remote soft-delete completes or no-ops. */ export async function deleteOrderInRemoteStorage( order: RampsOrder, options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, ): Promise { const { trace } = options; @@ -482,7 +486,7 @@ export async function deleteOrderInRemoteStorage( lastUpdatedAt: now, }; - await saveOrdersToUserStorage([deletedOrder], options); + await saveOrdersToUserStorage([deletedOrder], options, config); }; return trace From 7dc53041f458dd39f45b9b4a92ed0647ea06178a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 11 Aug 2026 14:36:38 -0600 Subject: [PATCH 11/31] fix(ramps-controller): preserve sync delete intent Prevent sync-applied mutations from scheduling redundant passes and retain pending tombstones until their remote write succeeds. Co-authored-by: Cursor --- .../src/RampsController.order-syncing.test.ts | 35 +++++++ .../ramps-controller/src/RampsController.ts | 74 ++++++++++----- .../controller-integration.test.ts | 91 ++++++++++++++++++- .../order-syncing/controller-integration.ts | 29 ++++-- .../src/order-syncing/sync-utils.test.ts | 8 +- .../src/order-syncing/types.ts | 12 ++- 6 files changed, 210 insertions(+), 39 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index 52ff8e766b7..a6b4e3b01cc 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -126,6 +126,17 @@ describe('RampsController order syncing', () => { expect(controller.isOrderSyncingInProgress).toBe(false); }); + it('ignores acknowledgements for deletes that are not pending', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.acknowledgePendingRemoteDeletes([ + { id: '', providerOrderId: '' } as never, + createMockOrder(), + ]); + + expect(controller.getPendingRemoteDeletes()).toStrictEqual([]); + }); + it('delegates syncOrdersWithUserStorage to the order-syncing module', async () => { const { controller, performGetStorageAllFeatureEntries } = setupControllerWithOrderSyncingMocks(); @@ -137,6 +148,30 @@ describe('RampsController order syncing', () => { expect(performGetStorageAllFeatureEntries).toHaveBeenCalled(); }); + it('does not queue another sync when applying remote orders', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + const remoteOrder = createMockOrder({ + providerOrderId: 'remote-only', + id: '/providers/transak/orders/remote-only', + }); + + performGetStorageAllFeatureEntries.mockResolvedValue([ + JSON.stringify({ + v: '1', + o: remoteOrder, + lu: remoteOrder.createdAt, + }), + ]); + + await controller.syncOrdersWithUserStorage(); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(1); + expect(controller.state.orders).toStrictEqual([ + expect.objectContaining({ providerOrderId: 'remote-only' }), + ]); + }); + it('stamps lastUpdatedAt on local addOrder edits', () => { jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); const { controller } = setupControllerWithOrderSyncingMocks(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b9274498c91..6981d720723 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -25,6 +25,7 @@ import { syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, updateOrderInRemoteStorage, } from './order-syncing/index.js'; +import type { SyncRampsOrder } from './order-syncing/types.js'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, @@ -396,7 +397,7 @@ export type RampsControllerState = { * The controller is the authority for V2 orders — it polls, updates, * and persists them. */ - orders: RampsOrder[]; + orders: SyncRampsOrder[]; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -934,8 +935,12 @@ export class RampsController extends BaseController< #isOrderSyncingInProgress = false; /** - * Orders deleted locally while a full sync held the semaphore. Drained at sync - * end so remote tombstones are still written. + * Whether the full sync is applying its own local state changes. + */ + #isApplyingOrderSyncChanges = false; + + /** + * Orders deleted locally while a full sync held the semaphore. */ readonly #pendingRemoteDeletes: Map = new Map(); @@ -967,16 +972,40 @@ export class RampsController extends BaseController< } /** - * Returns and clears orders deleted while a full sync was in progress. + * Distinguishes full sync's own state changes from external mutations. + * + * @param value - Whether sync changes are being applied. + * @internal + */ + setIsApplyingOrderSyncChanges(value: boolean): void { + this.#isApplyingOrderSyncChanges = value; + } + + /** + * Returns orders deleted while a full sync was in progress. * Used by the order-syncing module. * * @returns Pending deletes for remote tombstone upload. * @internal */ - drainPendingRemoteDeletes(): RampsOrder[] { - const pending = [...this.#pendingRemoteDeletes.values()]; - this.#pendingRemoteDeletes.clear(); - return pending; + getPendingRemoteDeletes(): RampsOrder[] { + return [...this.#pendingRemoteDeletes.values()]; + } + + /** + * Clears deletes whose tombstones were successfully persisted. A delete + * replaced while a write was in flight remains pending. + * + * @param orders - Deletes included in a successful remote write. + * @internal + */ + acknowledgePendingRemoteDeletes(orders: RampsOrder[]): void { + for (const order of orders) { + const key = getInternalOrderCode(order); + if (key && this.#pendingRemoteDeletes.get(key) === order) { + this.#pendingRemoteDeletes.delete(key); + } + } } /** @@ -2510,7 +2539,7 @@ export class RampsController extends BaseController< // over stale remote copies when an incremental push was skipped/failed. // During full sync, preserve remote `lu` / `createdAt` (never invent "now" // for missing `lu`, or stale remotes win later LWW comparisons). - const healedOrder = { + const healedOrder: SyncRampsOrder = { ...order, providerOrderId: internalOrderCode, lastUpdatedAt: this.#isOrderSyncingInProgress @@ -2518,28 +2547,29 @@ export class RampsController extends BaseController< : Date.now(), }; - // A mid-sync add cancels a pending delete for the same key. - this.#pendingRemoteDeletes.delete(internalOrderCode); + if (!this.#isApplyingOrderSyncChanges) { + this.#pendingRemoteDeletes.delete(internalOrderCode); + } this.update((state) => { const idx = state.orders.findIndex( (existing) => getInternalOrderCode(existing) === internalOrderCode, ); if (idx === -1) { - state.orders.push(healedOrder as Draft); + state.orders.push(healedOrder); } else { state.orders[idx] = { ...state.orders[idx], ...healedOrder, - } as Draft; + }; } }); - if (this.#isOrderSyncingInProgress) { + if (this.#isOrderSyncingInProgress && !this.#isApplyingOrderSyncChanges) { // Incremental push is suppressed during full sync; queue another full // sync pass so mutations during the upload await are not dropped. this.#orderSyncQueued = true; - } else { + } else if (!this.#isOrderSyncingInProgress) { updateOrderInRemoteStorage( healedOrder, { @@ -2593,12 +2623,14 @@ export class RampsController extends BaseController< if (orderToRemove) { const deleteKey = getInternalOrderCode(orderToRemove); if (this.#isOrderSyncingInProgress) { - // Incremental remote deletes are gated off during full sync; queue a - // tombstone write and another full sync pass so deletes during the - // upload await are not dropped. - this.#orderSyncQueued = true; - if (deleteKey) { - this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + if (!this.#isApplyingOrderSyncChanges) { + // Incremental remote deletes are gated off during full sync; queue a + // tombstone write and another full sync pass so deletes during the + // upload await are not dropped. + this.#orderSyncQueued = true; + if (deleteKey) { + this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + } } } else { deleteOrderInRemoteStorage( diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts index 5575096b100..6608e65a786 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -58,6 +58,7 @@ describe('order-syncing/controller-integration', () => { performSetStorage: jest.Mock; performGetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; + acknowledgePendingRemoteDeletes: jest.Mock; } => { const removeOrder = jest.fn(); const setIsOrderSyncingInProgress = jest.fn(); @@ -67,6 +68,7 @@ describe('order-syncing/controller-integration', () => { const performGetStorageAllFeatureEntries = jest .fn() .mockResolvedValue(remoteEntries); + const acknowledgePendingRemoteDeletes = jest.fn(); const controller = { state: { orders: localOrders }, @@ -74,7 +76,9 @@ describe('order-syncing/controller-integration', () => { setIsOrderSyncingInProgress, addOrder: jest.fn(), removeOrder, - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes, + setIsApplyingOrderSyncChanges: jest.fn(), }; const addOrder = jest.fn((order: RampsOrder) => { @@ -132,6 +136,7 @@ describe('order-syncing/controller-integration', () => { performSetStorage, performGetStorage, performGetStorageAllFeatureEntries, + acknowledgePendingRemoteDeletes, }; }; @@ -326,9 +331,11 @@ describe('order-syncing/controller-integration', () => { controller.state.orders = [updatedLocalOrder]; } }), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder: jest.fn(), removeOrder: jest.fn(), - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }; const messengerCall = jest @@ -605,13 +612,17 @@ describe('order-syncing/controller-integration', () => { id: '/providers/transak/orders/deleted-mid-sync', }); - const { options, performBatchSetStorage } = arrangeMocks({ + const { + options, + performBatchSetStorage, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ localOrders: [localOrder], remoteEntries: [], }); const controller = options.getRampsControllerInstance(); - (controller.drainPendingRemoteDeletes as jest.Mock).mockReturnValue([ + (controller.getPendingRemoteDeletes as jest.Mock).mockReturnValue([ deletedDuringSync, ]); @@ -632,6 +643,74 @@ describe('order-syncing/controller-integration', () => { ) as { dt?: number; o: { paymentDetails?: unknown } }; expect(tombstoneEntry.dt).toBe(1_700_000_000_500); expect(tombstoneEntry.o.paymentDetails).toBeUndefined(); + expect(acknowledgePendingRemoteDeletes).toHaveBeenCalledWith([ + deletedDuringSync, + ]); + }); + + it('keeps pending deletes authoritative over active remote orders', async () => { + const deletedDuringSync = createMockOrder({ + providerOrderId: 'deleted-mid-sync', + id: '/providers/transak/orders/deleted-mid-sync', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...deletedDuringSync, + lastUpdatedAt: 1, + }), + ); + const { + options, + addOrder, + performBatchSetStorage, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ + localOrders: [], + remoteEntries: [remoteEntry], + }); + const controller = options.getRampsControllerInstance(); + (controller.getPendingRemoteDeletes as jest.Mock).mockReturnValue([ + deletedDuringSync, + ]); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['deleted-mid-sync', expect.any(String)]), + ]), + ); + expect(acknowledgePendingRemoteDeletes).toHaveBeenCalledWith([ + deletedDuringSync, + ]); + }); + + it('retains pending deletes when tombstone upload fails', async () => { + const deletedDuringSync = createMockOrder({ + providerOrderId: 'deleted-mid-sync', + id: '/providers/transak/orders/deleted-mid-sync', + }); + const { + options, + performBatchSetStorage, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ + localOrders: [], + remoteEntries: [], + }); + const controller = options.getRampsControllerInstance(); + (controller.getPendingRemoteDeletes as jest.Mock).mockReturnValue([ + deletedDuringSync, + ]); + performBatchSetStorage.mockRejectedValue(new Error('batch failed')); + + await expect(syncOrdersWithUserStorage({}, options)).rejects.toThrow( + 'batch failed', + ); + + expect(acknowledgePendingRemoteDeletes).not.toHaveBeenCalled(); }); it('reports sync failures via onOrderSyncErroneousSituation', async () => { @@ -730,9 +809,11 @@ describe('order-syncing/controller-integration', () => { controller.state.orders = [localOrder, orderAddedDuringSync]; } }), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder, removeOrder, - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }; const messengerCall = jest diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 538f62e8fb7..7555e88a0bb 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -189,6 +189,12 @@ export async function syncOrdersWithUserStorage( const performSync = async (): Promise => { const getLocalOrders = (): RampsOrder[] => controller.state.orders.filter(isSyncableOrder); + const pendingDeleteKeysBeforeApply = new Set( + controller + .getPendingRemoteDeletes() + .map((order) => createOrderStorageKey(order)) + .filter((key) => key.length > 0), + ); const { remoteOrdersMap, @@ -197,14 +203,22 @@ export async function syncOrdersWithUserStorage( ordersToUpdateRemotely, } = computeMergePlan(getLocalOrders(), validRemoteOrders); - for (const order of ordersToDeleteLocally) { - controller.removeOrder(createOrderStorageKey(order)); - } + controller.setIsApplyingOrderSyncChanges(true); + try { + for (const order of ordersToDeleteLocally) { + controller.removeOrder(createOrderStorageKey(order)); + } - for (const order of ordersToAddOrUpdateLocally) { - if (!order.deletedAt) { - controller.addOrder(stripDeletedAt(order)); + for (const order of ordersToAddOrUpdateLocally) { + if ( + !order.deletedAt && + !pendingDeleteKeysBeforeApply.has(createOrderStorageKey(order)) + ) { + controller.addOrder(stripDeletedAt(order)); + } } + } finally { + controller.setIsApplyingOrderSyncChanges(false); } const ordersToUpload = reconcileOrdersForRemoteUpload( @@ -217,7 +231,7 @@ export async function syncOrdersWithUserStorage( getLocalOrders().map((order) => createOrderStorageKey(order)), ); const pendingDeletes = controller - .drainPendingRemoteDeletes() + .getPendingRemoteDeletes() .filter((order) => { const key = createOrderStorageKey(order); return key.length > 0 && !localKeys.has(key); @@ -246,6 +260,7 @@ export async function syncOrdersWithUserStorage( if (syncedUploads.length > 0) { await saveOrdersToUserStorage(syncedUploads, options, config); + controller.acknowledgePendingRemoteDeletes(pendingDeletes); } }; diff --git a/packages/ramps-controller/src/order-syncing/sync-utils.test.ts b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts index a474767a8db..4d2783a2c7b 100644 --- a/packages/ramps-controller/src/order-syncing/sync-utils.test.ts +++ b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts @@ -29,9 +29,11 @@ describe('order-syncing/sync-utils', () => { isOrderSyncingInProgress, state: { orders: [] }, setIsOrderSyncingInProgress: jest.fn(), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder: jest.fn(), removeOrder: jest.fn(), - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }), }; @@ -71,9 +73,11 @@ describe('order-syncing/sync-utils', () => { isOrderSyncingInProgress: false, state: { orders: [] }, setIsOrderSyncingInProgress: jest.fn(), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder: jest.fn(), removeOrder: jest.fn(), - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }), }; diff --git a/packages/ramps-controller/src/order-syncing/types.ts b/packages/ramps-controller/src/order-syncing/types.ts index 8ff1dfccf39..4bb28e7c318 100644 --- a/packages/ramps-controller/src/order-syncing/types.ts +++ b/packages/ramps-controller/src/order-syncing/types.ts @@ -45,16 +45,20 @@ export type SyncRampsOrder = RampsOrder & { * Minimal controller surface required by order syncing. */ export type OrderSyncingController = { - state: { orders: RampsOrder[] }; + state: { orders: SyncRampsOrder[] }; readonly isOrderSyncingInProgress: boolean; setIsOrderSyncingInProgress: (value: boolean) => void; + setIsApplyingOrderSyncChanges: (value: boolean) => void; addOrder: (order: RampsOrder) => void; removeOrder: (providerOrderId: string) => void; /** - * Returns and clears orders deleted locally while a full sync held the - * semaphore, so remote tombstones can still be written at sync end. + * Returns orders deleted locally while a full sync held the semaphore. */ - drainPendingRemoteDeletes: () => RampsOrder[]; + getPendingRemoteDeletes: () => RampsOrder[]; + /** + * Clears pending deletes after their remote tombstones are persisted. + */ + acknowledgePendingRemoteDeletes: (orders: RampsOrder[]) => void; }; /** From a0363900752fdb718f3886f117ad7a82100e1664 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 11 Aug 2026 14:58:30 -0600 Subject: [PATCH 12/31] fix(ramps-controller): bump timestamp for external mid-sync edits Key lastUpdatedAt preservation on the sync-apply phase so genuine local edits during a sync (e.g. order polling) still bump to now and win the queued follow-up sync under last-write-wins. Co-authored-by: Cursor --- .../src/RampsController.order-syncing.test.ts | 24 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 9 ++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index a6b4e3b01cc..48173193f87 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -477,6 +477,7 @@ describe('RampsController order syncing', () => { const { controller } = setupControllerWithOrderSyncingMocks(); controller.setIsOrderSyncingInProgress(true); + controller.setIsApplyingOrderSyncChanges(true); controller.addOrder({ ...createMockOrder({ providerOrderId: 'no-lu', @@ -493,10 +494,33 @@ describe('RampsController order syncing', () => { ); }); + it('bumps lastUpdatedAt for external edits made while a sync is in progress', () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_222); + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder( + createMockOrder({ + providerOrderId: 'mid-sync-edit', + id: '/providers/transak/orders/mid-sync-edit', + createdAt: 1_111, + lastUpdatedAt: 10, + }), + ); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'mid-sync-edit', + lastUpdatedAt: 1_700_000_000_222, + }), + ); + }); + it('uses 0 lastUpdatedAt when syncing remotes without lu or createdAt', () => { const { controller } = setupControllerWithOrderSyncingMocks(); controller.setIsOrderSyncingInProgress(true); + controller.setIsApplyingOrderSyncChanges(true); controller.addOrder({ providerOrderId: 'no-timestamps', id: '/providers/transak/orders/no-timestamps', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6981d720723..1f6c105b1e6 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -2537,12 +2537,15 @@ export class RampsController extends BaseController< const incomingLastUpdatedAt = order.lastUpdatedAt; // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them // over stale remote copies when an incremental push was skipped/failed. - // During full sync, preserve remote `lu` / `createdAt` (never invent "now" - // for missing `lu`, or stale remotes win later LWW comparisons). + // This includes external edits mid-sync (e.g. polling via `getOrder`), + // which the queued follow-up sync must not lose under LWW. + // Only when sync applies its own imported orders do we preserve the remote + // `lu` / `createdAt` (never invent "now" for missing `lu`, or stale remotes + // win later LWW comparisons). const healedOrder: SyncRampsOrder = { ...order, providerOrderId: internalOrderCode, - lastUpdatedAt: this.#isOrderSyncingInProgress + lastUpdatedAt: this.#isApplyingOrderSyncChanges ? (incomingLastUpdatedAt ?? order.createdAt ?? 0) : Date.now(), }; From 9b95933d2f595ee715c7b5af4080c3fd2e2a8403 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 09:13:44 +0000 Subject: [PATCH 13/31] feat(ramps-controller): add V2 ramps order syncing via User Storage (#9474) - Add order-syncing module storing RampsOrder objects (without paymentDetails) as per-order User Storage entries under rampsOrders feature - RampsController.syncOrdersWithUserStorage() performs bidirectional sync with timestamp-based conflict resolution and soft deletes - addOrder/removeOrder incrementally push when Backup & Sync and isRampsSyncingEnabled are on - Optional onOrderSyncErroneousSituation and trace callbacks for host observability BREAKING CHANGES: - RampsControllerMessenger requires UserStorageController storage actions and AuthenticationController:isSignedIn Fixes and improvements: - Default isRampsSyncingEnabled to true when absent from state - Route incremental writes through performBatchSetStorage for IDs with hyphens - Re-read local orders before upload so mid-sync additions sync correctly - Queue mid-sync deletes for tombstone writes at sync end - Coalesce overlapping sync calls - Preserve remote createdAt as lastUpdatedAt when importing - Apply LWW rules to tombstones - Prefer freshest copy across entropy profiles - Trim providerOrderId in key derivation - Clear polling metadata on removeOrder Co-authored-by: George Weiler --- packages/profile-sync-controller/CHANGELOG.md | 5 + .../UserStorageController.test.ts | 30 + .../user-storage/UserStorageController.ts | 15 + .../src/controllers/user-storage/constants.ts | 1 + .../RampsController-method-action-types.ts | 10 + .../src/RampsController.order-syncing.test.ts | 377 ++++++ .../src/RampsController.test.ts | 86 +- packages/ramps-controller/src/index.ts | 25 + .../src/order-syncing/constants.ts | 25 + .../controller-integration.test.ts | 1197 +++++++++++++++++ .../order-syncing/controller-integration.ts | 526 ++++++++ .../src/order-syncing/index.ts | 29 + .../src/order-syncing/sync-utils.test.ts | 83 ++ .../src/order-syncing/sync-utils.ts | 39 + .../src/order-syncing/types.ts | 67 + .../src/order-syncing/utils.test.ts | 257 ++++ .../src/order-syncing/utils.ts | 162 +++ 17 files changed, 2933 insertions(+), 1 deletion(-) create mode 100644 packages/ramps-controller/src/RampsController.order-syncing.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/constants.ts create mode 100644 packages/ramps-controller/src/order-syncing/controller-integration.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/controller-integration.ts create mode 100644 packages/ramps-controller/src/order-syncing/index.ts create mode 100644 packages/ramps-controller/src/order-syncing/sync-utils.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/sync-utils.ts create mode 100644 packages/ramps-controller/src/order-syncing/types.ts create mode 100644 packages/ramps-controller/src/order-syncing/utils.test.ts create mode 100644 packages/ramps-controller/src/order-syncing/utils.ts diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 48b54489ee5..36cbcf717de 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `BACKUPANDSYNC_FEATURES.rampsSyncing` and `UserStorageController.isRampsSyncingEnabled` ([#9474](https://github.com/MetaMask/core/pull/9474)) + ### Changed +- **BREAKING:** `UserStorageControllerState` now includes `isRampsSyncingEnabled`. Consumers that construct full state objects must include this field. ([#9474](https://github.com/MetaMask/core/pull/9474)) - **BREAKING:** Derive auth and user-storage message-signing keys natively via SIP-6 from HD keyring seeds instead of calling `@metamask/message-signing-snap` through `SnapController`. `AuthenticationController` and `UserStorageController` now require `KeyringController:withKeyringV2Unsafe` and no longer call `SnapController:handleRequest`. The message-signing snap remains for Portfolio / external origins ([#9824](https://github.com/MetaMask/core/pull/9824)) - Derive native SIP-6 keys with `@noble/hashes` HMAC-SHA-512 instead of Web Crypto, so auth works on React Native, whose SubtleCrypto cannot HMAC. - Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794)) diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index d8c163801ac..c863c2cd673 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -663,6 +663,7 @@ describe('UserStorageController', () => { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, }, }); @@ -674,6 +675,31 @@ describe('UserStorageController', () => { expect(controller.state.isAccountSyncingEnabled).toBe(false); expect(controller.state.isBackupAndSyncEnabled).toBe(true); }); + + it('should not disable backup and sync when disabling ramps syncing', async () => { + const { messengerMocks } = await arrangeMocks(); + messengerMocks.mockAuthIsSignedIn.mockReturnValue(false); + + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + state: { + isBackupAndSyncEnabled: true, + isBackupAndSyncUpdateLoading: false, + isAccountSyncingEnabled: true, + isContactSyncingEnabled: true, + isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, + }, + }); + + await controller.setIsBackupAndSyncFeatureEnabled( + BACKUPANDSYNC_FEATURES.rampsSyncing, + false, + ); + + expect(controller.state.isRampsSyncingEnabled).toBe(false); + expect(controller.state.isBackupAndSyncEnabled).toBe(true); + }); }); describe('error handling edge cases', () => { @@ -917,6 +943,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -937,6 +964,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -953,6 +981,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -975,6 +1004,7 @@ describe('metadata', () => { "isBackupAndSyncUpdateLoading": false, "isContactSyncingEnabled": true, "isContactSyncingInProgress": false, + "isRampsSyncingEnabled": true, } `); }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index e58d9923d9c..2a3d960e87e 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -74,6 +74,10 @@ export type UserStorageControllerState = { * Condition used by UI to determine if contact syncing is in progress. */ isContactSyncingInProgress: boolean; + /** + * Condition used by UI to determine if ramps order syncing is enabled. + */ + isRampsSyncingEnabled: boolean; }; export const defaultState: UserStorageControllerState = { @@ -82,6 +86,7 @@ export const defaultState: UserStorageControllerState = { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, }; const metadata: StateMetadata = { @@ -115,6 +120,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + isRampsSyncingEnabled: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; type ControllerConfig = { @@ -640,6 +651,10 @@ export class UserStorageController extends BaseController< if (feature === BACKUPANDSYNC_FEATURES.contactSyncing) { state.isContactSyncingEnabled = enabled; } + + if (feature === BACKUPANDSYNC_FEATURES.rampsSyncing) { + state.isRampsSyncingEnabled = enabled; + } }); } catch (e) { // istanbul ignore next diff --git a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts index 4b3efff235b..4ed73b30975 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts @@ -2,6 +2,7 @@ export const BACKUPANDSYNC_FEATURES = { main: 'main', accountSyncing: 'accountSyncing', contactSyncing: 'contactSyncing', + rampsSyncing: 'rampsSyncing', } as const; /** diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 8661f19c705..525a617027c 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -422,6 +422,15 @@ export type RampsControllerRefreshAutorampsAction = { handler: RampsController['refreshAutoramps']; }; +/** + * Bidirectionally syncs V2 ramps orders with User Storage. + * Hosts should call this on unlock / when ramps syncing is enabled. + */ +export type RampsControllerSyncOrdersWithUserStorageAction = { + type: `RampsController:syncOrdersWithUserStorage`; + handler: RampsController['syncOrdersWithUserStorage']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -840,6 +849,7 @@ export type RampsControllerMethodActions = | RampsControllerApplyAutorampStatusFromPushAction | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction + | RampsControllerSyncOrdersWithUserStorageAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts new file mode 100644 index 00000000000..0d245a0b96c --- /dev/null +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -0,0 +1,377 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import type { RampsControllerMessenger } from './RampsController.js'; +import { + RampsController, + RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, +} from './RampsController.js'; +import { RampsOrderStatus } from './RampsService.js'; +import type { RampsOrder } from './RampsService.js'; + +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak/orders/abc-123', + isOnlyLink: false, + provider: { id: 'transak', name: 'Transak' } as RampsOrder['provider'], + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + ...overrides, + }; +} + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function setupControllerWithOrderSyncingMocks(): { + controller: RampsController; + performBatchSetStorage: jest.Mock; + performGetStorageAllFeatureEntries: jest.Mock; +} { + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue([] as string[]); + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + rootMessenger.registerActionHandler('UserStorageController:getState', () => ({ + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + })); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + performGetStorageAllFeatureEntries, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + performBatchSetStorage, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + + const messenger: RampsControllerMessenger = new Messenger({ + namespace: 'RampsController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', + ], + }); + + const controller = new RampsController({ messenger }); + + return { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + }; +} + +describe('RampsController order syncing', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('exposes the order syncing semaphore getter and setter', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + expect(controller.isOrderSyncingInProgress).toBe(false); + controller.setIsOrderSyncingInProgress(true); + expect(controller.isOrderSyncingInProgress).toBe(true); + controller.setIsOrderSyncingInProgress(false); + expect(controller.isOrderSyncingInProgress).toBe(false); + }); + + it('delegates syncOrdersWithUserStorage to the order-syncing module', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + performGetStorageAllFeatureEntries.mockResolvedValue([]); + + await controller.syncOrdersWithUserStorage(); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalled(); + }); + + it('stamps lastUpdatedAt on local addOrder edits', () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.addOrder({ + providerOrderId: 'order-1', + status: 'PENDING', + createdAt: 1, + } as never); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'order-1', + lastUpdatedAt: 1_700_000_000_000, + }), + ); + }); + + it('logs incremental addOrder remote sync failures without throwing', async () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + performBatchSetStorage.mockRejectedValue(new Error('remote write failed')); + + controller.addOrder(createMockOrder()); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error updating ramps order in remote storage:', + expect.any(Error), + ); + }); + + it('logs incremental removeOrder remote sync failures without throwing', async () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + performBatchSetStorage.mockRejectedValue(new Error('remote delete failed')); + + controller.addOrder(createMockOrder()); + controller.removeOrder('abc-123'); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error deleting ramps order from remote storage:', + expect.any(Error), + ); + }); + + it('skips incremental remote writes while full sync is in progress', async () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder(createMockOrder()); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('queues mid-sync deletes and drains them after sync', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_700); + const { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = setupControllerWithOrderSyncingMocks(); + + const order = createMockOrder({ + providerOrderId: 'mid-sync-delete', + id: '/providers/transak/orders/mid-sync-delete', + }); + controller.addOrder(order); + performBatchSetStorage.mockClear(); + + performGetStorageAllFeatureEntries.mockImplementation(async () => { + controller.removeOrder('mid-sync-delete'); + return []; + }); + + await controller.syncOrdersWithUserStorage(); + + expect(controller.state.orders).toHaveLength(0); + expect(performBatchSetStorage).toHaveBeenCalledWith( + 'rampsOrders', + expect.arrayContaining([ + expect.arrayContaining(['mid-sync-delete', expect.any(String)]), + ]), + ); + const tombstone = JSON.parse( + (performBatchSetStorage.mock.calls[0][1] as [string, string][]).find( + ([key]) => key === 'mid-sync-delete', + )?.[1] as string, + ) as { dt?: number }; + expect(tombstone.dt).toBe(1_700_000_000_700); + }); + + it('coalesces overlapping syncOrdersWithUserStorage calls', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let releaseFirstFetch: (() => void) | undefined; + const firstFetchStarted = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + + performGetStorageAllFeatureEntries + .mockImplementationOnce(async () => { + await firstFetchStarted; + return []; + }) + .mockResolvedValue([]); + + const firstSync = controller.syncOrdersWithUserStorage(); + const secondSync = controller.syncOrdersWithUserStorage(); + + releaseFirstFetch?.(); + await Promise.all([firstSync, secondSync]); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(2); + }); + + it('handles sync requests that arrive after worker checks flag but before completion', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let resolveLateRequest: (() => void) | undefined; + const lateRequestQueued = new Promise((resolve) => { + resolveLateRequest = resolve; + }); + + let firstSyncNearCompletion = false; + performGetStorageAllFeatureEntries.mockImplementation(async () => { + if (firstSyncNearCompletion) { + return []; + } + firstSyncNearCompletion = true; + await lateRequestQueued; + return []; + }); + + const firstSync = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const lateSync = controller.syncOrdersWithUserStorage(); + resolveLateRequest?.(); + + await Promise.all([firstSync, lateSync]); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(2); + }); + + it('recursively calls syncOrdersWithUserStorage when flag is still set after waiting', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let unblockFirst: (() => void) | undefined; + const firstBlocked = new Promise((resolve) => { + unblockFirst = resolve; + }); + + let callCount = 0; + performGetStorageAllFeatureEntries.mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + await firstBlocked; + } + return []; + }); + + const firstSync = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + const secondSync = controller.syncOrdersWithUserStorage(); + + unblockFirst?.(); + await Promise.all([firstSync, secondSync]); + + expect(callCount).toBe(2); + }); + + it('preserves createdAt as lastUpdatedAt when syncing remotes without lu', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder({ + ...createMockOrder({ + providerOrderId: 'no-lu', + id: '/providers/transak/orders/no-lu', + createdAt: 1_111, + }), + }); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'no-lu', + lastUpdatedAt: 1_111, + }), + ); + }); + + it('uses 0 lastUpdatedAt when syncing remotes without lu or createdAt', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder({ + providerOrderId: 'no-timestamps', + id: '/providers/transak/orders/no-timestamps', + } as never); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'no-timestamps', + lastUpdatedAt: 0, + }), + ); + }); + + it('ignores addOrder calls that cannot derive a storage key', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.addOrder({ + id: '/providers/transak/orders/', + providerOrderId: '', + } as never); + + expect(controller.state.orders).toHaveLength(0); + }); +}); diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0bd71a83b39..6e207d386b0 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -22,6 +22,7 @@ import type { import { RampsController, getDefaultRampsControllerState, + getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, } from './RampsController.js'; @@ -10180,9 +10181,13 @@ describe('RampsController', () => { it('adds a new order to state', async () => { await withController(({ controller, rootMessenger }) => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_100); rootMessenger.call('RampsController:addOrder', mockOrder); expect(controller.state.orders).toHaveLength(1); - expect(controller.state.orders[0]).toStrictEqual(mockOrder); + expect(controller.state.orders[0]).toStrictEqual({ + ...mockOrder, + lastUpdatedAt: 1_700_000_000_100, + }); }); }); @@ -10295,6 +10300,60 @@ describe('RampsController', () => { expect(controller.state.orders).toHaveLength(1); }); }); + + it('clears polling metadata when removing by internal order code', async () => { + await withController(async ({ rootMessenger }) => { + jest.useFakeTimers(); + + const legacyOrder = createMockOrder({ + id: '/providers/transak/orders/internal-order-456', + providerOrderId: 'legacy-provider-id', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: '/providers/transak', + name: 'Transak', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', legacyOrder); + + let callCount = 0; + rootMessenger.registerActionHandler( + 'RampsService:getOrder', + async () => { + callCount += 1; + throw new Error('fail'); + }, + ); + + rootMessenger.call('RampsController:startOrderPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + rootMessenger.call('RampsController:removeOrder', 'internal-order-456'); + rootMessenger.call('RampsController:stopOrderPolling'); + + const replacementOrder = createMockOrder({ + id: '/providers/transak/orders/internal-order-456', + providerOrderId: 'legacy-provider-id', + status: RampsOrderStatus.Pending, + provider: createMockProvider({ + id: '/providers/transak', + name: 'Transak', + }), + walletAddress: '0xabc', + }); + rootMessenger.call('RampsController:addOrder', replacementOrder); + + callCount = 0; + rootMessenger.call('RampsController:startOrderPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(callCount).toBe(1); + + rootMessenger.call('RampsController:stopOrderPolling'); + jest.useRealTimers(); + }); + }); }); describe('getOrder', () => { @@ -12757,6 +12816,31 @@ describe('RampsController', () => { }); }); +describe('getInternalOrderCode', () => { + it('returns empty string when object has no /orders/ id and no providerOrderId', () => { + expect(getInternalOrderCode({ id: 'plain-id' })).toBe(''); + }); + + it('trims providerOrderId when id has no /orders/ path', () => { + expect( + getInternalOrderCode({ id: 'plain-id', providerOrderId: ' abc ' }), + ).toBe('abc'); + }); + + it('falls back to providerOrderId when /orders/ segment is empty', () => { + expect( + getInternalOrderCode({ + id: '/providers/transak/orders/', + providerOrderId: 'real-id', + }), + ).toBe('real-id'); + }); + + it('returns empty string for a string id with an empty /orders/ segment', () => { + expect(getInternalOrderCode('/providers/transak/orders/')).toBe(''); + }); +}); + /** * Creates a mock UserRegion object for testing. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index b52fbe4eb8c..b22d5d2c28d 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -74,6 +74,7 @@ export type { RampsControllerTransakCancelOrderAction, RampsControllerTransakCancelAllActiveOrdersAction, RampsControllerTransakGetActiveOrdersAction, + RampsControllerSyncOrdersWithUserStorageAction, } from './RampsController-method-action-types.js'; export { RampsController, @@ -194,6 +195,30 @@ export { extractExplicitTypedError, normalizeToTypedError, } from './errorNormalization.js'; +export type { + UserStorageRampsOrderEntry, + SyncRampsOrder, + OrderSyncingController, + OrderSyncingOptions, + SyncOrdersWithUserStorageConfig, +} from './order-syncing/index.js'; +export { + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripPaymentDetailsForRemoteStorage, + stripSyncMetadata, + stripDeletedAt, + areOrdersEqual, + canPerformOrderSyncing, + syncOrdersWithUserStorage, + updateOrderInRemoteStorage, + deleteOrderInRemoteStorage, +} from './order-syncing/index.js'; export type { TransakServiceActions, TransakServiceEvents, diff --git a/packages/ramps-controller/src/order-syncing/constants.ts b/packages/ramps-controller/src/order-syncing/constants.ts new file mode 100644 index 00000000000..071ae5efc7a --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/constants.ts @@ -0,0 +1,25 @@ +/** + * User Storage feature key for V2 ramps orders. + * Each order is stored as a separate entry under this feature. + */ +export const USER_STORAGE_RAMPS_ORDERS_FEATURE = 'rampsOrders'; + +/** + * Key for version in User Storage schema. + */ +export const USER_STORAGE_VERSION_KEY = 'v'; + +/** + * Current version of the ramps order User Storage schema. + */ +export const USER_STORAGE_VERSION = '1'; + +/** + * Trace names for ramps order syncing operations. + */ +export const TraceName = { + RampsOrderSyncFull: 'Ramps Order Sync Full', + RampsOrderSyncSaveBatch: 'Ramps Order Sync Save Batch', + RampsOrderSyncUpdateRemote: 'Ramps Order Sync Update Remote', + RampsOrderSyncDeleteRemote: 'Ramps Order Sync Delete Remote', +} as const; diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts new file mode 100644 index 00000000000..23636a5fa88 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -0,0 +1,1197 @@ +import { RampsOrderStatus } from '../RampsService.js'; +import type { RampsOrder } from '../RampsService.js'; +import { + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + deleteOrderInRemoteStorage, + orderSyncingTestExports, + syncOrdersWithUserStorage, + updateOrderInRemoteStorage, +} from './controller-integration.js'; +import type { OrderSyncingOptions, SyncRampsOrder } from './types.js'; +import { mapRampsOrderToUserStorageEntry } from './utils.js'; + +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak/orders/abc-123', + isOnlyLink: false, + provider: { id: 'transak', name: 'Transak' } as RampsOrder['provider'], + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + ...overrides, + }; +} + +describe('order-syncing/controller-integration', () => { + const arrangeMocks = ({ + localOrders = [] as RampsOrder[], + remoteEntries = null as string[] | null, + isBackupAndSyncEnabled = true, + isRampsSyncingEnabled = true, + isSignedIn = true, + } = {}): { + options: OrderSyncingOptions; + addOrder: jest.Mock; + removeOrder: jest.Mock; + setIsOrderSyncingInProgress: jest.Mock; + performBatchSetStorage: jest.Mock; + performSetStorage: jest.Mock; + performGetStorage: jest.Mock; + performGetStorageAllFeatureEntries: jest.Mock; + } => { + const removeOrder = jest.fn(); + const setIsOrderSyncingInProgress = jest.fn(); + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorage = jest.fn().mockResolvedValue(null); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue(remoteEntries); + + const controller = { + state: { orders: localOrders }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress, + addOrder: jest.fn(), + removeOrder, + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }; + + const addOrder = jest.fn((order: RampsOrder) => { + const existingIndex = controller.state.orders.findIndex( + (existing) => existing.providerOrderId === order.providerOrderId, + ); + + if (existingIndex === -1) { + controller.state.orders.push(order); + return; + } + + controller.state.orders[existingIndex] = { + ...controller.state.orders[existingIndex], + ...order, + }; + }); + controller.addOrder = addOrder; + + const messengerCall = jest + .fn() + .mockImplementation((action: string, ...args: unknown[]) => { + switch (action) { + case 'UserStorageController:getState': + return { isBackupAndSyncEnabled, isRampsSyncingEnabled }; + case 'AuthenticationController:isSignedIn': + return isSignedIn; + case 'UserStorageController:listEntropySources': + return ['entropy-primary']; + case 'UserStorageController:performGetStorageAllFeatureEntries': + return performGetStorageAllFeatureEntries(...args); + case 'UserStorageController:performBatchSetStorage': + return performBatchSetStorage(...args); + case 'UserStorageController:performSetStorage': + return performSetStorage(...args); + case 'UserStorageController:performGetStorage': + return performGetStorage(...args); + default: + return null; + } + }); + + const options: OrderSyncingOptions = { + getRampsControllerInstance: () => controller, + getMessenger: () => + ({ call: messengerCall }) as ReturnType< + OrderSyncingOptions['getMessenger'] + >, + }; + + return { + options, + addOrder, + removeOrder, + setIsOrderSyncingInProgress, + performBatchSetStorage, + performSetStorage, + performGetStorage, + performGetStorageAllFeatureEntries, + }; + }; + + describe('syncOrdersWithUserStorage', () => { + it('no-ops when syncing conditions are not met', async () => { + const { options, performGetStorageAllFeatureEntries } = arrangeMocks({ + isRampsSyncingEnabled: false, + localOrders: [createMockOrder()], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(performGetStorageAllFeatureEntries).not.toHaveBeenCalled(); + }); + + it('uploads local-only orders on first sync', async () => { + const localOrder = createMockOrder(); + const { options, performBatchSetStorage, setIsOrderSyncingInProgress } = + arrangeMocks({ + localOrders: [localOrder], + remoteEntries: null, + }); + + await syncOrdersWithUserStorage({}, options); + + expect(setIsOrderSyncingInProgress).toHaveBeenCalledWith(true); + expect(setIsOrderSyncingInProgress).toHaveBeenCalledWith(false); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + }); + + it('imports remote-only orders on new device sync', async () => { + const remoteOrder = createMockOrder({ + providerOrderId: 'remote-1', + id: '/providers/transak/orders/remote-1', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerOrderId: 'remote-1' }), + ); + }); + + it('keeps the freshest remote entry when duplicate storage keys are returned', async () => { + const olderRemote = createMockOrder({ + providerOrderId: 'dup-key', + id: '/providers/transak/orders/dup-key', + fiatAmount: 100, + }); + const newerRemote = createMockOrder({ + providerOrderId: 'dup-key', + id: '/providers/transak/orders/dup-key', + fiatAmount: 999, + }); + const olderEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...olderRemote, + lastUpdatedAt: 1_700_000_000_000, + }), + ); + const newerEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...newerRemote, + lastUpdatedAt: 1_700_000_000_100, + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [olderEntry, newerEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledTimes(1); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ + providerOrderId: 'dup-key', + fiatAmount: 999, + }), + ); + }); + + it('keeps an earlier fresher remote entry over a later stale duplicate', async () => { + const newerRemote = createMockOrder({ + providerOrderId: 'dup-key-2', + id: '/providers/transak/orders/dup-key-2', + fiatAmount: 500, + }); + const olderRemote = createMockOrder({ + providerOrderId: 'dup-key-2', + id: '/providers/transak/orders/dup-key-2', + fiatAmount: 50, + }); + const newerEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...newerRemote, + lastUpdatedAt: 1_700_000_000_200, + }), + ); + const olderEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...olderRemote, + lastUpdatedAt: 1_700_000_000_000, + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [newerEntry, olderEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledTimes(1); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ + providerOrderId: 'dup-key-2', + fiatAmount: 500, + }), + ); + }); + + it('prefers a newer remote tombstone over an older live duplicate key', async () => { + const liveRemote = createMockOrder({ + providerOrderId: 'dup-tombstone', + id: '/providers/transak/orders/dup-tombstone', + fiatAmount: 100, + }); + const tombstoneRemote: SyncRampsOrder = { + ...liveRemote, + deletedAt: 1_700_000_000_300, + lastUpdatedAt: 1_700_000_000_300, + }; + const liveEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...liveRemote, + lastUpdatedAt: 1_700_000_000_000, + }), + ); + const tombstoneEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(tombstoneRemote), + ); + + const { options, addOrder, removeOrder } = arrangeMocks({ + localOrders: [liveRemote], + remoteEntries: [liveEntry, tombstoneEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(removeOrder).toHaveBeenCalledWith('dup-tombstone'); + }); + + it('applies remote soft-deletes locally when the tombstone is newer', async () => { + const localOrder = createMockOrder(); + const deletedRemote: SyncRampsOrder = { + ...localOrder, + deletedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(deletedRemote), + ); + + const { options, removeOrder } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(removeOrder).toHaveBeenCalledWith('abc-123'); + }); + + it('restores a newer local order over an older remote tombstone', async () => { + const localOrder = createMockOrder({ + fiatAmount: 250, + createdAt: Date.now(), + }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: 1700000000000, + lastUpdatedAt: 1700000000000, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(deletedRemote), + ); + + const { options, removeOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(removeOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + }); + + it('does not persist tombstone metadata when uploading active local orders', async () => { + const localOrder = createMockOrder({ fiatAmount: 250 }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: 1700000000000, + lastUpdatedAt: 1700000000000, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(deletedRemote), + ); + + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { dt?: number }; + expect(saved.dt).toBeUndefined(); + }); + + it('uploads locally newer conflicting orders to remote', async () => { + const localOrder = createMockOrder({ + fiatAmount: 300, + createdAt: Date.now(), + }); + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + lastUpdatedAt: 1, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(remoteOrder), + ); + + const { options, addOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalled(); + }); + + it('skips remote upload when local and remote orders are identical', async () => { + const order = createMockOrder(); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...order, + lastUpdatedAt: Date.now(), + }), + ); + + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [order], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('replaces stale queued uploads with fresher local orders', async () => { + const localOrder = createMockOrder({ fiatAmount: 100 }); + const updatedLocalOrder = createMockOrder({ fiatAmount: 500 }); + + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue(null); + + const controller = { + state: { orders: [localOrder] }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress: jest.fn((value: boolean) => { + if (value) { + controller.state.orders = [updatedLocalOrder]; + } + }), + addOrder: jest.fn(), + removeOrder: jest.fn(), + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }; + + const messengerCall = jest + .fn() + .mockImplementation((action: string, ...callArgs: unknown[]) => { + if (action === 'UserStorageController:getState') { + return { + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if (action === 'UserStorageController:listEntropySources') { + return ['entropy-primary']; + } + if ( + action === + 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return performGetStorageAllFeatureEntries(); + } + if (action === 'UserStorageController:performBatchSetStorage') { + return performBatchSetStorage(...callArgs); + } + return null; + }); + + const options: OrderSyncingOptions = { + getRampsControllerInstance: () => controller, + getMessenger: () => + ({ call: messengerCall }) as ReturnType< + OrderSyncingOptions['getMessenger'] + >, + }; + + await syncOrdersWithUserStorage({}, options); + + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { o: { fiatAmount: number } }; + expect(saved.o.fiatAmount).toBe(500); + }); + + it('keeps the newer side when content conflicts', async () => { + const localOrder = createMockOrder({ fiatAmount: 100 }); + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 200 }), + lastUpdatedAt: Date.now() + 10_000, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(remoteOrder), + ); + + const { options, addOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ fiatAmount: 200 }), + ); + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('prefers local edits with newer lastUpdatedAt over stale remote content', async () => { + const localOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 500, createdAt: 1 }), + lastUpdatedAt: Date.now(), + }; + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100, createdAt: 1 }), + lastUpdatedAt: 10, + }; + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry(remoteOrder), + ); + + const { options, addOrder, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalled(); + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { o: { fiatAmount: number } }; + expect(saved.o.fiatAmount).toBe(500); + }); + + it('uploads local-only orders when remote fetch returns an empty array', async () => { + const localOrder = createMockOrder(); + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).toHaveBeenCalled(); + }); + + it('aborts sync when remote fetch fails instead of treating it as empty', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { + options, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = arrangeMocks({ + localOrders: [createMockOrder()], + }); + + performGetStorageAllFeatureEntries.mockRejectedValue( + new Error('network down'), + ); + + await expect( + syncOrdersWithUserStorage({ onOrderSyncErroneousSituation }, options), + ).rejects.toThrow('network down'); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to fetch remote ramps orders', + expect.objectContaining({ error: expect.any(Error) }), + ); + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('skips corrupt remote entries while still importing valid ones', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const remoteOrder = createMockOrder({ + providerOrderId: 'valid-remote', + id: '/providers/transak/orders/valid-remote', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ); + + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: ['{invalid-json', remoteEntry], + }); + + await syncOrdersWithUserStorage( + { onOrderSyncErroneousSituation }, + options, + ); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to parse remote ramps order entry', + expect.objectContaining({ + error: expect.any(SyntaxError), + entryLength: expect.any(Number), + }), + ); + expect(onOrderSyncErroneousSituation.mock.calls[0][1]).not.toHaveProperty( + 'orderJson', + ); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerOrderId: 'valid-remote' }), + ); + }); + + it('skips remote entries with unsupported version or missing payload', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const remoteOrder = createMockOrder({ + providerOrderId: 'valid-remote', + id: '/providers/transak/orders/valid-remote', + }); + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: '999', + o: remoteOrder, + }), + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + }), + JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ), + ], + }); + + await syncOrdersWithUserStorage( + { onOrderSyncErroneousSituation }, + options, + ); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Unsupported ramps order storage version', + expect.objectContaining({ version: '999' }), + ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Remote ramps order entry missing order payload', + {}, + ); + expect(addOrder).toHaveBeenCalledTimes(1); + }); + + it('skips remote entries that cannot derive a storage key', async () => { + const remoteOrder = createMockOrder({ + providerOrderId: 'valid-remote-2', + id: '/providers/transak/orders/valid-remote-2', + }); + const { options, addOrder } = arrangeMocks({ + localOrders: [], + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + ...remoteOrder, + id: '/providers/transak/orders/', + providerOrderId: '', + }, + lu: Date.now(), + }), + JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ), + ], + }); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).toHaveBeenCalledTimes(1); + expect(addOrder).toHaveBeenCalledWith( + expect.objectContaining({ providerOrderId: 'valid-remote-2' }), + ); + }); + + it('reports and skips empty storage keys when uploading', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { options, performBatchSetStorage } = arrangeMocks(); + + await orderSyncingTestExports.saveOrdersToUserStorage( + [ + createMockOrder({ + id: '/providers/transak/orders/', + providerOrderId: '', + }), + ], + options, + { onOrderSyncErroneousSituation }, + ); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Skipping ramps order remote write with empty storage key', + expect.objectContaining({ + hasId: true, + hasProviderOrderId: false, + }), + ); + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('uploads tombstones for orders deleted while sync is in progress', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_500); + const localOrder = createMockOrder(); + const deletedDuringSync = createMockOrder({ + providerOrderId: 'deleted-mid-sync', + id: '/providers/transak/orders/deleted-mid-sync', + }); + + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [], + }); + + const controller = options.getRampsControllerInstance(); + (controller.drainPendingRemoteDeletes as jest.Mock).mockReturnValue([ + deletedDuringSync, + ]); + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + expect.arrayContaining(['deleted-mid-sync', expect.any(String)]), + ]), + ); + + const tombstoneEntry = JSON.parse( + (performBatchSetStorage.mock.calls[0][1] as [string, string][]).find( + ([key]) => key === 'deleted-mid-sync', + )?.[1] as string, + ) as { dt?: number; o: { paymentDetails?: unknown } }; + expect(tombstoneEntry.dt).toBe(1_700_000_000_500); + expect(tombstoneEntry.o.paymentDetails).toBeUndefined(); + }); + + it('reports sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { options, performBatchSetStorage } = arrangeMocks({ + localOrders: [createMockOrder()], + remoteEntries: [], + }); + + performBatchSetStorage.mockRejectedValue(new Error('batch failed')); + + await expect( + syncOrdersWithUserStorage({ onOrderSyncErroneousSituation }, options), + ).rejects.toThrow('batch failed'); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error synchronizing ramps orders', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('wraps full sync in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const { options } = arrangeMocks({ + localOrders: [createMockOrder()], + remoteEntries: [], + }); + + await syncOrdersWithUserStorage( + {}, + { + ...options, + trace, + }, + ); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Full' }), + expect.any(Function), + ); + }); + + it('records new-device sync metadata in trace', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const remoteOrder = createMockOrder({ + providerOrderId: 'remote-only', + id: '/providers/transak/orders/remote-only', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...remoteOrder, + lastUpdatedAt: Date.now(), + }), + ); + + const { options } = arrangeMocks({ + localOrders: [], + remoteEntries: [remoteEntry], + }); + + await syncOrdersWithUserStorage( + {}, + { + ...options, + trace, + }, + ); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ isNewDeviceSync: true }), + }), + expect.any(Function), + ); + }); + + it('uploads orders added while sync is in progress', async () => { + const localOrder = createMockOrder(); + const orderAddedDuringSync = createMockOrder({ + providerOrderId: 'during-sync-1', + id: '/providers/transak/orders/during-sync-1', + }); + + const addOrder = jest.fn(); + const removeOrder = jest.fn(); + const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); + const performGetStorageAllFeatureEntries = jest + .fn() + .mockResolvedValue(null); + + const controller = { + state: { orders: [localOrder] }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress: jest.fn((value: boolean) => { + if (value) { + controller.state.orders = [localOrder, orderAddedDuringSync]; + } + }), + addOrder, + removeOrder, + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }; + + const messengerCall = jest + .fn() + .mockImplementation((action: string, ...callArgs: unknown[]) => { + if (action === 'UserStorageController:getState') { + return { + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if (action === 'UserStorageController:listEntropySources') { + return ['entropy-primary']; + } + if ( + action === + 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return performGetStorageAllFeatureEntries(); + } + if (action === 'UserStorageController:performBatchSetStorage') { + return performBatchSetStorage(...callArgs); + } + return null; + }); + + const options: OrderSyncingOptions = { + getRampsControllerInstance: () => controller, + getMessenger: () => + ({ call: messengerCall }) as ReturnType< + OrderSyncingOptions['getMessenger'] + >, + }; + + await syncOrdersWithUserStorage({}, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + expect.arrayContaining(['during-sync-1', expect.any(String)]), + ]), + ); + }); + }); + + describe('updateOrderInRemoteStorage', () => { + it('writes a single order entry via batch storage', async () => { + const order = createMockOrder(); + const { options, performBatchSetStorage } = arrangeMocks(); + + await updateOrderInRemoteStorage(order, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + }); + + it('supports provider order IDs that are invalid for performSetStorage paths', async () => { + const order = createMockOrder({ + providerOrderId: '550e8400-e29b-41d4-a716-446655440000', + id: '/providers/transak/orders/550e8400-e29b-41d4-a716-446655440000', + }); + const { options, performBatchSetStorage, performSetStorage } = + arrangeMocks(); + + await updateOrderInRemoteStorage(order, options); + + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining([ + '550e8400-e29b-41d4-a716-446655440000', + expect.any(String), + ]), + ]), + ); + expect(performSetStorage).not.toHaveBeenCalled(); + }); + + it('no-ops when syncing is disabled', async () => { + const { options, performBatchSetStorage } = arrangeMocks({ + isBackupAndSyncEnabled: false, + }); + + await updateOrderInRemoteStorage(createMockOrder(), options); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('wraps incremental updates in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const order = createMockOrder(); + const { options } = arrangeMocks(); + + await updateOrderInRemoteStorage(order, { + ...options, + trace, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Update Remote' }), + expect.any(Function), + ); + }); + }); + + describe('deleteOrderInRemoteStorage', () => { + it('soft-deletes via batch storage using the local order payload', async () => { + const order = createMockOrder(); + const { options, performBatchSetStorage, performGetStorage } = + arrangeMocks(); + + await deleteOrderInRemoteStorage(order, options); + + expect(performGetStorage).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['abc-123', expect.any(String)]), + ]), + ); + const saved = JSON.parse( + performBatchSetStorage.mock.calls[0][1][0][1] as string, + ) as { dt?: number }; + expect(saved.dt).toStrictEqual(expect.any(Number)); + }); + + it('no-ops when deleting a non-syncable order', async () => { + const { options, performBatchSetStorage } = arrangeMocks(); + + await deleteOrderInRemoteStorage( + createMockOrder({ id: '', providerOrderId: '' }), + options, + ); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('wraps incremental deletes in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const order = createMockOrder(); + const { options } = arrangeMocks(); + + await deleteOrderInRemoteStorage(order, { + ...options, + trace, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Delete Remote' }), + expect.any(Function), + ); + }); + + it('wraps batch saves in trace when provided', async () => { + const trace = jest.fn(async (_request, fn) => fn()); + const { options } = arrangeMocks({ + localOrders: [createMockOrder()], + remoteEntries: [], + }); + + await syncOrdersWithUserStorage( + {}, + { + ...options, + trace, + }, + ); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Save Batch' }), + expect.any(Function), + ); + }); + }); + + describe('merge helpers', () => { + const { + computeMergePlan, + reconcileOrdersForRemoteUpload, + getOrderTimestamp, + } = orderSyncingTestExports; + + it('resolves timestamps from sync metadata, createdAt, or zero', () => { + expect( + getOrderTimestamp({ + ...createMockOrder(), + lastUpdatedAt: 10, + createdAt: 5, + }), + ).toBe(10); + expect(getOrderTimestamp(createMockOrder({ createdAt: 5 }))).toBe(5); + expect( + getOrderTimestamp({ + providerOrderId: 'x', + } as RampsOrder), + ).toBe(0); + }); + + it('prefers newer local timestamps over older remote tombstones', () => { + const localOrder = createMockOrder({ createdAt: Date.now() }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder(), + deletedAt: 1, + lastUpdatedAt: 1, + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToUpdateRemotely).toHaveLength(1); + expect(plan.ordersToDeleteLocally).toHaveLength(0); + }); + + it('restores local orders that changed after an older remote tombstone', () => { + const localOrder = createMockOrder({ fiatAmount: 300 }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: 1, + lastUpdatedAt: 1, + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToUpdateRemotely).toHaveLength(1); + }); + + it('applies remote tombstones when deletion is newer and content matches', () => { + const localOrder = createMockOrder(); + const deletedRemote: SyncRampsOrder = { + ...localOrder, + deletedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToDeleteLocally).toHaveLength(1); + expect(plan.ordersToUpdateRemotely).toHaveLength(0); + }); + + it('applies newer remote tombstones when content differs', () => { + const localOrder = createMockOrder({ + fiatAmount: 300, + createdAt: 1, + }); + const deletedRemote: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100 }), + deletedAt: Date.now(), + lastUpdatedAt: Date.now(), + }; + + const plan = computeMergePlan([localOrder], [deletedRemote]); + + expect(plan.ordersToDeleteLocally).toHaveLength(1); + expect(plan.ordersToUpdateRemotely).toHaveLength(0); + }); + + it('prefers local lastUpdatedAt over older remote content during merge', () => { + const localOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 500, createdAt: 1 }), + lastUpdatedAt: 1000, + }; + const remoteOrder: SyncRampsOrder = { + ...createMockOrder({ fiatAmount: 100, createdAt: 1 }), + lastUpdatedAt: 10, + }; + + const plan = computeMergePlan([localOrder], [remoteOrder]); + + expect(plan.ordersToUpdateRemotely).toHaveLength(1); + expect(plan.ordersToAddOrUpdateLocally).toHaveLength(0); + }); + + it('ignores remote tombstones when no matching local order exists', () => { + const deletedRemote: SyncRampsOrder = { + ...createMockOrder(), + deletedAt: Date.now(), + }; + + const plan = computeMergePlan([], [deletedRemote]); + + expect(plan.ordersToDeleteLocally).toHaveLength(0); + expect(plan.ordersToUpdateRemotely).toHaveLength(0); + }); + + it('keeps queued uploads when the live local payload is unchanged', () => { + const localOrder = createMockOrder(); + const remoteOrdersMap = new Map(); + + const uploads = reconcileOrdersForRemoteUpload( + [localOrder], + remoteOrdersMap, + [localOrder], + ); + + expect(uploads).toHaveLength(1); + expect(uploads[0]).toStrictEqual(localOrder); + }); + + it('replaces queued uploads with fresher local payloads', () => { + const staleOrder = createMockOrder({ fiatAmount: 100 }); + const freshOrder = createMockOrder({ fiatAmount: 500 }); + + const uploads = reconcileOrdersForRemoteUpload([staleOrder], new Map(), [ + freshOrder, + ]); + + expect(uploads[0]?.fiatAmount).toBe(500); + }); + + it('uploads live local orders missing from the remote map', () => { + const localOrder = createMockOrder(); + + const uploads = reconcileOrdersForRemoteUpload([], new Map(), [ + localOrder, + ]); + + expect(uploads).toHaveLength(1); + }); + + it('skips uploads when the live local payload already matches remote', () => { + const localOrder = createMockOrder(); + const remoteOrder: SyncRampsOrder = { + ...localOrder, + lastUpdatedAt: Date.now(), + }; + const remoteOrdersMap = new Map([ + ['abc-123', remoteOrder], + ]); + + const uploads = reconcileOrdersForRemoteUpload([], remoteOrdersMap, [ + localOrder, + ]); + + expect(uploads).toHaveLength(0); + }); + }); +}); diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts new file mode 100644 index 00000000000..82c94970b35 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -0,0 +1,526 @@ +import type { RampsOrder } from '../RampsService.js'; +import { + TraceName, + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { canPerformOrderSyncing } from './sync-utils.js'; +import type { + OrderSyncingOptions, + SyncRampsOrder, + UserStorageRampsOrderEntry, +} from './types.js'; +import { + areOrdersEqual, + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripDeletedAt, + stripSyncMetadata, +} from './utils.js'; + +export type SyncOrdersWithUserStorageConfig = { + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}; + +type MergePlan = { + ordersToAddOrUpdateLocally: SyncRampsOrder[]; + ordersToDeleteLocally: SyncRampsOrder[]; + ordersToUpdateRemotely: RampsOrder[]; +}; + +/** + * Returns the timestamp used for ramps order conflict resolution. + * + * Content conflicts use `local >= remote` (local wins ties). Tombstones use + * `local > deletedAt` (tie deletes locally) so equal-time deletes still apply. + * + * @param order - A ramps order that may include sync metadata. + * @returns The best available last-updated timestamp. + */ +function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { + return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; +} + +/** + * Builds the local/remote merge plan from the current local snapshot and remote + * entries. + * + * @param localOrders - Syncable orders currently in {@link RampsController} state. + * @param validRemoteOrders - Syncable orders fetched from User Storage. + * @returns Lists of local mutations and remote uploads to apply. + */ +function computeMergePlan( + localOrders: RampsOrder[], + validRemoteOrders: SyncRampsOrder[], +): MergePlan & { remoteOrdersMap: Map } { + const localOrdersMap = new Map(); + const remoteOrdersMap = new Map(); + + localOrders.forEach((order) => { + localOrdersMap.set(createOrderStorageKey(order), order); + }); + + validRemoteOrders.forEach((order) => { + remoteOrdersMap.set(createOrderStorageKey(order), order); + }); + + const ordersToAddOrUpdateLocally: SyncRampsOrder[] = []; + const ordersToDeleteLocally: SyncRampsOrder[] = []; + const ordersToUpdateRemotely: RampsOrder[] = []; + + for (const remoteOrder of validRemoteOrders) { + const key = createOrderStorageKey(remoteOrder); + const localOrder = localOrdersMap.get(key); + + if (remoteOrder.deletedAt) { + if (localOrder) { + const localTimestamp = getOrderTimestamp(localOrder); + const remoteTimestamp = remoteOrder.deletedAt; + + if (localTimestamp > remoteTimestamp) { + ordersToUpdateRemotely.push(localOrder); + } else { + ordersToDeleteLocally.push(remoteOrder); + } + } + } else if (!localOrder) { + ordersToAddOrUpdateLocally.push(remoteOrder); + } else if (!areOrdersEqual(localOrder, remoteOrder)) { + const localTimestamp = getOrderTimestamp(localOrder); + const remoteTimestamp = getOrderTimestamp(remoteOrder); + + if (localTimestamp >= remoteTimestamp) { + ordersToUpdateRemotely.push(localOrder); + } else { + ordersToAddOrUpdateLocally.push(remoteOrder); + } + } + } + + for (const localOrder of localOrders) { + const key = createOrderStorageKey(localOrder); + if (!remoteOrdersMap.has(key)) { + ordersToUpdateRemotely.push(localOrder); + } + } + + return { + remoteOrdersMap, + ordersToAddOrUpdateLocally, + ordersToDeleteLocally, + ordersToUpdateRemotely, + }; +} + +/** + * Re-reads live local orders after merge mutations so orders added or updated + * while `isOrderSyncingInProgress` is true are still uploaded remotely. + * + * @param plannedRemoteUploads - Orders already queued for remote upload. + * @param remoteOrdersMap - Remote orders keyed by storage key. + * @param currentLocalOrders - Latest syncable local orders. + * @returns Remote uploads using the freshest local payloads. + */ +function reconcileOrdersForRemoteUpload( + plannedRemoteUploads: RampsOrder[], + remoteOrdersMap: Map, + currentLocalOrders: RampsOrder[], +): RampsOrder[] { + const keyedUploads = new Map( + plannedRemoteUploads.map((order) => [createOrderStorageKey(order), order]), + ); + + for (const localOrder of currentLocalOrders) { + const key = createOrderStorageKey(localOrder); + const existingUpload = keyedUploads.get(key); + const remoteOrder = remoteOrdersMap.get(key); + + if (existingUpload) { + if (!areOrdersEqual(existingUpload, localOrder)) { + keyedUploads.set(key, localOrder); + } + continue; + } + + if (!remoteOrder || !areOrdersEqual(localOrder, remoteOrder)) { + keyedUploads.set(key, localOrder); + } + } + + return [...keyedUploads.values()]; +} + +/** + * Syncs V2 ramps orders between local {@link RampsController} state and User Storage. + * + * Handles first sync, new-device sync, merges, timestamp-based conflict resolution, + * and remote soft-deletes. + * + * @param config - Optional callbacks for sync errors. + * @param options - Parameters used for syncing operations. + */ +export async function syncOrdersWithUserStorage( + config: SyncOrdersWithUserStorageConfig, + options: OrderSyncingOptions, +): Promise { + const { getRampsControllerInstance, trace } = options; + const { onOrderSyncErroneousSituation } = config; + + if (!canPerformOrderSyncing(options)) { + return; + } + + const controller = getRampsControllerInstance(); + // Set the semaphore before the remote fetch so overlapping sync calls and + // incremental addOrder/removeOrder pushes cannot race the merge. + controller.setIsOrderSyncingInProgress(true); + + try { + const validRemoteOrders = (await getRemoteOrders(options, config)).filter( + isSyncableOrder, + ); + + const performSync = async (): Promise => { + const getLocalOrders = (): RampsOrder[] => + controller.state.orders.filter(isSyncableOrder); + + const { + remoteOrdersMap, + ordersToAddOrUpdateLocally, + ordersToDeleteLocally, + ordersToUpdateRemotely, + } = computeMergePlan(getLocalOrders(), validRemoteOrders); + + for (const order of ordersToDeleteLocally) { + controller.removeOrder(createOrderStorageKey(order)); + } + + for (const order of ordersToAddOrUpdateLocally) { + if (!order.deletedAt) { + controller.addOrder(stripDeletedAt(order)); + } + } + + const ordersToUpload = reconcileOrdersForRemoteUpload( + ordersToUpdateRemotely, + remoteOrdersMap, + getLocalOrders(), + ); + + const localKeys = new Set( + getLocalOrders().map((order) => createOrderStorageKey(order)), + ); + const pendingDeletes = controller + .drainPendingRemoteDeletes() + .filter((order) => { + const key = createOrderStorageKey(order); + return key.length > 0 && !localKeys.has(key); + }); + const pendingDeleteKeys = new Set( + pendingDeletes.map((order) => createOrderStorageKey(order)), + ); + + const now = Date.now(); + const syncedUploads: SyncRampsOrder[] = [ + ...ordersToUpload + .filter( + (localOrder) => + !pendingDeleteKeys.has(createOrderStorageKey(localOrder)), + ) + .map((localOrder) => ({ + ...stripSyncMetadata(localOrder), + lastUpdatedAt: (localOrder as SyncRampsOrder).lastUpdatedAt ?? now, + })), + ...pendingDeletes.map((order) => ({ + ...stripSyncMetadata(order), + deletedAt: now, + lastUpdatedAt: now, + })), + ]; + + if (syncedUploads.length > 0) { + await saveOrdersToUserStorage(syncedUploads, options, config); + } + }; + + if (trace) { + const localOrderCount = + controller.state.orders.filter(isSyncableOrder).length; + + await trace( + { + name: TraceName.RampsOrderSyncFull, + data: { + localOrderCount, + remoteOrderCount: validRemoteOrders.length, + isFirstSync: validRemoteOrders.length === 0 && localOrderCount > 0, + isNewDeviceSync: + localOrderCount === 0 && validRemoteOrders.length > 0, + }, + }, + performSync, + ); + return; + } + + await performSync(); + } catch (error) { + onOrderSyncErroneousSituation?.('Error synchronizing ramps orders', { + error, + }); + throw error; + } finally { + controller.setIsOrderSyncingInProgress(false); + } +} + +/** + * Retrieves remote ramps orders from User Storage. + * + * @param options - Parameters used for retrieving remote orders. + * @param config - Optional sync callbacks for error reporting. + * @returns Parsed sync-aware orders. Returns an empty array when none exist. + */ +async function getRemoteOrders( + options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig, +): Promise { + const { getMessenger } = options; + const { onOrderSyncErroneousSituation } = config; + + try { + // Multi-SRP: Portfolio uploads under the connected account's HD entropy + // profile. Reading only the primary SRP misses secondary-wallet orders. + let entropySourceIds: (string | undefined)[] = [undefined]; + try { + const listed = await getMessenger().call( + 'UserStorageController:listEntropySources', + ); + if (listed.length > 0) { + entropySourceIds = listed; + } + } catch { + // Wallet locked / action missing — fall back to primary (omit id). + } + + const remoteOrdersJsonArray: string[] = []; + + for (const entropySourceId of entropySourceIds) { + const entries = await getMessenger().call( + 'UserStorageController:performGetStorageAllFeatureEntries', + USER_STORAGE_RAMPS_ORDERS_FEATURE, + entropySourceId, + ); + if (entries?.length) { + remoteOrdersJsonArray.push(...entries); + } + } + + if (remoteOrdersJsonArray.length === 0) { + return []; + } + + const ordersByKey = new Map(); + + for (const orderJson of remoteOrdersJsonArray) { + try { + const entry = JSON.parse(orderJson) as UserStorageRampsOrderEntry; + if (entry[USER_STORAGE_VERSION_KEY] !== USER_STORAGE_VERSION) { + onOrderSyncErroneousSituation?.( + 'Unsupported ramps order storage version', + { + version: entry[USER_STORAGE_VERSION_KEY], + expectedVersion: USER_STORAGE_VERSION, + }, + ); + continue; + } + if (!entry.o || typeof entry.o !== 'object') { + onOrderSyncErroneousSituation?.( + 'Remote ramps order entry missing order payload', + {}, + ); + continue; + } + const mapped = mapUserStorageEntryToRampsOrder(entry); + const key = createOrderStorageKey(mapped); + if (key) { + const existing = ordersByKey.get(key); + if (existing) { + const existingTimestamp = getOrderTimestamp(existing); + const mappedTimestamp = getOrderTimestamp(mapped); + if (mappedTimestamp > existingTimestamp) { + ordersByKey.set(key, mapped); + } + } else { + ordersByKey.set(key, mapped); + } + } + } catch (error) { + // Do not attach raw order JSON — it can include wallet/PII fields. + onOrderSyncErroneousSituation?.( + 'Failed to parse remote ramps order entry', + { + error, + entryLength: orderJson.length, + }, + ); + } + } + + return [...ordersByKey.values()]; + } catch (error) { + onOrderSyncErroneousSituation?.('Failed to fetch remote ramps orders', { + error, + }); + throw error; + } +} + +/** + * Saves orders to User Storage in a batch. + * + * @param orders - The orders to save. + * @param options - Parameters used for saving orders. + * @param config - Optional sync callbacks for error reporting. + * @returns Resolves when the batch write completes. + */ +async function saveOrdersToUserStorage( + orders: SyncRampsOrder[], + options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, +): Promise { + const { getMessenger, trace } = options; + const { onOrderSyncErroneousSituation } = config; + + const saveOrders = async (): Promise => { + const storageEntries: [string, string][] = []; + + for (const order of orders) { + const key = createOrderStorageKey(order); + if (!key) { + onOrderSyncErroneousSituation?.( + 'Skipping ramps order remote write with empty storage key', + { + hasId: Boolean(order.id), + hasProviderOrderId: Boolean(order.providerOrderId), + }, + ); + continue; + } + const storageEntry = mapRampsOrderToUserStorageEntry(order); + storageEntries.push([key, JSON.stringify(storageEntry)]); + } + + if (storageEntries.length === 0) { + return; + } + + await getMessenger().call( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_ORDERS_FEATURE, + storageEntries, + ); + }; + + return trace + ? await trace( + { + name: TraceName.RampsOrderSyncSaveBatch, + data: { + orderCount: orders.length, + hasBatchOperations: orders.length > 1, + }, + }, + saveOrders, + ) + : await saveOrders(); +} + +/** + * Updates a single order in remote storage without a full sync. + * + * @param order - The order that was updated locally. + * @param options - Parameters used for syncing operations. + * @returns Resolves when the remote update completes or no-ops. + */ +export async function updateOrderInRemoteStorage( + order: RampsOrder, + options: OrderSyncingOptions, +): Promise { + const { trace } = options; + + const updateOrder = async (): Promise => { + if (!canPerformOrderSyncing(options) || !isSyncableOrder(order)) { + return; + } + + const updatedEntry: SyncRampsOrder = { + ...order, + lastUpdatedAt: Date.now(), + }; + + await saveOrdersToUserStorage([updatedEntry], options); + }; + + if (trace) { + return await trace( + { + name: TraceName.RampsOrderSyncUpdateRemote, + data: { + orderStatus: order.status, + hasProviderOrderId: Boolean(order.providerOrderId), + }, + }, + updateOrder, + ); + } + + return await updateOrder(); +} + +/** + * Marks a single order as deleted in remote storage (soft delete). + * + * @param order - The order that was deleted locally (needs id / providerOrderId). + * @param options - Parameters used for syncing operations. + * @returns Resolves when the remote soft-delete completes or no-ops. + */ +export async function deleteOrderInRemoteStorage( + order: RampsOrder, + options: OrderSyncingOptions, +): Promise { + const { trace } = options; + + const deleteOrder = async (): Promise => { + if (!canPerformOrderSyncing(options) || !isSyncableOrder(order)) { + return; + } + + const now = Date.now(); + const deletedOrder: SyncRampsOrder = { + ...order, + deletedAt: now, + lastUpdatedAt: now, + }; + + await saveOrdersToUserStorage([deletedOrder], options); + }; + + return trace + ? await trace({ name: TraceName.RampsOrderSyncDeleteRemote }, deleteOrder) + : await deleteOrder(); +} + +export const orderSyncingTestExports = { + computeMergePlan, + reconcileOrdersForRemoteUpload, + getOrderTimestamp, + saveOrdersToUserStorage, +}; diff --git a/packages/ramps-controller/src/order-syncing/index.ts b/packages/ramps-controller/src/order-syncing/index.ts new file mode 100644 index 00000000000..238e193cce5 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/index.ts @@ -0,0 +1,29 @@ +export { + USER_STORAGE_RAMPS_ORDERS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +export type { + UserStorageRampsOrderEntry, + SyncRampsOrder, + OrderSyncingController, + OrderSyncingOptions, +} from './types.js'; +export { + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripPaymentDetailsForRemoteStorage, + stripSyncMetadata, + stripDeletedAt, + areOrdersEqual, +} from './utils.js'; +export { canPerformOrderSyncing } from './sync-utils.js'; +export type { SyncOrdersWithUserStorageConfig } from './controller-integration.js'; +export { + syncOrdersWithUserStorage, + updateOrderInRemoteStorage, + deleteOrderInRemoteStorage, +} from './controller-integration.js'; diff --git a/packages/ramps-controller/src/order-syncing/sync-utils.test.ts b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts new file mode 100644 index 00000000000..a474767a8db --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts @@ -0,0 +1,83 @@ +import { canPerformOrderSyncing } from './sync-utils.js'; +import type { OrderSyncingOptions } from './types.js'; + +describe('order-syncing/sync-utils', () => { + describe('canPerformOrderSyncing', () => { + const arrangeMocks = ({ + isBackupAndSyncEnabled = true, + isRampsSyncingEnabled = true, + isOrderSyncingInProgress = false, + isSignedIn = true, + throwOnMessengerCall = false, + } = {}): { options: OrderSyncingOptions } => { + const options: OrderSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest.fn().mockImplementation((action: string) => { + if (throwOnMessengerCall) { + throw new Error('action not registered'); + } + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled, isRampsSyncingEnabled }; + } + if (action === 'AuthenticationController:isSignedIn') { + return isSignedIn; + } + return null; + }), + }), + getRampsControllerInstance: jest.fn().mockReturnValue({ + isOrderSyncingInProgress, + state: { orders: [] }, + setIsOrderSyncingInProgress: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }), + }; + + return { options }; + }; + + it.each([ + ['backup and sync is disabled', { isBackupAndSyncEnabled: false }], + ['ramps syncing is disabled', { isRampsSyncingEnabled: false }], + ['order syncing is in progress', { isOrderSyncingInProgress: true }], + ['user is not signed in', { isSignedIn: false }], + ['messenger actions are unavailable', { throwOnMessengerCall: true }], + ] as const)('returns false if %s', (_message, mocks) => { + const { options } = arrangeMocks(mocks); + expect(canPerformOrderSyncing(options)).toBe(false); + }); + + it('returns true if all conditions are met', () => { + const { options } = arrangeMocks(); + expect(canPerformOrderSyncing(options)).toBe(true); + }); + + it('defaults isRampsSyncingEnabled to true when absent from User Storage state', () => { + const options: OrderSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest.fn().mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + return null; + }), + }), + getRampsControllerInstance: jest.fn().mockReturnValue({ + isOrderSyncingInProgress: false, + state: { orders: [] }, + setIsOrderSyncingInProgress: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + }), + }; + + expect(canPerformOrderSyncing(options)).toBe(true); + }); + }); +}); diff --git a/packages/ramps-controller/src/order-syncing/sync-utils.ts b/packages/ramps-controller/src/order-syncing/sync-utils.ts new file mode 100644 index 00000000000..eb37e2ee81f --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/sync-utils.ts @@ -0,0 +1,39 @@ +import type { OrderSyncingOptions } from './types.js'; + +/** + * Check if we can perform ramps order syncing. + * + * @param options - Parameters used for checking if we can perform order syncing. + * @returns Whether we can perform order syncing. + */ +export function canPerformOrderSyncing(options: OrderSyncingOptions): boolean { + const { getMessenger, getRampsControllerInstance } = options; + + try { + const { + isBackupAndSyncEnabled, + isRampsSyncingEnabled: rawIsRampsSyncingEnabled, + } = getMessenger().call('UserStorageController:getState'); + const isRampsSyncingEnabled = rawIsRampsSyncingEnabled ?? true; + + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + + const { isOrderSyncingInProgress } = getRampsControllerInstance(); + + if ( + !isBackupAndSyncEnabled || + !isRampsSyncingEnabled || + isOrderSyncingInProgress || + !isAuthEnabled + ) { + return false; + } + + return true; + } catch { + // Host has not delegated User Storage / auth actions yet + return false; + } +} diff --git a/packages/ramps-controller/src/order-syncing/types.ts b/packages/ramps-controller/src/order-syncing/types.ts new file mode 100644 index 00000000000..8ff1dfccf39 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/types.ts @@ -0,0 +1,67 @@ +import type { TraceCallback } from '@metamask/controller-utils'; + +import type { RampsControllerMessenger } from '../RampsController.js'; +import type { RampsOrder } from '../RampsService.js'; +import type { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; + +/** + * Compact User Storage entry wrapping a full {@link RampsOrder}. + */ +export type UserStorageRampsOrderEntry = { + /** + * Schema version — allows upgrade/downgrade handling in the future. + */ + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + /** + * Full V2 ramps order payload. + */ + o: RampsOrder; + /** + * Last-updated timestamp used for conflict resolution. + */ + lu?: number; + /** + * Soft-delete tombstone timestamp. When set, the order should be removed locally. + */ + dt?: number; +}; + +/** + * {@link RampsOrder} extended with sync metadata used for conflict resolution. + * + * `lastUpdatedAt` is persisted on local controller orders so last-write-wins can + * compare against remote `lu` after local edits. `deletedAt` is remote-only and + * must be stripped before writing into {@link RampsController} state. + */ +export type SyncRampsOrder = RampsOrder & { + lastUpdatedAt?: number; + deletedAt?: number; +}; + +/** + * Minimal controller surface required by order syncing. + */ +export type OrderSyncingController = { + state: { orders: RampsOrder[] }; + readonly isOrderSyncingInProgress: boolean; + setIsOrderSyncingInProgress: (value: boolean) => void; + addOrder: (order: RampsOrder) => void; + removeOrder: (providerOrderId: string) => void; + /** + * Returns and clears orders deleted locally while a full sync held the + * semaphore, so remote tombstones can still be written at sync end. + */ + drainPendingRemoteDeletes: () => RampsOrder[]; +}; + +/** + * Options for ramps order syncing operations. + */ +export type OrderSyncingOptions = { + getRampsControllerInstance: () => OrderSyncingController; + getMessenger: () => RampsControllerMessenger; + trace?: TraceCallback; +}; diff --git a/packages/ramps-controller/src/order-syncing/utils.test.ts b/packages/ramps-controller/src/order-syncing/utils.test.ts new file mode 100644 index 00000000000..409fa1d8871 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -0,0 +1,257 @@ +import { RampsOrderStatus } from '../RampsService.js'; +import type { RampsOrder } from '../RampsService.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; +import { + areOrdersEqual, + createOrderStorageKey, + isSyncableOrder, + mapRampsOrderToUserStorageEntry, + mapUserStorageEntryToRampsOrder, + stripDeletedAt, + stripPaymentDetailsForRemoteStorage, + stripSyncMetadata, +} from './utils.js'; + +function createMockOrder(overrides: Partial = {}): RampsOrder { + return { + id: '/providers/transak/orders/abc-123', + isOnlyLink: false, + provider: { + id: 'transak', + name: 'Transak', + environmentType: 'PRODUCTION', + logos: { light: {}, dark: {} }, + links: [], + deliveryTime: 5, + orderFrequency: null, + } as RampsOrder['provider'], + success: true, + cryptoAmount: 0.05, + fiatAmount: 100, + cryptoCurrency: { symbol: 'ETH', decimals: 18 }, + fiatCurrency: { symbol: 'USD', decimals: 2, denomSymbol: '$' }, + providerOrderId: 'abc-123', + providerOrderLink: 'https://transak.com/order/abc-123', + createdAt: 1700000000000, + paymentMethod: { id: '/payments/debit-credit-card', name: 'Card' }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0xabc', + status: RampsOrderStatus.Completed, + network: { chainId: '1', name: 'Ethereum Mainnet' }, + canBeUpdated: false, + idHasExpired: false, + excludeFromPurchases: false, + timeDescriptionPending: '', + orderType: 'BUY', + exchangeRate: 2000, + ...overrides, + }; +} + +describe('order-syncing/utils', () => { + describe('createOrderStorageKey', () => { + it('prefers the code embedded in the order id path', () => { + expect( + createOrderStorageKey({ + id: '/providers/transak/orders/order-1', + providerOrderId: 'provider-native-id', + }), + ).toBe('order-1'); + }); + + it('falls back to providerOrderId', () => { + expect( + createOrderStorageKey({ + providerOrderId: 'provider-native-id', + }), + ).toBe('provider-native-id'); + }); + + it('trims whitespace from providerOrderId keys', () => { + expect( + createOrderStorageKey({ + providerOrderId: ' provider-native-id ', + }), + ).toBe('provider-native-id'); + }); + + it('returns an empty key when providerOrderId is missing', () => { + expect(createOrderStorageKey({})).toBe(''); + }); + + it('falls back to providerOrderId when /orders/ segment is empty', () => { + expect( + createOrderStorageKey({ + id: '/providers/transak/orders/', + providerOrderId: 'real-id', + }), + ).toBe('real-id'); + }); + }); + + describe('isSyncableOrder', () => { + it('returns true when providerOrderId is present', () => { + expect(isSyncableOrder(createMockOrder())).toBe(true); + }); + + it('returns true when only the order id path is present', () => { + expect( + isSyncableOrder( + createMockOrder({ + providerOrderId: '', + id: '/providers/transak/orders/order-from-id', + }), + ), + ).toBe(true); + }); + + it('returns false when both id and providerOrderId are missing', () => { + expect( + isSyncableOrder( + createMockOrder({ id: undefined, providerOrderId: '' }), + ), + ).toBe(false); + }); + + it('accepts minimal id/providerOrderId picks', () => { + expect( + isSyncableOrder({ + id: '/providers/transak/orders/order-from-id', + providerOrderId: 'provider-native-id', + }), + ).toBe(true); + }); + }); + + describe('mapRampsOrderToUserStorageEntry / mapUserStorageEntryToRampsOrder', () => { + it('defaults lastUpdatedAt when mapping to storage', () => { + const entry = mapRampsOrderToUserStorageEntry(createMockOrder()); + + expect(entry.lu).toStrictEqual(expect.any(Number)); + }); + + it('maps storage entries without lastUpdatedAt metadata', () => { + const entry: UserStorageRampsOrderEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder(), + }; + + const mapped = mapUserStorageEntryToRampsOrder(entry); + expect(mapped.lastUpdatedAt).toBeUndefined(); + }); + + it('round-trips a full order with sync metadata', () => { + const order: SyncRampsOrder = { + ...createMockOrder(), + lastUpdatedAt: 1700000001000, + }; + + const entry = mapRampsOrderToUserStorageEntry(order); + + expect(entry[USER_STORAGE_VERSION_KEY]).toBe(USER_STORAGE_VERSION); + expect(entry.o.providerOrderId).toBe('abc-123'); + expect(entry.lu).toBe(1700000001000); + expect(entry.dt).toBeUndefined(); + + const mapped = mapUserStorageEntryToRampsOrder(entry); + expect(mapped.providerOrderId).toBe('abc-123'); + expect(mapped.lastUpdatedAt).toBe(1700000001000); + expect(mapped.deletedAt).toBeUndefined(); + }); + + it('preserves soft-delete tombstones', () => { + const entry: UserStorageRampsOrderEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder(), + lu: 1700000002000, + dt: 1700000002000, + }; + + const mapped = mapUserStorageEntryToRampsOrder(entry); + expect(mapped.deletedAt).toBe(1700000002000); + }); + + it('strips paymentDetails from remote storage payloads', () => { + const entry = mapRampsOrderToUserStorageEntry({ + ...createMockOrder(), + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa', + fields: [{ name: 'IBAN', id: 'iban', value: 'DE00' }], + }, + ], + }); + + expect(entry.o.paymentDetails).toBeUndefined(); + }); + }); + + describe('stripPaymentDetailsForRemoteStorage', () => { + it('omits paymentDetails while keeping other fields', () => { + const stripped = stripPaymentDetailsForRemoteStorage({ + ...createMockOrder(), + paymentDetails: [ + { + fiatCurrency: 'EUR', + paymentMethod: 'sepa', + fields: [{ name: 'IBAN', id: 'iban', value: 'DE00' }], + }, + ], + }); + + expect(stripped.paymentDetails).toBeUndefined(); + expect(stripped.providerOrderId).toBe('abc-123'); + }); + }); + + describe('stripSyncMetadata', () => { + it('removes lastUpdatedAt and deletedAt', () => { + const stripped = stripSyncMetadata({ + ...createMockOrder(), + lastUpdatedAt: 1, + deletedAt: 2, + }); + + expect(stripped).not.toHaveProperty('lastUpdatedAt'); + expect(stripped).not.toHaveProperty('deletedAt'); + expect(stripped.providerOrderId).toBe('abc-123'); + }); + }); + + describe('stripDeletedAt', () => { + it('removes deletedAt while preserving lastUpdatedAt', () => { + const stripped = stripDeletedAt({ + ...createMockOrder(), + lastUpdatedAt: 1, + deletedAt: 2, + }); + + expect(stripped.lastUpdatedAt).toBe(1); + expect(stripped).not.toHaveProperty('deletedAt'); + expect(stripped.providerOrderId).toBe('abc-123'); + }); + }); + + describe('areOrdersEqual', () => { + it('returns true for identical order bodies ignoring sync metadata', () => { + const a = { ...createMockOrder(), lastUpdatedAt: 1 }; + const b = { ...createMockOrder(), lastUpdatedAt: 999 }; + expect(areOrdersEqual(a, b)).toBe(true); + }); + + it('returns false when order content differs', () => { + const a = createMockOrder({ fiatAmount: 100 }); + const b = createMockOrder({ fiatAmount: 200 }); + expect(areOrdersEqual(a, b)).toBe(false); + }); + + it('ignores object key insertion order', () => { + const a = { fiatAmount: 100, providerOrderId: 'x' } as RampsOrder; + const b = { providerOrderId: 'x', fiatAmount: 100 } as RampsOrder; + expect(areOrdersEqual(a, b)).toBe(true); + }); + }); +}); diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts new file mode 100644 index 00000000000..4d8d8192fcc --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -0,0 +1,162 @@ +import type { RampsOrder } from '../RampsService.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; + +/** + * Creates a unique storage key for a ramps order. + * Mirrors {@link getInternalOrderCode} without importing the controller module + * (avoids circular dependencies). + * + * Prefers a non-empty `/orders/` suffix; otherwise falls back to a trimmed + * `providerOrderId`. An empty `/orders/` segment does not win over a real + * provider order id. + * + * @param order - Order fields used to derive the internal order code. + * @returns Storage entry key under the rampsOrders feature. + */ +export function createOrderStorageKey( + order: Pick, +): string { + const { id, providerOrderId } = order; + if (id?.includes('/orders/')) { + const code = id.split('/orders/')[1]?.trim(); + if (code) { + return code; + } + } + return providerOrderId?.trim() ?? ''; +} + +/** + * Whether an order has the minimum fields required for syncing. + * + * @param order - The order to validate. + * @returns True when the order can be synced. + */ +export function isSyncableOrder( + order: Pick, +): boolean { + return createOrderStorageKey(order).length > 0; +} + +/** + * Strips bank-transfer / PII-heavy payment details before persisting to User + * Storage. Local controller state may still keep `paymentDetails`. + * + * @param order - Order that may include payment details. + * @returns Order body safe for remote sync payloads. + */ +export function stripPaymentDetailsForRemoteStorage( + order: RampsOrder, +): RampsOrder { + const { paymentDetails: _paymentDetails, ...safeOrder } = order; + return safeOrder; +} + +/** + * Maps a ramps order (with optional sync metadata) to a User Storage entry. + * + * @param order - The order to map. + * @returns A User Storage entry ready to be JSON-stringified. + */ +export function mapRampsOrderToUserStorageEntry( + order: SyncRampsOrder, +): UserStorageRampsOrderEntry { + const { lastUpdatedAt, deletedAt, ...rampsOrder } = order; + const now = Date.now(); + + return { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: stripPaymentDetailsForRemoteStorage(rampsOrder), + lu: lastUpdatedAt ?? now, + ...(deletedAt ? { dt: deletedAt } : {}), + }; +} + +/** + * Maps a User Storage entry back to a sync-aware ramps order. + * + * @param entry - The User Storage entry. + * @returns A {@link SyncRampsOrder} for merge logic. + */ +export function mapUserStorageEntryToRampsOrder( + entry: UserStorageRampsOrderEntry, +): SyncRampsOrder { + return { + ...entry.o, + ...(entry.lu ? { lastUpdatedAt: entry.lu } : {}), + ...(entry.dt ? { deletedAt: entry.dt } : {}), + }; +} + +/** + * Strips sync metadata for equality checks and remote payload shaping. + * + * @param order - Order that may include sync metadata. + * @returns A plain {@link RampsOrder} without sync fields. + */ +export function stripSyncMetadata(order: SyncRampsOrder): RampsOrder { + const { + lastUpdatedAt: _lastUpdatedAt, + deletedAt: _deletedAt, + ...rampsOrder + } = order; + return rampsOrder; +} + +/** + * Strips remote tombstone metadata while preserving `lastUpdatedAt` so local + * controller state can participate in last-write-wins conflict resolution. + * + * @param order - Order that may include sync metadata. + * @returns Order safe to persist locally (no `deletedAt`). + */ +export function stripDeletedAt(order: SyncRampsOrder): SyncRampsOrder { + const { deletedAt: _deletedAt, ...rampsOrder } = order; + return rampsOrder; +} + +/** + * JSON-stringifies values with object keys sorted so equality is stable across + * key insertion order. + * + * @param value - Value to serialize. + * @returns Stable JSON string. + */ +function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, nested) => { + if ( + nested && + typeof nested === 'object' && + !Array.isArray(nested) && + !(nested instanceof Date) + ) { + const record = nested as Record; + return Object.keys(record) + .sort() + .reduce>((acc, key) => { + acc[key] = record[key]; + return acc; + }, {}); + } + return nested; + }); +} + +/** + * Deep-compares two ramps orders by stable JSON serialization of their order + * bodies (sync metadata excluded). + * + * @param a - First order. + * @param b - Second order. + * @returns True when the order payloads are equal. + */ +export function areOrdersEqual( + a: SyncRampsOrder | RampsOrder, + b: SyncRampsOrder | RampsOrder, +): boolean { + return ( + stableStringify(stripSyncMetadata(a as SyncRampsOrder)) === + stableStringify(stripSyncMetadata(b as SyncRampsOrder)) + ); +} From f67d9c86e6da9287c6cbdc073e14a28b03a05479 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 05:54:33 -0600 Subject: [PATCH 14/31] fix(ramps-controller): restore controller wiring dropped in squash The force-pushed squash left order-syncing modules/tests but omitted RampsController messenger AllowedActions, sync APIs, and changelog. Restore those, keep freshest remote (incl. tombstones) across entropy profiles, and harden sync coalescing. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 31 +++ .../RampsController-method-action-types.ts | 5 + .../src/RampsController.order-syncing.test.ts | 96 ++++++++ .../ramps-controller/src/RampsController.ts | 224 +++++++++++++++++- .../order-syncing/controller-integration.ts | 40 +++- 5 files changed, 376 insertions(+), 20 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index d61b6aa2e9b..25d74c375c4 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -12,6 +12,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) +- Add V2 ramps order syncing with User Storage ([#9474](https://github.com/MetaMask/core/pull/9474)) + - New `order-syncing/` module stores `RampsOrder` objects (without `paymentDetails`) as per-order User Storage entries under the `rampsOrders` feature + - `RampsController.syncOrdersWithUserStorage()` performs bidirectional sync with timestamp-based conflict resolution and soft deletes + - `addOrder` / `removeOrder` incrementally push local changes to User Storage when Backup & Sync and `isRampsSyncingEnabled` are on + - Messenger action `RampsController:syncOrdersWithUserStorage` for hosts to trigger a full sync on unlock / feature enable + - Optional constructor `onOrderSyncErroneousSituation` and `trace` callbacks for host observability + +### Changed + +- **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) + +### Fixed + +- Preserve remote `createdAt` as `lastUpdatedAt` when importing legacy records without an update timestamp ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Queue local deletes that occur during a full sync and write their tombstones before the sync completes ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Re-read local orders before upload so orders added while the remote fetch is in flight are included ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Route incremental writes through `performBatchSetStorage` so provider order IDs containing hyphens are stored without validation failures ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Coalesce overlapping full order sync calls so only one merge/upload worker runs at a time ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Default `isRampsSyncingEnabled` to `true` when hydrating older `UserStorageController` state ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Apply last-write-wins rules to remote soft-deletes so newer local orders can restore over older tombstones ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Surface remote fetch/parse failures via `onOrderSyncErroneousSituation` without attaching raw order JSON ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Honor newer remote tombstones during full sync even when local order content differs ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Trim `providerOrderId` when deriving User Storage keys so syncability checks and storage keys stay aligned ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Clear order polling metadata for stored `providerOrderId` and internal order codes when `removeOrder` is called with either identifier ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Persist `lastUpdatedAt` on local orders and bump it on every non-sync `addOrder` so last-write-wins prefers fresher local edits over stale remote copies ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Set `isOrderSyncingInProgress` before the remote fetch so overlapping full syncs cannot race merge/upload ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Trim `providerOrderId` in `getInternalOrderCode` to match User Storage key derivation ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Prefer the freshest remote copy when the same order key appears under multiple entropy profiles during order sync ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Prefer newer remote tombstones over older live duplicates across entropy profiles ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [20.2.0] diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 525a617027c..dea921714b1 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -425,6 +425,11 @@ export type RampsControllerRefreshAutorampsAction = { /** * Bidirectionally syncs V2 ramps orders with User Storage. * Hosts should call this on unlock / when ramps syncing is enabled. + * + * Overlapping calls are coalesced into the in-flight worker. After the worker + * settles, this method loops when `#orderSyncQueued` is still set so a + * request that arrived between the worker's last loop check and promise + * resolution is not dropped. */ export type RampsControllerSyncOrdersWithUserStorageAction = { type: `RampsController:syncOrdersWithUserStorage`; diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index 0d245a0b96c..aa5476a202e 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -327,6 +327,46 @@ describe('RampsController order syncing', () => { expect(callCount).toBe(2); }); + it('restarts sync when a request arrives after the worker exits its loop', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + + let releaseFirstFetch: (() => void) | undefined; + const firstFetchBlocked = new Promise((resolve) => { + releaseFirstFetch = resolve; + }); + let firstFetchResolved: (() => void) | undefined; + const firstFetchResolvedPromise = new Promise((resolve) => { + firstFetchResolved = resolve; + }); + + let fetchCount = 0; + performGetStorageAllFeatureEntries.mockImplementation(async () => { + fetchCount += 1; + if (fetchCount === 1) { + await firstFetchBlocked; + firstFetchResolved?.(); + } + return []; + }); + + const firstSync = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + + // Unblock the in-flight fetch. The worker then exits its while-loop and + // yields; attach as a waiter in that window so the post-await re-check + // starts a follow-up sync. + releaseFirstFetch?.(); + await firstFetchResolvedPromise; + const lateSync = controller.syncOrdersWithUserStorage(); + + await Promise.all([firstSync, lateSync]); + + expect(fetchCount).toBe(2); + }); + it('preserves createdAt as lastUpdatedAt when syncing remotes without lu', () => { const { controller } = setupControllerWithOrderSyncingMocks(); @@ -374,4 +414,60 @@ describe('RampsController order syncing', () => { expect(controller.state.orders).toHaveLength(0); }); + + it('reports when addOrder cannot derive an internal order code', () => { + const onOrderSyncErroneousSituation = jest.fn(); + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => ({ + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + }), + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + async () => undefined, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + const messenger: RampsControllerMessenger = new Messenger({ + namespace: 'RampsController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger, + actions: [ + ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', + ], + }); + + const controller = new RampsController({ + messenger, + onOrderSyncErroneousSituation, + }); + + controller.addOrder({ + id: '/providers/transak/orders/', + providerOrderId: '', + } as never); + + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Unable to derive internal order code for addOrder', + {}, + ); + expect(controller.state.orders).toHaveLength(0); + }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c0bdc2edb30..f7846fb1da5 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -4,9 +4,13 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; -import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { + AuthenticationController, + UserStorageController, +} from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; @@ -38,6 +42,11 @@ import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js'; +import { + deleteOrderInRemoteStorage, + syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, + updateOrderInRemoteStorage, +} from './order-syncing/index.js'; import { buildOwnershipMessage } from './ownership-message.js'; import { mergePaymentMethodsById, @@ -792,7 +801,12 @@ type AllowedActions = | NeoBankServiceGetWalletRegistrationStatusAction | NeoBankServiceRegisterSelfHostedWalletAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction - | KeyringControllerSignPersonalMessageAction; + | KeyringControllerSignPersonalMessageAction + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction + | UserStorageController.UserStorageControllerPerformBatchSetStorageAction + | UserStorageController.UserStorageControllerListEntropySourcesAction + | AuthenticationController.AuthenticationControllerIsSignedInAction; /** * Published when the state of {@link RampsController} changes. @@ -862,6 +876,18 @@ export type RampsControllerOptions = { requestCacheTTL?: number; /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; + /** + * Optional callback for order-sync parse/fetch/merge failures (e.g. Sentry). + * Context never includes full order JSON. + */ + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; + /** + * Optional performance tracing callback used by order sync operations. + */ + trace?: TraceCallback; }; // === HELPER FUNCTIONS === @@ -940,17 +966,21 @@ export function getInternalOrderCode( orderOrId: Pick | string, ): string { if (typeof orderOrId === 'string') { - return orderOrId.includes('/orders/') - ? orderOrId.split('/orders/')[1] - : orderOrId; + if (orderOrId.includes('/orders/')) { + return orderOrId.split('/orders/')[1]?.trim() || ''; + } + return orderOrId.trim(); } const { id, providerOrderId } = orderOrId; if (id?.includes('/orders/')) { - return id.split('/orders/')[1]; + const code = id.split('/orders/')[1]?.trim(); + if (code) { + return code; + } } - return providerOrderId; + return providerOrderId?.trim() ?? ''; } // === ORDER POLLING CONSTANTS === @@ -1024,6 +1054,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'transakCancelOrder', 'transakCancelAllActiveOrders', 'transakGetActiveOrders', + 'syncOrdersWithUserStorage', ] as const; /** @@ -1083,6 +1114,14 @@ export class RampsController extends BaseController< */ readonly #requestCacheMaxSize: number; + /** + readonly #onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; + + readonly #trace?: TraceCallback; + /** * Map of pending requests for deduplication. * Key is the cache key, value is the pending request with abort controller. @@ -1109,6 +1148,57 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + /** + * Semaphore that prevents sync feedback loops while applying remote order changes. + */ + #isOrderSyncingInProgress = false; + + /** + * Orders deleted locally while a full sync held the semaphore. Drained at sync + * end so remote tombstones are still written. + */ + readonly #pendingRemoteDeletes: Map = new Map(); + + /** + * Coalesces overlapping `syncOrdersWithUserStorage` calls into a follow-up run. + */ + #orderSyncQueued = false; + + #orderSyncPromise: Promise | null = null; + + /** + * Whether a full order sync is currently applying remote changes. + * + * @returns Whether order sync is in progress. + */ + get isOrderSyncingInProgress(): boolean { + return this.#isOrderSyncingInProgress; + } + + /** + * Sets the order-syncing-in-progress semaphore. + * Used by the order-syncing module; hosts should not call this. + * + * @param value - Whether sync is in progress. + * @internal + */ + setIsOrderSyncingInProgress(value: boolean): void { + this.#isOrderSyncingInProgress = value; + } + + /** + * Returns and clears orders deleted while a full sync was in progress. + * Used by the order-syncing module. + * + * @returns Pending deletes for remote tombstone upload. + * @internal + */ + drainPendingRemoteDeletes(): RampsOrder[] { + const pending = [...this.#pendingRemoteDeletes.values()]; + this.#pendingRemoteDeletes.clear(); + return pending; + } + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -1149,12 +1239,16 @@ export class RampsController extends BaseController< * controller. Missing properties will be filled in with defaults. * @param args.requestCacheTTL - Time to live for cached requests in milliseconds. * @param args.requestCacheMaxSize - Maximum number of entries in the request cache. + * @param args.onOrderSyncErroneousSituation - Optional order-sync error reporter. + * @param args.trace - Optional performance tracing callback for order sync. */ constructor({ messenger, state = {}, requestCacheTTL = DEFAULT_REQUEST_CACHE_TTL, requestCacheMaxSize = DEFAULT_REQUEST_CACHE_MAX_SIZE, + onOrderSyncErroneousSituation, + trace, }: RampsControllerOptions) { super({ messenger, @@ -1170,6 +1264,8 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; + this.#onOrderSyncErroneousSituation = onOrderSyncErroneousSituation; + this.#trace = trace; this.messenger.registerMethodActionHandlers( this, @@ -2876,11 +2972,31 @@ export class RampsController extends BaseController< */ addOrder(order: RampsOrder): void { const internalOrderCode = getInternalOrderCode(order); + if (!internalOrderCode) { + this.#onOrderSyncErroneousSituation?.( + 'Unable to derive internal order code for addOrder', + {}, + ); + return; + } + + const incomingLastUpdatedAt = (order as { lastUpdatedAt?: number }) + .lastUpdatedAt; + // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them + // over stale remote copies when an incremental push was skipped/failed. + // During full sync, preserve remote `lu` / `createdAt` (never invent "now" + // for missing `lu`, or stale remotes win later LWW comparisons). const healedOrder = { ...order, providerOrderId: internalOrderCode, + lastUpdatedAt: this.#isOrderSyncingInProgress + ? (incomingLastUpdatedAt ?? order.createdAt ?? 0) + : Date.now(), }; + // A mid-sync add cancels a pending delete for the same key. + this.#pendingRemoteDeletes.delete(internalOrderCode); + this.update((state) => { const idx = state.orders.findIndex( (existing) => getInternalOrderCode(existing) === internalOrderCode, @@ -2894,6 +3010,15 @@ export class RampsController extends BaseController< } as Draft; } }); + + if (!this.#isOrderSyncingInProgress) { + updateOrderInRemoteStorage(healedOrder, { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }).catch((error) => { + console.error('Error updating ramps order in remote storage:', error); + }); + } } /** @@ -2902,13 +3027,96 @@ export class RampsController extends BaseController< * @param providerOrderId - The provider order ID to remove. */ removeOrder(providerOrderId: string): void { + const orderToRemove = this.state.orders.find( + (order) => + order.providerOrderId === providerOrderId || + getInternalOrderCode(order) === providerOrderId, + ); + this.update((state) => { state.orders = state.orders.filter( - (order) => order.providerOrderId !== providerOrderId, + (order) => + order.providerOrderId !== providerOrderId && + getInternalOrderCode(order) !== providerOrderId, ); }); this.#orderPollingMeta.delete(providerOrderId); + + if (orderToRemove) { + if (orderToRemove.providerOrderId) { + this.#orderPollingMeta.delete(orderToRemove.providerOrderId); + } + + const internalOrderCode = getInternalOrderCode(orderToRemove); + this.#orderPollingMeta.delete(internalOrderCode); + } + + if (orderToRemove) { + const deleteKey = getInternalOrderCode(orderToRemove); + if (this.#isOrderSyncingInProgress) { + // Incremental remote deletes are gated off during full sync; queue a + // tombstone write for the end of the sync instead. + if (deleteKey) { + this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + } + } else { + deleteOrderInRemoteStorage(orderToRemove, { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }).catch((error) => { + console.error( + 'Error deleting ramps order from remote storage:', + error, + ); + }); + } + } + } + + /** + * Bidirectionally syncs V2 ramps orders with User Storage. + * Hosts should call this on unlock / when ramps syncing is enabled. + * + * Overlapping calls are coalesced into the in-flight worker. After the worker + * settles, this method loops when `#orderSyncQueued` is still set so a + * request that arrived between the worker's last loop check and promise + * resolution is not dropped. + */ + async syncOrdersWithUserStorage(): Promise { + this.#orderSyncQueued = true; + + while (this.#orderSyncQueued || this.#orderSyncPromise) { + if (this.#orderSyncPromise) { + await this.#orderSyncPromise; + continue; + } + + this.#orderSyncPromise = (async (): Promise => { + while (this.#orderSyncQueued) { + this.#orderSyncQueued = false; + await syncOrdersWithUserStorageInternal( + { + onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + }, + { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + trace: this.#trace, + }, + ); + } + // Yield so a caller can set `#orderSyncQueued` after the inner while + // check and still be observed by the outer loop. + await Promise.resolve(); + })(); + + try { + await this.#orderSyncPromise; + } finally { + this.#orderSyncPromise = null; + } + } } /** diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 82c94970b35..7ffc3bf406b 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -47,6 +47,19 @@ function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; } +/** + * Returns the conflict-resolution timestamp for a remote entry when the same + * order key appears under multiple entropy profiles. + * + * Soft-deletes use `deletedAt`; live entries use {@link getOrderTimestamp}. + * + * @param order - A remote ramps order that may include sync metadata. + * @returns The timestamp used to pick the freshest remote copy. + */ +function getRemoteConflictTimestamp(order: SyncRampsOrder): number { + return order.deletedAt ?? getOrderTimestamp(order); +} + /** * Builds the local/remote merge plan from the current local snapshot and remote * entries. @@ -326,6 +339,9 @@ async function getRemoteOrders( return []; } + // Multi-SRP can return the same order key from more than one entropy + // profile. Keep the freshest copy so secondary-profile updates are not + // discarded just because an older primary-profile entry was parsed first. const ordersByKey = new Map(); for (const orderJson of remoteOrdersJsonArray) { @@ -350,17 +366,16 @@ async function getRemoteOrders( } const mapped = mapUserStorageEntryToRampsOrder(entry); const key = createOrderStorageKey(mapped); - if (key) { - const existing = ordersByKey.get(key); - if (existing) { - const existingTimestamp = getOrderTimestamp(existing); - const mappedTimestamp = getOrderTimestamp(mapped); - if (mappedTimestamp > existingTimestamp) { - ordersByKey.set(key, mapped); - } - } else { - ordersByKey.set(key, mapped); - } + if (!key) { + continue; + } + const existing = ordersByKey.get(key); + if ( + !existing || + getRemoteConflictTimestamp(mapped) > + getRemoteConflictTimestamp(existing) + ) { + ordersByKey.set(key, mapped); } } catch (error) { // Do not attach raw order JSON — it can include wallet/PII fields. @@ -374,7 +389,7 @@ async function getRemoteOrders( } } - return [...ordersByKey.values()]; + return Array.from(ordersByKey.values()); } catch (error) { onOrderSyncErroneousSituation?.('Failed to fetch remote ramps orders', { error, @@ -522,5 +537,6 @@ export const orderSyncingTestExports = { computeMergePlan, reconcileOrdersForRemoteUpload, getOrderTimestamp, + getRemoteConflictTimestamp, saveOrdersToUserStorage, }; From 6f1553a73ce34727f94163679ba41d926c7ae55c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 05:56:31 -0600 Subject: [PATCH 15/31] style(ramps-controller): oxfmt RampsController sync wiring Co-authored-by: Cursor --- packages/ramps-controller/src/RampsController.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f7846fb1da5..edcfbb64455 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3097,7 +3097,8 @@ export class RampsController extends BaseController< this.#orderSyncQueued = false; await syncOrdersWithUserStorageInternal( { - onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + onOrderSyncErroneousSituation: + this.#onOrderSyncErroneousSituation, }, { getRampsControllerInstance: () => this, From 67a2aa020d1455cf05379427ab6894c3f7364107 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:00:25 -0600 Subject: [PATCH 16/31] fix(ramps-controller): ignore local-only paymentDetails in areOrdersEqual Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + .../src/order-syncing/utils.test.ts | 11 ++++++++++ .../src/order-syncing/utils.ts | 22 ++++++++++++++----- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 25d74c375c4..41619b84ad9 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prefer newer remote tombstones over older live duplicates across entropy profiles ([#9474](https://github.com/MetaMask/core/pull/9474)) - Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) - Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Exclude local-only `paymentDetails` from `areOrdersEqual` so orders carrying local payment details are not re-uploaded on every sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [20.2.0] diff --git a/packages/ramps-controller/src/order-syncing/utils.test.ts b/packages/ramps-controller/src/order-syncing/utils.test.ts index 409fa1d8871..03978275425 100644 --- a/packages/ramps-controller/src/order-syncing/utils.test.ts +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -253,5 +253,16 @@ describe('order-syncing/utils', () => { const b = { providerOrderId: 'x', fiatAmount: 100 } as RampsOrder; expect(areOrdersEqual(a, b)).toBe(true); }); + + it('ignores local-only paymentDetails so remote copies compare equal', () => { + const local = { + ...createMockOrder(), + paymentDetails: [ + { label: 'IBAN', value: 'DE00 0000 0000 0000 0000 00' }, + ], + } as RampsOrder; + const remote = createMockOrder(); + expect(areOrdersEqual(local, remote)).toBe(true); + }); }); }); diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts index 4d8d8192fcc..34d15864613 100644 --- a/packages/ramps-controller/src/order-syncing/utils.ts +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -144,19 +144,31 @@ function stableStringify(value: unknown): string { } /** - * Deep-compares two ramps orders by stable JSON serialization of their order - * bodies (sync metadata excluded). + * Deep-compares two ramps orders by stable JSON serialization of their + * syncable bodies. Sync metadata (`lastUpdatedAt` / `deletedAt`) and + * local-only `paymentDetails` are excluded: `paymentDetails` is never persisted + * remotely, so a local order that carries it must still compare equal to its + * remote copy. Otherwise such orders would look changed on every sync and, with + * local-wins-on-tie conflict resolution, be re-uploaded indefinitely. * * @param a - First order. * @param b - Second order. - * @returns True when the order payloads are equal. + * @returns True when the syncable order payloads are equal. */ export function areOrdersEqual( a: SyncRampsOrder | RampsOrder, b: SyncRampsOrder | RampsOrder, ): boolean { return ( - stableStringify(stripSyncMetadata(a as SyncRampsOrder)) === - stableStringify(stripSyncMetadata(b as SyncRampsOrder)) + stableStringify( + stripPaymentDetailsForRemoteStorage( + stripSyncMetadata(a as SyncRampsOrder), + ), + ) === + stableStringify( + stripPaymentDetailsForRemoteStorage( + stripSyncMetadata(b as SyncRampsOrder), + ), + ) ); } From 59587e0f78d9209d72f4036d7fd5aa202b4b1650 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:11:23 -0600 Subject: [PATCH 17/31] refactor(ramps-controller): drop cross-SRP order aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order sync is same-SRP only (Extension ↔ Mobile). Reading every entropy profile added complexity without a product requirement. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 3 +- .../ramps-controller/src/RampsController.ts | 1 - .../controller-integration.test.ts | 120 ------------------ .../order-syncing/controller-integration.ts | 62 ++------- 4 files changed, 10 insertions(+), 176 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 41619b84ad9..c647010a6f9 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -39,11 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Persist `lastUpdatedAt` on local orders and bump it on every non-sync `addOrder` so last-write-wins prefers fresher local edits over stale remote copies ([#9474](https://github.com/MetaMask/core/pull/9474)) - Set `isOrderSyncingInProgress` before the remote fetch so overlapping full syncs cannot race merge/upload ([#9474](https://github.com/MetaMask/core/pull/9474)) - Trim `providerOrderId` in `getInternalOrderCode` to match User Storage key derivation ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Prefer the freshest remote copy when the same order key appears under multiple entropy profiles during order sync ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Prefer newer remote tombstones over older live duplicates across entropy profiles ([#9474](https://github.com/MetaMask/core/pull/9474)) - Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) - Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) - Exclude local-only `paymentDetails` from `areOrdersEqual` so orders carrying local payment details are not re-uploaded on every sync ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Scope remote order fetches to the active/primary SRP profile only; cross-SRP aggregation is out of scope for same-SRP Extension ↔ Mobile sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [20.2.0] diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index edcfbb64455..61e29128929 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -805,7 +805,6 @@ type AllowedActions = | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction - | UserStorageController.UserStorageControllerListEntropySourcesAction | AuthenticationController.AuthenticationControllerIsSignedInAction; /** diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts index 23636a5fa88..5575096b100 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -102,8 +102,6 @@ describe('order-syncing/controller-integration', () => { return { isBackupAndSyncEnabled, isRampsSyncingEnabled }; case 'AuthenticationController:isSignedIn': return isSignedIn; - case 'UserStorageController:listEntropySources': - return ['entropy-primary']; case 'UserStorageController:performGetStorageAllFeatureEntries': return performGetStorageAllFeatureEntries(...args); case 'UserStorageController:performBatchSetStorage': @@ -193,118 +191,6 @@ describe('order-syncing/controller-integration', () => { ); }); - it('keeps the freshest remote entry when duplicate storage keys are returned', async () => { - const olderRemote = createMockOrder({ - providerOrderId: 'dup-key', - id: '/providers/transak/orders/dup-key', - fiatAmount: 100, - }); - const newerRemote = createMockOrder({ - providerOrderId: 'dup-key', - id: '/providers/transak/orders/dup-key', - fiatAmount: 999, - }); - const olderEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...olderRemote, - lastUpdatedAt: 1_700_000_000_000, - }), - ); - const newerEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...newerRemote, - lastUpdatedAt: 1_700_000_000_100, - }), - ); - - const { options, addOrder } = arrangeMocks({ - localOrders: [], - remoteEntries: [olderEntry, newerEntry], - }); - - await syncOrdersWithUserStorage({}, options); - - expect(addOrder).toHaveBeenCalledTimes(1); - expect(addOrder).toHaveBeenCalledWith( - expect.objectContaining({ - providerOrderId: 'dup-key', - fiatAmount: 999, - }), - ); - }); - - it('keeps an earlier fresher remote entry over a later stale duplicate', async () => { - const newerRemote = createMockOrder({ - providerOrderId: 'dup-key-2', - id: '/providers/transak/orders/dup-key-2', - fiatAmount: 500, - }); - const olderRemote = createMockOrder({ - providerOrderId: 'dup-key-2', - id: '/providers/transak/orders/dup-key-2', - fiatAmount: 50, - }); - const newerEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...newerRemote, - lastUpdatedAt: 1_700_000_000_200, - }), - ); - const olderEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...olderRemote, - lastUpdatedAt: 1_700_000_000_000, - }), - ); - - const { options, addOrder } = arrangeMocks({ - localOrders: [], - remoteEntries: [newerEntry, olderEntry], - }); - - await syncOrdersWithUserStorage({}, options); - - expect(addOrder).toHaveBeenCalledTimes(1); - expect(addOrder).toHaveBeenCalledWith( - expect.objectContaining({ - providerOrderId: 'dup-key-2', - fiatAmount: 500, - }), - ); - }); - - it('prefers a newer remote tombstone over an older live duplicate key', async () => { - const liveRemote = createMockOrder({ - providerOrderId: 'dup-tombstone', - id: '/providers/transak/orders/dup-tombstone', - fiatAmount: 100, - }); - const tombstoneRemote: SyncRampsOrder = { - ...liveRemote, - deletedAt: 1_700_000_000_300, - lastUpdatedAt: 1_700_000_000_300, - }; - const liveEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry({ - ...liveRemote, - lastUpdatedAt: 1_700_000_000_000, - }), - ); - const tombstoneEntry = JSON.stringify( - mapRampsOrderToUserStorageEntry(tombstoneRemote), - ); - - const { options, addOrder, removeOrder } = arrangeMocks({ - localOrders: [liveRemote], - remoteEntries: [liveEntry, tombstoneEntry], - }); - - await syncOrdersWithUserStorage({}, options); - - expect(addOrder).not.toHaveBeenCalled(); - expect(removeOrder).toHaveBeenCalledWith('dup-tombstone'); - }); - it('applies remote soft-deletes locally when the tombstone is newer', async () => { const localOrder = createMockOrder(); const deletedRemote: SyncRampsOrder = { @@ -457,9 +343,6 @@ describe('order-syncing/controller-integration', () => { if (action === 'AuthenticationController:isSignedIn') { return true; } - if (action === 'UserStorageController:listEntropySources') { - return ['entropy-primary']; - } if ( action === 'UserStorageController:performGetStorageAllFeatureEntries' @@ -864,9 +747,6 @@ describe('order-syncing/controller-integration', () => { if (action === 'AuthenticationController:isSignedIn') { return true; } - if (action === 'UserStorageController:listEntropySources') { - return ['entropy-primary']; - } if ( action === 'UserStorageController:performGetStorageAllFeatureEntries' diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 7ffc3bf406b..a17ab952f7d 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -47,19 +47,6 @@ function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; } -/** - * Returns the conflict-resolution timestamp for a remote entry when the same - * order key appears under multiple entropy profiles. - * - * Soft-deletes use `deletedAt`; live entries use {@link getOrderTimestamp}. - * - * @param order - A remote ramps order that may include sync metadata. - * @returns The timestamp used to pick the freshest remote copy. - */ -function getRemoteConflictTimestamp(order: SyncRampsOrder): number { - return order.deletedAt ?? getOrderTimestamp(order); -} - /** * Builds the local/remote merge plan from the current local snapshot and remote * entries. @@ -308,41 +295,19 @@ async function getRemoteOrders( const { onOrderSyncErroneousSituation } = config; try { - // Multi-SRP: Portfolio uploads under the connected account's HD entropy - // profile. Reading only the primary SRP misses secondary-wallet orders. - let entropySourceIds: (string | undefined)[] = [undefined]; - try { - const listed = await getMessenger().call( - 'UserStorageController:listEntropySources', - ); - if (listed.length > 0) { - entropySourceIds = listed; - } - } catch { - // Wallet locked / action missing — fall back to primary (omit id). - } - - const remoteOrdersJsonArray: string[] = []; - - for (const entropySourceId of entropySourceIds) { - const entries = await getMessenger().call( + // Reads the active/primary SRP profile only. Order sync is same-SRP: hosts + // sync Extension ↔ Mobile for a given SRP, not across secondary wallets. + const remoteOrdersJsonArray = + (await getMessenger().call( 'UserStorageController:performGetStorageAllFeatureEntries', USER_STORAGE_RAMPS_ORDERS_FEATURE, - entropySourceId, - ); - if (entries?.length) { - remoteOrdersJsonArray.push(...entries); - } - } + )) ?? []; if (remoteOrdersJsonArray.length === 0) { return []; } - // Multi-SRP can return the same order key from more than one entropy - // profile. Keep the freshest copy so secondary-profile updates are not - // discarded just because an older primary-profile entry was parsed first. - const ordersByKey = new Map(); + const remoteOrders: SyncRampsOrder[] = []; for (const orderJson of remoteOrdersJsonArray) { try { @@ -365,18 +330,10 @@ async function getRemoteOrders( continue; } const mapped = mapUserStorageEntryToRampsOrder(entry); - const key = createOrderStorageKey(mapped); - if (!key) { + if (!createOrderStorageKey(mapped)) { continue; } - const existing = ordersByKey.get(key); - if ( - !existing || - getRemoteConflictTimestamp(mapped) > - getRemoteConflictTimestamp(existing) - ) { - ordersByKey.set(key, mapped); - } + remoteOrders.push(mapped); } catch (error) { // Do not attach raw order JSON — it can include wallet/PII fields. onOrderSyncErroneousSituation?.( @@ -389,7 +346,7 @@ async function getRemoteOrders( } } - return Array.from(ordersByKey.values()); + return remoteOrders; } catch (error) { onOrderSyncErroneousSituation?.('Failed to fetch remote ramps orders', { error, @@ -537,6 +494,5 @@ export const orderSyncingTestExports = { computeMergePlan, reconcileOrdersForRemoteUpload, getOrderTimestamp, - getRemoteConflictTimestamp, saveOrdersToUserStorage, }; From 832127ac8cc38192f7c84633a91afa18240dc025 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:22:16 -0600 Subject: [PATCH 18/31] refactor(ramps-controller): use fast-deep-equal for order equality Replace the custom stableStringify helper with the monorepo-standard deep-equality dependency. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + packages/ramps-controller/package.json | 3 +- .../src/order-syncing/utils.ts | 55 ++++--------------- 3 files changed, 14 insertions(+), 45 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c647010a6f9..729491dda5a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) +- Add dependency `fast-deep-equal` for order equality checks during sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ### Fixed diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 65c885df3d5..d06fc835d06 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -60,7 +60,8 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", - "@metamask/remote-feature-flag-controller": "^6.1.0" + "@metamask/remote-feature-flag-controller": "^6.1.0", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts index 34d15864613..a30d35db755 100644 --- a/packages/ramps-controller/src/order-syncing/utils.ts +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -1,3 +1,5 @@ +import deepEqual from 'fast-deep-equal'; + import type { RampsOrder } from '../RampsService.js'; import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; @@ -117,39 +119,12 @@ export function stripDeletedAt(order: SyncRampsOrder): SyncRampsOrder { } /** - * JSON-stringifies values with object keys sorted so equality is stable across - * key insertion order. - * - * @param value - Value to serialize. - * @returns Stable JSON string. - */ -function stableStringify(value: unknown): string { - return JSON.stringify(value, (_key, nested) => { - if ( - nested && - typeof nested === 'object' && - !Array.isArray(nested) && - !(nested instanceof Date) - ) { - const record = nested as Record; - return Object.keys(record) - .sort() - .reduce>((acc, key) => { - acc[key] = record[key]; - return acc; - }, {}); - } - return nested; - }); -} - -/** - * Deep-compares two ramps orders by stable JSON serialization of their - * syncable bodies. Sync metadata (`lastUpdatedAt` / `deletedAt`) and - * local-only `paymentDetails` are excluded: `paymentDetails` is never persisted - * remotely, so a local order that carries it must still compare equal to its - * remote copy. Otherwise such orders would look changed on every sync and, with - * local-wins-on-tie conflict resolution, be re-uploaded indefinitely. + * Deep-compares two ramps orders by their syncable bodies. Sync metadata + * (`lastUpdatedAt` / `deletedAt`) and local-only `paymentDetails` are excluded: + * `paymentDetails` is never persisted remotely, so a local order that carries + * it must still compare equal to its remote copy. Otherwise such orders would + * look changed on every sync and, with local-wins-on-tie conflict resolution, + * be re-uploaded indefinitely. * * @param a - First order. * @param b - Second order. @@ -159,16 +134,8 @@ export function areOrdersEqual( a: SyncRampsOrder | RampsOrder, b: SyncRampsOrder | RampsOrder, ): boolean { - return ( - stableStringify( - stripPaymentDetailsForRemoteStorage( - stripSyncMetadata(a as SyncRampsOrder), - ), - ) === - stableStringify( - stripPaymentDetailsForRemoteStorage( - stripSyncMetadata(b as SyncRampsOrder), - ), - ) + return deepEqual( + stripPaymentDetailsForRemoteStorage(stripSyncMetadata(a as SyncRampsOrder)), + stripPaymentDetailsForRemoteStorage(stripSyncMetadata(b as SyncRampsOrder)), ); } From 4374080ded3f573e5f162ccf10cfd1b63e468c24 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:29:08 -0600 Subject: [PATCH 19/31] docs(ramps-controller): consolidate order sync changelog Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 29 ++------------------------ 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 729491dda5a..45987fcdabf 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,37 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) - Add V2 ramps order syncing with User Storage ([#9474](https://github.com/MetaMask/core/pull/9474)) - - New `order-syncing/` module stores `RampsOrder` objects (without `paymentDetails`) as per-order User Storage entries under the `rampsOrders` feature - - `RampsController.syncOrdersWithUserStorage()` performs bidirectional sync with timestamp-based conflict resolution and soft deletes - - `addOrder` / `removeOrder` incrementally push local changes to User Storage when Backup & Sync and `isRampsSyncingEnabled` are on - - Messenger action `RampsController:syncOrdersWithUserStorage` for hosts to trigger a full sync on unlock / feature enable - - Optional constructor `onOrderSyncErroneousSituation` and `trace` callbacks for host observability + - Synchronize orders across clients for the same SRP using timestamp-based conflict resolution, soft deletes, and incremental updates + - Add `RampsController:syncOrdersWithUserStorage` and optional error-reporting and tracing callbacks ### Changed - **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Add dependency `fast-deep-equal` for order equality checks during sync ([#9474](https://github.com/MetaMask/core/pull/9474)) - -### Fixed - -- Preserve remote `createdAt` as `lastUpdatedAt` when importing legacy records without an update timestamp ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Queue local deletes that occur during a full sync and write their tombstones before the sync completes ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Re-read local orders before upload so orders added while the remote fetch is in flight are included ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Route incremental writes through `performBatchSetStorage` so provider order IDs containing hyphens are stored without validation failures ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Coalesce overlapping full order sync calls so only one merge/upload worker runs at a time ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Default `isRampsSyncingEnabled` to `true` when hydrating older `UserStorageController` state ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Apply last-write-wins rules to remote soft-deletes so newer local orders can restore over older tombstones ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Surface remote fetch/parse failures via `onOrderSyncErroneousSituation` without attaching raw order JSON ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Honor newer remote tombstones during full sync even when local order content differs ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Trim `providerOrderId` when deriving User Storage keys so syncability checks and storage keys stay aligned ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Clear order polling metadata for stored `providerOrderId` and internal order codes when `removeOrder` is called with either identifier ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Persist `lastUpdatedAt` on local orders and bump it on every non-sync `addOrder` so last-write-wins prefers fresher local edits over stale remote copies ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Set `isOrderSyncingInProgress` before the remote fetch so overlapping full syncs cannot race merge/upload ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Trim `providerOrderId` in `getInternalOrderCode` to match User Storage key derivation ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Re-check the sync queue after awaiting an in-flight `syncOrdersWithUserStorage` worker so coalesced requests cannot be skipped ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Report via `onOrderSyncErroneousSituation` when `addOrder` cannot derive an internal order code ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Exclude local-only `paymentDetails` from `areOrdersEqual` so orders carrying local payment details are not re-uploaded on every sync ([#9474](https://github.com/MetaMask/core/pull/9474)) -- Scope remote order fetches to the active/primary SRP profile only; cross-SRP aggregation is out of scope for same-SRP Extension ↔ Mobile sync ([#9474](https://github.com/MetaMask/core/pull/9474)) ## [20.2.0] From 7b2814dc0dee6ec42cdc52f9c9a5b53c226b58dc Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 27 Jul 2026 06:43:15 -0600 Subject: [PATCH 20/31] chore(ramps-controller): update yarn.lock for fast-deep-equal Co-authored-by: Cursor --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index d19b8a720b0..ca790deea75 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8535,6 +8535,7 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^30.4.2" nock: "npm:^13.3.1" ts-jest: "npm:^29.4.11" From 46387f8e40421086d30739642e6c39ed390b5c35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:23:02 +0000 Subject: [PATCH 21/31] fix(ramps-controller): bump lastUpdatedAt and trigger sync on polling updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes High Severity Bugbot finding: "Polling updates skip sync timestamps" Problem: When getOrder() is called via order polling, it was directly updating state without bumping lastUpdatedAt or triggering incremental sync. This caused LWW conflicts where a stale remote timestamp could overwrite newer local status changes (e.g., COMPLETED → PENDING). Solution: Refactor getOrder() to use addOrder() internally, which: - Sets lastUpdatedAt to Date.now() for local updates - Triggers incremental sync push to User Storage - Ensures polling updates participate in LWW properly Impact: All paths that call getOrder() (direct API calls, order polling via #refreshOrder, getOrderFromCallback) now correctly maintain sync timestamps and trigger incremental sync when orders are updated. Co-authored-by: George Weiler --- packages/ramps-controller/src/RampsController.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 61e29128929..2a698d505ac 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3809,20 +3809,8 @@ export class RampsController extends BaseController< providerOrderId: internalOrderCode, }; - this.update((state) => { - const idx = state.orders.findIndex( - (existing: RampsOrder) => - getInternalOrderCode(existing) === internalOrderCode, - ); - if (idx === -1) { - state.orders.push(healedOrder as Draft); - } else { - state.orders[idx] = { - ...state.orders[idx], - ...healedOrder, - } as Draft; - } - }); + // Use addOrder to ensure lastUpdatedAt is bumped and incremental sync is triggered + this.addOrder(healedOrder); return healedOrder; } From 3f9138d20c389d1435043f14175cd12ca15d732c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 11 Aug 2026 11:42:44 -0600 Subject: [PATCH 22/31] fix(ramps-controller): harden order sync mid-upload and incremental errors Queue a follow-up full sync when local orders mutate during an in-flight sync, report incremental remote write failures via onOrderSyncErroneousSituation, and type lastUpdatedAt on RampsOrder for LWW. Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 3 + packages/ramps-controller/CHANGELOG.md | 17 +++- .../src/RampsController.order-syncing.test.ts | 83 +++++++++++++++++-- .../ramps-controller/src/RampsController.ts | 53 ++++++++---- packages/ramps-controller/src/RampsService.ts | 6 ++ .../order-syncing/controller-integration.ts | 8 +- 6 files changed, 145 insertions(+), 25 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 36cbcf717de..ed32696faf8 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -10,10 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `BACKUPANDSYNC_FEATURES.rampsSyncing` and `UserStorageController.isRampsSyncingEnabled` ([#9474](https://github.com/MetaMask/core/pull/9474)) + - Defaults to enabled (`true`); hosts can toggle Buy & sell order sync independently of account/contact sync + - Disabling ramps syncing alone does not disable main Backup & Sync ### Changed - **BREAKING:** `UserStorageControllerState` now includes `isRampsSyncingEnabled`. Consumers that construct full state objects must include this field. ([#9474](https://github.com/MetaMask/core/pull/9474)) + - Prefer `isRampsSyncingEnabled ?? true` in selectors so wallets upgraded before this field existed keep ramps syncing on by default - **BREAKING:** Derive auth and user-storage message-signing keys natively via SIP-6 from HD keyring seeds instead of calling `@metamask/message-signing-snap` through `SnapController`. `AuthenticationController` and `UserStorageController` now require `KeyringController:withKeyringV2Unsafe` and no longer call `SnapController:handleRequest`. The message-signing snap remains for Portfolio / external origins ([#9824](https://github.com/MetaMask/core/pull/9824)) - Derive native SIP-6 keys with `@noble/hashes` HMAC-SHA-512 instead of Web Crypto, so auth works on React Native, whose SubtleCrypto cannot HMAC. - Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794)) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 45987fcdabf..8a2856f7520 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,12 +13,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) - Add V2 ramps order syncing with User Storage ([#9474](https://github.com/MetaMask/core/pull/9474)) - - Synchronize orders across clients for the same SRP using timestamp-based conflict resolution, soft deletes, and incremental updates - - Add `RampsController:syncOrdersWithUserStorage` and optional error-reporting and tracing callbacks + - Synchronize orders across clients for the same SRP using timestamp-based last-write-wins conflict resolution, soft-delete tombstones, and incremental add/update/delete pushes + - Feature key: `rampsOrders`; hosts call `RampsController:syncOrdersWithUserStorage` on unlock when Backup & Sync + ramps syncing are enabled + - Persist optional `lastUpdatedAt` on local `RampsOrder` entries for LWW (not returned by the V2 API) + - Strip `paymentDetails` from remote payloads (PII stays local-only) + - Soft deletes use remote tombstones; retention matches contact sync (no remote purge/compaction) + - Mid-sync local mutations coalesce into a follow-up full sync pass so uploads are not dropped during `performBatchSetStorage` + - Polling via `getOrder` → `addOrder` stamps `lastUpdatedAt` so status refreshes participate in LWW + - Optional `onOrderSyncErroneousSituation` (full sync and incremental push/delete) and `trace` callbacks +- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) ### Changed -- **BREAKING:** `RampsControllerMessenger` now requires `UserStorageController` storage actions and `AuthenticationController:isSignedIn` to be delegated so order syncing can run ([#9474](https://github.com/MetaMask/core/pull/9474)) +- **BREAKING:** `RampsControllerMessenger` now requires these actions to be delegated for order syncing ([#9474](https://github.com/MetaMask/core/pull/9474)): + - `UserStorageController:getState` + - `UserStorageController:performGetStorageAllFeatureEntries` + - `UserStorageController:performBatchSetStorage` + - `AuthenticationController:isSignedIn` ## [20.2.0] diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index aa5476a202e..52ff8e766b7 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -47,7 +47,12 @@ type RootMessenger = Messenger< MessengerEvents >; -function setupControllerWithOrderSyncingMocks(): { +function setupControllerWithOrderSyncingMocks(args?: { + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}): { controller: RampsController; performBatchSetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; @@ -94,7 +99,10 @@ function setupControllerWithOrderSyncingMocks(): { ], }); - const controller = new RampsController({ messenger }); + const controller = new RampsController({ + messenger, + onOrderSyncErroneousSituation: args?.onOrderSyncErroneousSituation, + }); return { controller, @@ -147,9 +155,10 @@ describe('RampsController order syncing', () => { ); }); - it('logs incremental addOrder remote sync failures without throwing', async () => { + it('reports incremental addOrder remote sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); const { controller, performBatchSetStorage } = - setupControllerWithOrderSyncingMocks(); + setupControllerWithOrderSyncingMocks({ onOrderSyncErroneousSituation }); const consoleErrorSpy = jest .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -166,11 +175,16 @@ describe('RampsController order syncing', () => { 'Error updating ramps order in remote storage:', expect.any(Error), ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error updating ramps order in remote storage', + expect.objectContaining({ error: expect.any(Error) }), + ); }); - it('logs incremental removeOrder remote sync failures without throwing', async () => { + it('reports incremental removeOrder remote sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); const { controller, performBatchSetStorage } = - setupControllerWithOrderSyncingMocks(); + setupControllerWithOrderSyncingMocks({ onOrderSyncErroneousSituation }); const consoleErrorSpy = jest .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -188,6 +202,10 @@ describe('RampsController order syncing', () => { 'Error deleting ramps order from remote storage:', expect.any(Error), ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error deleting ramps order from remote storage', + expect.objectContaining({ error: expect.any(Error) }), + ); }); it('skips incremental remote writes while full sync is in progress', async () => { @@ -204,6 +222,59 @@ describe('RampsController order syncing', () => { expect(performBatchSetStorage).not.toHaveBeenCalled(); }); + it('re-runs sync when local orders change during remote upload', async () => { + const { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = setupControllerWithOrderSyncingMocks(); + + controller.addOrder( + createMockOrder({ + providerOrderId: 'seed-order', + id: '/providers/transak/orders/seed-order', + }), + ); + performBatchSetStorage.mockClear(); + performGetStorageAllFeatureEntries.mockResolvedValue([]); + + let releaseFirstUpload: (() => void) | undefined; + const firstUploadGate = new Promise((resolve) => { + releaseFirstUpload = resolve; + }); + let uploadCount = 0; + + performBatchSetStorage.mockImplementation(async () => { + uploadCount += 1; + if (uploadCount === 1) { + controller.addOrder( + createMockOrder({ + providerOrderId: 'during-upload', + id: '/providers/transak/orders/during-upload', + }), + ); + await firstUploadGate; + } + }); + + const syncPromise = controller.syncOrdersWithUserStorage(); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + expect(uploadCount).toBe(1); + releaseFirstUpload?.(); + await syncPromise; + + expect(uploadCount).toBeGreaterThanOrEqual(2); + expect(performBatchSetStorage).toHaveBeenCalledWith( + 'rampsOrders', + expect.arrayContaining([ + expect.arrayContaining(['during-upload', expect.any(String)]), + ]), + ); + }); + it('queues mid-sync deletes and drains them after sync', async () => { jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_700); const { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 2a698d505ac..3c7e6190e92 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -876,8 +876,8 @@ export type RampsControllerOptions = { /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; /** - * Optional callback for order-sync parse/fetch/merge failures (e.g. Sentry). - * Context never includes full order JSON. + * Optional callback for order-sync failures (full sync parse/fetch/merge and + * incremental remote push/delete). Context never includes full order JSON. */ onOrderSyncErroneousSituation?: ( errorMessage: string, @@ -2979,8 +2979,7 @@ export class RampsController extends BaseController< return; } - const incomingLastUpdatedAt = (order as { lastUpdatedAt?: number }) - .lastUpdatedAt; + const incomingLastUpdatedAt = order.lastUpdatedAt; // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them // over stale remote copies when an incremental push was skipped/failed. // During full sync, preserve remote `lu` / `createdAt` (never invent "now" @@ -3010,12 +3009,26 @@ export class RampsController extends BaseController< } }); - if (!this.#isOrderSyncingInProgress) { - updateOrderInRemoteStorage(healedOrder, { - getRampsControllerInstance: () => this, - getMessenger: () => this.messenger, - }).catch((error) => { + if (this.#isOrderSyncingInProgress) { + // Incremental push is suppressed during full sync; queue another full + // sync pass so mutations during the upload await are not dropped. + this.#orderSyncQueued = true; + } else { + updateOrderInRemoteStorage( + healedOrder, + { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }, + { + onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + }, + ).catch((error) => { console.error('Error updating ramps order in remote storage:', error); + this.#onOrderSyncErroneousSituation?.( + 'Error updating ramps order in remote storage', + { error }, + ); }); } } @@ -3055,19 +3068,31 @@ export class RampsController extends BaseController< const deleteKey = getInternalOrderCode(orderToRemove); if (this.#isOrderSyncingInProgress) { // Incremental remote deletes are gated off during full sync; queue a - // tombstone write for the end of the sync instead. + // tombstone write and another full sync pass so deletes during the + // upload await are not dropped. + this.#orderSyncQueued = true; if (deleteKey) { this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); } } else { - deleteOrderInRemoteStorage(orderToRemove, { - getRampsControllerInstance: () => this, - getMessenger: () => this.messenger, - }).catch((error) => { + deleteOrderInRemoteStorage( + orderToRemove, + { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }, + { + onOrderSyncErroneousSituation: this.#onOrderSyncErroneousSituation, + }, + ).catch((error) => { console.error( 'Error deleting ramps order from remote storage:', error, ); + this.#onOrderSyncErroneousSituation?.( + 'Error deleting ramps order from remote storage', + { error }, + ); }); } } diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index 0c5071195cf..ab2ade4a4e1 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -648,6 +648,12 @@ export type RampsOrder = { partnerFees?: number; networkFees?: number; paymentDetails?: OrderPaymentDetail[]; + /** + * Last-updated timestamp used by User Storage order sync for LWW conflict + * resolution. Set locally by {@link RampsController}; not returned by the V2 + * ramps API. + */ + lastUpdatedAt?: number; }; /** diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index a17ab952f7d..538f62e8fb7 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -420,11 +420,13 @@ async function saveOrdersToUserStorage( * * @param order - The order that was updated locally. * @param options - Parameters used for syncing operations. + * @param config - Optional sync callbacks for error reporting. * @returns Resolves when the remote update completes or no-ops. */ export async function updateOrderInRemoteStorage( order: RampsOrder, options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, ): Promise { const { trace } = options; @@ -438,7 +440,7 @@ export async function updateOrderInRemoteStorage( lastUpdatedAt: Date.now(), }; - await saveOrdersToUserStorage([updatedEntry], options); + await saveOrdersToUserStorage([updatedEntry], options, config); }; if (trace) { @@ -462,11 +464,13 @@ export async function updateOrderInRemoteStorage( * * @param order - The order that was deleted locally (needs id / providerOrderId). * @param options - Parameters used for syncing operations. + * @param config - Optional sync callbacks for error reporting. * @returns Resolves when the remote soft-delete completes or no-ops. */ export async function deleteOrderInRemoteStorage( order: RampsOrder, options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, ): Promise { const { trace } = options; @@ -482,7 +486,7 @@ export async function deleteOrderInRemoteStorage( lastUpdatedAt: now, }; - await saveOrdersToUserStorage([deletedOrder], options); + await saveOrdersToUserStorage([deletedOrder], options, config); }; return trace From 90c589d92c73ceff5c15f0eb9aac720b0928ff15 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 11 Aug 2026 14:36:38 -0600 Subject: [PATCH 23/31] fix(ramps-controller): preserve sync delete intent Prevent sync-applied mutations from scheduling redundant passes and retain pending tombstones until their remote write succeeds. Co-authored-by: Cursor --- .../src/RampsController.order-syncing.test.ts | 35 +++++++ .../ramps-controller/src/RampsController.ts | 74 ++++++++++----- .../controller-integration.test.ts | 91 ++++++++++++++++++- .../order-syncing/controller-integration.ts | 29 ++++-- .../src/order-syncing/sync-utils.test.ts | 8 +- .../src/order-syncing/types.ts | 12 ++- 6 files changed, 210 insertions(+), 39 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index 52ff8e766b7..a6b4e3b01cc 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -126,6 +126,17 @@ describe('RampsController order syncing', () => { expect(controller.isOrderSyncingInProgress).toBe(false); }); + it('ignores acknowledgements for deletes that are not pending', () => { + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.acknowledgePendingRemoteDeletes([ + { id: '', providerOrderId: '' } as never, + createMockOrder(), + ]); + + expect(controller.getPendingRemoteDeletes()).toStrictEqual([]); + }); + it('delegates syncOrdersWithUserStorage to the order-syncing module', async () => { const { controller, performGetStorageAllFeatureEntries } = setupControllerWithOrderSyncingMocks(); @@ -137,6 +148,30 @@ describe('RampsController order syncing', () => { expect(performGetStorageAllFeatureEntries).toHaveBeenCalled(); }); + it('does not queue another sync when applying remote orders', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + const remoteOrder = createMockOrder({ + providerOrderId: 'remote-only', + id: '/providers/transak/orders/remote-only', + }); + + performGetStorageAllFeatureEntries.mockResolvedValue([ + JSON.stringify({ + v: '1', + o: remoteOrder, + lu: remoteOrder.createdAt, + }), + ]); + + await controller.syncOrdersWithUserStorage(); + + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(1); + expect(controller.state.orders).toStrictEqual([ + expect.objectContaining({ providerOrderId: 'remote-only' }), + ]); + }); + it('stamps lastUpdatedAt on local addOrder edits', () => { jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); const { controller } = setupControllerWithOrderSyncingMocks(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 3c7e6190e92..479b7a79763 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -47,6 +47,7 @@ import { syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, updateOrderInRemoteStorage, } from './order-syncing/index.js'; +import type { SyncRampsOrder } from './order-syncing/types.js'; import { buildOwnershipMessage } from './ownership-message.js'; import { mergePaymentMethodsById, @@ -529,7 +530,7 @@ export type RampsControllerState = { * The controller is the authority for V2 orders — it polls, updates, * and persists them. */ - orders: RampsOrder[]; + orders: SyncRampsOrder[]; /** * Last-seen MoonPay autoramp accounts (standing routes). MoonPay is the * source of truth; this cache is used to detect status transitions for @@ -1153,8 +1154,12 @@ export class RampsController extends BaseController< #isOrderSyncingInProgress = false; /** - * Orders deleted locally while a full sync held the semaphore. Drained at sync - * end so remote tombstones are still written. + * Whether the full sync is applying its own local state changes. + */ + #isApplyingOrderSyncChanges = false; + + /** + * Orders deleted locally while a full sync held the semaphore. */ readonly #pendingRemoteDeletes: Map = new Map(); @@ -1186,16 +1191,40 @@ export class RampsController extends BaseController< } /** - * Returns and clears orders deleted while a full sync was in progress. + * Distinguishes full sync's own state changes from external mutations. + * + * @param value - Whether sync changes are being applied. + * @internal + */ + setIsApplyingOrderSyncChanges(value: boolean): void { + this.#isApplyingOrderSyncChanges = value; + } + + /** + * Returns orders deleted while a full sync was in progress. * Used by the order-syncing module. * * @returns Pending deletes for remote tombstone upload. * @internal */ - drainPendingRemoteDeletes(): RampsOrder[] { - const pending = [...this.#pendingRemoteDeletes.values()]; - this.#pendingRemoteDeletes.clear(); - return pending; + getPendingRemoteDeletes(): RampsOrder[] { + return [...this.#pendingRemoteDeletes.values()]; + } + + /** + * Clears deletes whose tombstones were successfully persisted. A delete + * replaced while a write was in flight remains pending. + * + * @param orders - Deletes included in a successful remote write. + * @internal + */ + acknowledgePendingRemoteDeletes(orders: RampsOrder[]): void { + for (const order of orders) { + const key = getInternalOrderCode(order); + if (key && this.#pendingRemoteDeletes.get(key) === order) { + this.#pendingRemoteDeletes.delete(key); + } + } } /** @@ -2984,7 +3013,7 @@ export class RampsController extends BaseController< // over stale remote copies when an incremental push was skipped/failed. // During full sync, preserve remote `lu` / `createdAt` (never invent "now" // for missing `lu`, or stale remotes win later LWW comparisons). - const healedOrder = { + const healedOrder: SyncRampsOrder = { ...order, providerOrderId: internalOrderCode, lastUpdatedAt: this.#isOrderSyncingInProgress @@ -2992,28 +3021,29 @@ export class RampsController extends BaseController< : Date.now(), }; - // A mid-sync add cancels a pending delete for the same key. - this.#pendingRemoteDeletes.delete(internalOrderCode); + if (!this.#isApplyingOrderSyncChanges) { + this.#pendingRemoteDeletes.delete(internalOrderCode); + } this.update((state) => { const idx = state.orders.findIndex( (existing) => getInternalOrderCode(existing) === internalOrderCode, ); if (idx === -1) { - state.orders.push(healedOrder as Draft); + state.orders.push(healedOrder); } else { state.orders[idx] = { ...state.orders[idx], ...healedOrder, - } as Draft; + }; } }); - if (this.#isOrderSyncingInProgress) { + if (this.#isOrderSyncingInProgress && !this.#isApplyingOrderSyncChanges) { // Incremental push is suppressed during full sync; queue another full // sync pass so mutations during the upload await are not dropped. this.#orderSyncQueued = true; - } else { + } else if (!this.#isOrderSyncingInProgress) { updateOrderInRemoteStorage( healedOrder, { @@ -3067,12 +3097,14 @@ export class RampsController extends BaseController< if (orderToRemove) { const deleteKey = getInternalOrderCode(orderToRemove); if (this.#isOrderSyncingInProgress) { - // Incremental remote deletes are gated off during full sync; queue a - // tombstone write and another full sync pass so deletes during the - // upload await are not dropped. - this.#orderSyncQueued = true; - if (deleteKey) { - this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + if (!this.#isApplyingOrderSyncChanges) { + // Incremental remote deletes are gated off during full sync; queue a + // tombstone write and another full sync pass so deletes during the + // upload await are not dropped. + this.#orderSyncQueued = true; + if (deleteKey) { + this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + } } } else { deleteOrderInRemoteStorage( diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts index 5575096b100..6608e65a786 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -58,6 +58,7 @@ describe('order-syncing/controller-integration', () => { performSetStorage: jest.Mock; performGetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; + acknowledgePendingRemoteDeletes: jest.Mock; } => { const removeOrder = jest.fn(); const setIsOrderSyncingInProgress = jest.fn(); @@ -67,6 +68,7 @@ describe('order-syncing/controller-integration', () => { const performGetStorageAllFeatureEntries = jest .fn() .mockResolvedValue(remoteEntries); + const acknowledgePendingRemoteDeletes = jest.fn(); const controller = { state: { orders: localOrders }, @@ -74,7 +76,9 @@ describe('order-syncing/controller-integration', () => { setIsOrderSyncingInProgress, addOrder: jest.fn(), removeOrder, - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes, + setIsApplyingOrderSyncChanges: jest.fn(), }; const addOrder = jest.fn((order: RampsOrder) => { @@ -132,6 +136,7 @@ describe('order-syncing/controller-integration', () => { performSetStorage, performGetStorage, performGetStorageAllFeatureEntries, + acknowledgePendingRemoteDeletes, }; }; @@ -326,9 +331,11 @@ describe('order-syncing/controller-integration', () => { controller.state.orders = [updatedLocalOrder]; } }), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder: jest.fn(), removeOrder: jest.fn(), - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }; const messengerCall = jest @@ -605,13 +612,17 @@ describe('order-syncing/controller-integration', () => { id: '/providers/transak/orders/deleted-mid-sync', }); - const { options, performBatchSetStorage } = arrangeMocks({ + const { + options, + performBatchSetStorage, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ localOrders: [localOrder], remoteEntries: [], }); const controller = options.getRampsControllerInstance(); - (controller.drainPendingRemoteDeletes as jest.Mock).mockReturnValue([ + (controller.getPendingRemoteDeletes as jest.Mock).mockReturnValue([ deletedDuringSync, ]); @@ -632,6 +643,74 @@ describe('order-syncing/controller-integration', () => { ) as { dt?: number; o: { paymentDetails?: unknown } }; expect(tombstoneEntry.dt).toBe(1_700_000_000_500); expect(tombstoneEntry.o.paymentDetails).toBeUndefined(); + expect(acknowledgePendingRemoteDeletes).toHaveBeenCalledWith([ + deletedDuringSync, + ]); + }); + + it('keeps pending deletes authoritative over active remote orders', async () => { + const deletedDuringSync = createMockOrder({ + providerOrderId: 'deleted-mid-sync', + id: '/providers/transak/orders/deleted-mid-sync', + }); + const remoteEntry = JSON.stringify( + mapRampsOrderToUserStorageEntry({ + ...deletedDuringSync, + lastUpdatedAt: 1, + }), + ); + const { + options, + addOrder, + performBatchSetStorage, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ + localOrders: [], + remoteEntries: [remoteEntry], + }); + const controller = options.getRampsControllerInstance(); + (controller.getPendingRemoteDeletes as jest.Mock).mockReturnValue([ + deletedDuringSync, + ]); + + await syncOrdersWithUserStorage({}, options); + + expect(addOrder).not.toHaveBeenCalled(); + expect(performBatchSetStorage).toHaveBeenCalledWith( + USER_STORAGE_RAMPS_ORDERS_FEATURE, + expect.arrayContaining([ + expect.arrayContaining(['deleted-mid-sync', expect.any(String)]), + ]), + ); + expect(acknowledgePendingRemoteDeletes).toHaveBeenCalledWith([ + deletedDuringSync, + ]); + }); + + it('retains pending deletes when tombstone upload fails', async () => { + const deletedDuringSync = createMockOrder({ + providerOrderId: 'deleted-mid-sync', + id: '/providers/transak/orders/deleted-mid-sync', + }); + const { + options, + performBatchSetStorage, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ + localOrders: [], + remoteEntries: [], + }); + const controller = options.getRampsControllerInstance(); + (controller.getPendingRemoteDeletes as jest.Mock).mockReturnValue([ + deletedDuringSync, + ]); + performBatchSetStorage.mockRejectedValue(new Error('batch failed')); + + await expect(syncOrdersWithUserStorage({}, options)).rejects.toThrow( + 'batch failed', + ); + + expect(acknowledgePendingRemoteDeletes).not.toHaveBeenCalled(); }); it('reports sync failures via onOrderSyncErroneousSituation', async () => { @@ -730,9 +809,11 @@ describe('order-syncing/controller-integration', () => { controller.state.orders = [localOrder, orderAddedDuringSync]; } }), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder, removeOrder, - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }; const messengerCall = jest diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 538f62e8fb7..7555e88a0bb 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -189,6 +189,12 @@ export async function syncOrdersWithUserStorage( const performSync = async (): Promise => { const getLocalOrders = (): RampsOrder[] => controller.state.orders.filter(isSyncableOrder); + const pendingDeleteKeysBeforeApply = new Set( + controller + .getPendingRemoteDeletes() + .map((order) => createOrderStorageKey(order)) + .filter((key) => key.length > 0), + ); const { remoteOrdersMap, @@ -197,14 +203,22 @@ export async function syncOrdersWithUserStorage( ordersToUpdateRemotely, } = computeMergePlan(getLocalOrders(), validRemoteOrders); - for (const order of ordersToDeleteLocally) { - controller.removeOrder(createOrderStorageKey(order)); - } + controller.setIsApplyingOrderSyncChanges(true); + try { + for (const order of ordersToDeleteLocally) { + controller.removeOrder(createOrderStorageKey(order)); + } - for (const order of ordersToAddOrUpdateLocally) { - if (!order.deletedAt) { - controller.addOrder(stripDeletedAt(order)); + for (const order of ordersToAddOrUpdateLocally) { + if ( + !order.deletedAt && + !pendingDeleteKeysBeforeApply.has(createOrderStorageKey(order)) + ) { + controller.addOrder(stripDeletedAt(order)); + } } + } finally { + controller.setIsApplyingOrderSyncChanges(false); } const ordersToUpload = reconcileOrdersForRemoteUpload( @@ -217,7 +231,7 @@ export async function syncOrdersWithUserStorage( getLocalOrders().map((order) => createOrderStorageKey(order)), ); const pendingDeletes = controller - .drainPendingRemoteDeletes() + .getPendingRemoteDeletes() .filter((order) => { const key = createOrderStorageKey(order); return key.length > 0 && !localKeys.has(key); @@ -246,6 +260,7 @@ export async function syncOrdersWithUserStorage( if (syncedUploads.length > 0) { await saveOrdersToUserStorage(syncedUploads, options, config); + controller.acknowledgePendingRemoteDeletes(pendingDeletes); } }; diff --git a/packages/ramps-controller/src/order-syncing/sync-utils.test.ts b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts index a474767a8db..4d2783a2c7b 100644 --- a/packages/ramps-controller/src/order-syncing/sync-utils.test.ts +++ b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts @@ -29,9 +29,11 @@ describe('order-syncing/sync-utils', () => { isOrderSyncingInProgress, state: { orders: [] }, setIsOrderSyncingInProgress: jest.fn(), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder: jest.fn(), removeOrder: jest.fn(), - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }), }; @@ -71,9 +73,11 @@ describe('order-syncing/sync-utils', () => { isOrderSyncingInProgress: false, state: { orders: [] }, setIsOrderSyncingInProgress: jest.fn(), + setIsApplyingOrderSyncChanges: jest.fn(), addOrder: jest.fn(), removeOrder: jest.fn(), - drainPendingRemoteDeletes: jest.fn().mockReturnValue([]), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), }), }; diff --git a/packages/ramps-controller/src/order-syncing/types.ts b/packages/ramps-controller/src/order-syncing/types.ts index 8ff1dfccf39..4bb28e7c318 100644 --- a/packages/ramps-controller/src/order-syncing/types.ts +++ b/packages/ramps-controller/src/order-syncing/types.ts @@ -45,16 +45,20 @@ export type SyncRampsOrder = RampsOrder & { * Minimal controller surface required by order syncing. */ export type OrderSyncingController = { - state: { orders: RampsOrder[] }; + state: { orders: SyncRampsOrder[] }; readonly isOrderSyncingInProgress: boolean; setIsOrderSyncingInProgress: (value: boolean) => void; + setIsApplyingOrderSyncChanges: (value: boolean) => void; addOrder: (order: RampsOrder) => void; removeOrder: (providerOrderId: string) => void; /** - * Returns and clears orders deleted locally while a full sync held the - * semaphore, so remote tombstones can still be written at sync end. + * Returns orders deleted locally while a full sync held the semaphore. */ - drainPendingRemoteDeletes: () => RampsOrder[]; + getPendingRemoteDeletes: () => RampsOrder[]; + /** + * Clears pending deletes after their remote tombstones are persisted. + */ + acknowledgePendingRemoteDeletes: (orders: RampsOrder[]) => void; }; /** From 1be137d3b240e23f431c5e9428ab6a051180498b Mon Sep 17 00:00:00 2001 From: George Weiler Date: Tue, 11 Aug 2026 14:58:30 -0600 Subject: [PATCH 24/31] fix(ramps-controller): bump timestamp for external mid-sync edits Key lastUpdatedAt preservation on the sync-apply phase so genuine local edits during a sync (e.g. order polling) still bump to now and win the queued follow-up sync under last-write-wins. Co-authored-by: Cursor --- .../RampsController-method-action-types.ts | 30 +++++++++---------- .../src/RampsController.order-syncing.test.ts | 24 +++++++++++++++ .../ramps-controller/src/RampsController.ts | 18 ++++++----- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index dea921714b1..69004a90501 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -324,6 +324,20 @@ export type RampsControllerRemoveOrderAction = { handler: RampsController['removeOrder']; }; +/** + * Bidirectionally syncs V2 ramps orders with User Storage. + * Hosts should call this on unlock / when ramps syncing is enabled. + * + * Overlapping calls are coalesced into the in-flight worker. After the worker + * settles, this method loops when `#orderSyncQueued` is still set so a + * request that arrived between the worker's last loop check and promise + * resolution is not dropped. + */ +export type RampsControllerSyncOrdersWithUserStorageAction = { + type: `RampsController:syncOrdersWithUserStorage`; + handler: RampsController['syncOrdersWithUserStorage']; +}; + /** * Adds or updates a local autoramp last-seen cursor (e.g. after create). * @@ -422,20 +436,6 @@ export type RampsControllerRefreshAutorampsAction = { handler: RampsController['refreshAutoramps']; }; -/** - * Bidirectionally syncs V2 ramps orders with User Storage. - * Hosts should call this on unlock / when ramps syncing is enabled. - * - * Overlapping calls are coalesced into the in-flight worker. After the worker - * settles, this method loops when `#orderSyncQueued` is still set so a - * request that arrived between the worker's last loop check and promise - * resolution is not dropped. - */ -export type RampsControllerSyncOrdersWithUserStorageAction = { - type: `RampsController:syncOrdersWithUserStorage`; - handler: RampsController['syncOrdersWithUserStorage']; -}; - /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -846,6 +846,7 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerSyncOrdersWithUserStorageAction | RampsControllerAddAutorampAction | RampsControllerCreateAutorampAction | RampsControllerRegisterMoneyAccountWalletAction @@ -854,7 +855,6 @@ export type RampsControllerMethodActions = | RampsControllerApplyAutorampStatusFromPushAction | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction - | RampsControllerSyncOrdersWithUserStorageAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index a6b4e3b01cc..48173193f87 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -477,6 +477,7 @@ describe('RampsController order syncing', () => { const { controller } = setupControllerWithOrderSyncingMocks(); controller.setIsOrderSyncingInProgress(true); + controller.setIsApplyingOrderSyncChanges(true); controller.addOrder({ ...createMockOrder({ providerOrderId: 'no-lu', @@ -493,10 +494,33 @@ describe('RampsController order syncing', () => { ); }); + it('bumps lastUpdatedAt for external edits made while a sync is in progress', () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_222); + const { controller } = setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder( + createMockOrder({ + providerOrderId: 'mid-sync-edit', + id: '/providers/transak/orders/mid-sync-edit', + createdAt: 1_111, + lastUpdatedAt: 10, + }), + ); + + expect(controller.state.orders[0]).toStrictEqual( + expect.objectContaining({ + providerOrderId: 'mid-sync-edit', + lastUpdatedAt: 1_700_000_000_222, + }), + ); + }); + it('uses 0 lastUpdatedAt when syncing remotes without lu or createdAt', () => { const { controller } = setupControllerWithOrderSyncingMocks(); controller.setIsOrderSyncingInProgress(true); + controller.setIsApplyingOrderSyncChanges(true); controller.addOrder({ providerOrderId: 'no-timestamps', id: '/providers/transak/orders/no-timestamps', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 479b7a79763..d03a04f2775 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -38,16 +38,16 @@ import type { NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; -import { - PENDING_ORDER_STATUSES, - TERMINAL_ORDER_STATUSES, -} from './orderStatus.js'; import { deleteOrderInRemoteStorage, syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, updateOrderInRemoteStorage, } from './order-syncing/index.js'; import type { SyncRampsOrder } from './order-syncing/types.js'; +import { + PENDING_ORDER_STATUSES, + TERMINAL_ORDER_STATUSES, +} from './orderStatus.js'; import { buildOwnershipMessage } from './ownership-message.js'; import { mergePaymentMethodsById, @@ -1114,7 +1114,6 @@ export class RampsController extends BaseController< */ readonly #requestCacheMaxSize: number; - /** readonly #onOrderSyncErroneousSituation?: ( errorMessage: string, sentryContext?: Record, @@ -3011,12 +3010,15 @@ export class RampsController extends BaseController< const incomingLastUpdatedAt = order.lastUpdatedAt; // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them // over stale remote copies when an incremental push was skipped/failed. - // During full sync, preserve remote `lu` / `createdAt` (never invent "now" - // for missing `lu`, or stale remotes win later LWW comparisons). + // This includes external edits mid-sync (e.g. polling via `getOrder`), + // which the queued follow-up sync must not lose under LWW. + // Only when sync applies its own imported orders do we preserve the remote + // `lu` / `createdAt` (never invent "now" for missing `lu`, or stale remotes + // win later LWW comparisons). const healedOrder: SyncRampsOrder = { ...order, providerOrderId: internalOrderCode, - lastUpdatedAt: this.#isOrderSyncingInProgress + lastUpdatedAt: this.#isApplyingOrderSyncChanges ? (incomingLastUpdatedAt ?? order.createdAt ?? 0) : Date.now(), }; From 66a51f7499fdc13fc49b0d240444ebfb0adff85c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 3 Sep 2026 17:33:49 -0600 Subject: [PATCH 25/31] fix(ramps-controller): normalize persisted order timestamps Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + .../src/order-syncing/utils.test.ts | 60 +++++++++++++++++++ .../src/order-syncing/utils.ts | 33 +++++++++- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 8a2856f7520..44835afbaa6 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Soft deletes use remote tombstones; retention matches contact sync (no remote purge/compaction) - Mid-sync local mutations coalesce into a follow-up full sync pass so uploads are not dropped during `performBatchSetStorage` - Polling via `getOrder` → `addOrder` stamps `lastUpdatedAt` so status refreshes participate in LWW + - Normalize ISO and numeric-string `createdAt` values from Portfolio and older clients to epoch milliseconds - Optional `onOrderSyncErroneousSituation` (full sync and incremental push/delete) and `trace` callbacks - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) diff --git a/packages/ramps-controller/src/order-syncing/utils.test.ts b/packages/ramps-controller/src/order-syncing/utils.test.ts index 03978275425..622c0e5fbca 100644 --- a/packages/ramps-controller/src/order-syncing/utils.test.ts +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -142,6 +142,66 @@ describe('order-syncing/utils', () => { expect(mapped.lastUpdatedAt).toBeUndefined(); }); + it('normalizes persisted Portfolio timestamps', () => { + const isoEntry: UserStorageRampsOrderEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder({ + createdAt: '2026-09-03T20:53:56.153Z', + } as unknown as Partial), + }; + const numericStringEntry: UserStorageRampsOrderEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder({ + createdAt: '1788468836153', + } as unknown as Partial), + }; + + expect({ + iso: mapUserStorageEntryToRampsOrder(isoEntry).createdAt, + numericString: + mapUserStorageEntryToRampsOrder(numericStringEntry).createdAt, + }).toMatchInlineSnapshot(` + { + "iso": 1788468836153, + "numericString": 1788468836153, + } + `); + }); + + it('normalizes runtime timestamps before writing to storage', () => { + const entry = mapRampsOrderToUserStorageEntry( + createMockOrder({ + createdAt: '2026-09-03T20:53:56.153Z', + } as unknown as Partial), + ); + + expect(entry.o.createdAt).toMatchInlineSnapshot(`1788468836153`); + }); + + it('replaces invalid persisted timestamps with zero', () => { + const mapTimestamp = (createdAt: unknown): number => + mapUserStorageEntryToRampsOrder({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: createMockOrder({ + createdAt, + } as unknown as Partial), + }).createdAt; + + expect({ + nan: mapTimestamp(Number.NaN), + blank: mapTimestamp(''), + invalid: mapTimestamp('not-a-date'), + missing: mapTimestamp(undefined), + }).toMatchInlineSnapshot(` + { + "blank": 0, + "invalid": 0, + "missing": 0, + "nan": 0, + } + `); + }); + it('round-trips a full order with sync metadata', () => { const order: SyncRampsOrder = { ...createMockOrder(), diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts index a30d35db755..e40fccf8bf3 100644 --- a/packages/ramps-controller/src/order-syncing/utils.ts +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -4,6 +4,33 @@ import type { RampsOrder } from '../RampsService.js'; import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; +/** + * Converts persisted ramps timestamps to epoch milliseconds. + * + * Portfolio and older clients may persist ISO strings or numeric strings even + * though the controller's public order type uses numbers. + * + * @param value - A timestamp from a local or remote order. + * @returns Epoch milliseconds, or zero when the value is invalid. + */ +function normalizeCreatedAt(value: unknown): number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + + if (typeof value !== 'string' || value.trim() === '') { + return 0; + } + + const numericValue = Number(value); + if (Number.isFinite(numericValue)) { + return numericValue; + } + + const parsedValue = Date.parse(value); + return Number.isNaN(parsedValue) ? 0 : parsedValue; +} + /** * Creates a unique storage key for a ramps order. * Mirrors {@link getInternalOrderCode} without importing the controller module @@ -69,7 +96,10 @@ export function mapRampsOrderToUserStorageEntry( return { [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, - o: stripPaymentDetailsForRemoteStorage(rampsOrder), + o: { + ...stripPaymentDetailsForRemoteStorage(rampsOrder), + createdAt: normalizeCreatedAt(rampsOrder.createdAt), + }, lu: lastUpdatedAt ?? now, ...(deletedAt ? { dt: deletedAt } : {}), }; @@ -86,6 +116,7 @@ export function mapUserStorageEntryToRampsOrder( ): SyncRampsOrder { return { ...entry.o, + createdAt: normalizeCreatedAt(entry.o.createdAt), ...(entry.lu ? { lastUpdatedAt: entry.lu } : {}), ...(entry.dt ? { deletedAt: entry.dt } : {}), }; From 2d576f0c96f433473e0c25fdd5701639d5ef01f9 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 3 Sep 2026 19:34:04 -0600 Subject: [PATCH 26/31] fix(ramps-controller): drain queued sync after failure Co-authored-by: Cursor --- .../src/RampsController.order-syncing.test.ts | 22 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 14 +++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index 48173193f87..b9ea18fba07 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -372,6 +372,28 @@ describe('RampsController order syncing', () => { expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(2); }); + it('drains a queued local mutation after a failed sync pass', async () => { + const { controller, performGetStorageAllFeatureEntries } = + setupControllerWithOrderSyncingMocks(); + let rejectFirstFetch: (error: Error) => void = () => undefined; + performGetStorageAllFeatureEntries + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirstFetch = reject; + }), + ) + .mockResolvedValue([]); + + const syncPromise = controller.syncOrdersWithUserStorage(); + await new Promise((resolve) => setImmediate(resolve)); + controller.addOrder(createMockOrder({ providerOrderId: 'queued-order' })); + rejectFirstFetch(new Error('first sync failed')); + + await expect(syncPromise).rejects.toThrow('first sync failed'); + expect(performGetStorageAllFeatureEntries).toHaveBeenCalledTimes(2); + }); + it('handles sync requests that arrive after worker checks flag but before completion', async () => { const { controller, performGetStorageAllFeatureEntries } = setupControllerWithOrderSyncingMocks(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index d03a04f2775..61cf10181b0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3143,10 +3143,16 @@ export class RampsController extends BaseController< */ async syncOrdersWithUserStorage(): Promise { this.#orderSyncQueued = true; + let syncError: Error | undefined; while (this.#orderSyncQueued || this.#orderSyncPromise) { if (this.#orderSyncPromise) { - await this.#orderSyncPromise; + try { + await this.#orderSyncPromise; + } catch (error) { + syncError ??= + error instanceof Error ? error : new Error(String(error)); + } continue; } @@ -3172,10 +3178,16 @@ export class RampsController extends BaseController< try { await this.#orderSyncPromise; + } catch (error) { + syncError ??= error instanceof Error ? error : new Error(String(error)); } finally { this.#orderSyncPromise = null; } } + + if (syncError) { + throw syncError; + } } /** From b16c13ba3fd9235cebd0e2784883876441b51021 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 9 Sep 2026 04:56:38 -0600 Subject: [PATCH 27/31] fix(ramps-controller): harden order sync races Co-authored-by: Cursor --- .../src/RampsController.order-syncing.test.ts | 72 ++++++++++++++++--- .../src/RampsController.test.ts | 26 ++++--- .../ramps-controller/src/RampsController.ts | 39 +++++++--- .../controller-integration.test.ts | 7 ++ .../order-syncing/controller-integration.ts | 5 +- .../src/order-syncing/utils.test.ts | 9 +++ .../src/order-syncing/utils.ts | 17 +++-- 7 files changed, 144 insertions(+), 31 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index b9ea18fba07..70a28273aeb 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -8,6 +8,7 @@ import type { import type { RampsControllerMessenger } from './RampsController.js'; import { RampsController, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController.js'; import { RampsOrderStatus } from './RampsService.js'; @@ -56,11 +57,13 @@ function setupControllerWithOrderSyncingMocks(args?: { controller: RampsController; performBatchSetStorage: jest.Mock; performGetStorageAllFeatureEntries: jest.Mock; + getOrder: jest.Mock; } { const performBatchSetStorage = jest.fn().mockResolvedValue(undefined); const performGetStorageAllFeatureEntries = jest .fn() .mockResolvedValue([] as string[]); + const getOrder = jest.fn(); const rootMessenger: RootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE, @@ -82,6 +85,7 @@ function setupControllerWithOrderSyncingMocks(args?: { 'AuthenticationController:isSignedIn', () => true, ); + rootMessenger.registerActionHandler('RampsService:getOrder', getOrder); const messenger: RampsControllerMessenger = new Messenger({ namespace: 'RampsController', @@ -92,10 +96,7 @@ function setupControllerWithOrderSyncingMocks(args?: { messenger, actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, - 'UserStorageController:getState', - 'UserStorageController:performGetStorageAllFeatureEntries', - 'UserStorageController:performBatchSetStorage', - 'AuthenticationController:isSignedIn', + ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, ], }); @@ -108,6 +109,7 @@ function setupControllerWithOrderSyncingMocks(args?: { controller, performBatchSetStorage, performGetStorageAllFeatureEntries, + getOrder, }; } @@ -218,15 +220,25 @@ describe('RampsController order syncing', () => { it('reports incremental removeOrder remote sync failures via onOrderSyncErroneousSituation', async () => { const onOrderSyncErroneousSituation = jest.fn(); - const { controller, performBatchSetStorage } = - setupControllerWithOrderSyncingMocks({ onOrderSyncErroneousSituation }); + const { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = setupControllerWithOrderSyncingMocks({ + onOrderSyncErroneousSituation, + }); const consoleErrorSpy = jest .spyOn(console, 'error') .mockImplementation(() => undefined); - performBatchSetStorage.mockRejectedValue(new Error('remote delete failed')); - - controller.addOrder(createMockOrder()); + const order = createMockOrder(); + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + performBatchSetStorage.mockRejectedValueOnce( + new Error('remote delete failed'), + ); controller.removeOrder('abc-123'); await new Promise((resolve) => { @@ -241,6 +253,48 @@ describe('RampsController order syncing', () => { 'Error deleting ramps order from remote storage', expect.objectContaining({ error: expect.any(Error) }), ); + + expect(controller.getPendingRemoteDeletes()).toStrictEqual([ + expect.objectContaining({ providerOrderId: 'abc-123' }), + ]); + + performGetStorageAllFeatureEntries.mockResolvedValue([ + JSON.stringify({ v: '1', o: order, lu: order.createdAt }), + ]); + await controller.syncOrdersWithUserStorage(); + + expect(controller.state.orders).toStrictEqual([]); + expect(controller.getPendingRemoteDeletes()).toStrictEqual([]); + }); + + it('does not restore an order when its fetch finishes after removal', async () => { + const { controller, getOrder } = setupControllerWithOrderSyncingMocks(); + const order = createMockOrder(); + let resolveFetch: (fetchedOrder: RampsOrder) => void = () => undefined; + getOrder.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + controller.addOrder(order); + const fetchPromise = controller.getOrder( + 'transak', + 'abc-123', + order.walletAddress, + ); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + controller.removeOrder('abc-123'); + resolveFetch(order); + + expect(await fetchPromise).toStrictEqual( + expect.objectContaining({ providerOrderId: 'abc-123' }), + ); + expect(controller.state.orders).toStrictEqual([]); }); it('skips incremental remote writes while full sync is in progress', async () => { diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 6e207d386b0..47357384730 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -103,17 +103,27 @@ describe('RampsController', () => { }); describe('RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS', () => { - it('includes every external controller action that RampsController calls', async () => { + it('includes every external controller action that ramps order code calls', async () => { expect.hasAssertions(); - const controllerPath = path.join(__dirname, 'RampsController.ts'); - const source = await fs.promises.readFile(controllerPath, 'utf-8'); + const sourcePaths = [ + path.join(__dirname, 'RampsController.ts'), + path.join(__dirname, 'order-syncing/controller-integration.ts'), + path.join(__dirname, 'order-syncing/sync-utils.ts'), + ]; + const sources = await Promise.all( + sourcePaths.map((sourcePath) => + fs.promises.readFile(sourcePath, 'utf-8'), + ), + ); const callPattern = - /messenger\.call\s*\(\s*['"]([A-Za-z]+Controller:[^'"]+)['"]/gu; + /(?:messenger|getMessenger\(\))\.call\s*\(\s*['"]([A-Za-z]+Controller:[^'"]+)['"]/gu; const calledActions = new Set(); - let match: RegExpExecArray | null; - while ((match = callPattern.exec(source)) !== null) { - if (!match[1].startsWith('RampsController:')) { - calledActions.add(match[1]); + for (const source of sources) { + let match: RegExpExecArray | null; + while ((match = callPattern.exec(source)) !== null) { + if (!match[1].startsWith('RampsController:')) { + calledActions.add(match[1]); + } } } const requiredSet = new Set( diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 61cf10181b0..1bbbb820a3b 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -232,12 +232,17 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ * `AuthenticationController:getSessionProfile` resolves the vendor customer * identity from Profile Sync, and `KeyringController:signPersonalMessage` signs * the EIP-191 ownership proof for Money Account self-hosted wallet - * registration; both are only exercised by the autoramp paths. + * registration; both are only exercised by the autoramp paths. User Storage + * and authentication actions support cross-client order syncing. */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', + 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', 'RemoteFeatureFlagController:getState', + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', ] as const; /** @@ -3098,17 +3103,23 @@ export class RampsController extends BaseController< if (orderToRemove) { const deleteKey = getInternalOrderCode(orderToRemove); + const isLocalDeletion = !this.#isApplyingOrderSyncChanges; + + if (isLocalDeletion && deleteKey) { + // Retain the delete until a full sync confirms its tombstone was + // persisted. This prevents a failed incremental write from allowing + // the still-active remote copy to be imported again. + this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); + } + if (this.#isOrderSyncingInProgress) { - if (!this.#isApplyingOrderSyncChanges) { + if (isLocalDeletion) { // Incremental remote deletes are gated off during full sync; queue a // tombstone write and another full sync pass so deletes during the // upload await are not dropped. this.#orderSyncQueued = true; - if (deleteKey) { - this.#pendingRemoteDeletes.set(deleteKey, orderToRemove); - } } - } else { + } else if (isLocalDeletion) { deleteOrderInRemoteStorage( orderToRemove, { @@ -3862,6 +3873,9 @@ export class RampsController extends BaseController< orderCode: string, wallet: string, ): Promise { + const hadOrderAtRequestStart = this.state.orders.some( + (existingOrder) => getInternalOrderCode(existingOrder) === orderCode, + ); const order = await this.messenger.call( 'RampsService:getOrder', providerCode, @@ -3880,8 +3894,17 @@ export class RampsController extends BaseController< providerOrderId: internalOrderCode, }; - // Use addOrder to ensure lastUpdatedAt is bumped and incremental sync is triggered - this.addOrder(healedOrder); + const orderStillExists = this.state.orders.some( + (existingOrder) => + getInternalOrderCode(existingOrder) === internalOrderCode, + ); + + // A polling request can finish after removeOrder. Do not let that stale + // response recreate the local order and overwrite its remote tombstone. + if (!hadOrderAtRequestStart || orderStillExists) { + // Use addOrder to bump lastUpdatedAt and trigger incremental sync. + this.addOrder(healedOrder); + } return healedOrder; } diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts index 6608e65a786..ce356d21e50 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -1010,6 +1010,13 @@ describe('order-syncing/controller-integration', () => { }), ).toBe(10); expect(getOrderTimestamp(createMockOrder({ createdAt: 5 }))).toBe(5); + expect( + getOrderTimestamp( + createMockOrder({ + createdAt: '2026-09-03T20:53:56.153Z', + } as unknown as Partial), + ), + ).toBe(1788468836153); expect( getOrderTimestamp({ providerOrderId: 'x', diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 7555e88a0bb..5288c6326ee 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -17,6 +17,7 @@ import { isSyncableOrder, mapRampsOrderToUserStorageEntry, mapUserStorageEntryToRampsOrder, + normalizeCreatedAt, stripDeletedAt, stripSyncMetadata, } from './utils.js'; @@ -44,7 +45,9 @@ type MergePlan = { * @returns The best available last-updated timestamp. */ function getOrderTimestamp(order: RampsOrder | SyncRampsOrder): number { - return (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt ?? 0; + return normalizeCreatedAt( + (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt, + ); } /** diff --git a/packages/ramps-controller/src/order-syncing/utils.test.ts b/packages/ramps-controller/src/order-syncing/utils.test.ts index 622c0e5fbca..3082e15b3a0 100644 --- a/packages/ramps-controller/src/order-syncing/utils.test.ts +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -324,5 +324,14 @@ describe('order-syncing/utils', () => { const remote = createMockOrder(); expect(areOrdersEqual(local, remote)).toBe(true); }); + + it('treats equivalent ISO and numeric createdAt values as equal', () => { + const isoOrder = createMockOrder({ + createdAt: '2026-09-03T20:53:56.153Z', + } as unknown as Partial); + const numericOrder = createMockOrder({ createdAt: 1788468836153 }); + + expect(areOrdersEqual(isoOrder, numericOrder)).toBe(true); + }); }); }); diff --git a/packages/ramps-controller/src/order-syncing/utils.ts b/packages/ramps-controller/src/order-syncing/utils.ts index e40fccf8bf3..c4a472ee86a 100644 --- a/packages/ramps-controller/src/order-syncing/utils.ts +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -13,7 +13,7 @@ import type { SyncRampsOrder, UserStorageRampsOrderEntry } from './types.js'; * @param value - A timestamp from a local or remote order. * @returns Epoch milliseconds, or zero when the value is invalid. */ -function normalizeCreatedAt(value: unknown): number { +export function normalizeCreatedAt(value: unknown): number { if (typeof value === 'number') { return Number.isFinite(value) ? value : 0; } @@ -165,8 +165,15 @@ export function areOrdersEqual( a: SyncRampsOrder | RampsOrder, b: SyncRampsOrder | RampsOrder, ): boolean { - return deepEqual( - stripPaymentDetailsForRemoteStorage(stripSyncMetadata(a as SyncRampsOrder)), - stripPaymentDetailsForRemoteStorage(stripSyncMetadata(b as SyncRampsOrder)), - ); + const normalizeOrder = (order: SyncRampsOrder | RampsOrder): RampsOrder => { + const syncableOrder = stripPaymentDetailsForRemoteStorage( + stripSyncMetadata(order as SyncRampsOrder), + ); + return { + ...syncableOrder, + createdAt: normalizeCreatedAt(syncableOrder.createdAt), + }; + }; + + return deepEqual(normalizeOrder(a), normalizeOrder(b)); } From 4dfc75d2df1d2edfe7c9c46c1d96781be2597046 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 9 Sep 2026 12:44:05 -0600 Subject: [PATCH 28/31] fix(ramps-controller): skip User Storage writes on unchanged polls Short-circuit addOrder when the syncable payload is unchanged so polling cannot amplify writes or steal LWW. Also rename incremental helpers to *UserStorage and trim a redundant changelog bullet. Co-authored-by: Cursor --- packages/profile-sync-controller/CHANGELOG.md | 1 - packages/ramps-controller/CHANGELOG.md | 2 +- .../src/RampsController.order-syncing.test.ts | 67 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 24 +++++-- packages/ramps-controller/src/index.ts | 4 +- .../controller-integration.test.ts | 22 +++--- .../order-syncing/controller-integration.ts | 13 ++-- .../src/order-syncing/index.ts | 4 +- 8 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index f0e36cf69ed..f513e6ce1eb 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -11,7 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `BACKUPANDSYNC_FEATURES.rampsSyncing` and `UserStorageController.isRampsSyncingEnabled` ([#9474](https://github.com/MetaMask/core/pull/9474)) - Defaults to enabled (`true`); hosts can toggle Buy & sell order sync independently of account/contact sync - - Disabling ramps syncing alone does not disable main Backup & Sync ### Changed diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 6179d5492c5..2d3d774b468 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Strip `paymentDetails` from remote payloads (PII stays local-only) - Soft deletes use remote tombstones; retention matches contact sync (no remote purge/compaction) - Mid-sync local mutations coalesce into a follow-up full sync pass so uploads are not dropped during `performBatchSetStorage` - - Polling via `getOrder` → `addOrder` stamps `lastUpdatedAt` so status refreshes participate in LWW + - Polling via `getOrder` → `addOrder` stamps `lastUpdatedAt` and writes to User Storage only when the syncable payload changed - Normalize ISO and numeric-string `createdAt` values from Portfolio and older clients to epoch milliseconds - Optional `onOrderSyncErroneousSituation` (full sync and incremental push/delete) and `trace` callbacks diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index 70a28273aeb..f43320ffd9e 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -192,6 +192,73 @@ describe('RampsController order syncing', () => { ); }); + it('does not bump lastUpdatedAt or write remotely when addOrder payload is unchanged', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const order = createMockOrder({ status: RampsOrderStatus.Pending }); + + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + performBatchSetStorage.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_999); + + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + expect(controller.state.orders[0]?.lastUpdatedAt).toBe(1_700_000_000_000); + }); + + it('does not write remotely when getOrder returns an unchanged order', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const { controller, getOrder, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const order = createMockOrder({ status: RampsOrderStatus.Pending }); + + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + performBatchSetStorage.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_999); + getOrder.mockResolvedValue(order); + + await controller.getOrder('transak', 'abc-123', order.walletAddress); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + expect(controller.state.orders[0]?.lastUpdatedAt).toBe(1_700_000_000_000); + }); + + it('writes remotely once when getOrder returns a status change', async () => { + const { controller, getOrder, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const order = createMockOrder({ status: RampsOrderStatus.Pending }); + + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + performBatchSetStorage.mockClear(); + getOrder.mockResolvedValue( + createMockOrder({ status: RampsOrderStatus.Completed }), + ); + + await controller.getOrder('transak', 'abc-123', order.walletAddress); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).toHaveBeenCalledTimes(1); + }); + it('reports incremental addOrder remote sync failures via onOrderSyncErroneousSituation', async () => { const onOrderSyncErroneousSituation = jest.fn(); const { controller, performBatchSetStorage } = diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 1bbbb820a3b..56092eef346 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -39,9 +39,10 @@ import type { } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; import { - deleteOrderInRemoteStorage, + areOrdersEqual, + deleteOrderInUserStorage, syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, - updateOrderInRemoteStorage, + updateOrderInUserStorage, } from './order-syncing/index.js'; import type { SyncRampsOrder } from './order-syncing/types.js'; import { @@ -2999,6 +3000,8 @@ export class RampsController extends BaseController< * If an order with the same internal order code already exists, the incoming * fields are merged on top of the existing order so that fields not present * in the update (e.g. paymentDetails from the Transak API) are preserved. + * Unchanged syncable payloads (including unchanged poll results) are ignored + * so `lastUpdatedAt` is not bumped and User Storage is not rewritten. * * @param order - The RampsOrder to add or update. */ @@ -3012,6 +3015,18 @@ export class RampsController extends BaseController< return; } + const existing = this.state.orders.find( + (existingOrder) => + getInternalOrderCode(existingOrder) === internalOrderCode, + ); + if ( + existing && + !this.#isApplyingOrderSyncChanges && + areOrdersEqual(existing, order) + ) { + return; + } + const incomingLastUpdatedAt = order.lastUpdatedAt; // Local edits always bump lastUpdatedAt so full-sync LWW can prefer them // over stale remote copies when an incremental push was skipped/failed. @@ -3051,7 +3066,7 @@ export class RampsController extends BaseController< // sync pass so mutations during the upload await are not dropped. this.#orderSyncQueued = true; } else if (!this.#isOrderSyncingInProgress) { - updateOrderInRemoteStorage( + updateOrderInUserStorage( healedOrder, { getRampsControllerInstance: () => this, @@ -3120,7 +3135,7 @@ export class RampsController extends BaseController< this.#orderSyncQueued = true; } } else if (isLocalDeletion) { - deleteOrderInRemoteStorage( + deleteOrderInUserStorage( orderToRemove, { getRampsControllerInstance: () => this, @@ -3902,7 +3917,6 @@ export class RampsController extends BaseController< // A polling request can finish after removeOrder. Do not let that stale // response recreate the local order and overwrite its remote tombstone. if (!hadOrderAtRequestStart || orderStillExists) { - // Use addOrder to bump lastUpdatedAt and trigger incremental sync. this.addOrder(healedOrder); } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index b22d5d2c28d..6aeecbcb6cd 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -216,8 +216,8 @@ export { areOrdersEqual, canPerformOrderSyncing, syncOrdersWithUserStorage, - updateOrderInRemoteStorage, - deleteOrderInRemoteStorage, + updateOrderInUserStorage, + deleteOrderInUserStorage, } from './order-syncing/index.js'; export type { TransakServiceActions, diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts index ce356d21e50..6992184a341 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -6,10 +6,10 @@ import { USER_STORAGE_VERSION_KEY, } from './constants.js'; import { - deleteOrderInRemoteStorage, + deleteOrderInUserStorage, orderSyncingTestExports, syncOrdersWithUserStorage, - updateOrderInRemoteStorage, + updateOrderInUserStorage, } from './controller-integration.js'; import type { OrderSyncingOptions, SyncRampsOrder } from './types.js'; import { mapRampsOrderToUserStorageEntry } from './utils.js'; @@ -860,12 +860,12 @@ describe('order-syncing/controller-integration', () => { }); }); - describe('updateOrderInRemoteStorage', () => { + describe('updateOrderInUserStorage', () => { it('writes a single order entry via batch storage', async () => { const order = createMockOrder(); const { options, performBatchSetStorage } = arrangeMocks(); - await updateOrderInRemoteStorage(order, options); + await updateOrderInUserStorage(order, options); expect(performBatchSetStorage).toHaveBeenCalledWith( USER_STORAGE_RAMPS_ORDERS_FEATURE, @@ -883,7 +883,7 @@ describe('order-syncing/controller-integration', () => { const { options, performBatchSetStorage, performSetStorage } = arrangeMocks(); - await updateOrderInRemoteStorage(order, options); + await updateOrderInUserStorage(order, options); expect(performBatchSetStorage).toHaveBeenCalledWith( USER_STORAGE_RAMPS_ORDERS_FEATURE, @@ -902,7 +902,7 @@ describe('order-syncing/controller-integration', () => { isBackupAndSyncEnabled: false, }); - await updateOrderInRemoteStorage(createMockOrder(), options); + await updateOrderInUserStorage(createMockOrder(), options); expect(performBatchSetStorage).not.toHaveBeenCalled(); }); @@ -912,7 +912,7 @@ describe('order-syncing/controller-integration', () => { const order = createMockOrder(); const { options } = arrangeMocks(); - await updateOrderInRemoteStorage(order, { + await updateOrderInUserStorage(order, { ...options, trace, }); @@ -924,13 +924,13 @@ describe('order-syncing/controller-integration', () => { }); }); - describe('deleteOrderInRemoteStorage', () => { + describe('deleteOrderInUserStorage', () => { it('soft-deletes via batch storage using the local order payload', async () => { const order = createMockOrder(); const { options, performBatchSetStorage, performGetStorage } = arrangeMocks(); - await deleteOrderInRemoteStorage(order, options); + await deleteOrderInUserStorage(order, options); expect(performGetStorage).not.toHaveBeenCalled(); expect(performBatchSetStorage).toHaveBeenCalledWith( @@ -948,7 +948,7 @@ describe('order-syncing/controller-integration', () => { it('no-ops when deleting a non-syncable order', async () => { const { options, performBatchSetStorage } = arrangeMocks(); - await deleteOrderInRemoteStorage( + await deleteOrderInUserStorage( createMockOrder({ id: '', providerOrderId: '' }), options, ); @@ -961,7 +961,7 @@ describe('order-syncing/controller-integration', () => { const order = createMockOrder(); const { options } = arrangeMocks(); - await deleteOrderInRemoteStorage(order, { + await deleteOrderInUserStorage(order, { ...options, trace, }); diff --git a/packages/ramps-controller/src/order-syncing/controller-integration.ts b/packages/ramps-controller/src/order-syncing/controller-integration.ts index 5288c6326ee..9aa10279764 100644 --- a/packages/ramps-controller/src/order-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -434,14 +434,19 @@ async function saveOrdersToUserStorage( } /** - * Updates a single order in remote storage without a full sync. + * Updates a single order in User Storage without a full sync. + * + * This helper always stamps `lastUpdatedAt` and writes. Callers own change + * detection — skip this when the syncable payload is unchanged so polling + * cannot amplify User Storage writes. `RampsController.addOrder` performs + * that check via {@link areOrdersEqual}. * * @param order - The order that was updated locally. * @param options - Parameters used for syncing operations. * @param config - Optional sync callbacks for error reporting. - * @returns Resolves when the remote update completes or no-ops. + * @returns Resolves when the User Storage update completes or no-ops. */ -export async function updateOrderInRemoteStorage( +export async function updateOrderInUserStorage( order: RampsOrder, options: OrderSyncingOptions, config: SyncOrdersWithUserStorageConfig = {}, @@ -485,7 +490,7 @@ export async function updateOrderInRemoteStorage( * @param config - Optional sync callbacks for error reporting. * @returns Resolves when the remote soft-delete completes or no-ops. */ -export async function deleteOrderInRemoteStorage( +export async function deleteOrderInUserStorage( order: RampsOrder, options: OrderSyncingOptions, config: SyncOrdersWithUserStorageConfig = {}, diff --git a/packages/ramps-controller/src/order-syncing/index.ts b/packages/ramps-controller/src/order-syncing/index.ts index 238e193cce5..9f675772599 100644 --- a/packages/ramps-controller/src/order-syncing/index.ts +++ b/packages/ramps-controller/src/order-syncing/index.ts @@ -24,6 +24,6 @@ export { canPerformOrderSyncing } from './sync-utils.js'; export type { SyncOrdersWithUserStorageConfig } from './controller-integration.js'; export { syncOrdersWithUserStorage, - updateOrderInRemoteStorage, - deleteOrderInRemoteStorage, + updateOrderInUserStorage, + deleteOrderInUserStorage, } from './controller-integration.js'; From bd505ff89d191e1740f7204f0d8763e3c0ffad8f Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 9 Sep 2026 13:19:40 -0600 Subject: [PATCH 29/31] fix(ramps-controller): resolve no-shadow and regenerate action types Rename the shadowed findIndex param in addOrder and regenerate RampsController-method-action-types.ts for the updated addOrder doc comment. Co-authored-by: Cursor --- .../ramps-controller/src/RampsController-method-action-types.ts | 2 ++ packages/ramps-controller/src/RampsController.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 69004a90501..59443821394 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -306,6 +306,8 @@ export type RampsControllerGetQuotesAction = { * If an order with the same internal order code already exists, the incoming * fields are merged on top of the existing order so that fields not present * in the update (e.g. paymentDetails from the Transak API) are preserved. + * Unchanged syncable payloads (including unchanged poll results) are ignored + * so `lastUpdatedAt` is not bumped and User Storage is not rewritten. * * @param order - The RampsOrder to add or update. */ diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 56092eef346..f3ed1205fd8 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3049,7 +3049,7 @@ export class RampsController extends BaseController< this.update((state) => { const idx = state.orders.findIndex( - (existing) => getInternalOrderCode(existing) === internalOrderCode, + (stateOrder) => getInternalOrderCode(stateOrder) === internalOrderCode, ); if (idx === -1) { state.orders.push(healedOrder); From 6b4819f282e9b3bfef149e2e79ffc2e284a453c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 02:12:58 +0000 Subject: [PATCH 30/31] fix(ramps): merge paymentDetails when only local-only fields change When a poll returns an order where only paymentDetails changed (everything else is identical), the previous implementation would return early due to areOrdersEqual returning true (it strips paymentDetails for comparison). This meant paymentDetails updates from polls were dropped. Now addOrder explicitly checks if paymentDetails differ when the syncable payload is unchanged, and merges them into local state without bumping lastUpdatedAt or writing remotely (since paymentDetails is local-only). Fixes Bugbot finding: polls drop local payment details Co-authored-by: George Weiler --- .../src/RampsController.order-syncing.test.ts | 59 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 21 +++++++ 2 files changed, 80 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.order-syncing.test.ts b/packages/ramps-controller/src/RampsController.order-syncing.test.ts index f43320ffd9e..7fc23786d95 100644 --- a/packages/ramps-controller/src/RampsController.order-syncing.test.ts +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -259,6 +259,65 @@ describe('RampsController order syncing', () => { expect(performBatchSetStorage).toHaveBeenCalledTimes(1); }); + it('merges paymentDetails without bumping lastUpdatedAt or writing remotely when only paymentDetails changed', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const order = createMockOrder({ status: RampsOrderStatus.Pending }); + + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + performBatchSetStorage.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_999); + + const orderWithPaymentDetails = { + ...order, + paymentDetails: [{ method: 'bank_transfer', details: 'some-details' }], + }; + controller.addOrder(orderWithPaymentDetails); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + expect(controller.state.orders[0]?.lastUpdatedAt).toBe(1_700_000_000_000); + expect(controller.state.orders[0]?.paymentDetails).toStrictEqual([ + { method: 'bank_transfer', details: 'some-details' }, + ]); + }); + + it('merges paymentDetails from poll without remote write when only paymentDetails changed', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const { controller, getOrder, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + const order = createMockOrder({ status: RampsOrderStatus.Pending }); + + controller.addOrder(order); + await new Promise((resolve) => { + setImmediate(resolve); + }); + performBatchSetStorage.mockClear(); + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_999); + const orderWithPaymentDetails = { + ...order, + paymentDetails: [{ method: 'bank_transfer', details: 'some-details' }], + }; + getOrder.mockResolvedValue(orderWithPaymentDetails); + + await controller.getOrder('transak', 'abc-123', order.walletAddress); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(performBatchSetStorage).not.toHaveBeenCalled(); + expect(controller.state.orders[0]?.lastUpdatedAt).toBe(1_700_000_000_000); + expect(controller.state.orders[0]?.paymentDetails).toStrictEqual([ + { method: 'bank_transfer', details: 'some-details' }, + ]); + }); + it('reports incremental addOrder remote sync failures via onOrderSyncErroneousSituation', async () => { const onOrderSyncErroneousSituation = jest.fn(); const { controller, performBatchSetStorage } = diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f3ed1205fd8..931de06a048 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3024,6 +3024,27 @@ export class RampsController extends BaseController< !this.#isApplyingOrderSyncChanges && areOrdersEqual(existing, order) ) { + // Syncable payload unchanged; check if paymentDetails differ. + // paymentDetails is local-only (never synced remotely), so if it's the + // only change we merge it without bumping lastUpdatedAt or writing remotely. + if ( + order.paymentDetails && + JSON.stringify(existing.paymentDetails) !== + JSON.stringify(order.paymentDetails) + ) { + this.update((state) => { + const idx = state.orders.findIndex( + (stateOrder) => + getInternalOrderCode(stateOrder) === internalOrderCode, + ); + if (idx !== -1) { + state.orders[idx] = { + ...state.orders[idx], + paymentDetails: order.paymentDetails, + }; + } + }); + } return; } From 1bcfd7d8c86c16ac83630c6f51efe9133bd827b4 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 9 Sep 2026 20:53:50 -0600 Subject: [PATCH 31/31] fix: include isRampsSyncingEnabled in full-state test literals Keep UserStorageControllerState constructions complete, and drop a duplicated #9679 changelog entry that already lives under 20.1.0. Co-authored-by: Cursor --- .../src/AccountTreeController.test.ts | 1 + .../controllers/user-storage/UserStorageController.test.ts | 6 ++---- packages/ramps-controller/CHANGELOG.md | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index f8b8fbf4ec1..f57e6da8a35 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -5680,6 +5680,7 @@ describe('AccountTreeController', () => { isBackupAndSyncUpdateLoading: false, isContactSyncingEnabled: false, isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, }, [], ); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index c863c2cd673..8158d25bf51 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -604,11 +604,10 @@ describe('UserStorageController', () => { const controller = new UserStorageController({ messenger: messengerMocks.messenger, state: { + ...defaultState, isBackupAndSyncEnabled: false, - isBackupAndSyncUpdateLoading: false, isAccountSyncingEnabled: false, isContactSyncingEnabled: false, - isContactSyncingInProgress: false, }, }); @@ -629,11 +628,10 @@ describe('UserStorageController', () => { const controller = new UserStorageController({ messenger: messengerMocks.messenger, state: { + ...defaultState, isBackupAndSyncEnabled: false, - isBackupAndSyncUpdateLoading: false, isAccountSyncingEnabled: false, isContactSyncingEnabled: false, - isContactSyncingInProgress: false, }, }); diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 94f365244c0..40ea7921fd1 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -49,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) -- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) ### Changed