From f06facf6db837429cba35d89f8e373f9de8b2e30 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 31 Aug 2026 10:04:05 -0600 Subject: [PATCH 1/5] feat(ramps-controller): persist autoramps as a last-seen cursor Wire NeoBankService into RampsController for create/refresh/push and Money Account wallet registration. Persist autoramps locally so resume refresh can emit notifications against MoonPay without User Storage or cross-device sync. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 + .../RampsController-method-action-types.ts | 104 +++ .../src/RampsController.test.ts | 716 +++++++++++++++++- .../ramps-controller/src/RampsController.ts | 576 +++++++++++++- .../src/autorampAccount.test.ts | 190 +++++ .../ramps-controller/src/autorampAccount.ts | 236 ++++++ packages/ramps-controller/src/index.ts | 28 + .../src/ownership-message.test.ts | 66 ++ .../ramps-controller/src/ownership-message.ts | 32 + .../src/wallet-registration-machine.test.ts | 297 ++++++++ .../src/wallet-registration-machine.ts | 221 ++++++ .../src/wallet-registration-service.ts | 7 +- 12 files changed, 2460 insertions(+), 15 deletions(-) create mode 100644 packages/ramps-controller/src/autorampAccount.test.ts create mode 100644 packages/ramps-controller/src/autorampAccount.ts create mode 100644 packages/ramps-controller/src/ownership-message.test.ts create mode 100644 packages/ramps-controller/src/ownership-message.ts create mode 100644 packages/ramps-controller/src/wallet-registration-machine.test.ts create mode 100644 packages/ramps-controller/src/wallet-registration-machine.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f7d0e8caf6..ee4207c2d8 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. + ## [20.2.0] ### Added diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index c1ba47ff75..890fd74652 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -324,6 +324,102 @@ export type RampsControllerRemoveOrderAction = { handler: RampsController['removeOrder']; }; +/** + * Adds or updates a local autoramp last-seen cursor (e.g. after create). + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ +export type RampsControllerAddAutorampAction = { + type: `RampsController:addAutoramp`; + handler: RampsController['addAutoramp']; +}; + +/** + * Creates an autoramp via the neo-bank proxy and applies the returned + * snapshot as the local last-seen cursor. + * + * The vendor `customer_id` is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request (any caller-supplied `customer_id` is overwritten). + * + * @param request - CreateAutoramp payload. + * @param options - Optional idempotency key forwarded to the proxy. + * @param options.idempotencyKey - Value sent as `Idempotency-Key`. + * @returns The created/updated local {@link AutorampAccount}. + */ +export type RampsControllerCreateAutorampAction = { + type: `RampsController:createAutoramp`; + handler: RampsController['createAutoramp']; +}; + +/** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The registration state, or `{ type: 'lookupUnavailable' }` when + * the address-list lookup fails (never treated as unregistered). + */ +export type RampsControllerRegisterMoneyAccountWalletAction = { + type: `RampsController:registerMoneyAccountWallet`; + handler: RampsController['registerMoneyAccountWallet']; +}; + +/** + * Removes a local autoramp last-seen cursor by id. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerRemoveAutorampAction = { + type: `RampsController:removeAutoramp`; + handler: RampsController['removeAutoramp']; +}; + +/** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerMarkAutorampAsNotifiedAction = { + type: `RampsController:markAutorampAsNotified`; + handler: RampsController['markAutorampAsNotified']; +}; + +/** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ +export type RampsControllerApplyAutorampStatusFromPushAction = { + type: `RampsController:applyAutorampStatusFromPush`; + handler: RampsController['applyAutorampStatusFromPush']; +}; + +/** + * Fetches one autoramp from the neo-bank proxy and applies it to the + * last-seen cursor. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ +export type RampsControllerRefreshAutorampAction = { + type: `RampsController:refreshAutoramp`; + handler: RampsController['refreshAutoramp']; +}; + +/** + * Refreshes all known local autoramps from MoonPay. + * Intended for app resume / unlock catch-up when webhooks were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ +export type RampsControllerRefreshAutorampsAction = { + type: `RampsController:refreshAutoramps`; + handler: RampsController['refreshAutoramps']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -734,6 +830,14 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerAddAutorampAction + | RampsControllerCreateAutorampAction + | RampsControllerRegisterMoneyAccountWalletAction + | RampsControllerRemoveAutorampAction + | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerApplyAutorampStatusFromPushAction + | RampsControllerRefreshAutorampAction + | RampsControllerRefreshAutorampsAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index f18c74bd91..f7eeb5511e 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -11,7 +11,9 @@ import type { Json } from '@metamask/utils'; import * as fs from 'fs'; import * as path from 'path'; +import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -22,6 +24,7 @@ import { RampsController, getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, } from './RampsController.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; import type { @@ -77,12 +80,12 @@ describe('RampsController', () => { 'Execution prevented because the circuit breaker is open'; describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { - it('includes every RampsService action that RampsController calls', async () => { + it('includes every RampsService, TransakService, and NeoBankService action that RampsController calls', async () => { expect.hasAssertions(); const controllerPath = path.join(__dirname, 'RampsController.ts'); const source = await fs.promises.readFile(controllerPath, 'utf-8'); const callPattern = - /messenger\.call\s*\(\s*['"]((RampsService|TransakService):[^'"]+)['"]/gu; + /messenger\.call\s*\(\s*['"]((RampsService|TransakService|NeoBankService):[^'"]+)['"]/gu; const calledActions = new Set(); let match: RegExpExecArray | null; while ((match = callPattern.exec(source)) !== null) { @@ -103,6 +106,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -179,6 +183,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2211,6 +2216,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2277,6 +2283,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2319,6 +2326,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2337,6 +2345,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -9368,6 +9377,708 @@ describe('RampsController', () => { }); }); + describe('autoramps', () => { + it('adds and removes autoramp accounts', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(controller.state.autoramps[0]?.id).toBe('ar-1'); + expect(controller.state.autoramps[0]?.status).toBe( + AutorampStatus.Authorized, + ); + + controller.removeAutoramp('ar-1'); + expect(controller.state.autoramps).toHaveLength(0); + }); + }); + + it('applies push snapshots and publishes notable transitions', async () => { + await withController(async ({ controller, messenger }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const events: unknown[] = []; + messenger.subscribe( + 'RampsController:autorampStatusChanged', + (payload) => { + events.push(payload); + }, + ); + + const updated = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + expect(updated.status).toBe(AutorampStatus.Approved); + expect(updated.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + shouldNotify: true, + }); + }); + }); + + it('refreshes autoramps via NeoBankService', async () => { + await withController(async ({ controller, rootMessenger }) => { + const getAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + getAutoramp, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramp('ar-1'); + expect(getAutoramp).toHaveBeenCalledWith('ar-1'); + expect(updated.status).toBe(AutorampStatus.Approved); + + await controller.refreshAutoramps(); + expect(getAutoramp).toHaveBeenCalledTimes(2); + }); + }); + + it('injects the Profile Sync customer id and applies the created autoramp', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => ({ id: 'cust-99' }), + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'cust-99', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + const created = await controller.createAutoramp( + { customer_id: 'attacker-supplied', foo: 'bar' }, + { idempotencyKey: 'idem-1' }, + ); + + expect(createAutoramp).toHaveBeenCalledWith( + { foo: 'bar', customer_id: 'cust-99' }, + { idempotencyKey: 'idem-1' }, + ); + expect(created.id).toBe('ar-new'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-new')?.customerId, + ).toBe('cust-99'); + }); + }); + + it('prefers canonicalProfileId when resolving the external customer id', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'cust-canonical' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'cust-canonical', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('canonical-1'); + }); + }); + + it('throws when no mapped external customer is available', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => null, + ); + const createAutoramp = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await expect(controller.createAutoramp({})).rejects.toThrow( + /no MoonPay customer is mapped to external id "profile-1"/u, + ); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + + it('throws when the wallet is not signed in to Profile Sync', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: '', + canonicalProfileId: '', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await expect(controller.createAutoramp({})).rejects.toThrow( + /wallet is not signed in to Profile Sync/u, + ); + expect(getCustomerByExternalId).not.toHaveBeenCalled(); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + + it('falls back to profileId when canonicalProfileId is empty', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: '', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'cust-profile' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + async () => ({ + id: 'ar-new', + customerId: 'cust-profile', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }), + ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1'); + }); + }); + + it('skips failed refreshes when refreshing all autoramps', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + async (id: string) => { + if (id === 'ar-bad') { + throw new Error('network'); + } + return { + id, + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }; + }, + ); + + controller.addAutoramp({ + id: 'ar-bad', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + controller.addAutoramp({ + id: 'ar-good', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramps(); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe('ar-good'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-bad')?.status, + ).toBe(AutorampStatus.Authorized); + }); + }); + + it('marks autoramp as notified', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + expect(controller.state.autoramps[0]?.notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); + it('updates an existing autoramp when the id is already known', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Approved, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(updated.walletAddress).toBe('0xdef'); + expect(updated.status).toBe(AutorampStatus.Approved); + }); + }); + + it('ignores removal and notification for unknown autoramp ids', async () => { + await withController(({ controller }) => { + controller.removeAutoramp('missing'); + controller.markAutorampAsNotified('missing'); + + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + it('creates an autoramp from a push that carries no wallet address', async () => { + await withController(({ controller }) => { + const created = controller.applyAutorampStatusFromPush({ + id: 'ar-new', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(created.walletAddress).toBe(''); + expect(controller.state.autoramps).toHaveLength(1); + }); + }); + + it('keeps local identity fields when a remote push omits or blanks them', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const afterOmitted = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + status: AutorampStatus.Approved, + }); + + expect(afterOmitted.customerId).toBe('cust-1'); + expect(afterOmitted.walletAddress).toBe('0xabc'); + + const afterBlank = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + walletAddress: '', + status: AutorampStatus.Approved, + }); + + expect(afterBlank.customerId).toBe('cust-1'); + expect(afterBlank.walletAddress).toBe('0xabc'); + }); + }); + }); + + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + type WalletRegistrationHandlers = { + getSessionProfile: jest.Mock; + getCustomerByExternalId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; + }; + + /** + * Registers default handlers for every messenger action the wallet + * registration flow calls, returning the mocks for per-test overrides. + * + * @param rootMessenger - The root messenger of the controller under test. + * @returns The registered handler mocks. + */ + function registerWalletRegistrationHandlers( + rootMessenger: RootMessenger, + ): WalletRegistrationHandlers { + const handlers: WalletRegistrationHandlers = { + getSessionProfile: jest.fn().mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }), + getCustomerByExternalId: jest + .fn() + .mockResolvedValue({ id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), + }; + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + handlers.getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + handlers.getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); + return handlers; + } + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('signs and submits an ownership proof for an absent registration', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer iron-customer-1.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'iron-customer-1', + signature: '0xsig', + idempotencyKey: expect.any(String), + }), + ); + }); + }); + + it('resolves the customer id via Profile Sync external-id lookup', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getSessionProfile.mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }); + handlers.getCustomerByExternalId.mockResolvedValue({ + id: 'iron-customer-fallback', + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.getCustomerByExternalId).toHaveBeenCalledWith( + 'canonical-1', + ); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockResolvedValueOnce({ type: 'active', registration }); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + }); + }); + + it('rethrows a transient failure when reconciliation remains absent', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new WalletRegistrationError('transient', { + httpStatus: 502, + }); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); + }); + }); + + it('rebuilds and re-signs after a UTC date rollover', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); + try { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.registerSelfHostedWallet + .mockImplementationOnce(async () => { + jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); + throw new WalletRegistrationError('validation', { + httpStatus: 400, + }); + }) + .mockResolvedValueOnce({ + type: 'registered', + registration, + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); + expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( + 'signed on 12/08/2026', + ); + expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( + 'signed on 13/08/2026', + ); + }); + } finally { + jest.useRealTimers(); + } + }); + + it.each([ + new WalletRegistrationError('validation', { httpStatus: 400 }), + new WalletRegistrationError('rateLimited', { httpStatus: 429 }), + new WalletRegistrationError('unauthorized', { httpStatus: 401 }), + new Error('unexpected'), + ])('rethrows terminal registration failure %#', async (error) => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('returns lookupUnavailable when the initial status lookup fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new WalletRegistrationError('lookupUnavailable', { + httpStatus: 500, + body: 'boom', + }); + handlers.getWalletRegistrationStatus.mockRejectedValue(error); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'lookupUnavailable', + error, + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + + it('wraps a non-typed initial lookup failure as lookupUnavailable', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockRejectedValue( + new Error('lookup failed'), + ); + + const result = await controller.registerMoneyAccountWallet({ + address: '0xabc', + }); + + expect(result).toMatchObject({ + type: 'lookupUnavailable', + error: expect.objectContaining({ + name: 'WalletRegistrationError', + kind: 'lookupUnavailable', + body: 'lookup failed', + }), + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns lookupUnavailable when conflict reconciliation cannot list addresses', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const lookupError = new WalletRegistrationError('lookupUnavailable', { + httpStatus: 503, + }); + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockRejectedValueOnce(lookupError); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'lookupUnavailable', + error: lookupError, + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(1); + }); + }); + + it('rethrows a signing failure without submitting', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new Error('signing failed'); + handlers.signPersonalMessage.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + }); + describe('addOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', @@ -12268,6 +12979,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { messenger, actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, 'RemoteFeatureFlagController:getState', ], }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 84319250bc..01170f46dd 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,19 +6,39 @@ import type { import { BaseController } from '@metamask/base-controller'; import { BrokenCircuitError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; +import type { + AutorampAccount, + AutorampRemoteSnapshot, + CreateAutorampRequest, +} from './autorampAccount.js'; +import { + applyAutorampRemoteStatus, + createAutorampAccount, + markAutorampNotified, +} from './autorampAccount.js'; import { getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampAction, + NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, +} from './NeoBankService-method-action-types.js'; +import type { NeoBankServiceActions } from './NeoBankService.js'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js'; +import { buildOwnershipMessage } from './ownership-message.js'; import { mergePaymentMethodsById, pickPaymentMethod, @@ -122,6 +142,18 @@ import type { TransakOrder, } from './TransakService.js'; import type { TransakServiceActions } from './TransakService.js'; +import { + createInitialState as createInitialWalletRegistrationState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; // === GENERAL === @@ -137,10 +169,7 @@ export const controllerName = 'RampsController'; * Any host (e.g. mobile) that creates a RampsController messenger must delegate * these actions from the root messenger so the controller can function. */ -export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( - | RampsServiceActions['type'] - | TransakServiceActions['type'] -)[] = [ +export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', 'RampsService:getCountries', @@ -176,7 +205,80 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:cancelOrder', 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', -]; + 'NeoBankService:getAutoramp', + 'NeoBankService:createAutoramp', + 'NeoBankService:getCustomerByExternalId', + 'NeoBankService:getWalletRegistrationStatus', + 'NeoBankService:registerSelfHostedWallet', +] as const satisfies readonly ( + | RampsServiceActions['type'] + | TransakServiceActions['type'] + | NeoBankServiceActions['type'] +)[]; + +/** + * Other controller actions RampsController calls via the messenger. + * Hosts that enable autoramp creation must delegate these from the root + * messenger so the controller can resolve the vendor customer identity from + * Profile Sync. `KeyringController:signPersonalMessage` is required for Money + * Account self-hosted wallet registration (EIP-191 ownership proof). + */ +export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ + 'AuthenticationController:getSessionProfile', + 'KeyringController:signPersonalMessage', +] as const; + +/** + * Structural type for the keyring controller's `signPersonalMessage` messenger + * action (EIP-191). Declared locally to avoid a package dependency for a single + * type-only messenger action. + */ +export type KeyringControllerSignPersonalMessageAction = { + type: 'KeyringController:signPersonalMessage'; + handler: (messageParams: { data: string; from: string }) => Promise; +}; + +/** + * Outcome of {@link RampsController.registerMoneyAccountWallet}. + * + * `lookupUnavailable` means the address list could not be fetched or parsed. + * It is not the same as unregistered — callers must not treat it as a cue to + * submit a new ownership proof. + */ +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + } + | { + type: 'lookupUnavailable'; + error: WalletRegistrationError; + }; + +type LookupUnavailableResult = Extract< + MoneyAccountWalletRegistrationResult, + { type: 'lookupUnavailable' } +>; + +/** + * Distinguishes an already-materialized {@link AutorampAccount} from the + * create-fields shape accepted by {@link RampsController.addAutoramp}. + * + * @param value - Full account or create fields. + * @returns Whether the value already carries the derived account fields. + */ +function isFullAutorampAccount( + value: AutorampAccount | { id: string; customerId: string }, +): value is AutorampAccount { + return ( + typeof (value as AutorampAccount).updatedAt === 'number' && + (value as AutorampAccount).lastSeenStatus !== undefined + ); +} /** * Default TTL for quotes requests (15 seconds). @@ -417,6 +519,12 @@ export type RampsControllerState = { * and persists them. */ orders: RampsOrder[]; + /** + * Last-seen MoonPay autoramp accounts (standing routes). MoonPay is the + * source of truth; this cache is used to detect status transitions for + * notifications after refresh or push. + */ + autoramps: AutorampAccount[]; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -478,6 +586,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + autoramps: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -544,6 +658,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, }, orders: [], + autoramps: [], providerAutoSelected: false, }; } @@ -668,7 +783,14 @@ type AllowedActions = | TransakServiceGetIdProofStatusAction | TransakServiceCancelOrderAction | TransakServiceCancelAllActiveOrdersAction - | TransakServiceGetActiveOrdersAction; + | TransakServiceGetActiveOrdersAction + | NeoBankServiceGetAutorampAction + | NeoBankServiceCreateAutorampAction + | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction + | KeyringControllerSignPersonalMessageAction; /** * Published when the state of {@link RampsController} changes. @@ -687,12 +809,27 @@ export type RampsControllerOrderStatusChangedEvent = { payload: [{ order: RampsOrder; previousStatus: RampsOrderStatus }]; }; +/** + * Published when an autoramp's last-seen status changes after refresh or push. + */ +export type RampsControllerAutorampStatusChangedEvent = { + type: `${typeof controllerName}:autorampStatusChanged`; + payload: [ + { + autoramp: AutorampAccount; + previousStatus: AutorampAccount['status']; + shouldNotify: boolean; + }, + ]; +}; + /** * Events that {@link RampsControllerMessenger} exposes to other consumers. */ export type RampsControllerEvents = | RampsControllerStateChangeEvent - | RampsControllerOrderStatusChangedEvent; + | RampsControllerOrderStatusChangedEvent + | RampsControllerAutorampStatusChangedEvent; /** * Events from other messengers that {@link RampsController} subscribes to. @@ -842,6 +979,14 @@ const MESSENGER_EXPOSED_METHODS = [ 'getQuotes', 'addOrder', 'removeOrder', + 'addAutoramp', + 'createAutoramp', + 'removeAutoramp', + 'registerMoneyAccountWallet', + 'markAutorampAsNotified', + 'applyAutorampStatusFromPush', + 'refreshAutoramp', + 'refreshAutoramps', 'startOrderPolling', 'stopOrderPolling', 'getBuyWidgetData', @@ -2762,6 +2907,423 @@ export class RampsController extends BaseController< this.#orderPollingMeta.delete(providerOrderId); } + /** + * Adds or updates a local autoramp last-seen cursor (e.g. after create). + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ + addAutoramp( + accountOrInput: + | AutorampAccount + | { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampAccount['status'] | string; + }, + ): AutorampAccount { + const account: AutorampAccount = isFullAutorampAccount(accountOrInput) + ? accountOrInput + : createAutorampAccount(accountOrInput); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (existing) => existing.id === account.id, + ); + if (idx === -1) { + state.autoramps.push(account as Draft); + } else { + state.autoramps[idx] = { + ...state.autoramps[idx], + ...account, + } as Draft; + } + }); + + return ( + this.state.autoramps.find((existing) => existing.id === account.id) ?? + account + ); + } + + /** + * Creates an autoramp via the neo-bank proxy and applies the returned + * snapshot as the local last-seen cursor. + * + * The vendor `customer_id` is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request (any caller-supplied `customer_id` is overwritten). + * + * @param request - CreateAutoramp payload. + * @param options - Optional idempotency key forwarded to the proxy. + * @param options.idempotencyKey - Value sent as `Idempotency-Key`. + * @returns The created/updated local {@link AutorampAccount}. + */ + async createAutoramp( + request: CreateAutorampRequest, + options: { idempotencyKey?: string } = {}, + ): Promise { + const customerId = await this.resolveAutorampCustomerId(); + + const body = { ...request, customer_id: customerId }; + const remote = await this.messenger.call( + 'NeoBankService:createAutoramp', + body, + options, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Resolves the vendor `customer_id` for autoramp / Money Account operations. + * + * Maps the wallet's Profile Sync id (the partner `external_id`) to the + * vendor customer via `GET /neobank/customers/{external_id}/external`. + * + * @returns The vendor customer id. + */ + async resolveAutorampCustomerId(): Promise { + const profile = await this.messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Cannot resolve MoonPay customer id: wallet is not signed in to Profile Sync.', + ); + } + + const customer = await this.messenger.call( + 'NeoBankService:getCustomerByExternalId', + externalId, + ); + const customerId = + customer && + typeof customer === 'object' && + typeof (customer as { id?: unknown }).id === 'string' + ? (customer as { id: string }).id + : null; + if (!customerId) { + throw new Error( + `Cannot resolve MoonPay customer id: no MoonPay customer is mapped to external id "${externalId}".`, + ); + } + return customerId; + } + + /** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The registration state, or `{ type: 'lookupUnavailable' }` when + * the address-list lookup fails (never treated as unregistered). + */ + async registerMoneyAccountWallet({ + address, + }: { + address: string; + }): Promise { + let machine = transitionWalletRegistration( + createInitialWalletRegistrationState(), + { type: 'START' }, + ); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + const customerId = await this.resolveAutorampCustomerId(); + + const toLookupUnavailableResult = ( + error: unknown, + ): LookupUnavailableResult => { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + return { + type: 'lookupUnavailable', + error: + error instanceof WalletRegistrationError + ? error + : new WalletRegistrationError('lookupUnavailable', { + message: 'self-hosted address lookup failed', + body: error instanceof Error ? error.message : undefined, + }), + }; + }; + + const lookup = async (): Promise< + RegistrationStatus | LookupUnavailableResult + > => { + try { + return await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address }, + ); + } catch (error) { + return toLookupUnavailableResult(error); + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const resolveLookup = async (): Promise< + MoneyAccountWalletRegistrationResult | undefined + > => { + const status = await lookup(); + if (status.type === 'lookupUnavailable') { + return status; + } + return applyLookup(status); + }; + + const existingResult = await resolveLookup(); + if (existingResult) { + return existingResult; + } + + let idempotencyKey = createIdempotencyKey(); + let lastMessage: string | undefined; + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'NeoBankService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + idempotencyKey, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = await resolveLookup(); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + + /** + * Removes a local autoramp last-seen cursor by id. + * + * @param autorampId - MoonPay autoramp id. + */ + removeAutoramp(autorampId: string): void { + this.update((state) => { + state.autoramps = state.autoramps.filter( + (autoramp) => autoramp.id !== autorampId, + ); + }); + } + + /** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ + markAutorampAsNotified(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + if (!existing) { + return; + } + const notified = markAutorampNotified(existing); + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === autorampId, + ); + if (idx !== -1) { + state.autoramps[idx] = notified as Draft; + } + }); + } + + /** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ + applyAutorampStatusFromPush(remote: AutorampRemoteSnapshot): AutorampAccount { + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Fetches one autoramp from the neo-bank proxy and applies it to the + * last-seen cursor. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ + async refreshAutoramp(autorampId: string): Promise { + const remote = await this.messenger.call( + 'NeoBankService:getAutoramp', + autorampId, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Refreshes all known local autoramps from MoonPay. + * Intended for app resume / unlock catch-up when webhooks were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ + async refreshAutoramps(): Promise { + const ids = this.state.autoramps.map((autoramp) => autoramp.id); + const updated: AutorampAccount[] = []; + + for (const id of ids) { + try { + updated.push(await this.refreshAutoramp(id)); + } catch { + // Keep local cursor for this id; continue remaining refreshes. + } + } + + return updated; + } + + #applyAutorampRemoteSnapshot( + remote: AutorampRemoteSnapshot, + ): AutorampAccount { + const local = + this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? + null; + const result = applyAutorampRemoteStatus(local, remote); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === result.account.id, + ); + if (idx === -1) { + state.autoramps.push(result.account as Draft); + } else { + state.autoramps[idx] = result.account as Draft; + } + }); + + if (result.statusChanged) { + this.messenger.publish('RampsController:autorampStatusChanged', { + autoramp: result.account, + previousStatus: result.previousStatus, + shouldNotify: result.shouldNotify, + }); + } + + return ( + this.state.autoramps.find( + (autoramp) => autoramp.id === result.account.id, + ) ?? result.account + ); + } + /** * Refreshes a single order via the V2 API and updates it in state. * Publishes orderStatusChanged if the status transitioned. diff --git a/packages/ramps-controller/src/autorampAccount.test.ts b/packages/ramps-controller/src/autorampAccount.test.ts new file mode 100644 index 0000000000..8dc7aedea0 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.test.ts @@ -0,0 +1,190 @@ +import type { + ApplyAutorampRemoteStatusResult, + AutorampAccount, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import { + AutorampStatus, + applyAutorampRemoteStatus, + createAutorampAccount, + isTerminalAutorampStatus, + markAutorampNotified, + normalizeAutorampStatus, +} from './autorampAccount.js'; + +describe('autorampAccount', () => { + describe('normalizeAutorampStatus', () => { + it('returns known statuses as-is', () => { + expect(normalizeAutorampStatus(AutorampStatus.Approved)).toBe( + AutorampStatus.Approved, + ); + expect(normalizeAutorampStatus('DepositAccountAdded')).toBe( + AutorampStatus.DepositAccountAdded, + ); + }); + + it('falls back to Created for unknown values', () => { + expect(normalizeAutorampStatus('Nope')).toBe(AutorampStatus.Created); + }); + }); + + describe('isTerminalAutorampStatus', () => { + it('identifies terminal statuses', () => { + expect(isTerminalAutorampStatus(AutorampStatus.Rejected)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Cancelled)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Approved)).toBe(false); + expect(isTerminalAutorampStatus(AutorampStatus.Authorized)).toBe(false); + }); + }); + + describe('createAutorampAccount', () => { + it('defaults status to Authorized and mirrors lastSeenStatus', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + updatedAt: 1000, + }); + + expect(account).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1000, + depositRailsSummary: undefined, + }); + }); + }); + + describe('applyAutorampRemoteStatus', () => { + const baseLocal: AutorampAccount = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + updatedAt: 1, + }); + + it('creates a local account without notify when local is null', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }; + + const result = applyAutorampRemoteStatus(null, remote); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + }); + + it('defaults missing identity on first upsert', () => { + const result = applyAutorampRemoteStatus(null, { + id: 'ar-1', + status: AutorampStatus.Authorized, + }); + expect(result.account.customerId).toBe(''); + expect(result.account.walletAddress).toBe(''); + }); + + it('detects Approved transition and requests notify once', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }; + + const result = applyAutorampRemoteStatus(baseLocal, remote); + + expect(result).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + statusChanged: true, + shouldNotify: true, + } satisfies Partial); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.lastSeenStatus).toBe(AutorampStatus.Authorized); + }); + + it('does not notify again when already notified for that status', () => { + const local = markAutorampNotified({ + ...baseLocal, + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Authorized, + notifiedForStatus: AutorampStatus.Approved, + }); + + const result = applyAutorampRemoteStatus(local, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + }); + + it('does not notify for non-notable transitions', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.DepositAccountAdded, + }); + + expect(result.statusChanged).toBe(true); + expect(result.shouldNotify).toBe(false); + }); + + it('keeps local identity when remote omits or blanks customerId and walletAddress', () => { + const omitted = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + status: AutorampStatus.Approved, + }); + expect(omitted.account.customerId).toBe('cust-1'); + expect(omitted.account.walletAddress).toBe('0xabc'); + + const blank = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: '', + walletAddress: '', + status: AutorampStatus.Approved, + }); + expect(blank.account.customerId).toBe('cust-1'); + expect(blank.account.walletAddress).toBe('0xabc'); + }); + + it('notifies for Rejected', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Rejected, + }); + + expect(result.shouldNotify).toBe(true); + }); + }); + + describe('markAutorampNotified', () => { + it('sets notifiedForStatus to current status', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + + expect(markAutorampNotified(account).notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); +}); diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts new file mode 100644 index 0000000000..213a346f29 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -0,0 +1,236 @@ +/** + * Local + remote models for MoonPay Enterprise autoramp accounts. + * Separate from {@link RampsOrder}: autoramps are standing routes; orders are payments. + */ + +import type { + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, +} from './autoramp-types.js'; + +export type { + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, +} from './autoramp-types.js'; + +/** + * Autoramp lifecycle statuses from MoonPay Enterprise. + * + * @see https://dev.enterprise.moonpay.com/autoramp-status + */ +export enum AutorampStatus { + Created = 'Created', + Authorized = 'Authorized', + EditPending = 'EditPending', + DepositAccountAdded = 'DepositAccountAdded', + Approved = 'Approved', + Rejected = 'Rejected', + Cancelled = 'Cancelled', +} + +/** + * Local controller representation of an autoramp account. + */ +export type AutorampAccount = { + /** MoonPay autoramp id. */ + id: string; + /** MoonPay customer id. */ + customerId: string; + /** Destination wallet address associated with this autoramp. */ + walletAddress: string; + /** Latest status from MoonPay (source of truth after refresh). */ + status: AutorampStatus; + /** + * Status observed before the most recent remote apply. + * Used for transition UX / analytics (e.g. Authorized → Approved). + */ + lastSeenStatus: AutorampStatus; + /** + * Last status for which the UI already showed a notification. + * Prevents duplicate toasts across refresh and push. + */ + notifiedForStatus?: AutorampStatus; + /** Epoch ms of the last local update from remote or push. */ + updatedAt: number; + /** Optional non-PII deposit readiness cache. */ + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Controller-facing payload for creating an autoramp. + * + * Mirrors the MoonPay `POST /api/autoramps` body that + * {@link NeoBankService.createAutoramp} forwards opaquely, minus `customer_id`: + * `RampsController.createAutoramp` injects the vendor customer id resolved from + * Profile Sync, so callers never supply (or need to know) it. + */ +export type CreateAutorampRequest = Record; + +/** + * Result of applying a remote autoramp snapshot onto local state. + */ +export type ApplyAutorampRemoteStatusResult = { + account: AutorampAccount; + previousStatus: AutorampStatus; + statusChanged: boolean; + /** True when status changed and UI has not yet notified for the new status. */ + shouldNotify: boolean; +}; + +/** + * Terminal autoramp statuses — no further lifecycle progress expected. + */ +export const TERMINAL_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Statuses that commonly warrant user-visible transition UX (toast / banner). + */ +export const NOTABLE_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Approved, + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Whether an autoramp status is terminal. + * + * @param status - Status to test. + * @returns Whether the status is terminal. + */ +export function isTerminalAutorampStatus(status: AutorampStatus): boolean { + return TERMINAL_AUTORAMP_STATUSES.has(status); +} + +/** + * Normalize a remote status string into {@link AutorampStatus}. + * Unknown values fall back to {@link AutorampStatus.Created}. + * + * @param status - Remote status string. + * @returns A known {@link AutorampStatus}. + */ +export function normalizeAutorampStatus( + status: AutorampStatus | string, +): AutorampStatus { + if (Object.values(AutorampStatus).includes(status as AutorampStatus)) { + return status as AutorampStatus; + } + return AutorampStatus.Created; +} + +/** + * Build a new local autoramp account from create/response fields. + * + * @param input - Required identity + status fields. + * @param input.id - MoonPay autoramp id. + * @param input.customerId - MoonPay customer id. + * @param input.walletAddress - Destination wallet address. + * @param input.status - Optional remote status (defaults to Authorized). + * @param input.depositRailsSummary - Optional non-PII deposit readiness cache. + * @param input.updatedAt - Optional epoch ms timestamp (defaults to now). + * @returns A new {@link AutorampAccount}. + */ +export function createAutorampAccount(input: { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; + updatedAt?: number; +}): AutorampAccount { + const status = normalizeAutorampStatus( + input.status ?? AutorampStatus.Authorized, + ); + return { + id: input.id, + customerId: input.customerId, + walletAddress: input.walletAddress, + status, + lastSeenStatus: status, + updatedAt: input.updatedAt ?? Date.now(), + depositRailsSummary: input.depositRailsSummary, + }; +} + +/** + * Apply a remote autoramp snapshot onto a local account for transition detection. + * Pure helper — shared by refresh-on-load and websocket push paths. + * + * @param local - Current local account (or null when first upserting from remote). + * @param remote - Remote snapshot (MoonPay GET or push). + * @returns Updated account plus change / notify flags. + */ +export function applyAutorampRemoteStatus( + local: AutorampAccount | null, + remote: AutorampRemoteSnapshot, +): ApplyAutorampRemoteStatusResult { + const remoteStatus = normalizeAutorampStatus(remote.status); + + if (!local) { + const account = createAutorampAccount({ + id: remote.id, + customerId: remote.customerId ?? '', + walletAddress: remote.walletAddress ?? '', + status: remoteStatus, + depositRailsSummary: remote.depositRailsSummary, + }); + return { + account, + previousStatus: remoteStatus, + statusChanged: false, + shouldNotify: false, + }; + } + + const previousStatus = local.status; + const statusChanged = previousStatus !== remoteStatus; + const shouldNotify = + statusChanged && + local.notifiedForStatus !== remoteStatus && + NOTABLE_AUTORAMP_STATUSES.has(remoteStatus); + + const account: AutorampAccount = { + ...local, + id: remote.id, + // A blank remote identity field means "not supplied", not "cleared": the + // proxy omits or empties these on partial status pushes, so keep the local + // value rather than wiping it. + customerId: + remote.customerId !== undefined && remote.customerId.length > 0 + ? remote.customerId + : local.customerId, + walletAddress: + remote.walletAddress !== undefined && remote.walletAddress.length > 0 + ? remote.walletAddress + : local.walletAddress, + status: remoteStatus, + lastSeenStatus: previousStatus, + updatedAt: Date.now(), + depositRailsSummary: + remote.depositRailsSummary ?? local.depositRailsSummary, + }; + + return { + account, + previousStatus, + statusChanged, + shouldNotify, + }; +} + +/** + * Mark that the UI has notified for the account's current status. + * + * @param account - Account to update. + * @returns Account with `notifiedForStatus` set to current status. + */ +export function markAutorampNotified( + account: AutorampAccount, +): AutorampAccount { + return { + ...account, + notifiedForStatus: account.status, + }; +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 1c345c3a35..b52fbe4eb8 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -6,12 +6,15 @@ export type { RampsControllerState, RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, + RampsControllerAutorampStatusChangedEvent, RampsControllerOptions, PaymentMethodsForContextResponse, UserRegion, ResourceState, TransakState, NativeProvidersState, + MoneyAccountWalletRegistrationResult, + KeyringControllerSignPersonalMessageAction, } from './RampsController.js'; export type { RampsControllerExecuteRequestAction, @@ -31,6 +34,14 @@ export type { RampsControllerGetQuotesAction, RampsControllerAddOrderAction, RampsControllerRemoveOrderAction, + RampsControllerAddAutorampAction, + RampsControllerCreateAutorampAction, + RampsControllerRemoveAutorampAction, + RampsControllerRegisterMoneyAccountWalletAction, + RampsControllerMarkAutorampAsNotifiedAction, + RampsControllerApplyAutorampStatusFromPushAction, + RampsControllerRefreshAutorampAction, + RampsControllerRefreshAutorampsAction, RampsControllerStartOrderPollingAction, RampsControllerStopOrderPollingAction, RampsControllerGetBuyWidgetDataAction, @@ -69,6 +80,7 @@ export { getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, } from './RampsController.js'; export type { RampsServiceActions, @@ -231,6 +243,22 @@ export type { TransakServiceGeneratePaymentWidgetUrlAction, TransakServiceCreateWidgetUrlAction, } from './TransakService-method-action-types.js'; +export type { + AutorampAccount, + ApplyAutorampRemoteStatusResult, + CreateAutorampRequest, +} from './autorampAccount.js'; +export { + AutorampStatus, + TERMINAL_AUTORAMP_STATUSES, + NOTABLE_AUTORAMP_STATUSES, + isTerminalAutorampStatus, + normalizeAutorampStatus, + createAutorampAccount, + applyAutorampRemoteStatus, + markAutorampNotified, +} from './autorampAccount.js'; +export { buildOwnershipMessage } from './ownership-message.js'; export type { AutorampDepositRailsSummary, AutorampRemoteSnapshot, diff --git a/packages/ramps-controller/src/ownership-message.test.ts b/packages/ramps-controller/src/ownership-message.test.ts new file mode 100644 index 0000000000..071144a464 --- /dev/null +++ b/packages/ramps-controller/src/ownership-message.test.ts @@ -0,0 +1,66 @@ +import { buildOwnershipMessage } from './ownership-message.js'; + +describe('buildOwnershipMessage', () => { + it('builds the exact MoonPay ownership sentence', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf1234567890', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe( + 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.', + ); + }); + + it('formats the date in UTC across a local date boundary', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2027-01-01T00:30:00.000Z'), + }); + + expect(result).toContain('signed on 01/01/2027'); + }); + + it('preserves the exact supplied address casing', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toContain('wallet address 0xAbCdEf as customer'); + }); + + it('does not add surrounding whitespace or a trailing newline', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe(result.trim()); + expect(result.endsWith('\n')).toBe(false); + }); + + it('builds a fresh message after UTC midnight', () => { + const request = { + address: '0x1234', + customerId: 'customer-123', + }; + + const beforeMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-12T23:59:59.999Z'), + }); + const afterMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-13T00:00:00.000Z'), + }); + + expect(beforeMidnight).toContain('signed on 12/08/2026'); + expect(afterMidnight).toContain('signed on 13/08/2026'); + expect(afterMidnight).not.toBe(beforeMidnight); + }); +}); diff --git a/packages/ramps-controller/src/ownership-message.ts b/packages/ramps-controller/src/ownership-message.ts new file mode 100644 index 0000000000..539d5e3aac --- /dev/null +++ b/packages/ramps-controller/src/ownership-message.ts @@ -0,0 +1,32 @@ +export type BuildOwnershipMessageRequest = { + address: string; + customerId: string; + now: Date; +}; + +/** + * Builds the proof-of-ownership message required to register a self-hosted + * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`). + * + * The returned string is the exact sentence that must be both signed (EIP-191 + * `personal_sign`) and sent, byte-for-byte, in the registration request body. + * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced + * just before UTC midnight is not reused with a stale date after rollover. + * + * @param request - Values embedded in the ownership message. + * @param request.address - Wallet address, kept verbatim (no re-casing). + * @param request.customerId - Iron customer id; must match the request body. + * @param request.now - Reference time used to derive the UTC calendar date. + * @returns The exact message to sign and submit. + */ +export function buildOwnershipMessage({ + address, + customerId, + now, +}: BuildOwnershipMessageRequest): string { + const day = String(now.getUTCDate()).padStart(2, '0'); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const year = now.getUTCFullYear(); + + return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`; +} diff --git a/packages/ramps-controller/src/wallet-registration-machine.test.ts b/packages/ramps-controller/src/wallet-registration-machine.test.ts new file mode 100644 index 0000000000..de58a81ef1 --- /dev/null +++ b/packages/ramps-controller/src/wallet-registration-machine.test.ts @@ -0,0 +1,297 @@ +import { + createInitialState, + transition, +} from './wallet-registration-machine.js'; +import type { + WalletRegistrationEvent, + WalletRegistrationState, +} from './wallet-registration-machine.js'; + +const run = ( + events: WalletRegistrationEvent[], + initial: WalletRegistrationState = createInitialState(), +): WalletRegistrationState => + events.reduce((state, event) => transition(state, event), initial); + +describe('wallet registration machine: lookup', () => { + it('starts idle', () => { + expect(createInitialState().status).toBe('idle'); + }); + + it('start moves idle to preparing', () => { + expect(run([{ type: 'START' }]).status).toBe('preparing'); + }); + + it('an active existing registration skips signing and completes', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]); + expect(state.status).toBe('alreadyRegistered'); + }); + + it('a disabled existing registration enters registeredDisabled', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]); + expect(state.status).toBe('registeredDisabled'); + }); + + it('an absent registration proceeds to signing', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(state.status).toBe('signing'); + }); + + it('a failed lookup enters lookupUnavailable and never assumes absent', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]); + expect(state.status).toBe('lookupUnavailable'); + }); +}); + +describe('wallet registration machine: signing', () => { + const atSigning = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + + it('a locked keyring during signing waits then resumes the same attempt', () => { + const locked = transition(atSigning(), { type: 'WALLET_LOCKED' }); + expect(locked.status).toBe('awaitingUnlock'); + + const resumed = transition(locked, { type: 'WALLET_UNLOCKED' }); + expect(resumed.status).toBe('signing'); + }); + + it('successful signing moves to submitting', () => { + expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe( + 'submitting', + ); + }); + + it('explicit user rejection reaches cancelled', () => { + expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe( + 'cancelled', + ); + }); + + it('classifies signing failures as retryable or terminal', () => { + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status, + ).toBe('failedRetryable'); + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status, + ).toBe('failedTerminal'); + }); + + it('cancellation during signing aborts without failing', () => { + expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: submitting outcomes', () => { + const atSubmitting = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]); + + it('200 reaches registered', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe( + 'registered', + ); + }); + + it('any 409 enters disambiguate409', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_CONFLICT', + }).status, + ).toBe('disambiguate409'); + }); + + it('timeout / 5xx enters checkThenRetry', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status, + ).toBe('checkThenRetry'); + }); + + it('a UTC-rollover 400 rebuilds and re-signs once', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: true, + }).status, + ).toBe('signing'); + }); + + it('a non-rollover 400 is terminal', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: false, + }).status, + ).toBe('failedTerminal'); + }); + + it('401 / 403 / 404 are terminal', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe( + 'failedTerminal', + ); + }); + + it('429 becomes retryable', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status, + ).toBe('failedRetryable'); + }); + + it('cancellation during submitting aborts without failing', () => { + expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: 409 disambiguation', () => { + const atDisambiguate = (): WalletRegistrationState => + run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_CONFLICT' }, + ]); + + it('an active list match after 409 completes as alreadyRegistered', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled list match after 409 enters registeredDisabled', () => { + expect( + transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status, + ).toBe('registeredDisabled'); + }); + + it('a 409 plus GET miss is retryable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'failedRetryable', + ); + }); + + it('a failed GET during disambiguation is lookupUnavailable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('cancellation during disambiguation does not become a failure', () => { + expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => { + const atCheck = ( + initial?: WalletRegistrationState, + ): WalletRegistrationState => + run( + [ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_TRANSIENT' }, + ], + initial, + ); + + it('a GET showing the resource completes without another POST', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled GET result enters registeredDisabled', () => { + expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe( + 'registeredDisabled', + ); + }); + + it('an absent GET result retries signing within the attempt ceiling', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'signing', + ); + }); + + it('a failed GET during reconciliation is lookupUnavailable', () => { + expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('stops retrying once the attempt ceiling is reached', () => { + let state = createInitialState(); + state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state); + // Loop sign -> transient -> absent until the ceiling flips to retryable. + for (let i = 0; i < 5; i++) { + if (state.status === 'signing') { + state = transition(state, { type: 'SIGN_OK' }); + state = transition(state, { type: 'SUBMIT_TRANSIENT' }); + state = transition(state, { type: 'LOOKUP_ABSENT' }); + } + } + expect(state.status).toBe('failedRetryable'); + }); + + it('cancellation during checkThenRetry does not become a failure', () => { + expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled'); + }); +}); + +describe('wallet registration machine: retry, resume, and concurrency', () => { + it('retry from failedRetryable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_RATE_LIMITED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from lookupUnavailable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_FAILED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from cancelled restarts via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'CANCEL' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('a second START while in-flight is ignored (one operation)', () => { + const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(inFlight.status).toBe('signing'); + expect(transition(inFlight, { type: 'START' }).status).toBe('signing'); + }); + + it('ignores events that do not apply to the current state', () => { + const preparing = run([{ type: 'START' }]); + expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe( + 'preparing', + ); + }); + + it('terminal success states ignore further events', () => { + const registered = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_OK' }, + ]); + expect(transition(registered, { type: 'RETRY' }).status).toBe('registered'); + }); +}); diff --git a/packages/ramps-controller/src/wallet-registration-machine.ts b/packages/ramps-controller/src/wallet-registration-machine.ts new file mode 100644 index 0000000000..3251c56d02 --- /dev/null +++ b/packages/ramps-controller/src/wallet-registration-machine.ts @@ -0,0 +1,221 @@ +/** + * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted + * wallet registration signing step. It follows the FSM convention used + * elsewhere in `core` (no XState dependency): a single pure `transition` + * reducer plus a data-driven transition table. + * + * Side effects (server lookups, signing, POSTing) live in the interpreter that + * drives this machine; every effect result is fed back in as an event, so the + * machine itself stays deterministic and trivially testable. + */ + +/** Every state in the signing step. */ +export type WalletRegistrationStatus = + | 'idle' + | 'preparing' + | 'awaitingUnlock' + | 'signing' + | 'submitting' + | 'disambiguate409' + | 'checkThenRetry' + | 'lookupUnavailable' + | 'registered' + | 'alreadyRegistered' + | 'registeredDisabled' + | 'failedRetryable' + | 'failedTerminal' + | 'cancelled'; + +/** Machine context carried across transitions. */ +export type WalletRegistrationContext = { + /** Number of sign attempts made so far (used for the retry ceiling). */ + attempts: number; + /** Maximum number of sign attempts before a retryable failure is surfaced. */ + maxAttempts: number; +}; + +export type WalletRegistrationState = { + status: WalletRegistrationStatus; + context: WalletRegistrationContext; +}; + +/** Events the interpreter dispatches into the machine. */ +export type WalletRegistrationEvent = + | { type: 'START' } + | { type: 'WALLET_LOCKED' } + | { type: 'WALLET_UNLOCKED' } + | { type: 'LOOKUP_ACTIVE' } + | { type: 'LOOKUP_DISABLED' } + | { type: 'LOOKUP_ABSENT' } + | { type: 'LOOKUP_FAILED' } + | { type: 'SIGN_OK' } + | { type: 'SIGN_REJECTED' } + | { type: 'SIGN_FAILED'; retryable: boolean } + | { type: 'SUBMIT_OK' } + | { type: 'SUBMIT_CONFLICT' } + | { type: 'SUBMIT_TRANSIENT' } + | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean } + | { type: 'SUBMIT_TERMINAL' } + | { type: 'SUBMIT_RATE_LIMITED' } + | { type: 'RETRY' } + | { type: 'CANCEL' }; + +type EventType = WalletRegistrationEvent['type']; + +type Handler = ( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +) => WalletRegistrationState; + +const DEFAULT_MAX_ATTEMPTS = 3; + +/** + * Creates the initial idle state. + * + * @param maxAttempts - Optional retry ceiling for sign attempts. + * @returns A fresh idle machine state. + */ +export function createInitialState( + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, +): WalletRegistrationState { + return { status: 'idle', context: { attempts: 0, maxAttempts } }; +} + +/** + * Builds a handler that moves to a status while preserving context. + * + * @param status - Target status. + * @returns A handler transitioning to `status`. + */ +function keep(status: WalletRegistrationStatus): Handler { + return (state) => ({ status, context: state.context }); +} + +/** + * Builds a handler that moves to a status and resets the retry context. Used + * when the user (or app resume) starts a fresh attempt from scratch. + * + * @param status - Target status. + * @returns A handler transitioning to `status` with reset context. + */ +function reset(status: WalletRegistrationStatus): Handler { + return (state) => ({ + status, + context: { ...state.context, attempts: 0 }, + }); +} + +/** + * Moves to `signing` and counts this as a new sign attempt. + * + * @param state - Current state. + * @returns The `signing` state with an incremented attempt count. + */ +const toSigning: Handler = (state) => ({ + status: 'signing', + context: { ...state.context, attempts: state.context.attempts + 1 }, +}); + +const toPreparing = reset('preparing'); +const toAlreadyRegistered = keep('alreadyRegistered'); +const toRegisteredDisabled = keep('registeredDisabled'); +const toLookupUnavailable = keep('lookupUnavailable'); +const toCancelled = keep('cancelled'); + +const signFailed: Handler = (state, event) => { + const { retryable } = event as Extract< + WalletRegistrationEvent, + { type: 'SIGN_FAILED' } + >; + return retryable + ? keep('failedRetryable')(state, event) + : keep('failedTerminal')(state, event); +}; + +const submitValidation: Handler = (state, event) => { + const { utcRollover } = event as Extract< + WalletRegistrationEvent, + { type: 'SUBMIT_VALIDATION' } + >; + return utcRollover && state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedTerminal')(state, event); +}; + +const checkThenRetryAbsent: Handler = (state, event) => + state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedRetryable')(state, event); + +const TABLE: Partial< + Record>> +> = { + idle: { + START: toPreparing, + }, + preparing: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: toSigning, + LOOKUP_FAILED: toLookupUnavailable, + }, + awaitingUnlock: { + WALLET_UNLOCKED: keep('signing'), + }, + signing: { + SIGN_OK: keep('submitting'), + SIGN_REJECTED: toCancelled, + SIGN_FAILED: signFailed, + WALLET_LOCKED: keep('awaitingUnlock'), + CANCEL: toCancelled, + }, + submitting: { + SUBMIT_OK: keep('registered'), + SUBMIT_CONFLICT: keep('disambiguate409'), + SUBMIT_TRANSIENT: keep('checkThenRetry'), + SUBMIT_VALIDATION: submitValidation, + SUBMIT_TERMINAL: keep('failedTerminal'), + SUBMIT_RATE_LIMITED: keep('failedRetryable'), + CANCEL: toCancelled, + }, + disambiguate409: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: keep('failedRetryable'), + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + checkThenRetry: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: checkThenRetryAbsent, + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + failedRetryable: { + RETRY: toPreparing, + }, + lookupUnavailable: { + RETRY: toPreparing, + }, + cancelled: { + RETRY: toPreparing, + }, +}; + +/** + * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is + * how the machine enforces "one in-flight operation" (a second `START` while + * busy is ignored) and how terminal states stay put. + * + * @param state - Current machine state. + * @param event - Event to apply. + * @returns The next state (or the same state for unhandled events). + */ +export function transition( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +): WalletRegistrationState { + const handler = TABLE[state.status]?.[event.type]; + return handler ? handler(state, event) : state; +} diff --git a/packages/ramps-controller/src/wallet-registration-service.ts b/packages/ramps-controller/src/wallet-registration-service.ts index 338c824d8d..d8eb91ffa1 100644 --- a/packages/ramps-controller/src/wallet-registration-service.ts +++ b/packages/ramps-controller/src/wallet-registration-service.ts @@ -456,14 +456,9 @@ export class WalletRegistrationService { message: 'registered address missing id', }); } - if (typeof record.wallet_address !== 'string') { - throw new WalletRegistrationError('malformedResponse', { - message: 'registered address missing wallet_address', - }); - } return { id: record.id, - address: record.wallet_address, + address: record.wallet_address as string, blockchain: 'Monad', disabled: Boolean(record.disabled), isSelf: Boolean(record.is_self), From bdfd201fd2ef3aeccd6b68d334568c8b3c1f6399 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 31 Aug 2026 10:05:23 -0600 Subject: [PATCH 2/5] chore(ramps-controller): link stacked PRs in changelog Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index ee4207c2d8..05e5480467 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. +- 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`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) ## [20.2.0] From 039385ee7f882d3a866395247375909838a689d9 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 31 Aug 2026 11:31:45 -0600 Subject: [PATCH 3/5] fix(ramps-controller): complete required controller actions list Restore the wallet address guard that this branch dropped, list every external controller action hosts must delegate, and assert that list against the controller source so future messenger calls cannot silently drift. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 +- .../src/RampsController.test.ts | 25 ++++++++++++++++++- .../ramps-controller/src/RampsController.ts | 14 +++++++---- .../src/wallet-registration-service.ts | 7 +++++- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 05e5480467..d61b6aa2e9 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) +- 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)) ## [20.2.0] diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index f7eeb5511e..6423218e35 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -101,6 +101,30 @@ describe('RampsController', () => { }); }); + describe('RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS', () => { + it('includes every external controller action that RampsController calls', async () => { + expect.hasAssertions(); + const controllerPath = path.join(__dirname, 'RampsController.ts'); + const source = await fs.promises.readFile(controllerPath, 'utf-8'); + const callPattern = + /messenger\.call\s*\(\s*['"]([A-Za-z]+Controller:[^'"]+)['"]/gu; + const calledActions = new Set(); + let match: RegExpExecArray | null; + while ((match = callPattern.exec(source)) !== null) { + if (!match[1].startsWith('RampsController:')) { + calledActions.add(match[1]); + } + } + const requiredSet = new Set( + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS as readonly string[], + ); + const missing = [...calledActions].filter((a) => !requiredSet.has(a)); + const extra = [...requiredSet].filter((a) => !calledActions.has(a)); + expect(missing).toHaveLength(0); + expect(extra).toHaveLength(0); + }); + }); + describe('constructor', () => { it('uses default state when no state is provided', async () => { await withController(({ controller }) => { @@ -12980,7 +13004,6 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, - 'RemoteFeatureFlagController:getState', ], }); return messenger; diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 01170f46dd..b437334472 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -217,15 +217,17 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ )[]; /** - * Other controller actions RampsController calls via the messenger. - * Hosts that enable autoramp creation must delegate these from the root - * messenger so the controller can resolve the vendor customer identity from - * Profile Sync. `KeyringController:signPersonalMessage` is required for Money - * Account self-hosted wallet registration (EIP-191 ownership proof). + * Every external controller action RampsController calls via the messenger, + * which hosts must delegate from the root messenger. + * `AuthenticationController:getSessionProfile` resolves the vendor customer + * identity from Profile Sync, and `KeyringController:signPersonalMessage` signs + * the EIP-191 ownership proof for Money Account self-hosted wallet + * registration; both are only exercised by the autoramp paths. */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'KeyringController:signPersonalMessage', + 'RemoteFeatureFlagController:getState', ] as const; /** @@ -811,6 +813,8 @@ export type RampsControllerOrderStatusChangedEvent = { /** * Published when an autoramp's last-seen status changes after refresh or push. + * Every transition is published so analytics can observe the full lifecycle; + * `shouldNotify` distinguishes the subset the UI should surface to the user. */ export type RampsControllerAutorampStatusChangedEvent = { type: `${typeof controllerName}:autorampStatusChanged`; diff --git a/packages/ramps-controller/src/wallet-registration-service.ts b/packages/ramps-controller/src/wallet-registration-service.ts index d8eb91ffa1..338c824d8d 100644 --- a/packages/ramps-controller/src/wallet-registration-service.ts +++ b/packages/ramps-controller/src/wallet-registration-service.ts @@ -456,9 +456,14 @@ export class WalletRegistrationService { message: 'registered address missing id', }); } + if (typeof record.wallet_address !== 'string') { + throw new WalletRegistrationError('malformedResponse', { + message: 'registered address missing wallet_address', + }); + } return { id: record.id, - address: record.wallet_address as string, + address: record.wallet_address, blockchain: 'Monad', disabled: Boolean(record.disabled), isSelf: Boolean(record.is_self), From fb9925dc227b262c9ff92e43396878eae23e8a4d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 31 Aug 2026 12:38:19 -0600 Subject: [PATCH 4/5] fix(ramps-controller): keep removed autoramps from being restored Refresh no longer inserts a last-seen cursor that disappeared while MoonPay was being fetched, and oxfmt import order is aligned so lint:misc:check passes. Co-authored-by: Cursor --- .../src/RampsController.test.ts | 42 ++++++++++++++++++- .../ramps-controller/src/RampsController.ts | 29 +++++++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 6423218e35..0bd71a83b3 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -13,7 +13,6 @@ import * as path from 'path'; import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; -import { WalletRegistrationError } from './wallet-registration-service.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -66,6 +65,7 @@ import type { TransakOrderPaymentMethod, PatchUserRequestBody, } from './TransakService.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; /** * The default redirect ("fake callback") URL a staging `RampsService` returns. @@ -9701,6 +9701,46 @@ describe('RampsController', () => { }); }); + it('does not restore an autoramp removed while refresh is in flight', async () => { + await withController(async ({ controller, rootMessenger }) => { + let releaseFetch: ((value: unknown) => void) | undefined; + let markFetchStarted: (() => void) | undefined; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); + const remoteSnapshot = new Promise((resolve) => { + releaseFetch = resolve; + }); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + async () => { + markFetchStarted?.(); + return await remoteSnapshot; + }, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const refreshPromise = controller.refreshAutoramps(); + await fetchStarted; + controller.removeAutoramp('ar-1'); + releaseFetch?.({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + + expect(await refreshPromise).toStrictEqual([]); + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + it('marks autoramp as notified', async () => { await withController(({ controller }) => { controller.addAutoramp({ diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index b437334472..c0bdc2edb3 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3260,17 +3260,19 @@ export class RampsController extends BaseController< /** * Fetches one autoramp from the neo-bank proxy and applies it to the - * last-seen cursor. + * last-seen cursor. Does not recreate a cursor that was removed while the + * request was in flight. * * @param autorampId - MoonPay autoramp id. - * @returns The updated local account. + * @returns The updated local account, or an unpersisted snapshot if the + * cursor was removed during the fetch. */ async refreshAutoramp(autorampId: string): Promise { const remote = await this.messenger.call( 'NeoBankService:getAutoramp', autorampId, ); - return this.#applyAutorampRemoteSnapshot(remote); + return this.#applyAutorampRemoteSnapshot(remote, { allowInsert: false }); } /** @@ -3285,7 +3287,12 @@ export class RampsController extends BaseController< for (const id of ids) { try { - updated.push(await this.refreshAutoramp(id)); + const account = await this.refreshAutoramp(id); + if ( + this.state.autoramps.some((autoramp) => autoramp.id === account.id) + ) { + updated.push(account); + } } catch { // Keep local cursor for this id; continue remaining refreshes. } @@ -3294,12 +3301,26 @@ export class RampsController extends BaseController< return updated; } + /** + * Applies a remote snapshot to the last-seen cursor. + * + * @param remote - MoonPay snapshot. + * @param options - Apply options. + * @param options.allowInsert - When false, a missing local cursor is not + * recreated (used by refresh so a concurrent `removeAutoramp` wins). + * @returns The applied account. When `allowInsert` is false and no local + * cursor exists, the snapshot is returned without writing state. + */ #applyAutorampRemoteSnapshot( remote: AutorampRemoteSnapshot, + { allowInsert = true }: { allowInsert?: boolean } = {}, ): AutorampAccount { const local = this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? null; + if (local === null && !allowInsert) { + return applyAutorampRemoteStatus(null, remote).account; + } const result = applyAutorampRemoteStatus(local, remote); this.update((state) => { From 5cd2e004a4fe58a105110247edb173f065697704 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 31 Aug 2026 14:44:35 -0600 Subject: [PATCH 5/5] chore(ramps-controller): regenerate refreshAutoramp action type docs Sync the generated messenger action types with the refresh JSDoc so messenger-action-types:check passes. Co-authored-by: Cursor --- .../src/RampsController-method-action-types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 890fd74652..8661f19c70 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -399,10 +399,12 @@ export type RampsControllerApplyAutorampStatusFromPushAction = { /** * Fetches one autoramp from the neo-bank proxy and applies it to the - * last-seen cursor. + * last-seen cursor. Does not recreate a cursor that was removed while the + * request was in flight. * * @param autorampId - MoonPay autoramp id. - * @returns The updated local account. + * @returns The updated local account, or an unpersisted snapshot if the + * cursor was removed during the fetch. */ export type RampsControllerRefreshAutorampAction = { type: `RampsController:refreshAutoramp`;