diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index d61b6aa2e9b..38d7a164c22 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `RampsController.hydrateNeobankStore` and persisted `state.neobank` with a `NeobankOnboardingStage` enum so Mobile can route Money Account onboarding after cold start or missed KYC events. The method refreshes `KycController` status, looks up wallet registration / autoramp readiness, and derives a single stage without navigating or auto-submitting wallet/autoramp creation. Hosts must also delegate `KycController:getState`, `KycController:getCustomerIdentity`, and `KycController:refreshKycStatus` (now listed in `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). Also exports `deriveNeobankOnboardingStage`, `getDefaultNeobankState`, and related helpers. ([#10116](https://github.com/MetaMask/core/pull/10116)) + - Add `NeoBankService` for MetaMask Ramp API neo-bank-proxy endpoints under the `/neobank` prefix on the Ramp API host, including messenger actions for `getAutoramp`, `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, `getCustomerByExternalId`, `getMoonpayCustomerId`, `getWalletRegistrationStatus`, and `registerSelfHostedWallet`. Mutating POSTs do not retry (to avoid duplicate Pix/autoramp creates without a stable `Idempotency-Key`); GETs still retry 429/5xx/network errors. Optional `Idempotency-Key` is forwarded when callers supply one. Also exports `mapNeoBankAutorampToRemoteSnapshot`, `AutorampRemoteSnapshot`, and wallet-registration HTTP types (`WalletRegistrationError`, `RegistrationStatus`, `RegistrationOutcome`). ([#10031](https://github.com/MetaMask/core/pull/10031)) - Add `RampsController` autoramp last-seen cursor and Money Account wallet registration: persisted `autoramps` state, `createAutoramp` / `refreshAutoramp(s)` / `applyAutorampStatusFromPush`, `registerMoneyAccountWallet`, and `RampsController:autorampStatusChanged`. MoonPay remains the source of truth; hosts should call `refreshAutoramps` on resume to catch webhooks missed while the app was closed. Hosts must delegate `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`, `RemoteFeatureFlagController:getState`) plus the NeoBank actions listed in `RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS`. ([#10032](https://github.com/MetaMask/core/pull/10032)) diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 65c885df3d5..b66beabc7b3 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -58,6 +58,7 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", + "@metamask/kyc-controller": "workspace:^", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/remote-feature-flag-controller": "^6.1.0" diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 8661f19c705..4fdfa4fd520 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -386,6 +386,25 @@ export type RampsControllerMarkAutorampAsNotifiedAction = { handler: RampsController['markAutorampAsNotified']; }; +/** + * Refreshes authoritative Money Account / NeoBank onboarding signals and + * writes a single Mobile-routable stage to `state.neobank`. + * + * Safe to call twice. Does **not** navigate and does **not** auto-submit + * wallet registration or autoramp creation (see TRAM-3924). Clients must not + * persist UKYC `sessionId` or re-register; this method is lookup-then-derive + * only. + * + * @param params - Hydration parameters. + * @param params.walletAddress - Money Account wallet address used for + * registration / autoramp checks after KYC is complete. + * @returns The derived {@link NeobankOnboardingStage}. + */ +export type RampsControllerHydrateNeobankStoreAction = { + type: `RampsController:hydrateNeobankStore`; + handler: RampsController['hydrateNeobankStore']; +}; + /** * Applies a remote autoramp snapshot from a websocket / webhook push. * @@ -837,6 +856,7 @@ export type RampsControllerMethodActions = | RampsControllerRegisterMoneyAccountWalletAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerHydrateNeobankStoreAction | RampsControllerApplyAutorampStatusFromPushAction | RampsControllerRefreshAutorampAction | RampsControllerRefreshAutorampsAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0bd71a83b39..9e8cc46ec44 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { NeobankOnboardingStage } from './neobank-onboarding.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -160,6 +161,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -237,6 +243,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2270,6 +2281,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2314,6 +2330,11 @@ describe('RampsController', () => { "isLoading": false, "selected": null, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -2351,6 +2372,11 @@ describe('RampsController', () => { ).toMatchInlineSnapshot(` { "autoramps": [], + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2399,6 +2425,11 @@ describe('RampsController', () => { }, }, }, + "neobank": { + "lastError": null, + "lastHydratedAt": null, + "stage": null, + }, "orders": [], "paymentMethods": { "data": [], @@ -9829,6 +9860,385 @@ describe('RampsController', () => { }); }); + describe('hydrateNeobankStore', () => { + const defaultKycState = { + phase: 'idle' as const, + statusMessage: '', + error: null, + email: null, + vendorDisclaimersAccepted: { moonpay: null, iron: null }, + providerDisclaimersAccepted: { sumsub: null }, + idosDisclaimersAccepted: null, + credentialReusabilityConsentGiven: null, + vendorDisclaimers: [], + vendorError: null, + sessionDisclaimers: null, + geoCountry: null, + moonpaySessionToken: null, + moonpayAccessToken: null, + moonpayCustomerId: null, + activeVendor: 'iron' as const, + activeProduct: 'money' as const, + kycRequiredByProduct: {}, + lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, + sumsub: { + status: 'idle' as const, + result: null, + sessionId: null, + applicantAccessToken: null, + sessionStatus: null, + }, + }; + + type HydrateHandlers = { + refreshKycStatus: jest.Mock; + getState: jest.Mock; + getCustomerIdentity: jest.Mock; + getSessionProfile: jest.Mock; + getCustomerByExternalId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + getAutoramp: jest.Mock; + }; + + /** + * Registers KYC + NeoBank handlers used by hydrateNeobankStore. + * + * @param rootMessenger - Root messenger for the controller under test. + * @param kycState - KYC controller state returned by getState. + * @returns Handler mocks for per-test overrides. + */ + function registerHydrateHandlers( + rootMessenger: RootMessenger, + kycState: typeof defaultKycState = defaultKycState, + ): HydrateHandlers { + const handlers: HydrateHandlers = { + refreshKycStatus: jest.fn().mockResolvedValue({ + status: kycState.userStatus ?? 'not-started', + sumsubSessionId: null, + errorCode: null, + }), + getState: jest.fn().mockReturnValue(kycState), + getCustomerIdentity: jest.fn().mockReturnValue(null), + getSessionProfile: jest.fn().mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + canonicalProfileId: 'canonical-1', + }), + getCustomerByExternalId: jest + .fn() + .mockResolvedValue({ id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + getAutoramp: jest.fn(), + }; + rootMessenger.registerActionHandler( + 'KycController:refreshKycStatus', + handlers.refreshKycStatus, + ); + rootMessenger.registerActionHandler( + 'KycController:getState', + handlers.getState, + ); + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + handlers.getCustomerIdentity, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + handlers.getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + handlers.getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + handlers.getAutoramp, + ); + return handlers; + } + + it('returns NoUser and persists neobank state when KYC has no identity', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerHydrateHandlers(rootMessenger); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.NoUser); + expect(controller.state.neobank.stage).toBe( + NeobankOnboardingStage.NoUser, + ); + expect(controller.state.neobank.lastHydratedAt).toEqual( + expect.any(String), + ); + expect(controller.state.neobank.lastError).toBeNull(); + }); + }); + + it('returns LookupFailed when KYC refresh fails with no local signal', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger); + handlers.refreshKycStatus.mockRejectedValue(new Error('network down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch(/network down/u); + }); + }); + + it('returns WalletNotSigned after completed KYC when registration is absent', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(stage).toBe(NeobankOnboardingStage.WalletNotSigned); + }); + }); + + it('returns AutorampCreated when wallet is registered and autoramp is Approved', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + handlers.getAutoramp.mockResolvedValue({ + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xAbC', + status: AutorampStatus.Approved, + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.AutorampCreated); + expect(controller.state.neobank.stage).toBe( + NeobankOnboardingStage.AutorampCreated, + ); + }, + ); + }); + + it('is idempotent when called twice with the same signals', async () => { + await withController(async ({ controller, rootMessenger }) => { + registerHydrateHandlers(rootMessenger); + + const first = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + const second = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(first).toBe(NeobankOnboardingStage.NoUser); + expect(second).toBe(NeobankOnboardingStage.NoUser); + }); + }); + + it('returns LookupFailed when customer resolution fails after KYC complete', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getSessionProfile.mockResolvedValue({ + identifierId: 'id-1', + profileId: '', + metaMetricsId: 'mm-1', + }); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.LookupFailed); + expect(controller.state.neobank.lastError).toMatch( + /Cannot resolve MoonPay customer id/u, + ); + }); + }); + + it('still derives autoramp stage when refreshAutoramps fails', async () => { + const completedKyc = { + ...defaultKycState, + phase: 'done' as const, + userStatus: 'completed' as const, + email: 'user@example.com', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + providerDisclaimersAccepted: { + sumsub: [{ key: 'sumsub', version: '1' }], + }, + idosDisclaimersAccepted: [{ key: 'idos', version: '1' }], + sumsub: { + ...defaultKycState.sumsub, + status: 'complete' as const, + }, + }; + + await withController( + { + options: { + state: { + autoramps: [ + { + id: 'ar-1', + customerId: 'iron-customer-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1, + }, + ], + }, + }, + }, + async ({ controller, rootMessenger }) => { + const handlers = registerHydrateHandlers(rootMessenger, completedKyc); + handlers.refreshKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }); + handlers.getAutoramp.mockRejectedValue(new Error('autoramp down')); + + const stage = await controller.hydrateNeobankStore({ + walletAddress: '0xabc', + }); + + expect(stage).toBe(NeobankOnboardingStage.AutorampPending); + }, + ); + }); + }); + describe('registerMoneyAccountWallet', () => { const registration = { id: 'wallet-1', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index c0bdc2edb30..5d9e19e32c8 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -5,6 +5,12 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { BrokenCircuitError } from '@metamask/controller-utils'; +import type { + KycControllerGetCustomerIdentityAction, + KycControllerGetStateAction, + KycControllerRefreshKycStatusAction, + KycControllerState, +} from '@metamask/kyc-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; @@ -34,6 +40,14 @@ import type { NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; +import type { NeobankState } from './neobank-onboarding.js'; +import { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; +import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, @@ -223,13 +237,46 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ * identity from Profile Sync, and `KeyringController:signPersonalMessage` signs * the EIP-191 ownership proof for Money Account self-hosted wallet * registration; both are only exercised by the autoramp paths. + * `KycController:*` actions power {@link RampsController.hydrateNeobankStore}. */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'AuthenticationController:getSessionProfile', 'KeyringController:signPersonalMessage', + 'KycController:getCustomerIdentity', + 'KycController:getState', + 'KycController:refreshKycStatus', 'RemoteFeatureFlagController:getState', ] as const; +/** + * Whether persisted vendor T&C1 acceptance exists for the active vendor. + * + * @param state - KYC controller state. + * @returns Whether vendor terms are accepted. + */ +function hasKycVendorTerms(state: KycControllerState): boolean { + if (state.activeVendor === 'moonpay') { + return Boolean(state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt); + } + if (state.activeVendor === 'iron') { + return Boolean(state.vendorDisclaimersAccepted.iron?.disclaimerIds.length); + } + return false; +} + +/** + * Whether SumSub + idOS T&C2 were accepted as a batch. + * + * @param state - KYC controller state. + * @returns Whether provider terms are accepted. + */ +function hasKycProviderTerms(state: KycControllerState): boolean { + return ( + state.providerDisclaimersAccepted.sumsub !== null && + state.idosDisclaimersAccepted !== null + ); +} + /** * Structural type for the keyring controller's `signPersonalMessage` messenger * action (EIP-191). Declared locally to avoid a package dependency for a single @@ -527,6 +574,13 @@ export type RampsControllerState = { * notifications after refresh or push. */ autoramps: AutorampAccount[]; + /** + * Money Account / NeoBank onboarding stage derived by + * {@link RampsController.hydrateNeobankStore}. Mobile reads `stage` to route + * after cold start or missed KYC events. Persist so a failed hydrate can still + * surface the last known stage. + */ + neobank: NeobankState; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -594,6 +648,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + neobank: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -661,6 +721,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, orders: [], autoramps: [], + neobank: getDefaultNeobankState(), providerAutoSelected: false, }; } @@ -792,7 +853,10 @@ type AllowedActions = | NeoBankServiceGetWalletRegistrationStatusAction | NeoBankServiceRegisterSelfHostedWalletAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction - | KeyringControllerSignPersonalMessageAction; + | KeyringControllerSignPersonalMessageAction + | KycControllerGetCustomerIdentityAction + | KycControllerGetStateAction + | KycControllerRefreshKycStatusAction; /** * Published when the state of {@link RampsController} changes. @@ -987,6 +1051,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'createAutoramp', 'removeAutoramp', 'registerMoneyAccountWallet', + 'hydrateNeobankStore', 'markAutorampAsNotified', 'applyAutorampStatusFromPush', 'refreshAutoramp', @@ -3248,6 +3313,130 @@ export class RampsController extends BaseController< }); } + /** + * Refreshes authoritative Money Account / NeoBank onboarding signals and + * writes a single Mobile-routable stage to `state.neobank`. + * + * Safe to call twice. Does **not** navigate and does **not** auto-submit + * wallet registration or autoramp creation (see TRAM-3924). Clients must not + * persist UKYC `sessionId` or re-register; this method is lookup-then-derive + * only. + * + * @param params - Hydration parameters. + * @param params.walletAddress - Money Account wallet address used for + * registration / autoramp checks after KYC is complete. + * @returns The derived {@link NeobankOnboardingStage}. + */ + async hydrateNeobankStore({ + walletAddress, + }: { + walletAddress: string; + }): Promise { + let refreshError: string | null = null; + try { + await this.messenger.call('KycController:refreshKycStatus'); + } catch (error) { + refreshError = String(error); + } + + const kycState = this.messenger.call('KycController:getState'); + const identity = this.messenger.call('KycController:getCustomerIdentity'); + const hasVendorTerms = hasKycVendorTerms(kycState); + const hasProviderTerms = hasKycProviderTerms(kycState); + const hasCustomerIdentity = Boolean( + identity?.id || kycState.email || kycState.userStatus !== null, + ); + + const kycInput: NeobankOnboardingDerivationInput['kyc'] = { + phase: kycState.phase, + userStatus: kycState.userStatus, + sumsubStatus: kycState.sumsub.status, + hasCustomerIdentity, + hasVendorTerms, + hasProviderTerms, + }; + + // A refresh failure with no local KYC signal is not "no user" — Mobile + // should retry rather than restart onboarding. + if ( + refreshError && + !hasVendorTerms && + !hasProviderTerms && + kycState.userStatus === null && + !hasCustomerIdentity + ) { + return this.#commitNeobankStage( + NeobankOnboardingStage.LookupFailed, + refreshError, + ); + } + + let wallet: NeobankOnboardingDerivationInput['wallet'] = { + status: 'skipped', + }; + let autoramp: NeobankOnboardingDerivationInput['autoramp'] = { + status: 'skipped', + }; + + if (kycState.userStatus === 'completed') { + try { + const customerId = await this.resolveAutorampCustomerId(); + const registration = await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address: walletAddress }, + ); + wallet = { status: 'resolved', registration }; + try { + await this.refreshAutoramps(); + } catch { + // Keep the local autoramp cursor; summarize whatever we have. + } + autoramp = summarizeAutorampsForWallet( + this.state.autoramps, + walletAddress, + ); + } catch (error) { + wallet = { + status: 'lookupUnavailable', + message: String(error), + }; + refreshError = refreshError ?? String(error); + } + } + + const stage = deriveNeobankOnboardingStage({ + kyc: kycInput, + wallet, + autoramp, + }); + return this.#commitNeobankStage( + stage, + stage === NeobankOnboardingStage.LookupFailed ? refreshError : null, + ); + } + + /** + * Writes the NeoBank onboarding stage into controller state. + * + * @param stage - Derived stage. + * @param lastError - Optional error message for lookup failures. + * @returns The same stage. + */ + #commitNeobankStage( + stage: NeobankOnboardingStage, + lastError: string | null, + ): NeobankOnboardingStage { + this.update((state) => { + state.neobank = { + stage, + lastHydratedAt: new Date().toISOString(), + lastError: + stage === NeobankOnboardingStage.LookupFailed ? lastError : null, + }; + }); + return stage; + } + /** * Applies a remote autoramp snapshot from a websocket / webhook push. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index b52fbe4eb8c..4b402176182 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -38,6 +38,7 @@ export type { RampsControllerCreateAutorampAction, RampsControllerRemoveAutorampAction, RampsControllerRegisterMoneyAccountWalletAction, + RampsControllerHydrateNeobankStoreAction, RampsControllerMarkAutorampAsNotifiedAction, RampsControllerApplyAutorampStatusFromPushAction, RampsControllerRefreshAutorampAction, @@ -82,6 +83,18 @@ export { RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, } from './RampsController.js'; +export type { + NeobankOnboardingDerivationInput, + NeobankState, +} from './neobank-onboarding.js'; +export { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + isAutorampCreatedStatus, + isAutorampPendingStatus, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; export type { RampsServiceActions, RampsServiceEvents, diff --git a/packages/ramps-controller/src/neobank-onboarding.test.ts b/packages/ramps-controller/src/neobank-onboarding.test.ts new file mode 100644 index 00000000000..36e76624f3a --- /dev/null +++ b/packages/ramps-controller/src/neobank-onboarding.test.ts @@ -0,0 +1,419 @@ +import { AutorampStatus } from './autorampAccount.js'; +import type { AutorampAccount } from './autorampAccount.js'; +import { + NeobankOnboardingStage, + deriveNeobankOnboardingStage, + getDefaultNeobankState, + summarizeAutorampsForWallet, +} from './neobank-onboarding.js'; +import type { NeobankOnboardingDerivationInput } from './neobank-onboarding.js'; + +/** + * Builds a derivation input with KYC complete and wallet/autoramp skipped by + * default so each test can override the branch under assertion. + * + * @param overrides - Partial input overrides. + * @returns A complete derivation input. + */ +function buildInput( + overrides: { + kyc?: Partial; + wallet?: NeobankOnboardingDerivationInput['wallet']; + autoramp?: NeobankOnboardingDerivationInput['autoramp']; + } = {}, +): NeobankOnboardingDerivationInput { + return { + kyc: { + phase: 'done', + userStatus: 'completed', + sumsubStatus: 'complete', + hasCustomerIdentity: true, + hasVendorTerms: true, + hasProviderTerms: true, + ...overrides.kyc, + }, + wallet: overrides.wallet ?? { status: 'skipped' }, + autoramp: overrides.autoramp ?? { status: 'skipped' }, + }; +} + +describe('getDefaultNeobankState', () => { + it('returns an empty neobank slice', () => { + expect(getDefaultNeobankState()).toStrictEqual({ + stage: null, + lastHydratedAt: null, + lastError: null, + }); + }); +}); + +describe('summarizeAutorampsForWallet', () => { + const base: AutorampAccount = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xAbC', + status: AutorampStatus.Created, + lastSeenStatus: AutorampStatus.Created, + updatedAt: 1, + }; + + it('returns none when no autoramp matches the wallet', () => { + expect(summarizeAutorampsForWallet([base], '0xdef')).toStrictEqual({ + status: 'none', + }); + }); + + it('returns created when any matching autoramp is Approved', () => { + expect( + summarizeAutorampsForWallet( + [ + { ...base, status: AutorampStatus.Authorized }, + { + ...base, + id: 'ar-2', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + }, + ], + '0xabc', + ), + ).toStrictEqual({ status: 'created' }); + }); + + it('returns pending when a matching autoramp is still in progress', () => { + expect( + summarizeAutorampsForWallet( + [{ ...base, status: AutorampStatus.DepositAccountAdded }], + '0xabc', + ), + ).toStrictEqual({ status: 'pending' }); + }); + + it('returns none when matching autoramps are only rejected or cancelled', () => { + expect( + summarizeAutorampsForWallet( + [ + { + ...base, + status: AutorampStatus.Rejected, + lastSeenStatus: AutorampStatus.Rejected, + }, + ], + '0xabc', + ), + ).toStrictEqual({ status: 'none' }); + }); +}); + +describe('deriveNeobankOnboardingStage', () => { + it('returns EmailOtpRequired while the MoonPay auth phase is active', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'auth', + userStatus: null, + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: false, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.EmailOtpRequired); + }); + + it('returns NoUser when there is no identity, no terms, and no status', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'idle', + userStatus: null, + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: false, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.NoUser); + }); + + it('returns VendorTermsRequired when vendor T&C1 is missing', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'terms', + userStatus: 'not-started', + hasVendorTerms: false, + hasProviderTerms: false, + hasCustomerIdentity: true, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.VendorTermsRequired); + }); + + it('returns ProviderTermsRequired when T&C2 batch is incomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + phase: 'terms', + userStatus: 'not-started', + hasVendorTerms: true, + hasProviderTerms: false, + hasCustomerIdentity: true, + sumsubStatus: 'idle', + }, + }), + ), + ).toBe(NeobankOnboardingStage.ProviderTermsRequired); + }); + + it('maps userStatus need-more-information to KycNeedsReview', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'need-more-information', sumsubStatus: 'failed' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycNeedsReview); + }); + + it('maps userStatus terminal-failure to KycRejected', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'terminal-failure', sumsubStatus: 'failed' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('maps userStatus pending to KycPending', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { userStatus: 'pending', sumsubStatus: 'polling' }, + }), + ), + ).toBe(NeobankOnboardingStage.KycPending); + }); + + it('maps in-progress SumSub to KycStartedIncomplete before completion', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'inProgress', + phase: 'submit', + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps idle not-started KYC to KycNotStarted', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'done', + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycNotStarted); + }); + + it('returns WalletNotSigned when KYC is complete and wallet is absent', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'resolved', registration: { type: 'absent' } }, + autoramp: { status: 'none' }, + }), + ), + ).toBe(NeobankOnboardingStage.WalletNotSigned); + }); + + it('returns LookupFailed when wallet lookup is unavailable', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'lookupUnavailable', message: 'down' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('returns AutorampNotCreated when the wallet is registered but no route exists', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'none' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampNotCreated); + }); + + it('returns AutorampPending when a standing route is not Approved', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'pending' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampPending); + }); + + it('returns AutorampCreated when a standing route is Approved', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'disabled', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: true, + isSelf: true, + }, + }, + }, + autoramp: { status: 'created' }, + }), + ), + ).toBe(NeobankOnboardingStage.AutorampCreated); + }); + + it('maps submit/session phases to KycStartedIncomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'submit', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'session', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycStartedIncomplete); + }); + + it('maps error phase to KycRejected when status is incomplete', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'idle', + phase: 'error', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('maps failed SumSub to KycRejected before completion', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + kyc: { + userStatus: 'not-started', + sumsubStatus: 'failed', + phase: 'done', + hasVendorTerms: true, + hasProviderTerms: true, + }, + }), + ), + ).toBe(NeobankOnboardingStage.KycRejected); + }); + + it('returns LookupFailed when KYC is complete but wallet was skipped', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { status: 'skipped' }, + autoramp: { status: 'skipped' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); + + it('returns LookupFailed when wallet is registered but autoramp was skipped', () => { + expect( + deriveNeobankOnboardingStage( + buildInput({ + wallet: { + status: 'resolved', + registration: { + type: 'active', + registration: { + id: 'w1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }, + }, + autoramp: { status: 'skipped' }, + }), + ), + ).toBe(NeobankOnboardingStage.LookupFailed); + }); +}); diff --git a/packages/ramps-controller/src/neobank-onboarding.ts b/packages/ramps-controller/src/neobank-onboarding.ts new file mode 100644 index 00000000000..a403585e04d --- /dev/null +++ b/packages/ramps-controller/src/neobank-onboarding.ts @@ -0,0 +1,265 @@ +/** + * Money Account / NeoBank onboarding stage derivation for Mobile routing. + * + * {@link RampsController.hydrateNeobankStore} refreshes authoritative KYC / + * wallet / autoramp signals, then uses {@link deriveNeobankOnboardingStage} to + * pick a single stage. This ticket returns state only — it never navigates and + * does not auto-submit wallet registration or autoramp creation (TRAM-3924). + */ + +import type { + KycPhase, + KycSumSubStatus, + KycUserStatus, +} from '@metamask/kyc-controller'; + +import { AutorampStatus } from './autorampAccount.js'; +import type { AutorampAccount } from './autorampAccount.js'; +import type { RegistrationStatus } from './wallet-registration-service.js'; + +/** + * Single Mobile-routable stage for NeoBank / Money Account onboarding. + * + * Ordered roughly by funnel position. Terms 1 = vendor disclaimers; Terms 2 = + * SumSub + idOS provider disclaimers (submitted as a batch — if either is + * missing after a partial accept, Mobile routes back to Terms 2). + */ +export enum NeobankOnboardingStage { + /** No vendor customer / identity yet. */ + NoUser = 'NoUser', + /** MoonPay Auth frame: email OTP required. */ + EmailOtpRequired = 'EmailOtpRequired', + /** Vendor T&C (Terms 1) not accepted. */ + VendorTermsRequired = 'VendorTermsRequired', + /** SumSub + idOS T&C (Terms 2) not accepted as a batch. */ + ProviderTermsRequired = 'ProviderTermsRequired', + /** KYC not started. */ + KycNotStarted = 'KycNotStarted', + /** SumSub / document flow started but not finished. */ + KycStartedIncomplete = 'KycStartedIncomplete', + /** Terminal KYC failure / rejection. */ + KycRejected = 'KycRejected', + /** Needs review / more information (EDD). */ + KycNeedsReview = 'KycNeedsReview', + /** KYC submitted; vendor still deciding. */ + KycPending = 'KycPending', + /** Money Account wallet ownership proof not registered. */ + WalletNotSigned = 'WalletNotSigned', + /** No autoramp standing route yet. */ + AutorampNotCreated = 'AutorampNotCreated', + /** Autoramp exists but is not yet Approved. */ + AutorampPending = 'AutorampPending', + /** Autoramp Approved (account ready). */ + AutorampCreated = 'AutorampCreated', + /** Authoritative lookup failed; Mobile should show a retryable error. */ + LookupFailed = 'LookupFailed', +} + +/** + * Persisted / UI-facing NeoBank slice on {@link RampsController} state. + */ +export type NeobankState = { + /** Latest derived onboarding stage. */ + stage: NeobankOnboardingStage | null; + /** ISO-8601 timestamp of the last successful hydrate, or `null`. */ + lastHydratedAt: string | null; + /** Last hydrate error message when stage is {@link NeobankOnboardingStage.LookupFailed}. */ + lastError: string | null; +}; + +/** + * Inputs for {@link deriveNeobankOnboardingStage}. Built by + * {@link RampsController.hydrateNeobankStore} after refreshing remotes. + */ +export type NeobankOnboardingDerivationInput = { + kyc: { + phase: KycPhase; + userStatus: KycUserStatus | null; + sumsubStatus: KycSumSubStatus; + hasCustomerIdentity: boolean; + hasVendorTerms: boolean; + /** + * True only when both SumSub provider disclaimers and idOS disclaimers + * were accepted together (batch T&C2). + */ + hasProviderTerms: boolean; + }; + /** + * Wallet registration lookup for the Money Account address. + * `skipped` when KYC is not complete yet. + */ + wallet: + | { status: 'skipped' } + | { status: 'lookupUnavailable'; message?: string } + | { status: 'resolved'; registration: RegistrationStatus }; + /** + * Autoramp readiness for the Money Account address. + * `skipped` when KYC or wallet registration is incomplete. + */ + autoramp: + | { status: 'skipped' } + | { status: 'none' } + | { status: 'pending' } + | { status: 'created' }; +}; + +/** + * Default NeoBank slice for {@link RampsController} state. + * + * @returns Empty neobank state. + */ +export function getDefaultNeobankState(): NeobankState { + return { + stage: null, + lastHydratedAt: null, + lastError: null, + }; +} + +/** + * Whether a SumSub sub-flow status means the applicant started documents but + * has not reached a terminal outcome yet. + * + * @param status - SumSub sub-flow status. + * @returns Whether the flow is in progress. + */ +function isSumSubInProgress(status: KycSumSubStatus): boolean { + return ( + status === 'creatingSession' || + status === 'fetchingToken' || + status === 'launching' || + status === 'inProgress' || + status === 'polling' || + status === 'vendorProcessing' + ); +} + +/** + * Whether an autoramp status counts as "created / ready" for Mobile routing. + * + * @param status - Autoramp status. + * @returns Whether the autoramp is Approved. + */ +export function isAutorampCreatedStatus(status: AutorampStatus): boolean { + return status === AutorampStatus.Approved; +} + +/** + * Whether an autoramp status counts as still pending (exists but not ready). + * + * @param status - Autoramp status. + * @returns Whether Mobile should show a pending autoramp stage. + */ +export function isAutorampPendingStatus(status: AutorampStatus): boolean { + return ( + status !== AutorampStatus.Approved && + status !== AutorampStatus.Rejected && + status !== AutorampStatus.Cancelled + ); +} + +/** + * Summarizes local autoramp accounts for a wallet into a derivation input. + * + * @param autoramps - Local last-seen autoramp cache. + * @param walletAddress - Money Account wallet address (case-insensitive). + * @returns Autoramp branch for {@link NeobankOnboardingDerivationInput}. + */ +export function summarizeAutorampsForWallet( + autoramps: readonly AutorampAccount[], + walletAddress: string, +): NeobankOnboardingDerivationInput['autoramp'] { + const normalized = walletAddress.toLowerCase(); + const matching = autoramps.filter( + (autoramp) => autoramp.walletAddress.toLowerCase() === normalized, + ); + if (matching.length === 0) { + return { status: 'none' }; + } + if (matching.some((autoramp) => isAutorampCreatedStatus(autoramp.status))) { + return { status: 'created' }; + } + if (matching.some((autoramp) => isAutorampPendingStatus(autoramp.status))) { + return { status: 'pending' }; + } + // Only rejected/cancelled remain — treat as needing a new autoramp. + return { status: 'none' }; +} + +/** + * Derives the single Mobile-routable NeoBank onboarding stage. + * + * Priority follows the product funnel: identity / terms / KYC, then wallet + * registration, then autoramp. Safe to call twice with the same inputs. + * + * @param input - Refreshed KYC + wallet + autoramp signals. + * @returns The onboarding stage. + */ +export function deriveNeobankOnboardingStage( + input: NeobankOnboardingDerivationInput, +): NeobankOnboardingStage { + const { kyc, wallet, autoramp } = input; + + if (kyc.phase === 'auth') { + return NeobankOnboardingStage.EmailOtpRequired; + } + + if (!kyc.hasVendorTerms) { + if (!kyc.hasCustomerIdentity && kyc.userStatus === null) { + return NeobankOnboardingStage.NoUser; + } + return NeobankOnboardingStage.VendorTermsRequired; + } + + if (!kyc.hasProviderTerms) { + return NeobankOnboardingStage.ProviderTermsRequired; + } + + if (kyc.userStatus === 'need-more-information') { + return NeobankOnboardingStage.KycNeedsReview; + } + if (kyc.userStatus === 'terminal-failure') { + return NeobankOnboardingStage.KycRejected; + } + if (kyc.userStatus === 'pending') { + return NeobankOnboardingStage.KycPending; + } + + if (kyc.userStatus !== 'completed') { + if (isSumSubInProgress(kyc.sumsubStatus) || kyc.sumsubStatus === 'failed') { + return kyc.sumsubStatus === 'failed' + ? NeobankOnboardingStage.KycRejected + : NeobankOnboardingStage.KycStartedIncomplete; + } + if (kyc.phase === 'error') { + return NeobankOnboardingStage.KycRejected; + } + if (kyc.phase === 'submit' || kyc.phase === 'session') { + return NeobankOnboardingStage.KycStartedIncomplete; + } + return NeobankOnboardingStage.KycNotStarted; + } + + // KYC completed — wallet / autoramp stages. + if (wallet.status === 'lookupUnavailable') { + return NeobankOnboardingStage.LookupFailed; + } + if (wallet.status === 'skipped') { + return NeobankOnboardingStage.LookupFailed; + } + if (wallet.registration.type === 'absent') { + return NeobankOnboardingStage.WalletNotSigned; + } + // active or disabled registrations both count as "signed"; disabled is a + // downstream product concern, not a missing ownership proof. + if (autoramp.status === 'skipped') { + return NeobankOnboardingStage.LookupFailed; + } + if (autoramp.status === 'none') { + return NeobankOnboardingStage.AutorampNotCreated; + } + if (autoramp.status === 'pending') { + return NeobankOnboardingStage.AutorampPending; + } + return NeobankOnboardingStage.AutorampCreated; +} diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index c7f4c2add68..7d97281761e 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -13,6 +13,9 @@ { "path": "../controller-utils/tsconfig.build.json" }, + { + "path": "../kyc-controller/tsconfig.build.json" + }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index f85e8ef6394..07ab0fa8826 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -8,6 +8,12 @@ { "path": "../base-controller" }, + { + "path": "../controller-utils" + }, + { + "path": "../kyc-controller" + }, { "path": "../messenger" }, @@ -16,9 +22,6 @@ }, { "path": "../remote-feature-flag-controller" - }, - { - "path": "../controller-utils" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 3db674c19fc..6dd7c68f856 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7526,7 +7526,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@workspace:^, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8531,6 +8531,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/kyc-controller": "workspace:^" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^6.1.0"