From db7c778491deaf397993812e425bc8b1d1245f3c Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Fri, 4 Sep 2026 09:00:37 -0700 Subject: [PATCH 1/5] 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 | 284 ++++++++++- packages/ramps-controller/src/index.ts | 25 + .../src/moneyAccountDeposit.test.ts | 228 +++++++++ .../src/moneyAccountDeposit.ts | 262 +++++++++++ 10 files changed, 1551 insertions(+), 1 deletion(-) 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 d61b6aa2e9b..6eec074ecaf 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` 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..0e477df5a8c 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). @@ -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..b098f0f6d8e 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,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 ef73ddb5fca..cfe01da8160 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,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. */ @@ -112,6 +147,7 @@ export type RegisterSelfHostedWalletParams = { const MESSENGER_EXPOSED_METHODS = [ 'getAutoramp', + 'getAutorampTransactions', 'registerPixAddress', 'getAutorampQuote', 'createAutoramp', @@ -213,6 +249,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). * @@ -405,6 +464,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 @@ -420,6 +498,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 8661f19c705..015472a2d22 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -422,6 +422,60 @@ 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. 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, @@ -840,6 +894,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..6bed8229abd 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,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 c0bdc2edb30..f1a6c9eebc9 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,231 @@ 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 { + 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. @@ -3483,6 +3764,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..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..57fba305a10 --- /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 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; +}; + +/** + * 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 bceb08887391d59f90f77e7dd1b012745eae0538 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Fri, 4 Sep 2026 09:02:17 -0700 Subject: [PATCH 2/5] 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 6eec074ecaf..72d638d73a8 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. ([#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`). -- 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`). ([#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/autoramps/{id}/transactions` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot` (accepting either a bare array or a `{ transactions }` envelope). ([#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)) From 0e5ae1dd2976ded182c3ade88114d6a35bf5325c Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Fri, 4 Sep 2026 09:31:44 -0700 Subject: [PATCH 3/5] fix(ramps): address deposit-polling review (churn, id backfill, re-entrancy) - Backfill the owning autoramp id onto deposit snapshots from the poll query key, so an in-flight deposit stays pollable once its route goes terminal even when the proxy omits autoramp_id. - Only write state when a snapshot is new or materially changed (new `changed` flag on applyDepositRemoteStatus), so unchanged deposits no longer churn updatedAt / stateChange on every 30s poll for the life of the autoramp. - Guard refreshDeposits with the same in-flight flag as the poll loop so an unlock catch-up cannot overlap the interval poll. - Correct the removeDeposit doc: a deposit under an actively polled autoramp is a live mirror of the proxy and re-syncs; removal only sticks once the autoramp is no longer pollable. - Note that a deposit first observed already-terminal is recorded for display without a notification (no prior local state to have transitioned from). --- .../RampsController-method-action-types.ts | 7 +- .../src/RampsController.test.ts | 94 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 57 +++++++---- .../src/moneyAccountDeposit.test.ts | 40 +++++++- .../src/moneyAccountDeposit.ts | 53 +++++++++-- 5 files changed, 221 insertions(+), 30 deletions(-) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 015472a2d22..8a2a576de8a 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -448,8 +448,11 @@ export type RampsControllerMarkDepositAsNotifiedAction = { }; /** - * Removes a local deposit record by id. Lets consumers prune settled or stale - * deposits so the persisted `deposits` array does not grow without bound. + * 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. */ diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 6bed8229abd..d55ed3c5e23 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10795,6 +10795,31 @@ describe('RampsController', () => { }); }); + 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); @@ -11068,6 +11093,75 @@ describe('RampsController', () => { 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.Pending }, + ]); + 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', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f1a6c9eebc9..83191baa8ca 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3437,11 +3437,19 @@ export class RampsController extends BaseController< * action is taken. */ async refreshDeposits(): Promise { - await Promise.allSettled( - this.#autorampsToPollForDeposits().map(async (autoramp) => - this.#refreshAutorampDeposits(autoramp.id), - ), - ); + if (this.#isPollingDeposits) { + return; + } + this.#isPollingDeposits = true; + try { + await Promise.allSettled( + this.#autorampsToPollForDeposits().map(async (autoramp) => + this.#refreshAutorampDeposits(autoramp.id), + ), + ); + } finally { + this.#isPollingDeposits = false; + } } /** @@ -3458,7 +3466,12 @@ export class RampsController extends BaseController< ); for (const remote of remotes) { - this.#applyDepositRemoteSnapshot(remote); + // 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) ?? { @@ -3494,16 +3507,21 @@ export class RampsController extends BaseController< 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; - } - }); + // 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', { @@ -3546,8 +3564,11 @@ export class RampsController extends BaseController< } /** - * Removes a local deposit record by id. Lets consumers prune settled or stale - * deposits so the persisted `deposits` array does not grow without bound. + * 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. */ diff --git a/packages/ramps-controller/src/moneyAccountDeposit.test.ts b/packages/ramps-controller/src/moneyAccountDeposit.test.ts index 4cbe92ea467..7299302943a 100644 --- a/packages/ramps-controller/src/moneyAccountDeposit.test.ts +++ b/packages/ramps-controller/src/moneyAccountDeposit.test.ts @@ -49,9 +49,9 @@ describe('moneyAccountDeposit', () => { expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Pending)).toBe( false, ); - expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Processing)).toBe( - false, - ); + expect( + isTerminalDepositStatus(MoneyAccountDepositStatus.Processing), + ).toBe(false); }); }); @@ -210,6 +210,40 @@ describe('moneyAccountDeposit', () => { expect(result.deposit.moneyAccountAddress).toBe(MONEY_ACCOUNT); }); + + it('reports changed for a newly created record', () => { + const result = applyDepositRemoteStatus(null, { + id: 'dep-1', + status: MoneyAccountDepositStatus.Pending, + }); + + 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.Processing, + }); + + 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.Processing, + payoutTransactionHash: PAYOUT_HASH, + }); + + expect(result.statusChanged).toBe(false); + expect(result.changed).toBe(true); + expect(result.deposit.payoutTransactionHash).toBe(PAYOUT_HASH); + }); }); describe('markDepositNotified', () => { diff --git a/packages/ramps-controller/src/moneyAccountDeposit.ts b/packages/ramps-controller/src/moneyAccountDeposit.ts index 57fba305a10..39e995a0a58 100644 --- a/packages/ramps-controller/src/moneyAccountDeposit.ts +++ b/packages/ramps-controller/src/moneyAccountDeposit.ts @@ -91,6 +91,13 @@ export type ApplyDepositRemoteStatusResult = { 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; }; /** @@ -198,6 +205,9 @@ export function applyDepositRemoteStatus( ): 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, @@ -213,6 +223,7 @@ export function applyDepositRemoteStatus( previousStatus: remoteStatus, statusChanged: false, shouldNotify: false, + changed: true, }; } @@ -223,17 +234,44 @@ export function applyDepositRemoteStatus( 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: remote.autorampId ?? local.autorampId, - moneyAccountAddress: remote.moneyAccountAddress ?? local.moneyAccountAddress, + autorampId, + 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, + payoutTransactionHash, + amount, + currency, lastSeenStatus: previousStatus, updatedAt: Date.now(), }; @@ -243,6 +281,7 @@ export function applyDepositRemoteStatus( previousStatus, statusChanged, shouldNotify, + changed: true, }; } From f8acca8bd32c5dc5429086deb2298aca4ee7b77f Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Fri, 4 Sep 2026 10:50:41 -0700 Subject: [PATCH 4/5] fix(ramps): align deposit poll to the real neobank-proxy transactions contract Replace the assumed transactions contract with the one shipping in onramp-api PR #1124 (the neobank-proxy forwards raw MoonPay Enterprise verbatim): - Route: GET /neobank/autoramp-transactions?autoramp_id={id} (flat, not the assumed nested /autoramps/{id}/transactions), returning a MoonPay PagedList; read the `data` array (single page for now, next_cursor pagination is a follow-up). - Payout hash: flat `transaction_hash` (drop the invented payout_transaction_hash and nested payout.transaction_hash). - Status: the real 8 AutorampTransactionStatus values (FundsReviewInProgress, ConversionInProgress, PayoutInProgress, Completed, Failed, RejectedAml, RejectedFraud, RejectedMinAmount). The three Rejected* are terminal and notable; an unknown status falls back to a non-terminal value. This fixes the prior invented enum, which treated rejections as non-terminal and would have polled them forever without ever notifying. - Display fields (amount/currency/moneyAccountAddress) are intentionally left unmapped: MoonPay carries them as structured objects, and the mobile-safe DTO (TRAM-3925) will pin the wire names. --- packages/ramps-controller/CHANGELOG.md | 2 +- .../src/NeoBankService-method-action-types.ts | 6 +- .../src/NeoBankService.test.ts | 71 +++++++--------- .../ramps-controller/src/NeoBankService.ts | 60 +++++++------ .../src/RampsController.test.ts | 48 +++++++++-- .../src/moneyAccountDeposit.test.ts | 84 ++++++++++++------- .../src/moneyAccountDeposit.ts | 56 +++++++++---- 7 files changed, 204 insertions(+), 123 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 72d638d73a8..4fac4fb00ab 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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/autoramps/{id}/transactions` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot` (accepting either a bare array or a `{ transactions }` envelope). ([#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` (reading the MoonPay `PagedList` `data` array, with a bare array kept as a defensive fallback). ([#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 0e477df5a8c..e9d3a23f677 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -20,11 +20,11 @@ export type NeoBankServiceGetAutorampAction = { /** * Fetches deposit/transaction records for an autoramp via neobank-proxy - * `GET /neobank/autoramps/{autoramp_id}/transactions`. + * `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 are assumed pending the proxy contract - * (onramp-api #1124). + * on Monad). Route + response shape track onramp-api PR #1124. * * @param autorampId - MoonPay / Ramp API autoramp id. * @returns Deposit snapshots for controller apply/refresh. diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index b098f0f6d8e..e877f625eb9 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -138,61 +138,48 @@ describe('NeoBankService', () => { }); describe('mapNeoBankTransactionToRemoteSnapshot', () => { - it('maps proxy transaction fields into a deposit snapshot', () => { + it('maps the confirmed 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', + transaction_hash: '0xpayout', }), ).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', + status: 'PayoutInProgress', }), ).toMatchObject({ payoutTransactionHash: undefined }); }); }); describe('getAutorampTransactions', () => { - it('fetches /neobank/autoramps/{id}/transactions and maps an array body', async () => { + it('fetches /neobank/autoramp-transactions?autoramp_id={id} and maps a PagedList data envelope', async () => { const scope = nock(STAGING_BASE) - .get(/\/neobank\/autoramps\/ar-1\/transactions/u) + .get(/\/neobank\/autoramp-transactions\?.*autoramp_id=ar-1/u) .matchHeader('Authorization', 'Bearer test-token') - .reply(200, [ - { - id: 'dep-1', - autoramp_id: 'ar-1', - status: 'Completed', - payout_transaction_hash: '0xpayout', - }, - ]); + .reply(200, { + data: [ + { + id: 'dep-1', + autoramp_id: 'ar-1', + status: 'Completed', + transaction_hash: '0xpayout', + }, + ], + next_cursor: null, + }); const service = createService(); const snapshots = await service.getAutorampTransactions('ar-1'); @@ -202,30 +189,30 @@ describe('NeoBankService', () => { { id: 'dep-1', autorampId: 'ar-1', - moneyAccountAddress: undefined, status: 'Completed', payoutTransactionHash: '0xpayout', - amount: undefined, - currency: undefined, }, ]); }); - it('accepts a { transactions } envelope', async () => { + it('accepts a bare array body as a defensive fallback', async () => { nock(STAGING_BASE) - .get(/\/neobank\/autoramps\/ar-1\/transactions/u) - .reply(200, { transactions: [{ id: 'dep-1', status: 'Pending' }] }); + .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: 'Pending' }); + 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\/autoramps\/ar-1\/transactions/u) + .get(/\/neobank\/autoramp-transactions/u) .reply(500); const service = createService(); @@ -236,7 +223,7 @@ describe('NeoBankService', () => { it('throws when the response body is not a transaction list', async () => { nock(STAGING_BASE) - .get(/\/neobank\/autoramps\/ar-1\/transactions/u) + .get(/\/neobank\/autoramp-transactions/u) .reply(200, { nope: true }); const service = createService(); @@ -247,8 +234,8 @@ describe('NeoBankService', () => { it('throws when an item is missing an id', async () => { nock(STAGING_BASE) - .get(/\/neobank\/autoramps\/ar-1\/transactions/u) - .reply(200, [{ status: 'Pending' }]); + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, { data: [{ status: 'PayoutInProgress' }] }); const service = createService(); await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( @@ -258,8 +245,8 @@ describe('NeoBankService', () => { 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' }]); + .get(/\/neobank\/autoramp-transactions/u) + .reply(200, { data: [{ id: 'dep-1' }] }); const service = createService(); await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index cfe01da8160..ba00116b5dd 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -80,34 +80,40 @@ export type NeoBankAutorampResponse = { * 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. + * receives fiat, pays out mUSD on Monad to the Money Account). + * + * Source of truth: onramp-api PR #1124, which forwards raw MoonPay Enterprise + * transactions verbatim. Only the fields confirmed there are read here (`id`, + * `autoramp_id`, `status`, flat `transaction_hash`). MoonPay carries + * amount/currency/recipient as structured source/destination objects rather than + * simple top-level fields, so display fields are intentionally not mapped yet; + * TRAM-3925 will introduce a mobile-safe DTO that may rename fields. */ /* 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; + transaction_hash?: 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. + * Envelope returned by the neo-bank transactions endpoint. + * + * The primary shape is the MoonPay `PagedList` (`{ data, next_cursor }`); a bare + * array is kept as a defensive fallback. Only the first page is read for now - + * cursor pagination (`next_cursor`) is a follow-up. */ +/* eslint-disable @typescript-eslint/naming-convention -- snake_case proxy wire format */ export type NeoBankTransactionsResponse = | NeoBankTransactionResponse[] - | { transactions?: NeoBankTransactionResponse[] }; + | { + data?: NeoBankTransactionResponse[]; + next_cursor?: string | null; + }; +/* eslint-enable @typescript-eslint/naming-convention */ /** * Optional headers for neo-bank mutating requests. @@ -258,17 +264,15 @@ export function mapNeoBankAutorampToRemoteSnapshot( export function mapNeoBankTransactionToRemoteSnapshot( response: NeoBankTransactionResponse, ): MoneyAccountDepositRemoteSnapshot { - const payoutTransactionHash = - response.payout_transaction_hash ?? response.payout?.transaction_hash; - + // Display fields (moneyAccountAddress/amount/currency) are left unset: #1124 + // forwards raw MoonPay, which carries them as structured source/destination + // objects, not simple top-level fields. They await the mobile-safe DTO + // (TRAM-3925). Only confirmed fields are mapped. 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, + payoutTransactionHash: response.transaction_hash as Hex | undefined, }; } @@ -467,7 +471,10 @@ export class NeoBankService { #mapTransactionsResponse( response: NeoBankTransactionsResponse, ): MoneyAccountDepositRemoteSnapshot[] { - const list = Array.isArray(response) ? response : response?.transactions; + // Primary shape is the MoonPay `PagedList` (`data` array); a bare array is a + // defensive fallback. Single page only for now - `next_cursor` pagination is + // a follow-up. + const list = Array.isArray(response) ? response : response?.data; if (!Array.isArray(list)) { throw new Error( 'Malformed response received from neo-bank transactions API', @@ -500,11 +507,11 @@ export class NeoBankService { /** * Fetches deposit/transaction records for an autoramp via neobank-proxy - * `GET /neobank/autoramps/{autoramp_id}/transactions`. + * `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 are assumed pending the proxy contract - * (onramp-api #1124). + * on Monad). Route + response shape track onramp-api PR #1124. * * @param autorampId - MoonPay / Ramp API autoramp id. * @returns Deposit snapshots for controller apply/refresh. @@ -513,7 +520,8 @@ export class NeoBankService { autorampId: string, ): Promise { const response = await this.#getJson( - `autoramps/${encodeURIComponent(autorampId)}/transactions`, + 'autoramp-transactions', + { autoramp_id: autorampId }, ); return this.#mapTransactionsResponse(response); } diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index d55ed3c5e23..307eb5ec4b3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10665,7 +10665,7 @@ describe('RampsController', () => { { id: 'dep-1', autorampId: 'ar-1', - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.PayoutInProgress, }, ], ); @@ -10676,7 +10676,7 @@ describe('RampsController', () => { expect(controller.state.deposits).toHaveLength(1); expect(controller.state.deposits[0]).toMatchObject({ id: 'dep-1', - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.PayoutInProgress, }); rootMessenger.call('RampsController:stopDepositPolling'); @@ -10690,7 +10690,7 @@ describe('RampsController', () => { const getTransactions = jest .fn() .mockResolvedValueOnce([ - { id: 'dep-1', status: MoneyAccountDepositStatus.Processing }, + { id: 'dep-1', status: MoneyAccountDepositStatus.PayoutInProgress }, ]) .mockResolvedValue([ { @@ -10722,7 +10722,7 @@ describe('RampsController', () => { expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - previousStatus: MoneyAccountDepositStatus.Processing, + previousStatus: MoneyAccountDepositStatus.PayoutInProgress, shouldNotify: true, }); expect(controller.state.deposits[0]).toMatchObject({ @@ -11004,7 +11004,7 @@ describe('RampsController', () => { { id: 'dep-1', autorampId: 'ar-1', - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.PayoutInProgress, }, ]); rootMessenger.registerActionHandler( @@ -11016,7 +11016,7 @@ describe('RampsController', () => { await jest.advanceTimersByTimeAsync(0); expect(getTransactions).toHaveBeenCalledTimes(1); - // Route cancelled, but the Pending deposit must keep being tracked. + // Route cancelled, but the in-flight deposit must keep being tracked. controller.applyAutorampStatusFromPush({ id: 'ar-1', customerId: 'cust-1', @@ -11029,6 +11029,40 @@ describe('RampsController', () => { }); }); + 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({ @@ -11100,7 +11134,7 @@ describe('RampsController', () => { const getTransactions = jest .fn() .mockResolvedValue([ - { id: 'dep-1', status: MoneyAccountDepositStatus.Pending }, + { id: 'dep-1', status: MoneyAccountDepositStatus.PayoutInProgress }, ]); rootMessenger.registerActionHandler( 'NeoBankService:getAutorampTransactions', diff --git a/packages/ramps-controller/src/moneyAccountDeposit.test.ts b/packages/ramps-controller/src/moneyAccountDeposit.test.ts index 7299302943a..c37f1b27b2d 100644 --- a/packages/ramps-controller/src/moneyAccountDeposit.test.ts +++ b/packages/ramps-controller/src/moneyAccountDeposit.test.ts @@ -23,15 +23,18 @@ describe('moneyAccountDeposit', () => { expect(normalizeDepositStatus(MoneyAccountDepositStatus.Completed)).toBe( MoneyAccountDepositStatus.Completed, ); - expect(normalizeDepositStatus('Processing')).toBe( - MoneyAccountDepositStatus.Processing, - ); + expect( + normalizeDepositStatus(MoneyAccountDepositStatus.PayoutInProgress), + ).toBe(MoneyAccountDepositStatus.PayoutInProgress); }); - it('falls back to Pending for unknown values', () => { - expect(normalizeDepositStatus('Nope')).toBe( - MoneyAccountDepositStatus.Pending, - ); + 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); }); }); @@ -43,20 +46,28 @@ describe('moneyAccountDeposit', () => { expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Failed)).toBe( true, ); - expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Cancelled)).toBe( - true, - ); - expect(isTerminalDepositStatus(MoneyAccountDepositStatus.Pending)).toBe( - false, - ); expect( - isTerminalDepositStatus(MoneyAccountDepositStatus.Processing), + 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 Pending and mirrors lastSeenStatus', () => { + it('defaults status to FundsReviewInProgress and mirrors lastSeenStatus', () => { const deposit = createMoneyAccountDeposit({ id: 'dep-1', moneyAccountAddress: MONEY_ACCOUNT, @@ -67,11 +78,11 @@ describe('moneyAccountDeposit', () => { id: 'dep-1', autorampId: undefined, moneyAccountAddress: MONEY_ACCOUNT, - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.FundsReviewInProgress, payoutTransactionHash: undefined, amount: undefined, currency: undefined, - lastSeenStatus: MoneyAccountDepositStatus.Pending, + lastSeenStatus: MoneyAccountDepositStatus.FundsReviewInProgress, updatedAt: 1000, }); }); @@ -103,7 +114,7 @@ describe('moneyAccountDeposit', () => { id: 'dep-1', moneyAccountAddress: MONEY_ACCOUNT, autorampId: 'ar-1', - status: MoneyAccountDepositStatus.Processing, + status: MoneyAccountDepositStatus.PayoutInProgress, updatedAt: 1, }); @@ -112,14 +123,16 @@ describe('moneyAccountDeposit', () => { id: 'dep-1', autorampId: 'ar-1', moneyAccountAddress: MONEY_ACCOUNT, - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.PayoutInProgress, }; 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.status).toBe( + MoneyAccountDepositStatus.PayoutInProgress, + ); expect(result.deposit.autorampId).toBe('ar-1'); }); @@ -133,22 +146,37 @@ describe('moneyAccountDeposit', () => { const result = applyDepositRemoteStatus(baseLocal, remote); expect(result).toMatchObject({ - previousStatus: MoneyAccountDepositStatus.Processing, + previousStatus: MoneyAccountDepositStatus.PayoutInProgress, statusChanged: true, shouldNotify: true, } satisfies Partial); expect(result.deposit.status).toBe(MoneyAccountDepositStatus.Completed); expect(result.deposit.lastSeenStatus).toBe( - MoneyAccountDepositStatus.Processing, + 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.Processing, + lastSeenStatus: MoneyAccountDepositStatus.PayoutInProgress, notifiedForStatus: MoneyAccountDepositStatus.Completed, }); @@ -165,13 +193,13 @@ describe('moneyAccountDeposit', () => { const local = createMoneyAccountDeposit({ id: 'dep-1', moneyAccountAddress: MONEY_ACCOUNT, - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.FundsReviewInProgress, updatedAt: 1, }); const result = applyDepositRemoteStatus(local, { id: 'dep-1', - status: MoneyAccountDepositStatus.Processing, + status: MoneyAccountDepositStatus.PayoutInProgress, }); expect(result.statusChanged).toBe(true); @@ -214,7 +242,7 @@ describe('moneyAccountDeposit', () => { it('reports changed for a newly created record', () => { const result = applyDepositRemoteStatus(null, { id: 'dep-1', - status: MoneyAccountDepositStatus.Pending, + status: MoneyAccountDepositStatus.PayoutInProgress, }); expect(result.changed).toBe(true); @@ -223,7 +251,7 @@ describe('moneyAccountDeposit', () => { it('reports not-changed and returns the untouched local when nothing changed', () => { const result = applyDepositRemoteStatus(baseLocal, { id: 'dep-1', - status: MoneyAccountDepositStatus.Processing, + status: MoneyAccountDepositStatus.PayoutInProgress, }); expect(result.changed).toBe(false); @@ -236,7 +264,7 @@ describe('moneyAccountDeposit', () => { it('reports changed when only a field changes without a status change', () => { const result = applyDepositRemoteStatus(baseLocal, { id: 'dep-1', - status: MoneyAccountDepositStatus.Processing, + status: MoneyAccountDepositStatus.PayoutInProgress, payoutTransactionHash: PAYOUT_HASH, }); diff --git a/packages/ramps-controller/src/moneyAccountDeposit.ts b/packages/ramps-controller/src/moneyAccountDeposit.ts index 39e995a0a58..6e8d743c8eb 100644 --- a/packages/ramps-controller/src/moneyAccountDeposit.ts +++ b/packages/ramps-controller/src/moneyAccountDeposit.ts @@ -8,27 +8,39 @@ * 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. + * 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 { - /** Created / awaiting partner processing. */ - Pending = 'Pending', - /** Partner is processing the fiat leg. */ - Processing = 'Processing', + /** 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', - /** Cancelled before completion. */ - Cancelled = 'Cancelled', + /** Rejected by AML screening. */ + RejectedAml = 'RejectedAml', + /** Rejected by fraud screening. */ + RejectedFraud = 'RejectedFraud', + /** Rejected for being under the minimum amount. */ + RejectedMinAmount = 'RejectedMinAmount', } /** @@ -56,7 +68,7 @@ export type MoneyAccountDeposit = { currency?: string; /** * Status observed before the most recent remote apply. - * Used for transition UX / analytics (e.g. Processing to Completed). + * Used for transition UX / analytics (e.g. PayoutInProgress to Completed). */ lastSeenStatus: MoneyAccountDepositStatus; /** @@ -107,16 +119,23 @@ export const TERMINAL_DEPOSIT_STATUSES: ReadonlySet = new Set([ MoneyAccountDepositStatus.Completed, MoneyAccountDepositStatus.Failed, - MoneyAccountDepositStatus.Cancelled, + MoneyAccountDepositStatus.RejectedAml, + MoneyAccountDepositStatus.RejectedFraud, + MoneyAccountDepositStatus.RejectedMinAmount, ]); /** - * Statuses that commonly warrant user-visible transition UX (toast / banner). + * 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, ]); /** @@ -133,7 +152,12 @@ export function isTerminalDepositStatus( /** * Normalize a remote status string into {@link MoneyAccountDepositStatus}. - * Unknown values fall back to {@link MoneyAccountDepositStatus.Pending}. + * + * 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}. @@ -148,7 +172,7 @@ export function normalizeDepositStatus( ) { return status as MoneyAccountDepositStatus; } - return MoneyAccountDepositStatus.Pending; + return MoneyAccountDepositStatus.FundsReviewInProgress; } /** @@ -157,7 +181,7 @@ export function normalizeDepositStatus( * @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.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. @@ -176,7 +200,7 @@ export function createMoneyAccountDeposit(input: { updatedAt?: number; }): MoneyAccountDeposit { const status = normalizeDepositStatus( - input.status ?? MoneyAccountDepositStatus.Pending, + input.status ?? MoneyAccountDepositStatus.FundsReviewInProgress, ); return { id: input.id, From 66373b5ef32238872be65a26a0be21a37a26f03b Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Fri, 4 Sep 2026 11:41:45 -0700 Subject: [PATCH 5/5] fix(ramps): read the real Iron PagedList shape (items + nested payout hash) The prior alignment matched onramp-api #1124's internal mock fixture ({ data, next_cursor } + flat transaction_hash), not the real MoonPay/Iron AutorampTransaction the proxy forwards verbatim. Per Iron's OpenAPI spec the list is a PagedList with an `items` array and the payout hash is nested at `payout_crypto_transaction.transaction_hash`. Reading only `data` / a flat hash would have thrown on every real response (no deposit ever tracked) and never captured the payout hash. Make the mapping tolerant of both, since the proxy PR is unmerged and TRAM-3925 will re-shape it again: - Envelope: read `items` (real Iron), then `data` (proxy fixture), then a bare array. - Payout hash: read nested `payout_crypto_transaction.transaction_hash`, then flat `transaction_hash`. - Tests use the real `{ items, cursor, prev_cursor }` + nested hash as the primary case, with the `data` / flat / bare-array fallbacks covered too. --- packages/ramps-controller/CHANGELOG.md | 2 +- .../src/NeoBankService.test.ts | 45 +++++++++++--- .../ramps-controller/src/NeoBankService.ts | 60 +++++++++++++------ 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 4fac4fb00ab..db46e3853d4 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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` (reading the MoonPay `PagedList` `data` array, with a bare array kept as a defensive fallback). ([#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.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index e877f625eb9..356bb685a9d 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -138,13 +138,13 @@ describe('NeoBankService', () => { }); describe('mapNeoBankTransactionToRemoteSnapshot', () => { - it('maps the confirmed proxy transaction fields into a deposit snapshot', () => { + 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', - transaction_hash: '0xpayout', + payout_crypto_transaction: { transaction_hash: '0xpayout' }, }), ).toStrictEqual({ id: 'dep-1', @@ -154,6 +154,16 @@ describe('NeoBankService', () => { }); }); + 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({ @@ -165,20 +175,21 @@ describe('NeoBankService', () => { }); describe('getAutorampTransactions', () => { - it('fetches /neobank/autoramp-transactions?autoramp_id={id} and maps a PagedList data envelope', async () => { + 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, { - data: [ + items: [ { id: 'dep-1', autoramp_id: 'ar-1', status: 'Completed', - transaction_hash: '0xpayout', + payout_crypto_transaction: { transaction_hash: '0xpayout' }, }, ], - next_cursor: null, + cursor: null, + prev_cursor: null, }); const service = createService(); @@ -195,6 +206,24 @@ describe('NeoBankService', () => { ]); }); + 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) @@ -235,7 +264,7 @@ describe('NeoBankService', () => { it('throws when an item is missing an id', async () => { nock(STAGING_BASE) .get(/\/neobank\/autoramp-transactions/u) - .reply(200, { data: [{ status: 'PayoutInProgress' }] }); + .reply(200, { items: [{ status: 'PayoutInProgress' }] }); const service = createService(); await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( @@ -246,7 +275,7 @@ describe('NeoBankService', () => { it('throws when an item is missing a status', async () => { nock(STAGING_BASE) .get(/\/neobank\/autoramp-transactions/u) - .reply(200, { data: [{ id: 'dep-1' }] }); + .reply(200, { items: [{ id: 'dep-1' }] }); const service = createService(); await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow( diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index ba00116b5dd..235f8b997cd 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -82,19 +82,30 @@ export type NeoBankAutorampResponse = { * Represents a single payment instance flowing through an autoramp (partner * receives fiat, pays out mUSD on Monad to the Money Account). * - * Source of truth: onramp-api PR #1124, which forwards raw MoonPay Enterprise - * transactions verbatim. Only the fields confirmed there are read here (`id`, - * `autoramp_id`, `status`, flat `transaction_hash`). MoonPay carries - * amount/currency/recipient as structured source/destination objects rather than - * simple top-level fields, so display fields are intentionally not mapped yet; - * TRAM-3925 will introduce a mobile-safe DTO that may rename fields. + * 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 when the payout has settled on-chain. */ + /** + * 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 */ @@ -102,14 +113,18 @@ export type NeoBankTransactionResponse = { /** * Envelope returned by the neo-bank transactions endpoint. * - * The primary shape is the MoonPay `PagedList` (`{ data, next_cursor }`); a bare - * array is kept as a defensive fallback. Only the first page is read for now - - * cursor pagination (`next_cursor`) is a follow-up. + * 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; }; @@ -264,15 +279,20 @@ export function mapNeoBankAutorampToRemoteSnapshot( export function mapNeoBankTransactionToRemoteSnapshot( response: NeoBankTransactionResponse, ): MoneyAccountDepositRemoteSnapshot { - // Display fields (moneyAccountAddress/amount/currency) are left unset: #1124 - // forwards raw MoonPay, which carries them as structured source/destination - // objects, not simple top-level fields. They await the mobile-safe DTO - // (TRAM-3925). Only confirmed fields are mapped. + // 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: response.transaction_hash as Hex | undefined, + payoutTransactionHash: payoutTransactionHash as Hex | undefined, }; } @@ -471,10 +491,12 @@ export class NeoBankService { #mapTransactionsResponse( response: NeoBankTransactionsResponse, ): MoneyAccountDepositRemoteSnapshot[] { - // Primary shape is the MoonPay `PagedList` (`data` array); a bare array is a - // defensive fallback. Single page only for now - `next_cursor` pagination is - // a follow-up. - const list = Array.isArray(response) ? response : response?.data; + // 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',