diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index dcf3548fc84..d0a6f52f534 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `NeoBankService:getAutoramps` to load all autoramp accounts for the authenticated customer from `GET /neobank/autoramps` ([#10278](https://github.com/MetaMask/core/pull/10278)) +- Add `RampsController:hydrateVbaOnboarding`, the persisted `vbaOnboardingStage` state, and the `VbaOnboardingStage` enum for Mobile routing ([#10278](https://github.com/MetaMask/core/pull/10278)) + - Resolve the current onboarding stage (email OTP, vendor terms, provider terms, SumSub, pending KYC, rejected KYC, or completed) from the customer's KYC session status. + - After KYC acceptance, register the Money Account wallet, load the customer's authoritative autoramps, and create one only when needed; keep the user on the pending stage if account activation is momentarily unavailable. + - Coalesce overlapping hydration calls to prevent duplicate wallet signatures or autoramp creation during polling. + +### Changed + +- **BREAKING:** `RampsControllerMessenger` now requires the `KycController:getSessionStatusForVendor`, `KycController:refreshSessionStatus`, `KycController:hasCompletedVendorDisclaimers`, and `KycController:hasCompletedSessionDisclaimers` actions to hydrate VBA onboarding ([#10278](https://github.com/MetaMask/core/pull/10278)) + - The action types are declared structurally in the ramps package, so no dependency on `@metamask/kyc-controller` is added. + ## [23.0.0] ### Added diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 956b7f2b6b4..cf927750ac1 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -18,6 +18,16 @@ export type NeoBankServiceGetAutorampAction = { handler: NeoBankService['getAutoramp']; }; +/** + * Fetches all autoramp accounts belonging to the authenticated customer. + * + * @returns Remote snapshots for all customer autoramps. + */ +export type NeoBankServiceGetAutorampsAction = { + type: `NeoBankService:getAutoramps`; + handler: NeoBankService['getAutoramps']; +}; + /** * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. * Body is forwarded as opaque JSON (MoonPay address schema). @@ -136,6 +146,7 @@ export type NeoBankServiceRegisterSelfHostedWalletAction = { */ export type NeoBankServiceMethodActions = | NeoBankServiceGetAutorampAction + | NeoBankServiceGetAutorampsAction | NeoBankServiceRegisterPixAddressAction | NeoBankServiceGetAutorampQuoteAction | NeoBankServiceCreateAutorampAction diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index ce306b95545..673527dadb7 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -229,6 +229,66 @@ describe('NeoBankService', () => { }); }); + describe('getAutoramps', () => { + it('gets and maps all autoramps for the authenticated customer', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps') + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, [ + { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + wallet_address: '0xabc', + }, + { + id: 'ar-2', + customer_id: 'cust-1', + status: 'Authorized', + recipient_account: { address: '0xdef' }, + }, + ]); + + const service = createService(); + + expect(await service.getAutoramps()).toMatchInlineSnapshot(` + [ + { + "customerId": "cust-1", + "depositRailsSummary": { + "ready": false, + }, + "id": "ar-1", + "status": "Approved", + "walletAddress": "0xabc", + }, + { + "customerId": "cust-1", + "depositRailsSummary": undefined, + "id": "ar-2", + "status": "Authorized", + "walletAddress": "0xdef", + }, + ] + `); + expect(scope.isDone()).toBe(true); + }); + + it('rejects a malformed list response', async () => { + nock(STAGING_BASE) + .get('/neobank/autoramps') + .query(true) + .reply(200, { autoramps: [] }); + + const service = createService(); + + await expect(service.getAutoramps()).rejects.toThrow( + 'Malformed response received from neo-bank autoramps API', + ); + }); + }); + describe('registerPixAddress', () => { it('posts /neobank/addresses/pix with JSON body and bearer auth', async () => { const body = { diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index ef73ddb5fca..d9c1f26bbf4 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -112,6 +112,7 @@ export type RegisterSelfHostedWalletParams = { const MESSENGER_EXPOSED_METHODS = [ 'getAutoramp', + 'getAutoramps', 'registerPixAddress', 'getAutorampQuote', 'createAutoramp', @@ -420,6 +421,23 @@ export class NeoBankService { return this.#mapAutorampResponse(response); } + /** + * Fetches all autoramp accounts belonging to the authenticated customer. + * + * @returns Remote snapshots for all customer autoramps. + */ + async getAutoramps(): Promise { + const response = await this.#getJson('autoramps'); + if (!Array.isArray(response)) { + throw new Error( + 'Malformed response received from neo-bank autoramps API', + ); + } + return response.map((autoramp) => + this.#mapAutorampResponse(autoramp as NeoBankAutorampResponse), + ); + } + /** * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. * Body is forwarded as opaque JSON (MoonPay address schema). diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 7ad966b6a95..d6e9bbf8aaa 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -427,6 +427,22 @@ export type RampsControllerRegisterMoneyAccountWalletAction = { handler: RampsController['registerMoneyAccountWallet']; }; +/** + * Hydrates the Mobile-routable VBA onboarding stage from KYC state and + * completes wallet and autoramp setup after KYC acceptance. + * + * Overlapping calls share one run so polling cannot trigger duplicate wallet + * signatures or autoramp creation. + * + * @param params - VBA onboarding parameters. + * @param params.walletAddress - Monad Money Account wallet address. + * @returns The hydrated onboarding stage. + */ +export type RampsControllerHydrateVbaOnboardingAction = { + type: `RampsController:hydrateVbaOnboarding`; + handler: RampsController['hydrateVbaOnboarding']; +}; + /** * Removes a local autoramp last-seen cursor by id. * @@ -900,6 +916,7 @@ export type RampsControllerMethodActions = | RampsControllerAddAutorampAction | RampsControllerCreateAutorampAction | RampsControllerRegisterMoneyAccountWalletAction + | RampsControllerHydrateVbaOnboardingAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction | RampsControllerApplyAutorampStatusFromPushAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 87be2734b00..0118ad73517 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -21,6 +21,7 @@ import type { } from './RampsController.js'; import { RampsController, + VbaOnboardingStage, getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, @@ -193,6 +194,7 @@ describe('RampsController', () => { "selected": null, }, "userRegion": null, + "vbaOnboardingStage": null, } `); }); @@ -270,6 +272,7 @@ describe('RampsController', () => { "selected": null, }, "userRegion": null, + "vbaOnboardingStage": null, } `); }); @@ -2642,6 +2645,7 @@ describe('RampsController', () => { "selected": null, }, "userRegion": null, + "vbaOnboardingStage": null, } `); }); @@ -2685,6 +2689,7 @@ describe('RampsController', () => { "selected": null, }, "userRegion": null, + "vbaOnboardingStage": null, } `); }); @@ -2704,6 +2709,7 @@ describe('RampsController', () => { "orders": [], "providerAutoSelected": false, "userRegion": null, + "vbaOnboardingStage": null, } `); }); @@ -2771,6 +2777,7 @@ describe('RampsController', () => { "selected": null, }, "userRegion": null, + "vbaOnboardingStage": null, } `); }); @@ -10179,6 +10186,467 @@ describe('RampsController', () => { }); }); + describe('hydrateVbaOnboarding', () => { + type KycSession = { + id: string; + finalStatus: string; + kycStatus: string; + vendorStatus: string; + }; + + type KycHandlers = { + getSessionStatusForVendor: jest.Mock; + refreshSessionStatus: jest.Mock; + hasCompletedVendorDisclaimers: jest.Mock; + hasCompletedSessionDisclaimers: jest.Mock; + getAutoramps: jest.Mock; + }; + + type KycValues = { + /** + * Session status resolved by the KYC controller. `null` means no session + * exists yet (start onboarding at the email step). + */ + session: KycSession | null; + /** + * When `true`, `KycController:refreshSessionStatus` throws (no session in + * controller state), so hydration falls back to the backend + * `getSessionStatusForVendor` fetch. + */ + refreshThrows: boolean; + /** + * When `true`, the `getSessionStatusForVendor` fallback rejects with a + * 404-style error (treated as "no session"). + */ + getSessionRejects: boolean; + vendorDisclaimersCompleted: boolean; + sessionDisclaimersCompleted: boolean; + }; + + const approvedSession: KycSession = { + id: 'session-1', + finalStatus: 'approved', + kycStatus: 'approved', + vendorStatus: 'approved', + }; + + const registerKycHandlers = ( + rootMessenger: RootMessenger, + overrides: Partial = {}, + ): KycHandlers => { + const values: KycValues = { + session: approvedSession, + refreshThrows: false, + getSessionRejects: false, + vendorDisclaimersCompleted: true, + sessionDisclaimersCompleted: true, + ...overrides, + }; + + const refreshSessionStatus = jest.fn(() => { + if (values.refreshThrows) { + throw new Error('no session in state'); + } + return values.session; + }); + const getSessionStatusForVendor = jest.fn(async () => { + if (values.getSessionRejects) { + throw new Error('KYC session not found'); + } + return values.session; + }); + + const handlers: KycHandlers = { + getSessionStatusForVendor, + refreshSessionStatus, + hasCompletedVendorDisclaimers: jest + .fn() + .mockResolvedValue(values.vendorDisclaimersCompleted), + hasCompletedSessionDisclaimers: jest + .fn() + .mockResolvedValue(values.sessionDisclaimersCompleted), + getAutoramps: jest.fn().mockResolvedValue([]), + }; + + rootMessenger.registerActionHandler( + 'KycController:getSessionStatusForVendor' as never, + handlers.getSessionStatusForVendor as never, + ); + rootMessenger.registerActionHandler( + 'KycController:refreshSessionStatus' as never, + handlers.refreshSessionStatus as never, + ); + rootMessenger.registerActionHandler( + 'KycController:hasCompletedVendorDisclaimers' as never, + handlers.hasCompletedVendorDisclaimers as never, + ); + rootMessenger.registerActionHandler( + 'KycController:hasCompletedSessionDisclaimers' as never, + handlers.hasCompletedSessionDisclaimers as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramps' as never, + handlers.getAutoramps as never, + ); + + return handlers; + }; + + const pendingSession = (kycStatus: string): KycSession => ({ + id: 'session-1', + finalStatus: 'pending', + kycStatus, + vendorStatus: 'pending', + }); + + it.each([ + { + name: 'email OTP when no session exists in state or on the backend', + overrides: { refreshThrows: true, session: null }, + expected: VbaOnboardingStage.EmailOtpRequired, + }, + { + name: 'email OTP when the backend session lookup 404s', + overrides: { refreshThrows: true, getSessionRejects: true }, + expected: VbaOnboardingStage.EmailOtpRequired, + }, + { + name: 'vendor terms when Iron disclaimers are incomplete', + overrides: { vendorDisclaimersCompleted: false }, + expected: VbaOnboardingStage.VendorTermsRequired, + }, + { + name: 'provider terms when SumSub session disclaimers are incomplete', + overrides: { sessionDisclaimersCompleted: false }, + expected: VbaOnboardingStage.ProviderTermsRequired, + }, + { + name: 'the SumSub widget when KYC has not started', + overrides: { session: pendingSession('new') }, + expected: VbaOnboardingStage.KycRequired, + }, + { + name: 'the SumSub widget when KYC needs a retry', + overrides: { session: pendingSession('retry') }, + expected: VbaOnboardingStage.KycRequired, + }, + { + name: 'pending KYC while the vendor finalizes', + overrides: { session: pendingSession('pending') }, + expected: VbaOnboardingStage.KycPending, + }, + { + name: 'rejected KYC when the vendor finalizes as rejected', + overrides: { session: pendingSession('rejected') }, + expected: VbaOnboardingStage.KycRejected, + }, + { + name: 'rejected KYC when the final status is rejected', + overrides: { + session: { + id: 'session-1', + finalStatus: 'rejected', + kycStatus: 'pending', + vendorStatus: 'rejected', + }, + }, + expected: VbaOnboardingStage.KycRejected, + }, + ])('routes to $name', async ({ overrides, expected }) => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger, overrides); + const registerWallet = jest.spyOn( + controller, + 'registerMoneyAccountWallet', + ); + const createAutoramp = jest.spyOn(controller, 'createAutoramp'); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(expected); + + expect(controller.state.vbaOnboardingStage).toBe(expected); + expect(registerWallet).not.toHaveBeenCalled(); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + + it('falls back to the backend session fetch when no session is in state', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerKycHandlers(rootMessenger, { + refreshThrows: true, + session: null, + }); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.EmailOtpRequired); + + expect(handlers.refreshSessionStatus).toHaveBeenCalledTimes(1); + expect(handlers.getSessionStatusForVendor).toHaveBeenCalledWith('iron'); + }); + }); + + it('prefers the in-state session status over the backend fetch', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerKycHandlers(rootMessenger, { + session: pendingSession('new'), + }); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycRequired); + + expect(handlers.refreshSessionStatus).toHaveBeenCalledTimes(1); + expect(handlers.getSessionStatusForVendor).not.toHaveBeenCalled(); + }); + }); + + it('registers the wallet, creates the autoramp, and completes onboarding after accepted KYC', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + jest.spyOn(controller, 'registerMoneyAccountWallet').mockResolvedValue({ + type: 'registered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + const createAutoramp = jest + .spyOn(controller, 'createAutoramp') + .mockImplementation(async () => + controller.addAutoramp({ + id: 'autoramp-1', + customerId: 'customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }), + ); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.Completed); + + expect(createAutoramp).toHaveBeenCalledWith({}); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.Completed, + ); + }); + }); + + it('loads an existing autoramp from the service instead of creating another', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerKycHandlers(rootMessenger); + handlers.getAutoramps.mockResolvedValue([ + { + id: 'autoramp-1', + customerId: 'customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + }, + ]); + jest.spyOn(controller, 'registerMoneyAccountWallet').mockResolvedValue({ + type: 'alreadyRegistered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + const createAutoramp = jest.spyOn(controller, 'createAutoramp'); + + expect( + await controller.hydrateVbaOnboarding({ + walletAddress: '0xabc', + }), + ).toBe(VbaOnboardingStage.Completed); + + expect(createAutoramp).not.toHaveBeenCalled(); + expect( + controller.state.autoramps.map( + ({ updatedAt: _updatedAt, ...account }) => account, + ), + ).toMatchInlineSnapshot(` + [ + { + "customerId": "customer-1", + "depositRailsSummary": undefined, + "id": "autoramp-1", + "lastSeenStatus": "Approved", + "status": "Approved", + "walletAddress": "0xAbC", + }, + ] + `); + }); + }); + + it('creates a new autoramp when the existing account is terminal', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerKycHandlers(rootMessenger); + handlers.getAutoramps.mockResolvedValue([ + { + id: 'autoramp-rejected', + customerId: 'customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Rejected, + }, + ]); + jest.spyOn(controller, 'registerMoneyAccountWallet').mockResolvedValue({ + type: 'alreadyRegistered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + const createAutoramp = jest + .spyOn(controller, 'createAutoramp') + .mockImplementation(async () => + controller.addAutoramp({ + id: 'autoramp-new', + customerId: 'customer-1', + walletAddress: '0xabc', + }), + ); + + await controller.hydrateVbaOnboarding({ + walletAddress: '0xabc', + }); + + expect(createAutoramp).toHaveBeenCalledWith({}); + }); + }); + + it('coalesces overlapping hydration calls', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + let resolveRegistration: ( + result: Awaited< + ReturnType + >, + ) => void = () => undefined; + const registerWallet = jest + .spyOn(controller, 'registerMoneyAccountWallet') + .mockReturnValue( + new Promise((resolve) => { + resolveRegistration = resolve; + }), + ); + jest.spyOn(controller, 'createAutoramp').mockImplementation(async () => + controller.addAutoramp({ + id: 'autoramp-1', + customerId: 'customer-1', + walletAddress: '0xabc', + }), + ); + + const first = controller.hydrateVbaOnboarding({ + walletAddress: '0xabc', + }); + const second = controller.hydrateVbaOnboarding({ + walletAddress: '0xabc', + }); + resolveRegistration({ + type: 'registered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + + expect(await Promise.all([first, second])).toStrictEqual([ + VbaOnboardingStage.Completed, + VbaOnboardingStage.Completed, + ]); + expect(registerWallet).toHaveBeenCalledTimes(1); + }); + }); + + it('falls back to pending KYC when wallet registration fails on the approved path', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + const error = new Error('signing rejected'); + jest + .spyOn(controller, 'registerMoneyAccountWallet') + .mockRejectedValue(error); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycPending); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }); + }); + + it('falls back to pending KYC when the wallet lookup is unavailable', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + const error = new WalletRegistrationError('lookupUnavailable', {}); + jest.spyOn(controller, 'registerMoneyAccountWallet').mockResolvedValue({ + type: 'lookupUnavailable', + error, + }); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycPending); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }); + }); + + it('falls back to pending KYC when loading autoramps fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerKycHandlers(rootMessenger); + const error = new Error('autoramp lookup failed'); + handlers.getAutoramps.mockRejectedValue(error); + jest.spyOn(controller, 'registerMoneyAccountWallet').mockResolvedValue({ + type: 'alreadyRegistered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + const createAutoramp = jest.spyOn(controller, 'createAutoramp'); + + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycPending); + expect(createAutoramp).not.toHaveBeenCalled(); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }); + }); + + it('requires a wallet address only after KYC is accepted', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + + await expect( + controller.hydrateVbaOnboarding({ walletAddress: ' ' }), + ).rejects.toThrow('walletAddress is required after KYC acceptance.'); + expect(controller.state.vbaOnboardingStage).toBeNull(); + }); + }); + }); + describe('registerMoneyAccountWallet', () => { const registration = { id: 'wallet-1', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 9040dfa0861..d1047401e8c 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -23,6 +23,7 @@ import type { } from './autorampAccount.js'; import { applyAutorampRemoteStatus, + AutorampStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js'; @@ -34,6 +35,7 @@ import { import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampsAction, NeoBankServiceGetCustomerByExternalIdAction, NeoBankServiceGetWalletRegistrationStatusAction, NeoBankServiceRegisterSelfHostedWalletAction, @@ -218,6 +220,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', + 'NeoBankService:getAutoramps', 'NeoBankService:createAutoramp', 'NeoBankService:getCustomerByExternalId', 'NeoBankService:getWalletRegistrationStatus', @@ -241,6 +244,10 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', + 'KycController:getSessionStatusForVendor', + 'KycController:refreshSessionStatus', + 'KycController:hasCompletedVendorDisclaimers', + 'KycController:hasCompletedSessionDisclaimers', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', 'UserStorageController:performGetStorageAllFeatureEntries', @@ -257,6 +264,48 @@ export type KeyringControllerSignPersonalMessageAction = { handler: (messageParams: { data: string; from: string }) => Promise; }; +/** + * Minimal structural subset of the KYC controller's session status — only the + * status fields the VBA stage machine reads. + */ +/** + * Identity vendor accepted by the KYC controller. Declared locally so the + * ramps package does not depend on `@metamask/kyc-controller`. + */ +type KycVendor = 'moonpay' | 'iron'; + +type KycControllerSessionStatus = { + finalStatus: string; + kycStatus: string; + vendorStatus: string; +}; + +/** + * Structural types for the KYC controller's VBA onboarding messenger actions. + * Declared locally so the ramps package does not require a kyc-controller + * version that already exports them — the two changes land as separate PRs, + * with KYC merging first. + */ +export type KycControllerGetSessionStatusForVendorAction = { + type: 'KycController:getSessionStatusForVendor'; + handler: (vendor: KycVendor) => Promise; +}; + +export type KycControllerRefreshSessionStatusAction = { + type: 'KycController:refreshSessionStatus'; + handler: () => KycControllerSessionStatus; +}; + +export type KycControllerHasCompletedVendorDisclaimersAction = { + type: 'KycController:hasCompletedVendorDisclaimers'; + handler: () => Promise; +}; + +export type KycControllerHasCompletedSessionDisclaimersAction = { + type: 'KycController:hasCompletedSessionDisclaimers'; + handler: () => Promise; +}; + /** * Outcome of {@link RampsController.registerMoneyAccountWallet}. * @@ -283,6 +332,19 @@ type LookupUnavailableResult = Extract< { type: 'lookupUnavailable' } >; +/** + * The Mobile route for the current VBA onboarding step. + */ +export enum VbaOnboardingStage { + EmailOtpRequired = 'EmailOtpRequired', + VendorTermsRequired = 'VendorTermsRequired', + ProviderTermsRequired = 'ProviderTermsRequired', + KycRequired = 'KycRequired', + KycPending = 'KycPending', + KycRejected = 'KycRejected', + Completed = 'Completed', +} + /** * Distinguishes an already-materialized {@link AutorampAccount} from the * create-fields shape accepted by {@link RampsController.addAutoramp}. @@ -551,6 +613,10 @@ export type RampsControllerState = { * token conflict instead of showing the "Token Not Available" modal. */ providerAutoSelected: boolean; + /** + * The current Mobile-routable VBA onboarding stage. + */ + vbaOnboardingStage: VbaOnboardingStage | null; }; /** @@ -617,6 +683,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + vbaOnboardingStage: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, } satisfies StateMetadata; /** @@ -679,6 +751,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { orders: [], autoramps: [], providerAutoSelected: false, + vbaOnboardingStage: null, }; } @@ -804,12 +877,17 @@ type AllowedActions = | TransakServiceCancelAllActiveOrdersAction | TransakServiceGetActiveOrdersAction | NeoBankServiceGetAutorampAction + | NeoBankServiceGetAutorampsAction | NeoBankServiceCreateAutorampAction | NeoBankServiceGetCustomerByExternalIdAction | NeoBankServiceGetWalletRegistrationStatusAction | NeoBankServiceRegisterSelfHostedWalletAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | KeyringControllerSignPersonalMessageAction + | KycControllerGetSessionStatusForVendorAction + | KycControllerRefreshSessionStatusAction + | KycControllerHasCompletedVendorDisclaimersAction + | KycControllerHasCompletedSessionDisclaimersAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -1023,6 +1101,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'removeOrder', 'addAutoramp', 'createAutoramp', + 'hydrateVbaOnboarding', 'removeAutoramp', 'registerMoneyAccountWallet', 'markAutorampAsNotified', @@ -1178,6 +1257,8 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + #vbaOnboardingHydrationPromise: Promise | null = null; + /** * Semaphore that prevents sync feedback loops while applying remote order changes. */ @@ -3729,6 +3810,171 @@ export class RampsController extends BaseController< } } + /** + * Hydrates the Mobile-routable VBA onboarding stage from KYC state and + * completes wallet and autoramp setup after KYC acceptance. + * + * Overlapping calls share one run so polling cannot trigger duplicate wallet + * signatures or autoramp creation. + * + * @param params - VBA onboarding parameters. + * @param params.walletAddress - Monad Money Account wallet address. + * @returns The hydrated onboarding stage. + */ + async hydrateVbaOnboarding({ + walletAddress, + }: { + walletAddress: string; + }): Promise { + if (this.#vbaOnboardingHydrationPromise) { + return await this.#vbaOnboardingHydrationPromise; + } + + const hydrationPromise = this.#hydrateVbaOnboarding(walletAddress); + this.#vbaOnboardingHydrationPromise = hydrationPromise; + + try { + return await hydrationPromise; + } finally { + if (this.#vbaOnboardingHydrationPromise === hydrationPromise) { + this.#vbaOnboardingHydrationPromise = null; + } + } + } + + async #hydrateVbaOnboarding( + walletAddress: string, + ): Promise { + // Fetch the customer's latest session from the vendor account so each stage + // reflects backend truth (e.g. re-verification required after a new + // document) rather than only device-local state. A `null` session means no + // customer/session exists yet, so onboarding starts at the email step. + // Prefer the in-memory/persisted session status over the backend + // latest-status endpoint: after SumSub the backend endpoint lags (it still + // reports kycStatus 'new' right after an 'approved' applicant result), while + // the controller state reflects the journey/SDK outcome. Fall back to a + // backend fetch only when the controller has no session in state (e.g. a + // reinstall/cleared state resuming an existing customer, or a brand-new user + // with no session at all). + let session: KycControllerSessionStatus | null = null; + try { + session = this.messenger.call('KycController:refreshSessionStatus'); + } catch { + try { + session = await this.messenger.call( + 'KycController:getSessionStatusForVendor', + 'iron', + ); + } catch { + // No session exists for this customer yet: the backend returns 404 + // ("KYC session not found"), which surfaces as a rejection here. Treat + // it as "start onboarding at the email step" rather than an error. + session = null; + } + } + if (!session) { + return this.#setVbaOnboardingStage(VbaOnboardingStage.EmailOtpRequired); + } + + if ( + !(await this.messenger.call( + 'KycController:hasCompletedVendorDisclaimers', + )) + ) { + return this.#setVbaOnboardingStage( + VbaOnboardingStage.VendorTermsRequired, + ); + } + + if ( + !(await this.messenger.call( + 'KycController:hasCompletedSessionDisclaimers', + )) + ) { + return this.#setVbaOnboardingStage( + VbaOnboardingStage.ProviderTermsRequired, + ); + } + + // Status fields draw from the KYC vocabulary (new | pending | approved | + // rejected | retry). `finalStatus` is the vendor's final decision, which + // stays `pending` until Iron finalizes. `kycStatus` is the SumSub applicant + // outcome (from the journey/SDK result): `new` before the applicant runs + // SumSub, moving to `approved`/`pending` once they submit while the vendor + // finalizes. So gate the SumSub screen on `kycStatus`, and only complete + // onboarding once `finalStatus` is the terminal `approved`. + const { finalStatus, kycStatus } = session; + + if (finalStatus === 'rejected' || kycStatus === 'rejected') { + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRejected); + } + if (finalStatus !== 'approved') { + if (kycStatus === 'new' || kycStatus === 'retry') { + // Applicant still has to run (or re-run) SumSub document verification. + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired); + } + // Submitted; vendor is finalizing → "verification in progress". + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); + } + if (!walletAddress.trim()) { + throw new Error('walletAddress is required after KYC acceptance.'); + } + + // KYC is approved; the remaining work activates the Money account (register + // the wallet + ensure an autoramp). Those calls hit the neobank backend and + // can fail transiently (e.g. an address-list lookup timeout). If they do, + // keep the user on the "verification in progress" screen so a refresh + // retries the activation, rather than dropping them onto the recoverable- + // error screen — the KYC decision itself already succeeded. + try { + const registration = await this.registerMoneyAccountWallet({ + address: walletAddress, + }); + if (registration.type === 'lookupUnavailable') { + throw registration.error; + } + + const remoteAutoramps = await this.messenger.call( + 'NeoBankService:getAutoramps', + ); + const remoteAutorampIds = new Set( + remoteAutoramps.map((autoramp) => autoramp.id), + ); + for (const autoramp of remoteAutoramps) { + this.#applyAutorampRemoteSnapshot(autoramp); + } + this.update((state) => { + state.autoramps = state.autoramps.filter((autoramp) => + remoteAutorampIds.has(autoramp.id), + ); + }); + + const normalizedWalletAddress = walletAddress.toLowerCase(); + const hasUsableAutoramp = this.state.autoramps.some( + (autoramp) => + autoramp.walletAddress.toLowerCase() === normalizedWalletAddress && + autoramp.status !== AutorampStatus.Rejected && + autoramp.status !== AutorampStatus.Cancelled, + ); + if (!hasUsableAutoramp) { + await this.createAutoramp({}); + } + } catch { + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); + } + + return this.#setVbaOnboardingStage(VbaOnboardingStage.Completed); + } + + #setVbaOnboardingStage(stage: VbaOnboardingStage): VbaOnboardingStage { + if (this.state.vbaOnboardingStage !== stage) { + this.update((state) => { + state.vbaOnboardingStage = stage; + }); + } + return stage; + } + /** * Removes a local autoramp last-seen cursor by id. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index e1756d75f52..d73b3faf99b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -37,6 +37,7 @@ export type { RampsControllerRemoveOrderAction, RampsControllerAddAutorampAction, RampsControllerCreateAutorampAction, + RampsControllerHydrateVbaOnboardingAction, RampsControllerRemoveAutorampAction, RampsControllerRegisterMoneyAccountWalletAction, RampsControllerMarkAutorampAsNotifiedAction, @@ -79,6 +80,7 @@ export type { } from './RampsController-method-action-types.js'; export { RampsController, + VbaOnboardingStage, getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, @@ -301,6 +303,7 @@ export type { } from './NeoBankService.js'; export type { NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampsAction, NeoBankServiceRegisterPixAddressAction, NeoBankServiceGetAutorampQuoteAction, NeoBankServiceCreateAutorampAction,