From 32604da9c86295a160be21be5c20d72958ad8dcf Mon Sep 17 00:00:00 2001 From: Arnold Rozon Date: Fri, 4 Sep 2026 11:14:01 -0400 Subject: [PATCH 1/6] feat(ramps-controller): add hydrateNeobankStore onboarding stage lookup Give Mobile a single idempotent Core action that refreshes KYC/wallet/autoramp signals and returns the Money Account onboarding stage without navigating or auto-submitting. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 2 + packages/ramps-controller/package.json | 1 + .../RampsController-method-action-types.ts | 20 + .../src/RampsController.test.ts | 410 +++++++++++++++++ .../ramps-controller/src/RampsController.ts | 191 +++++++- packages/ramps-controller/src/index.ts | 13 + .../src/neobank-onboarding.test.ts | 419 ++++++++++++++++++ .../src/neobank-onboarding.ts | 265 +++++++++++ packages/ramps-controller/tsconfig.build.json | 3 + packages/ramps-controller/tsconfig.json | 9 +- yarn.lock | 3 +- 11 files changed, 1331 insertions(+), 5 deletions(-) create mode 100644 packages/ramps-controller/src/neobank-onboarding.test.ts create mode 100644 packages/ramps-controller/src/neobank-onboarding.ts diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c916801be88..baf68ba0af8 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -54,6 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#xxxx](https://github.com/MetaMask/core/pull/xxxx)) + - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 2a26409ad03..4e25e228130 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -53,6 +53,7 @@ "dependencies": { "@metamask/base-controller": "^10.0.0", "@metamask/controller-utils": "^13.0.0", + "@metamask/kyc-controller": "workspace:^", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.1.0", "@metamask/remote-feature-flag-controller": "^7.0.0", diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 59443821394..7600cb5588c 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -402,6 +402,25 @@ export type RampsControllerMarkAutorampAsNotifiedAction = { handler: RampsController['markAutorampAsNotified']; }; +/** + * Refreshes authoritative Money Account / NeoBank onboarding signals and + * writes a single Mobile-routable stage to `state.neobank`. + * + * Safe to call twice. Does **not** navigate and does **not** auto-submit + * wallet registration or autoramp creation (see TRAM-3924). Clients must not + * persist UKYC `sessionId` or re-register; this method is lookup-then-derive + * only. + * + * @param params - Hydration parameters. + * @param params.walletAddress - Money Account wallet address used for + * registration / autoramp checks after KYC is complete. + * @returns The derived {@link NeobankOnboardingStage}. + */ +export type RampsControllerHydrateNeobankStoreAction = { + type: `RampsController:hydrateNeobankStore`; + handler: RampsController['hydrateNeobankStore']; +}; + /** * Applies a remote autoramp snapshot from a websocket / webhook push. * @@ -854,6 +873,7 @@ export type RampsControllerMethodActions = | RampsControllerRegisterMoneyAccountWalletAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerHydrateNeobankStoreAction | RampsControllerApplyAutorampStatusFromPushAction | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 47357384730..a79b09d6f3f 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { NeobankOnboardingStage } from './neobank-onboarding.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -171,6 +172,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -248,6 +254,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2281,6 +2292,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2325,6 +2341,11 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2362,6 +2383,11 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` { "autoramps": [], + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2410,6 +2436,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -9840,6 +9871,385 @@ describe('RampsController', () => { }); }); + describe('hydrateNeobankStore', () => { + const defaultKycState = { + phase: 'idle' as const, + statusMessage: '', + error: null, + email: null, + vendorDisclaimersAccepted: { moonpay: null, iron: null }, + providerDisclaimersAccepted: { sumsub: null }, + idosDisclaimersAccepted: null, + credentialReusabilityConsentGiven: null, + vendorDisclaimers: [], + vendorError: null, + sessionDisclaimers: null, + geoCountry: null, + moonpaySessionToken: null, + moonpayAccessToken: null, + moonpayCustomerId: null, + activeVendor: 'iron' as const, + activeProduct: 'money' as const, + kycRequiredByProduct: {}, + lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, + sumsub: { + status: 'idle' as const, + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }, + }; + + type HydrateHandlers = { + refreshKycStatus: jest.Mock; + getState: jest.Mock; + getCustomerIdentity: jest.Mock; + getSessionProfile: jest.Mock; + getCustomerByExternalId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + getAutoramp: jest.Mock; + }; + + /** + * Registers KYC + NeoBank handlers used by hydrateNeobankStore. + * + * @param rootMessenger - Root messenger for the controller under test. + * @param kycState - KYC controller state returned by getState. + * @returns Handler mocks for per-test overrides. + */ + function registerHydrateHandlers( + rootMessenger: RootMessenger, + kycState: typeof defaultKycState = defaultKycState, + ): HydrateHandlers { + const handlers: HydrateHandlers = { + refreshKycStatus: jest.fn().mockResolvedValue({ + status: kycState.userStatus ?? 'not-started', + sumsubSessionId: null, + errorCode: null, + }), + getState: jest.fn().mockReturnValue(kycState), + getCustomerIdentity: jest.fn().mockReturnValue(null), + getSessionProfile: jest.fn().mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + canonicalProfileId: 'canonical-1', + }), + getCustomerByExternalId: jest + .fn() + .mockResolvedValue({ id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + getAutoramp: jest.fn(), + }; + rootMessenger.registerActionHandler( + 'KycController:refreshKycStatus', + handlers.refreshKycStatus, + ); + rootMessenger.registerActionHandler( + 'KycController:getState', + handlers.getState, + ); + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + handlers.getCustomerIdentity, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + handlers.getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + handlers.getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + handlers.getAutoramp, + ); + return handlers; + } + + it('returns NoUser and persists neobank state when KYC has no identity', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerHydrateHandlers(rootMessenger); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.NoUser); + expect(controller.state.neobank.stage).toBe( + NeobankOnboardingStage.NoUser, + ); + expect(controller.state.neobank.lastHydratedAt).toEqual( + expect.any(String), + ); + expect(controller.state.neobank.lastError).toBeNull(); + }); + }); + + it('returns LookupFailed when KYC refresh fails with no local signal', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger); + handlers.refreshKycStatus.mockRejectedValue(new Error('network down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch(/network down/u); + }); + }); + + it('returns WalletNotSigned after completed KYC when registration is absent', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(stage).toBe(NeobankOnboardingStage.WalletNotSigned); + }); + }); + + it('returns AutorampCreated when wallet is registered and autoramp is Approved', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + handlers.getAutoramp.mockResolvedValue({ + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.AutorampCreated); + expect(controller.state.neobank.stage).toBe( + NeobankOnboardingStage.AutorampCreated, + ); + }, + ); + }); + + it('is idempotent when called twice with the same signals', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerHydrateHandlers(rootMessenger); + + const first = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + const second = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(first).toBe(NeobankOnboardingStage.NoUser); + expect(second).toBe(NeobankOnboardingStage.NoUser); + }); + }); + + it('returns LookupFailed when customer resolution fails after KYC complete', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getSessionProfile.mockResolvedValue({ + identifierId: 'id-1', + profileId: '', + metaMetricsId: 'mm-1', + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch( + /Cannot resolve MoonPay customer id/u, + ); + }); + }); + + it('still derives autoramp stage when refreshAutoramps fails', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + handlers.getAutoramp.mockRejectedValue(new Error('autoramp down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.AutorampPending); + }, + ); + }); + }); + describe('registerMoneyAccountWallet', () => { const registration = { id: 'wallet-1', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 931de06a048..28ef757a9f9 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,6 +6,12 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; +import type { + KycControllerGetCustomerIdentityAction, + KycControllerGetStateAction, + KycControllerRefreshKycStatusAction, + KycControllerState, +} from '@metamask/kyc-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController, @@ -38,6 +44,14 @@ import type { NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; +import type { NeobankState } from './neobank-onboarding.js'; +import { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; +import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; import { areOrdersEqual, deleteOrderInUserStorage, @@ -235,17 +249,50 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ * the EIP-191 ownership proof for Money Account self-hosted wallet * registration; both are only exercised by the autoramp paths. User Storage * and authentication actions support cross-client order syncing. + * `KycController:*` actions power {@link RampsController.hydrateNeobankStore}. */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', + 'KycController:getCustomerIdentity', + 'KycController:getState', + 'KycController:refreshKycStatus', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', 'UserStorageController:performGetStorageAllFeatureEntries', 'UserStorageController:performBatchSetStorage', ] as const; +/** + * Whether persisted vendor T&C1 acceptance exists for the active vendor. + * + * @param state - KYC controller state. + * @returns Whether vendor terms are accepted. + */ +function hasKycVendorTerms(state: KycControllerState): boolean { + if (state.activeVendor === 'moonpay') { + return Boolean(state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt); + } + if (state.activeVendor === 'iron') { + return Boolean(state.vendorDisclaimersAccepted.iron?.disclaimerIds.length); + } + return false; +} + +/** + * Whether SumSub + idOS T&C2 were accepted as a batch. + * + * @param state - KYC controller state. + * @returns Whether provider terms are accepted. + */ +function hasKycProviderTerms(state: KycControllerState): boolean { + return ( + state.providerDisclaimersAccepted.sumsub !== null && + state.idosDisclaimersAccepted !== null + ); +} + /** * Structural type for the keyring controller's `signPersonalMessage` messenger * action (EIP-191). Declared locally to avoid a package dependency for a single @@ -543,6 +590,13 @@ export type RampsControllerState = { * notifications after refresh or push. */ autoramps: AutorampAccount[]; + /** + * Money Account / NeoBank onboarding stage derived by + * {@link RampsController.hydrateNeobankStore}. Mobile reads `stage` to route + * after cold start or missed KYC events. Persist so a failed hydrate can still + * surface the last known stage. + */ + neobank: NeobankState; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -610,6 +664,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + neobank: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -677,6 +737,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, orders: [], autoramps: [], + neobank: getDefaultNeobankState(), providerAutoSelected: false, }; } @@ -812,7 +873,10 @@ type AllowedActions = | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction - | AuthenticationController.AuthenticationControllerIsSignedInAction; + | AuthenticationController.AuthenticationControllerIsSignedInAction + | KycControllerGetCustomerIdentityAction + | KycControllerGetStateAction + | KycControllerRefreshKycStatusAction; /** * Published when the state of {@link RampsController} changes. @@ -1023,6 +1087,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'createAutoramp', 'removeAutoramp', 'registerMoneyAccountWallet', + 'hydrateNeobankStore', 'markAutorampAsNotified', 'applyAutorampStatusFromPush', 'refreshAutoramp', @@ -3574,6 +3639,130 @@ export class RampsController extends BaseController< }); } + /** + * Refreshes authoritative Money Account / NeoBank onboarding signals and + * writes a single Mobile-routable stage to `state.neobank`. + * + * Safe to call twice. Does **not** navigate and does **not** auto-submit + * wallet registration or autoramp creation (see TRAM-3924). Clients must not + * persist UKYC `sessionId` or re-register; this method is lookup-then-derive + * only. + * + * @param params - Hydration parameters. + * @param params.walletAddress - Money Account wallet address used for + * registration / autoramp checks after KYC is complete. + * @returns The derived {@link NeobankOnboardingStage}. + */ + async hydrateNeobankStore({ + walletAddress, + }: { + walletAddress: string; + }): Promise { + let refreshError: string | null = null; + try { + await this.messenger.call('KycController:refreshKycStatus'); + } catch (error) { + refreshError = String(error); + } + + const kycState = this.messenger.call('KycController:getState'); + const identity = this.messenger.call('KycController:getCustomerIdentity'); + const hasVendorTerms = hasKycVendorTerms(kycState); + const hasProviderTerms = hasKycProviderTerms(kycState); + const hasCustomerIdentity = Boolean( + identity?.id || kycState.email || kycState.userStatus !== null, + ); + + const kycInput: NeobankOnboardingDerivationInput['kyc'] = { + phase: kycState.phase, + userStatus: kycState.userStatus, + sumsubStatus: kycState.sumsub.status, + hasCustomerIdentity, + hasVendorTerms, + hasProviderTerms, + }; + + // A refresh failure with no local KYC signal is not "no user" — Mobile + // should retry rather than restart onboarding. + if ( + refreshError && + !hasVendorTerms && + !hasProviderTerms && + kycState.userStatus === null && + !hasCustomerIdentity + ) { + return this.#commitNeobankStage( + NeobankOnboardingStage.LookupFailed, + refreshError, + ); + } + + let wallet: NeobankOnboardingDerivationInput['wallet'] = { + status: 'skipped', + }; + let autoramp: NeobankOnboardingDerivationInput['autoramp'] = { + status: 'skipped', + }; + + if (kycState.userStatus === 'completed') { + try { + const customerId = await this.resolveAutorampCustomerId(); + const registration = await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address: walletAddress }, + ); + wallet = { status: 'resolved', registration }; + try { + await this.refreshAutoramps(); + } catch { + // Keep the local autoramp cursor; summarize whatever we have. + } + autoramp = summarizeAutorampsForWallet( + this.state.autoramps, + walletAddress, + ); + } catch (error) { + wallet = { + status: 'lookupUnavailable', + message: String(error), + }; + refreshError = refreshError ?? String(error); + } + } + + const stage = deriveNeobankOnboardingStage({ + kyc: kycInput, + wallet, + autoramp, + }); + return this.#commitNeobankStage( + stage, + stage === NeobankOnboardingStage.LookupFailed ? refreshError : null, + ); + } + + /** + * Writes the NeoBank onboarding stage into controller state. + * + * @param stage - Derived stage. + * @param lastError - Optional error message for lookup failures. + * @returns The same stage. + */ + #commitNeobankStage( + stage: NeobankOnboardingStage, + lastError: string | null, + ): NeobankOnboardingStage { + this.update((state) => { + state.neobank = { + stage, + lastHydratedAt: new Date().toISOString(), + lastError: + stage === NeobankOnboardingStage.LookupFailed ? lastError : null, + }; + }); + return stage; + } + /** * Applies a remote autoramp snapshot from a websocket / webhook push. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 6aeecbcb6cd..8b2d56e7045 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -38,6 +38,7 @@ export type { RampsControllerCreateAutorampAction, RampsControllerRemoveAutorampAction, RampsControllerRegisterMoneyAccountWalletAction, + RampsControllerHydrateNeobankStoreAction, RampsControllerMarkAutorampAsNotifiedAction, RampsControllerApplyAutorampStatusFromPushAction, RampsControllerRefreshAutorampAction, @@ -83,6 +84,18 @@ export { RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, } from './RampsController.js'; +export type { + NeobankOnboardingDerivationInput, + NeobankState, +} from './neobank-onboarding.js'; +export { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + isAutorampCreatedStatus, + isAutorampPendingStatus, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; export type { RampsServiceActions, RampsServiceEvents, diff --git a/packages/ramps-controller/src/neobank-onboarding.test.ts b/packages/ramps-controller/src/neobank-onboarding.test.ts new file mode 100644 index 00000000000..36e76624f3a --- /dev/null +++ b/packages/ramps-controller/src/neobank-onboarding.test.ts @@ -0,0 +1,419 @@ +import { AutorampStatus } from './autorampAccount.js'; +import type { AutorampAccount } from './autorampAccount.js'; +import { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; +import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; + +/** + * Builds a derivation input with KYC complete and wallet/autoramp skipped by + * default so each test can override the branch under assertion. + * + * @param overrides - Partial input overrides. + * @returns A complete derivation input. + */ +function buildInput( + overrides: { + kyc?: Partial; + wallet?: NeobankOnboardingDerivationInput['wallet']; + autoramp?: NeobankOnboardingDerivationInput['autoramp']; + } = {}, +): NeobankOnboardingDerivationInput { + return { + kyc: { + phase: 'done', + userStatus: 'completed', + sumsubStatus: 'complete', + hasCustomerIdentity: true, + hasVendorTerms: true, + hasProviderTerms: true, + ...overrides.kyc, + }, + wallet: overrides.wallet ?? { status: 'skipped' }, + autoramp: overrides.autoramp ?? { status: 'skipped' }, + }; +} + +describe('getDefaultNeobankState', () => { + it('returns an empty neobank slice', () => { + expect(getDefaultNeobankState()).toStrictEqual({ + stage: null, + lastHydratedAt: null, + lastError: null, + }); + }); +}); + +describe('summarizeAutorampsForWallet', () => { + const base: AutorampAccount = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xAbC', + status: AutorampStatus.Created, + lastSeenStatus: AutorampStatus.Created, + updatedAt: 1, + }; + + it('returns none when no autoramp matches the wallet', () => { + expect(summarizeAutorampsForWallet([base], '0xdef')).toStrictEqual({ + status: 'none', + }); + }); + + it('returns created when any matching autoramp is Approved', () => { + expect( + summarizeAutorampsForWallet( + [ + { ...base, status: AutorampStatus.Authorized }, + { + ...base, + id: 'ar-2', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + }, + ], + '0xabc', + ), + ).toStrictEqual({ status: 'created' }); + }); + + it('returns pending when a matching autoramp is still in progress', () => { + expect( + summarizeAutorampsForWallet( + [{ ...base, status: AutorampStatus.DepositAccountAdded }], + '0xabc', + ), + ).toStrictEqual({ status: 'pending' }); + }); + + it('returns none when matching autoramps are only rejected or cancelled', () => { + expect( + summarizeAutorampsForWallet( + [ + { + ...base, + status: AutorampStatus.Rejected, + lastSeenStatus: AutorampStatus.Rejected, + }, + ], + '0xabc', + ), + ).toStrictEqual({ status: 'none' }); + }); +}); + +describe('deriveNeobankOnboardingStage', () => { + it('returns EmailOtpRequired while the MoonPay auth phase is active', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'auth', + userStatus: null, + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: false, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.EmailOtpRequired); + }); + + it('returns NoUser when there is no identity, no terms, and no status', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'idle', + userStatus: null, + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: false, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.NoUser); + }); + + it('returns VendorTermsRequired when vendor T&C1 is missing', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'terms', + userStatus: 'not-started', + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: true, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.VendorTermsRequired); + }); + + it('returns ProviderTermsRequired when T&C2 batch is incomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'terms', + userStatus: 'not-started', + hasVendorTerms: true, + hasProviderTerms: false, + hasCustomerIdentity: true, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.ProviderTermsRequired); + }); + + it('maps userStatus need-more-information to KycNeedsReview', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'need-more-information', sumsubStatus: 'failed' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycNeedsReview); + }); + + it('maps userStatus terminal-failure to KycRejected', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'terminal-failure', sumsubStatus: 'failed' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('maps userStatus pending to KycPending', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'pending', sumsubStatus: 'polling' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycPending); + }); + + it('maps in-progress SumSub to KycStartedIncomplete before completion', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'inProgress', + phase: 'submit', + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps idle not-started KYC to KycNotStarted', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'done', + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycNotStarted); + }); + + it('returns WalletNotSigned when KYC is complete and wallet is absent', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'resolved', registration: { type: 'absent' } }, + autoramp: { status: 'none' }, + }), + ), + ).toBe(NeobankOnboardingStage.WalletNotSigned); + }); + + it('returns LookupFailed when wallet lookup is unavailable', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'lookupUnavailable', message: 'down' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('returns AutorampNotCreated when the wallet is registered but no route exists', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'none' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampNotCreated); + }); + + it('returns AutorampPending when a standing route is not Approved', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'pending' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampPending); + }); + + it('returns AutorampCreated when a standing route is Approved', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'disabled', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: true, + isSelf: true, + }, + }, + }, + autoramp: { status: 'created' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampCreated); + }); + + it('maps submit/session phases to KycStartedIncomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'submit', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'session', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps error phase to KycRejected when status is incomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'error', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('maps failed SumSub to KycRejected before completion', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'failed', + phase: 'done', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('returns LookupFailed when KYC is complete but wallet was skipped', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'skipped' }, + autoramp: { status: 'skipped' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('returns LookupFailed when wallet is registered but autoramp was skipped', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'skipped' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); +}); diff --git a/packages/ramps-controller/src/neobank-onboarding.ts b/packages/ramps-controller/src/neobank-onboarding.ts new file mode 100644 index 00000000000..a403585e04d --- /dev/null +++ b/packages/ramps-controller/src/neobank-onboarding.ts @@ -0,0 +1,265 @@ +/** + * Money Account / NeoBank onboarding stage derivation for Mobile routing. + * + * {@link RampsController.hydrateNeobankStore} refreshes authoritative KYC / + * wallet / autoramp signals, then uses {@link deriveNeobankOnboardingStage} to + * pick a single stage. This ticket returns state only — it never navigates and + * does not auto-submit wallet registration or autoramp creation (TRAM-3924). + */ + +import type { + KycPhase, + KycSumSubStatus, + KycUserStatus, +} from '@metamask/kyc-controller'; + +import { AutorampStatus } from './autorampAccount.js'; +import type { AutorampAccount } from './autorampAccount.js'; +import type { RegistrationStatus } from './wallet-registration-service.js'; + +/** + * Single Mobile-routable stage for NeoBank / Money Account onboarding. + * + * Ordered roughly by funnel position. Terms 1 = vendor disclaimers; Terms 2 = + * SumSub + idOS provider disclaimers (submitted as a batch — if either is + * missing after a partial accept, Mobile routes back to Terms 2). + */ +export enum NeobankOnboardingStage { + /** No vendor customer / identity yet. */ + NoUser = 'NoUser', + /** MoonPay Auth frame: email OTP required. */ + EmailOtpRequired = 'EmailOtpRequired', + /** Vendor T&C (Terms 1) not accepted. */ + VendorTermsRequired = 'VendorTermsRequired', + /** SumSub + idOS T&C (Terms 2) not accepted as a batch. */ + ProviderTermsRequired = 'ProviderTermsRequired', + /** KYC not started. */ + KycNotStarted = 'KycNotStarted', + /** SumSub / document flow started but not finished. */ + KycStartedIncomplete = 'KycStartedIncomplete', + /** Terminal KYC failure / rejection. */ + KycRejected = 'KycRejected', + /** Needs review / more information (EDD). */ + KycNeedsReview = 'KycNeedsReview', + /** KYC submitted; vendor still deciding. */ + KycPending = 'KycPending', + /** Money Account wallet ownership proof not registered. */ + WalletNotSigned = 'WalletNotSigned', + /** No autoramp standing route yet. */ + AutorampNotCreated = 'AutorampNotCreated', + /** Autoramp exists but is not yet Approved. */ + AutorampPending = 'AutorampPending', + /** Autoramp Approved (account ready). */ + AutorampCreated = 'AutorampCreated', + /** Authoritative lookup failed; Mobile should show a retryable error. */ + LookupFailed = 'LookupFailed', +} + +/** + * Persisted / UI-facing NeoBank slice on {@link RampsController} state. + */ +export type NeobankState = { + /** Latest derived onboarding stage. */ + stage: NeobankOnboardingStage | null; + /** ISO-8601 timestamp of the last successful hydrate, or `null`. */ + lastHydratedAt: string | null; + /** Last hydrate error message when stage is {@link NeobankOnboardingStage.LookupFailed}. */ + lastError: string | null; +}; + +/** + * Inputs for {@link deriveNeobankOnboardingStage}. Built by + * {@link RampsController.hydrateNeobankStore} after refreshing remotes. + */ +export type NeobankOnboardingDerivationInput = { + kyc: { + phase: KycPhase; + userStatus: KycUserStatus | null; + sumsubStatus: KycSumSubStatus; + hasCustomerIdentity: boolean; + hasVendorTerms: boolean; + /** + * True only when both SumSub provider disclaimers and idOS disclaimers + * were accepted together (batch T&C2). + */ + hasProviderTerms: boolean; + }; + /** + * Wallet registration lookup for the Money Account address. + * `skipped` when KYC is not complete yet. + */ + wallet: + | { status: 'skipped' } + | { status: 'lookupUnavailable'; message?: string } + | { status: 'resolved'; registration: RegistrationStatus }; + /** + * Autoramp readiness for the Money Account address. + * `skipped` when KYC or wallet registration is incomplete. + */ + autoramp: + | { status: 'skipped' } + | { status: 'none' } + | { status: 'pending' } + | { status: 'created' }; +}; + +/** + * Default NeoBank slice for {@link RampsController} state. + * + * @returns Empty neobank state. + */ +export function getDefaultNeobankState(): NeobankState { + return { + stage: null, + lastHydratedAt: null, + lastError: null, + }; +} + +/** + * Whether a SumSub sub-flow status means the applicant started documents but + * has not reached a terminal outcome yet. + * + * @param status - SumSub sub-flow status. + * @returns Whether the flow is in progress. + */ +function isSumSubInProgress(status: KycSumSubStatus): boolean { + return ( + status === 'creatingSession' || + status === 'fetchingToken' || + status === 'launching' || + status === 'inProgress' || + status === 'polling' || + status === 'vendorProcessing' + ); +} + +/** + * Whether an autoramp status counts as "created / ready" for Mobile routing. + * + * @param status - Autoramp status. + * @returns Whether the autoramp is Approved. + */ +export function isAutorampCreatedStatus(status: AutorampStatus): boolean { + return status === AutorampStatus.Approved; +} + +/** + * Whether an autoramp status counts as still pending (exists but not ready). + * + * @param status - Autoramp status. + * @returns Whether Mobile should show a pending autoramp stage. + */ +export function isAutorampPendingStatus(status: AutorampStatus): boolean { + return ( + status !== AutorampStatus.Approved && + status !== AutorampStatus.Rejected && + status !== AutorampStatus.Cancelled + ); +} + +/** + * Summarizes local autoramp accounts for a wallet into a derivation input. + * + * @param autoramps - Local last-seen autoramp cache. + * @param walletAddress - Money Account wallet address (case-insensitive). + * @returns Autoramp branch for {@link NeobankOnboardingDerivationInput}. + */ +export function summarizeAutorampsForWallet( + autoramps: readonly AutorampAccount[], + walletAddress: string, +): NeobankOnboardingDerivationInput['autoramp'] { + const normalized = walletAddress.toLowerCase(); + const matching = autoramps.filter( + (autoramp) => autoramp.walletAddress.toLowerCase() === normalized, + ); + if (matching.length === 0) { + return { status: 'none' }; + } + if (matching.some((autoramp) => isAutorampCreatedStatus(autoramp.status))) { + return { status: 'created' }; + } + if (matching.some((autoramp) => isAutorampPendingStatus(autoramp.status))) { + return { status: 'pending' }; + } + // Only rejected/cancelled remain — treat as needing a new autoramp. + return { status: 'none' }; +} + +/** + * Derives the single Mobile-routable NeoBank onboarding stage. + * + * Priority follows the product funnel: identity / terms / KYC, then wallet + * registration, then autoramp. Safe to call twice with the same inputs. + * + * @param input - Refreshed KYC + wallet + autoramp signals. + * @returns The onboarding stage. + */ +export function deriveNeobankOnboardingStage( + input: NeobankOnboardingDerivationInput, +): NeobankOnboardingStage { + const { kyc, wallet, autoramp } = input; + + if (kyc.phase === 'auth') { + return NeobankOnboardingStage.EmailOtpRequired; + } + + if (!kyc.hasVendorTerms) { + if (!kyc.hasCustomerIdentity && kyc.userStatus === null) { + return NeobankOnboardingStage.NoUser; + } + return NeobankOnboardingStage.VendorTermsRequired; + } + + if (!kyc.hasProviderTerms) { + return NeobankOnboardingStage.ProviderTermsRequired; + } + + if (kyc.userStatus === 'need-more-information') { + return NeobankOnboardingStage.KycNeedsReview; + } + if (kyc.userStatus === 'terminal-failure') { + return NeobankOnboardingStage.KycRejected; + } + if (kyc.userStatus === 'pending') { + return NeobankOnboardingStage.KycPending; + } + + if (kyc.userStatus !== 'completed') { + if (isSumSubInProgress(kyc.sumsubStatus) || kyc.sumsubStatus === 'failed') { + return kyc.sumsubStatus === 'failed' + ? NeobankOnboardingStage.KycRejected + : NeobankOnboardingStage.KycStartedIncomplete; + } + if (kyc.phase === 'error') { + return NeobankOnboardingStage.KycRejected; + } + if (kyc.phase === 'submit' || kyc.phase === 'session') { + return NeobankOnboardingStage.KycStartedIncomplete; + } + return NeobankOnboardingStage.KycNotStarted; + } + + // KYC completed — wallet / autoramp stages. + if (wallet.status === 'lookupUnavailable') { + return NeobankOnboardingStage.LookupFailed; + } + if (wallet.status === 'skipped') { + return NeobankOnboardingStage.LookupFailed; + } + if (wallet.registration.type === 'absent') { + return NeobankOnboardingStage.WalletNotSigned; + } + // active or disabled registrations both count as "signed"; disabled is a + // downstream product concern, not a missing ownership proof. + if (autoramp.status === 'skipped') { + return NeobankOnboardingStage.LookupFailed; + } + if (autoramp.status === 'none') { + return NeobankOnboardingStage.AutorampNotCreated; + } + if (autoramp.status === 'pending') { + return NeobankOnboardingStage.AutorampPending; + } + return NeobankOnboardingStage.AutorampCreated; +} diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index 4031cf9ae57..459f977c7d9 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -12,6 +12,9 @@ { "path": "../controller-utils/tsconfig.build.json" }, + { + "path": "../kyc-controller/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index 6e32c0cd68b..10784e74996 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -7,6 +7,12 @@ { "path": "../base-controller" }, + { + "path": "../controller-utils" + }, + { + "path": "../kyc-controller" + }, { "path": "../messenger" }, @@ -15,9 +21,6 @@ }, { "path": "../remote-feature-flag-controller" - }, - { - "path": "../controller-utils" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 43f86c63a0c..0e233c5879f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7649,7 +7649,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@workspace:^, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8703,6 +8703,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^10.0.0" "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/kyc-controller": "workspace:^" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^32.1.0" "@metamask/remote-feature-flag-controller": "npm:^7.0.0" From 754385ba4e7fba71630868b026645386e1830eda Mon Sep 17 00:00:00 2001 From: Arnold Rozon Date: Fri, 4 Sep 2026 11:14:34 -0400 Subject: [PATCH 2/6] docs(ramps-controller): link hydrateNeobankStore changelog to PR #10116 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 baf68ba0af8..5fbc00c1cb0 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#xxxx](https://github.com/MetaMask/core/pull/xxxx)) +- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#10116](https://github.com/MetaMask/core/pull/10116)) - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) From b5736903fff7148875b2432a0ed985512c31018a Mon Sep 17 00:00:00 2001 From: Arnold Rozon Date: Tue, 8 Sep 2026 11:09:45 -0400 Subject: [PATCH 3/6] feat(ramps-controller): add neobank onboarding lookup Co-authored-by: Cursor --- README.md | 1 + .../src/RampsController.test.ts | 45 ++++++++++++- .../ramps-controller/src/RampsController.ts | 63 +++++++++---------- .../src/neobank-onboarding.test.ts | 3 + yarn.lock | 2 +- 5 files changed, 79 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index c68b300cb33..e878f4bba1a 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,7 @@ linkStyle default opacity:0.5 profile_sync_controller --> seedless_onboarding_controller; ramps_controller --> base_controller; ramps_controller --> controller_utils; + ramps_controller --> kyc_controller; ramps_controller --> messenger; ramps_controller --> profile_sync_controller; ramps_controller --> remote_feature_flag_controller; diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index a79b09d6f3f..4f55761540d 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9990,7 +9990,7 @@ describe('RampsController', () => { expect(controller.state.neobank.stage).toBe( NeobankOnboardingStage.NoUser, ); - expect(controller.state.neobank.lastHydratedAt).toEqual( + expect(controller.state.neobank.lastHydratedAt).toStrictEqual( expect.any(String), ); expect(controller.state.neobank.lastError).toBeNull(); @@ -10011,6 +10011,49 @@ describe('RampsController', () => { }); }); + it('returns LookupFailed instead of routing from stale KYC state', async () => { + const staleKycState = { + ...defaultKycState, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers( + rootMessenger, + staleKycState, + ); + handlers.refreshKycStatus.mockRejectedValue(new Error('network down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastHydratedAt).toBeNull(); + expect(controller.state.neobank.lastError).toMatch(/network down/u); + }); + }); + + it('returns LookupFailed for a missing wallet address', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: ' ', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch( + /walletAddress is required/u, + ); + expect(handlers.refreshKycStatus).not.toHaveBeenCalled(); + }); + }); + it('returns WalletNotSigned after completed KYC when registration is absent', async () => { const completedKyc = { ...defaultKycState, diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 28ef757a9f9..a5eda5c7ea0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -36,14 +36,6 @@ import { 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 type { NeobankState } from './neobank-onboarding.js'; import { NeobankOnboardingStage, @@ -52,6 +44,14 @@ import { summarizeAutorampsForWallet, } from './neobank-onboarding.js'; import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; +import type { + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampAction, + NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, +} from './NeoBankService-method-action-types.js'; +import type { NeoBankServiceActions } from './NeoBankService.js'; import { areOrdersEqual, deleteOrderInUserStorage, @@ -593,8 +593,8 @@ export type RampsControllerState = { /** * Money Account / NeoBank onboarding stage derived by * {@link RampsController.hydrateNeobankStore}. Mobile reads `stage` to route - * after cold start or missed KYC events. Persist so a failed hydrate can still - * surface the last known stage. + * after cold start or missed KYC events. Lookup failures are persisted with + * their error while the last successful hydration time is retained. */ neobank: NeobankState; /** @@ -3658,20 +3658,28 @@ export class RampsController extends BaseController< }: { walletAddress: string; }): Promise { - let refreshError: string | null = null; + if (!walletAddress.trim()) { + return this.#commitNeobankStage( + NeobankOnboardingStage.LookupFailed, + 'walletAddress is required.', + ); + } + try { await this.messenger.call('KycController:refreshKycStatus'); } catch (error) { - refreshError = String(error); + return this.#commitNeobankStage( + NeobankOnboardingStage.LookupFailed, + String(error), + ); } const kycState = this.messenger.call('KycController:getState'); const identity = this.messenger.call('KycController:getCustomerIdentity'); const hasVendorTerms = hasKycVendorTerms(kycState); const hasProviderTerms = hasKycProviderTerms(kycState); - const hasCustomerIdentity = Boolean( - identity?.id || kycState.email || kycState.userStatus !== null, - ); + const hasCustomerIdentity = + Boolean(identity?.id ?? kycState.email) || kycState.userStatus !== null; const kycInput: NeobankOnboardingDerivationInput['kyc'] = { phase: kycState.phase, @@ -3682,27 +3690,13 @@ export class RampsController extends BaseController< hasProviderTerms, }; - // A refresh failure with no local KYC signal is not "no user" — Mobile - // should retry rather than restart onboarding. - if ( - refreshError && - !hasVendorTerms && - !hasProviderTerms && - kycState.userStatus === null && - !hasCustomerIdentity - ) { - return this.#commitNeobankStage( - NeobankOnboardingStage.LookupFailed, - refreshError, - ); - } - let wallet: NeobankOnboardingDerivationInput['wallet'] = { status: 'skipped', }; let autoramp: NeobankOnboardingDerivationInput['autoramp'] = { status: 'skipped', }; + let lookupError: string | null = null; if (kycState.userStatus === 'completed') { try { @@ -3726,7 +3720,7 @@ export class RampsController extends BaseController< status: 'lookupUnavailable', message: String(error), }; - refreshError = refreshError ?? String(error); + lookupError = String(error); } } @@ -3737,7 +3731,7 @@ export class RampsController extends BaseController< }); return this.#commitNeobankStage( stage, - stage === NeobankOnboardingStage.LookupFailed ? refreshError : null, + stage === NeobankOnboardingStage.LookupFailed ? lookupError : null, ); } @@ -3755,7 +3749,10 @@ export class RampsController extends BaseController< this.update((state) => { state.neobank = { stage, - lastHydratedAt: new Date().toISOString(), + lastHydratedAt: + stage === NeobankOnboardingStage.LookupFailed + ? state.neobank.lastHydratedAt + : new Date().toISOString(), lastError: stage === NeobankOnboardingStage.LookupFailed ? lastError : null, }; diff --git a/packages/ramps-controller/src/neobank-onboarding.test.ts b/packages/ramps-controller/src/neobank-onboarding.test.ts index 36e76624f3a..a18174bb7c3 100644 --- a/packages/ramps-controller/src/neobank-onboarding.test.ts +++ b/packages/ramps-controller/src/neobank-onboarding.test.ts @@ -13,6 +13,9 @@ import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; * default so each test can override the branch under assertion. * * @param overrides - Partial input overrides. + * @param overrides.kyc - KYC input fields to override. + * @param overrides.wallet - Wallet lookup result to override. + * @param overrides.autoramp - Autoramp summary to override. * @returns A complete derivation input. */ function buildInput( diff --git a/yarn.lock b/yarn.lock index 0e233c5879f..9b904847eb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7649,7 +7649,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:^, @metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@npm:^0.0.0, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: From 49def7a07752a27f853ac470376a87a0707fe5d1 Mon Sep 17 00:00:00 2001 From: Arnold Rozon Date: Fri, 11 Sep 2026 10:05:02 -0400 Subject: [PATCH 4/6] chore(ramps-controller): put hydrateNeobankStore changelog under Unreleased Keep the onboarding stage entry out of released 20.3.0 so the changelog check can see it, and format the hydrateNeobankStore tests. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 6 ++++-- packages/ramps-controller/src/RampsController.test.ts | 5 +---- yarn.lock | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 5fbc00c1cb0..f8a5a055737 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#10116](https://github.com/MetaMask/core/pull/10116)) + ### Changed - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.0` ([#10184](https://github.com/MetaMask/core/pull/10184)) @@ -54,8 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#10116](https://github.com/MetaMask/core/pull/10116)) - - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4f55761540d..a5ca7283f06 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10022,10 +10022,7 @@ describe('RampsController', () => { }; await withController(async ({ controller, rootMessenger }) => { - const handlers = registerHydrateHandlers( - rootMessenger, - staleKycState, - ); + const handlers = registerHydrateHandlers(rootMessenger, staleKycState); handlers.refreshKycStatus.mockRejectedValue(new Error('network down')); const stage = await controller.hydrateNeobankStore({ diff --git a/yarn.lock b/yarn.lock index 9b904847eb4..0e233c5879f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7649,7 +7649,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@npm:^0.0.0, @metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@workspace:^, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: From c05e25b7608c8f10486cf0e87247a995dd8e887b Mon Sep 17 00:00:00 2001 From: Arnold Rozon Date: Fri, 11 Sep 2026 10:24:35 -0400 Subject: [PATCH 5/6] fix(ramps-controller): correct NeoBank KYC stages for SumSub resume Treat abandoned/retryable SumSub as KYCPage-resume, vendorProcessing as pending without launching the SDK, and controller errors as lookup failures rather than terminal rejects. Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 1 + .../src/neobank-onboarding.test.ts | 45 +++++++++++---- .../src/neobank-onboarding.ts | 55 ++++++++++++++----- 3 files changed, 76 insertions(+), 25 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f8a5a055737..32c9b22b6b6 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#10116](https://github.com/MetaMask/core/pull/10116)) + - SumSub `abandoned` / retryable `failed` map to `KycStartedIncomplete` (KYCPage hook may relaunch). `vendorProcessing` and `userStatus === 'pending'` map to `KycPending` (do not launch the SDK). Controller `phase === 'error'` is `LookupFailed`, not a terminal KYC reject. `form` / `check` count as in-progress. Terminal reject is only `userStatus === 'terminal-failure'`. ### Changed diff --git a/packages/ramps-controller/src/neobank-onboarding.test.ts b/packages/ramps-controller/src/neobank-onboarding.test.ts index a18174bb7c3..6636b31e49a 100644 --- a/packages/ramps-controller/src/neobank-onboarding.test.ts +++ b/packages/ramps-controller/src/neobank-onboarding.test.ts @@ -325,27 +325,48 @@ describe('deriveNeobankOnboardingStage', () => { ).toBe(NeobankOnboardingStage.AutorampCreated); }); - it('maps submit/session phases to KycStartedIncomplete', () => { + it('maps submit/session/form/check phases to KycStartedIncomplete', () => { + for (const phase of ['submit', 'session', 'form', 'check'] as const) { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase, + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + } + }); + + it('maps error phase to LookupFailed when status is incomplete', () => { expect( deriveNeobankOnboardingStage( buildInput({ kyc: { userStatus: 'not-started', sumsubStatus: 'idle', - phase: 'submit', + phase: 'error', hasVendorTerms: true, hasProviderTerms: true, }, }), ), - ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('maps retryable SumSub failure to KycStartedIncomplete', () => { expect( deriveNeobankOnboardingStage( buildInput({ kyc: { userStatus: 'not-started', - sumsubStatus: 'idle', - phase: 'session', + sumsubStatus: 'failed', + phase: 'done', hasVendorTerms: true, hasProviderTerms: true, }, @@ -354,36 +375,36 @@ describe('deriveNeobankOnboardingStage', () => { ).toBe(NeobankOnboardingStage.KycStartedIncomplete); }); - it('maps error phase to KycRejected when status is incomplete', () => { + it('maps abandoned SumSub to KycStartedIncomplete', () => { expect( deriveNeobankOnboardingStage( buildInput({ kyc: { userStatus: 'not-started', - sumsubStatus: 'idle', - phase: 'error', + sumsubStatus: 'abandoned', + phase: 'done', hasVendorTerms: true, hasProviderTerms: true, }, }), ), - ).toBe(NeobankOnboardingStage.KycRejected); + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); }); - it('maps failed SumSub to KycRejected before completion', () => { + it('maps vendorProcessing SumSub to KycPending so the SDK is not relaunched', () => { expect( deriveNeobankOnboardingStage( buildInput({ kyc: { userStatus: 'not-started', - sumsubStatus: 'failed', + sumsubStatus: 'vendorProcessing', phase: 'done', hasVendorTerms: true, hasProviderTerms: true, }, }), ), - ).toBe(NeobankOnboardingStage.KycRejected); + ).toBe(NeobankOnboardingStage.KycPending); }); it('returns LookupFailed when KYC is complete but wallet was skipped', () => { diff --git a/packages/ramps-controller/src/neobank-onboarding.ts b/packages/ramps-controller/src/neobank-onboarding.ts index a403585e04d..d7ee165b220 100644 --- a/packages/ramps-controller/src/neobank-onboarding.ts +++ b/packages/ramps-controller/src/neobank-onboarding.ts @@ -33,15 +33,22 @@ export enum NeobankOnboardingStage { VendorTermsRequired = 'VendorTermsRequired', /** SumSub + idOS T&C (Terms 2) not accepted as a batch. */ ProviderTermsRequired = 'ProviderTermsRequired', - /** KYC not started. */ + /** KYC not started. Mobile mounts KYCPage; the page hook may launch SumSub. */ KycNotStarted = 'KycNotStarted', - /** SumSub / document flow started but not finished. */ + /** + * Document flow started, abandoned, or retryable SumSub failure. + * Mobile mounts KYCPage; the page hook should resume / relaunch SumSub. + * Do not launch the SDK from the router. + */ KycStartedIncomplete = 'KycStartedIncomplete', - /** Terminal KYC failure / rejection. */ + /** Terminal KYC failure / rejection (`userStatus === 'terminal-failure'`). */ KycRejected = 'KycRejected', - /** Needs review / more information (EDD). */ + /** Needs review / more information (EDD). Mobile mounts KYCPage, not a dead error. */ KycNeedsReview = 'KycNeedsReview', - /** KYC submitted; vendor still deciding. */ + /** + * KYC submitted or vendor is still deciding (`userStatus === 'pending'` or + * SumSub `vendorProcessing`). Processing UI — do not launch the SDK. + */ KycPending = 'KycPending', /** Money Account wallet ownership proof not registered. */ WalletNotSigned = 'WalletNotSigned', @@ -129,8 +136,21 @@ function isSumSubInProgress(status: KycSumSubStatus): boolean { status === 'fetchingToken' || status === 'launching' || status === 'inProgress' || - status === 'polling' || - status === 'vendorProcessing' + status === 'polling' + ); +} + +/** + * Whether SumSub was started but the applicant can still resume (including a + * retryable SDK failure). Distinct from `vendorProcessing`, which must not + * relaunch the SDK. + * + * @param status - SumSub sub-flow status. + * @returns Whether Mobile should mount KYCPage and let the page hook launch. + */ +function isSumSubResumeRequired(status: KycSumSubStatus): boolean { + return ( + isSumSubInProgress(status) || status === 'abandoned' || status === 'failed' ); } @@ -191,6 +211,9 @@ export function summarizeAutorampsForWallet( * * Priority follows the product funnel: identity / terms / KYC, then wallet * registration, then autoramp. Safe to call twice with the same inputs. + * This returns a stage only — Mobile mounts KYCPage when terms are done and + * documents are still needed; the page hook launches SumSub. Do not launch + * the SDK from a router method call. * * @param input - Refreshed KYC + wallet + autoramp signals. * @returns The onboarding stage. @@ -226,15 +249,21 @@ export function deriveNeobankOnboardingStage( } if (kyc.userStatus !== 'completed') { - if (isSumSubInProgress(kyc.sumsubStatus) || kyc.sumsubStatus === 'failed') { - return kyc.sumsubStatus === 'failed' - ? NeobankOnboardingStage.KycRejected - : NeobankOnboardingStage.KycStartedIncomplete; + if (kyc.sumsubStatus === 'vendorProcessing') { + return NeobankOnboardingStage.KycPending; + } + if (isSumSubResumeRequired(kyc.sumsubStatus)) { + return NeobankOnboardingStage.KycStartedIncomplete; } if (kyc.phase === 'error') { - return NeobankOnboardingStage.KycRejected; + return NeobankOnboardingStage.LookupFailed; } - if (kyc.phase === 'submit' || kyc.phase === 'session') { + if ( + kyc.phase === 'submit' || + kyc.phase === 'session' || + kyc.phase === 'form' || + kyc.phase === 'check' + ) { return NeobankOnboardingStage.KycStartedIncomplete; } return NeobankOnboardingStage.KycNotStarted; From 99dc313867c5d36387af85215d18725f470aca86 Mon Sep 17 00:00:00 2001 From: Arnold Rozon Date: Fri, 11 Sep 2026 10:56:49 -0400 Subject: [PATCH 6/6] fix(ramps-controller): align KYC dependency version Co-authored-by: Cursor --- packages/ramps-controller/package.json | 2 +- yarn.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 4e25e228130..d3289872fe6 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -53,7 +53,7 @@ "dependencies": { "@metamask/base-controller": "^10.0.0", "@metamask/controller-utils": "^13.0.0", - "@metamask/kyc-controller": "workspace:^", + "@metamask/kyc-controller": "^0.2.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.1.0", "@metamask/remote-feature-flag-controller": "^7.0.0", diff --git a/yarn.lock b/yarn.lock index 0e233c5879f..06760ceb93f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7649,7 +7649,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:^, @metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@npm:^0.2.0, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8703,7 +8703,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^10.0.0" "@metamask/controller-utils": "npm:^13.0.0" - "@metamask/kyc-controller": "workspace:^" + "@metamask/kyc-controller": "npm:^0.2.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^32.1.0" "@metamask/remote-feature-flag-controller": "npm:^7.0.0"