From 79f61afcdab26de461429081cd2d051b8dad1c3a Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 20:17:32 -0600 Subject: [PATCH 1/6] feat(kyc-controller): add VBA onboarding stub getters Expose KycVendor/KycProvider/KycStatus const objects and noop messenger methods so ramps can hydrate VBA onboarding without local KYC contracts. Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 6 ++ .../src/KycController-method-action-types.ts | 57 ++++++++++++++-- .../kyc-controller/src/KycController.test.ts | 38 +++++++++++ packages/kyc-controller/src/KycController.ts | 68 +++++++++++++++++-- packages/kyc-controller/src/index.test.ts | 14 ++++ packages/kyc-controller/src/index.ts | 6 ++ packages/kyc-controller/src/types.ts | 32 ++++++++- 7 files changed, 210 insertions(+), 11 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index af3ce152015..d0460228ffb 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -7,6 +7,12 @@ 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 +- Add stub messenger methods `isCustomerCreated`, `hasCompletedVendorTerms`, and `hasCompletedProviderTerms` +- Extend `getKycStatus` with a vendor overload that returns `KycStatus` (noop stub returning `NOT_STARTED`) + ### 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..92a33b96cd2 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -167,17 +167,63 @@ 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 is a temporary noop stub that always returns + * {@link KycStatus.NOT_STARTED} until Iron status wiring lands. + * + * @param paramsOrVendor - Either `{ product }` for the cached required flag, + * or a {@link KycVendor} for the vendor-scoped 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. + * + * Temporary noop stub for VBA onboarding hydration; always returns `false` + * until Iron customer lookup is wired. + * + * @param _vendor - Identity vendor to check. + * @returns Whether the customer has been created. + */ +export type KycControllerIsCustomerCreatedAction = { + type: `KycController:isCustomerCreated`; + handler: KycController['isCustomerCreated']; +}; + +/** + * Whether the user has accepted terms for the given identity vendor. + * + * Temporary noop stub for VBA onboarding hydration; always returns `false` + * until vendor-terms state is exposed here. + * + * @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. + * + * Temporary noop stub for VBA onboarding hydration; always returns `false` + * until provider-terms state is exposed here. + * + * @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 @@ -296,6 +342,9 @@ export type KycControllerMethodActions = | KycControllerBuildResetFrameUrlAction | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction + | KycControllerIsCustomerCreatedAction + | 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..7b57dade803 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'; @@ -1354,6 +1355,43 @@ describe('KycController', () => { }, ); }); + + it('returns NOT_STARTED for the vendor-scoped stub', async () => { + await withController(({ controller }) => { + expect(controller.getKycStatus(KycVendor.Iron)).toBe( + KycStatus.NOT_STARTED, + ); + expect(controller.getKycStatus(KycVendor.Moonpay)).toBe( + KycStatus.NOT_STARTED, + ); + }); + }); + }); + + describe('isCustomerCreated', () => { + it('returns false as a noop stub', async () => { + await withController(({ controller }) => { + expect(controller.isCustomerCreated(KycVendor.Iron)).toBe(false); + }); + }); + }); + + describe('hasCompletedVendorTerms', () => { + it('returns false as a noop stub', async () => { + await withController(({ controller }) => { + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(false); + }); + }); + }); + + describe('hasCompletedProviderTerms', () => { + it('returns false as a noop stub', async () => { + await withController(({ controller }) => { + expect(controller.hasCompletedProviderTerms(KycProvider.sumsub)).toBe( + false, + ); + }); + }); }); describe('getCustomerIdentity', () => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 1553a8e41be..2bb51912e76 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -29,9 +29,11 @@ import type { KycDisclaimersCatalog, KycPhase, KycProduct, + KycProvider, KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, + KycStatus, KycSumSubLauncher, KycSumSubSdkStatus, KycSumSubStatus, @@ -39,6 +41,7 @@ import type { KycVendor, KycVendorDisclaimersAccepted, } 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'; @@ -646,6 +649,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'buildResetFrameUrl', 'checkKycRequired', 'getKycStatus', + 'isCustomerCreated', + 'hasCompletedVendorTerms', + 'hasCompletedProviderTerms', 'getCustomerIdentity', 'refreshKycStatus', 'startSumSub', @@ -1803,14 +1809,64 @@ 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 is a temporary noop stub that always returns + * {@link KycStatus.NOT_STARTED} until Iron status wiring lands. + * + * @param paramsOrVendor - Either `{ product }` for the cached required flag, + * or a {@link KycVendor} for the vendor-scoped 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 KycStatusEnum.NOT_STARTED; + } + return this.state.kycRequiredByProduct[paramsOrVendor.product]; + } + + /** + * Whether a customer shell exists for the given identity vendor. + * + * Temporary noop stub for VBA onboarding hydration; always returns `false` + * until Iron customer lookup is wired. + * + * @param _vendor - Identity vendor to check. + * @returns Whether the customer has been created. + */ + isCustomerCreated(_vendor: KycVendor): boolean { + return false; + } + + /** + * Whether the user has accepted terms for the given identity vendor. + * + * Temporary noop stub for VBA onboarding hydration; always returns `false` + * until vendor-terms state is exposed here. + * + * @param _vendor - Identity vendor whose terms to check. + * @returns Whether vendor terms are complete. + */ + hasCompletedVendorTerms(_vendor: KycVendor): boolean { + return false; + } + + /** + * Whether the user has accepted terms for the given KYC provider. + * + * Temporary noop stub for VBA onboarding hydration; always returns `false` + * until provider-terms state is exposed here. + * + * @param _provider - Document / identity provider whose terms to check. + * @returns Whether provider terms are complete. + */ + hasCompletedProviderTerms(_provider: KycProvider): boolean { + return false; } /** 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..1b9194b22bc 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -30,7 +30,10 @@ export type { KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, + KycControllerHasCompletedProviderTermsAction, + KycControllerHasCompletedVendorTermsAction, KycControllerInitializeAction, + KycControllerIsCustomerCreatedAction, KycControllerLoadDisclaimersAction, KycControllerRefreshKycStatusAction, KycControllerResetAction, @@ -106,9 +109,11 @@ export type { KycDisclaimersCatalog, KycPhase, KycProduct, + KycProvider, KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, + KycStatus, KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubSdkStatus, @@ -121,6 +126,7 @@ export type { 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..21069350f18 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. From df2af7bc71840836c26d4dac33ba9d6c438a6c0b Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 20:17:54 -0600 Subject: [PATCH 2/6] docs(kyc-controller): link stub changelog entries to #10279 Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index d0460228ffb..5a746c8620b 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `KycVendor`, `KycProvider`, and `KycStatus` const objects (and matching types) for VBA onboarding consumers -- Add stub messenger methods `isCustomerCreated`, `hasCompletedVendorTerms`, and `hasCompletedProviderTerms` -- Extend `getKycStatus` with a vendor overload that returns `KycStatus` (noop stub returning `NOT_STARTED`) +- Add `KycVendor`, `KycProvider`, and `KycStatus` const objects (and matching types) for VBA onboarding consumers ([#10279](https://github.com/MetaMask/core/pull/10279)) +- Add stub messenger methods `isCustomerCreated`, `hasCompletedVendorTerms`, and `hasCompletedProviderTerms` ([#10279](https://github.com/MetaMask/core/pull/10279)) +- Extend `getKycStatus` with a vendor overload that returns `KycStatus` (noop stub returning `NOT_STARTED`) ([#10279](https://github.com/MetaMask/core/pull/10279)) ### Changed From 7d18b9227b4978bb2049d4c99a7a2e6fedc3b226 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 16 Sep 2026 20:25:06 -0600 Subject: [PATCH 3/6] fix(kyc-controller): repair duplicate exports and overload spacing Co-authored-by: Cursor --- packages/kyc-controller/src/KycController.ts | 2 ++ packages/kyc-controller/src/index.ts | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 2bb51912e76..fab6585c078 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -1820,7 +1820,9 @@ export class KycController extends BaseController< * @returns The cached product flag, or a {@link KycStatus} for a vendor. */ getKycStatus(params: { product: KycProduct }): boolean | undefined; + getKycStatus(vendor: KycVendor): KycStatus; + getKycStatus( paramsOrVendor: { product: KycProduct } | KycVendor, ): boolean | undefined | KycStatus { diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 1b9194b22bc..d1242e92284 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -109,18 +109,15 @@ export type { KycDisclaimersCatalog, KycPhase, KycProduct, - KycProvider, KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, - KycStatus, KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubSdkStatus, KycSumSubStatus, KycUserStatus, KycUserStatusResponse, - KycVendor, KycIronVendorDisclaimersAccepted, KycMoonpayVendorDisclaimersAccepted, KycVendorDisclaimersAccepted, From e347ed2c3dbba7e08b6ef7e90b3bd85103fe66f4 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 17 Sep 2026 03:20:05 -0600 Subject: [PATCH 4/6] feat(kyc-controller): wire VBA getters to persisted KYC state Persist vendorCustomerIds from createVendorCustomer, and implement isCustomerCreated / hasCompletedVendorTerms / hasCompletedProviderTerms / getKycStatus(vendor) against real controller state so ramps hydration can advance past EmailOtpRequired. Co-authored-by: Cursor --- packages/kyc-controller/ARCHITECTURE.md | 11 +- packages/kyc-controller/CHANGELOG.md | 5 +- .../src/KycController-method-action-types.ts | 32 ++--- .../kyc-controller/src/KycController.test.ts | 115 +++++++++++++-- packages/kyc-controller/src/KycController.ts | 132 ++++++++++++++---- packages/kyc-controller/src/index.ts | 2 + packages/kyc-controller/src/types.ts | 12 ++ 7 files changed, 246 insertions(+), 63 deletions(-) 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 5a746c8620b..357952aa814 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,8 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `KycVendor`, `KycProvider`, and `KycStatus` const objects (and matching types) for VBA onboarding consumers ([#10279](https://github.com/MetaMask/core/pull/10279)) -- Add stub messenger methods `isCustomerCreated`, `hasCompletedVendorTerms`, and `hasCompletedProviderTerms` ([#10279](https://github.com/MetaMask/core/pull/10279)) -- Extend `getKycStatus` with a vendor overload that returns `KycStatus` (noop stub returning `NOT_STARTED`) ([#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 diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 92a33b96cd2..e33cc2dc574 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -170,11 +170,14 @@ export type KycControllerCheckKycRequiredAction = { * Reads the cached "is KYC required" result for a product, or the * vendor-scoped KYC decision used by VBA onboarding. * - * The vendor overload is a temporary noop stub that always returns - * {@link KycStatus.NOT_STARTED} until Iron status wiring lands. + * 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 vendor-scoped decision. + * or a {@link KycVendor} for the onboarding decision. * @returns The cached product flag, or a {@link KycStatus} for a vendor. */ export type KycControllerGetKycStatusAction = { @@ -185,10 +188,11 @@ export type KycControllerGetKycStatusAction = { /** * Whether a customer shell exists for the given identity vendor. * - * Temporary noop stub for VBA onboarding hydration; always returns `false` - * until Iron customer lookup is wired. + * 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. + * @param vendor - Identity vendor to check. * @returns Whether the customer has been created. */ export type KycControllerIsCustomerCreatedAction = { @@ -199,10 +203,7 @@ export type KycControllerIsCustomerCreatedAction = { /** * Whether the user has accepted terms for the given identity vendor. * - * Temporary noop stub for VBA onboarding hydration; always returns `false` - * until vendor-terms state is exposed here. - * - * @param _vendor - Identity vendor whose terms to check. + * @param vendor - Identity vendor whose terms to check. * @returns Whether vendor terms are complete. */ export type KycControllerHasCompletedVendorTermsAction = { @@ -213,10 +214,7 @@ export type KycControllerHasCompletedVendorTermsAction = { /** * Whether the user has accepted terms for the given KYC provider. * - * Temporary noop stub for VBA onboarding hydration; always returns `false` - * until provider-terms state is exposed here. - * - * @param _provider - Document / identity provider whose terms to check. + * @param provider - Document / identity provider whose terms to check. * @returns Whether provider terms are complete. */ export type KycControllerHasCompletedProviderTermsAction = { @@ -306,7 +304,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`; @@ -316,7 +315,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. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 7b57dade803..804ea0d76d1 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1356,42 +1356,129 @@ describe('KycController', () => { ); }); - it('returns NOT_STARTED for the vendor-scoped stub', async () => { - await withController(({ controller }) => { - expect(controller.getKycStatus(KycVendor.Iron)).toBe( - KycStatus.NOT_STARTED, - ); - expect(controller.getKycStatus(KycVendor.Moonpay)).toBe( - KycStatus.NOT_STARTED, + 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( + { options: { state: { userStatus } } }, + ({ controller }) => { + expect(controller.getKycStatus(KycVendor.Iron)).toBe(expected); + }, ); - }); - }); + }, + ); }); describe('isCustomerCreated', () => { - it('returns false as a noop stub', async () => { + 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 as a noop stub', async () => { + 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 as a noop stub', async () => { + 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', () => { @@ -2563,6 +2650,10 @@ describe('KycController', () => { moonpay: null, iron: { disclaimerIds: ['1'] }, }, + vendorCustomerIds: { + moonpay: null, + iron: 'iron-cust-1', + }, providerDisclaimersAccepted: { sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, }, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index fab6585c078..1bc155c464e 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -39,6 +39,7 @@ import type { KycSumSubStatus, KycUserStatus, KycVendor, + KycVendorCustomerIds, KycVendorDisclaimersAccepted, } from './types.js'; import { KycStatus as KycStatusEnum } from './types.js'; @@ -208,6 +209,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`). @@ -329,6 +336,12 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, + vendorCustomerIds: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, providerDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, @@ -448,6 +461,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 }; } @@ -464,6 +486,7 @@ export function getDefaultKycControllerState(): KycControllerState { error: null, email: null, vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), + vendorCustomerIds: getDefaultKycVendorCustomerIds(), providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, @@ -504,6 +527,32 @@ 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`). @@ -947,9 +996,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) { @@ -1048,9 +1106,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) { @@ -1812,11 +1879,14 @@ export class KycController extends BaseController< * Reads the cached "is KYC required" result for a product, or the * vendor-scoped KYC decision used by VBA onboarding. * - * The vendor overload is a temporary noop stub that always returns - * {@link KycStatus.NOT_STARTED} until Iron status wiring lands. + * 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 vendor-scoped decision. + * 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; @@ -1827,7 +1897,7 @@ export class KycController extends BaseController< paramsOrVendor: { product: KycProduct } | KycVendor, ): boolean | undefined | KycStatus { if (typeof paramsOrVendor === 'string') { - return KycStatusEnum.NOT_STARTED; + return mapUserStatusToKycStatus(this.state.userStatus); } return this.state.kycRequiredByProduct[paramsOrVendor.product]; } @@ -1835,40 +1905,42 @@ export class KycController extends BaseController< /** * Whether a customer shell exists for the given identity vendor. * - * Temporary noop stub for VBA onboarding hydration; always returns `false` - * until Iron customer lookup is wired. + * 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. + * @param vendor - Identity vendor to check. * @returns Whether the customer has been created. */ - isCustomerCreated(_vendor: KycVendor): boolean { - return false; + isCustomerCreated(vendor: KycVendor): boolean { + return Boolean(this.state.vendorCustomerIds[vendor]); } /** * Whether the user has accepted terms for the given identity vendor. * - * Temporary noop stub for VBA onboarding hydration; always returns `false` - * until vendor-terms state is exposed here. - * - * @param _vendor - Identity vendor whose terms to check. + * @param vendor - Identity vendor whose terms to check. * @returns Whether vendor terms are complete. */ - hasCompletedVendorTerms(_vendor: KycVendor): boolean { - return false; + hasCompletedVendorTerms(vendor: KycVendor): boolean { + return hasVendorDisclaimerAcceptance( + this.state.vendorDisclaimersAccepted, + vendor, + ); } /** * Whether the user has accepted terms for the given KYC provider. * - * Temporary noop stub for VBA onboarding hydration; always returns `false` - * until provider-terms state is exposed here. - * - * @param _provider - Document / identity provider whose terms to check. + * @param provider - Document / identity provider whose terms to check. * @returns Whether provider terms are complete. */ - hasCompletedProviderTerms(_provider: KycProvider): boolean { - return false; + hasCompletedProviderTerms(provider: KycProvider): boolean { + if (provider !== 'sumsub') { + return false; + } + const accepted = this.state.providerDisclaimersAccepted.sumsub; + return Boolean(accepted?.length); } /** @@ -2551,7 +2623,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(); @@ -2579,7 +2652,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/index.ts b/packages/kyc-controller/src/index.ts index d1242e92284..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'; @@ -120,6 +121,7 @@ export type { KycUserStatusResponse, KycIronVendorDisclaimersAccepted, KycMoonpayVendorDisclaimersAccepted, + KycVendorCustomerIds, KycVendorDisclaimersAccepted, KycVendorSigning, } from './types.js'; diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 21069350f18..3f7b7dc1dc7 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -293,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). From ef6261e9892004e5ecf79935101bcfc82b277734 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 17 Sep 2026 08:12:03 -0600 Subject: [PATCH 5/6] feat(kyc-controller): server-side VBA terms + backend-authoritative hydrate Turn the VBA onboarding terms steps into account-backed operations and make hydration re-read status from the vendor account: - acceptVendorTerms / acceptProviderTerms now persist to the account (submitVendorDisclaimers; UKYC session + submitSessionDisclaimers) before recording locally, so acceptance is never claimed without a backend write - add KycService.fetchRequiredSignings (GET required-signings) and KycController.refreshVbaOnboardingStatus so hydrate reflects the account's vendor-terms and KYC status rather than only device-local state - gate getKycStatus on a persisted sumSubSubmitted flag so the session-created 'pending' routes to the SumSub screen until documents are actually submitted - persist activeVendor so a resumed flow keeps its Iron vendor context across reloads (avoids sending empty MoonPay session metadata) Co-Authored-By: Claude Opus 4.8 --- .../src/KycController-method-action-types.ts | 86 ++++++ .../kyc-controller/src/KycController.test.ts | 264 ++++++++++++++++- packages/kyc-controller/src/KycController.ts | 268 +++++++++++++++++- .../src/KycService-method-action-types.ts | 23 ++ .../kyc-controller/src/KycService.test.ts | 57 ++++ packages/kyc-controller/src/KycService.ts | 53 ++++ 6 files changed, 741 insertions(+), 10 deletions(-) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index e33cc2dc574..8d04c7ab149 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. */ @@ -200,9 +254,38 @@ export type KycControllerIsCustomerCreatedAction = { 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}; + * - provider / idOS terms — the current UKYC session's `consented` catalog, + * when a session exists, into + * {@link KycControllerState.providerDisclaimersAccepted} and + * {@link KycControllerState.idosDisclaimersAccepted}; + * - KYC status — {@link refreshKycStatus} (`GET /kyc/status`). + * + * 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. */ @@ -335,6 +418,8 @@ export type KycControllerMethodActions = | KycControllerLoadDisclaimersAction | KycControllerFetchSessionDisclaimersAction | KycControllerAcceptTermsAndStartSessionAction + | KycControllerAcceptVendorTermsAction + | KycControllerAcceptProviderTermsAction | KycControllerClearSavedTermsAction | KycControllerHandleFrameMessageAction | KycControllerBuildCheckFrameUrlAction @@ -343,6 +428,7 @@ export type KycControllerMethodActions = | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction | KycControllerIsCustomerCreatedAction + | KycControllerRefreshVbaOnboardingStatusAction | KycControllerHasCompletedVendorTermsAction | KycControllerHasCompletedProviderTermsAction | KycControllerGetCustomerIdentityAction diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 804ea0d76d1..b21aafc37f4 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -930,6 +930,246 @@ 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')); + + await expect( + controller.refreshVbaOnboardingStatus(), + ).resolves.toBeUndefined(); + + expect(controller.state.vbaRequiredSignings).toBeNull(); + }, + ); + }); + }); + describe('acceptTermsAndStartSession (iron)', () => { it('persists Iron disclaimer ids for vendor disclaimer submission', async () => { await withController( @@ -1373,13 +1613,28 @@ describe('KycController', () => { 'maps userStatus $userStatus to $expected for a vendor', async ({ userStatus, expected }) => { await withController( - { options: { state: { userStatus } } }, + // `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', () => { @@ -5099,6 +5354,7 @@ type ServiceHandlers = { checkKycRequired: jest.Mock; createVendorCustomer: jest.Mock; submitVendorDisclaimers: jest.Mock; + fetchRequiredSignings: jest.Mock; fetchSessionDisclaimersByCountry: jest.Mock; fetchSessionDisclaimersBySessionId: jest.Mock; submitSessionDisclaimers: jest.Mock; @@ -5137,6 +5393,7 @@ const SERVICE_ACTIONS = [ 'KycService:checkKycRequired', 'KycService:createVendorCustomer', 'KycService:submitVendorDisclaimers', + 'KycService:fetchRequiredSignings', 'KycService:fetchSessionDisclaimersByCountry', 'KycService:fetchSessionDisclaimersBySessionId', 'KycService:submitSessionDisclaimers', @@ -5247,6 +5504,7 @@ function withController( .mockResolvedValue([ { id: 'sign-1', customer_id: 'cust-1', content_id: 'd1' }, ]), + fetchRequiredSignings: jest.fn().mockResolvedValue([]), fetchSessionDisclaimersByCountry: jest.fn().mockResolvedValue({ idOS: [], kycProvider: [], @@ -5302,6 +5560,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 1bc155c464e..a856767c753 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -41,6 +41,7 @@ import type { KycVendor, KycVendorCustomerIds, KycVendorDisclaimersAccepted, + KycVendorSigning, } from './types.js'; import { KycStatus as KycStatusEnum } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; @@ -291,6 +292,29 @@ 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; + /** SumSub document-verification sub-flow state. */ sumsub: { status: KycSumSubStatus; @@ -405,7 +429,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: { @@ -444,6 +472,18 @@ const kycControllerMetadata = { persist: true, usedInUi: true, }, + vbaRequiredSignings: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + sumSubSubmitted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, sumsub: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -504,6 +544,8 @@ export function getDefaultKycControllerState(): KycControllerState { userStatus: null, userStatusSumsubSessionId: null, userStatusErrorCode: null, + vbaRequiredSignings: null, + sumSubSubmitted: false, sumsub: { status: 'idle', result: null, @@ -690,6 +732,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'loadDisclaimers', 'fetchSessionDisclaimers', 'acceptTermsAndStartSession', + 'acceptVendorTerms', + 'acceptProviderTerms', 'createVendorCustomer', 'clearSavedTerms', 'handleFrameMessage', @@ -701,6 +745,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'isCustomerCreated', 'hasCompletedVendorTerms', 'hasCompletedProviderTerms', + 'refreshVbaOnboardingStatus', 'getCustomerIdentity', 'refreshKycStatus', 'startSumSub', @@ -1678,6 +1723,134 @@ 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; + await this.messenger.call('KycService:submitVendorDisclaimers', { + vendor, + disclaimerIds, + }); + const termsAcceptedAt = new Date().toISOString(); + this.#applyUpdate((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. */ @@ -1896,10 +2069,18 @@ export class KycController extends BaseController< getKycStatus( paramsOrVendor: { product: KycProduct } | KycVendor, ): boolean | undefined | KycStatus { - if (typeof paramsOrVendor === 'string') { - return mapUserStatusToKycStatus(this.state.userStatus); + 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 this.state.kycRequiredByProduct[paramsOrVendor.product]; + return status; } /** @@ -1913,16 +2094,78 @@ export class KycController extends BaseController< * @returns Whether the customer has been created. */ isCustomerCreated(vendor: KycVendor): boolean { - return Boolean(this.state.vendorCustomerIds[vendor]); + 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, @@ -1936,11 +2179,9 @@ export class KycController extends BaseController< * @returns Whether provider terms are complete. */ hasCompletedProviderTerms(provider: KycProvider): boolean { - if (provider !== 'sumsub') { - return false; - } const accepted = this.state.providerDisclaimersAccepted.sumsub; - return Boolean(accepted?.length); + const result = provider === 'sumsub' && Boolean(accepted?.length); + return result; } /** @@ -2284,6 +2525,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 @@ -2319,6 +2566,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; @@ -2636,6 +2884,8 @@ export class KycController extends BaseController< state.vendorError = null; state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; + state.vbaRequiredSignings = null; + state.sumSubSubmitted = false; clearMoonPaySession(state); state.activeVendor = 'moonpay'; state.activeProduct = null; 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 From 2e9e869c804d04f3e4efcce8811c474a16005335 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 17 Sep 2026 15:19:41 -0600 Subject: [PATCH 6/6] fix(kyc-controller): CI (lint/format/action-types) and Bugbot findings - persist `ukycSessionId` so a reload reuses the consented UKYC session instead of `startSumSub` creating a fresh, unconsented one (Bugbot: provider session dropped after reload) - `acceptVendorTerms` captures the flow generation and writes via `#updateIfCurrent`, so a concurrent `reset()` no longer lands acceptance on an idle controller (Bugbot: vendor terms write ignores reset) - regenerate `KycController-method-action-types` for the updated method JSDoc - use `expect(await ...)` instead of the restricted `.resolves` matcher - apply oxfmt formatting Co-Authored-By: Claude Opus 4.8 --- .../src/KycController-method-action-types.ts | 13 +++++-- .../kyc-controller/src/KycController.test.ts | 36 ++++++++++------- packages/kyc-controller/src/KycController.ts | 39 +++++++++++++++++-- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 8d04c7ab149..342fbbc0048 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -261,12 +261,17 @@ export type KycControllerIsCustomerCreatedAction = { * * - vendor terms — {@link KycService.fetchRequiredSignings} outstanding * signings into {@link KycControllerState.vbaRequiredSignings}; - * - provider / idOS terms — the current UKYC session's `consented` catalog, - * when a session exists, into - * {@link KycControllerState.providerDisclaimersAccepted} and - * {@link KycControllerState.idosDisclaimersAccepted}; * - 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 diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index b21aafc37f4..393a311408e 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -974,13 +974,13 @@ describe('KycController', () => { }, }, async ({ controller, handlers }) => { - handlers.submitVendorDisclaimers.mockRejectedValue( - new Error('down'), - ); + handlers.submitVendorDisclaimers.mockRejectedValue(new Error('down')); await expect(controller.acceptVendorTerms()).rejects.toThrow('down'); - expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(false); + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe( + false, + ); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); }, ); @@ -997,7 +997,9 @@ describe('KycController', () => { await controller.acceptVendorTerms(); expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); - expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(false); + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe( + false, + ); expect(controller.state.vendorDisclaimersAccepted).toStrictEqual( DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED, ); @@ -1057,7 +1059,9 @@ describe('KycController', () => { expect(handlers.createUkycSession).not.toHaveBeenCalled(); expect(handlers.submitSessionDisclaimers).toHaveBeenCalled(); - expect(controller.state.credentialReusabilityConsentGiven).toBe(false); + expect(controller.state.credentialReusabilityConsentGiven).toBe( + false, + ); }, ); }); @@ -1076,7 +1080,9 @@ describe('KycController', () => { expect(controller.hasCompletedProviderTerms(KycProvider.sumsub)).toBe( false, ); - expect(controller.state.providerDisclaimersAccepted.sumsub).toBeNull(); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toBeNull(); }, ); }); @@ -1142,7 +1148,9 @@ describe('KycController', () => { await controller.refreshVbaOnboardingStatus(); - expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe(false); + expect(controller.hasCompletedVendorTerms(KycVendor.Iron)).toBe( + false, + ); }, ); }); @@ -1158,11 +1166,11 @@ describe('KycController', () => { }, }, async ({ controller, handlers }) => { - handlers.fetchRequiredSignings.mockRejectedValue(new Error('offline')); + handlers.fetchRequiredSignings.mockRejectedValue( + new Error('offline'), + ); - await expect( - controller.refreshVbaOnboardingStatus(), - ).resolves.toBeUndefined(); + expect(await controller.refreshVbaOnboardingStatus()).toBeUndefined(); expect(controller.state.vbaRequiredSignings).toBeNull(); }, @@ -1625,7 +1633,9 @@ describe('KycController', () => { it('treats pending as NOT_STARTED until SumSub has been submitted', async () => { await withController( - { options: { state: { userStatus: 'pending', sumSubSubmitted: false } } }, + { + 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. diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index a856767c753..1d620567eef 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -315,6 +315,16 @@ export type KycControllerState = { */ 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; @@ -484,6 +494,12 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, + ukycSessionId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: false, + }, sumsub: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -546,6 +562,7 @@ export function getDefaultKycControllerState(): KycControllerState { userStatusErrorCode: null, vbaRequiredSignings: null, sumSubSubmitted: false, + ukycSessionId: null, sumsub: { status: 'idle', result: null, @@ -576,9 +593,7 @@ function isSessionAlreadyCompletedError(error: unknown): boolean { * @param userStatus - The simplified user-keyed status, or `null`. * @returns The matching {@link KycStatus}. */ -function mapUserStatusToKycStatus( - userStatus: KycUserStatus | null, -): KycStatus { +function mapUserStatusToKycStatus(userStatus: KycUserStatus | null): KycStatus { switch (userStatus) { case 'pending': return KycStatusEnum.PENDING; @@ -1750,12 +1765,15 @@ export class KycController extends BaseController< return; } const vendor = this.state.activeVendor; + const generation = this.#generation; await this.messenger.call('KycService:submitVendorDisclaimers', { vendor, disclaimerIds, }); const termsAcceptedAt = new Date().toISOString(); - this.#applyUpdate((state) => { + // 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, @@ -2349,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; @@ -2417,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'; @@ -2886,6 +2916,7 @@ export class KycController extends BaseController< state.credentialReusabilityConsentGiven = null; state.vbaRequiredSignings = null; state.sumSubSubmitted = false; + state.ukycSessionId = null; clearMoonPaySession(state); state.activeVendor = 'moonpay'; state.activeProduct = null;