Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/ramps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add Money Account deposit polling to `RampsController` (emit-only). New `startDepositPolling` / `stopDepositPolling` / `refreshDeposits` methods and messenger actions poll the neo-bank proxy for each pollable autoramp's transactions on the shared 30s interval, keep a persisted `state.deposits` clone, and publish the new `RampsController:depositStatusChanged` event (`{ deposit, previousStatus, shouldNotify }`) on status transitions. Only `Approved` autoramps (or ones with an in-flight local deposit) are polled. The poller takes no on-chain action; vault sweeping is owned by the backend. ([#10120](https://github.com/MetaMask/core/pull/10120))
- Also adds `markDepositAsNotified(depositId)` (dedupes repeat notifications for the same status) and `removeDeposit(depositId)` (lets consumers prune the persisted deposit list), each exposed as a messenger action.
- `RampsController` now calls `NeoBankService:getAutorampTransactions`, added to the exported `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. Hosts that enumerate their delegated actions instead of spreading that constant must add it, or `startDepositPolling` / `refreshDeposits` reject with a messenger "handler has not been delegated" error.
- Add the `moneyAccountDeposit` model: `MoneyAccountDeposit`, `MoneyAccountDepositStatus`, `MoneyAccountDepositRemoteSnapshot`, the pure `applyDepositRemoteStatus` diff, and helpers (`normalizeDepositStatus`, `isTerminalDepositStatus`, `createMoneyAccountDeposit`, `markDepositNotified`, `TERMINAL_DEPOSIT_STATUSES`, `NOTABLE_DEPOSIT_STATUSES`). ([#10120](https://github.com/MetaMask/core/pull/10120))
- Add `NeoBankService.getAutorampTransactions(autorampId)` and the `NeoBankService:getAutorampTransactions` messenger action, which fetch deposit/transaction records from neobank-proxy `GET /neobank/autoramp-transactions?autoramp_id={id}` and map them via the exported `mapNeoBankTransactionToRemoteSnapshot`. Reads the Iron `PagedList` `items` array and the nested `payout_crypto_transaction.transaction_hash`, tolerating the in-flight proxy's transitional `data` / flat `transaction_hash` shapes and a bare array as fallbacks. ([#10120](https://github.com/MetaMask/core/pull/10120))
- Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031))

- Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export type NeoBankServiceGetAutorampAction = {
handler: NeoBankService['getAutoramp'];
};

/**
* Fetches deposit/transaction records for an autoramp via neobank-proxy
* `GET /neobank/autoramp-transactions?autoramp_id={autoramp_id}` (MoonPay
* `GET /api/autoramp-transactions`, response is a MoonPay `PagedList`).
*
* Used by the deposit poller to detect status changes (e.g. a payout settling
* on Monad). Route + response shape track onramp-api PR #1124.
*
* @param autorampId - MoonPay / Ramp API autoramp id.
* @returns Deposit snapshots for controller apply/refresh.
*/
export type NeoBankServiceGetAutorampTransactionsAction = {
type: `NeoBankService:getAutorampTransactions`;
handler: NeoBankService['getAutorampTransactions'];
};

/**
* Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`.
* Body is forwarded as opaque JSON (MoonPay address schema).
Expand Down Expand Up @@ -136,6 +152,7 @@ export type NeoBankServiceRegisterSelfHostedWalletAction = {
*/
export type NeoBankServiceMethodActions =
| NeoBankServiceGetAutorampAction
| NeoBankServiceGetAutorampTransactionsAction
| NeoBankServiceRegisterPixAddressAction
| NeoBankServiceGetAutorampQuoteAction
| NeoBankServiceCreateAutorampAction
Expand Down
148 changes: 148 additions & 0 deletions packages/ramps-controller/src/NeoBankService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import nock, { cleanAll } from 'nock';

import {
mapNeoBankAutorampToRemoteSnapshot,
mapNeoBankTransactionToRemoteSnapshot,
NeoBankService,
} from './NeoBankService.js';
import type { NeoBankServiceMessenger } from './NeoBankService.js';
Expand Down Expand Up @@ -136,6 +137,153 @@ describe('NeoBankService', () => {
});
});

describe('mapNeoBankTransactionToRemoteSnapshot', () => {
it('maps the confirmed Iron transaction fields (nested payout hash) into a deposit snapshot', () => {
expect(
mapNeoBankTransactionToRemoteSnapshot({
id: 'dep-1',
autoramp_id: 'ar-1',
status: 'Completed',
payout_crypto_transaction: { transaction_hash: '0xpayout' },
}),
).toStrictEqual({
id: 'dep-1',
autorampId: 'ar-1',
status: 'Completed',
payoutTransactionHash: '0xpayout',
});
});

it('falls back to a flat transaction_hash (webhook / proxy-fixture shape)', () => {
expect(
mapNeoBankTransactionToRemoteSnapshot({
id: 'dep-1',
status: 'Completed',
transaction_hash: '0xflat',
}),
).toMatchObject({ payoutTransactionHash: '0xflat' });
});

it('leaves the payout hash undefined when the proxy omits it', () => {
expect(
mapNeoBankTransactionToRemoteSnapshot({
id: 'dep-1',
status: 'PayoutInProgress',
}),
).toMatchObject({ payoutTransactionHash: undefined });
});
});

