diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 0dac3cc4f3e..8c6d310d832 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -205,9 +205,11 @@ classDiagram State metadata highlights (`kycControllerMetadata`): - **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, - `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, - `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow - can skip already-accepted terms and reuse cached results. Session-scoped + `vendorCustomerIds`, `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, + `kycRequiredByProduct`, `lastCheckedAt`, `userStatus`, + `userStatusSumsubSessionId`, `userStatusErrorCode`. These survive restarts so + the flow can skip already-accepted terms, reuse cached results, and so VBA + hydration can read customer / KYC progress after a cold start. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory only (`persist: false`) and are cleared on `reset()`. Acceptance is vendor-scoped: `initialize` (and `createVendorCustomer`) drops @@ -215,7 +217,8 @@ State metadata highlights (`kycControllerMetadata`): disclaimer ids are never submitted to another. The drop waits until the vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay path proceeds); a failed or reset switch leaves the previous vendor's - acceptance in place. + acceptance in place. `vendorCustomerIds` is likewise preserved across + `reset()` and only cleared by `clearState()`. - **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` sub-tree. Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index af3ce152015..357952aa814 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `KycVendor`, `KycProvider`, and `KycStatus` const objects (and matching types) for VBA onboarding consumers ([#10279](https://github.com/MetaMask/core/pull/10279)) +- Add messenger methods `isCustomerCreated`, `hasCompletedVendorTerms`, and `hasCompletedProviderTerms` that read persisted customer / terms state ([#10279](https://github.com/MetaMask/core/pull/10279)) +- Extend `getKycStatus` with a vendor overload that maps persisted `userStatus` to `KycStatus` ([#10279](https://github.com/MetaMask/core/pull/10279)) +- Persist `vendorCustomerIds` from successful `createVendorCustomer` (create or resume) so `isCustomerCreated` survives cold starts and `reset()` ([#10279](https://github.com/MetaMask/core/pull/10279)) + ### Changed - Bump `@metamask/profile-sync-controller` from `^32.1.0` to `^32.1.1` ([#10220](https://github.com/MetaMask/core/pull/10220)) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index c03a617de6b..342fbbc0048 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -100,6 +100,60 @@ export type KycControllerAcceptTermsAndStartSessionAction = { handler: KycController['acceptTermsAndStartSession']; }; +/** + * Signs the active vendor's currently loaded T&Cs on the customer's account + * (`POST /vendors/{vendor}/disclaimers`), then records the acceptance + * locally. + * + * The standalone vendor-terms step for flows (e.g. VBA / Pix onboarding) that + * present the vendor disclaimers on their own screen, ahead of the provider / + * idOS consents captured later by {@link acceptProviderTerms}. Persists to + * the account first so {@link hasCompletedVendorTerms} (and a later hydrate's + * `required-signings` refresh) never reports an acceptance the account does + * not hold; the local record is written only after the backend call + * succeeds. Does not create a UKYC session. + * + * A no-op when no disclaimers are loaded, so acceptance is never recorded for + * terms the user was not shown. Load the disclaimers (see + * {@link loadDisclaimers}) before calling. + * + * @throws When the backend signing call fails; nothing is recorded locally. + */ +export type KycControllerAcceptVendorTermsAction = { + type: `KycController:acceptVendorTerms`; + handler: KycController['acceptVendorTerms']; +}; + +/** + * Records provider (SumSub) + idOS session-disclaimer consents on the + * customer's account, creating the UKYC session first if one does not exist + * yet, without launching SumSub. + * + * The standalone provider-terms step for flows (e.g. VBA / Pix onboarding) + * that present the idOS + KYC-provider disclaimers on their own screen and + * hand SumSub off to a later screen. Provider/idOS consents are + * session-scoped, so the session is created here (vendor terms are already + * signed by this point, satisfying MoonPay's "terms before KYC" order) and + * the consents are posted to it via `POST /sessions/{id}/disclaimers`; a + * later {@link startSumSub} reuses that session instead of creating another. + * The local record is written only after the account holds the consents, so + * a subsequent hydrate reads the real status. Fails closed and records + * nothing when either consent list is malformed, mirroring + * {@link acceptTermsAndStartSession}. + * + * @param params - The parameters. + * @param params.providerDisclaimersAccepted - Accepted SumSub disclaimer + * records ({@link KycConsentRecord}). + * @param params.idosDisclaimersAccepted - Accepted idOS disclaimer records. + * @param params.credentialReusabilityConsentGiven - Whether the customer + * consented to reuse existing idOS credentials. Defaults to `false`. + * @throws When session creation or the backend consent submission fails. + */ +export type KycControllerAcceptProviderTermsAction = { + type: `KycController:acceptProviderTerms`; + handler: KycController['acceptProviderTerms']; +}; + /** * Clears the persisted terms acceptance. */ @@ -167,17 +221,95 @@ export type KycControllerCheckKycRequiredAction = { }; /** - * Reads the cached "is KYC required" result for a product. + * Reads the cached "is KYC required" result for a product, or the + * vendor-scoped KYC decision used by VBA onboarding. * - * @param params - The parameters. - * @param params.product - The consuming feature. - * @returns The cached value, or `undefined` if not yet checked. + * The vendor overload maps persisted {@link KycUserStatus} from + * `GET /kyc/status` into {@link KycStatus}. That status is currently + * user-keyed rather than filtered by vendor; the vendor argument is kept so + * callers can pass {@link KycVendor.Iron} today and a vendor-scoped lookup + * can land later without changing the messenger contract. + * + * @param paramsOrVendor - Either `{ product }` for the cached required flag, + * or a {@link KycVendor} for the onboarding decision. + * @returns The cached product flag, or a {@link KycStatus} for a vendor. */ export type KycControllerGetKycStatusAction = { type: `KycController:getKycStatus`; handler: KycController['getKycStatus']; }; +/** + * Whether a customer shell exists for the given identity vendor. + * + * Reads the persisted id from a successful + * `POST /vendors/{vendor}/customers` create-or-resume. Survives + * {@link reset}; cleared by {@link clearState}. + * + * @param vendor - Identity vendor to check. + * @returns Whether the customer has been created. + */ +export type KycControllerIsCustomerCreatedAction = { + type: `KycController:isCustomerCreated`; + handler: KycController['isCustomerCreated']; +}; + +/** + * Refreshes the backend-authoritative VBA onboarding signals the ramps + * controller reads during hydration, so each stage reflects the customer's + * account rather than only device-local state: + * + * - vendor terms — {@link KycService.fetchRequiredSignings} outstanding + * signings into {@link KycControllerState.vbaRequiredSignings}; + * - KYC status — {@link refreshKycStatus} (`GET /kyc/status`). + * + * Provider / idOS terms are deliberately not re-derived here: they are posted + * to the account by {@link acceptProviderTerms}, which only records them + * locally after that POST succeeds, so the local value is already + * backend-confirmed. Re-deriving them from the session catalog is both + * redundant and fragile — the session catalog can list documents beyond the + * ones consented on the provider-terms screen (built from the country + * catalog), which would incorrectly clear a valid acceptance and loop the + * flow back to the provider-terms screen. + * + * A no-op when the active vendor has no customer yet (the flow is still at + * the email step). Each signal soft-fails independently: a failed fetch keeps + * that signal's last-known value rather than throwing, so hydration can still + * resolve a stage from whatever is current. + */ +export type KycControllerRefreshVbaOnboardingStatusAction = { + type: `KycController:refreshVbaOnboardingStatus`; + handler: KycController['refreshVbaOnboardingStatus']; +}; + +/** + * Whether the user has accepted terms for the given identity vendor. + * + * Backend-authoritative once {@link refreshVbaOnboardingStatus} has populated + * {@link KycControllerState.vbaRequiredSignings} for the active vendor: + * complete means the account has no outstanding required signings. Before the + * first refresh (e.g. immediately after {@link acceptVendorTerms}) it falls + * back to the locally recorded acceptance. + * + * @param vendor - Identity vendor whose terms to check. + * @returns Whether vendor terms are complete. + */ +export type KycControllerHasCompletedVendorTermsAction = { + type: `KycController:hasCompletedVendorTerms`; + handler: KycController['hasCompletedVendorTerms']; +}; + +/** + * Whether the user has accepted terms for the given KYC provider. + * + * @param provider - Document / identity provider whose terms to check. + * @returns Whether provider terms are complete. + */ +export type KycControllerHasCompletedProviderTermsAction = { + type: `KycController:hasCompletedProviderTerms`; + handler: KycController['hasCompletedProviderTerms']; +}; + /** * Returns the vendor-scoped identity for the currently authenticated * customer, or `null` when the flow has not yet captured a vendor customer @@ -260,7 +392,8 @@ export type KycControllerGetSessionStatusAction = { /** * Resets the flow to idle, clearing session tokens and sub-flow state while - * preserving persisted terms acceptance and the per-product cache. + * preserving persisted terms acceptance, vendor customer ids, and the + * per-product cache. */ export type KycControllerResetAction = { type: `KycController:reset`; @@ -270,7 +403,8 @@ export type KycControllerResetAction = { /** * Restores the controller to its default state, discarding everything * {@link reset} deliberately keeps: the session email, the persisted terms - * acceptance, the per-product KYC-required cache and the user-keyed status. + * acceptance, the persisted vendor customer ids, the per-product KYC-required + * cache and the user-keyed status. * * Intended for a full wallet reset, where no trace of the previous * customer may survive into the next wallet. @@ -289,6 +423,8 @@ export type KycControllerMethodActions = | KycControllerLoadDisclaimersAction | KycControllerFetchSessionDisclaimersAction | KycControllerAcceptTermsAndStartSessionAction + | KycControllerAcceptVendorTermsAction + | KycControllerAcceptProviderTermsAction | KycControllerClearSavedTermsAction | KycControllerHandleFrameMessageAction | KycControllerBuildCheckFrameUrlAction @@ -296,6 +432,10 @@ export type KycControllerMethodActions = | KycControllerBuildResetFrameUrlAction | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction + | KycControllerIsCustomerCreatedAction + | KycControllerRefreshVbaOnboardingStatusAction + | KycControllerHasCompletedVendorTermsAction + | KycControllerHasCompletedProviderTermsAction | KycControllerGetCustomerIdentityAction | KycControllerStartSumSubAction | KycControllerRefreshKycStatusAction diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index e67e970bcf3..393a311408e 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -23,6 +23,7 @@ import type { KycSessionDisclaimers, KycSumSubLauncher, } from './types.js'; +import { KycProvider, KycStatus, KycVendor } from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; import { MoonPayFrameHandler } from './vendors/MoonPayFrameHandler.js'; @@ -929,6 +930,254 @@ describe('KycController', () => { }); }); + describe('acceptVendorTerms', () => { + it('signs the loaded disclaimers on the account, then records acceptance and clears required signings', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [ + { id: 'd1', display_name: 'T', url: 'u' }, + { id: 'd2', display_name: 'T2', url: 'u2' }, + ], + }, + }, + }, + async ({ controller, handlers }) => { + await controller.acceptVendorTerms(); + + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['d1', 'd2'], + }); + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(true); + expect( + controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, + ).toStrictEqual(['d1', 'd2']); + // Just signed every document, so nothing is outstanding on the account. + expect(controller.state.vbaRequiredSignings).toStrictEqual([]); + // Vendor terms do not create a UKYC session. + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('records nothing locally when the account signing call fails', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitVendorDisclaimers.mockRejectedValue(new Error('down')); + + await expect(controller.acceptVendorTerms()).rejects.toThrow('down'); + + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe( + false, + ); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); + }, + ); + }); + + it('is a no-op when no disclaimers are loaded', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', vendorDisclaimers: [] }, + }, + }, + async ({ controller, handlers }) => { + await controller.acceptVendorTerms(); + + expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe( + false, + ); + expect(controller.state.vendorDisclaimersAccepted).toStrictEqual( + DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED, + ); + }, + ); + }); + }); + + describe('acceptProviderTerms', () => { + it('creates a session, submits the session disclaimers to the account, then records consents', async () => { + await withController( + { options: { state: { activeVendor: 'iron' } } }, + async ({ controller, handlers }) => { + await controller.acceptProviderTerms({ + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + credentialReusabilityConsentGiven: true, + }); + + expect(handlers.createUkycSession).toHaveBeenCalledTimes(1); + expect(handlers.submitSessionDisclaimers).toHaveBeenCalled(); + expect(controller.hasCompletedProviderTerms(KycProvider.sumsub)).toBe( + true, + ); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual(MOCK_SUMSUB_DISCLAIMERS_ACCEPTED); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual( + MOCK_IDOS_DISCLAIMERS_ACCEPTED, + ); + expect(controller.state.credentialReusabilityConsentGiven).toBe(true); + }, + ); + }); + + it('reuses an existing session instead of creating another', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + sumsub: { + status: 'idle', + result: null, + sessionId: 'existing-session', + applicantAccessToken: null, + sessionStatus: null, + }, + }, + }, + }, + async ({ controller, handlers }) => { + await controller.acceptProviderTerms({ + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.submitSessionDisclaimers).toHaveBeenCalled(); + expect(controller.state.credentialReusabilityConsentGiven).toBe( + false, + ); + }, + ); + }); + + it('fails closed and records nothing when a consent list is malformed', async () => { + await withController( + { options: { state: { activeVendor: 'iron' } } }, + async ({ controller, handlers }) => { + await controller.acceptProviderTerms({ + providerDisclaimersAccepted: + 'nope' as unknown as typeof MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(controller.hasCompletedProviderTerms(KycProvider.sumsub)).toBe( + false, + ); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toBeNull(); + }, + ); + }); + }); + + describe('refreshVbaOnboardingStatus', () => { + it('is a no-op when the active vendor has no customer yet', async () => { + await withController( + { options: { state: { activeVendor: 'iron' } } }, + async ({ controller, handlers }) => { + await controller.refreshVbaOnboardingStatus(); + + expect(handlers.fetchRequiredSignings).not.toHaveBeenCalled(); + expect(controller.state.vbaRequiredSignings).toBeNull(); + }, + ); + }); + + it('reports vendor terms complete when the account has no outstanding required signings', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorCustomerIds: { moonpay: null, iron: 'iron-1' }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchRequiredSignings.mockResolvedValue([]); + + await controller.refreshVbaOnboardingStatus(); + + expect(handlers.fetchRequiredSignings).toHaveBeenCalledWith({ + vendor: 'iron', + customerId: 'iron-1', + }); + expect(controller.state.vbaRequiredSignings).toStrictEqual([]); + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(true); + }, + ); + }); + + it('reports vendor terms incomplete when the account still has outstanding signings', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorCustomerIds: { moonpay: null, iron: 'iron-1' }, + // Stale local acceptance must be overridden by the account. + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchRequiredSignings.mockResolvedValue([ + { id: 'sign-2', customer_id: 'iron-1', content_id: 'd2' }, + ]); + + await controller.refreshVbaOnboardingStatus(); + + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe( + false, + ); + }, + ); + }); + + it('keeps the last-known signal when a refresh fetch fails', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorCustomerIds: { moonpay: null, iron: 'iron-1' }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchRequiredSignings.mockRejectedValue( + new Error('offline'), + ); + + expect(await controller.refreshVbaOnboardingStatus()).toBeUndefined(); + + expect(controller.state.vbaRequiredSignings).toBeNull(); + }, + ); + }); + }); + describe('acceptTermsAndStartSession (iron)', () => { it('persists Iron disclaimer ids for vendor disclaimer submission', async () => { await withController( @@ -1354,6 +1603,147 @@ describe('KycController', () => { }, ); }); + + it.each([ + { userStatus: null, expected: KycStatus.NOT_STARTED }, + { userStatus: 'not-started' as const, expected: KycStatus.NOT_STARTED }, + { userStatus: 'pending' as const, expected: KycStatus.PENDING }, + { + userStatus: 'need-more-information' as const, + expected: KycStatus.NEED_INFO, + }, + { + userStatus: 'terminal-failure' as const, + expected: KycStatus.REJECTED, + }, + { userStatus: 'completed' as const, expected: KycStatus.ACCEPTED }, + ])( + 'maps userStatus $userStatus to $expected for a vendor', + async ({ userStatus, expected }) => { + await withController( + // `sumSubSubmitted` so a `pending` status maps straight to PENDING; + // the pre-submission gate is covered separately below. + { options: { state: { userStatus, sumSubSubmitted: true } } }, + ({ controller }) => { + expect(controller.getKycStatus(KycVendor.Iron)).toBe(expected); + }, + ); + }, + ); + + it('treats pending as NOT_STARTED until SumSub has been submitted', async () => { + await withController( + { + options: { state: { userStatus: 'pending', sumSubSubmitted: false } }, + }, + ({ controller }) => { + // Session created (backend `pending`) but no documents captured yet, + // so onboarding must route to the SumSub screen, not KYC-pending. + expect(controller.getKycStatus(KycVendor.Iron)).toBe( + KycStatus.NOT_STARTED, + ); + }, + ); + }); + }); + + describe('isCustomerCreated', () => { + it('returns false when no vendor customer id is persisted', async () => { + await withController(({ controller }) => { + expect(controller.isCustomerCreated(KycVendor.Iron)).toBe(false); + }); + }); + + it('returns true after createVendorCustomer persists the id', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockResolvedValue({ + id: 'iron-cust-1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + + await controller.createVendorCustomer({ + vendor: KycVendor.Iron, + email: 'a@b.co', + }); + + expect(controller.state.vendorCustomerIds.iron).toBe('iron-cust-1'); + expect(controller.isCustomerCreated(KycVendor.Iron)).toBe(true); + expect(controller.isCustomerCreated(KycVendor.Moonpay)).toBe(false); + }); + }); + + it('survives reset but is cleared by clearState', async () => { + await withController( + { + options: { + state: { + vendorCustomerIds: { moonpay: null, iron: 'iron-cust-1' }, + }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.isCustomerCreated(KycVendor.Iron)).toBe(true); + + controller.clearState(); + expect(controller.isCustomerCreated(KycVendor.Iron)).toBe(false); + }, + ); + }); + }); + + describe('hasCompletedVendorTerms', () => { + it('returns false when vendor terms are missing', async () => { + await withController(({ controller }) => { + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(false); + }); + }); + + it('returns true when Iron disclaimer ids are persisted', async () => { + await withController( + { + options: { + state: VENDOR_TERMS_IRON_D1, + }, + }, + ({ controller }) => { + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(true); + expect(controller.hasCompletedVendorTerms(KycVendor.Moonpay)).toBe( + false, + ); + }, + ); + }); + }); + + describe('hasCompletedProviderTerms', () => { + it('returns false when SumSub provider terms are missing', async () => { + await withController(({ controller }) => { + expect(controller.hasCompletedProviderTerms(KycProvider.sumsub)).toBe( + false, + ); + }); + }); + + it('returns true when SumSub consent records are persisted', async () => { + await withController( + { + options: { + state: { + providerDisclaimersAccepted: { + sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + }, + }, + }, + }, + ({ controller }) => { + expect(controller.hasCompletedProviderTerms(KycProvider.sumsub)).toBe( + true, + ); + }, + ); + }); }); describe('getCustomerIdentity', () => { @@ -2525,6 +2915,10 @@ describe('KycController', () => { moonpay: null, iron: { disclaimerIds: ['1'] }, }, + vendorCustomerIds: { + moonpay: null, + iron: 'iron-cust-1', + }, providerDisclaimersAccepted: { sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, }, @@ -4970,6 +5364,7 @@ type ServiceHandlers = { checkKycRequired: jest.Mock; createVendorCustomer: jest.Mock; submitVendorDisclaimers: jest.Mock; + fetchRequiredSignings: jest.Mock; fetchSessionDisclaimersByCountry: jest.Mock; fetchSessionDisclaimersBySessionId: jest.Mock; submitSessionDisclaimers: jest.Mock; @@ -5008,6 +5403,7 @@ const SERVICE_ACTIONS = [ 'KycService:checkKycRequired', 'KycService:createVendorCustomer', 'KycService:submitVendorDisclaimers', + 'KycService:fetchRequiredSignings', 'KycService:fetchSessionDisclaimersByCountry', 'KycService:fetchSessionDisclaimersBySessionId', 'KycService:submitSessionDisclaimers', @@ -5118,6 +5514,7 @@ function withController( .mockResolvedValue([ { id: 'sign-1', customer_id: 'cust-1', content_id: 'd1' }, ]), + fetchRequiredSignings: jest.fn().mockResolvedValue([]), fetchSessionDisclaimersByCountry: jest.fn().mockResolvedValue({ idOS: [], kycProvider: [], @@ -5173,6 +5570,10 @@ function withController( 'KycService:submitVendorDisclaimers', handlers.submitVendorDisclaimers, ); + rootMessenger.registerActionHandler( + 'KycService:fetchRequiredSignings', + handlers.fetchRequiredSignings, + ); rootMessenger.registerActionHandler( 'KycService:fetchSessionDisclaimersByCountry', handlers.fetchSessionDisclaimersByCountry, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 1553a8e41be..1d620567eef 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -29,16 +29,21 @@ import type { KycDisclaimersCatalog, KycPhase, KycProduct, + KycProvider, KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, + KycStatus, KycSumSubLauncher, KycSumSubSdkStatus, KycSumSubStatus, KycUserStatus, KycVendor, + KycVendorCustomerIds, KycVendorDisclaimersAccepted, + KycVendorSigning, } from './types.js'; +import { KycStatus as KycStatusEnum } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import type { Jwk } from './ukyc/jwtChain.js'; @@ -205,6 +210,12 @@ export type KycControllerState = { * `disclaimerIds`. */ vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; + /** + * Persisted vendor customer ids from successful + * `POST /vendors/{vendor}/customers` calls (create or resume). Used by + * {@link KycController.isCustomerCreated} for VBA onboarding hydration. + */ + vendorCustomerIds: KycVendorCustomerIds; /** * KYC-provider disclaimer documents the customer accepted during the last * terms acceptance (persisted `{ key, version }` records under `sumsub`). @@ -281,6 +292,39 @@ export type KycControllerState = { /** Optional machine-readable error code for terminal / EDD UX. */ userStatusErrorCode: string | null; + /** + * Outstanding vendor T&C signings for the active vendor's customer, from the + * last `GET .../required-signings` (see + * {@link KycService.fetchRequiredSignings}). An empty array means every + * currently-published document is signed; a non-empty array means the + * customer is `SigningsRequired`. `null` until the first refresh — before + * that {@link hasCompletedVendorTerms} falls back to the locally recorded + * acceptance. Not persisted: re-fetched on each VBA hydrate. + */ + vbaRequiredSignings: KycVendorSigning[] | null; + + /** + * Whether the SumSub document-verification flow has been submitted for the + * current onboarding. Set when the SDK flow completes; persisted so it + * survives reloads. Distinguishes "session created, documents still to + * capture" (KYC treated as {@link KycStatus.NOT_STARTED}, routing to the + * SumSub screen) from "documents submitted, under review" — the backend + * `/kyc/status` and session `finalStatus` both report `pending` for both, so + * they cannot be told apart on their own. Cleared by {@link reset} and + * {@link clearState}. + */ + sumSubSubmitted: boolean; + + /** + * The active UKYC session id, persisted so it survives reloads. Unlike the + * `sumsub` sub-flow (which holds session-scoped tokens and is not persisted), + * this lets {@link startSumSub} reuse the session that + * {@link acceptProviderTerms} created and posted idOS/SumSub consents to, + * rather than creating a fresh, unconsented session after a cold start. + * `null` until a session exists; cleared by {@link reset} / {@link clearState}. + */ + ukycSessionId: string | null; + /** SumSub document-verification sub-flow state. */ sumsub: { status: KycSumSubStatus; @@ -326,6 +370,12 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, + vendorCustomerIds: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, providerDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, @@ -389,7 +439,11 @@ const kycControllerMetadata = { activeVendor: { includeInDebugSnapshot: true, includeInStateLogs: true, - persist: false, + // Persisted alongside the vendor customer id so a resumable flow (e.g. VBA) + // keeps its vendor context across app reloads. Without this it resets to + // the `moonpay` default on reload, and a resumed Iron session sends empty + // MoonPay `vendorMetadata`, which the sessions API rejects. + persist: true, usedInUi: true, }, activeProduct: { @@ -428,6 +482,24 @@ const kycControllerMetadata = { persist: true, usedInUi: true, }, + vbaRequiredSignings: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + sumSubSubmitted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + ukycSessionId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: false, + }, sumsub: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -445,6 +517,15 @@ export function getDefaultKycVendorDisclaimersAccepted(): KycVendorDisclaimersAc return { moonpay: null, iron: null }; } +/** + * Constructs the default {@link KycVendorCustomerIds} value. + * + * @returns The default vendor-customer-id map. + */ +export function getDefaultKycVendorCustomerIds(): KycVendorCustomerIds { + return { moonpay: null, iron: null }; +} + export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaimersAccepted { return { sumsub: null }; } @@ -461,6 +542,7 @@ export function getDefaultKycControllerState(): KycControllerState { error: null, email: null, vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), + vendorCustomerIds: getDefaultKycVendorCustomerIds(), providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, @@ -478,6 +560,9 @@ export function getDefaultKycControllerState(): KycControllerState { userStatus: null, userStatusSumsubSessionId: null, userStatusErrorCode: null, + vbaRequiredSignings: null, + sumSubSubmitted: false, + ukycSessionId: null, sumsub: { status: 'idle', result: null, @@ -501,6 +586,30 @@ function isSessionAlreadyCompletedError(error: unknown): boolean { return String(error).includes(SESSION_NOT_IN_VALID_STATE); } +/** + * Maps persisted {@link KycUserStatus} into the VBA onboarding {@link KycStatus} + * contract. `null` (never refreshed) is treated as not started. + * + * @param userStatus - The simplified user-keyed status, or `null`. + * @returns The matching {@link KycStatus}. + */ +function mapUserStatusToKycStatus(userStatus: KycUserStatus | null): KycStatus { + switch (userStatus) { + case 'pending': + return KycStatusEnum.PENDING; + case 'need-more-information': + return KycStatusEnum.NEED_INFO; + case 'terminal-failure': + return KycStatusEnum.REJECTED; + case 'completed': + return KycStatusEnum.ACCEPTED; + case 'not-started': + case null: + default: + return KycStatusEnum.NOT_STARTED; + } +} + /** * Whether recording session disclaimers failed because those document * versions were already consented for the session (`409 Conflict`). @@ -638,6 +747,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'loadDisclaimers', 'fetchSessionDisclaimers', 'acceptTermsAndStartSession', + 'acceptVendorTerms', + 'acceptProviderTerms', 'createVendorCustomer', 'clearSavedTerms', 'handleFrameMessage', @@ -646,6 +757,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'buildResetFrameUrl', 'checkKycRequired', 'getKycStatus', + 'isCustomerCreated', + 'hasCompletedVendorTerms', + 'hasCompletedProviderTerms', + 'refreshVbaOnboardingStatus', 'getCustomerIdentity', 'refreshKycStatus', 'startSumSub', @@ -941,9 +1056,18 @@ export class KycController extends BaseController< if (usesConsentsFlow(vendor) && this.state.email) { try { - await this.messenger.call('KycService:createVendorCustomer', { - vendor, - email: this.state.email, + const customer = await this.messenger.call( + 'KycService:createVendorCustomer', + { + vendor, + email: this.state.email, + }, + ); + this.#updateIfCurrent(generation, (state) => { + state.vendorCustomerIds = { + ...state.vendorCustomerIds, + [vendor]: customer.id, + }; }); } catch (error) { if (this.#generation !== generation) { @@ -1042,9 +1166,18 @@ export class KycController extends BaseController< }); const generation = this.#generation; try { - await this.messenger.call('KycService:createVendorCustomer', { - vendor: params.vendor, - email: params.email, + const customer = await this.messenger.call( + 'KycService:createVendorCustomer', + { + vendor: params.vendor, + email: params.email, + }, + ); + this.#updateIfCurrent(generation, (state) => { + state.vendorCustomerIds = { + ...state.vendorCustomerIds, + [params.vendor]: customer.id, + }; }); } catch (error) { if (this.#generation !== generation) { @@ -1605,6 +1738,137 @@ export class KycController extends BaseController< } } + /** + * Signs the active vendor's currently loaded T&Cs on the customer's account + * (`POST /vendors/{vendor}/disclaimers`), then records the acceptance + * locally. + * + * The standalone vendor-terms step for flows (e.g. VBA / Pix onboarding) that + * present the vendor disclaimers on their own screen, ahead of the provider / + * idOS consents captured later by {@link acceptProviderTerms}. Persists to + * the account first so {@link hasCompletedVendorTerms} (and a later hydrate's + * `required-signings` refresh) never reports an acceptance the account does + * not hold; the local record is written only after the backend call + * succeeds. Does not create a UKYC session. + * + * A no-op when no disclaimers are loaded, so acceptance is never recorded for + * terms the user was not shown. Load the disclaimers (see + * {@link loadDisclaimers}) before calling. + * + * @throws When the backend signing call fails; nothing is recorded locally. + */ + async acceptVendorTerms(): Promise { + const disclaimerIds = this.state.vendorDisclaimers.map( + (disclaimer) => disclaimer.id, + ); + if (disclaimerIds.length === 0) { + return; + } + const vendor = this.state.activeVendor; + const generation = this.#generation; + await this.messenger.call('KycService:submitVendorDisclaimers', { + vendor, + disclaimerIds, + }); + const termsAcceptedAt = new Date().toISOString(); + // Skip the write if a reset() superseded the flow while the signing call + // was in flight, so acceptance is never recorded on an idle controller. + this.#updateIfCurrent(generation, (state) => { + state.vendorDisclaimersAccepted = recordVendorDisclaimerAcceptance( + state.vendorDisclaimersAccepted, + vendor, + { termsAcceptedAt, disclaimerIds }, + ); + // Every outstanding document was just signed, so the account now has no + // required signings — keep the backend-authoritative signal in sync. + state.vbaRequiredSignings = []; + }); + } + + /** + * Records provider (SumSub) + idOS session-disclaimer consents on the + * customer's account, creating the UKYC session first if one does not exist + * yet, without launching SumSub. + * + * The standalone provider-terms step for flows (e.g. VBA / Pix onboarding) + * that present the idOS + KYC-provider disclaimers on their own screen and + * hand SumSub off to a later screen. Provider/idOS consents are + * session-scoped, so the session is created here (vendor terms are already + * signed by this point, satisfying MoonPay's "terms before KYC" order) and + * the consents are posted to it via `POST /sessions/{id}/disclaimers`; a + * later {@link startSumSub} reuses that session instead of creating another. + * The local record is written only after the account holds the consents, so + * a subsequent hydrate reads the real status. Fails closed and records + * nothing when either consent list is malformed, mirroring + * {@link acceptTermsAndStartSession}. + * + * @param params - The parameters. + * @param params.providerDisclaimersAccepted - Accepted SumSub disclaimer + * records ({@link KycConsentRecord}). + * @param params.idosDisclaimersAccepted - Accepted idOS disclaimer records. + * @param params.credentialReusabilityConsentGiven - Whether the customer + * consented to reuse existing idOS credentials. Defaults to `false`. + * @throws When session creation or the backend consent submission fails. + */ + async acceptProviderTerms(params: { + providerDisclaimersAccepted: KycConsentRecord[]; + idosDisclaimersAccepted: KycConsentRecord[]; + credentialReusabilityConsentGiven?: boolean; + }): Promise { + const { providerDisclaimersAccepted, idosDisclaimersAccepted } = params; + if ( + !isValidConsentRecordList(providerDisclaimersAccepted) || + !isValidConsentRecordList(idosDisclaimersAccepted) + ) { + this.#fail('Missing T&C2 acceptance flags.'); + return; + } + const credentialReusabilityConsentGiven = + params.credentialReusabilityConsentGiven ?? false; + const consents = { + providerDisclaimersAccepted, + idosDisclaimersAccepted, + credentialReusabilityConsentGiven, + }; + + const generation = this.#generation; + let vendorProcessing = false; + if (!this.state.sumsub.sessionId) { + this.#applyUpdate((state) => { + state.error = null; + state.sumsub.status = 'creatingSession'; + state.sumsub.result = null; + state.sumsub.sessionStatus = null; + }); + const created = await this.#createUkycSession(generation); + if (!created) { + return; + } + vendorProcessing = created.vendorProcessing; + } + + // A customer the relay has already approved has nothing left to consent to; + // recording session disclaimers would be rejected. Persist locally so the + // gate reflects acceptance and let the flow move on. + if (!vendorProcessing) { + // Empty string is a valid "no id to poll" session id used by tests and + // must not be coalesced away as missing. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const sessionId = this.state.sumsub.sessionId || ''; + await this.#recordSessionDisclaimers(sessionId, consents, generation); + } + + this.#updateIfCurrent(generation, (state) => { + state.providerDisclaimersAccepted = { + ...state.providerDisclaimersAccepted, + sumsub: providerDisclaimersAccepted, + }; + state.idosDisclaimersAccepted = idosDisclaimersAccepted; + state.credentialReusabilityConsentGiven = + credentialReusabilityConsentGiven; + }); + } + /** * Clears the persisted terms acceptance. */ @@ -1803,14 +2067,139 @@ export class KycController extends BaseController< } /** - * Reads the cached "is KYC required" result for a product. + * Reads the cached "is KYC required" result for a product, or the + * vendor-scoped KYC decision used by VBA onboarding. * - * @param params - The parameters. - * @param params.product - The consuming feature. - * @returns The cached value, or `undefined` if not yet checked. + * The vendor overload maps persisted {@link KycUserStatus} from + * `GET /kyc/status` into {@link KycStatus}. That status is currently + * user-keyed rather than filtered by vendor; the vendor argument is kept so + * callers can pass {@link KycVendor.Iron} today and a vendor-scoped lookup + * can land later without changing the messenger contract. + * + * @param paramsOrVendor - Either `{ product }` for the cached required flag, + * or a {@link KycVendor} for the onboarding decision. + * @returns The cached product flag, or a {@link KycStatus} for a vendor. */ - getKycStatus(params: { product: KycProduct }): boolean | undefined { - return this.state.kycRequiredByProduct[params.product]; + getKycStatus(params: { product: KycProduct }): boolean | undefined; + + getKycStatus(vendor: KycVendor): KycStatus; + + getKycStatus( + paramsOrVendor: { product: KycProduct } | KycVendor, + ): boolean | undefined | KycStatus { + if (typeof paramsOrVendor !== 'string') { + return this.state.kycRequiredByProduct[paramsOrVendor.product]; + } + const status = mapUserStatusToKycStatus(this.state.userStatus); + // A UKYC session is created when provider terms are accepted, which flips + // the backend status to `pending` before any documents are captured. Until + // SumSub has actually been submitted, treat that as NOT_STARTED so VBA + // onboarding routes to the SumSub screen rather than the KYC-pending screen. + if (status === KycStatusEnum.PENDING && !this.state.sumSubSubmitted) { + return KycStatusEnum.NOT_STARTED; + } + return status; + } + + /** + * Whether a customer shell exists for the given identity vendor. + * + * Reads the persisted id from a successful + * `POST /vendors/{vendor}/customers` create-or-resume. Survives + * {@link reset}; cleared by {@link clearState}. + * + * @param vendor - Identity vendor to check. + * @returns Whether the customer has been created. + */ + isCustomerCreated(vendor: KycVendor): boolean { + const result = Boolean(this.state.vendorCustomerIds[vendor]); + return result; + } + + /** + * Refreshes the backend-authoritative VBA onboarding signals the ramps + * controller reads during hydration, so each stage reflects the customer's + * account rather than only device-local state: + * + * - vendor terms — {@link KycService.fetchRequiredSignings} outstanding + * signings into {@link KycControllerState.vbaRequiredSignings}; + * - KYC status — {@link refreshKycStatus} (`GET /kyc/status`). + * + * Provider / idOS terms are deliberately not re-derived here: they are posted + * to the account by {@link acceptProviderTerms}, which only records them + * locally after that POST succeeds, so the local value is already + * backend-confirmed. Re-deriving them from the session catalog is both + * redundant and fragile — the session catalog can list documents beyond the + * ones consented on the provider-terms screen (built from the country + * catalog), which would incorrectly clear a valid acceptance and loop the + * flow back to the provider-terms screen. + * + * A no-op when the active vendor has no customer yet (the flow is still at + * the email step). Each signal soft-fails independently: a failed fetch keeps + * that signal's last-known value rather than throwing, so hydration can still + * resolve a stage from whatever is current. + */ + async refreshVbaOnboardingStatus(): Promise { + const vendor = this.state.activeVendor; + const customerId = this.state.vendorCustomerIds[vendor]; + if (!customerId) { + return; + } + + try { + const requiredSignings = await this.messenger.call( + 'KycService:fetchRequiredSignings', + { vendor, customerId }, + ); + this.#applyUpdate((state) => { + state.vbaRequiredSignings = requiredSignings; + }); + } catch (error) { + controllerLog('VBA required-signings refresh failed:', error); + } + + try { + await this.refreshKycStatus(); + } catch (error) { + controllerLog('VBA KYC status refresh failed:', error); + } + } + + /** + * Whether the user has accepted terms for the given identity vendor. + * + * Backend-authoritative once {@link refreshVbaOnboardingStatus} has populated + * {@link KycControllerState.vbaRequiredSignings} for the active vendor: + * complete means the account has no outstanding required signings. Before the + * first refresh (e.g. immediately after {@link acceptVendorTerms}) it falls + * back to the locally recorded acceptance. + * + * @param vendor - Identity vendor whose terms to check. + * @returns Whether vendor terms are complete. + */ + hasCompletedVendorTerms(vendor: KycVendor): boolean { + if ( + vendor === this.state.activeVendor && + this.state.vbaRequiredSignings !== null + ) { + return this.state.vbaRequiredSignings.length === 0; + } + return hasVendorDisclaimerAcceptance( + this.state.vendorDisclaimersAccepted, + vendor, + ); + } + + /** + * Whether the user has accepted terms for the given KYC provider. + * + * @param provider - Document / identity provider whose terms to check. + * @returns Whether provider terms are complete. + */ + hasCompletedProviderTerms(provider: KycProvider): boolean { + const accepted = this.state.providerDisclaimersAccepted.sumsub; + const result = provider === 'sumsub' && Boolean(accepted?.length); + return result; } /** @@ -1978,6 +2367,9 @@ export class KycController extends BaseController< const stillCurrent = this.#updateIfCurrent(generation, (state) => { state.sumsub.sessionId = sessionId; + // Persist the id (the sumsub sub-flow itself is not persisted) so a + // reload can reuse this consented session instead of creating a new one. + state.ukycSessionId = sessionId; if (vendorProcessing) { state.sumsub.status = 'vendorProcessing'; state.statusMessage = VENDOR_PROCESSING_MESSAGE; @@ -2046,6 +2438,15 @@ export class KycController extends BaseController< } try { + // After a reload the sub-flow (and its `sessionId`) is gone but the + // persisted `ukycSessionId` survives. Restore it so a session that + // already had consents posted is reused rather than replaced by a new, + // unconsented one. + if (!this.state.sumsub.sessionId && this.state.ukycSessionId) { + this.#applyUpdate((state) => { + state.sumsub.sessionId = state.ukycSessionId; + }); + } if (!this.state.sumsub.sessionId) { this.#applyUpdate((state) => { state.sumsub.status = 'creatingSession'; @@ -2154,6 +2555,12 @@ export class KycController extends BaseController< const applied = this.#updateIfCurrent(generation, (state) => { state.sumsub.status = settledStatus; state.sumsub.result = result as Json; + if (reachedCompletion) { + // Documents were submitted, so KYC is now genuinely under review — + // a subsequent `pending` status should route to the KYC-pending + // screen rather than back to the SumSub screen. + state.sumSubSubmitted = true; + } }); // Once the SDK completes, the authoritative verification decision comes @@ -2189,6 +2596,7 @@ export class KycController extends BaseController< this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'complete'; state.sumsub.result = { alreadyCompleted: true }; + state.sumSubSubmitted = true; state.statusMessage = 'KYC already completed.'; state.phase = 'done'; state.error = null; @@ -2493,7 +2901,8 @@ export class KycController extends BaseController< /** * Resets the flow to idle, clearing session tokens and sub-flow state while - * preserving persisted terms acceptance and the per-product cache. + * preserving persisted terms acceptance, vendor customer ids, and the + * per-product cache. */ reset(): void { this.#cancelPendingSession(); @@ -2505,6 +2914,9 @@ export class KycController extends BaseController< state.vendorError = null; state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; + state.vbaRequiredSignings = null; + state.sumSubSubmitted = false; + state.ukycSessionId = null; clearMoonPaySession(state); state.activeVendor = 'moonpay'; state.activeProduct = null; @@ -2521,7 +2933,8 @@ export class KycController extends BaseController< /** * Restores the controller to its default state, discarding everything * {@link reset} deliberately keeps: the session email, the persisted terms - * acceptance, the per-product KYC-required cache and the user-keyed status. + * acceptance, the persisted vendor customer ids, the per-product KYC-required + * cache and the user-keyed status. * * Intended for a full wallet reset, where no trace of the previous * customer may survive into the next wallet. diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 975254525ec..0710ee03ca3 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -87,6 +87,28 @@ export type KycServiceSubmitVendorDisclaimersAction = { handler: KycService['submitVendorDisclaimers']; }; +/** + * Fetches the customer's still-outstanding required signings + * (`GET /vendors/{vendor}/customers/{customerId}/required-signings`). + * + * An empty list means every currently-published vendor T&C is signed; a + * non-empty list means the customer is in `SigningsRequired` and must sign + * before KYC / transacting. The list re-populates whenever a new document is + * published, so this is the source of truth for vendor-terms completion for + * the account's lifetime — unlike {@link fetchVendorDisclaimers}, which is + * only the catalog to display. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor (e.g. `iron`). + * @param params.customerId - Vendor customer id from + * {@link createVendorCustomer}. + * @returns The outstanding required signings (empty when all are signed). + */ +export type KycServiceFetchRequiredSigningsAction = { + type: `KycService:fetchRequiredSignings`; + handler: KycService['fetchRequiredSignings']; +}; + /** * Fetches the global idOS + KYC-provider disclaimer catalog * (`GET /disclaimers?country=`). Carries no consent state — per-document @@ -241,6 +263,7 @@ export type KycServiceMethodActions = | KycServiceCheckKycRequiredAction | KycServiceCreateVendorCustomerAction | KycServiceSubmitVendorDisclaimersAction + | KycServiceFetchRequiredSigningsAction | KycServiceFetchSessionDisclaimersByCountryAction | KycServiceFetchSessionDisclaimersBySessionIdAction | KycServiceSubmitSessionDisclaimersAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index a6fc864b7bb..08421825f00 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -690,6 +690,63 @@ describe('KycService', () => { }); }); + describe('fetchRequiredSignings', () => { + it('returns the outstanding required signings for a customer', async () => { + const signings = [ + { id: 'sign-1', customer_id: 'iron-1', content_id: 'disc-1' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/iron/customers/iron-1/required-signings') + .reply(200, signings); + const { service } = getService(); + + expect( + await service.fetchRequiredSignings({ + vendor: 'iron', + customerId: 'iron-1', + }), + ).toStrictEqual(signings); + }); + + it('returns an empty list when the account has nothing outstanding', async () => { + nock(MOCK_API_URL) + .get('/vendors/iron/customers/iron-1/required-signings') + .reply(200, []); + const { service } = getService(); + + expect( + await service.fetchRequiredSignings({ + vendor: 'iron', + customerId: 'iron-1', + }), + ).toStrictEqual([]); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/iron/customers/iron-1/required-signings') + .reply(200, {}); + const { service } = getService(); + + await expect( + service.fetchRequiredSignings({ vendor: 'iron', customerId: 'iron-1' }), + ).rejects.toThrow( + /Malformed response received from required signings API/u, + ); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL) + .get('/vendors/iron/customers/iron-1/required-signings') + .reply(500); + const { service } = getService(); + + await expect( + service.fetchRequiredSignings({ vendor: 'iron', customerId: 'iron-1' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + describe('fetchVendorDisclaimers for a non-MoonPay vendor', () => { it('returns Iron disclaimers for a country', async () => { const disclaimers = [ diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index ea2afb80ccc..a4c940c2cb3 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -54,6 +54,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkKycRequired', 'createVendorCustomer', 'submitVendorDisclaimers', + 'fetchRequiredSignings', 'fetchSessionDisclaimersByCountry', 'fetchSessionDisclaimersBySessionId', 'submitSessionDisclaimers', @@ -323,6 +324,13 @@ export type SubmitVendorDisclaimersParams = { disclaimerIds: string[]; }; +export type FetchRequiredSigningsParams = { + /** Identity vendor to check (currently `iron`). */ + vendor: KycVendor; + /** Vendor customer id from {@link KycService.createVendorCustomer}. */ + customerId: string; +}; + export type FetchSessionDisclaimersByCountryParams = { /** ISO 3166-1 alpha-3 country code for `GET /disclaimers?country=`. */ country: string; @@ -675,6 +683,51 @@ export class KycService extends BaseDataService< ); } + /** + * Fetches the customer's still-outstanding required signings + * (`GET /vendors/{vendor}/customers/{customerId}/required-signings`). + * + * An empty list means every currently-published vendor T&C is signed; a + * non-empty list means the customer is in `SigningsRequired` and must sign + * before KYC / transacting. The list re-populates whenever a new document is + * published, so this is the source of truth for vendor-terms completion for + * the account's lifetime — unlike {@link fetchVendorDisclaimers}, which is + * only the catalog to display. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor (e.g. `iron`). + * @param params.customerId - Vendor customer id from + * {@link createVendorCustomer}. + * @returns The outstanding required signings (empty when all are signed). + */ + async fetchRequiredSignings( + params: FetchRequiredSigningsParams, + ): Promise { + const url = new URL( + `/vendors/${encodeURIComponent(params.vendor)}/customers/${encodeURIComponent( + params.customerId, + )}/required-signings`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:fetchRequiredSignings`, + params.vendor, + params.customerId, + ], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Signing state changes after a POST and when new documents ship, so it + // must always be re-fetched. + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + VendorSigningsResponseStruct, + 'required signings', + ); + } + /** * Fetches the global idOS + KYC-provider disclaimer catalog * (`GET /disclaimers?country=`). Carries no consent state — per-document diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index f986f8847a4..26769a8d2ff 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -14,6 +14,20 @@ describe('@metamask/kyc-controller', () => { decryptCredentials: expect.any(Function), controllerName: 'KycController', serviceName: 'KycService', + KycVendor: { + Moonpay: 'moonpay', + Iron: 'iron', + }, + KycProvider: { + sumsub: 'sumsub', + }, + KycStatus: { + NOT_STARTED: 'NOT_STARTED', + PENDING: 'PENDING', + NEED_INFO: 'NEED_INFO', + REJECTED: 'REJECTED', + ACCEPTED: 'ACCEPTED', + }, }); }); }); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 911f13e5957..70494d18984 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -2,6 +2,7 @@ export { KycController, getDefaultKycControllerState, getDefaultKycProviderDisclaimersAccepted, + getDefaultKycVendorCustomerIds, getDefaultKycVendorDisclaimersAccepted, controllerName, } from './KycController.js'; @@ -30,7 +31,10 @@ export type { KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, + KycControllerHasCompletedProviderTermsAction, + KycControllerHasCompletedVendorTermsAction, KycControllerInitializeAction, + KycControllerIsCustomerCreatedAction, KycControllerLoadDisclaimersAction, KycControllerRefreshKycStatusAction, KycControllerResetAction, @@ -115,12 +119,13 @@ export type { KycSumSubStatus, KycUserStatus, KycUserStatusResponse, - KycVendor, KycIronVendorDisclaimersAccepted, KycMoonpayVendorDisclaimersAccepted, + KycVendorCustomerIds, KycVendorDisclaimersAccepted, KycVendorSigning, } from './types.js'; +export { KycProvider, KycStatus, KycVendor } from './types.js'; // UKYC storage-access-token utilities. Exported so a signed capability token can // be minted for testing UKYC Storage (see `mintUkycTestToken`). diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 3af974539c9..3f7b7dc1dc7 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -19,7 +19,37 @@ export type KycProduct = 'ramps' | 'card' | 'money'; * - `iron` — Iron-only Money/VBA path: empty-shell customer → consents → * SumSub, with no MoonPay Check/Auth frames. */ -export type KycVendor = 'moonpay' | 'iron'; +export const KycVendor = { + Moonpay: 'moonpay', + Iron: 'iron', +} as const; + +export type KycVendor = (typeof KycVendor)[keyof typeof KycVendor]; + +/** + * Document / identity providers used after vendor terms (e.g. SumSub). + */ +export const KycProvider = { + sumsub: 'sumsub', +} as const; + +export type KycProvider = (typeof KycProvider)[keyof typeof KycProvider]; + +/** + * Vendor-scoped KYC decision surface for consumers such as VBA onboarding. + * + * Distinct from {@link KycUserStatus}, which is the user-keyed toast/banner + * contract returned by `GET /kyc/status`. + */ +export const KycStatus = { + NOT_STARTED: 'NOT_STARTED', + PENDING: 'PENDING', + NEED_INFO: 'NEED_INFO', + REJECTED: 'REJECTED', + ACCEPTED: 'ACCEPTED', +} as const; + +export type KycStatus = (typeof KycStatus)[keyof typeof KycStatus]; /** * Vendor-scoped identity for the currently authenticated KYC customer. @@ -263,6 +293,18 @@ export type KycVendorDisclaimersAccepted = { iron: KycIronVendorDisclaimersAccepted | null; }; +/** + * Persisted vendor customer ids from `POST /vendors/{vendor}/customers`. + * + * Survives {@link KycController.reset} so VBA hydration can tell whether a + * customer shell already exists after a cold start. Cleared by + * {@link KycController.clearState}. + */ +export type KycVendorCustomerIds = { + moonpay: string | null; + iron: string | null; +}; + /** * idOS / KYC-provider disclaimer catalog returned by * `GET /disclaimers?country=` (no session — no credential-reuse consent state).