diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index d61b6aa2e9b..db46e3853d4 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add Money Account deposit polling to `RampsController` (emit-only). New `startDepositPolling` / `stopDepositPolling` / `refreshDeposits` methods and messenger actions poll the neo-bank proxy for each pollable autoramp's transactions on the shared 30s interval, keep a persisted `state.deposits` clone, and publish the new `RampsController:depositStatusChanged` event (`{ deposit, previousStatus, shouldNotify }`) on status transitions. Only `Approved` autoramps (or ones with an in-flight local deposit) are polled. The poller takes no on-chain action; vault sweeping is owned by the backend. ([#10120](https://github.com/MetaMask/core/pull/10120)) + - Also adds `markDepositAsNotified(depositId)` (dedupes repeat notifications for the same status) and `removeDeposit(depositId)` (lets consumers prune the persisted deposit list), each exposed as a messenger action. + - `RampsController` now calls `NeoBankService:getAutorampTransactions`, added to the exported `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. Hosts that enumerate their delegated actions instead of spreading that constant must add it, or `startDepositPolling` / `refreshDeposits` reject with a messenger "handler has not been delegated" error. +- Add the `moneyAccountDeposit` model: `MoneyAccountDeposit`, `MoneyAccountDepositStatus`, `MoneyAccountDepositRemoteSnapshot`, the pure `applyDepositRemoteStatus` diff, and helpers (`normalizeDepositStatus`, `isTerminalDepositStatus`, `createMoneyAccountDeposit`, `markDepositNotified`, `TERMINAL_DEPOSIT_STATUSES`, `NOTABLE_DEPOSIT_STATUSES`). ([#10120](https://github.com/MetaMask/core/pull/10120)) +- Add `NeoBankService.getAutorampTransactions(autorampId)` and the `NeoBankService:getAutorampTransactions` messenger action, which fetch deposit/transaction records from neobank-proxy `GET /neobank/autoramp-transactions?autoramp_id={id}` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot`. Reads the Iron `PagedList` `items` array and the nested `payout_crypto_transaction.transaction_hash`, tolerating the in-flight proxy's transitional `data` / flat `transaction_hash` shapes and a bare array as fallbacks. ([#10120](https://github.com/MetaMask/core/pull/10120)) - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 956b7f2b6b4..e9d3a23f677 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -18,6 +18,22 @@ export type NeoBankServiceGetAutorampAction = { handler: NeoBankService['getAutoramp']; }; +/** + * Fetches deposit/transaction records for an autoramp via neobank-proxy + * `GET /neobank/autoramp-transactions?autoramp_id={autoramp_id}` (MoonPay + * `GET /api/autoramp-transactions`, response is a MoonPay `PagedList`). + * + * Used by the deposit poller to detect status changes (e.g. a payout settling + * on Monad). Route + response shape track onramp-api PR #1124. + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Deposit snapshots for controller apply/refresh. + */ +export type NeoBankServiceGetAutorampTransactionsAction = { + type: `NeoBankService:getAutorampTransactions`; + handler: NeoBankService['getAutorampTransactions']; +}; + /** * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. * Body is forwarded as opaque JSON (MoonPay address schema). @@ -136,6 +152,7 @@ export type NeoBankServiceRegisterSelfHostedWalletAction = { */ export type NeoBankServiceMethodActions = | NeoBankServiceGetAutorampAction + | NeoBankServiceGetAutorampTransactionsAction | NeoBankServiceRegisterPixAddressAction | NeoBankServiceGetAutorampQuoteAction | NeoBankServiceCreateAutorampAction diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index ce306b95545..356bb685a9d 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -6,6 +6,7 @@ import nock, { cleanAll } from 'nock'; import { mapNeoBankAutorampToRemoteSnapshot, + mapNeoBankTransactionToRemoteSnapshot, NeoBankService, } from './NeoBankService.js'; import type { NeoBankServiceMessenger } from './NeoBankService.js'; @@ -136,6 +137,153 @@ describe('NeoBankService', () => { }); }); + describe('mapNeoBankTransactionToRemoteSnapshot', () => { + it('maps the confirmed Iron transaction fields (nested payout hash) into a deposit snapshot', () => { + expect( + mapNeoBankTransactionToRemoteSnapshot({ + id: 'dep-1', + autoramp_id: 'ar-1', + status: 'Completed', + payout_crypto_transaction: { transaction_hash: '0xpayout' }, + }), + ).toStrictEqual({ + id: 'dep-1', + autorampId: 'ar-1', + status: 'Completed', + payoutTransactionHash: '0xpayout', + }); + }); + + it('falls back to a flat transaction_hash (webhook / proxy-fixture shape)', () => { + expect( + mapNeoBankTransactionToRemoteSnapshot({ + id: 'dep-1', + status: 'Completed', + transaction_hash: '0xflat', + }), + ).toMatchObject({ payoutTransactionHash: '0xflat' }); + }); + + it('leaves the payout hash undefined when the proxy omits it', () => { + expect( + mapNeoBankTransactionToRemoteSnapshot({ + id: 'dep-1', + status: 'PayoutInProgress', + }), + ).toMatchObject({ payoutTransactionHash: undefined }); + }); + }); + + describe('getAutorampTransactions', () => { + it('fetches /neobank/autoramp-transactions?autoramp_id={id} and maps an Iron PagedList (items) envelope', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions\?.*autoramp_id=ar-1/u) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { + items: [ + { + id: 'dep-1', + autoramp_id: 'ar-1', + status: 'Completed', + payout_crypto_transaction: { transaction_hash: '0xpayout' }, + }, + ], + cursor: null, + prev_cursor: null, + }); + + const service = createService(); + const snapshots = await service.getAutorampTransactions('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshots).toStrictEqual([ + { + id: 'dep-1', + autorampId: 'ar-1', + status: 'Completed', + payoutTransactionHash: '0xpayout', + }, + ]); + }); + + it('accepts the proxy PR transitional { data } envelope as a fallback', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, { + data: [{ id: 'dep-1', status: 'PayoutInProgress' }], + next_cursor: null, + }); + + const service = createService(); + const snapshots = await service.getAutorampTransactions('ar-1'); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: 'dep-1', + status: 'PayoutInProgress', + }); + }); + + it('accepts a bare array body as a defensive fallback', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, [{ id: 'dep-1', status: 'PayoutInProgress' }]); + + const service = createService(); + const snapshots = await service.getAutorampTransactions('ar-1'); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: 'dep-1', + status: 'PayoutInProgress', + }); + }); + + it('throws HttpError when the proxy returns a non-2xx status', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions/u) + .reply(500); + + const service = createService(); + await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( + /failed with status '500'/u, + ); + }); + + it('throws when the response body is not a transaction list', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, { nope: true }); + + const service = createService(); + await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( + 'Malformed response received from neo-bank transactions API', + ); + }); + + it('throws when an item is missing an id', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, { items: [{ status: 'PayoutInProgress' }] }); + + const service = createService(); + await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( + 'Malformed response received from neo-bank transactions API', + ); + }); + + it('throws when an item is missing a status', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, { items: [{ id: 'dep-1' }] }); + + const service = createService(); + await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( + 'Malformed response received from neo-bank transactions API', + ); + }); + }); + describe('getAutoramp', () => { it('gets /neobank/autoramps/{id} with bearer auth', async () => { const scope = nock(STAGING_BASE) diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index ef73ddb5fca..235f8b997cd 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -9,12 +9,14 @@ import { } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { Hex } from '@metamask/utils'; import packageJson from '../package.json'; import type { AutorampDepositRailsSummary, AutorampRemoteSnapshot, } from './autoramp-types.js'; +import type { MoneyAccountDepositRemoteSnapshot } from './moneyAccountDeposit.js'; import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; import { WalletRegistrationService } from './wallet-registration-service.js'; @@ -74,6 +76,60 @@ export type NeoBankAutorampResponse = { deposit_rails?: unknown[]; }; +/** + * Raw deposit/transaction payload from the MetaMask Ramp API neo-bank proxy. + * + * Represents a single payment instance flowing through an autoramp (partner + * receives fiat, pays out mUSD on Monad to the Money Account). + * + * Source of truth: the MoonPay/Iron Enterprise `AutorampTransaction`, which the + * neobank-proxy (onramp-api PR #1124) forwards verbatim. Per Iron's OpenAPI + * spec the payout hash is nested at `payout_crypto_transaction.transaction_hash` + * and the list is a `PagedList` with an `items` array. Because that proxy PR is + * unmerged and still in flux (its own fixtures use `data`/flat `transaction_hash`, + * matching the webhook shape) and TRAM-3925 will add a mobile-safe DTO, the + * mapper reads both the nested and flat hash and both the `items` and `data` + * envelopes so it works whichever shape ships. Display fields (amount/currency/ + * recipient) are MoonPay structured objects, not simple top-level fields, so they + * are intentionally not mapped yet; TRAM-3925 will pin the wire names. + */ +/* eslint-disable @typescript-eslint/naming-convention -- snake_case proxy wire format */ +export type NeoBankTransactionResponse = { + id: string; + status: string; + autoramp_id?: string; + /** + * Monad payout transaction hash, nested under the crypto payout on the Iron + * `AutorampTransaction` (present once the payout settles on-chain). + */ + payout_crypto_transaction?: { + transaction_hash?: string; + }; + /** Flat payout hash fallback (the webhook / proxy-fixture shape). */ + transaction_hash?: string; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Envelope returned by the neo-bank transactions endpoint. + * + * Primary shape is the Iron `PagedList` (`{ items, cursor, prev_cursor }`); the + * proxy PR's transitional `{ data, next_cursor }` and a bare array are accepted + * as fallbacks. Only the first page is read for now - cursor pagination is a + * follow-up. + */ +/* eslint-disable @typescript-eslint/naming-convention -- snake_case proxy wire format */ +export type NeoBankTransactionsResponse = + | NeoBankTransactionResponse[] + | { + items?: NeoBankTransactionResponse[]; + cursor?: string | null; + prev_cursor?: string | null; + data?: NeoBankTransactionResponse[]; + next_cursor?: string | null; + }; +/* eslint-enable @typescript-eslint/naming-convention */ + /** * Optional headers for neo-bank mutating requests. */ @@ -112,6 +168,7 @@ export type RegisterSelfHostedWalletParams = { const MESSENGER_EXPOSED_METHODS = [ 'getAutoramp', + 'getAutorampTransactions', 'registerPixAddress', 'getAutorampQuote', 'createAutoramp', @@ -213,6 +270,32 @@ export function mapNeoBankAutorampToRemoteSnapshot( }; } +/** + * Maps a neo-bank proxy transaction response into a local deposit snapshot. + * + * @param response - Single transaction from the proxy transactions endpoint. + * @returns Snapshot consumed by `applyDepositRemoteStatus`. + */ +export function mapNeoBankTransactionToRemoteSnapshot( + response: NeoBankTransactionResponse, +): MoneyAccountDepositRemoteSnapshot { + // Payout hash: nested on the Iron `AutorampTransaction`, flat on the webhook / + // proxy-fixture shape - read both. Display fields (moneyAccountAddress/amount/ + // currency) are left unset: MoonPay carries them as structured source/ + // destination objects, not simple top-level fields; they await the mobile-safe + // DTO (TRAM-3925). + const payoutTransactionHash = + response.payout_crypto_transaction?.transaction_hash ?? + response.transaction_hash; + + return { + id: response.id, + autorampId: response.autoramp_id, + status: response.status, + payoutTransactionHash: payoutTransactionHash as Hex | undefined, + }; +} + /** * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). * @@ -405,6 +488,30 @@ export class NeoBankService { return mapNeoBankAutorampToRemoteSnapshot(response); } + #mapTransactionsResponse( + response: NeoBankTransactionsResponse, + ): MoneyAccountDepositRemoteSnapshot[] { + // Primary shape is the Iron `PagedList` (`items` array); the proxy PR's + // transitional `data` array and a bare array are accepted as fallbacks. + // Single page only for now - cursor pagination is a follow-up. + const list = Array.isArray(response) + ? response + : (response?.items ?? response?.data); + if (!Array.isArray(list)) { + throw new Error( + 'Malformed response received from neo-bank transactions API', + ); + } + return list.map((item) => { + if (!item || typeof item !== 'object' || !item.id || !item.status) { + throw new Error( + 'Malformed response received from neo-bank transactions API', + ); + } + return mapNeoBankTransactionToRemoteSnapshot(item); + }); + } + /** * Fetches an autoramp account via neobank-proxy * `GET /neobank/autoramps/{autoramp_id}` (MoonPay @@ -420,6 +527,27 @@ export class NeoBankService { return this.#mapAutorampResponse(response); } + /** + * Fetches deposit/transaction records for an autoramp via neobank-proxy + * `GET /neobank/autoramp-transactions?autoramp_id={autoramp_id}` (MoonPay + * `GET /api/autoramp-transactions`, response is a MoonPay `PagedList`). + * + * Used by the deposit poller to detect status changes (e.g. a payout settling + * on Monad). Route + response shape track onramp-api PR #1124. + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Deposit snapshots for controller apply/refresh. + */ + async getAutorampTransactions( + autorampId: string, + ): Promise { + const response = await this.#getJson( + 'autoramp-transactions', + { autoramp_id: autorampId }, + ); + return this.#mapTransactionsResponse(response); + } + /** * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. * Body is forwarded as opaque JSON (MoonPay address schema). diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 8661f19c705..8a2a576de8a 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -422,6 +422,63 @@ export type RampsControllerRefreshAutorampsAction = { handler: RampsController['refreshAutoramps']; }; +/** + * Refreshes Money Account deposit/transaction records for the pollable + * autoramps from the neo-bank proxy, applying any status changes to local + * state and emitting `depositStatusChanged`. Intended for app load / unlock + * catch-up, and reused as the deposit poll worker. Emit-only: no on-chain + * action is taken. + */ +export type RampsControllerRefreshDepositsAction = { + type: `RampsController:refreshDeposits`; + handler: RampsController['refreshDeposits']; +}; + +/** + * Marks that the UI has already notified for the deposit's current status, + * so a later transition back into the same notable status does not re-notify. + * Consumers call this after surfacing a `depositStatusChanged` with + * `shouldNotify: true`. + * + * @param depositId - Proxy deposit/transaction id. + */ +export type RampsControllerMarkDepositAsNotifiedAction = { + type: `RampsController:markDepositAsNotified`; + handler: RampsController['markDepositAsNotified']; +}; + +/** + * Removes a local deposit record by id. Useful for pruning deposits under a + * completed or cancelled autoramp that is no longer polled. Note: a deposit + * under an actively polled (`Approved`) autoramp is a live mirror of the + * proxy, so removing it here only sticks once its autoramp is no longer + * pollable; otherwise the next poll re-syncs it. + * + * @param depositId - Proxy deposit/transaction id. + */ +export type RampsControllerRemoveDepositAction = { + type: `RampsController:removeDeposit`; + handler: RampsController['removeDeposit']; +}; + +/** + * Starts polling Money Account deposits for active autoramps at a fixed + * interval. Emit-only: publishes `depositStatusChanged` on transitions and + * takes no on-chain action (vault sweeping is owned by the backend). + */ +export type RampsControllerStartDepositPollingAction = { + type: `RampsController:startDepositPolling`; + handler: RampsController['startDepositPolling']; +}; + +/** + * Stops deposit polling and clears the interval. + */ +export type RampsControllerStopDepositPollingAction = { + type: `RampsController:stopDepositPolling`; + handler: RampsController['stopDepositPolling']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -840,6 +897,11 @@ export type RampsControllerMethodActions = | RampsControllerApplyAutorampStatusFromPushAction | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction + | RampsControllerRefreshDepositsAction + | RampsControllerMarkDepositAsNotifiedAction + | RampsControllerRemoveDepositAction + | RampsControllerStartDepositPollingAction + | RampsControllerStopDepositPollingAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0bd71a83b39..307eb5ec4b3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { MoneyAccountDepositStatus } from './moneyAccountDeposit.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -137,6 +138,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -214,6 +216,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -2247,6 +2250,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -2314,6 +2318,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "orders": [], "paymentMethods": { "data": [], @@ -2351,6 +2356,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` { "autoramps": [], + "deposits": [], "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2376,6 +2382,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -10628,6 +10635,569 @@ describe('RampsController', () => { }); }); + describe('deposit polling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const addApprovedAutoramp = ( + controller: RampsController, + id = 'ar-1', + ): void => { + controller.addAutoramp({ + id, + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + }; + + it('startDepositPolling fetches transactions for active autoramps and upserts deposits', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { + id: 'dep-1', + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + }, + ], + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.deposits).toHaveLength(1); + expect(controller.state.deposits[0]).toMatchObject({ + id: 'dep-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + }); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('publishes depositStatusChanged with shouldNotify on a notable transition', async () => { + await withController(async ({ controller, rootMessenger, messenger }) => { + addApprovedAutoramp(controller); + + const getTransactions = jest + .fn() + .mockResolvedValueOnce([ + { id: 'dep-1', status: MoneyAccountDepositStatus.PayoutInProgress }, + ]) + .mockResolvedValue([ + { + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: '0xpayout', + }, + ]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + const events: unknown[] = []; + messenger.subscribe( + 'RampsController:depositStatusChanged', + (payload) => { + events.push(payload); + }, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + // Immediate poll seeds the deposit as Processing (no event on create). + await jest.advanceTimersByTimeAsync(0); + expect(events).toHaveLength(0); + + // Next interval observes Completed -> notable transition -> event. + await jest.advanceTimersByTimeAsync(30_000); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + previousStatus: MoneyAccountDepositStatus.PayoutInProgress, + shouldNotify: true, + }); + expect(controller.state.deposits[0]).toMatchObject({ + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: '0xpayout', + }); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('does not poll terminal autoramps', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.addAutoramp({ + id: 'ar-terminal', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Cancelled, + }); + addApprovedAutoramp(controller, 'ar-active'); + + const getTransactions = jest.fn().mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + + expect(getTransactions).toHaveBeenCalledTimes(1); + expect(getTransactions).toHaveBeenCalledWith('ar-active'); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('startDepositPolling is idempotent', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + const getTransactions = jest.fn().mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + + // Only one immediate poll despite two starts. + expect(getTransactions).toHaveBeenCalledTimes(1); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('destroy stops deposit polling', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [], + ); + + rootMessenger.call('RampsController:startDepositPolling'); + controller.destroy(); + + expect(controller.state.deposits).toStrictEqual([]); + }); + }); + + it('refreshDeposits skips when a poll is already in flight', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + let resolveFirst: (value: unknown[]) => void = () => undefined; + const firstCall = new Promise((resolve) => { + resolveFirst = resolve; + }); + const getTransactions = jest + .fn() + .mockReturnValueOnce(firstCall) + .mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + const inFlight = controller.refreshDeposits(); // sets the guard, awaits handler + await controller.refreshDeposits(); // guarded: returns immediately + expect(getTransactions).toHaveBeenCalledTimes(1); + + resolveFirst([]); + await inFlight; + }); + }); + + it('refreshDeposits applies snapshots without a running timer', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { id: 'dep-1', status: MoneyAccountDepositStatus.Completed }, + ], + ); + + await controller.refreshDeposits(); + + expect(controller.state.deposits[0]).toMatchObject({ + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + }); + }); + }); + + it('keeps polling despite a transaction fetch failure', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => { + throw new Error('network'); + }, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + + expect(controller.state.deposits).toStrictEqual([]); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('does not start an overlapping poll while one is in flight', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + let resolveFirst: (value: unknown[]) => void = () => undefined; + const firstCall = new Promise((resolve) => { + resolveFirst = resolve; + }); + const getTransactions = jest + .fn() + .mockReturnValueOnce(firstCall) + .mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + // Interval fires while the first poll is still awaiting the handler. + await jest.advanceTimersByTimeAsync(30_000); + expect(getTransactions).toHaveBeenCalledTimes(1); + + resolveFirst([]); + await jest.advanceTimersByTimeAsync(0); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('drops poll bookkeeping for autoramps that become terminal', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller, 'ar-1'); + const getTransactions = jest.fn().mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(getTransactions).toHaveBeenCalledTimes(1); + + // Autoramp turns terminal: the next cycle prunes its meta and skips it. + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Cancelled, + }); + await jest.advanceTimersByTimeAsync(30_000); + + expect(getTransactions).toHaveBeenCalledTimes(1); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('backs off polling an autoramp after repeated failures', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + const getTransactions = jest + .fn() + .mockRejectedValue(new Error('network')); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); // errorCount 1 @ t0 + await jest.advanceTimersByTimeAsync(30_000); // backoff 30s met -> errorCount 2 @ t30 + await jest.advanceTimersByTimeAsync(30_000); // backoff 60s not met -> skipped + + expect(getTransactions).toHaveBeenCalledTimes(2); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('does not re-emit depositStatusChanged when a repeat poll is unchanged', async () => { + await withController(async ({ controller, rootMessenger, messenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { id: 'dep-1', status: MoneyAccountDepositStatus.Completed }, + ], + ); + + const events: unknown[] = []; + messenger.subscribe( + 'RampsController:depositStatusChanged', + (payload) => { + events.push(payload); + }, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); // first observation: create, no event + await jest.advanceTimersByTimeAsync(30_000); // same Completed again + await jest.advanceTimersByTimeAsync(30_000); // and again + + expect(events).toHaveLength(0); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('takes no on-chain action even when a payout hash settles (emit-only)', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: '0xpayout', + }, + ], + ); + const addTransactionBatch = jest.fn(); + const submitVaultDeposit = jest.fn(); + rootMessenger.registerActionHandler( + 'TransactionController:addTransactionBatch', + addTransactionBatch, + ); + rootMessenger.registerActionHandler( + 'TransactionPayController:submitMoneyAccountVaultDeposit', + submitVaultDeposit, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + await jest.advanceTimersByTimeAsync(30_000); + + expect(addTransactionBatch).not.toHaveBeenCalled(); + expect(submitVaultDeposit).not.toHaveBeenCalled(); + expect(controller.state.deposits[0]?.payoutTransactionHash).toBe( + '0xpayout', + ); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('keeps polling a terminal autoramp that still has an in-flight deposit', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller, 'ar-1'); + const getTransactions = jest.fn().mockResolvedValue([ + { + id: 'dep-1', + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + }, + ]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(getTransactions).toHaveBeenCalledTimes(1); + + // Route cancelled, but the in-flight deposit must keep being tracked. + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Cancelled, + }); + await jest.advanceTimersByTimeAsync(30_000); + + expect(getTransactions).toHaveBeenCalledTimes(2); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('treats a Rejected deposit as terminal and stops polling once the route is terminal', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller, 'ar-1'); + const getTransactions = jest.fn().mockResolvedValue([ + { + id: 'dep-1', + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.RejectedAml, + }, + ]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(getTransactions).toHaveBeenCalledTimes(1); + + // Route cancelled and the only deposit is terminal (RejectedAml), so + // the autoramp drops out of the poll set: no further fetch. A Rejected + // status must count as terminal (the contract fix under test). + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Cancelled, + }); + await jest.advanceTimersByTimeAsync(30_000); + + expect(getTransactions).toHaveBeenCalledTimes(1); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('does not poll autoramps that are not yet Approved', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.addAutoramp({ + id: 'ar-pending', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + const getTransactions = jest.fn().mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + + expect(getTransactions).not.toHaveBeenCalled(); + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('markDepositAsNotified records the notified status', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { id: 'dep-1', status: MoneyAccountDepositStatus.Completed }, + ], + ); + + await controller.refreshDeposits(); + expect(controller.state.deposits[0]?.notifiedForStatus).toBeUndefined(); + + rootMessenger.call('RampsController:markDepositAsNotified', 'dep-1'); + expect(controller.state.deposits[0]?.notifiedForStatus).toBe( + MoneyAccountDepositStatus.Completed, + ); + + // No-op for an unknown deposit id. + expect(() => + rootMessenger.call('RampsController:markDepositAsNotified', 'nope'), + ).not.toThrow(); + }); + }); + + it('removeDeposit prunes a deposit from state', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { id: 'dep-1', status: MoneyAccountDepositStatus.Completed }, + ], + ); + + await controller.refreshDeposits(); + expect(controller.state.deposits).toHaveLength(1); + + rootMessenger.call('RampsController:removeDeposit', 'dep-1'); + expect(controller.state.deposits).toStrictEqual([]); + }); + }); + + it('backfills the owning autoramp id when the proxy omits it, and keeps polling after the route goes terminal', async () => { + await withController(async ({ controller, rootMessenger }) => { + addApprovedAutoramp(controller, 'ar-1'); + const getTransactions = jest + .fn() + .mockResolvedValue([ + { id: 'dep-1', status: MoneyAccountDepositStatus.PayoutInProgress }, + ]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + + // autorampId came from the query key, not the (omitted) proxy field. + expect(controller.state.deposits[0]?.autorampId).toBe('ar-1'); + expect(getTransactions).toHaveBeenCalledTimes(1); + + // Route cancelled: the in-flight deposit keeps being polled via its + // backfilled autoramp id. + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Cancelled, + }); + await jest.advanceTimersByTimeAsync(30_000); + expect(getTransactions).toHaveBeenCalledTimes(2); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + + it('does not fire stateChange or bump updatedAt on an unchanged repeat poll', async () => { + await withController(async ({ controller, rootMessenger, messenger }) => { + addApprovedAutoramp(controller); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + async () => [ + { + id: 'dep-1', + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.Completed, + }, + ], + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); // first poll: create (writes) + const updatedAtAfterCreate = controller.state.deposits[0]?.updatedAt; + + let stateChanges = 0; + messenger.subscribe('RampsController:stateChange', () => { + stateChanges += 1; + }); + + await jest.advanceTimersByTimeAsync(30_000); // unchanged + await jest.advanceTimersByTimeAsync(30_000); // unchanged + + expect(stateChanges).toBe(0); + expect(controller.state.deposits[0]?.updatedAt).toBe( + updatedAtAfterCreate, + ); + + rootMessenger.call('RampsController:stopDepositPolling'); + }); + }); + }); + describe('order polling', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c0bdc2edb30..83191baa8ca 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -18,6 +18,7 @@ import type { } from './autorampAccount.js'; import { applyAutorampRemoteStatus, + AutorampStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js'; @@ -26,9 +27,19 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { + MoneyAccountDeposit, + MoneyAccountDepositRemoteSnapshot, +} from './moneyAccountDeposit.js'; +import { + applyDepositRemoteStatus, + isTerminalDepositStatus, + markDepositNotified, +} from './moneyAccountDeposit.js'; import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampTransactionsAction, NeoBankServiceGetCustomerByExternalIdAction, NeoBankServiceGetWalletRegistrationStatusAction, NeoBankServiceRegisterSelfHostedWalletAction, @@ -206,6 +217,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', + 'NeoBankService:getAutorampTransactions', 'NeoBankService:createAutoramp', 'NeoBankService:getCustomerByExternalId', 'NeoBankService:getWalletRegistrationStatus', @@ -527,6 +539,13 @@ export type RampsControllerState = { * notifications after refresh or push. */ autoramps: AutorampAccount[]; + /** + * Money Account deposit/payout transactions observed via polling, separate + * from {@link AutorampAccount} standing routes. A thin local clone of the + * partner transactions used to detect status changes and emit notifications; + * persisted for cross-restart dedupe. + */ + deposits: MoneyAccountDeposit[]; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -594,6 +613,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + deposits: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -661,6 +686,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, orders: [], autoramps: [], + deposits: [], providerAutoSelected: false, }; } @@ -787,6 +813,7 @@ type AllowedActions = | TransakServiceCancelAllActiveOrdersAction | TransakServiceGetActiveOrdersAction | NeoBankServiceGetAutorampAction + | NeoBankServiceGetAutorampTransactionsAction | NeoBankServiceCreateAutorampAction | NeoBankServiceGetCustomerByExternalIdAction | NeoBankServiceGetWalletRegistrationStatusAction @@ -827,13 +854,30 @@ export type RampsControllerAutorampStatusChangedEvent = { ]; }; +/** + * Published when a Money Account deposit/payout transaction status transitions. + * Consumed by mobile's init layer for notifications (toast / account refresh); + * `shouldNotify` is true only for a notable transition not yet surfaced. + */ +export type RampsControllerDepositStatusChangedEvent = { + type: `${typeof controllerName}:depositStatusChanged`; + payload: [ + { + deposit: MoneyAccountDeposit; + previousStatus: MoneyAccountDeposit['status']; + shouldNotify: boolean; + }, + ]; +}; + /** * Events that {@link RampsControllerMessenger} exposes to other consumers. */ export type RampsControllerEvents = | RampsControllerStateChangeEvent | RampsControllerOrderStatusChangedEvent - | RampsControllerAutorampStatusChangedEvent; + | RampsControllerAutorampStatusChangedEvent + | RampsControllerDepositStatusChangedEvent; /** * Events from other messengers that {@link RampsController} subscribes to. @@ -993,6 +1037,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshAutoramps', 'startOrderPolling', 'stopOrderPolling', + 'refreshDeposits', + 'markDepositAsNotified', + 'removeDeposit', + 'startDepositPolling', + 'stopDepositPolling', 'getBuyWidgetData', 'addPrecreatedOrder', 'getOrder', @@ -1107,6 +1156,13 @@ export class RampsController extends BaseController< #isPolling = false; + /** Deposit poll bookkeeping (last fetch time + error count), keyed by autoramp id. */ + readonly #depositPollingMeta: Map = new Map(); + + #depositPollingTimer: ReturnType | null = null; + + #isPollingDeposits = false; + #initPromise: Promise | null = null; /** @@ -3349,6 +3405,252 @@ export class RampsController extends BaseController< ); } + /** + * Autoramps to poll for deposits: those that are Approved (deposit-ready) + * plus any autoramp that still has a non-terminal deposit locally, so an + * in-flight deposit keeps being tracked even if its route later goes + * terminal. Pre-Approved autoramps are skipped since they cannot yet have + * deposits. + * + * @returns Autoramps that should be polled for deposits. + */ + #autorampsToPollForDeposits(): AutorampAccount[] { + const autorampIdsWithPendingDeposits = new Set( + this.state.deposits + .filter((deposit) => !isTerminalDepositStatus(deposit.status)) + .map((deposit) => deposit.autorampId) + .filter((id): id is string => id !== undefined), + ); + + return this.state.autoramps.filter( + (autoramp) => + autoramp.status === AutorampStatus.Approved || + autorampIdsWithPendingDeposits.has(autoramp.id), + ); + } + + /** + * Refreshes Money Account deposit/transaction records for the pollable + * autoramps from the neo-bank proxy, applying any status changes to local + * state and emitting `depositStatusChanged`. Intended for app load / unlock + * catch-up, and reused as the deposit poll worker. Emit-only: no on-chain + * action is taken. + */ + async refreshDeposits(): Promise { + if (this.#isPollingDeposits) { + return; + } + this.#isPollingDeposits = true; + try { + await Promise.allSettled( + this.#autorampsToPollForDeposits().map(async (autoramp) => + this.#refreshAutorampDeposits(autoramp.id), + ), + ); + } finally { + this.#isPollingDeposits = false; + } + } + + /** + * Fetches the deposits for one autoramp and applies each snapshot to state. + * Updates per-autoramp poll bookkeeping (error backoff) and never throws. + * + * @param autorampId - Autoramp whose deposits to refresh. + */ + async #refreshAutorampDeposits(autorampId: string): Promise { + try { + const remotes = await this.messenger.call( + 'NeoBankService:getAutorampTransactions', + autorampId, + ); + + for (const remote of remotes) { + // Backfill the owning autoramp id from the query key when the proxy + // omits it, so the deposit stays pollable once its route goes terminal. + this.#applyDepositRemoteSnapshot({ + ...remote, + autorampId: remote.autorampId ?? autorampId, + }); + } + + const meta = this.#depositPollingMeta.get(autorampId) ?? { + lastTimeFetched: 0, + errorCount: 0, + }; + meta.errorCount = 0; + meta.lastTimeFetched = Date.now(); + this.#depositPollingMeta.set(autorampId, meta); + } catch { + const meta = this.#depositPollingMeta.get(autorampId) ?? { + lastTimeFetched: 0, + errorCount: 0, + }; + meta.errorCount = Math.min(meta.errorCount + 1, MAX_ERROR_COUNT); + meta.lastTimeFetched = Date.now(); + this.#depositPollingMeta.set(autorampId, meta); + } + } + + /** + * Applies a remote deposit snapshot onto local state (upsert), publishing + * `depositStatusChanged` when the status transitions. Shared by catch-up and + * poll paths. + * + * @param remote - Remote deposit snapshot from the proxy. + * @returns The upserted local deposit. + */ + #applyDepositRemoteSnapshot( + remote: MoneyAccountDepositRemoteSnapshot, + ): MoneyAccountDeposit { + const local = + this.state.deposits.find((deposit) => deposit.id === remote.id) ?? null; + const result = applyDepositRemoteStatus(local, remote); + + // Only write when the snapshot is new or materially changed, so an + // unchanged deposit does not churn `updatedAt` / `stateChange` on every + // 30s poll for the life of its (long-lived) autoramp. + if (result.changed) { + this.update((state) => { + const idx = state.deposits.findIndex( + (deposit) => deposit.id === result.deposit.id, + ); + if (idx === -1) { + state.deposits.push(result.deposit as Draft); + } else { + state.deposits[idx] = result.deposit as Draft; + } + }); + } + + if (result.statusChanged) { + this.messenger.publish('RampsController:depositStatusChanged', { + deposit: result.deposit, + previousStatus: result.previousStatus, + shouldNotify: result.shouldNotify, + }); + } + + return ( + this.state.deposits.find((deposit) => deposit.id === result.deposit.id) ?? + result.deposit + ); + } + + /** + * Marks that the UI has already notified for the deposit's current status, + * so a later transition back into the same notable status does not re-notify. + * Consumers call this after surfacing a `depositStatusChanged` with + * `shouldNotify: true`. + * + * @param depositId - Proxy deposit/transaction id. + */ + markDepositAsNotified(depositId: string): void { + const existing = this.state.deposits.find( + (deposit) => deposit.id === depositId, + ); + if (!existing) { + return; + } + const notified = markDepositNotified(existing); + this.update((state) => { + const idx = state.deposits.findIndex( + (deposit) => deposit.id === depositId, + ); + if (idx !== -1) { + state.deposits[idx] = notified as Draft; + } + }); + } + + /** + * Removes a local deposit record by id. Useful for pruning deposits under a + * completed or cancelled autoramp that is no longer polled. Note: a deposit + * under an actively polled (`Approved`) autoramp is a live mirror of the + * proxy, so removing it here only sticks once its autoramp is no longer + * pollable; otherwise the next poll re-syncs it. + * + * @param depositId - Proxy deposit/transaction id. + */ + removeDeposit(depositId: string): void { + this.update((state) => { + state.deposits = state.deposits.filter( + (deposit) => deposit.id !== depositId, + ); + }); + } + + /** + * Starts polling Money Account deposits for active autoramps at a fixed + * interval. Emit-only: publishes `depositStatusChanged` on transitions and + * takes no on-chain action (vault sweeping is owned by the backend). + */ + startDepositPolling(): void { + if (this.#depositPollingTimer) { + return; + } + + this.#depositPollingTimer = setInterval(() => { + this.#pollPendingDeposits().catch(() => undefined); + }, DEFAULT_POLLING_INTERVAL_MS); + + this.#pollPendingDeposits().catch(() => undefined); + } + + /** + * Stops deposit polling and clears the interval. + */ + stopDepositPolling(): void { + if (this.#depositPollingTimer) { + clearInterval(this.#depositPollingTimer); + this.#depositPollingTimer = null; + } + } + + async #pollPendingDeposits(): Promise { + if (this.#isPollingDeposits) { + return; + } + this.#isPollingDeposits = true; + try { + const autoramps = this.#autorampsToPollForDeposits(); + const activeIds = new Set(autoramps.map((autoramp) => autoramp.id)); + + // Drop backoff bookkeeping for autoramps that are no longer polled. + for (const id of this.#depositPollingMeta.keys()) { + if (!activeIds.has(id)) { + this.#depositPollingMeta.delete(id); + } + } + + const now = Date.now(); + + await Promise.allSettled( + autoramps.map(async (autoramp) => { + const meta = this.#depositPollingMeta.get(autoramp.id); + + // errorCount === 1 yields a backoff equal to the interval (no extra + // wait); exponential backoff begins at the 2nd consecutive error. + // Kept identical to the order poller (#pollPendingOrders) on purpose. + if (meta && meta.errorCount > 0) { + const backoffMs = Math.min( + DEFAULT_POLLING_INTERVAL_MS * Math.pow(2, meta.errorCount - 1), + 5 * 60 * 1000, + ); + + if (now - meta.lastTimeFetched < backoffMs) { + return; + } + } + + await this.#refreshAutorampDeposits(autoramp.id); + }), + ); + } finally { + this.#isPollingDeposits = false; + } + } + /** * Refreshes a single order via the V2 API and updates it in state. * Publishes orderStatusChanged if the status transitioned. @@ -3483,6 +3785,7 @@ export class RampsController extends BaseController< */ override destroy(): void { this.stopOrderPolling(); + this.stopDepositPolling(); super.destroy(); } diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index b52fbe4eb8c..1f949162457 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -7,6 +7,7 @@ export type { RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, RampsControllerAutorampStatusChangedEvent, + RampsControllerDepositStatusChangedEvent, RampsControllerOptions, PaymentMethodsForContextResponse, UserRegion, @@ -44,6 +45,11 @@ export type { RampsControllerRefreshAutorampsAction, RampsControllerStartOrderPollingAction, RampsControllerStopOrderPollingAction, + RampsControllerRefreshDepositsAction, + RampsControllerMarkDepositAsNotifiedAction, + RampsControllerRemoveDepositAction, + RampsControllerStartDepositPollingAction, + RampsControllerStopDepositPollingAction, RampsControllerGetBuyWidgetDataAction, RampsControllerAddPrecreatedOrderAction, RampsControllerGetOrderAction, @@ -263,11 +269,28 @@ export type { AutorampDepositRailsSummary, AutorampRemoteSnapshot, } from './autoramp-types.js'; +export type { + MoneyAccountDeposit, + MoneyAccountDepositRemoteSnapshot, + ApplyDepositRemoteStatusResult, +} from './moneyAccountDeposit.js'; +export { + MoneyAccountDepositStatus, + TERMINAL_DEPOSIT_STATUSES, + NOTABLE_DEPOSIT_STATUSES, + isTerminalDepositStatus, + normalizeDepositStatus, + createMoneyAccountDeposit, + applyDepositRemoteStatus, + markDepositNotified, +} from './moneyAccountDeposit.js'; export type { NeoBankServiceActions, NeoBankServiceEvents, NeoBankServiceMessenger, NeoBankAutorampResponse, + NeoBankTransactionResponse, + NeoBankTransactionsResponse, NeoBankRequestOptions, NeoBankQueryParams, GetWalletRegistrationStatusParams, @@ -275,6 +298,7 @@ export type { } from './NeoBankService.js'; export type { NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampTransactionsAction, NeoBankServiceRegisterPixAddressAction, NeoBankServiceGetAutorampQuoteAction, NeoBankServiceCreateAutorampAction, @@ -289,6 +313,7 @@ export { NeoBankService, serviceName as neoBankServiceName, mapNeoBankAutorampToRemoteSnapshot, + mapNeoBankTransactionToRemoteSnapshot, } from './NeoBankService.js'; export type { Blockchain, diff --git a/packages/ramps-controller/src/moneyAccountDeposit.test.ts b/packages/ramps-controller/src/moneyAccountDeposit.test.ts new file mode 100644 index 00000000000..c37f1b27b2d --- /dev/null +++ b/packages/ramps-controller/src/moneyAccountDeposit.test.ts @@ -0,0 +1,290 @@ +import type { Hex } from '@metamask/utils'; + +import type { + ApplyDepositRemoteStatusResult, + MoneyAccountDeposit, + MoneyAccountDepositRemoteSnapshot, +} from './moneyAccountDeposit.js'; +import { + MoneyAccountDepositStatus, + applyDepositRemoteStatus, + createMoneyAccountDeposit, + isTerminalDepositStatus, + markDepositNotified, + normalizeDepositStatus, +} from './moneyAccountDeposit.js'; + +const MONEY_ACCOUNT = '0xaccount' as Hex; +const PAYOUT_HASH = '0xpayout' as Hex; + +describe('moneyAccountDeposit', () => { + describe('normalizeDepositStatus', () => { + it('returns known statuses as-is', () => { + expect(normalizeDepositStatus(MoneyAccountDepositStatus.Completed)).toBe( + MoneyAccountDepositStatus.Completed, + ); + expect( + normalizeDepositStatus(MoneyAccountDepositStatus.PayoutInProgress), + ).toBe(MoneyAccountDepositStatus.PayoutInProgress); + }); + + it('maps an unknown status to a non-terminal in-progress value', () => { + const normalized = normalizeDepositStatus('Nope'); + + expect(normalized).toBe(MoneyAccountDepositStatus.FundsReviewInProgress); + // Unknown statuses must never look terminal (that would fire a bogus + // notification and stop polling early). + expect(isTerminalDepositStatus(normalized)).toBe(false); + }); + }); + + describe('isTerminalDepositStatus', () => { + it('identifies terminal statuses', () => { + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Completed)).toBe( + true, + ); + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Failed)).toBe( + true, + ); + expect( + isTerminalDepositStatus(MoneyAccountDepositStatus.RejectedAml), + ).toBe(true); + expect( + isTerminalDepositStatus(MoneyAccountDepositStatus.RejectedFraud), + ).toBe(true); + expect( + isTerminalDepositStatus(MoneyAccountDepositStatus.RejectedMinAmount), + ).toBe(true); + expect( + isTerminalDepositStatus( + MoneyAccountDepositStatus.FundsReviewInProgress, + ), + ).toBe(false); + expect( + isTerminalDepositStatus(MoneyAccountDepositStatus.PayoutInProgress), + ).toBe(false); + }); + }); + + describe('createMoneyAccountDeposit', () => { + it('defaults status to FundsReviewInProgress and mirrors lastSeenStatus', () => { + const deposit = createMoneyAccountDeposit({ + id: 'dep-1', + moneyAccountAddress: MONEY_ACCOUNT, + updatedAt: 1000, + }); + + expect(deposit).toStrictEqual({ + id: 'dep-1', + autorampId: undefined, + moneyAccountAddress: MONEY_ACCOUNT, + status: MoneyAccountDepositStatus.FundsReviewInProgress, + payoutTransactionHash: undefined, + amount: undefined, + currency: undefined, + lastSeenStatus: MoneyAccountDepositStatus.FundsReviewInProgress, + updatedAt: 1000, + }); + }); + + it('carries optional display + payout fields', () => { + const deposit = createMoneyAccountDeposit({ + id: 'dep-1', + moneyAccountAddress: MONEY_ACCOUNT, + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: PAYOUT_HASH, + amount: '100.00', + currency: 'BRL', + updatedAt: 5, + }); + + expect(deposit).toMatchObject({ + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: PAYOUT_HASH, + amount: '100.00', + currency: 'BRL', + }); + }); + }); + + describe('applyDepositRemoteStatus', () => { + const baseLocal: MoneyAccountDeposit = createMoneyAccountDeposit({ + id: 'dep-1', + moneyAccountAddress: MONEY_ACCOUNT, + autorampId: 'ar-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + updatedAt: 1, + }); + + it('creates a local deposit without notify when local is null', () => { + const remote: MoneyAccountDepositRemoteSnapshot = { + id: 'dep-1', + autorampId: 'ar-1', + moneyAccountAddress: MONEY_ACCOUNT, + status: MoneyAccountDepositStatus.PayoutInProgress, + }; + + const result = applyDepositRemoteStatus(null, remote); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + expect(result.deposit.status).toBe( + MoneyAccountDepositStatus.PayoutInProgress, + ); + expect(result.deposit.autorampId).toBe('ar-1'); + }); + + it('detects Completed transition and requests notify once', () => { + const remote: MoneyAccountDepositRemoteSnapshot = { + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: PAYOUT_HASH, + }; + + const result = applyDepositRemoteStatus(baseLocal, remote); + + expect(result).toMatchObject({ + previousStatus: MoneyAccountDepositStatus.PayoutInProgress, + statusChanged: true, + shouldNotify: true, + } satisfies Partial); + expect(result.deposit.status).toBe(MoneyAccountDepositStatus.Completed); + expect(result.deposit.lastSeenStatus).toBe( + MoneyAccountDepositStatus.PayoutInProgress, + ); + expect(result.deposit.payoutTransactionHash).toBe(PAYOUT_HASH); + }); + + it('treats a Rejected transition as terminal and notable', () => { + expect( + isTerminalDepositStatus(MoneyAccountDepositStatus.RejectedAml), + ).toBe(true); + + const result = applyDepositRemoteStatus(baseLocal, { + id: 'dep-1', + status: MoneyAccountDepositStatus.RejectedAml, + }); + + expect(result.statusChanged).toBe(true); + expect(result.shouldNotify).toBe(true); + expect(result.deposit.status).toBe(MoneyAccountDepositStatus.RejectedAml); + }); + + it('does not notify again when already notified for that status', () => { + const local = markDepositNotified({ + ...baseLocal, + status: MoneyAccountDepositStatus.Completed, + lastSeenStatus: MoneyAccountDepositStatus.PayoutInProgress, + notifiedForStatus: MoneyAccountDepositStatus.Completed, + }); + + const result = applyDepositRemoteStatus(local, { + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + }); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + }); + + it('does not notify for non-notable transitions', () => { + const local = createMoneyAccountDeposit({ + id: 'dep-1', + moneyAccountAddress: MONEY_ACCOUNT, + status: MoneyAccountDepositStatus.FundsReviewInProgress, + updatedAt: 1, + }); + + const result = applyDepositRemoteStatus(local, { + id: 'dep-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + }); + + expect(result.statusChanged).toBe(true); + expect(result.shouldNotify).toBe(false); + }); + + it('notifies for Failed', () => { + const result = applyDepositRemoteStatus(baseLocal, { + id: 'dep-1', + status: MoneyAccountDepositStatus.Failed, + }); + + expect(result.shouldNotify).toBe(true); + }); + + it('preserves a previously observed payout hash when a later snapshot omits it', () => { + const local: MoneyAccountDeposit = { + ...baseLocal, + status: MoneyAccountDepositStatus.Completed, + payoutTransactionHash: PAYOUT_HASH, + }; + + const result = applyDepositRemoteStatus(local, { + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + }); + + expect(result.deposit.payoutTransactionHash).toBe(PAYOUT_HASH); + }); + + it('preserves local money account address when the snapshot omits it', () => { + const result = applyDepositRemoteStatus(baseLocal, { + id: 'dep-1', + status: MoneyAccountDepositStatus.Completed, + }); + + expect(result.deposit.moneyAccountAddress).toBe(MONEY_ACCOUNT); + }); + + it('reports changed for a newly created record', () => { + const result = applyDepositRemoteStatus(null, { + id: 'dep-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + }); + + expect(result.changed).toBe(true); + }); + + it('reports not-changed and returns the untouched local when nothing changed', () => { + const result = applyDepositRemoteStatus(baseLocal, { + id: 'dep-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + }); + + expect(result.changed).toBe(false); + expect(result.statusChanged).toBe(false); + // Same reference + untouched updatedAt: caller can skip the write. + expect(result.deposit).toBe(baseLocal); + expect(result.deposit.updatedAt).toBe(baseLocal.updatedAt); + }); + + it('reports changed when only a field changes without a status change', () => { + const result = applyDepositRemoteStatus(baseLocal, { + id: 'dep-1', + status: MoneyAccountDepositStatus.PayoutInProgress, + payoutTransactionHash: PAYOUT_HASH, + }); + + expect(result.statusChanged).toBe(false); + expect(result.changed).toBe(true); + expect(result.deposit.payoutTransactionHash).toBe(PAYOUT_HASH); + }); + }); + + describe('markDepositNotified', () => { + it('sets notifiedForStatus to current status', () => { + const deposit = createMoneyAccountDeposit({ + id: 'dep-1', + moneyAccountAddress: MONEY_ACCOUNT, + status: MoneyAccountDepositStatus.Completed, + }); + + expect(markDepositNotified(deposit).notifiedForStatus).toBe( + MoneyAccountDepositStatus.Completed, + ); + }); + }); +}); diff --git a/packages/ramps-controller/src/moneyAccountDeposit.ts b/packages/ramps-controller/src/moneyAccountDeposit.ts new file mode 100644 index 00000000000..6e8d743c8eb --- /dev/null +++ b/packages/ramps-controller/src/moneyAccountDeposit.ts @@ -0,0 +1,325 @@ +/** + * Local + remote models for Money Account deposit/payout transactions. + * + * A deposit is a single payment instance flowing through an + * {@link ./autorampAccount.ts AutorampAccount} (the standing route): the partner + * receives fiat (e.g. a Pix payment in Brazil) and pays out mUSD on Monad to the + * user's Money Account. Deposits are tracked separately from autoramps because a + * single autoramp can produce many deposits over time, each with its own status + * lifecycle, payout transaction hash, and notification bookkeeping. + * + * NOTE: The status values below mirror MoonPay Enterprise's + * `AutorampTransactionStatus` as surfaced by the neobank-proxy (onramp-api PR + * #1124, raw MoonPay). TRAM-3925 will introduce a mobile-safe DTO that may + * rename these fields, so keep {@link normalizeDepositStatus} tolerant of + * unknown values. + */ + +import type { Hex } from '@metamask/utils'; + +/** + * Deposit/transaction lifecycle statuses from the neo-bank proxy. + * + * These are MoonPay Enterprise's `AutorampTransactionStatus` values: three + * in-progress states followed by five terminal outcomes (one success, four + * failure/rejection). + */ +export enum MoneyAccountDepositStatus { + /** Partner is reviewing the received funds (first in-progress state). */ + FundsReviewInProgress = 'FundsReviewInProgress', + /** Received fiat is being converted to crypto. */ + ConversionInProgress = 'ConversionInProgress', + /** Crypto payout is being sent on-chain. */ + PayoutInProgress = 'PayoutInProgress', + /** Payout settled on Monad; `payoutTransactionHash` is available. */ + Completed = 'Completed', + /** Terminal failure. */ + Failed = 'Failed', + /** Rejected by AML screening. */ + RejectedAml = 'RejectedAml', + /** Rejected by fraud screening. */ + RejectedFraud = 'RejectedFraud', + /** Rejected for being under the minimum amount. */ + RejectedMinAmount = 'RejectedMinAmount', +} + +/** + * Local controller representation of a Money Account deposit. + */ +export type MoneyAccountDeposit = { + /** Proxy deposit/transaction id (dedupe key). */ + id: string; + /** Owning autoramp id, when known. */ + autorampId?: string; + /** Destination Money Account address (the mUSD payout recipient), when known. */ + moneyAccountAddress?: Hex; + /** Latest status from the partner (source of truth after refresh). */ + status: MoneyAccountDepositStatus; + /** + * Monad payout transaction hash, present once the payout settles on-chain. + * Surfaced for display/analytics (and any future vault sweep, which is out of + * scope for this ticket). Preserved across refreshes; a later snapshot must + * never null it out. + */ + payoutTransactionHash?: Hex; + /** Optional payout amount as returned by the partner (display only). */ + amount?: string; + /** Optional currency code for {@link amount} (display only). */ + currency?: string; + /** + * Status observed before the most recent remote apply. + * Used for transition UX / analytics (e.g. PayoutInProgress to Completed). + */ + lastSeenStatus: MoneyAccountDepositStatus; + /** + * Last status for which the UI already showed a notification. + * Prevents duplicate toasts across refreshes. + */ + notifiedForStatus?: MoneyAccountDepositStatus; + /** Epoch ms of the last local update from remote. */ + updatedAt: number; +}; + +/** + * Minimal remote snapshot from the neo-bank proxy transactions endpoint. + * The service maps proxy responses into this shape. + */ +export type MoneyAccountDepositRemoteSnapshot = { + id: string; + autorampId?: string; + moneyAccountAddress?: Hex; + status: MoneyAccountDepositStatus | string; + payoutTransactionHash?: Hex; + amount?: string; + currency?: string; +}; + +/** + * Result of applying a remote deposit snapshot onto local state. + */ +export type ApplyDepositRemoteStatusResult = { + deposit: MoneyAccountDeposit; + previousStatus: MoneyAccountDepositStatus; + statusChanged: boolean; + /** True when status changed and UI has not yet notified for the new status. */ + shouldNotify: boolean; + /** + * True when the snapshot produced a materially different record (new record, + * status change, or any field change). When false the returned `deposit` is + * the unchanged local record, so callers can skip a redundant state write and + * avoid churning `updatedAt` / `stateChange` on every poll. + */ + changed: boolean; +}; + +/** + * Terminal deposit statuses (no further lifecycle progress expected). + */ +export const TERMINAL_DEPOSIT_STATUSES: ReadonlySet = + new Set([ + MoneyAccountDepositStatus.Completed, + MoneyAccountDepositStatus.Failed, + MoneyAccountDepositStatus.RejectedAml, + MoneyAccountDepositStatus.RejectedFraud, + MoneyAccountDepositStatus.RejectedMinAmount, + ]); + +/** + * Statuses that warrant user-visible transition UX (toast / banner). Every + * terminal outcome is notable: a landed deposit (`Completed`) or a + * failure/rejection the user should be told about. + */ +export const NOTABLE_DEPOSIT_STATUSES: ReadonlySet = + new Set([ + MoneyAccountDepositStatus.Completed, + MoneyAccountDepositStatus.Failed, + MoneyAccountDepositStatus.RejectedAml, + MoneyAccountDepositStatus.RejectedFraud, + MoneyAccountDepositStatus.RejectedMinAmount, + ]); + +/** + * Whether a deposit status is terminal. + * + * @param status - Status to test. + * @returns Whether the status is terminal. + */ +export function isTerminalDepositStatus( + status: MoneyAccountDepositStatus, +): boolean { + return TERMINAL_DEPOSIT_STATUSES.has(status); +} + +/** + * Normalize a remote status string into {@link MoneyAccountDepositStatus}. + * + * Unknown values fall back to a non-terminal in-progress status + * ({@link MoneyAccountDepositStatus.FundsReviewInProgress}) so an unrecognized + * status keeps polling instead of being mistaken for a terminal outcome and + * firing a bogus notification. Unknown-status handling is best-effort until the + * mobile-safe DTO (TRAM-3925) pins the wire values. + * + * @param status - Remote status string. + * @returns A known {@link MoneyAccountDepositStatus}. + */ +export function normalizeDepositStatus( + status: MoneyAccountDepositStatus | string, +): MoneyAccountDepositStatus { + if ( + Object.values(MoneyAccountDepositStatus).includes( + status as MoneyAccountDepositStatus, + ) + ) { + return status as MoneyAccountDepositStatus; + } + return MoneyAccountDepositStatus.FundsReviewInProgress; +} + +/** + * Build a new local deposit record from create/response fields. + * + * @param input - Deposit fields. + * @param input.id - Proxy deposit/transaction id. + * @param input.moneyAccountAddress - Destination Money Account address. + * @param input.status - Current deposit status (defaults to FundsReviewInProgress). + * @param input.autorampId - Owning autoramp id, when known. + * @param input.payoutTransactionHash - Monad payout hash, when settled. + * @param input.amount - Optional payout amount for display. + * @param input.currency - Optional currency code for the amount. + * @param input.updatedAt - Epoch ms of this update (defaults to now). + * @returns A new {@link MoneyAccountDeposit}. + */ +export function createMoneyAccountDeposit(input: { + id: string; + moneyAccountAddress?: Hex; + status?: MoneyAccountDepositStatus | string; + autorampId?: string; + payoutTransactionHash?: Hex; + amount?: string; + currency?: string; + updatedAt?: number; +}): MoneyAccountDeposit { + const status = normalizeDepositStatus( + input.status ?? MoneyAccountDepositStatus.FundsReviewInProgress, + ); + return { + id: input.id, + autorampId: input.autorampId, + moneyAccountAddress: input.moneyAccountAddress, + status, + payoutTransactionHash: input.payoutTransactionHash, + amount: input.amount, + currency: input.currency, + lastSeenStatus: status, + updatedAt: input.updatedAt ?? Date.now(), + }; +} + +/** + * Apply a remote deposit snapshot onto a local deposit for transition detection. + * Pure helper, shared by refresh-on-poll paths. + * + * @param local - Current local deposit (or null when first upserting from remote). + * @param remote - Remote snapshot from the neo-bank proxy. + * @returns Updated deposit plus change / notify flags. + */ +export function applyDepositRemoteStatus( + local: MoneyAccountDeposit | null, + remote: MoneyAccountDepositRemoteSnapshot, +): ApplyDepositRemoteStatusResult { + const remoteStatus = normalizeDepositStatus(remote.status); + + // First observation: record the deposit for display but report no status + // transition (there is no prior local state to have changed from), so a + // deposit first seen already-terminal is stored without firing a notification. + if (!local) { + const deposit = createMoneyAccountDeposit({ + id: remote.id, + autorampId: remote.autorampId, + moneyAccountAddress: remote.moneyAccountAddress, + status: remoteStatus, + payoutTransactionHash: remote.payoutTransactionHash, + amount: remote.amount, + currency: remote.currency, + }); + return { + deposit, + previousStatus: remoteStatus, + statusChanged: false, + shouldNotify: false, + changed: true, + }; + } + + const previousStatus = local.status; + const statusChanged = previousStatus !== remoteStatus; + const shouldNotify = + statusChanged && + local.notifiedForStatus !== remoteStatus && + NOTABLE_DEPOSIT_STATUSES.has(remoteStatus); + + // Merge fields, never nulling out a value already observed. + const autorampId = remote.autorampId ?? local.autorampId; + const moneyAccountAddress = + remote.moneyAccountAddress ?? local.moneyAccountAddress; + const payoutTransactionHash = + remote.payoutTransactionHash ?? local.payoutTransactionHash; + const amount = remote.amount ?? local.amount; + const currency = remote.currency ?? local.currency; + + const changed = + statusChanged || + autorampId !== local.autorampId || + moneyAccountAddress !== local.moneyAccountAddress || + payoutTransactionHash !== local.payoutTransactionHash || + amount !== local.amount || + currency !== local.currency; + + // Nothing material changed: return the untouched local record so the caller + // can skip a redundant write (avoids per-poll `updatedAt` / stateChange churn). + if (!changed) { + return { + deposit: local, + previousStatus, + statusChanged: false, + shouldNotify: false, + changed: false, + }; + } + + const deposit: MoneyAccountDeposit = { + ...local, + id: remote.id, + autorampId, + moneyAccountAddress, + status: remoteStatus, + payoutTransactionHash, + amount, + currency, + lastSeenStatus: previousStatus, + updatedAt: Date.now(), + }; + + return { + deposit, + previousStatus, + statusChanged, + shouldNotify, + changed: true, + }; +} + +/** + * Mark that the UI has notified for the deposit's current status. + * + * @param deposit - Deposit to update. + * @returns Deposit with `notifiedForStatus` set to current status. + */ +export function markDepositNotified( + deposit: MoneyAccountDeposit, +): MoneyAccountDeposit { + return { + ...deposit, + notifiedForStatus: deposit.status, + }; +}