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/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 135f518ba50..97f7bca798a 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,16 @@ 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)) + - Defaults to enabled (`true`); hosts can toggle Buy & sell order sync independently of account/contact 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 + ## [31.0.0] ### Changed 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..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, }, }); @@ -663,6 +661,7 @@ describe('UserStorageController', () => { isAccountSyncingEnabled: true, isContactSyncingEnabled: true, isContactSyncingInProgress: false, + isRampsSyncingEnabled: true, }, }); @@ -674,6 +673,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 +941,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -937,6 +962,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -953,6 +979,7 @@ describe('metadata', () => { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, + "isRampsSyncingEnabled": true, } `); }); @@ -975,6 +1002,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/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index bd334ba5bcd..40ea7921fd1 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,27 @@ 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)) + - 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` 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 + +### Changed + +- **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` + ## [21.0.0] ### Changed diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index c653c2dea15..52b22d869ac 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -55,7 +55,8 @@ "@metamask/controller-utils": "^13.0.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^31.0.0", - "@metamask/remote-feature-flag-controller": "^7.0.0" + "@metamask/remote-feature-flag-controller": "^7.0.0", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 8661f19c705..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. */ @@ -324,6 +326,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). * @@ -832,6 +848,7 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerSyncOrdersWithUserStorageAction | RampsControllerAddAutorampAction | RampsControllerCreateAutorampAction | RampsControllerRegisterMoneyAccountWalletAction 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..7fc23786d95 --- /dev/null +++ b/packages/ramps-controller/src/RampsController.order-syncing.test.ts @@ -0,0 +1,805 @@ +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_CONTROLLER_ACTIONS, + 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(args?: { + onOrderSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}): { + 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, + }); + + rootMessenger.registerActionHandler('UserStorageController:getState', () => ({ + isBackupAndSyncEnabled: true, + isRampsSyncingEnabled: true, + })); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + performGetStorageAllFeatureEntries, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + performBatchSetStorage, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler('RampsService:getOrder', getOrder); + + const messenger: RampsControllerMessenger = new Messenger({ + namespace: 'RampsController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger, + actions: [ + ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, + ], + }); + + const controller = new RampsController({ + messenger, + onOrderSyncErroneousSituation: args?.onOrderSyncErroneousSituation, + }); + + return { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + getOrder, + }; +} + +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('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(); + + performGetStorageAllFeatureEntries.mockResolvedValue([]); + + await controller.syncOrdersWithUserStorage(); + + 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(); + + 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('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('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 } = + setupControllerWithOrderSyncingMocks({ onOrderSyncErroneousSituation }); + 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), + ); + expect(onOrderSyncErroneousSituation).toHaveBeenCalledWith( + 'Error updating ramps order in remote storage', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('reports incremental removeOrder remote sync failures via onOrderSyncErroneousSituation', async () => { + const onOrderSyncErroneousSituation = jest.fn(); + const { + controller, + performBatchSetStorage, + performGetStorageAllFeatureEntries, + } = setupControllerWithOrderSyncingMocks({ + onOrderSyncErroneousSituation, + }); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + 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) => { + setImmediate(resolve); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '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) }), + ); + + 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 () => { + const { controller, performBatchSetStorage } = + setupControllerWithOrderSyncingMocks(); + + controller.setIsOrderSyncingInProgress(true); + controller.addOrder(createMockOrder()); + + await new Promise((resolve) => { + setImmediate(resolve); + }); + + 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 { + 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('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(); + + 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('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(); + + controller.setIsOrderSyncingInProgress(true); + controller.setIsApplyingOrderSyncChanges(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('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', + } 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); + }); + + 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.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0bd71a83b39..47357384730 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'; @@ -102,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( @@ -10180,9 +10191,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 +10310,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 +12826,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/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c0bdc2edb30..931de06a048 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'; @@ -34,6 +38,13 @@ import type { NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; +import { + areOrdersEqual, + deleteOrderInUserStorage, + syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, + updateOrderInUserStorage, +} from './order-syncing/index.js'; +import type { SyncRampsOrder } from './order-syncing/types.js'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, @@ -222,12 +233,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; /** @@ -520,7 +536,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 @@ -792,7 +808,11 @@ type AllowedActions = | NeoBankServiceGetWalletRegistrationStatusAction | NeoBankServiceRegisterSelfHostedWalletAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction - | KeyringControllerSignPersonalMessageAction; + | KeyringControllerSignPersonalMessageAction + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction + | UserStorageController.UserStorageControllerPerformBatchSetStorageAction + | AuthenticationController.AuthenticationControllerIsSignedInAction; /** * Published when the state of {@link RampsController} changes. @@ -862,6 +882,18 @@ export type RampsControllerOptions = { requestCacheTTL?: number; /** Maximum number of entries in the request cache. Defaults to 250. */ requestCacheMaxSize?: number; + /** + * 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, + sentryContext?: Record, + ) => void; + /** + * Optional performance tracing callback used by order sync operations. + */ + trace?: TraceCallback; }; // === HELPER FUNCTIONS === @@ -940,17 +972,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 +1060,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'transakCancelOrder', 'transakCancelAllActiveOrders', 'transakGetActiveOrders', + 'syncOrdersWithUserStorage', ] as const; /** @@ -1083,6 +1120,13 @@ 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 +1153,85 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + /** + * Semaphore that prevents sync feedback loops while applying remote order changes. + */ + #isOrderSyncingInProgress = false; + + /** + * 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(); + + /** + * 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; + } + + /** + * 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 + */ + 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); + } + } + } + /** * 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 +1272,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 +1297,8 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; + this.#onOrderSyncErroneousSituation = onOrderSyncErroneousSituation; + this.#trace = trace; this.messenger.registerMethodActionHandlers( this, @@ -2871,29 +3000,110 @@ 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. */ addOrder(order: RampsOrder): void { const internalOrderCode = getInternalOrderCode(order); - const healedOrder = { + if (!internalOrderCode) { + this.#onOrderSyncErroneousSituation?.( + 'Unable to derive internal order code for addOrder', + {}, + ); + return; + } + + const existing = this.state.orders.find( + (existingOrder) => + getInternalOrderCode(existingOrder) === internalOrderCode, + ); + if ( + existing && + !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; + } + + 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. + // 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.#isApplyingOrderSyncChanges + ? (incomingLastUpdatedAt ?? order.createdAt ?? 0) + : Date.now(), }; + if (!this.#isApplyingOrderSyncChanges) { + this.#pendingRemoteDeletes.delete(internalOrderCode); + } + this.update((state) => { const idx = state.orders.findIndex( - (existing) => getInternalOrderCode(existing) === internalOrderCode, + (stateOrder) => getInternalOrderCode(stateOrder) === 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 && !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 if (!this.#isOrderSyncingInProgress) { + updateOrderInUserStorage( + 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 }, + ); + }); + } } /** @@ -2902,13 +3112,129 @@ 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); + 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 (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; + } + } else if (isLocalDeletion) { + deleteOrderInUserStorage( + 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 }, + ); + }); + } + } + } + + /** + * 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; + let syncError: Error | undefined; + + while (this.#orderSyncQueued || this.#orderSyncPromise) { + if (this.#orderSyncPromise) { + try { + await this.#orderSyncPromise; + } catch (error) { + syncError ??= + error instanceof Error ? error : new Error(String(error)); + } + 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; + } catch (error) { + syncError ??= error instanceof Error ? error : new Error(String(error)); + } finally { + this.#orderSyncPromise = null; + } + } + + if (syncError) { + throw syncError; + } } /** @@ -3583,6 +3909,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, @@ -3601,20 +3930,16 @@ 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; - } - }); + 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) { + this.addOrder(healedOrder); + } return healedOrder; } 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/index.ts b/packages/ramps-controller/src/index.ts index b52fbe4eb8c..6aeecbcb6cd 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, + updateOrderInUserStorage, + deleteOrderInUserStorage, +} 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..6992184a341 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/controller-integration.test.ts @@ -0,0 +1,1165 @@ +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 { + deleteOrderInUserStorage, + orderSyncingTestExports, + syncOrdersWithUserStorage, + updateOrderInUserStorage, +} 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; + acknowledgePendingRemoteDeletes: 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 acknowledgePendingRemoteDeletes = jest.fn(); + + const controller = { + state: { orders: localOrders }, + isOrderSyncingInProgress: false, + setIsOrderSyncingInProgress, + addOrder: jest.fn(), + removeOrder, + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes, + setIsApplyingOrderSyncChanges: jest.fn(), + }; + + 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: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, + acknowledgePendingRemoteDeletes, + }; + }; + + 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('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]; + } + }), + setIsApplyingOrderSyncChanges: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), + }; + + 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: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, + acknowledgePendingRemoteDeletes, + } = arrangeMocks({ + localOrders: [localOrder], + remoteEntries: [], + }); + + const controller = options.getRampsControllerInstance(); + (controller.getPendingRemoteDeletes 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(); + 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 () => { + 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]; + } + }), + setIsApplyingOrderSyncChanges: jest.fn(), + addOrder, + removeOrder, + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), + }; + + 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: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('updateOrderInUserStorage', () => { + it('writes a single order entry via batch storage', async () => { + const order = createMockOrder(); + const { options, performBatchSetStorage } = arrangeMocks(); + + await updateOrderInUserStorage(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 updateOrderInUserStorage(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 updateOrderInUserStorage(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 updateOrderInUserStorage(order, { + ...options, + trace, + }); + + expect(trace).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Ramps Order Sync Update Remote' }), + expect.any(Function), + ); + }); + }); + + describe('deleteOrderInUserStorage', () => { + it('soft-deletes via batch storage using the local order payload', async () => { + const order = createMockOrder(); + const { options, performBatchSetStorage, performGetStorage } = + arrangeMocks(); + + await deleteOrderInUserStorage(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 deleteOrderInUserStorage( + 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 deleteOrderInUserStorage(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( + createMockOrder({ + createdAt: '2026-09-03T20:53:56.153Z', + } as unknown as Partial), + ), + ).toBe(1788468836153); + 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..9aa10279764 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/controller-integration.ts @@ -0,0 +1,525 @@ +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, + normalizeCreatedAt, + 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 normalizeCreatedAt( + (order as SyncRampsOrder).lastUpdatedAt ?? order.createdAt, + ); +} + +/** + * 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 pendingDeleteKeysBeforeApply = new Set( + controller + .getPendingRemoteDeletes() + .map((order) => createOrderStorageKey(order)) + .filter((key) => key.length > 0), + ); + + const { + remoteOrdersMap, + ordersToAddOrUpdateLocally, + ordersToDeleteLocally, + ordersToUpdateRemotely, + } = computeMergePlan(getLocalOrders(), validRemoteOrders); + + controller.setIsApplyingOrderSyncChanges(true); + try { + for (const order of ordersToDeleteLocally) { + controller.removeOrder(createOrderStorageKey(order)); + } + + for (const order of ordersToAddOrUpdateLocally) { + if ( + !order.deletedAt && + !pendingDeleteKeysBeforeApply.has(createOrderStorageKey(order)) + ) { + controller.addOrder(stripDeletedAt(order)); + } + } + } finally { + controller.setIsApplyingOrderSyncChanges(false); + } + + const ordersToUpload = reconcileOrdersForRemoteUpload( + ordersToUpdateRemotely, + remoteOrdersMap, + getLocalOrders(), + ); + + const localKeys = new Set( + getLocalOrders().map((order) => createOrderStorageKey(order)), + ); + const pendingDeletes = controller + .getPendingRemoteDeletes() + .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); + controller.acknowledgePendingRemoteDeletes(pendingDeletes); + } + }; + + 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 { + // 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, + )) ?? []; + + if (remoteOrdersJsonArray.length === 0) { + return []; + } + + const remoteOrders: SyncRampsOrder[] = []; + + 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); + if (!createOrderStorageKey(mapped)) { + continue; + } + remoteOrders.push(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 remoteOrders; + } 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 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 User Storage update completes or no-ops. + */ +export async function updateOrderInUserStorage( + order: RampsOrder, + options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, +): 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, config); + }; + + 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. + * @param config - Optional sync callbacks for error reporting. + * @returns Resolves when the remote soft-delete completes or no-ops. + */ +export async function deleteOrderInUserStorage( + order: RampsOrder, + options: OrderSyncingOptions, + config: SyncOrdersWithUserStorageConfig = {}, +): 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, config); + }; + + 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..9f675772599 --- /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, + updateOrderInUserStorage, + deleteOrderInUserStorage, +} 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..4d2783a2c7b --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/sync-utils.test.ts @@ -0,0 +1,87 @@ +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(), + setIsApplyingOrderSyncChanges: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), + }), + }; + + 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(), + setIsApplyingOrderSyncChanges: jest.fn(), + addOrder: jest.fn(), + removeOrder: jest.fn(), + getPendingRemoteDeletes: jest.fn().mockReturnValue([]), + acknowledgePendingRemoteDeletes: jest.fn(), + }), + }; + + 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..4bb28e7c318 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/types.ts @@ -0,0 +1,71 @@ +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: SyncRampsOrder[] }; + readonly isOrderSyncingInProgress: boolean; + setIsOrderSyncingInProgress: (value: boolean) => void; + setIsApplyingOrderSyncChanges: (value: boolean) => void; + addOrder: (order: RampsOrder) => void; + removeOrder: (providerOrderId: string) => void; + /** + * Returns orders deleted locally while a full sync held the semaphore. + */ + getPendingRemoteDeletes: () => RampsOrder[]; + /** + * Clears pending deletes after their remote tombstones are persisted. + */ + acknowledgePendingRemoteDeletes: (orders: RampsOrder[]) => void; +}; + +/** + * 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..3082e15b3a0 --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/utils.test.ts @@ -0,0 +1,337 @@ +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('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(), + 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); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000000..c4a472ee86a --- /dev/null +++ b/packages/ramps-controller/src/order-syncing/utils.ts @@ -0,0 +1,179 @@ +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'; + +/** + * 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. + */ +export 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 + * (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), + createdAt: normalizeCreatedAt(rampsOrder.createdAt), + }, + 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, + createdAt: normalizeCreatedAt(entry.o.createdAt), + ...(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; +} + +/** + * 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. + * @returns True when the syncable order payloads are equal. + */ +export function areOrdersEqual( + a: SyncRampsOrder | RampsOrder, + b: SyncRampsOrder | RampsOrder, +): boolean { + 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)); +} diff --git a/yarn.lock b/yarn.lock index 5b161c73a9c..40efc924f01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8710,6 +8710,7 @@ __metadata: "@types/jest": "npm:^30.0.0" "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.2.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^30.4.2" nock: "npm:^13.3.1" rimraf: "npm:^5.0.5"