describe('getAutorampTransactions', () => {
it('fetches /neobank/autoramp-transactions?autoramp_id={id} and maps an Iron PagedList (items) envelope', async () => {
const scope = nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions\?.*autoramp_id=ar-1/u)
.matchHeader('Authorization', 'Bearer test-token')
.reply(200, {
items: [
{
id: 'dep-1',
autoramp_id: 'ar-1',
status: 'Completed',
payout_crypto_transaction: { transaction_hash: '0xpayout' },
},
],
cursor: null,
prev_cursor: null,
});

const service = createService();
const snapshots = await service.getAutorampTransactions('ar-1');

expect(scope.isDone()).toBe(true);
expect(snapshots).toStrictEqual([
{
id: 'dep-1',
autorampId: 'ar-1',
status: 'Completed',
payoutTransactionHash: '0xpayout',
},
]);
});

it('accepts the proxy PR transitional { data } envelope as a fallback', async () => {
nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions/u)
.reply(200, {
data: [{ id: 'dep-1', status: 'PayoutInProgress' }],
next_cursor: null,
});

const service = createService();
const snapshots = await service.getAutorampTransactions('ar-1');

expect(snapshots).toHaveLength(1);
expect(snapshots[0]).toMatchObject({
id: 'dep-1',
status: 'PayoutInProgress',
});
});

it('accepts a bare array body as a defensive fallback', async () => {
nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions/u)
.reply(200, [{ id: 'dep-1', status: 'PayoutInProgress' }]);

const service = createService();
const snapshots = await service.getAutorampTransactions('ar-1');

expect(snapshots).toHaveLength(1);
expect(snapshots[0]).toMatchObject({
id: 'dep-1',
status: 'PayoutInProgress',
});
});

it('throws HttpError when the proxy returns a non-2xx status', async () => {
nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions/u)
.reply(500);

const service = createService();
await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow(
/failed with status '500'/u,
);
});

it('throws when the response body is not a transaction list', async () => {
nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions/u)
.reply(200, { nope: true });

const service = createService();
await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow(
'Malformed response received from neo-bank transactions API',
);
});

it('throws when an item is missing an id', async () => {
nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions/u)
.reply(200, { items: [{ status: 'PayoutInProgress' }] });

const service = createService();
await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow(
'Malformed response received from neo-bank transactions API',
);
});

it('throws when an item is missing a status', async () => {
nock(STAGING_BASE)
.get(/\/neobank\/autoramp-transactions/u)
.reply(200, { items: [{ id: 'dep-1' }] });

const service = createService();
await expect(service.getAutorampTransactions('ar-1')).rejects.toThrow(
'Malformed response received from neo-bank transactions API',
);
});
});

