diff --git a/packages/profile-sync-controller/src/shared/storage-schema.ts b/packages/profile-sync-controller/src/shared/storage-schema.ts index dc9f73fcfb..e8e74f363e 100644 --- a/packages/profile-sync-controller/src/shared/storage-schema.ts +++ b/packages/profile-sync-controller/src/shared/storage-schema.ts @@ -13,6 +13,7 @@ export const USER_STORAGE_FEATURE_NAMES = { notifications: 'notifications', accounts: 'accounts_v2', addressBook: 'addressBook', + rampsAutoramps: 'rampsAutoramps', }; export type UserStorageGenericFeatureName = string; diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts new file mode 100644 index 0000000000..48dcc1839b --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -0,0 +1,23 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NeoBankService } from './NeoBankService.js'; + +/** + * Fetches an autoramp account via the Ramp API proxy of + * MoonPay `GET /api/autoramps/{autoramp_id}`. + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ +export type NeoBankServiceGetAutorampAction = { + type: `NeoBankService:getAutoramp`; + handler: NeoBankService['getAutoramp']; +}; + +/** + * Union of all NeoBankService action types. + */ +export type NeoBankServiceMethodActions = NeoBankServiceGetAutorampAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts new file mode 100644 index 0000000000..a0765b0002 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -0,0 +1,80 @@ +import nock from 'nock'; + +import { + mapNeoBankAutorampToRemoteSnapshot, + NeoBankService, +} from './NeoBankService.js'; +import type { NeoBankServiceMessenger } from './NeoBankService.js'; +import { RampsEnvironment } from './RampsService.js'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; + +describe('NeoBankService', () => { + describe('mapNeoBankAutorampToRemoteSnapshot', () => { + it('maps MoonPay-shaped fields into a remote snapshot', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + wallet_address: '0xabc', + deposit_rails: [{ type: 'Iban' }], + }), + ).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: 'Approved', + depositRailsSummary: { ready: true }, + }); + }); + }); + + describe('getAutoramp', () => { + it('GETs the proxied autoramp endpoint with bearer auth', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'test-token', + ); + + const messenger = new Messenger({ + namespace: 'NeoBankService', + parent: rootMessenger, + }) as unknown as NeoBankServiceMessenger; + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + }); + + const scope = nock('https://on-ramp.uat-api.cx.metamask.io') + .get(/\/api\/v2\/autoramps\/ar-1/u) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + wallet_address: '0xabc', + }); + + const service = new NeoBankService({ + messenger, + environment: RampsEnvironment.Staging, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + }); + + const snapshot = await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshot).toMatchObject({ + id: 'ar-1', + customerId: 'cust-1', + status: 'Authorized', + walletAddress: '0xabc', + }); + }); + }); +}); diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts new file mode 100644 index 0000000000..ca8f5dd7b0 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -0,0 +1,246 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; + +import packageJson from '../package.json'; +import type { + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; +import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; + +/** + * Name of the NeoBankService messenger namespace. + */ +export const serviceName = 'NeoBankService'; + +/** + * Raw autoramp payload from the MetaMask Ramp API neo-bank proxy. + * Shape mirrors MoonPay Enterprise `GET /api/autoramps/{autoramp_id}`. + * The Ramp API handles partner auth / headers; the client only sends the + * MetaMask bearer token. + */ +export type NeoBankAutorampResponse = { + id: string; + customer_id: string; + status: string; + /** + * Destination wallet when present on the proxy response. + * Field name may evolve with the Ramp API contract. + */ + wallet_address?: string; + recipient_account?: { + address?: string; + }; + deposit_rails?: unknown[]; +}; + +const MESSENGER_EXPOSED_METHODS = ['getAutoramp'] as const; + +/** + * Actions that {@link NeoBankService} exposes to other consumers. + */ +export type NeoBankServiceActions = NeoBankServiceMethodActions; + +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +export type NeoBankServiceEvents = never; + +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link NeoBankService}. + */ +export type NeoBankServiceMessenger = Messenger< + typeof serviceName, + NeoBankServiceActions | AllowedActions, + NeoBankServiceEvents | AllowedEvents +>; + +/** + * Builds an `/api/v2/...` path for the Ramp API neo-bank proxy. + * + * @param path - Path under the versioned API root (no leading slash). + * @param version - API version segment. + * @returns Versioned API path. + */ +function getApiPath(path: string, version: string = 'v2'): string { + return `api/${version}/${path.replace(/^\//u, '')}`; +} + +/** + * Resolves the Ramp API host for neo-bank calls (same hosts as {@link RampsService}). + * + * @param environment - Ramp environment. + * @returns Base URL. + */ +function getBaseUrl(environment: RampsEnvironment): string { + switch (environment) { + case RampsEnvironment.Production: + return 'https://on-ramp.api.cx.metamask.io'; + case RampsEnvironment.Staging: + return 'https://on-ramp.uat-api.cx.metamask.io'; + case RampsEnvironment.Development: + return 'https://on-ramp.dev-api.cx.metamask.io'; + case RampsEnvironment.Local: + return 'http://localhost:3000'; + default: + throw new Error(`Invalid environment: ${String(environment)}`); + } +} + +/** + * Maps a Ramp API / MoonPay-shaped autoramp response into the local remote snapshot. + * + * @param response - Proxy response body. + * @returns Snapshot consumed by {@link applyAutorampRemoteStatus}. + */ +export function mapNeoBankAutorampToRemoteSnapshot( + response: NeoBankAutorampResponse, +): AutorampRemoteSnapshot { + const depositRails = response.deposit_rails; + const hasDepositRails = Array.isArray(depositRails) && depositRails.length > 0; + const depositRailsSummary: AutorampDepositRailsSummary | undefined = + hasDepositRails || response.status === 'Approved' + ? { + ready: response.status === 'Approved' && hasDepositRails, + } + : undefined; + + return { + id: response.id, + customerId: response.customer_id, + walletAddress: + response.wallet_address ?? response.recipient_account?.address, + status: response.status, + depositRailsSummary, + }; +} + +/** + * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). + * + * Lives alongside {@link RampsService} and {@link TransakService}. Authentication + * and MoonPay partner headers are handled by the Ramp API — this service only + * attaches the MetaMask user bearer token. + */ +export class NeoBankService { + readonly name: typeof serviceName; + + readonly #messenger: NeoBankServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #policy: ServicePolicy; + + readonly #environment: RampsEnvironment; + + readonly #context: string; + + readonly #baseUrlOverride?: string; + + constructor({ + messenger, + environment = RampsEnvironment.Staging, + context, + fetch: fetchFunction, + policyOptions = {}, + baseUrlOverride, + }: { + messenger: NeoBankServiceMessenger; + environment?: RampsEnvironment; + context: string; + fetch: typeof fetch; + policyOptions?: CreateServicePolicyOptions; + baseUrlOverride?: string; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#policy = createServicePolicy(policyOptions); + this.#environment = environment; + this.#context = context; + this.#baseUrlOverride = baseUrlOverride; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + #getBaseUrl(): string { + if (this.#baseUrlOverride) { + return this.#baseUrlOverride; + } + return getBaseUrl(this.#environment); + } + + async #getRequestHeaders(): Promise> { + const bearerToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + ); + return { + Authorization: `Bearer ${bearerToken}`, + }; + } + + /** + * Fetches an autoramp account via the Ramp API proxy of + * MoonPay `GET /api/autoramps/{autoramp_id}`. + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ + async getAutoramp(autorampId: string): Promise { + const url = new URL( + getApiPath(`autoramps/${encodeURIComponent(autorampId)}`), + this.#getBaseUrl(), + ); + url.searchParams.set('sdk', RAMPS_SDK_VERSION); + url.searchParams.set('controller', packageJson.version); + url.searchParams.set('context', this.#context); + + const response = await this.#policy.execute(async () => { + const headers = await this.#getRequestHeaders(); + const fetchResponse = await this.#fetch(url, { headers }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; + }); + + if (!response || typeof response !== 'object' || !response.id) { + throw new Error('Malformed response received from neo-bank autoramp API'); + } + + return mapNeoBankAutorampToRemoteSnapshot(response); + } + + onRetry( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onRetry(listener); + } + + onBreak( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onBreak(listener); + } + + onDegraded( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onDegraded(listener); + } +} diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 3898e4fea1..4fa4d1f19e 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -280,6 +280,85 @@ export type RampsControllerRemoveOrderAction = { handler: RampsController['removeOrder']; }; +/** + * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`). + * When Backup & Sync is available, also pushes an incremental User Storage update + * unless a full sync is applying remote changes. + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ +export type RampsControllerAddAutorampAction = { + type: `RampsController:addAutoramp`; + handler: RampsController['addAutoramp']; +}; + +/** + * Removes a local autoramp account by id. + * Soft-deletes the remote User Storage entry when sync is available. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerRemoveAutorampAction = { + type: `RampsController:removeAutoramp`; + handler: RampsController['removeAutoramp']; +}; + +/** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerMarkAutorampAsNotifiedAction = { + type: `RampsController:markAutorampAsNotified`; + handler: RampsController['markAutorampAsNotified']; +}; + +/** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * Uses the same compare helper as refresh-on-load. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ +export type RampsControllerApplyAutorampStatusFromPushAction = { + type: `RampsController:applyAutorampStatusFromPush`; + handler: RampsController['applyAutorampStatusFromPush']; +}; + +/** + * Fetches one autoramp from the Ramp API neo-bank proxy and applies it. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ +export type RampsControllerRefreshAutorampAction = { + type: `RampsController:refreshAutoramp`; + handler: RampsController['refreshAutoramp']; +}; + +/** + * Refreshes all known local autoramps from remote. + * Intended for app load / unlock catch-up when websockets were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ +export type RampsControllerRefreshAutorampsAction = { + type: `RampsController:refreshAutoramps`; + handler: RampsController['refreshAutoramps']; +}; + +/** + * Bidirectional sync of autoramp accounts with MetaMask User Storage + * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail. + * + * @param config - Optional error callbacks for Sentry / logging. + */ +export type RampsControllerSyncAutorampsWithUserStorageAction = { + type: `RampsController:syncAutorampsWithUserStorage`; + handler: RampsController['syncAutorampsWithUserStorage']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -689,6 +768,13 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerAddAutorampAction + | RampsControllerRemoveAutorampAction + | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerApplyAutorampStatusFromPushAction + | RampsControllerRefreshAutorampAction + | RampsControllerRefreshAutorampsAction + | RampsControllerSyncAutorampsWithUserStorageAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 54dca251d3..e71ecf6b9d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { AutorampStatus } from './autorampAccount.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -22,6 +23,7 @@ import { RampsController, getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; import type { @@ -77,12 +79,12 @@ describe('RampsController', () => { 'Execution prevented because the circuit breaker is open'; describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { - it('includes every RampsService action that RampsController calls', async () => { + it('includes every RampsService, TransakService, and NeoBankService action that RampsController calls', async () => { expect.hasAssertions(); const controllerPath = path.join(__dirname, 'RampsController.ts'); const source = await fs.promises.readFile(controllerPath, 'utf-8'); const callPattern = - /messenger\.call\s*\(\s*['"]((RampsService|TransakService):[^'"]+)['"]/gu; + /messenger\.call\s*\(\s*['"]((RampsService|TransakService|NeoBankService):[^'"]+)['"]/gu; const calledActions = new Set(); let match: RegExpExecArray | null; while ((match = callPattern.exec(source)) !== null) { @@ -103,6 +105,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -179,6 +182,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2198,6 +2202,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2264,6 +2269,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2306,6 +2312,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2324,6 +2331,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -8935,6 +8943,195 @@ describe('RampsController', () => { }); }); + describe('autoramps', () => { + it('adds and removes autoramp accounts', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(controller.state.autoramps[0]?.id).toBe('ar-1'); + expect(controller.state.autoramps[0]?.status).toBe( + AutorampStatus.Authorized, + ); + + controller.removeAutoramp('ar-1'); + expect(controller.state.autoramps).toHaveLength(0); + }); + }); + + it('applies push snapshots and publishes notable transitions', async () => { + await withController(async ({ controller, messenger }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const events: unknown[] = []; + messenger.subscribe( + 'RampsController:autorampStatusChanged', + (payload) => { + events.push(payload); + }, + ); + + const updated = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + expect(updated.status).toBe(AutorampStatus.Approved); + expect(updated.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + shouldNotify: true, + }); + }); + }); + + it('refreshes autoramps via NeoBankService', async () => { + await withController(async ({ controller, rootMessenger }) => { + const getAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + getAutoramp, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramp('ar-1'); + expect(getAutoramp).toHaveBeenCalledWith('ar-1'); + expect(updated.status).toBe(AutorampStatus.Approved); + + await controller.refreshAutoramps(); + expect(getAutoramp).toHaveBeenCalledTimes(2); + }); + }); + + it('skips failed refreshes when refreshing all autoramps', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + async (id: string) => { + if (id === 'ar-bad') { + throw new Error('network'); + } + return { + id, + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }; + }, + ); + + controller.addAutoramp({ + id: 'ar-bad', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + controller.addAutoramp({ + id: 'ar-good', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramps(); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe('ar-good'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-bad')?.status, + ).toBe(AutorampStatus.Authorized); + }); + }); + + it('syncs autoramps with user storage when gates pass', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockResolvedValue(undefined); + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => + ({ + isBackupAndSyncEnabled: true, + }) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + batchSet, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + // Allow any incremental push from addAutoramp to settle, then full sync. + await Promise.resolve(); + batchSet.mockClear(); + + await controller.syncAutorampsWithUserStorage(); + + expect(batchSet).toHaveBeenCalled(); + const [, entries] = batchSet.mock.calls[0] as [ + string, + [string, string][], + ]; + expect(entries[0]?.[0]).toBe('ar-1'); + expect(JSON.parse(entries[0]?.[1] ?? '{}').o.id).toBe('ar-1'); + }); + }); + + it('marks autoramp as notified', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + expect(controller.state.autoramps[0]?.notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); + }); + describe('addOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', @@ -11835,6 +12032,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { messenger, actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + ...RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, 'RemoteFeatureFlagController:getState', ], }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index aac160d882..e3d41c7185 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,6 +15,25 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { + AutorampAccount, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import { + applyAutorampRemoteStatus, + createAutorampAccount, + markAutorampNotified, +} from './autorampAccount.js'; +import { + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage as syncAutorampsWithUserStorageInternal, + updateAutorampInRemoteStorage, +} from './autoramp-syncing/index.js'; +import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js'; +import type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +import type { NeoBankServiceActions } from './NeoBankService.js'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { UserStorageController } from '@metamask/profile-sync-controller'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, @@ -134,6 +153,7 @@ export const controllerName = 'RampsController'; export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] + | NeoBankServiceActions['type'] )[] = [ 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', @@ -170,8 +190,20 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:cancelOrder', 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', + 'NeoBankService:getAutoramp', ]; +/** + * User Storage / auth actions needed for autoramp Backup & Sync. + * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these. + */ +export const RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS = [ + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', +] as const; + /** * Default TTL for quotes requests (15 seconds). * Quotes are time-sensitive and should have a shorter cache duration. @@ -387,6 +419,12 @@ export type RampsControllerState = { * and persists them. */ orders: RampsOrder[]; + /** + * MoonPay Enterprise autoramp accounts (standing routes), separate from + * {@link RampsOrder} payment instances. Refreshed from remote on load / + * push; persisted for rediscovery and transition UX. + */ + autoramps: AutorampAccount[]; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -448,6 +486,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + autoramps: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -514,6 +558,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, }, orders: [], + autoramps: [], providerAutoSelected: false, }; } @@ -638,7 +683,12 @@ type AllowedActions = | TransakServiceGetIdProofStatusAction | TransakServiceCancelOrderAction | TransakServiceCancelAllActiveOrdersAction - | TransakServiceGetActiveOrdersAction; + | TransakServiceGetActiveOrdersAction + | NeoBankServiceGetAutorampAction + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction + | UserStorageController.UserStorageControllerPerformBatchSetStorageAction + | AuthenticationController.AuthenticationControllerIsSignedInAction; /** * Published when the state of {@link RampsController} changes. @@ -657,12 +707,28 @@ export type RampsControllerOrderStatusChangedEvent = { payload: [{ order: RampsOrder; previousStatus: RampsOrderStatus }]; }; +/** + * Published when an autoramp account status transitions to a notable state + * that the UI has not yet notified for (e.g. Approved / Rejected). + */ +export type RampsControllerAutorampStatusChangedEvent = { + type: `${typeof controllerName}:autorampStatusChanged`; + payload: [ + { + autoramp: AutorampAccount; + previousStatus: AutorampAccount['status']; + shouldNotify: boolean; + }, + ]; +}; + /** * Events that {@link RampsControllerMessenger} exposes to other consumers. */ export type RampsControllerEvents = | RampsControllerStateChangeEvent - | RampsControllerOrderStatusChangedEvent; + | RampsControllerOrderStatusChangedEvent + | RampsControllerAutorampStatusChangedEvent; /** * Events from other messengers that {@link RampsController} subscribes to. @@ -811,6 +877,13 @@ const MESSENGER_EXPOSED_METHODS = [ 'getQuotes', 'addOrder', 'removeOrder', + 'addAutoramp', + 'removeAutoramp', + 'markAutorampAsNotified', + 'applyAutorampStatusFromPush', + 'refreshAutoramp', + 'refreshAutoramps', + 'syncAutorampsWithUserStorage', 'startOrderPolling', 'stopOrderPolling', 'getBuyWidgetData', @@ -890,6 +963,12 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + #isAutorampSyncingInProgress = false; + + #isApplyingAutorampSyncChanges = false; + + #pendingRemoteAutorampDeletes: AutorampAccount[] = []; + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -2437,6 +2516,290 @@ export class RampsController extends BaseController< this.#orderPollingMeta.delete(providerOrderId); } + // === AUTORAMP ACCOUNT MANAGEMENT === + + /** + * Whether a full autoramp User Storage sync is currently running. + */ + get isAutorampSyncingInProgress(): boolean { + return this.#isAutorampSyncingInProgress; + } + + /** + * Sets the autoramp sync semaphore (used by autoramp-syncing module). + * + * @param value - Whether sync is in progress. + */ + setIsAutorampSyncingInProgress(value: boolean): void { + this.#isAutorampSyncingInProgress = value; + } + + /** + * Sets whether local mutations are applying remote sync results + * (suppresses incremental remote pushes). + * + * @param value - Whether sync changes are being applied locally. + */ + setIsApplyingAutorampSyncChanges(value: boolean): void { + this.#isApplyingAutorampSyncChanges = value; + } + + /** + * Returns autoramps deleted locally while a full sync held the semaphore. + * + * @returns Pending remote delete queue. + */ + getPendingRemoteAutorampDeletes(): AutorampAccount[] { + return [...this.#pendingRemoteAutorampDeletes]; + } + + /** + * Clears acknowledged pending remote deletes after tombstones are written. + * + * @param accounts - Accounts whose remote tombstones were persisted. + */ + acknowledgePendingRemoteAutorampDeletes(accounts: AutorampAccount[]): void { + if (accounts.length === 0) { + return; + } + const keys = new Set(accounts.map((account) => account.id)); + this.#pendingRemoteAutorampDeletes = + this.#pendingRemoteAutorampDeletes.filter( + (account) => !keys.has(account.id), + ); + } + + #getAutorampSyncingOptions() { + return { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }; + } + + /** + * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`). + * When Backup & Sync is available, also pushes an incremental User Storage update + * unless a full sync is applying remote changes. + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ + addAutoramp( + accountOrInput: + | AutorampAccount + | { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampAccount['status'] | string; + }, + ): AutorampAccount { + const account = + 'updatedAt' in accountOrInput && 'lastSeenStatus' in accountOrInput + ? (accountOrInput as AutorampAccount) + : createAutorampAccount(accountOrInput); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (existing) => existing.id === account.id, + ); + if (idx === -1) { + state.autoramps.push(account as Draft); + } else { + state.autoramps[idx] = { + ...state.autoramps[idx], + ...account, + } as Draft; + } + }); + + const upserted = + this.state.autoramps.find((existing) => existing.id === account.id) ?? + account; + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + upserted, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + return upserted; + } + + /** + * Removes a local autoramp account by id. + * Soft-deletes the remote User Storage entry when sync is available. + * + * @param autorampId - MoonPay autoramp id. + */ + removeAutoramp(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + + this.update((state) => { + state.autoramps = state.autoramps.filter( + (autoramp) => autoramp.id !== autorampId, + ); + }); + + if (!existing || this.#isApplyingAutorampSyncChanges) { + return; + } + + if (this.#isAutorampSyncingInProgress) { + this.#pendingRemoteAutorampDeletes.push(existing); + return; + } + + deleteAutorampInRemoteStorage( + existing, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + /** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ + markAutorampAsNotified(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + if (!existing) { + return; + } + const notified = markAutorampNotified(existing); + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === autorampId, + ); + if (idx !== -1) { + state.autoramps[idx] = notified as Draft; + } + }); + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + notified, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + } + + /** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * Uses the same compare helper as refresh-on-load. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ + applyAutorampStatusFromPush( + remote: AutorampRemoteSnapshot, + ): AutorampAccount { + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Fetches one autoramp from the Ramp API neo-bank proxy and applies it. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ + async refreshAutoramp(autorampId: string): Promise { + const remote = await this.messenger.call( + 'NeoBankService:getAutoramp', + autorampId, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Refreshes all known local autoramps from remote. + * Intended for app load / unlock catch-up when websockets were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ + async refreshAutoramps(): Promise { + const ids = this.state.autoramps.map((autoramp) => autoramp.id); + const updated: AutorampAccount[] = []; + + for (const id of ids) { + try { + updated.push(await this.refreshAutoramp(id)); + } catch { + // Keep local state for this id; continue remaining refreshes. + } + } + + return updated; + } + + /** + * Bidirectional sync of autoramp accounts with MetaMask User Storage + * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail. + * + * @param config - Optional error callbacks for Sentry / logging. + */ + async syncAutorampsWithUserStorage( + config: SyncAutorampsWithUserStorageConfig = {}, + ): Promise { + await syncAutorampsWithUserStorageInternal( + config, + this.#getAutorampSyncingOptions(), + ); + } + + #applyAutorampRemoteSnapshot(remote: AutorampRemoteSnapshot): AutorampAccount { + const local = + this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? null; + const result = applyAutorampRemoteStatus(local, remote); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === result.account.id, + ); + if (idx === -1) { + state.autoramps.push(result.account as Draft); + } else { + state.autoramps[idx] = result.account as Draft; + } + }); + + if (result.statusChanged) { + this.messenger.publish('RampsController:autorampStatusChanged', { + autoramp: result.account, + previousStatus: result.previousStatus, + shouldNotify: result.shouldNotify, + }); + } + + const upserted = + this.state.autoramps.find( + (autoramp) => autoramp.id === result.account.id, + ) ?? result.account; + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + upserted, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + return upserted; + } + /** * Refreshes a single order via the V2 API and updates it in state. * Publishes orderStatusChanged if the status transitioned. diff --git a/packages/ramps-controller/src/autoramp-syncing/constants.ts b/packages/ramps-controller/src/autoramp-syncing/constants.ts new file mode 100644 index 0000000000..c69c57ea62 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/constants.ts @@ -0,0 +1,25 @@ +/** + * User Storage feature key for MoonPay Enterprise autoramp accounts. + * Each autoramp is stored as a separate entry under this feature. + */ +export const USER_STORAGE_RAMPS_AUTORAMPS_FEATURE = 'rampsAutoramps'; + +/** + * Key for version in User Storage schema. + */ +export const USER_STORAGE_VERSION_KEY = 'v'; + +/** + * Current version of the autoramp User Storage schema. + */ +export const USER_STORAGE_VERSION = '1'; + +/** + * Trace names for autoramp syncing operations. + */ +export const TraceName = { + AutorampSyncFull: 'Ramps Autoramp Sync Full', + AutorampSyncSaveBatch: 'Ramps Autoramp Sync Save Batch', + AutorampSyncUpdateRemote: 'Ramps Autoramp Sync Update Remote', + AutorampSyncDeleteRemote: 'Ramps Autoramp Sync Delete Remote', +} as const; diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts new file mode 100644 index 0000000000..3834b2c5cf --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts @@ -0,0 +1,429 @@ +import { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +import { + areAutorampsEqual, + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, +} from './format-utils.js'; +import { canPerformAutorampSyncing } from './sync-utils.js'; +import type { + AutorampSyncingOptions, + SyncAutorampAccount, + SyncAutorampsWithUserStorageConfig, + UserStorageAutorampEntry, +} from './types.js'; + +function getAutorampTimestamp(account: SyncAutorampAccount): number { + return account.updatedAt ?? 0; +} + +/** + * Builds the local/remote merge plan for autoramp sync. + * + * @param localAccounts - Syncable local accounts. + * @param validRemoteAccounts - Syncable remote accounts. + * @returns Local mutations and remote uploads to apply. + */ +export function computeAutorampMergePlan( + localAccounts: SyncAutorampAccount[], + validRemoteAccounts: SyncAutorampAccount[], +): { + remoteAccountsMap: Map; + accountsToAddOrUpdateLocally: SyncAutorampAccount[]; + accountsToDeleteLocally: SyncAutorampAccount[]; + accountsToUpdateRemotely: SyncAutorampAccount[]; +} { + const localAccountsMap = new Map(); + const remoteAccountsMap = new Map(); + + localAccounts.forEach((account) => { + localAccountsMap.set(createAutorampStorageKey(account), account); + }); + validRemoteAccounts.forEach((account) => { + remoteAccountsMap.set(createAutorampStorageKey(account), account); + }); + + const accountsToAddOrUpdateLocally: SyncAutorampAccount[] = []; + const accountsToDeleteLocally: SyncAutorampAccount[] = []; + const accountsToUpdateRemotely: SyncAutorampAccount[] = []; + + for (const remoteAccount of validRemoteAccounts) { + const key = createAutorampStorageKey(remoteAccount); + const localAccount = localAccountsMap.get(key); + + if (remoteAccount.deletedAt) { + if (localAccount) { + const localTimestamp = getAutorampTimestamp(localAccount); + if (localTimestamp > remoteAccount.deletedAt) { + accountsToUpdateRemotely.push(localAccount); + } else { + accountsToDeleteLocally.push(remoteAccount); + } + } + } else if (!localAccount) { + accountsToAddOrUpdateLocally.push(remoteAccount); + } else if (!areAutorampsEqual(localAccount, remoteAccount)) { + const localTimestamp = getAutorampTimestamp(localAccount); + const remoteTimestamp = getAutorampTimestamp(remoteAccount); + if (localTimestamp >= remoteTimestamp) { + accountsToUpdateRemotely.push(localAccount); + } else { + accountsToAddOrUpdateLocally.push(remoteAccount); + } + } + } + + for (const localAccount of localAccounts) { + const key = createAutorampStorageKey(localAccount); + if (!remoteAccountsMap.has(key)) { + accountsToUpdateRemotely.push(localAccount); + } + } + + return { + remoteAccountsMap, + accountsToAddOrUpdateLocally, + accountsToDeleteLocally, + accountsToUpdateRemotely, + }; +} + +async function getRemoteAutoramps( + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig, +): Promise { + const { getMessenger } = options; + const { onAutorampSyncErroneousSituation } = config; + + const remoteJsonArray = + (await getMessenger().call( + 'UserStorageController:performGetStorageAllFeatureEntries', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + )) ?? []; + + if (remoteJsonArray.length === 0) { + return []; + } + + const remoteAccounts: SyncAutorampAccount[] = []; + for (const entryJson of remoteJsonArray) { + try { + const entry = JSON.parse(entryJson) as UserStorageAutorampEntry; + if (entry[USER_STORAGE_VERSION_KEY] !== USER_STORAGE_VERSION) { + onAutorampSyncErroneousSituation?.( + 'Unsupported autoramp storage version', + { + version: entry[USER_STORAGE_VERSION_KEY], + expectedVersion: USER_STORAGE_VERSION, + }, + ); + continue; + } + if (!entry.o || typeof entry.o !== 'object') { + onAutorampSyncErroneousSituation?.( + 'Remote autoramp entry missing payload', + {}, + ); + continue; + } + const mapped = mapUserStorageEntryToAutoramp(entry); + if (!createAutorampStorageKey(mapped)) { + continue; + } + remoteAccounts.push(mapped); + } catch (error) { + onAutorampSyncErroneousSituation?.( + 'Failed to parse remote autoramp entry', + { error, entryLength: entryJson.length }, + ); + } + } + + return remoteAccounts; +} + +async function saveAutorampsToUserStorage( + accounts: SyncAutorampAccount[], + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { getMessenger, trace } = options; + const { onAutorampSyncErroneousSituation } = config; + + const save = async () => { + const storageEntries: [string, string][] = []; + for (const account of accounts) { + const key = createAutorampStorageKey(account); + if (!key) { + onAutorampSyncErroneousSituation?.( + 'Skipping autoramp remote write with empty storage key', + { hasId: Boolean(account.id) }, + ); + continue; + } + storageEntries.push([ + key, + JSON.stringify(mapAutorampToUserStorageEntry(account)), + ]); + } + if (storageEntries.length === 0) { + return; + } + await getMessenger().call( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + storageEntries, + ); + }; + + if (trace) { + await trace( + { + name: TraceName.AutorampSyncSaveBatch, + data: { autorampCount: accounts.length }, + }, + save, + ); + return; + } + await save(); +} + +/** + * Syncs autoramp accounts between local controller state and User Storage. + * + * @param config - Optional error callbacks. + * @param options - Sync options (controller + messenger). + */ +export async function syncAutorampsWithUserStorage( + config: SyncAutorampsWithUserStorageConfig, + options: AutorampSyncingOptions, +): Promise { + const { getRampsControllerInstance, trace } = options; + const { onAutorampSyncErroneousSituation } = config; + + if (!canPerformAutorampSyncing(options)) { + return; + } + + const controller = getRampsControllerInstance(); + controller.setIsAutorampSyncingInProgress(true); + + try { + const validRemoteAccounts = (await getRemoteAutoramps(options, config)).filter( + (account: SyncAutorampAccount) => + Boolean(account.deletedAt) || isSyncableAutoramp(account), + ); + + const performSync = async () => { + const getLocalAccounts = () => + controller.state.autoramps.filter(isSyncableAutoramp); + + const pendingDeleteKeysBeforeApply = new Set( + controller + .getPendingRemoteAutorampDeletes() + .map((account) => createAutorampStorageKey(account)) + .filter((key) => key.length > 0), + ); + + const { + remoteAccountsMap, + accountsToAddOrUpdateLocally, + accountsToDeleteLocally, + accountsToUpdateRemotely, + } = computeAutorampMergePlan(getLocalAccounts(), validRemoteAccounts); + + controller.setIsApplyingAutorampSyncChanges(true); + try { + for (const account of accountsToDeleteLocally) { + controller.removeAutoramp(createAutorampStorageKey(account)); + } + for (const account of accountsToAddOrUpdateLocally) { + if ( + !account.deletedAt && + !pendingDeleteKeysBeforeApply.has(createAutorampStorageKey(account)) + ) { + controller.addAutoramp(stripAutorampSyncMetadata(account)); + } + } + } finally { + controller.setIsApplyingAutorampSyncChanges(false); + } + + const localKeys = new Set( + getLocalAccounts().map((account) => createAutorampStorageKey(account)), + ); + const pendingDeletes = controller + .getPendingRemoteAutorampDeletes() + .filter((account) => { + const key = createAutorampStorageKey(account); + return key.length > 0 && !localKeys.has(key); + }); + const pendingDeleteKeys = new Set( + pendingDeletes.map((account) => createAutorampStorageKey(account)), + ); + + const now = Date.now(); + const uploads: SyncAutorampAccount[] = [ + ...accountsToUpdateRemotely + .filter( + (account) => + !pendingDeleteKeys.has(createAutorampStorageKey(account)), + ) + .map((account) => ({ + ...account, + updatedAt: account.updatedAt || now, + })), + // Local-only accounts already included via merge plan; also upload + // accounts present locally that differ after apply. + ...getLocalAccounts() + .filter((account) => { + const key = createAutorampStorageKey(account); + if (pendingDeleteKeys.has(key)) { + return false; + } + const remote = remoteAccountsMap.get(key); + return !remote || !areAutorampsEqual(account, remote); + }) + .filter( + (account) => + !accountsToUpdateRemotely.some( + (planned) => + createAutorampStorageKey(planned) === + createAutorampStorageKey(account), + ), + ) + .map((account) => ({ + ...account, + updatedAt: account.updatedAt || now, + })), + ...pendingDeletes.map((account) => ({ + ...account, + deletedAt: now, + updatedAt: now, + })), + ]; + + // Dedupe by key, prefer later entries + const uploadMap = new Map(); + for (const account of uploads) { + uploadMap.set(createAutorampStorageKey(account), account); + } + + if (uploadMap.size > 0) { + await saveAutorampsToUserStorage( + [...uploadMap.values()], + options, + config, + ); + controller.acknowledgePendingRemoteAutorampDeletes(pendingDeletes); + } + }; + + if (trace) { + await trace( + { + name: TraceName.AutorampSyncFull, + data: { + localAutorampCount: controller.state.autoramps.filter( + isSyncableAutoramp, + ).length, + remoteAutorampCount: validRemoteAccounts.length, + }, + }, + performSync, + ); + return; + } + + await performSync(); + } catch (error) { + onAutorampSyncErroneousSituation?.('Error synchronizing autoramps', { + error, + }); + throw error; + } finally { + controller.setIsAutorampSyncingInProgress(false); + } +} + +/** + * Updates a single autoramp in remote storage without a full sync. + * + * @param account - Local autoramp that changed. + * @param options - Sync options. + * @param config - Optional error callbacks. + */ +export async function updateAutorampInRemoteStorage( + account: SyncAutorampAccount, + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { trace } = options; + + const update = async () => { + if ( + !canPerformAutorampSyncing(options) || + !isSyncableAutoramp(account) + ) { + return; + } + await saveAutorampsToUserStorage( + [{ ...account, updatedAt: Date.now() }], + options, + config, + ); + }; + + if (trace) { + await trace({ name: TraceName.AutorampSyncUpdateRemote }, update); + return; + } + await update(); +} + +/** + * Soft-deletes an autoramp in remote storage. + * + * @param account - Autoramp to tombstone remotely. + * @param options - Sync options. + * @param config - Optional error callbacks. + */ +export async function deleteAutorampInRemoteStorage( + account: SyncAutorampAccount, + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { trace } = options; + + const remove = async () => { + if (!canPerformAutorampSyncing(options) || !account.id) { + return; + } + const now = Date.now(); + await saveAutorampsToUserStorage( + [ + { + ...account, + deletedAt: now, + updatedAt: now, + }, + ], + options, + config, + ); + }; + + if (trace) { + await trace({ name: TraceName.AutorampSyncDeleteRemote }, remove); + return; + } + await remove(); +} diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts new file mode 100644 index 0000000000..4b00ece435 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts @@ -0,0 +1,76 @@ +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + areAutorampsEqual, + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, +} from './format-utils.js'; + +describe('autoramp-syncing/format-utils', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + updatedAt: 1000, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + it('creates storage keys from id', () => { + expect(createAutorampStorageKey(account)).toBe('ar-1'); + expect(createAutorampStorageKey('ar-2')).toBe('ar-2'); + }); + + it('detects syncable autoramps', () => { + expect(isSyncableAutoramp(account)).toBe(true); + expect(isSyncableAutoramp({ id: '' })).toBe(false); + expect(isSyncableAutoramp(null)).toBe(false); + }); + + it('maps to user storage without deposit rails', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + notifiedForStatus: AutorampStatus.Approved, + }); + + expect(entry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + notifiedForStatus: AutorampStatus.Approved, + }, + lu: 1000, + }); + expect(entry.o).not.toHaveProperty('depositRailsSummary'); + }); + + it('round-trips storage entries and strips deletedAt', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + deletedAt: 2000, + }); + const mapped = mapUserStorageEntryToAutoramp(entry); + expect(mapped.deletedAt).toBe(2000); + expect(stripAutorampSyncMetadata(mapped)).not.toHaveProperty('deletedAt'); + }); + + it('compares sync-relevant fields', () => { + expect(areAutorampsEqual(account, { ...account })).toBe(true); + expect( + areAutorampsEqual(account, { + ...account, + status: AutorampStatus.Authorized, + }), + ).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts new file mode 100644 index 0000000000..81669481ac --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts @@ -0,0 +1,127 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { normalizeAutorampStatus } from '../autorampAccount.js'; +import { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import type { + SyncAutorampAccount, + UserStorageAutorampEntry, +} from './types.js'; + +/** + * Storage key for an autoramp entry (MoonPay autoramp id). + * + * @param account - Autoramp account or id-bearing object. + * @returns Storage key string. + */ +export function createAutorampStorageKey( + account: Pick | string, +): string { + return typeof account === 'string' ? account : account.id; +} + +/** + * Whether an autoramp has the minimum fields required to sync. + * + * @param account - Candidate autoramp. + * @returns True when syncable. + */ +export function isSyncableAutoramp( + account: Partial | null | undefined, +): account is AutorampAccount { + return Boolean( + account && + typeof account.id === 'string' && + account.id.length > 0 && + typeof account.customerId === 'string' && + typeof account.walletAddress === 'string' && + account.status, + ); +} + +/** + * Map a local autoramp to a User Storage entry (strips depositRailsSummary). + * + * @param account - Local or sync-aware autoramp. + * @returns Compact storage entry. + */ +export function mapAutorampToUserStorageEntry( + account: SyncAutorampAccount, +): UserStorageAutorampEntry { + const now = Date.now(); + return { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: account.id, + customerId: account.customerId, + walletAddress: account.walletAddress, + status: account.status, + lastSeenStatus: account.lastSeenStatus, + ...(account.notifiedForStatus + ? { notifiedForStatus: account.notifiedForStatus } + : {}), + }, + lu: account.updatedAt || now, + ...(account.deletedAt ? { dt: account.deletedAt } : {}), + }; +} + +/** + * Map a User Storage entry back to a sync-aware autoramp account. + * + * @param entry - Remote storage entry. + * @returns Sync autoramp (no depositRailsSummary). + */ +export function mapUserStorageEntryToAutoramp( + entry: UserStorageAutorampEntry, +): SyncAutorampAccount { + return { + id: entry.o.id, + customerId: entry.o.customerId, + walletAddress: entry.o.walletAddress, + status: normalizeAutorampStatus(entry.o.status), + lastSeenStatus: normalizeAutorampStatus(entry.o.lastSeenStatus), + ...(entry.o.notifiedForStatus + ? { + notifiedForStatus: normalizeAutorampStatus(entry.o.notifiedForStatus), + } + : {}), + updatedAt: entry.lu ?? Date.now(), + ...(entry.dt ? { deletedAt: entry.dt } : {}), + }; +} + +/** + * Strip sync-only metadata before writing into controller state. + * + * @param account - Sync-aware autoramp. + * @returns Plain {@link AutorampAccount}. + */ +export function stripAutorampSyncMetadata( + account: SyncAutorampAccount, +): AutorampAccount { + const { deletedAt: _deletedAt, ...rest } = account; + return rest; +} + +/** + * Compare syncable fields for equality (ignores depositRailsSummary). + * + * @param left - First account. + * @param right - Second account. + * @returns True when sync-relevant fields match. + */ +export function areAutorampsEqual( + left: SyncAutorampAccount, + right: SyncAutorampAccount, +): boolean { + return ( + left.id === right.id && + left.customerId === right.customerId && + left.walletAddress === right.walletAddress && + left.status === right.status && + left.lastSeenStatus === right.lastSeenStatus && + left.notifiedForStatus === right.notifiedForStatus + ); +} diff --git a/packages/ramps-controller/src/autoramp-syncing/index.ts b/packages/ramps-controller/src/autoramp-syncing/index.ts new file mode 100644 index 0000000000..f8dd806463 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/index.ts @@ -0,0 +1,28 @@ +export { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +export type { + UserStorageAutorampEntry, + SyncAutorampAccount, + AutorampSyncingController, + AutorampSyncingOptions, + SyncAutorampsWithUserStorageConfig, +} from './types.js'; +export { + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, + areAutorampsEqual, +} from './format-utils.js'; +export { canPerformAutorampSyncing } from './sync-utils.js'; +export { + computeAutorampMergePlan, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, + deleteAutorampInRemoteStorage, +} from './controller-integration.js'; diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts new file mode 100644 index 0000000000..c619447ad2 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts @@ -0,0 +1,128 @@ +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { computeAutorampMergePlan } from './controller-integration.js'; +import { canPerformAutorampSyncing } from './sync-utils.js'; +import type { AutorampSyncingOptions } from './types.js'; + +describe('autoramp-syncing/sync-utils', () => { + it('returns false when messenger actions are unavailable', () => { + const options: AutorampSyncingOptions = { + getMessenger: () => + ({ + call: () => { + throw new Error('not delegated'); + }, + }) as AutorampSyncingOptions['getMessenger'] extends () => infer R + ? R + : never, + getRampsControllerInstance: () => ({ + state: { autoramps: [] }, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn(), + removeAutoramp: jest.fn(), + getPendingRemoteAutorampDeletes: () => [], + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + }), + }; + + expect(canPerformAutorampSyncing(options)).toBe(false); + }); + + it('returns true when B&S and auth gates pass', () => { + const call = jest.fn((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + throw new Error(`unexpected ${action}`); + }); + + const options = { + getMessenger: () => ({ call }) as never, + getRampsControllerInstance: () => ({ + state: { autoramps: [] }, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn(), + removeAutoramp: jest.fn(), + getPendingRemoteAutorampDeletes: () => [], + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + }), + } as AutorampSyncingOptions; + + expect(canPerformAutorampSyncing(options)).toBe(true); + }); +}); + +describe('autoramp-syncing/computeAutorampMergePlan', () => { + it('imports remote-only accounts and uploads local-only accounts', () => { + const local = createAutorampAccount({ + id: 'local-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 10, + }); + const remote = createAutorampAccount({ + id: 'remote-1', + customerId: 'c', + walletAddress: '0x2', + status: AutorampStatus.Approved, + updatedAt: 20, + }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally.map((a) => a.id)).toStrictEqual([ + 'remote-1', + ]); + expect(plan.accountsToUpdateRemotely.map((a) => a.id)).toStrictEqual([ + 'local-1', + ]); + }); + + it('prefers newer timestamp on conflicts', () => { + const local = createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 50, + }); + const remote = { + ...createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Approved, + updatedAt: 10, + }), + }; + + const plan = computeAutorampMergePlan([local], [remote]); + expect(plan.accountsToUpdateRemotely).toHaveLength(1); + expect(plan.accountsToAddOrUpdateLocally).toHaveLength(0); + }); + + it('applies remote tombstones when local is older', () => { + const local = createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 10, + }); + const remote = { + ...local, + deletedAt: 20, + updatedAt: 20, + }; + + const plan = computeAutorampMergePlan([local], [remote]); + expect(plan.accountsToDeleteLocally).toHaveLength(1); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts new file mode 100644 index 0000000000..735bc6f65b --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts @@ -0,0 +1,48 @@ +import type { AutorampSyncingOptions } from './types.js'; + +/** + * Check if we can perform autoramp User Storage syncing. + * + * Requires Backup & Sync enabled, signed-in auth, and no in-progress sync. + * Optional `isRampsSyncingEnabled` on User Storage state defaults to true when absent. + * + * @param options - Sync options. + * @returns Whether sync can run. + */ +export function canPerformAutorampSyncing( + options: AutorampSyncingOptions, +): boolean { + const { getMessenger, getRampsControllerInstance } = options; + + try { + const userStorageState = getMessenger().call( + 'UserStorageController:getState', + ) as { + isBackupAndSyncEnabled?: boolean; + isRampsSyncingEnabled?: boolean; + }; + + const isBackupAndSyncEnabled = Boolean( + userStorageState.isBackupAndSyncEnabled, + ); + const isRampsSyncingEnabled = userStorageState.isRampsSyncingEnabled ?? true; + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + const { isAutorampSyncingInProgress } = getRampsControllerInstance(); + + if ( + !isBackupAndSyncEnabled || + !isRampsSyncingEnabled || + isAutorampSyncingInProgress || + !isAuthEnabled + ) { + return false; + } + + return true; + } catch { + // Host has not delegated User Storage / auth actions yet. + return false; + } +} diff --git a/packages/ramps-controller/src/autoramp-syncing/types.ts b/packages/ramps-controller/src/autoramp-syncing/types.ts new file mode 100644 index 0000000000..b3b732e619 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/types.ts @@ -0,0 +1,70 @@ +import type { TraceCallback } from '@metamask/controller-utils'; + +import type { AutorampAccount } from '../autorampAccount.js'; +import type { RampsControllerMessenger } from '../RampsController.js'; +import type { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; + +/** + * Compact User Storage entry for an autoramp account. + * Omits deposit rail details — those are re-fetched from the Ramp API / MoonPay. + */ +export type UserStorageAutorampEntry = { + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + o: { + id: string; + customerId: string; + walletAddress: string; + status: string; + lastSeenStatus: string; + notifiedForStatus?: string; + }; + lu?: number; + dt?: number; +}; + +/** + * {@link AutorampAccount} plus optional soft-delete metadata for sync merge. + */ +export type SyncAutorampAccount = AutorampAccount & { + deletedAt?: number; +}; + +/** + * Minimal controller surface required by autoramp syncing. + */ +export type AutorampSyncingController = { + state: { + autoramps: AutorampAccount[]; + }; + readonly isAutorampSyncingInProgress: boolean; + setIsAutorampSyncingInProgress: (value: boolean) => void; + setIsApplyingAutorampSyncChanges: (value: boolean) => void; + addAutoramp: (account: AutorampAccount) => AutorampAccount; + removeAutoramp: (autorampId: string) => void; + getPendingRemoteAutorampDeletes: () => AutorampAccount[]; + acknowledgePendingRemoteAutorampDeletes: ( + accounts: AutorampAccount[], + ) => void; +}; + +/** + * Options for autoramp syncing operations. + */ +export type AutorampSyncingOptions = { + getRampsControllerInstance: () => AutorampSyncingController; + getMessenger: () => RampsControllerMessenger; + trace?: TraceCallback; +}; + +/** + * Optional callbacks for sync error reporting. + */ +export type SyncAutorampsWithUserStorageConfig = { + onAutorampSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}; diff --git a/packages/ramps-controller/src/autorampAccount.test.ts b/packages/ramps-controller/src/autorampAccount.test.ts new file mode 100644 index 0000000000..d5518956a9 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.test.ts @@ -0,0 +1,163 @@ +import type { + ApplyAutorampRemoteStatusResult, + AutorampAccount, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import { + AutorampStatus, + applyAutorampRemoteStatus, + createAutorampAccount, + isTerminalAutorampStatus, + markAutorampNotified, + normalizeAutorampStatus, +} from './autorampAccount.js'; + +describe('autorampAccount', () => { + describe('normalizeAutorampStatus', () => { + it('returns known statuses as-is', () => { + expect(normalizeAutorampStatus(AutorampStatus.Approved)).toBe( + AutorampStatus.Approved, + ); + expect(normalizeAutorampStatus('DepositAccountAdded')).toBe( + AutorampStatus.DepositAccountAdded, + ); + }); + + it('falls back to Created for unknown values', () => { + expect(normalizeAutorampStatus('Nope')).toBe(AutorampStatus.Created); + }); + }); + + describe('isTerminalAutorampStatus', () => { + it('identifies terminal statuses', () => { + expect(isTerminalAutorampStatus(AutorampStatus.Rejected)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Cancelled)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Approved)).toBe(false); + expect(isTerminalAutorampStatus(AutorampStatus.Authorized)).toBe(false); + }); + }); + + describe('createAutorampAccount', () => { + it('defaults status to Authorized and mirrors lastSeenStatus', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + updatedAt: 1000, + }); + + expect(account).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1000, + depositRailsSummary: undefined, + }); + }); + }); + + describe('applyAutorampRemoteStatus', () => { + const baseLocal: AutorampAccount = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + updatedAt: 1, + }); + + it('creates a local account without notify when local is null', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }; + + const result = applyAutorampRemoteStatus(null, remote); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + }); + + it('detects Approved transition and requests notify once', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }; + + const result = applyAutorampRemoteStatus(baseLocal, remote); + + expect(result).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + statusChanged: true, + shouldNotify: true, + } satisfies Partial); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.lastSeenStatus).toBe(AutorampStatus.Authorized); + }); + + it('does not notify again when already notified for that status', () => { + const local = markAutorampNotified({ + ...baseLocal, + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Authorized, + notifiedForStatus: AutorampStatus.Approved, + }); + + const result = applyAutorampRemoteStatus(local, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + }); + + it('does not notify for non-notable transitions', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.DepositAccountAdded, + }); + + expect(result.statusChanged).toBe(true); + expect(result.shouldNotify).toBe(false); + }); + + it('notifies for Rejected', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Rejected, + }); + + expect(result.shouldNotify).toBe(true); + }); + }); + + describe('markAutorampNotified', () => { + it('sets notifiedForStatus to current status', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + + expect(markAutorampNotified(account).notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); +}); diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts new file mode 100644 index 0000000000..0cdec9615a --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -0,0 +1,221 @@ +/** + * Local + remote models for MoonPay Enterprise autoramp accounts. + * Separate from {@link RampsOrder}: autoramps are standing routes; orders are payments. + */ + +/** + * Autoramp lifecycle statuses from MoonPay Enterprise. + * @see https://dev.enterprise.moonpay.com/autoramp-status + */ +export enum AutorampStatus { + Created = 'Created', + Authorized = 'Authorized', + EditPending = 'EditPending', + DepositAccountAdded = 'DepositAccountAdded', + Approved = 'Approved', + Rejected = 'Rejected', + Cancelled = 'Cancelled', +} + +/** + * Non-PII deposit readiness summary cached after a remote refresh. + * Full deposit rail details (IBAN, etc.) should be re-fetched when needed — not synced. + */ +export type AutorampDepositRailsSummary = { + /** Source currency code when known (e.g. EUR). */ + currency?: string; + /** True when the autoramp is approved and deposit details may be shared. */ + ready: boolean; +}; + +/** + * Local controller representation of an autoramp account. + */ +export type AutorampAccount = { + /** MoonPay autoramp id. */ + id: string; + /** MoonPay customer id. */ + customerId: string; + /** Destination wallet address associated with this autoramp. */ + walletAddress: string; + /** Latest status from MoonPay (source of truth after refresh). */ + status: AutorampStatus; + /** + * Status observed before the most recent remote apply. + * Used for transition UX / analytics (e.g. Authorized → Approved). + */ + lastSeenStatus: AutorampStatus; + /** + * Last status for which the UI already showed a notification. + * Prevents duplicate toasts across refresh and push. + */ + notifiedForStatus?: AutorampStatus; + /** Epoch ms of the last local update from remote or push. */ + updatedAt: number; + /** Optional non-PII deposit readiness cache. */ + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Minimal remote snapshot from `GET /api/autoramps/{id}` (or a push payload). + * Host apps / BFF map MoonPay responses into this shape. + */ +export type AutorampRemoteSnapshot = { + id: string; + customerId: string; + walletAddress?: string; + status: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Result of applying a remote autoramp snapshot onto local state. + */ +export type ApplyAutorampRemoteStatusResult = { + account: AutorampAccount; + previousStatus: AutorampStatus; + statusChanged: boolean; + /** True when status changed and UI has not yet notified for the new status. */ + shouldNotify: boolean; +}; + +/** + * Terminal autoramp statuses — no further lifecycle progress expected. + */ +export const TERMINAL_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Statuses that commonly warrant user-visible transition UX (toast / banner). + */ +export const NOTABLE_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Approved, + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Whether an autoramp status is terminal. + * + * @param status - Status to test. + * @returns Whether the status is terminal. + */ +export function isTerminalAutorampStatus(status: AutorampStatus): boolean { + return TERMINAL_AUTORAMP_STATUSES.has(status); +} + +/** + * Normalize a remote status string into {@link AutorampStatus}. + * Unknown values fall back to {@link AutorampStatus.Created}. + * + * @param status - Remote status string. + * @returns A known {@link AutorampStatus}. + */ +export function normalizeAutorampStatus( + status: AutorampStatus | string, +): AutorampStatus { + if (Object.values(AutorampStatus).includes(status as AutorampStatus)) { + return status as AutorampStatus; + } + return AutorampStatus.Created; +} + +/** + * Build a new local autoramp account from create/response fields. + * + * @param input - Required identity + status fields. + * @returns A new {@link AutorampAccount}. + */ +export function createAutorampAccount(input: { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; + updatedAt?: number; +}): AutorampAccount { + const status = normalizeAutorampStatus( + input.status ?? AutorampStatus.Authorized, + ); + return { + id: input.id, + customerId: input.customerId, + walletAddress: input.walletAddress, + status, + lastSeenStatus: status, + updatedAt: input.updatedAt ?? Date.now(), + depositRailsSummary: input.depositRailsSummary, + }; +} + +/** + * Apply a remote autoramp snapshot onto a local account for transition detection. + * Pure helper — shared by refresh-on-load and websocket push paths. + * + * @param local - Current local account (or null when first upserting from remote). + * @param remote - Remote snapshot (MoonPay GET or push). + * @returns Updated account plus change / notify flags. + */ +export function applyAutorampRemoteStatus( + local: AutorampAccount | null, + remote: AutorampRemoteSnapshot, +): ApplyAutorampRemoteStatusResult { + const remoteStatus = normalizeAutorampStatus(remote.status); + + if (!local) { + const account = createAutorampAccount({ + id: remote.id, + customerId: remote.customerId, + walletAddress: remote.walletAddress ?? '', + status: remoteStatus, + depositRailsSummary: remote.depositRailsSummary, + }); + return { + account, + previousStatus: remoteStatus, + statusChanged: false, + shouldNotify: false, + }; + } + + const previousStatus = local.status; + const statusChanged = previousStatus !== remoteStatus; + const shouldNotify = + statusChanged && + local.notifiedForStatus !== remoteStatus && + NOTABLE_AUTORAMP_STATUSES.has(remoteStatus); + + const account: AutorampAccount = { + ...local, + id: remote.id, + customerId: remote.customerId || local.customerId, + walletAddress: remote.walletAddress || local.walletAddress, + status: remoteStatus, + lastSeenStatus: previousStatus, + updatedAt: Date.now(), + depositRailsSummary: + remote.depositRailsSummary ?? local.depositRailsSummary, + }; + + return { + account, + previousStatus, + statusChanged, + shouldNotify, + }; +} + +/** + * Mark that the UI has notified for the account's current status. + * + * @param account - Account to update. + * @returns Account with `notifiedForStatus` set to current status. + */ +export function markAutorampNotified(account: AutorampAccount): AutorampAccount { + return { + ...account, + notifiedForStatus: account.status, + }; +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f1d1dcdbe6..eb84a47d86 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -6,6 +6,7 @@ export type { RampsControllerState, RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, + RampsControllerAutorampStatusChangedEvent, RampsControllerOptions, UserRegion, ResourceState, @@ -29,6 +30,13 @@ export type { RampsControllerGetQuotesAction, RampsControllerAddOrderAction, RampsControllerRemoveOrderAction, + RampsControllerAddAutorampAction, + RampsControllerRemoveAutorampAction, + RampsControllerMarkAutorampAsNotifiedAction, + RampsControllerApplyAutorampStatusFromPushAction, + RampsControllerRefreshAutorampAction, + RampsControllerRefreshAutorampsAction, + RampsControllerSyncAutorampsWithUserStorageAction, RampsControllerStartOrderPollingAction, RampsControllerStopOrderPollingAction, RampsControllerGetBuyWidgetDataAction, @@ -67,6 +75,7 @@ export { getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; export type { RampsServiceActions, @@ -165,6 +174,50 @@ export { TERMINAL_ORDER_STATUSES, isTerminalOrderStatus, } from './orderStatus.js'; +export type { + AutorampAccount, + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, + ApplyAutorampRemoteStatusResult, +} from './autorampAccount.js'; +export { + AutorampStatus, + TERMINAL_AUTORAMP_STATUSES, + NOTABLE_AUTORAMP_STATUSES, + isTerminalAutorampStatus, + normalizeAutorampStatus, + createAutorampAccount, + applyAutorampRemoteStatus, + markAutorampNotified, +} from './autorampAccount.js'; +export type { + UserStorageAutorampEntry, + SyncAutorampAccount, + AutorampSyncingOptions, + SyncAutorampsWithUserStorageConfig, +} from './autoramp-syncing/index.js'; +export { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, + deleteAutorampInRemoteStorage, + canPerformAutorampSyncing, + computeAutorampMergePlan, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, +} from './autoramp-syncing/index.js'; +export type { + NeoBankServiceActions, + NeoBankServiceEvents, + NeoBankServiceMessenger, + NeoBankAutorampResponse, +} from './NeoBankService.js'; +export type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +export { + NeoBankService, + serviceName as neoBankServiceName, + mapNeoBankAutorampToRemoteSnapshot, +} from './NeoBankService.js'; export type { TypedError } from './errorNormalization.js'; export { getErrorMessage,