From 750fd255ff058287837fffd81a622785bed13654 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Thu, 3 Sep 2026 09:22:45 -0700 Subject: [PATCH 1/2] feat(ramps): poll neo-bank deposits and emit status notifications Add emit-only Money Account deposit polling to RampsController for the MM Neobank onramp flow (TRAM-3898, "poll for deposit and notifications"). A sibling poll loop fetches each pollable autoramp's deposit/transaction records from the neo-bank proxy, keeps a persisted state.deposits clone, and publishes RampsController:depositStatusChanged on status transitions so the app can show a toast or refresh the account screen. The poller takes no on-chain action; vault sweeping stays with the backend. - Add NeoBankService.getAutorampTransactions and the matching messenger action, mapping proxy transactions (bare array or a { transactions } envelope) via the exported mapNeoBankTransactionToRemoteSnapshot. Rejects items missing id/status. - Add the moneyAccountDeposit model: MoneyAccountDeposit, MoneyAccountDepositStatus, the pure applyDepositRemoteStatus diff, and helpers, mirroring autorampAccount. - Add startDepositPolling / stopDepositPolling / refreshDeposits, plus markDepositAsNotified (notify dedupe) and removeDeposit (prune persisted state), a persisted state.deposits array, and the depositStatusChanged event, reusing the order poller's 30s interval and error backoff. - Poll only Approved autoramps, or ones with an in-flight local deposit, so an in-flight deposit keeps being tracked even if its route later goes terminal. --- packages/ramps-controller/CHANGELOG.md | 5 + .../src/NeoBankService-method-action-types.ts | 17 + .../src/NeoBankService.test.ts | 132 ++++++ .../ramps-controller/src/NeoBankService.ts | 98 ++++ .../RampsController-method-action-types.ts | 59 +++ .../src/RampsController.test.ts | 442 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 288 +++++++++++- packages/ramps-controller/src/index.ts | 25 + .../src/moneyAccountDeposit.test.ts | 228 +++++++++ .../src/moneyAccountDeposit.ts | 262 +++++++++++ 10 files changed, 1554 insertions(+), 2 deletions(-) create mode 100644 packages/ramps-controller/src/moneyAccountDeposit.test.ts create mode 100644 packages/ramps-controller/src/moneyAccountDeposit.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 557119c6903..c10c2da6aeb 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. + - 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`). +- Add `NeoBankService.getAutorampTransactions(autorampId)` and the `NeoBankService:getAutorampTransactions` messenger action, which fetch deposit/transaction records from neobank-proxy `GET /neobank/autoramps/{id}/transactions` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot` (accepting either a bare array or a `{ transactions }` envelope). - Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 3343ed3ba87..1f37783c9c8 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/autoramps/{autoramp_id}/transactions`. + * + * Used by the deposit poller to detect status changes (e.g. a payout settling + * on Monad). Route + response shape are assumed pending the proxy contract + * (onramp-api #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). @@ -99,6 +115,7 @@ export type NeoBankServiceGetCustomerByExternalIdAction = { */ 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 c2f40d582ac..e49e292439e 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -2,6 +2,7 @@ import nock from 'nock'; import { mapNeoBankAutorampToRemoteSnapshot, + mapNeoBankTransactionToRemoteSnapshot, NeoBankService, } from './NeoBankService.js'; import type { NeoBankServiceMessenger } from './NeoBankService.js'; @@ -110,6 +111,137 @@ describe('NeoBankService', () => { }); }); + describe('mapNeoBankTransactionToRemoteSnapshot', () => { + it('maps proxy transaction fields into a deposit snapshot', () => { + expect( + mapNeoBankTransactionToRemoteSnapshot({ + id: 'dep-1', + autoramp_id: 'ar-1', + status: 'Completed', + money_account_address: '0xaccount', + payout_transaction_hash: '0xpayout', + amount: '100.00', + currency: 'BRL', + }), + ).toStrictEqual({ + id: 'dep-1', + autorampId: 'ar-1', + moneyAccountAddress: '0xaccount', + status: 'Completed', + payoutTransactionHash: '0xpayout', + amount: '100.00', + currency: 'BRL', + }); + }); + + it('falls back to a nested payout.transaction_hash', () => { + expect( + mapNeoBankTransactionToRemoteSnapshot({ + id: 'dep-1', + status: 'Completed', + payout: { transaction_hash: '0xnested' }, + }), + ).toMatchObject({ payoutTransactionHash: '0xnested' }); + }); + + it('leaves the payout hash undefined when the proxy omits it', () => { + expect( + mapNeoBankTransactionToRemoteSnapshot({ + id: 'dep-1', + status: 'Pending', + }), + ).toMatchObject({ payoutTransactionHash: undefined }); + }); + }); + + describe('getAutorampTransactions', () => { + it('fetches /neobank/autoramps/{id}/transactions and maps an array body', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1\/transactions/u) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, [ + { + id: 'dep-1', + autoramp_id: 'ar-1', + status: 'Completed', + payout_transaction_hash: '0xpayout', + }, + ]); + + const service = createService(); + const snapshots = await service.getAutorampTransactions('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshots).toStrictEqual([ + { + id: 'dep-1', + autorampId: 'ar-1', + moneyAccountAddress: undefined, + status: 'Completed', + payoutTransactionHash: '0xpayout', + amount: undefined, + currency: undefined, + }, + ]); + }); + + it('accepts a { transactions } envelope', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1\/transactions/u) + .reply(200, { transactions: [{ id: 'dep-1', status: 'Pending' }] }); + + const service = createService(); + const snapshots = await service.getAutorampTransactions('ar-1'); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ id: 'dep-1', status: 'Pending' }); + }); + + it('throws HttpError when the proxy returns a non-2xx status', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1\/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\/autoramps\/ar-1\/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\/autoramps\/ar-1\/transactions/u) + .reply(200, [{ status: 'Pending' }]); + + 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\/autoramps\/ar-1\/transactions/u) + .reply(200, [{ 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 e13d8b1ef1d..5203d483d9c 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -5,12 +5,14 @@ import type { import { createServicePolicy, HttpError } 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 './autorampAccount.js'; +import type { MoneyAccountDepositRemoteSnapshot } from './moneyAccountDeposit.js'; import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; @@ -40,6 +42,39 @@ 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). Field names mirror + * the assumed neobank-proxy transactions contract (onramp-api #1124) and may + * evolve — keep the mapper tolerant. + */ +/* eslint-disable @typescript-eslint/naming-convention -- snake_case proxy wire format */ +export type NeoBankTransactionResponse = { + id: string; + status: string; + autoramp_id?: string; + money_account_address?: string; + /** Monad payout transaction hash when the payout has settled on-chain. */ + payout_transaction_hash?: string; + /** Alternate nested location for the payout hash, if the proxy nests it. */ + payout?: { + transaction_hash?: string; + }; + amount?: string; + currency?: string; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Envelope returned by the neo-bank transactions endpoint. The proxy may return + * a bare array or wrap it under `transactions`; the mapper accepts both. + */ +export type NeoBankTransactionsResponse = + | NeoBankTransactionResponse[] + | { transactions?: NeoBankTransactionResponse[] }; + /** * Optional headers for neo-bank mutating requests. */ @@ -61,6 +96,7 @@ export type NeoBankQueryParams = Record< const MESSENGER_EXPOSED_METHODS = [ 'getAutoramp', + 'getAutorampTransactions', 'registerPixAddress', 'getAutorampQuote', 'createAutoramp', @@ -154,6 +190,29 @@ 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 { + const payoutTransactionHash = + response.payout_transaction_hash ?? response.payout?.transaction_hash; + + return { + id: response.id, + autorampId: response.autoramp_id, + moneyAccountAddress: response.money_account_address as Hex | undefined, + status: response.status, + payoutTransactionHash: payoutTransactionHash as Hex | undefined, + amount: response.amount, + currency: response.currency, + }; +} + /** * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). * @@ -293,6 +352,25 @@ export class NeoBankService { return mapNeoBankAutorampToRemoteSnapshot(response); } + #mapTransactionsResponse( + response: NeoBankTransactionsResponse, + ): MoneyAccountDepositRemoteSnapshot[] { + const list = Array.isArray(response) ? response : response?.transactions; + 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 @@ -308,6 +386,26 @@ export class NeoBankService { return this.#mapAutorampResponse(response); } + /** + * Fetches deposit/transaction records for an autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/transactions`. + * + * Used by the deposit poller to detect status changes (e.g. a payout settling + * on Monad). Route + response shape are assumed pending the proxy contract + * (onramp-api #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( + `autoramps/${encodeURIComponent(autorampId)}/transactions`, + ); + 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 4fa4d1f19e7..a88dffdc139 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -359,6 +359,60 @@ export type RampsControllerSyncAutorampsWithUserStorageAction = { handler: RampsController['syncAutorampsWithUserStorage']; }; +/** + * 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. Lets consumers prune settled or stale + * deposits so the persisted `deposits` array does not grow without bound. + * + * @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, @@ -775,6 +829,11 @@ export type RampsControllerMethodActions = | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction | RampsControllerSyncAutorampsWithUserStorageAction + | 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 e71ecf6b9df..d728303b000 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 { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; import { AutorampStatus } from './autorampAccount.js'; +import { MoneyAccountDepositStatus } from './moneyAccountDeposit.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -112,6 +113,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -189,6 +191,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -2209,6 +2212,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -2276,6 +2280,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "orders": [], "paymentMethods": { "data": [], @@ -2313,6 +2318,7 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` { "autoramps": [], + "deposits": [], "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2338,6 +2344,7 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "deposits": [], "nativeProviders": { "transak": { "buyQuote": { @@ -9617,6 +9624,441 @@ 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.Pending, + }, + ], + ); + + 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.Pending, + }); + + 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.Processing }, + ]) + .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.Processing, + 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 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.Pending, + }, + ]); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutorampTransactions', + getTransactions, + ); + + rootMessenger.call('RampsController:startDepositPolling'); + await jest.advanceTimersByTimeAsync(0); + expect(getTransactions).toHaveBeenCalledTimes(1); + + // Route cancelled, but the Pending 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('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([]); + }); + }); + }); + describe('order polling', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e3d41c7185a..10ba89e92a7 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -21,16 +21,29 @@ import type { } from './autorampAccount.js'; import { applyAutorampRemoteStatus, + AutorampStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js'; +import type { + MoneyAccountDeposit, + MoneyAccountDepositRemoteSnapshot, +} from './moneyAccountDeposit.js'; +import { + applyDepositRemoteStatus, + isTerminalDepositStatus, + markDepositNotified, +} from './moneyAccountDeposit.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 { + NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampTransactionsAction, +} 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'; @@ -191,6 +204,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', + 'NeoBankService:getAutorampTransactions', ]; /** @@ -425,6 +439,13 @@ export type RampsControllerState = { * push; persisted for rediscovery and transition UX. */ 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 @@ -492,6 +513,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + deposits: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -559,6 +586,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, orders: [], autoramps: [], + deposits: [], providerAutoSelected: false, }; } @@ -685,6 +713,7 @@ type AllowedActions = | TransakServiceCancelAllActiveOrdersAction | TransakServiceGetActiveOrdersAction | NeoBankServiceGetAutorampAction + | NeoBankServiceGetAutorampTransactionsAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -722,13 +751,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. @@ -886,6 +932,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'syncAutorampsWithUserStorage', 'startOrderPolling', 'stopOrderPolling', + 'refreshDeposits', + 'markDepositAsNotified', + 'removeDeposit', + 'startDepositPolling', + 'stopDepositPolling', 'getBuyWidgetData', 'addPrecreatedOrder', 'getOrder', @@ -961,6 +1012,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; #isAutorampSyncingInProgress = false; @@ -2800,6 +2858,231 @@ export class RampsController extends BaseController< return upserted; } + /** + * 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 { + await Promise.allSettled( + this.#autorampsToPollForDeposits().map(async (autoramp) => + this.#refreshAutorampDeposits(autoramp.id), + ), + ); + } + + /** + * 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) { + this.#applyDepositRemoteSnapshot(remote); + } + + 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); + + 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. Lets consumers prune settled or stale + * deposits so the persisted `deposits` array does not grow without bound. + * + * @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. @@ -2934,6 +3217,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 5dbe85e45ed..78409368e79 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, UserRegion, ResourceState, @@ -39,6 +40,11 @@ export type { RampsControllerSyncAutorampsWithUserStorageAction, RampsControllerStartOrderPollingAction, RampsControllerStopOrderPollingAction, + RampsControllerRefreshDepositsAction, + RampsControllerMarkDepositAsNotifiedAction, + RampsControllerRemoveDepositAction, + RampsControllerStartDepositPollingAction, + RampsControllerStopDepositPollingAction, RampsControllerGetBuyWidgetDataAction, RampsControllerAddPrecreatedOrderAction, RampsControllerGetOrderAction, @@ -206,16 +212,34 @@ export { mapAutorampToUserStorageEntry, mapUserStorageEntryToAutoramp, } from './autoramp-syncing/index.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, } from './NeoBankService.js'; export type { NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampTransactionsAction, NeoBankServiceRegisterPixAddressAction, NeoBankServiceGetAutorampQuoteAction, NeoBankServiceCreateAutorampAction, @@ -228,6 +252,7 @@ export { NeoBankService, serviceName as neoBankServiceName, mapNeoBankAutorampToRemoteSnapshot, + mapNeoBankTransactionToRemoteSnapshot, } from './NeoBankService.js'; export type { TypedError } from './errorNormalization.js'; export { diff --git a/packages/ramps-controller/src/moneyAccountDeposit.test.ts b/packages/ramps-controller/src/moneyAccountDeposit.test.ts new file mode 100644 index 00000000000..4cbe92ea467 --- /dev/null +++ b/packages/ramps-controller/src/moneyAccountDeposit.test.ts @@ -0,0 +1,228 @@ +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('Processing')).toBe( + MoneyAccountDepositStatus.Processing, + ); + }); + + it('falls back to Pending for unknown values', () => { + expect(normalizeDepositStatus('Nope')).toBe( + MoneyAccountDepositStatus.Pending, + ); + }); + }); + + describe('isTerminalDepositStatus', () => { + it('identifies terminal statuses', () => { + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Completed)).toBe( + true, + ); + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Failed)).toBe( + true, + ); + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Cancelled)).toBe( + true, + ); + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Pending)).toBe( + false, + ); + expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Processing)).toBe( + false, + ); + }); + }); + + describe('createMoneyAccountDeposit', () => { + it('defaults status to Pending 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.Pending, + payoutTransactionHash: undefined, + amount: undefined, + currency: undefined, + lastSeenStatus: MoneyAccountDepositStatus.Pending, + 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.Processing, + 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.Pending, + }; + + const result = applyDepositRemoteStatus(null, remote); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + expect(result.deposit.status).toBe(MoneyAccountDepositStatus.Pending); + 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.Processing, + statusChanged: true, + shouldNotify: true, + } satisfies Partial); + expect(result.deposit.status).toBe(MoneyAccountDepositStatus.Completed); + expect(result.deposit.lastSeenStatus).toBe( + MoneyAccountDepositStatus.Processing, + ); + expect(result.deposit.payoutTransactionHash).toBe(PAYOUT_HASH); + }); + + it('does not notify again when already notified for that status', () => { + const local = markDepositNotified({ + ...baseLocal, + status: MoneyAccountDepositStatus.Completed, + lastSeenStatus: MoneyAccountDepositStatus.Processing, + 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.Pending, + updatedAt: 1, + }); + + const result = applyDepositRemoteStatus(local, { + id: 'dep-1', + status: MoneyAccountDepositStatus.Processing, + }); + + 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); + }); + }); + + 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..ee704c06e89 --- /dev/null +++ b/packages/ramps-controller/src/moneyAccountDeposit.ts @@ -0,0 +1,262 @@ +/** + * 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 the partner (Iron) transaction lifecycle + * and are assumed pending confirmation of the neo-bank proxy transactions + * contract. Keep {@link normalizeDepositStatus} tolerant of unknown values. + */ + +import type { Hex } from '@metamask/utils'; + +/** + * Deposit/transaction lifecycle statuses from the neo-bank proxy. + */ +export enum MoneyAccountDepositStatus { + /** Created / awaiting partner processing. */ + Pending = 'Pending', + /** Partner is processing the fiat leg. */ + Processing = 'Processing', + /** Payout settled on Monad; `payoutTransactionHash` is available. */ + Completed = 'Completed', + /** Terminal failure. */ + Failed = 'Failed', + /** Cancelled before completion. */ + Cancelled = 'Cancelled', +} + +/** + * 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. Processing → 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; +}; + +/** + * Terminal deposit statuses — no further lifecycle progress expected. + */ +export const TERMINAL_DEPOSIT_STATUSES: ReadonlySet = + new Set([ + MoneyAccountDepositStatus.Completed, + MoneyAccountDepositStatus.Failed, + MoneyAccountDepositStatus.Cancelled, + ]); + +/** + * Statuses that commonly warrant user-visible transition UX (toast / banner). + */ +export const NOTABLE_DEPOSIT_STATUSES: ReadonlySet = + new Set([ + MoneyAccountDepositStatus.Completed, + MoneyAccountDepositStatus.Failed, + ]); + +/** + * 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 {@link MoneyAccountDepositStatus.Pending}. + * + * @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.Pending; +} + +/** + * 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 Pending). + * @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.Pending, + ); + 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); + + 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, + }; + } + + const previousStatus = local.status; + const statusChanged = previousStatus !== remoteStatus; + const shouldNotify = + statusChanged && + local.notifiedForStatus !== remoteStatus && + NOTABLE_DEPOSIT_STATUSES.has(remoteStatus); + + const deposit: MoneyAccountDeposit = { + ...local, + id: remote.id, + autorampId: remote.autorampId ?? local.autorampId, + moneyAccountAddress: remote.moneyAccountAddress ?? local.moneyAccountAddress, + status: remoteStatus, + // Never null out a payout hash once observed. + payoutTransactionHash: + remote.payoutTransactionHash ?? local.payoutTransactionHash, + amount: remote.amount ?? local.amount, + currency: remote.currency ?? local.currency, + lastSeenStatus: previousStatus, + updatedAt: Date.now(), + }; + + return { + deposit, + previousStatus, + statusChanged, + shouldNotify, + }; +} + +/** + * 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, + }; +} From f6b2ba5023340d65938e35bcff7043bd96bff7a9 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Fri, 4 Sep 2026 03:27:06 -0700 Subject: [PATCH 2/2] docs(ramps): link deposit-polling changelog entries to the PR --- packages/ramps-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c10c2da6aeb..784b01e9700 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,11 +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. +- 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. ([#10108](https://github.com/MetaMask/core/pull/10108)) - 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`). -- Add `NeoBankService.getAutorampTransactions(autorampId)` and the `NeoBankService:getAutorampTransactions` messenger action, which fetch deposit/transaction records from neobank-proxy `GET /neobank/autoramps/{id}/transactions` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot` (accepting either a bare array or a `{ transactions }` envelope). +- Add the `moneyAccountDeposit` model: `MoneyAccountDeposit`, `MoneyAccountDepositStatus`, `MoneyAccountDepositRemoteSnapshot`, the pure `applyDepositRemoteStatus` diff, and helpers (`normalizeDepositStatus`, `isTerminalDepositStatus`, `createMoneyAccountDeposit`, `markDepositNotified`, `TERMINAL_DEPOSIT_STATUSES`, `NOTABLE_DEPOSIT_STATUSES`). ([#10108](https://github.com/MetaMask/core/pull/10108)) +- Add `NeoBankService.getAutorampTransactions(autorampId)` and the `NeoBankService:getAutorampTransactions` messenger action, which fetch deposit/transaction records from neobank-proxy `GET /neobank/autoramps/{id}/transactions` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot` (accepting either a bare array or a `{ transactions }` envelope). ([#10108](https://github.com/MetaMask/core/pull/10108)) - Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679))