describe('getAutoramp', () => {
it('gets /neobank/autoramps/{id} with bearer auth', async () => {
const scope = nock(STAGING_BASE)
Expand Down
128 changes: 128 additions & 0 deletions packages/ramps-controller/src/NeoBankService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,6 +76,60 @@ export type NeoBankAutorampResponse = {
deposit_rails?: unknown[];
};

/**
* Raw deposit/transaction payload from the MetaMask Ramp API neo-bank proxy.
*
* Represents a single payment instance flowing through an autoramp (partner
* receives fiat, pays out mUSD on Monad to the Money Account).
*
* Source of truth: the MoonPay/Iron Enterprise `AutorampTransaction`, which the
* neobank-proxy (onramp-api PR #1124) forwards verbatim. Per Iron's OpenAPI
* spec the payout hash is nested at `payout_crypto_transaction.transaction_hash`
* and the list is a `PagedList` with an `items` array. Because that proxy PR is
* unmerged and still in flux (its own fixtures use `data`/flat `transaction_hash`,
* matching the webhook shape) and TRAM-3925 will add a mobile-safe DTO, the
* mapper reads both the nested and flat hash and both the `items` and `data`
* envelopes so it works whichever shape ships. Display fields (amount/currency/
* recipient) are MoonPay structured objects, not simple top-level fields, so they
* are intentionally not mapped yet; TRAM-3925 will pin the wire names.
*/
/* eslint-disable @typescript-eslint/naming-convention -- snake_case proxy wire format */
export type NeoBankTransactionResponse = {
id: string;
status: string;
autoramp_id?: string;
/**
* Monad payout transaction hash, nested under the crypto payout on the Iron
* `AutorampTransaction` (present once the payout settles on-chain).
*/
payout_crypto_transaction?: {
transaction_hash?: string;
};
/** Flat payout hash fallback (the webhook / proxy-fixture shape). */
transaction_hash?: string;
};
/* eslint-enable @typescript-eslint/naming-convention */

/**
* Envelope returned by the neo-bank transactions endpoint.
*
* Primary shape is the Iron `PagedList` (`{ items, cursor, prev_cursor }`); the
* proxy PR's transitional `{ data, next_cursor }` and a bare array are accepted
* as fallbacks. Only the first page is read for now - cursor pagination is a
* follow-up.
*/
/* eslint-disable @typescript-eslint/naming-convention -- snake_case proxy wire format */
export type NeoBankTransactionsResponse =
| NeoBankTransactionResponse[]
| {
items?: NeoBankTransactionResponse[];
cursor?: string | null;
prev_cursor?: string | null;
data?: NeoBankTransactionResponse[];
next_cursor?: string | null;
};
/* eslint-enable @typescript-eslint/naming-convention */

/**
* Optional headers for neo-bank mutating requests.
*/
Expand Down Expand Up @@ -112,6 +168,7 @@ export type RegisterSelfHostedWalletParams = {

const MESSENGER_EXPOSED_METHODS = [
'getAutoramp',
'getAutorampTransactions',
'registerPixAddress',
'getAutorampQuote',
'createAutoramp',
Expand Down Expand Up @@ -213,6 +270,32 @@ export function mapNeoBankAutorampToRemoteSnapshot(
};
}

/**
* Maps a neo-bank proxy transaction response into a local deposit snapshot.
*
* @param response - Single transaction from the proxy transactions endpoint.
* @returns Snapshot consumed by `applyDepositRemoteStatus`.
*/
export function mapNeoBankTransactionToRemoteSnapshot(
response: NeoBankTransactionResponse,
): MoneyAccountDepositRemoteSnapshot {
// Payout hash: nested on the Iron `AutorampTransaction`, flat on the webhook /
// proxy-fixture shape - read both. Display fields (moneyAccountAddress/amount/
// currency) are left unset: MoonPay carries them as structured source/
// destination objects, not simple top-level fields; they await the mobile-safe
// DTO (TRAM-3925).
const payoutTransactionHash =
response.payout_crypto_transaction?.transaction_hash ??
response.transaction_hash;

return {
id: response.id,
autorampId: response.autoramp_id,
status: response.status,
payoutTransactionHash: payoutTransactionHash as Hex | undefined,
};
}

/**
* Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy).
*
Expand Down Expand Up @@ -405,6 +488,30 @@ export class NeoBankService {
return mapNeoBankAutorampToRemoteSnapshot(response);
}

#mapTransactionsResponse(
response: NeoBankTransactionsResponse,
): MoneyAccountDepositRemoteSnapshot[] {
// Primary shape is the Iron `PagedList` (`items` array); the proxy PR's
// transitional `data` array and a bare array are accepted as fallbacks.
// Single page only for now - cursor pagination is a follow-up.
const list = Array.isArray(response)
? response
: (response?.items ?? response?.data);
if (!Array.isArray(list)) {
throw new Error(
'Malformed response received from neo-bank transactions API',
);
}
return list.map((item) => {
if (!item || typeof item !== 'object' || !item.id || !item.status) {
throw new Error(
'Malformed response received from neo-bank transactions API',
);
}
return mapNeoBankTransactionToRemoteSnapshot(item);
});
}

/**
* Fetches an autoramp account via neobank-proxy
* `GET /neobank/autoramps/{autoramp_id}` (MoonPay
Expand All @@ -420,6 +527,27 @@ export class NeoBankService {
return this.#mapAutorampResponse(response);
}

/**
* Fetches deposit/transaction records for an autoramp via neobank-proxy
* `GET /neobank/autoramp-transactions?autoramp_id={autoramp_id}` (MoonPay
* `GET /api/autoramp-transactions`, response is a MoonPay `PagedList`).
*
* Used by the deposit poller to detect status changes (e.g. a payout settling
* on Monad). Route + response shape track onramp-api PR #1124.
*
* @param autorampId - MoonPay / Ramp API autoramp id.
* @returns Deposit snapshots for controller apply/refresh.
*/
async getAutorampTransactions(
autorampId: string,
): Promise<MoneyAccountDepositRemoteSnapshot[]> {
const response = await this.#getJson<NeoBankTransactionsResponse>(
'autoramp-transactions',
{ autoramp_id: autorampId },
);
return this.#mapTransactionsResponse(response);
}

/**
* Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`.
* Body is forwarded as opaque JSON (MoonPay address schema).
Expand Down
Loading