From 179f7c8f1c5905e03d38cd70a584656428c68121 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 19:40:48 -0600 Subject: [PATCH 01/12] feat: add VBA onboarding hydration Co-authored-by: Cursor --- .../RampsController-method-action-types.ts | 17 + .../src/RampsController.test.ts | 389 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 191 +++++++++ packages/ramps-controller/src/index.ts | 2 + 4 files changed, 599 insertions(+) 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..7cf124b44dc 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,388 @@ describe('RampsController', () => { }); }); + describe('hydrateVbaOnboarding', () => { + type KycHandlers = { + isCustomerCreated: jest.Mock; + hasCompletedVendorTerms: jest.Mock; + hasCompletedProviderTerms: jest.Mock; + getKycStatus: jest.Mock; + }; + + type KycValues = { + customerCreated: boolean; + vendorTermsCompleted: boolean; + providerTermsCompleted: boolean; + status: string; + }; + + const registerKycHandlers = ( + rootMessenger: RootMessenger, + overrides: Partial = {}, + ): KycHandlers => { + const values = { + customerCreated: true, + vendorTermsCompleted: true, + providerTermsCompleted: true, + status: 'ACCEPTED', + ...overrides, + }; + const handlers = { + isCustomerCreated: jest.fn().mockReturnValue(values.customerCreated), + hasCompletedVendorTerms: jest + .fn() + .mockReturnValue(values.vendorTermsCompleted), + hasCompletedProviderTerms: jest + .fn() + .mockReturnValue(values.providerTermsCompleted), + getKycStatus: jest.fn().mockReturnValue(values.status), + }; + + rootMessenger.registerActionHandler( + 'KycController:isCustomerCreated' as never, + handlers.isCustomerCreated as never, + ); + rootMessenger.registerActionHandler( + 'KycController:hasCompletedVendorTerms' as never, + handlers.hasCompletedVendorTerms as never, + ); + rootMessenger.registerActionHandler( + 'KycController:hasCompletedProviderTerms' as never, + handlers.hasCompletedProviderTerms as never, + ); + rootMessenger.registerActionHandler( + 'KycController:getKycStatus' as never, + handlers.getKycStatus as never, + ); + + return handlers; + }; + + it.each([ + { + name: 'email OTP when the Iron customer does not exist', + overrides: { customerCreated: false }, + expected: VbaOnboardingStage.EmailOtpRequired, + expectedCalls: [[['iron']], [], [], []], + }, + { + name: 'vendor terms when Iron terms are incomplete', + overrides: { vendorTermsCompleted: false }, + expected: VbaOnboardingStage.VendorTermsRequired, + expectedCalls: [[['iron']], [['iron']], [], []], + }, + { + name: 'provider terms when SumSub terms are incomplete', + overrides: { providerTermsCompleted: false }, + expected: VbaOnboardingStage.ProviderTermsRequired, + expectedCalls: [[['iron']], [['iron']], [['sumsub']], []], + }, + { + name: 'the SumSub widget when KYC has not started', + overrides: { status: 'NOT_STARTED' }, + expected: VbaOnboardingStage.KycRequired, + expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], + }, + { + name: 'the SumSub widget when KYC needs more information', + overrides: { status: 'NEED_INFO' }, + expected: VbaOnboardingStage.KycRequired, + expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], + }, + { + name: 'pending KYC', + overrides: { status: 'PENDING' }, + expected: VbaOnboardingStage.KycPending, + expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], + }, + { + name: 'rejected KYC', + overrides: { status: 'REJECTED' }, + expected: VbaOnboardingStage.KycRejected, + expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], + }, + ])('routes to $name', async ({ overrides, expected, expectedCalls }) => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = 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(); + expect([ + handlers.isCustomerCreated.mock.calls, + handlers.hasCompletedVendorTerms.mock.calls, + handlers.hasCompletedProviderTerms.mock.calls, + handlers.getKycStatus.mock.calls, + ]).toStrictEqual(expectedCalls); + }); + }); + + 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('does not create another autoramp when a usable account exists for the wallet', async () => { + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'autoramp-1', + customerId: 'customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + 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(); + }, + ); + }); + + it('creates a new autoramp when the existing account is terminal', async () => { + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'autoramp-rejected', + customerId: 'customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Rejected, + lastSeenStatus: AutorampStatus.Rejected, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + 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('retains the previous stage when automatic setup fails', async () => { + await withController( + { + options: { + state: { + vbaOnboardingStage: VbaOnboardingStage.KycPending, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + const error = new Error('signing rejected'); + jest + .spyOn(controller, 'registerMoneyAccountWallet') + .mockRejectedValue(error); + + await expect( + controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).rejects.toBe(error); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }, + ); + }); + + it('treats an unavailable wallet lookup as a setup failure', async () => { + await withController( + { + options: { + state: { + vbaOnboardingStage: VbaOnboardingStage.KycPending, + }, + }, + }, + async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger); + const error = new WalletRegistrationError('lookupUnavailable', {}); + jest + .spyOn(controller, 'registerMoneyAccountWallet') + .mockResolvedValue({ + type: 'lookupUnavailable', + error, + }); + + await expect( + controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).rejects.toBe(error); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }, + ); + }); + + it('rejects an unsupported KYC status without changing the stage', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerKycHandlers(rootMessenger, { status: 'UNKNOWN' }); + + await expect( + controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).rejects.toThrow('Unsupported KYC status: UNKNOWN'); + expect(controller.state.vbaOnboardingStage).toBeNull(); + }); + }); + + 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..62d60eecfe1 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'; @@ -241,6 +242,10 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', + 'KycController:getKycStatus', + 'KycController:hasCompletedProviderTerms', + 'KycController:hasCompletedVendorTerms', + 'KycController:isCustomerCreated', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', 'UserStorageController:performGetStorageAllFeatureEntries', @@ -257,6 +262,45 @@ export type KeyringControllerSignPersonalMessageAction = { handler: (messageParams: { data: string; from: string }) => Promise; }; +const KycVendor = { + Iron: 'iron', +} as const; +type KycVendor = (typeof KycVendor)[keyof typeof KycVendor]; + +const KycProvider = { + sumsub: 'sumsub', +} as const; +type KycProvider = (typeof KycProvider)[keyof typeof KycProvider]; + +const KycStatus = { + NOT_STARTED: 'NOT_STARTED', + PENDING: 'PENDING', + NEED_INFO: 'NEED_INFO', + REJECTED: 'REJECTED', + ACCEPTED: 'ACCEPTED', +} as const; +type KycStatus = (typeof KycStatus)[keyof typeof KycStatus]; + +type KycControllerIsCustomerCreatedAction = { + type: 'KycController:isCustomerCreated'; + handler: (vendor: KycVendor) => boolean; +}; + +type KycControllerHasCompletedVendorTermsAction = { + type: 'KycController:hasCompletedVendorTerms'; + handler: (vendor: KycVendor) => boolean; +}; + +type KycControllerHasCompletedProviderTermsAction = { + type: 'KycController:hasCompletedProviderTerms'; + handler: (provider: KycProvider) => boolean; +}; + +type KycControllerGetKycStatusAction = { + type: 'KycController:getKycStatus'; + handler: (vendor: KycVendor) => KycStatus; +}; + /** * Outcome of {@link RampsController.registerMoneyAccountWallet}. * @@ -283,6 +327,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 +608,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 +678,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + vbaOnboardingStage: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, } satisfies StateMetadata; /** @@ -679,6 +746,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { orders: [], autoramps: [], providerAutoSelected: false, + vbaOnboardingStage: null, }; } @@ -810,6 +878,10 @@ type AllowedActions = | NeoBankServiceRegisterSelfHostedWalletAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | KeyringControllerSignPersonalMessageAction + | KycControllerIsCustomerCreatedAction + | KycControllerHasCompletedVendorTermsAction + | KycControllerHasCompletedProviderTermsAction + | KycControllerGetKycStatusAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -1023,6 +1095,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'removeOrder', 'addAutoramp', 'createAutoramp', + 'hydrateVbaOnboarding', 'removeAutoramp', 'registerMoneyAccountWallet', 'markAutorampAsNotified', @@ -1178,6 +1251,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 +3804,122 @@ 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 { + if ( + !this.messenger.call('KycController:isCustomerCreated', KycVendor.Iron) + ) { + return this.#setVbaOnboardingStage(VbaOnboardingStage.EmailOtpRequired); + } + + if ( + !this.messenger.call( + 'KycController:hasCompletedVendorTerms', + KycVendor.Iron, + ) + ) { + return this.#setVbaOnboardingStage( + VbaOnboardingStage.VendorTermsRequired, + ); + } + + if ( + !this.messenger.call( + 'KycController:hasCompletedProviderTerms', + KycProvider.sumsub, + ) + ) { + return this.#setVbaOnboardingStage( + VbaOnboardingStage.ProviderTermsRequired, + ); + } + + const kycStatus = this.messenger.call( + 'KycController:getKycStatus', + KycVendor.Iron, + ); + if ( + kycStatus === KycStatus.NOT_STARTED || + kycStatus === KycStatus.NEED_INFO + ) { + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired); + } + if (kycStatus === KycStatus.PENDING) { + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); + } + if (kycStatus === KycStatus.REJECTED) { + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRejected); + } + if (kycStatus !== KycStatus.ACCEPTED) { + throw new Error(`Unsupported KYC status: ${kycStatus as string}`); + } + if (!walletAddress.trim()) { + throw new Error('walletAddress is required after KYC acceptance.'); + } + + const registration = await this.registerMoneyAccountWallet({ + address: walletAddress, + }); + if (registration.type === 'lookupUnavailable') { + throw registration.error; + } + + 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({}); + } + + 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..0bde3835435 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, From f028185cc522624c8ca081df0031c76f4f7531c7 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 19:41:56 -0600 Subject: [PATCH 02/12] docs: document VBA onboarding hydration Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index dcf3548fc84..e637a235e6b 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `RampsController:hydrateVbaOnboarding`, the persisted `vbaOnboardingStage` state, and the `VbaOnboardingStage` enum for Mobile routing ([#10278](https://github.com/MetaMask/core/pull/10278)) + - Route users to email OTP, vendor terms, provider terms, SumSub, pending KYC, rejected KYC, or their completed account using KYC-owned status getters. + - After KYC acceptance, automatically register the Money Account wallet and create an autoramp when needed. + - Coalesce overlapping hydration calls to prevent duplicate wallet signatures or autoramp creation during polling. + +### Changed + +- **BREAKING:** `RampsControllerMessenger` now requires the `KycController:isCustomerCreated`, `KycController:hasCompletedVendorTerms`, `KycController:hasCompletedProviderTerms`, and `KycController:getKycStatus` actions to hydrate VBA onboarding ([#10278](https://github.com/MetaMask/core/pull/10278)) + ## [23.0.0] ### Added From ca442a35214326aa002c0e648ec17659a7ac79b5 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 20:03:18 -0600 Subject: [PATCH 03/12] feat: load autoramps during VBA hydration Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 3 +- .../src/NeoBankService-method-action-types.ts | 11 + .../src/NeoBankService.test.ts | 60 +++++ .../ramps-controller/src/NeoBankService.ts | 18 ++ .../src/RampsController.test.ts | 206 ++++++++++-------- .../ramps-controller/src/RampsController.ts | 18 ++ packages/ramps-controller/src/index.ts | 1 + 7 files changed, 231 insertions(+), 86 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index e637a235e6b..0c64bfe153a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) - Route users to email OTP, vendor terms, provider terms, SumSub, pending KYC, rejected KYC, or their completed account using KYC-owned status getters. - - After KYC acceptance, automatically register the Money Account wallet and create an autoramp when needed. + - After KYC acceptance, automatically register the Money Account wallet, load the customer's authoritative autoramps, and create one only when needed. - Coalesce overlapping hydration calls to prevent duplicate wallet signatures or autoramp creation during polling. ### Changed 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.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 7cf124b44dc..4336dc4ee41 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10192,6 +10192,7 @@ describe('RampsController', () => { hasCompletedVendorTerms: jest.Mock; hasCompletedProviderTerms: jest.Mock; getKycStatus: jest.Mock; + getAutoramps: jest.Mock; }; type KycValues = { @@ -10221,6 +10222,7 @@ describe('RampsController', () => { .fn() .mockReturnValue(values.providerTermsCompleted), getKycStatus: jest.fn().mockReturnValue(values.status), + getAutoramps: jest.fn().mockResolvedValue([]), }; rootMessenger.registerActionHandler( @@ -10239,6 +10241,10 @@ describe('RampsController', () => { 'KycController:getKycStatus' as never, handlers.getKycStatus as never, ); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramps' as never, + handlers.getAutoramps as never, + ); return handlers; }; @@ -10346,100 +10352,92 @@ describe('RampsController', () => { }); }); - it('does not create another autoramp when a usable account exists for the wallet', async () => { - await withController( - { - options: { - state: { - autoramps: [ - { - id: 'autoramp-1', - customerId: 'customer-1', - walletAddress: '0xAbC', - status: AutorampStatus.Approved, - lastSeenStatus: AutorampStatus.Approved, - updatedAt: 1, - }, - ], - }, + 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, }, - }, - async ({ controller, rootMessenger }) => { - registerKycHandlers(rootMessenger); - 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'); + ]); + 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( + await controller.hydrateVbaOnboarding({ + walletAddress: '0xabc', + }), + ).toBe(VbaOnboardingStage.Completed); - expect(createAutoramp).not.toHaveBeenCalled(); - }, - ); + 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( - { - options: { - state: { - autoramps: [ - { - id: 'autoramp-rejected', - customerId: 'customer-1', - walletAddress: '0xabc', - status: AutorampStatus.Rejected, - lastSeenStatus: AutorampStatus.Rejected, - updatedAt: 1, - }, - ], - }, + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerKycHandlers(rootMessenger); + handlers.getAutoramps.mockResolvedValue([ + { + id: 'autoramp-rejected', + customerId: 'customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Rejected, }, - }, - async ({ controller, rootMessenger }) => { - registerKycHandlers(rootMessenger); - 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', - }), - ); + ]); + 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', - }); + await controller.hydrateVbaOnboarding({ + walletAddress: '0xabc', + }); - expect(createAutoramp).toHaveBeenCalledWith({}); - }, - ); + expect(createAutoramp).toHaveBeenCalledWith({}); + }); }); it('coalesces overlapping hydration calls', async () => { @@ -10545,6 +10543,44 @@ describe('RampsController', () => { ); }); + it('retains the previous stage when loading autoramps fails', async () => { + await withController( + { + options: { + state: { + vbaOnboardingStage: VbaOnboardingStage.KycPending, + }, + }, + }, + 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'); + + await expect( + controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).rejects.toBe(error); + expect(createAutoramp).not.toHaveBeenCalled(); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }, + ); + }); + it('rejects an unsupported KYC status without changing the stage', async () => { await withController(async ({ controller, rootMessenger }) => { registerKycHandlers(rootMessenger, { status: 'UNKNOWN' }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 62d60eecfe1..e7e7cf9a439 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -35,6 +35,7 @@ import { import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampsAction, NeoBankServiceGetCustomerByExternalIdAction, NeoBankServiceGetWalletRegistrationStatusAction, NeoBankServiceRegisterSelfHostedWalletAction, @@ -219,6 +220,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', + 'NeoBankService:getAutoramps', 'NeoBankService:createAutoramp', 'NeoBankService:getCustomerByExternalId', 'NeoBankService:getWalletRegistrationStatus', @@ -872,6 +874,7 @@ type AllowedActions = | TransakServiceCancelAllActiveOrdersAction | TransakServiceGetActiveOrdersAction | NeoBankServiceGetAutorampAction + | NeoBankServiceGetAutorampsAction | NeoBankServiceCreateAutorampAction | NeoBankServiceGetCustomerByExternalIdAction | NeoBankServiceGetWalletRegistrationStatusAction @@ -3897,6 +3900,21 @@ export class RampsController extends BaseController< 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) => diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 0bde3835435..d73b3faf99b 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -303,6 +303,7 @@ export type { } from './NeoBankService.js'; export type { NeoBankServiceGetAutorampAction, + NeoBankServiceGetAutorampsAction, NeoBankServiceRegisterPixAddressAction, NeoBankServiceGetAutorampQuoteAction, NeoBankServiceCreateAutorampAction, From 94c53959547581cacc1bc5fd39838b2c4873aef8 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 20:20:01 -0600 Subject: [PATCH 04/12] refactor(ramps-controller): import VBA KYC contracts from kyc-controller Drop local KycVendor/KycProvider/KycStatus stubs and action types in favor of the package exports introduced in #10279. Co-authored-by: Cursor --- README.md | 1 + packages/ramps-controller/CHANGELOG.md | 2 + packages/ramps-controller/package.json | 1 + .../ramps-controller/src/RampsController.ts | 50 ++++--------------- yarn.lock | 3 +- 5 files changed, 17 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 7b05731b953..fa630ea6b32 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,7 @@ linkStyle default opacity:0.5 profile_sync_controller --> utils; 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/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 0c64bfe153a..d766fedbc36 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `RampsControllerMessenger` now requires the `KycController:isCustomerCreated`, `KycController:hasCompletedVendorTerms`, `KycController:hasCompletedProviderTerms`, and `KycController:getKycStatus` actions to hydrate VBA onboarding ([#10278](https://github.com/MetaMask/core/pull/10278)) + - Imports `KycVendor`, `KycProvider`, `KycStatus`, and the matching action types from `@metamask/kyc-controller` (see [#10279](https://github.com/MetaMask/core/pull/10279)). +- Add `@metamask/kyc-controller` as a dependency ([#10278](https://github.com/MetaMask/core/pull/10278)) ## [23.0.0] diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index a5fe7551a86..507384bef26 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": "^0.3.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.1.1", "@metamask/remote-feature-flag-controller": "^7.0.0", diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e7e7cf9a439..1944659bdf4 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,6 +6,17 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; +import type { + KycControllerGetKycStatusAction, + KycControllerHasCompletedProviderTermsAction, + KycControllerHasCompletedVendorTermsAction, + KycControllerIsCustomerCreatedAction, +} from '@metamask/kyc-controller'; +import { + KycProvider, + KycStatus, + KycVendor, +} from '@metamask/kyc-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController, @@ -264,45 +275,6 @@ export type KeyringControllerSignPersonalMessageAction = { handler: (messageParams: { data: string; from: string }) => Promise; }; -const KycVendor = { - Iron: 'iron', -} as const; -type KycVendor = (typeof KycVendor)[keyof typeof KycVendor]; - -const KycProvider = { - sumsub: 'sumsub', -} as const; -type KycProvider = (typeof KycProvider)[keyof typeof KycProvider]; - -const KycStatus = { - NOT_STARTED: 'NOT_STARTED', - PENDING: 'PENDING', - NEED_INFO: 'NEED_INFO', - REJECTED: 'REJECTED', - ACCEPTED: 'ACCEPTED', -} as const; -type KycStatus = (typeof KycStatus)[keyof typeof KycStatus]; - -type KycControllerIsCustomerCreatedAction = { - type: 'KycController:isCustomerCreated'; - handler: (vendor: KycVendor) => boolean; -}; - -type KycControllerHasCompletedVendorTermsAction = { - type: 'KycController:hasCompletedVendorTerms'; - handler: (vendor: KycVendor) => boolean; -}; - -type KycControllerHasCompletedProviderTermsAction = { - type: 'KycController:hasCompletedProviderTerms'; - handler: (provider: KycProvider) => boolean; -}; - -type KycControllerGetKycStatusAction = { - type: 'KycController:getKycStatus'; - handler: (vendor: KycVendor) => KycStatus; -}; - /** * Outcome of {@link RampsController.registerMoneyAccountWallet}. * diff --git a/yarn.lock b/yarn.lock index 0f3e9cb12e2..2dd410fbabf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7645,7 +7645,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@npm:^0.3.0, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8709,6 +8709,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": "npm:^0.3.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^32.1.1" "@metamask/remote-feature-flag-controller": "npm:^7.0.0" From 8e15ef05c67f6b76e35096f9c170edae7150b932 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 17 Sep 2026 08:12:40 -0600 Subject: [PATCH 05/12] feat(ramps-controller): refresh VBA onboarding status from the account on hydrate Before reading the VBA stage gates, hydrateVbaOnboarding now calls KycController:refreshVbaOnboardingStatus so each stage reflects the vendor account (vendor-terms re-signing, KYC status) rather than only device-local state. The action is declared as a local structural messenger-action type so the ramps package does not require a kyc-controller release that already exports it (the KYC PR merges first). Co-Authored-By: Claude Opus 4.8 --- .../ramps-controller/src/RampsController.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 1944659bdf4..7e9b86ec17d 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -259,6 +259,7 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'KycController:hasCompletedProviderTerms', 'KycController:hasCompletedVendorTerms', 'KycController:isCustomerCreated', + 'KycController:refreshVbaOnboardingStatus', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', 'UserStorageController:performGetStorageAllFeatureEntries', @@ -275,6 +276,17 @@ export type KeyringControllerSignPersonalMessageAction = { handler: (messageParams: { data: string; from: string }) => Promise; }; +/** + * Structural type for the KYC controller's `refreshVbaOnboardingStatus` + * messenger action. Declared locally so the ramps package does not require a + * kyc-controller version that already exports it — the two changes land as + * separate PRs, with KYC merging first. + */ +export type KycControllerRefreshVbaOnboardingStatusAction = { + type: 'KycController:refreshVbaOnboardingStatus'; + handler: () => Promise; +}; + /** * Outcome of {@link RampsController.registerMoneyAccountWallet}. * @@ -857,6 +869,7 @@ type AllowedActions = | KycControllerHasCompletedVendorTermsAction | KycControllerHasCompletedProviderTermsAction | KycControllerGetKycStatusAction + | KycControllerRefreshVbaOnboardingStatusAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -3814,6 +3827,12 @@ export class RampsController extends BaseController< async #hydrateVbaOnboarding( walletAddress: string, ): Promise { + // Pull the customer's up-to-date terms + KYC status from the vendor account + // before reading the stage gates, so each stage reflects what the account + // holds (e.g. re-signing required after a new document) rather than only + // device-local state. + await this.messenger.call('KycController:refreshVbaOnboardingStatus'); + if ( !this.messenger.call('KycController:isCustomerCreated', KycVendor.Iron) ) { From 117861f40b3bcd8d8cce9845f88f31698c6201b4 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Sat, 19 Sep 2026 03:33:49 -0600 Subject: [PATCH 06/12] refactor(ramps-controller): hydrate VBA onboarding via kyc-controller 0.4.0 session API --- .../ramps-controller/src/RampsController.ts | 112 +++++++++--------- 1 file changed, 55 insertions(+), 57 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 7e9b86ec17d..7790286bfcd 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,17 +6,7 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; -import type { - KycControllerGetKycStatusAction, - KycControllerHasCompletedProviderTermsAction, - KycControllerHasCompletedVendorTermsAction, - KycControllerIsCustomerCreatedAction, -} from '@metamask/kyc-controller'; -import { - KycProvider, - KycStatus, - KycVendor, -} from '@metamask/kyc-controller'; +import type { KycVendor } from '@metamask/kyc-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController, @@ -255,11 +245,9 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', - 'KycController:getKycStatus', - 'KycController:hasCompletedProviderTerms', - 'KycController:hasCompletedVendorTerms', - 'KycController:isCustomerCreated', - 'KycController:refreshVbaOnboardingStatus', + 'KycController:getSessionStatusForVendor', + 'KycController:hasCompletedVendorDisclaimers', + 'KycController:hasCompletedSessionDisclaimers', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', 'UserStorageController:performGetStorageAllFeatureEntries', @@ -277,14 +265,33 @@ export type KeyringControllerSignPersonalMessageAction = { }; /** - * Structural type for the KYC controller's `refreshVbaOnboardingStatus` - * messenger action. Declared locally so the ramps package does not require a - * kyc-controller version that already exports it — the two changes land as - * separate PRs, with KYC merging first. + * Minimal structural subset of the KYC controller's session status — only the + * status fields the VBA stage machine reads. + */ +type KycControllerSessionStatus = { + finalStatus: string; + kycStatus: 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 KycControllerRefreshVbaOnboardingStatusAction = { - type: 'KycController:refreshVbaOnboardingStatus'; - handler: () => Promise; +export type KycControllerGetSessionStatusForVendorAction = { + type: 'KycController:getSessionStatusForVendor'; + handler: (vendor: KycVendor) => Promise; +}; + +export type KycControllerHasCompletedVendorDisclaimersAction = { + type: 'KycController:hasCompletedVendorDisclaimers'; + handler: () => Promise; +}; + +export type KycControllerHasCompletedSessionDisclaimersAction = { + type: 'KycController:hasCompletedSessionDisclaimers'; + handler: () => Promise; }; /** @@ -865,11 +872,9 @@ type AllowedActions = | NeoBankServiceRegisterSelfHostedWalletAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | KeyringControllerSignPersonalMessageAction - | KycControllerIsCustomerCreatedAction - | KycControllerHasCompletedVendorTermsAction - | KycControllerHasCompletedProviderTermsAction - | KycControllerGetKycStatusAction - | KycControllerRefreshVbaOnboardingStatusAction + | KycControllerGetSessionStatusForVendorAction + | KycControllerHasCompletedVendorDisclaimersAction + | KycControllerHasCompletedSessionDisclaimersAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -3827,23 +3832,20 @@ export class RampsController extends BaseController< async #hydrateVbaOnboarding( walletAddress: string, ): Promise { - // Pull the customer's up-to-date terms + KYC status from the vendor account - // before reading the stage gates, so each stage reflects what the account - // holds (e.g. re-signing required after a new document) rather than only - // device-local state. - await this.messenger.call('KycController:refreshVbaOnboardingStatus'); - - if ( - !this.messenger.call('KycController:isCustomerCreated', KycVendor.Iron) - ) { + // 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. + const session = await this.messenger.call( + 'KycController:getSessionStatusForVendor', + 'iron', + ); + if (!session) { return this.#setVbaOnboardingStage(VbaOnboardingStage.EmailOtpRequired); } if ( - !this.messenger.call( - 'KycController:hasCompletedVendorTerms', - KycVendor.Iron, - ) + !(await this.messenger.call('KycController:hasCompletedVendorDisclaimers')) ) { return this.#setVbaOnboardingStage( VbaOnboardingStage.VendorTermsRequired, @@ -3851,34 +3853,30 @@ export class RampsController extends BaseController< } if ( - !this.messenger.call( - 'KycController:hasCompletedProviderTerms', - KycProvider.sumsub, - ) + !(await this.messenger.call( + 'KycController:hasCompletedSessionDisclaimers', + )) ) { return this.#setVbaOnboardingStage( VbaOnboardingStage.ProviderTermsRequired, ); } - const kycStatus = this.messenger.call( - 'KycController:getKycStatus', - KycVendor.Iron, - ); - if ( - kycStatus === KycStatus.NOT_STARTED || - kycStatus === KycStatus.NEED_INFO - ) { + // `finalStatus` draws from the KYC status vocabulary + // (new | pending | approved | rejected | retry). `new`/`retry` mean the + // applicant still has to run (or re-run) SumSub document verification. + const { finalStatus } = session; + if (finalStatus === 'new' || finalStatus === 'retry') { return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired); } - if (kycStatus === KycStatus.PENDING) { + if (finalStatus === 'pending') { return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); } - if (kycStatus === KycStatus.REJECTED) { + if (finalStatus === 'rejected') { return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRejected); } - if (kycStatus !== KycStatus.ACCEPTED) { - throw new Error(`Unsupported KYC status: ${kycStatus as string}`); + if (finalStatus !== 'approved') { + throw new Error(`Unsupported KYC status: ${finalStatus}`); } if (!walletAddress.trim()) { throw new Error('walletAddress is required after KYC acceptance.'); From 24656d61a4114f5094ecd8d7f5a3af63aa0e89c3 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Sat, 19 Sep 2026 07:34:33 -0600 Subject: [PATCH 07/12] fix(ramps-controller): gate VBA KYC stage on vendor/applicant status not finalStatus finalStatus is already 'pending' once the session + consents exist (pre-SumSub), so route to KycRequired until vendorStatus/kycStatus leaves 'new'/'retry'; only show KycPending once the applicant has actually submitted. Co-Authored-By: Claude Opus 4.8 --- .../ramps-controller/src/RampsController.ts | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 7790286bfcd..49ad2944e09 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -271,6 +271,7 @@ export type KeyringControllerSignPersonalMessageAction = { type KycControllerSessionStatus = { finalStatus: string; kycStatus: string; + vendorStatus: string; }; /** @@ -3862,21 +3863,28 @@ export class RampsController extends BaseController< ); } - // `finalStatus` draws from the KYC status vocabulary - // (new | pending | approved | rejected | retry). `new`/`retry` mean the - // applicant still has to run (or re-run) SumSub document verification. - const { finalStatus } = session; - if (finalStatus === 'new' || finalStatus === 'retry') { - return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired); - } - if (finalStatus === 'pending') { - return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); - } - if (finalStatus === 'rejected') { + // Status fields draw from the KYC vocabulary (new | pending | approved | + // rejected | retry). Right after the session is created and its consents + // are recorded — before the applicant runs SumSub — the backend already + // reports `finalStatus: 'pending'` while the applicant/vendor lifecycle is + // still `new`. So the "has the user actually submitted documents yet?" + // decision must read the vendor/applicant status, not `finalStatus` (which + // only distinguishes the terminal decision). `new`/`retry` on the vendor or + // applicant means SumSub still has to run (or re-run). + const { finalStatus, vendorStatus, kycStatus } = session; + const lifecycleStatuses = [finalStatus, vendorStatus, kycStatus]; + + if (lifecycleStatuses.includes('rejected')) { return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRejected); } - if (finalStatus !== 'approved') { - throw new Error(`Unsupported KYC status: ${finalStatus}`); + if (!lifecycleStatuses.includes('approved')) { + // Not a terminal decision yet: route to the SumSub launch screen until + // the applicant has submitted (vendor/applicant status leaves `new`); + // once submitted, show the "verification in progress" screen. + if (vendorStatus === 'pending' || kycStatus === 'pending') { + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); + } + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired); } if (!walletAddress.trim()) { throw new Error('walletAddress is required after KYC acceptance.'); From 0c50d9f4a77cb8596f6e67744510606b919d6816 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 21 Sep 2026 10:36:38 -0600 Subject: [PATCH 08/12] fix(ramps-controller): resilient VBA hydration (in-memory status, 404, registration) - Read session status from refreshSessionStatus (in-memory) so the SumSub outcome is seen immediately; the backend latest-status endpoint lags. - Gate KycRequired on kycStatus 'new'/'retry'; KycPending once submitted. - Treat a 404 'no session' from getSessionStatusForVendor as start-at-email. - Money-account registration failure after approval keeps the user on 'verification in progress' (retryable) instead of the error screen. Co-Authored-By: Claude Opus 4.8 --- .../ramps-controller/src/RampsController.ts | 133 +++++++++++------- 1 file changed, 83 insertions(+), 50 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 49ad2944e09..363f559873a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -246,6 +246,7 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:isSignedIn', 'KeyringController:signPersonalMessage', 'KycController:getSessionStatusForVendor', + 'KycController:refreshSessionStatus', 'KycController:hasCompletedVendorDisclaimers', 'KycController:hasCompletedSessionDisclaimers', 'RemoteFeatureFlagController:getState', @@ -285,6 +286,11 @@ export type KycControllerGetSessionStatusForVendorAction = { handler: (vendor: KycVendor) => Promise; }; +export type KycControllerRefreshSessionStatusAction = { + type: 'KycController:refreshSessionStatus'; + handler: () => KycControllerSessionStatus; +}; + export type KycControllerHasCompletedVendorDisclaimersAction = { type: 'KycController:hasCompletedVendorDisclaimers'; handler: () => Promise; @@ -874,6 +880,7 @@ type AllowedActions = | AuthenticationController.AuthenticationControllerGetSessionProfileAction | KeyringControllerSignPersonalMessageAction | KycControllerGetSessionStatusForVendorAction + | KycControllerRefreshSessionStatusAction | KycControllerHasCompletedVendorDisclaimersAction | KycControllerHasCompletedSessionDisclaimersAction | UserStorageController.UserStorageControllerGetStateAction @@ -3837,10 +3844,29 @@ export class RampsController extends BaseController< // 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. - const session = await this.messenger.call( - 'KycController:getSessionStatusForVendor', - 'iron', - ); + // 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); } @@ -3864,63 +3890,70 @@ export class RampsController extends BaseController< } // Status fields draw from the KYC vocabulary (new | pending | approved | - // rejected | retry). Right after the session is created and its consents - // are recorded — before the applicant runs SumSub — the backend already - // reports `finalStatus: 'pending'` while the applicant/vendor lifecycle is - // still `new`. So the "has the user actually submitted documents yet?" - // decision must read the vendor/applicant status, not `finalStatus` (which - // only distinguishes the terminal decision). `new`/`retry` on the vendor or - // applicant means SumSub still has to run (or re-run). - const { finalStatus, vendorStatus, kycStatus } = session; - const lifecycleStatuses = [finalStatus, vendorStatus, kycStatus]; - - if (lifecycleStatuses.includes('rejected')) { + // 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 (!lifecycleStatuses.includes('approved')) { - // Not a terminal decision yet: route to the SumSub launch screen until - // the applicant has submitted (vendor/applicant status leaves `new`); - // once submitted, show the "verification in progress" screen. - if (vendorStatus === 'pending' || kycStatus === 'pending') { - return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending); + if (finalStatus !== 'approved') { + if (kycStatus === 'new' || kycStatus === 'retry') { + // Applicant still has to run (or re-run) SumSub document verification. + return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired); } - 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.'); } - const registration = await this.registerMoneyAccountWallet({ - address: walletAddress, - }); - if (registration.type === 'lookupUnavailable') { - throw registration.error; - } + // 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 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({}); + 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); From 839bbdd7b61d111db41639bd972dcbdeedf62e75 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 21 Sep 2026 10:43:55 -0600 Subject: [PATCH 09/12] chore(ramps-controller): decouple from @metamask/kyc-controller; refresh changelog + lockfile Declare the KYC messenger action types and KycVendor structurally in the ramps package instead of importing from @metamask/kyc-controller, dropping the dependency (which was pinned to ^0.3.0 while the monorepo moved to 0.4.0 and broke the immutable-lockfile check). Update the changelog to the session-status based hydration API. Co-Authored-By: Claude Opus 4.8 --- packages/ramps-controller/CHANGELOG.md | 9 ++++----- packages/ramps-controller/package.json | 1 - packages/ramps-controller/src/RampsController.ts | 5 ++++- yarn.lock | 3 +-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index d766fedbc36..d0a6f52f534 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -11,15 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) - - Route users to email OTP, vendor terms, provider terms, SumSub, pending KYC, rejected KYC, or their completed account using KYC-owned status getters. - - After KYC acceptance, automatically register the Money Account wallet, load the customer's authoritative autoramps, and create one only when needed. + - 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:isCustomerCreated`, `KycController:hasCompletedVendorTerms`, `KycController:hasCompletedProviderTerms`, and `KycController:getKycStatus` actions to hydrate VBA onboarding ([#10278](https://github.com/MetaMask/core/pull/10278)) - - Imports `KycVendor`, `KycProvider`, `KycStatus`, and the matching action types from `@metamask/kyc-controller` (see [#10279](https://github.com/MetaMask/core/pull/10279)). -- Add `@metamask/kyc-controller` as a dependency ([#10278](https://github.com/MetaMask/core/pull/10278)) +- **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] diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 507384bef26..a5fe7551a86 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -53,7 +53,6 @@ "dependencies": { "@metamask/base-controller": "^10.0.0", "@metamask/controller-utils": "^13.0.0", - "@metamask/kyc-controller": "^0.3.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.1.1", "@metamask/remote-feature-flag-controller": "^7.0.0", diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 363f559873a..73cf9783be2 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,7 +6,6 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { BrokenCircuitError } from '@metamask/controller-utils'; -import type { KycVendor } from '@metamask/kyc-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController, @@ -269,6 +268,10 @@ export type KeyringControllerSignPersonalMessageAction = { * 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; diff --git a/yarn.lock b/yarn.lock index 2dd410fbabf..0f3e9cb12e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7645,7 +7645,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@npm:^0.3.0, @metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8709,7 +8709,6 @@ __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": "npm:^0.3.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^32.1.1" "@metamask/remote-feature-flag-controller": "npm:^7.0.0" From 44a2a0de2cfbfc4a70ba715a6bc973d59d22359d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 21 Sep 2026 10:46:55 -0600 Subject: [PATCH 10/12] chore(ramps-controller): fix jsdoc lint on local KycVendor type Co-Authored-By: Claude Opus 4.8 --- packages/ramps-controller/src/RampsController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 73cf9783be2..6f5487c72e8 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -268,8 +268,10 @@ export type KeyringControllerSignPersonalMessageAction = { * 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`. */ +/** + * 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 = { From b84dac1ee089953593b7db4f59d264c7b461bc12 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 21 Sep 2026 10:52:09 -0600 Subject: [PATCH 11/12] chore(ramps-controller): prettier format + regenerate README after dep removal Co-Authored-By: Claude Opus 4.8 --- README.md | 1 - packages/ramps-controller/src/RampsController.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fa630ea6b32..7b05731b953 100644 --- a/README.md +++ b/README.md @@ -624,7 +624,6 @@ linkStyle default opacity:0.5 profile_sync_controller --> utils; 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.ts b/packages/ramps-controller/src/RampsController.ts index 6f5487c72e8..d1047401e8c 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -3877,7 +3877,9 @@ export class RampsController extends BaseController< } if ( - !(await this.messenger.call('KycController:hasCompletedVendorDisclaimers')) + !(await this.messenger.call( + 'KycController:hasCompletedVendorDisclaimers', + )) ) { return this.#setVbaOnboardingStage( VbaOnboardingStage.VendorTermsRequired, From 6e0eb4b97fe3f4a51084a82b795638ef4125dfe9 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Mon, 21 Sep 2026 10:56:58 -0600 Subject: [PATCH 12/12] test(ramps-controller): update VBA hydration tests to the session-status API Co-Authored-By: Claude Opus 4.8 --- .../src/RampsController.test.ts | 341 ++++++++++-------- 1 file changed, 192 insertions(+), 149 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4336dc4ee41..0118ad73517 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10187,59 +10187,102 @@ describe('RampsController', () => { }); describe('hydrateVbaOnboarding', () => { + type KycSession = { + id: string; + finalStatus: string; + kycStatus: string; + vendorStatus: string; + }; + type KycHandlers = { - isCustomerCreated: jest.Mock; - hasCompletedVendorTerms: jest.Mock; - hasCompletedProviderTerms: jest.Mock; - getKycStatus: jest.Mock; + getSessionStatusForVendor: jest.Mock; + refreshSessionStatus: jest.Mock; + hasCompletedVendorDisclaimers: jest.Mock; + hasCompletedSessionDisclaimers: jest.Mock; getAutoramps: jest.Mock; }; type KycValues = { - customerCreated: boolean; - vendorTermsCompleted: boolean; - providerTermsCompleted: boolean; - status: string; + /** + * 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 = { - customerCreated: true, - vendorTermsCompleted: true, - providerTermsCompleted: true, - status: 'ACCEPTED', + const values: KycValues = { + session: approvedSession, + refreshThrows: false, + getSessionRejects: false, + vendorDisclaimersCompleted: true, + sessionDisclaimersCompleted: true, ...overrides, }; - const handlers = { - isCustomerCreated: jest.fn().mockReturnValue(values.customerCreated), - hasCompletedVendorTerms: jest + + 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() - .mockReturnValue(values.vendorTermsCompleted), - hasCompletedProviderTerms: jest + .mockResolvedValue(values.vendorDisclaimersCompleted), + hasCompletedSessionDisclaimers: jest .fn() - .mockReturnValue(values.providerTermsCompleted), - getKycStatus: jest.fn().mockReturnValue(values.status), + .mockResolvedValue(values.sessionDisclaimersCompleted), getAutoramps: jest.fn().mockResolvedValue([]), }; rootMessenger.registerActionHandler( - 'KycController:isCustomerCreated' as never, - handlers.isCustomerCreated as never, + 'KycController:getSessionStatusForVendor' as never, + handlers.getSessionStatusForVendor as never, ); rootMessenger.registerActionHandler( - 'KycController:hasCompletedVendorTerms' as never, - handlers.hasCompletedVendorTerms as never, + 'KycController:refreshSessionStatus' as never, + handlers.refreshSessionStatus as never, ); rootMessenger.registerActionHandler( - 'KycController:hasCompletedProviderTerms' as never, - handlers.hasCompletedProviderTerms as never, + 'KycController:hasCompletedVendorDisclaimers' as never, + handlers.hasCompletedVendorDisclaimers as never, ); rootMessenger.registerActionHandler( - 'KycController:getKycStatus' as never, - handlers.getKycStatus as never, + 'KycController:hasCompletedSessionDisclaimers' as never, + handlers.hasCompletedSessionDisclaimers as never, ); rootMessenger.registerActionHandler( 'NeoBankService:getAutoramps' as never, @@ -10249,52 +10292,69 @@ describe('RampsController', () => { return handlers; }; + const pendingSession = (kycStatus: string): KycSession => ({ + id: 'session-1', + finalStatus: 'pending', + kycStatus, + vendorStatus: 'pending', + }); + it.each([ { - name: 'email OTP when the Iron customer does not exist', - overrides: { customerCreated: false }, + 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, - expectedCalls: [[['iron']], [], [], []], }, { - name: 'vendor terms when Iron terms are incomplete', - overrides: { vendorTermsCompleted: false }, + name: 'vendor terms when Iron disclaimers are incomplete', + overrides: { vendorDisclaimersCompleted: false }, expected: VbaOnboardingStage.VendorTermsRequired, - expectedCalls: [[['iron']], [['iron']], [], []], }, { - name: 'provider terms when SumSub terms are incomplete', - overrides: { providerTermsCompleted: false }, + name: 'provider terms when SumSub session disclaimers are incomplete', + overrides: { sessionDisclaimersCompleted: false }, expected: VbaOnboardingStage.ProviderTermsRequired, - expectedCalls: [[['iron']], [['iron']], [['sumsub']], []], }, { name: 'the SumSub widget when KYC has not started', - overrides: { status: 'NOT_STARTED' }, + overrides: { session: pendingSession('new') }, expected: VbaOnboardingStage.KycRequired, - expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], }, { - name: 'the SumSub widget when KYC needs more information', - overrides: { status: 'NEED_INFO' }, + name: 'the SumSub widget when KYC needs a retry', + overrides: { session: pendingSession('retry') }, expected: VbaOnboardingStage.KycRequired, - expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], }, { - name: 'pending KYC', - overrides: { status: 'PENDING' }, + name: 'pending KYC while the vendor finalizes', + overrides: { session: pendingSession('pending') }, expected: VbaOnboardingStage.KycPending, - expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], }, { - name: 'rejected KYC', - overrides: { status: 'REJECTED' }, + name: 'rejected KYC when the vendor finalizes as rejected', + overrides: { session: pendingSession('rejected') }, expected: VbaOnboardingStage.KycRejected, - expectedCalls: [[['iron']], [['iron']], [['sumsub']], [['iron']]], }, - ])('routes to $name', async ({ overrides, expected, expectedCalls }) => { + { + 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 }) => { - const handlers = registerKycHandlers(rootMessenger, overrides); + registerKycHandlers(rootMessenger, overrides); const registerWallet = jest.spyOn( controller, 'registerMoneyAccountWallet', @@ -10308,12 +10368,37 @@ describe('RampsController', () => { expect(controller.state.vbaOnboardingStage).toBe(expected); expect(registerWallet).not.toHaveBeenCalled(); expect(createAutoramp).not.toHaveBeenCalled(); - expect([ - handlers.isCustomerCreated.mock.calls, - handlers.hasCompletedVendorTerms.mock.calls, - handlers.hasCompletedProviderTerms.mock.calls, - handlers.getKycStatus.mock.calls, - ]).toStrictEqual(expectedCalls); + }); + }); + + 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(); }); }); @@ -10488,107 +10573,65 @@ describe('RampsController', () => { }); }); - it('retains the previous stage when automatic setup fails', async () => { - await withController( - { - options: { - state: { - vbaOnboardingStage: VbaOnboardingStage.KycPending, - }, - }, - }, - async ({ controller, rootMessenger }) => { - registerKycHandlers(rootMessenger); - const error = new Error('signing rejected'); - jest - .spyOn(controller, 'registerMoneyAccountWallet') - .mockRejectedValue(error); - - await expect( - controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), - ).rejects.toBe(error); - expect(controller.state.vbaOnboardingStage).toBe( - VbaOnboardingStage.KycPending, - ); - }, - ); - }); - - it('treats an unavailable wallet lookup as a setup failure', async () => { - await withController( - { - options: { - state: { - vbaOnboardingStage: VbaOnboardingStage.KycPending, - }, - }, - }, - async ({ controller, rootMessenger }) => { - registerKycHandlers(rootMessenger); - const error = new WalletRegistrationError('lookupUnavailable', {}); - jest - .spyOn(controller, 'registerMoneyAccountWallet') - .mockResolvedValue({ - type: 'lookupUnavailable', - error, - }); + 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); - await expect( - controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), - ).rejects.toBe(error); - expect(controller.state.vbaOnboardingStage).toBe( - VbaOnboardingStage.KycPending, - ); - }, - ); + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycPending); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }); }); - it('retains the previous stage when loading autoramps fails', async () => { - await withController( - { - options: { - state: { - vbaOnboardingStage: VbaOnboardingStage.KycPending, - }, - }, - }, - 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'); + 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, + }); - await expect( - controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), - ).rejects.toBe(error); - expect(createAutoramp).not.toHaveBeenCalled(); - expect(controller.state.vbaOnboardingStage).toBe( - VbaOnboardingStage.KycPending, - ); - }, - ); + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycPending); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); + }); }); - it('rejects an unsupported KYC status without changing the stage', async () => { + it('falls back to pending KYC when loading autoramps fails', async () => { await withController(async ({ controller, rootMessenger }) => { - registerKycHandlers(rootMessenger, { status: 'UNKNOWN' }); + 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'); - await expect( - controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), - ).rejects.toThrow('Unsupported KYC status: UNKNOWN'); - expect(controller.state.vbaOnboardingStage).toBeNull(); + expect( + await controller.hydrateVbaOnboarding({ walletAddress: '0xabc' }), + ).toBe(VbaOnboardingStage.KycPending); + expect(createAutoramp).not.toHaveBeenCalled(); + expect(controller.state.vbaOnboardingStage).toBe( + VbaOnboardingStage.KycPending, + ); }); });