From 3f26318ab4e4f26efd66c01165e261304d092610 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 13:03:55 -0700 Subject: [PATCH 01/10] change idosTncAccepted boolean to idosDisclaimersAccepted KycConsentRecord[] --- packages/kyc-controller/ARCHITECTURE.md | 8 +- packages/kyc-controller/CHANGELOG.md | 1 + .../src/KycController-method-action-types.ts | 5 +- .../kyc-controller/src/KycController.test.ts | 116 +++++++------ packages/kyc-controller/src/KycController.ts | 152 ++++++++++++++---- 5 files changed, 195 insertions(+), 87 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index a8035e8777d..bb3aed197b6 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -207,7 +207,7 @@ classDiagram State metadata highlights (`kycControllerMetadata`): - **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, - `termsAcceptedVendor`, `sumsubTncAccepted`, `idosTncAccepted`, + `termsAcceptedVendor`, `sumsubTncAccepted`, `idosDisclaimersAccepted`, `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow can skip already-accepted terms and reuse cached results. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory @@ -241,7 +241,7 @@ stateDiagram-v2 idle --> terms : initialize() (no saved terms) idle --> session : initialize() (saved terms + email) - terms --> session : acceptTermsAndStartSession({ sumsubTncSigned, idosTncSigned }) + terms --> session : acceptTermsAndStartSession({ sumsubTncSigned, idosDisclaimersAccepted }) session --> check : createSession() ok session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) @@ -279,7 +279,7 @@ stateDiagram-v2 > or SDK close without completion) rewinds to `terms` instead of forcing `done`. > A terminal UKYC rejection after the SDK reported `Completed` still finishes as > `done` so `refreshKycStatus` can surface the decision. -> `acceptTermsAndStartSession` requires `sumsubTncSigned` and `idosTncSigned` +> `acceptTermsAndStartSession` requires `sumsubTncSigned` and `idosDisclaimersAccepted` > (T&C2) for every vendor; omitted flags fail the flow instead of defaulting to > `true`. Those flags are mapped onto the session catalog's `idOS` / > `kycProvider` document records; `credentialReusabilityConsentGiven` is @@ -347,7 +347,7 @@ sequenceDiagram Svc->>API: GET /vendors/moonpay/disclaimers?country= Ctrl-->>UI: phase = terms (+ disclaimers) - User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosTncSigned }) + User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosDisclaimersAccepted }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) Svc->>API: POST /sessions Ctrl-->>UI: phase = check (+ sessionToken) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 0bd2648aaa0..f8c2086c030 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index dd4614d1a36..effcb29d5ef 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -64,8 +64,9 @@ export type KycControllerLoadDisclaimersAction = { * authentication continuation. * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). * Required for every vendor so callers explicitly declare acceptance. - * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. + * @param params.idosDisclaimersAccepted - idOS disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. * @param params.credentialReusabilityConsentGiven - Whether the customer * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 5651378d5ea..8cb5b9ad623 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -18,7 +18,11 @@ import { KycController, } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; -import type { KycSessionDisclaimers, KycSumSubLauncher } from './types.js'; +import type { + KycConsentRecord, + KycSessionDisclaimers, + KycSumSubLauncher, +} from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; @@ -72,6 +76,9 @@ const MOCK_SESSION_DISCLAIMERS: KycSessionDisclaimers = { credentialReusabilityConsentGiven: false, }; +const MOCK_IDOS_DISCLAIMERS_ACCEPTED: KycConsentRecord[] = + MOCK_SESSION_DISCLAIMERS.idOS.map(({ key, version }) => ({ key, version })); + /** * Builds an encrypted envelope for a recipient's X25519 public key. * @@ -351,7 +358,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'ramps', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); @@ -399,13 +406,15 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); expect(controller.state.termsAcceptedVendor).toBe('moonpay'); expect(controller.state.sumsubTncAccepted).toBe(true); - expect(controller.state.idosTncAccepted).toBe(true); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual( + MOCK_IDOS_DISCLAIMERS_ACCEPTED, + ); expect(controller.state.phase).toBe('check'); expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); }, @@ -448,7 +457,7 @@ describe('KycController', () => { // Creating a new session must invalidate the carried-over auth. await controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.accessToken).toBeNull(); @@ -483,7 +492,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // While the request is in flight (phase `session`) the stale token @@ -520,7 +529,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -557,7 +566,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // Reset while the create request is in flight, then let it fail. The @@ -592,7 +601,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ product: 'ramps', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // The failed flow must not leave a lingering product behind that a @@ -613,7 +622,7 @@ describe('KycController', () => { async ({ controller }) => { await controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -627,7 +636,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -2372,7 +2381,7 @@ describe('KycController', () => { acceptedDisclaimerIds: ['1'], termsAcceptedVendor: 'iron', sumsubTncAccepted: true, - idosTncAccepted: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], disclaimersError: 'stale disclaimers error', geoCountry: 'USA', @@ -2667,7 +2676,7 @@ describe('KycController', () => { acceptedDisclaimerIds: ['d1'], termsAcceptedVendor: 'iron', sumsubTncAccepted: true, - idosTncAccepted: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }, userStatusPollIntervalMs: 60_000, }, @@ -2738,7 +2747,7 @@ describe('KycController', () => { termsAcceptedVendor: 'iron', // T&C2 flags are null, simulating pre-migration state sumsubTncAccepted: null, - idosTncAccepted: null, + idosDisclaimersAccepted: null, }, }, }, @@ -2753,7 +2762,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.termsAcceptedAt).toBeNull(); expect(controller.state.sumsubTncAccepted).toBeNull(); - expect(controller.state.idosTncAccepted).toBeNull(); + expect(controller.state.idosDisclaimersAccepted).toBeNull(); }, ); }); @@ -2882,12 +2891,14 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.termsAcceptedVendor).toBe('iron'); expect(controller.state.sumsubTncAccepted).toBe(true); - expect(controller.state.idosTncAccepted).toBe(true); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual( + MOCK_IDOS_DISCLAIMERS_ACCEPTED, + ); controller.reset(); }, ); @@ -3041,7 +3052,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.createSession).not.toHaveBeenCalled(); @@ -3110,7 +3121,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3168,7 +3179,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledTimes(2); @@ -3216,7 +3227,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: false, + idosDisclaimersAccepted: [], }); expect(controller.state.phase).toBe('done'); @@ -3261,7 +3272,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3298,7 +3309,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3349,7 +3360,10 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: [ + { key: 'idos-tos', version: '1' }, + { key: 'idos-privacy', version: '2' }, + ], }); expect(handlers.submitSessionDisclaimers).toHaveBeenCalledWith({ @@ -3395,7 +3409,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); @@ -3475,12 +3489,12 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: false, - idosTncSigned: false, + idosDisclaimersAccepted: [], }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); expect(controller.state.sumsubTncAccepted).toBe(false); - expect(controller.state.idosTncAccepted).toBe(false); + expect(controller.state.idosDisclaimersAccepted).toStrictEqual([]); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', disclaimerIds: ['d1'], @@ -3504,7 +3518,7 @@ describe('KycController', () => { async ({ controller }) => { await controller.acceptTermsAndStartSession({ sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3528,7 +3542,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3558,7 +3572,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3585,7 +3599,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3615,7 +3629,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3654,7 +3668,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); @@ -3692,7 +3706,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); @@ -3727,7 +3741,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); controller.reset(); release(); @@ -3763,7 +3777,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // Session create + session disclaimers run first; wait until launch // is pending so reset races with an in-flight SDK presentation. @@ -3803,7 +3817,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); while (handlers.createUkycSession.mock.calls.length === 0) { await Promise.resolve(); @@ -3841,7 +3855,7 @@ describe('KycController', () => { email: 'a@b.co', product: 'money', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(handlers.fetchSessionDisclaimers).toHaveBeenCalledWith({ @@ -3882,7 +3896,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -3910,7 +3924,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -3938,7 +3952,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -3969,7 +3983,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4000,7 +4014,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4033,7 +4047,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4068,7 +4082,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4113,7 +4127,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('terms'); @@ -4143,7 +4157,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4171,7 +4185,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4199,7 +4213,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('idle'); @@ -4577,7 +4591,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); @@ -4606,7 +4620,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', sumsubTncSigned: true, - idosTncSigned: true, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('done'); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 94a7af58c52..e78853398cf 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -24,6 +24,7 @@ import type { } from './KycService.js'; import type { KycConsentDocument, + KycConsentRecord, KycCustomerIdentity, KycDisclaimer, KycPhase, @@ -166,12 +167,12 @@ export type KycControllerState = { */ sumsubTncAccepted: boolean | null; /** - * Whether the customer accepted the idOS T&C (T&C2) during the last - * terms acceptance (persisted). Consents-path vendors require this flag - * when resuming a session. `null` for acceptance recorded before this - * field existed (treated as requiring reacceptance). + * idOS disclaimer documents the customer accepted during the last terms + * acceptance (persisted `{ key, version }` records). Consents-path vendors + * require this when resuming a session. `null` for acceptance recorded + * before this field existed (treated as requiring reacceptance). */ - idosTncAccepted: boolean | null; + idosDisclaimersAccepted: KycConsentRecord[] | null; /** * Whether the customer consented to reuse existing idOS credentials * during this session. Applied when recording session-scoped disclaimers. @@ -296,7 +297,7 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, - idosTncAccepted: { + idosDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, @@ -415,7 +416,7 @@ export function getDefaultKycControllerState(): KycControllerState { acceptedDisclaimerIds: [], termsAcceptedVendor: null, sumsubTncAccepted: null, - idosTncAccepted: null, + idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, disclaimers: [], disclaimersError: null, @@ -482,7 +483,7 @@ function isConsentConflictError(error: unknown): boolean { function consentRecordsFromCatalog( documents: KycConsentDocument[], accepted: boolean, -): { key: string; version: string }[] { +): KycConsentRecord[] { if (!accepted) { return []; } @@ -491,6 +492,94 @@ function consentRecordsFromCatalog( .map(({ key, version }) => ({ key, version })); } +/** + * Whether a value is a list of consent records (`{ key, version }`). + * + * @param value - The value to validate. + * @returns `true` when `value` is a valid consent record list. + */ +function isValidConsentRecordList(value: unknown): value is KycConsentRecord[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === 'object' && + item !== null && + typeof (item as KycConsentRecord).key === 'string' && + typeof (item as KycConsentRecord).version === 'string', + ) + ); +} + +/** + * Maps accepted idOS disclaimer records onto unconsented catalog documents. + * + * @param documents - Catalog documents for the idOS category. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns Consent records to POST, omitting already-consented documents. + */ +function consentRecordsFromAcceptedList( + documents: KycConsentDocument[], + accepted: KycConsentRecord[], +): KycConsentRecord[] { + if (accepted.length === 0) { + return []; + } + const acceptedKeys = new Set( + accepted.map((record) => `${record.key}:${record.version}`), + ); + return documents + .filter( + (document) => + !document.consented && + acceptedKeys.has(`${document.key}:${document.version}`), + ) + .map(({ key, version }) => ({ key, version })); +} + +/** + * Whether accepted idOS disclaimers reference a missing catalog category. + * + * @param documents - Catalog documents for the idOS category. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns `true` when the caller accepted idOS docs but the catalog is empty. + */ +function isAcceptedIdosCategoryEmpty( + documents: KycConsentDocument[], + accepted: KycConsentRecord[], +): boolean { + return accepted.length > 0 && documents.length === 0; +} + +/** + * Whether accepted idOS disclaimers are still missing consent after a 409 + * re-GET: empty catalog or any accepted document still unconsented. + * + * @param documents - Latest catalog documents for the idOS category. + * @param accepted - Accepted `{ key, version }` records from the caller. + * @returns `true` when accepted idOS documents are not fully consented. + */ +function acceptedIdosCategoryStillMissing( + documents: KycConsentDocument[], + accepted: KycConsentRecord[], +): boolean { + if (accepted.length === 0) { + return false; + } + if (documents.length === 0) { + return true; + } + const acceptedKeys = new Set( + accepted.map((record) => `${record.key}:${record.version}`), + ); + const relevant = documents.filter((document) => + acceptedKeys.has(`${document.key}:${document.version}`), + ); + return ( + relevant.length === 0 || relevant.some((document) => !document.consented) + ); +} + /** * Whether an accepted T&C2 category has no catalog documents. An empty list * would otherwise skip the POST and count as success. @@ -886,9 +975,9 @@ export class KycController extends BaseController< if (usesConsentsFlow(vendor)) { // Consents-path vendors require T&C2 flags; if they weren't persisted // (i.e. null from pre-migration state), require reacceptance. - const sumsubTncSigned = this.state.sumsubTncAccepted; - const idosTncSigned = this.state.idosTncAccepted; - if (sumsubTncSigned === null || idosTncSigned === null) { + const { sumsubTncAccepted: sumsubTncSigned, idosDisclaimersAccepted } = + this.state; + if (sumsubTncSigned === null || idosDisclaimersAccepted === null) { this.#applyUpdate((state) => { this.#clearAcceptedTerms(state); state.phase = 'terms'; @@ -898,7 +987,7 @@ export class KycController extends BaseController< } await this.#startConsentsSession({ sumsubTncSigned, - idosTncSigned, + idosDisclaimersAccepted, credentialReusabilityConsentGiven: this.state.credentialReusabilityConsentGiven ?? false, }); @@ -1014,8 +1103,9 @@ export class KycController extends BaseController< * authentication continuation. * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). * Required for every vendor so callers explicitly declare acceptance. - * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. + * @param params.idosDisclaimersAccepted - idOS disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. * @param params.credentialReusabilityConsentGiven - Whether the customer * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. @@ -1024,14 +1114,13 @@ export class KycController extends BaseController< email?: string; product?: KycProduct; sumsubTncSigned: boolean; - idosTncSigned: boolean; + idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven?: boolean; }): Promise { - const sumsubTncSigned = params?.sumsubTncSigned; - const idosTncSigned = params?.idosTncSigned; + const { sumsubTncSigned, idosDisclaimersAccepted } = params; if ( typeof sumsubTncSigned !== 'boolean' || - typeof idosTncSigned !== 'boolean' + !isValidConsentRecordList(idosDisclaimersAccepted) ) { this.#fail('Missing T&C2 acceptance flags.'); return; @@ -1054,14 +1143,14 @@ export class KycController extends BaseController< state.acceptedDisclaimerIds = disclaimerIds; state.termsAcceptedVendor = state.activeVendor; state.sumsubTncAccepted = sumsubTncSigned; - state.idosTncAccepted = idosTncSigned; + state.idosDisclaimersAccepted = idosDisclaimersAccepted; state.credentialReusabilityConsentGiven = credentialReusabilityConsentGiven; }); if (usesConsentsFlow(this.state.activeVendor)) { await this.#startConsentsSession({ sumsubTncSigned, - idosTncSigned, + idosDisclaimersAccepted, credentialReusabilityConsentGiven, }); return; @@ -1076,13 +1165,13 @@ export class KycController extends BaseController< * * @param consents - T&C2 flags mapped onto the session disclaimer catalog. * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. - * @param consents.idosTncSigned - Whether idOS T&C were accepted. + * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. */ async #startConsentsSession(consents: { sumsubTncSigned: boolean; - idosTncSigned: boolean; + idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }): Promise { const { email, acceptedDisclaimerIds } = this.state; @@ -1233,7 +1322,7 @@ export class KycController extends BaseController< * @param sessionId - The UKYC session id. * @param consents - T&C2 flags mapped onto catalog documents. * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. - * @param consents.idosTncSigned - Whether idOS T&C were accepted. + * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. * @param generation - Flow generation captured by the caller. @@ -1242,7 +1331,7 @@ export class KycController extends BaseController< sessionId: string, consents: { sumsubTncSigned: boolean; - idosTncSigned: boolean; + idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }, generation: number, @@ -1260,7 +1349,10 @@ export class KycController extends BaseController< }); if ( - isAcceptedCategoryEmpty(catalog.idOS, consents.idosTncSigned) || + isAcceptedIdosCategoryEmpty( + catalog.idOS, + consents.idosDisclaimersAccepted, + ) || isAcceptedCategoryEmpty(catalog.kycProvider, consents.sumsubTncSigned) ) { throw new Error( @@ -1268,9 +1360,9 @@ export class KycController extends BaseController< ); } - const idOS = consentRecordsFromCatalog( + const idOS = consentRecordsFromAcceptedList( catalog.idOS, - consents.idosTncSigned, + consents.idosDisclaimersAccepted, ); const kycProvider = consentRecordsFromCatalog( catalog.kycProvider, @@ -1314,9 +1406,9 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.sessionDisclaimers = latest; }); - const stillMissingIdos = acceptedCategoryStillMissing( + const stillMissingIdos = acceptedIdosCategoryStillMissing( latest.idOS, - consents.idosTncSigned, + consents.idosDisclaimersAccepted, ); const stillMissingProvider = acceptedCategoryStillMissing( latest.kycProvider, @@ -1424,7 +1516,7 @@ export class KycController extends BaseController< state.acceptedDisclaimerIds = []; state.termsAcceptedVendor = null; state.sumsubTncAccepted = null; - state.idosTncAccepted = null; + state.idosDisclaimersAccepted = null; state.credentialReusabilityConsentGiven = null; } From 5bacc587e44d4b030a2b10292bce9488ef34b6fd Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 13:07:14 -0700 Subject: [PATCH 02/10] change sumsubTncAccepted boolean to sumsubDisclaimersAccepted KycConsentRecord[] --- packages/kyc-controller/ARCHITECTURE.md | 8 +- packages/kyc-controller/CHANGELOG.md | 1 + .../src/KycController-method-action-types.ts | 5 +- .../kyc-controller/src/KycController.test.ts | 112 ++++++++------ packages/kyc-controller/src/KycController.ts | 146 ++++++------------ 5 files changed, 117 insertions(+), 155 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index bb3aed197b6..4a49a8f6d30 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -207,7 +207,7 @@ classDiagram State metadata highlights (`kycControllerMetadata`): - **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, - `termsAcceptedVendor`, `sumsubTncAccepted`, `idosDisclaimersAccepted`, + `termsAcceptedVendor`, `sumsubDisclaimersAccepted`, `idosDisclaimersAccepted`, `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow can skip already-accepted terms and reuse cached results. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory @@ -241,7 +241,7 @@ stateDiagram-v2 idle --> terms : initialize() (no saved terms) idle --> session : initialize() (saved terms + email) - terms --> session : acceptTermsAndStartSession({ sumsubTncSigned, idosDisclaimersAccepted }) + terms --> session : acceptTermsAndStartSession({ sumsubDisclaimersAccepted, idosDisclaimersAccepted }) session --> check : createSession() ok session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) @@ -279,7 +279,7 @@ stateDiagram-v2 > or SDK close without completion) rewinds to `terms` instead of forcing `done`. > A terminal UKYC rejection after the SDK reported `Completed` still finishes as > `done` so `refreshKycStatus` can surface the decision. -> `acceptTermsAndStartSession` requires `sumsubTncSigned` and `idosDisclaimersAccepted` +> `acceptTermsAndStartSession` requires `sumsubDisclaimersAccepted` and `idosDisclaimersAccepted` > (T&C2) for every vendor; omitted flags fail the flow instead of defaulting to > `true`. Those flags are mapped onto the session catalog's `idOS` / > `kycProvider` document records; `credentialReusabilityConsentGiven` is @@ -347,7 +347,7 @@ sequenceDiagram Svc->>API: GET /vendors/moonpay/disclaimers?country= Ctrl-->>UI: phase = terms (+ disclaimers) - User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosDisclaimersAccepted }) + User->>Ctrl: acceptTermsAndStartSession({ email, sumsubDisclaimersAccepted, idosDisclaimersAccepted }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) Svc->>API: POST /sessions Ctrl-->>UI: phase = check (+ sessionToken) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index f8c2086c030..50ded9ab7ea 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. +- **BREAKING:** Rename persisted `sumsubTncAccepted` to `sumsubDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `sumsubDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index effcb29d5ef..cf56ad19a92 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -62,8 +62,9 @@ export type KycControllerLoadDisclaimersAction = { * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. - * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. + * @param params.sumsubDisclaimersAccepted - Sumsub disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. * @param params.idosDisclaimersAccepted - idOS disclaimer documents the * customer accepted (`{ key, version }` records). Required for every vendor * so callers explicitly declare acceptance. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 8cb5b9ad623..aadeeb7ab72 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -79,6 +79,12 @@ const MOCK_SESSION_DISCLAIMERS: KycSessionDisclaimers = { const MOCK_IDOS_DISCLAIMERS_ACCEPTED: KycConsentRecord[] = MOCK_SESSION_DISCLAIMERS.idOS.map(({ key, version }) => ({ key, version })); +const MOCK_SUMSUB_DISCLAIMERS_ACCEPTED: KycConsentRecord[] = + MOCK_SESSION_DISCLAIMERS.kycProvider.map(({ key, version }) => ({ + key, + version, + })); + /** * Builds an encrypted envelope for a recipient's X25519 public key. * @@ -357,7 +363,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'ramps', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -405,13 +411,15 @@ describe('KycController', () => { handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); expect(controller.state.termsAcceptedVendor).toBe('moonpay'); - expect(controller.state.sumsubTncAccepted).toBe(true); + expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual( + MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + ); expect(controller.state.idosDisclaimersAccepted).toStrictEqual( MOCK_IDOS_DISCLAIMERS_ACCEPTED, ); @@ -456,7 +464,7 @@ describe('KycController', () => { // Creating a new session must invalidate the carried-over auth. await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -491,7 +499,7 @@ describe('KycController', () => { ); const pending = controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -528,7 +536,7 @@ describe('KycController', () => { handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -565,7 +573,7 @@ describe('KycController', () => { ); const pending = controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -600,7 +608,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ product: 'ramps', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -621,7 +629,7 @@ describe('KycController', () => { }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -635,7 +643,7 @@ describe('KycController', () => { await withController(async ({ controller }) => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -2380,7 +2388,7 @@ describe('KycController', () => { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'], termsAcceptedVendor: 'iron', - sumsubTncAccepted: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], disclaimersError: 'stale disclaimers error', @@ -2675,7 +2683,7 @@ describe('KycController', () => { termsAcceptedAt: 't', acceptedDisclaimerIds: ['d1'], termsAcceptedVendor: 'iron', - sumsubTncAccepted: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }, userStatusPollIntervalMs: 60_000, @@ -2746,7 +2754,7 @@ describe('KycController', () => { acceptedDisclaimerIds: ['d1'], termsAcceptedVendor: 'iron', // T&C2 flags are null, simulating pre-migration state - sumsubTncAccepted: null, + sumsubDisclaimersAccepted: null, idosDisclaimersAccepted: null, }, }, @@ -2761,7 +2769,7 @@ describe('KycController', () => { // T&C2 flags were null; reacceptance required. expect(controller.state.phase).toBe('terms'); expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.sumsubTncAccepted).toBeNull(); + expect(controller.state.sumsubDisclaimersAccepted).toBeNull(); expect(controller.state.idosDisclaimersAccepted).toBeNull(); }, ); @@ -2890,12 +2898,14 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.termsAcceptedVendor).toBe('iron'); - expect(controller.state.sumsubTncAccepted).toBe(true); + expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual( + MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + ); expect(controller.state.idosDisclaimersAccepted).toStrictEqual( MOCK_IDOS_DISCLAIMERS_ACCEPTED, ); @@ -3051,7 +3061,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3120,7 +3130,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3178,7 +3188,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3226,7 +3236,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: [], }); @@ -3271,7 +3281,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3308,7 +3318,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3359,7 +3369,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: [ { key: 'idos-tos', version: '1' }, { key: 'idos-privacy', version: '2' }, @@ -3408,7 +3418,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3458,7 +3468,7 @@ describe('KycController', () => { // @ts-expect-error both T&C2 flags are required await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3488,12 +3498,12 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: false, + sumsubDisclaimersAccepted: [], idosDisclaimersAccepted: [], }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.sumsubTncAccepted).toBe(false); + expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual([]); expect(controller.state.idosDisclaimersAccepted).toStrictEqual([]); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', @@ -3517,7 +3527,7 @@ describe('KycController', () => { }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3541,7 +3551,7 @@ describe('KycController', () => { async ({ controller }) => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3571,7 +3581,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3598,7 +3608,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3628,7 +3638,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3667,7 +3677,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3705,7 +3715,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3740,7 +3750,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); controller.reset(); @@ -3776,7 +3786,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // Session create + session disclaimers run first; wait until launch @@ -3816,7 +3826,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); while (handlers.createUkycSession.mock.calls.length === 0) { @@ -3854,7 +3864,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3895,7 +3905,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3923,7 +3933,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3951,7 +3961,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3982,7 +3992,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4013,7 +4023,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4046,7 +4056,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4081,7 +4091,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4126,7 +4136,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4156,7 +4166,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4184,7 +4194,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4212,7 +4222,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4590,7 +4600,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4619,7 +4629,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubTncSigned: true, + sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index e78853398cf..aa28eb4f811 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -160,12 +160,12 @@ export type KycControllerState = { */ termsAcceptedVendor: KycVendor | null; /** - * Whether the customer accepted the SumSub T&C (T&C2) during the last - * terms acceptance (persisted). Consents-path vendors require this flag - * when resuming a session. `null` for acceptance recorded before this - * field existed (treated as requiring reacceptance). + * Sumsub disclaimer documents the customer accepted during the last terms + * acceptance (persisted `{ key, version }` records). Consents-path vendors + * require this when resuming a session. `null` for acceptance recorded + * before this field existed (treated as requiring reacceptance). */ - sumsubTncAccepted: boolean | null; + sumsubDisclaimersAccepted: KycConsentRecord[] | null; /** * idOS disclaimer documents the customer accepted during the last terms * acceptance (persisted `{ key, version }` records). Consents-path vendors @@ -291,7 +291,7 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, - sumsubTncAccepted: { + sumsubDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, @@ -415,7 +415,7 @@ export function getDefaultKycControllerState(): KycControllerState { termsAcceptedAt: null, acceptedDisclaimerIds: [], termsAcceptedVendor: null, - sumsubTncAccepted: null, + sumsubDisclaimersAccepted: null, idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, disclaimers: [], @@ -471,27 +471,6 @@ function isConsentConflictError(error: unknown): boolean { ); } -/** - * Maps a session-disclaimer catalog into the `{ key, version }` records the - * record-consents API expects, or an empty list when the user declined that - * category. - * - * @param documents - Catalog documents for one consent category. - * @param accepted - Whether the user accepted that category. - * @returns Consent records, or `[]` when not accepted. - */ -function consentRecordsFromCatalog( - documents: KycConsentDocument[], - accepted: boolean, -): KycConsentRecord[] { - if (!accepted) { - return []; - } - return documents - .filter((document) => !document.consented) - .map(({ key, version }) => ({ key, version })); -} - /** * Whether a value is a list of consent records (`{ key, version }`). * @@ -512,9 +491,9 @@ function isValidConsentRecordList(value: unknown): value is KycConsentRecord[] { } /** - * Maps accepted idOS disclaimer records onto unconsented catalog documents. + * Maps accepted disclaimer records onto unconsented catalog documents. * - * @param documents - Catalog documents for the idOS category. + * @param documents - Catalog documents for one consent category. * @param accepted - Accepted `{ key, version }` records from the caller. * @returns Consent records to POST, omitting already-consented documents. */ @@ -538,13 +517,13 @@ function consentRecordsFromAcceptedList( } /** - * Whether accepted idOS disclaimers reference a missing catalog category. + * Whether accepted disclaimers reference a missing catalog category. * - * @param documents - Catalog documents for the idOS category. + * @param documents - Catalog documents for one consent category. * @param accepted - Accepted `{ key, version }` records from the caller. - * @returns `true` when the caller accepted idOS docs but the catalog is empty. + * @returns `true` when the caller accepted docs but the catalog is empty. */ -function isAcceptedIdosCategoryEmpty( +function isAcceptedCategoryEmpty( documents: KycConsentDocument[], accepted: KycConsentRecord[], ): boolean { @@ -552,14 +531,14 @@ function isAcceptedIdosCategoryEmpty( } /** - * Whether accepted idOS disclaimers are still missing consent after a 409 - * re-GET: empty catalog or any accepted document still unconsented. + * Whether accepted disclaimers are still missing consent after a 409 re-GET: + * empty catalog or any accepted document still unconsented. * - * @param documents - Latest catalog documents for the idOS category. + * @param documents - Latest catalog documents for one consent category. * @param accepted - Accepted `{ key, version }` records from the caller. - * @returns `true` when accepted idOS documents are not fully consented. + * @returns `true` when accepted documents are not fully consented. */ -function acceptedIdosCategoryStillMissing( +function acceptedCategoryStillMissing( documents: KycConsentDocument[], accepted: KycConsentRecord[], ): boolean { @@ -580,40 +559,6 @@ function acceptedIdosCategoryStillMissing( ); } -/** - * Whether an accepted T&C2 category has no catalog documents. An empty list - * would otherwise skip the POST and count as success. - * - * @param documents - Catalog documents for one consent category. - * @param accepted - Whether the user accepted that category. - * @returns `true` when the user accepted and the catalog is empty. - */ -function isAcceptedCategoryEmpty( - documents: KycConsentDocument[], - accepted: boolean, -): boolean { - return accepted && documents.length === 0; -} - -/** - * Whether an accepted category is still missing consent after a 409 re-GET: - * empty catalog or any document still unconsented. - * - * @param documents - Latest catalog documents for one consent category. - * @param accepted - Whether the user accepted that category. - * @returns `true` when accepted documents are not fully consented. - */ -function acceptedCategoryStillMissing( - documents: KycConsentDocument[], - accepted: boolean, -): boolean { - return ( - accepted && - (documents.length === 0 || - documents.some((document) => !document.consented)) - ); -} - /** * Vendors other than MoonPay skip Check/Auth frames and use the empty-shell * customer + consents path instead. @@ -975,9 +920,9 @@ export class KycController extends BaseController< if (usesConsentsFlow(vendor)) { // Consents-path vendors require T&C2 flags; if they weren't persisted // (i.e. null from pre-migration state), require reacceptance. - const { sumsubTncAccepted: sumsubTncSigned, idosDisclaimersAccepted } = + const { sumsubDisclaimersAccepted, idosDisclaimersAccepted } = this.state; - if (sumsubTncSigned === null || idosDisclaimersAccepted === null) { + if (sumsubDisclaimersAccepted === null || idosDisclaimersAccepted === null) { this.#applyUpdate((state) => { this.#clearAcceptedTerms(state); state.phase = 'terms'; @@ -986,7 +931,7 @@ export class KycController extends BaseController< return; } await this.#startConsentsSession({ - sumsubTncSigned, + sumsubDisclaimersAccepted, idosDisclaimersAccepted, credentialReusabilityConsentGiven: this.state.credentialReusabilityConsentGiven ?? false, @@ -1101,8 +1046,9 @@ export class KycController extends BaseController< * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. - * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). - * Required for every vendor so callers explicitly declare acceptance. + * @param params.sumsubDisclaimersAccepted - Sumsub disclaimer documents the + * customer accepted (`{ key, version }` records). Required for every vendor + * so callers explicitly declare acceptance. * @param params.idosDisclaimersAccepted - idOS disclaimer documents the * customer accepted (`{ key, version }` records). Required for every vendor * so callers explicitly declare acceptance. @@ -1110,46 +1056,47 @@ export class KycController extends BaseController< * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. */ - async acceptTermsAndStartSession(params: { + async acceptTermsAndStartSession(params?: { email?: string; product?: KycProduct; - sumsubTncSigned: boolean; + sumsubDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven?: boolean; }): Promise { - const { sumsubTncSigned, idosDisclaimersAccepted } = params; + const sumsubDisclaimersAccepted = params?.sumsubDisclaimersAccepted; + const idosDisclaimersAccepted = params?.idosDisclaimersAccepted; if ( - typeof sumsubTncSigned !== 'boolean' || + !isValidConsentRecordList(sumsubDisclaimersAccepted) || !isValidConsentRecordList(idosDisclaimersAccepted) ) { this.#fail('Missing T&C2 acceptance flags.'); return; } const credentialReusabilityConsentGiven = - params.credentialReusabilityConsentGiven ?? false; + params?.credentialReusabilityConsentGiven ?? false; const termsAcceptedAt = new Date().toISOString(); const disclaimerIds = this.state.disclaimers.map( (disclaimer) => disclaimer.id, ); this.#applyUpdate((state) => { - if (params.email) { + if (params?.email) { state.email = params.email; } - if (params.product) { + if (params?.product) { state.activeProduct = params.product; } state.termsAcceptedAt = termsAcceptedAt; state.acceptedDisclaimerIds = disclaimerIds; state.termsAcceptedVendor = state.activeVendor; - state.sumsubTncAccepted = sumsubTncSigned; + state.sumsubDisclaimersAccepted = sumsubDisclaimersAccepted; state.idosDisclaimersAccepted = idosDisclaimersAccepted; state.credentialReusabilityConsentGiven = credentialReusabilityConsentGiven; }); if (usesConsentsFlow(this.state.activeVendor)) { await this.#startConsentsSession({ - sumsubTncSigned, + sumsubDisclaimersAccepted, idosDisclaimersAccepted, credentialReusabilityConsentGiven, }); @@ -1164,13 +1111,13 @@ export class KycController extends BaseController< * SumSub — skipping MoonPay Check/Auth frames. * * @param consents - T&C2 flags mapped onto the session disclaimer catalog. - * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. + * @param consents.sumsubDisclaimersAccepted - Accepted Sumsub disclaimer records. * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. */ async #startConsentsSession(consents: { - sumsubTncSigned: boolean; + sumsubDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }): Promise { @@ -1321,7 +1268,7 @@ export class KycController extends BaseController< * * @param sessionId - The UKYC session id. * @param consents - T&C2 flags mapped onto catalog documents. - * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. + * @param consents.sumsubDisclaimersAccepted - Accepted Sumsub disclaimer records. * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. @@ -1330,7 +1277,7 @@ export class KycController extends BaseController< async #recordSessionDisclaimers( sessionId: string, consents: { - sumsubTncSigned: boolean; + sumsubDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }, @@ -1349,11 +1296,14 @@ export class KycController extends BaseController< }); if ( - isAcceptedIdosCategoryEmpty( + isAcceptedCategoryEmpty( catalog.idOS, consents.idosDisclaimersAccepted, ) || - isAcceptedCategoryEmpty(catalog.kycProvider, consents.sumsubTncSigned) + isAcceptedCategoryEmpty( + catalog.kycProvider, + consents.sumsubDisclaimersAccepted, + ) ) { throw new Error( 'Session disclaimer catalog is missing documents for an accepted category.', @@ -1364,9 +1314,9 @@ export class KycController extends BaseController< catalog.idOS, consents.idosDisclaimersAccepted, ); - const kycProvider = consentRecordsFromCatalog( + const kycProvider = consentRecordsFromAcceptedList( catalog.kycProvider, - consents.sumsubTncSigned, + consents.sumsubDisclaimersAccepted, ); const reuseUnchanged = catalog.credentialReusabilityConsentGiven === @@ -1406,13 +1356,13 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.sessionDisclaimers = latest; }); - const stillMissingIdos = acceptedIdosCategoryStillMissing( + const stillMissingIdos = acceptedCategoryStillMissing( latest.idOS, consents.idosDisclaimersAccepted, ); const stillMissingProvider = acceptedCategoryStillMissing( latest.kycProvider, - consents.sumsubTncSigned, + consents.sumsubDisclaimersAccepted, ); const stillMissingReuse = consents.credentialReusabilityConsentGiven && @@ -1515,7 +1465,7 @@ export class KycController extends BaseController< state.termsAcceptedAt = null; state.acceptedDisclaimerIds = []; state.termsAcceptedVendor = null; - state.sumsubTncAccepted = null; + state.sumsubDisclaimersAccepted = null; state.idosDisclaimersAccepted = null; state.credentialReusabilityConsentGiven = null; } From 4265021bb05f785a7d4b91b068d80c7c8d87ed3c Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 13:25:46 -0700 Subject: [PATCH 03/10] combine termsAcceptedAt and acceptedDisclaimerIds into vendorDisclaimersAccepted --- packages/kyc-controller/ARCHITECTURE.md | 8 +- packages/kyc-controller/CHANGELOG.md | 1 + .../kyc-controller/src/KycController.test.ts | 216 ++++++++++-------- packages/kyc-controller/src/KycController.ts | 166 ++++++++------ packages/kyc-controller/src/index.ts | 4 + packages/kyc-controller/src/types.ts | 26 +++ 6 files changed, 248 insertions(+), 173 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 4a49a8f6d30..c5c5535141b 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -177,9 +177,7 @@ classDiagram +string statusMessage +string error +string email - +string termsAcceptedAt [persisted] - +string[] acceptedDisclaimerIds [persisted] - +KycVendor termsAcceptedVendor [persisted] + +KycVendorDisclaimersAccepted vendorDisclaimersAccepted [persisted] +KycDisclaimer[] disclaimers +string disclaimersError +string geoCountry @@ -206,8 +204,8 @@ classDiagram State metadata highlights (`kycControllerMetadata`): -- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, - `termsAcceptedVendor`, `sumsubDisclaimersAccepted`, `idosDisclaimersAccepted`, +- **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, + `sumsubDisclaimersAccepted`, `idosDisclaimersAccepted`, `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow can skip already-accepted terms and reuse cached results. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 50ded9ab7ea..d930a6f7930 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). - **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. - **BREAKING:** Rename persisted `sumsubTncAccepted` to `sumsubDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `sumsubDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index aadeeb7ab72..917a647994e 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -85,6 +85,39 @@ const MOCK_SUMSUB_DISCLAIMERS_ACCEPTED: KycConsentRecord[] = version, })); +const DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED = { + moonpay: null, + iron: null, +}; + +const VENDOR_TERMS_MOONPAY = { + vendorDisclaimersAccepted: { + moonpay: { termsAcceptedAt: 't' }, + iron: null, + }, +}; + +const VENDOR_TERMS_MOONPAY_D1 = { + vendorDisclaimersAccepted: { + moonpay: { termsAcceptedAt: 't' }, + iron: null, + }, +}; + +const VENDOR_TERMS_IRON = { + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }, +}; + +const VENDOR_TERMS_IRON_D1 = { + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['iron-d1'] }, + }, +}; + /** * Builds an encrypted envelope for a recipient's X25519 public key. * @@ -149,14 +182,15 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY, }, }, }, async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: '1', display_name: 'T', url: 'u' }, + ]); handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); await controller.initialize({ email: 'a@b.co' }); @@ -212,8 +246,7 @@ describe('KycController', () => { phase: 'check', email: 'a@b.co', sessionToken: 'live-session', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], + ...VENDOR_TERMS_MOONPAY, activeProduct: 'ramps', activeVendor: 'moonpay', moonpayCustomerId: 'cust-1', @@ -247,7 +280,7 @@ describe('KycController', () => { await withController( { options: { - state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + state: { ...VENDOR_TERMS_MOONPAY }, }, }, async ({ controller, handlers }) => { @@ -367,8 +400,10 @@ describe('KycController', () => { idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); - expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBeDefined(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); expect(controller.state.activeProduct).toBe('ramps'); expect(controller.state.phase).toBe('check'); }, @@ -391,7 +426,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(handlers.createSession).not.toHaveBeenCalled(); }, ); @@ -415,8 +450,10 @@ describe('KycController', () => { idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBeDefined(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual( MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, ); @@ -541,7 +578,7 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(controller.state.error).toMatch(/Session creation failed/u); // A failed creation must not leave the old session token behind, so // the Check frame cannot be built against an invalid session. @@ -658,13 +695,15 @@ describe('KycController', () => { await withController( { options: { - state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + state: { ...VENDOR_TERMS_MOONPAY }, }, }, ({ controller }) => { controller.clearSavedTerms(); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted).toStrictEqual( + DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED, + ); }, ); }); @@ -853,8 +892,7 @@ describe('KycController', () => { state: { phase: 'check', sessionToken: 'tok', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], + ...VENDOR_TERMS_MOONPAY, }, }, }, @@ -867,7 +905,7 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); }, ); }); @@ -1132,9 +1170,7 @@ describe('KycController', () => { geoCountry: 'USA', // Persisted terms so a post-reset `initialize` auto-recreates the // session (reaching phase `check`) for the second completion. - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY, }, }, }, @@ -1185,6 +1221,10 @@ describe('KycController', () => { // Re-establish a product-scoped flow (auto-creates a session and // returns to phase `check`) and confirm the next completion continues // again rather than being blocked forever by a stuck guard. + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: '1', display_name: 'T', url: 'u' }, + ]); + handlers.createSession.mockResolvedValue({ sessionToken: 'tok-2' }); await controller.initialize({ product: 'ramps' }); handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); await controller.handleFrameMessage({ @@ -2335,8 +2375,7 @@ describe('KycController', () => { sessionToken: 'tok', accessToken: 'a', activeProduct: 'ramps', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], + ...VENDOR_TERMS_MOONPAY, kycRequiredByProduct: { ramps: true }, }, }, @@ -2347,7 +2386,9 @@ describe('KycController', () => { expect(controller.state.sessionToken).toBeNull(); expect(controller.state.accessToken).toBeNull(); expect(controller.state.activeProduct).toBeNull(); - expect(controller.state.termsAcceptedAt).toBe('t'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); expect(controller.state.kycRequiredByProduct.ramps).toBe(true); }, ); @@ -2385,9 +2426,10 @@ describe('KycController', () => { statusMessage: 'Review to submit.', error: 'stale error', email: 'a@b.co', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['1'], - termsAcceptedVendor: 'iron', + vendorDisclaimersAccepted: { + moonpay: null, + iron: { disclaimerIds: ['1'] }, + }, sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], @@ -2591,9 +2633,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2605,11 +2645,10 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); expect(controller.state.phase).toBe('error'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); }, ); }); @@ -2619,9 +2658,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2637,11 +2674,10 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('idle'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); }, ); }); @@ -2680,9 +2716,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON, sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }, @@ -2719,9 +2753,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2733,9 +2765,10 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); - expect(controller.state.termsAcceptedVendor).toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toStrictEqual(VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', country: 'USA', @@ -2750,9 +2783,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON, // T&C2 flags are null, simulating pre-migration state sumsubDisclaimersAccepted: null, idosDisclaimersAccepted: null, @@ -2768,7 +2799,7 @@ describe('KycController', () => { // T&C2 flags were null; reacceptance required. expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(controller.state.sumsubDisclaimersAccepted).toBeNull(); expect(controller.state.idosDisclaimersAccepted).toBeNull(); }, @@ -2780,9 +2811,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['iron-d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON_D1, }, }, }, @@ -2790,20 +2819,21 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'moonpay' }); expect(handlers.createSession).not.toHaveBeenCalled(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect( + controller.state.vendorDisclaimersAccepted.iron, + ).toStrictEqual(VENDOR_TERMS_IRON_D1.vendorDisclaimersAccepted.iron); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(controller.state.phase).toBe('terms'); }, ); }); - it('drops another vendor terms acceptance when createVendorCustomer switches vendor', async () => { + it('preserves another vendor disclaimer acceptance when createVendorCustomer switches vendor', async () => { await withController( { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2813,9 +2843,10 @@ describe('KycController', () => { email: 'a@b.co', }); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); - expect(controller.state.termsAcceptedVendor).toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toStrictEqual(VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); }, ); }); @@ -2826,9 +2857,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['iron-d1'], - termsAcceptedVendor: 'iron', + ...VENDOR_TERMS_IRON_D1, }, }, }, @@ -2838,10 +2867,12 @@ describe('KycController', () => { email: 'a@b.co', }); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + expect( + controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, + ).toStrictEqual([ 'iron-d1', ]); - expect(controller.state.termsAcceptedVendor).toBe('iron'); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); }, ); }); @@ -2902,7 +2933,7 @@ describe('KycController', () => { idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.termsAcceptedVendor).toBe('iron'); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual( MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, ); @@ -2934,9 +2965,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2949,11 +2978,10 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('error'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); }, ); }); @@ -2963,9 +2991,7 @@ describe('KycController', () => { { options: { state: { - termsAcceptedAt: 't', - acceptedDisclaimerIds: ['moonpay-d1'], - termsAcceptedVendor: 'moonpay', + ...VENDOR_TERMS_MOONPAY_D1, }, }, }, @@ -2992,11 +3018,10 @@ describe('KycController', () => { await pending; expect(controller.state.phase).toBe('idle'); - expect(controller.state.termsAcceptedAt).toBe('t'); - expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ - 'moonpay-d1', - ]); - expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect( + controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, + ).toBe('t'); + expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); }, ); }); @@ -3448,8 +3473,9 @@ describe('KycController', () => { expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); - expect(controller.state.termsAcceptedAt).toBeNull(); - expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.vendorDisclaimersAccepted).toStrictEqual( + DEFAULT_VENDOR_DISCLAIMERS_ACCEPTED, + ); }, ); }); @@ -3586,7 +3612,7 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(controller.state.error).toMatch(/Consents session failed/u); }, ); @@ -3645,7 +3671,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.sumsub.status).toBe('idle'); expect(controller.state.sumsub.sessionId).toBeNull(); - expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(controller.state.error).toMatch(/Consents session failed/u); expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); }, @@ -3686,7 +3712,9 @@ describe('KycController', () => { expect(controller.state.sumsub.sessionStatus).toStrictEqual( sessionStatus('rejected'), ); - expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, + ).toStrictEqual(['d1']); expect(controller.state.error).toBeNull(); expect(handlers.fetchKycStatus).toHaveBeenCalled(); expect(controller.state.userStatus).toBe('terminal-failure'); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index aa28eb4f811..5d2ce0946da 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -35,6 +35,7 @@ import type { KycSumSubStatus, KycUserStatus, KycVendor, + KycVendorDisclaimersAccepted, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; @@ -149,16 +150,12 @@ export type KycControllerState = { /** Email associated with the session (sourced from the account). */ email: string | null; - /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */ - termsAcceptedAt: string | null; - /** IDs of the disclaimers the customer accepted (persisted). */ - acceptedDisclaimerIds: string[]; /** - * The vendor whose disclaimers `acceptedDisclaimerIds` belong to (persisted). - * Each vendor serves its own disclaimer set, so acceptance recorded for one - * vendor must not be reused for another. `null` when nothing is accepted. + * Persisted vendor-disclaimer acceptance (T&C1) with fixed `moonpay` and + * `iron` keys. MoonPay stores only `termsAcceptedAt`; Iron stores + * `disclaimerIds`. */ - termsAcceptedVendor: KycVendor | null; + vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; /** * Sumsub disclaimer documents the customer accepted during the last terms * acceptance (persisted `{ key, version }` records). Consents-path vendors @@ -273,19 +270,7 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, - termsAcceptedAt: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - acceptedDisclaimerIds: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - termsAcceptedVendor: { + vendorDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, @@ -401,6 +386,15 @@ const kycControllerMetadata = { }, } satisfies StateMetadata; +/** + * Constructs the default {@link KycVendorDisclaimersAccepted} value. + * + * @returns The default vendor-disclaimer acceptance map. + */ +export function getDefaultKycVendorDisclaimersAccepted(): KycVendorDisclaimersAccepted { + return { moonpay: null, iron: null }; +} + /** * Constructs the default {@link KycController} state. * @@ -412,9 +406,7 @@ export function getDefaultKycControllerState(): KycControllerState { statusMessage: '', error: null, email: null, - termsAcceptedAt: null, - acceptedDisclaimerIds: [], - termsAcceptedVendor: null, + vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), sumsubDisclaimersAccepted: null, idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, @@ -471,8 +463,30 @@ function isConsentConflictError(error: unknown): boolean { ); } +function hasVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, +): boolean { + if (vendor === 'moonpay') { + return Boolean(accepted.moonpay?.termsAcceptedAt); + } + if (vendor === 'iron') { + return Boolean(accepted.iron?.disclaimerIds.length); + } + return false; +} + +/** + * Returns persisted Iron disclaimer ids, if any. + * + * @param accepted - Vendor-disclaimer acceptance map. + * @returns The accepted disclaimer ids, or an empty array. + */ +function ironDisclaimerIds(accepted: KycVendorDisclaimersAccepted): string[] { + return accepted.iron?.disclaimerIds ?? []; +} + /** - * Whether a value is a list of consent records (`{ key, version }`). * * @param value - The value to validate. * @returns `true` when `value` is a valid consent record list. @@ -910,11 +924,10 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - this.#dropTermsUnlessForVendor(vendor); - - const hasTerms = - Boolean(this.state.termsAcceptedAt) && - this.state.acceptedDisclaimerIds.length > 0; + const hasTerms = hasVendorDisclaimerAcceptance( + this.state.vendorDisclaimersAccepted, + vendor, + ); if (hasTerms && this.state.email) { if (usesConsentsFlow(vendor)) { @@ -937,6 +950,15 @@ export class KycController extends BaseController< this.state.credentialReusabilityConsentGiven ?? false, }); } else { + if ( + vendor === 'moonpay' && + this.state.disclaimers.length === 0 + ) { + await this.loadDisclaimers(); + if (this.#generation !== generation) { + return; + } + } await this.#createSession(); } return; @@ -989,7 +1011,6 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - this.#dropTermsUnlessForVendor(params.vendor); } catch (error) { if (this.#generation !== generation) { return; @@ -1086,9 +1107,17 @@ export class KycController extends BaseController< if (params?.product) { state.activeProduct = params.product; } - state.termsAcceptedAt = termsAcceptedAt; - state.acceptedDisclaimerIds = disclaimerIds; - state.termsAcceptedVendor = state.activeVendor; + if (state.activeVendor === 'moonpay') { + state.vendorDisclaimersAccepted = { + ...state.vendorDisclaimersAccepted, + moonpay: { termsAcceptedAt }, + }; + } else if (state.activeVendor === 'iron') { + state.vendorDisclaimersAccepted = { + ...state.vendorDisclaimersAccepted, + iron: { disclaimerIds }, + }; + } state.sumsubDisclaimersAccepted = sumsubDisclaimersAccepted; state.idosDisclaimersAccepted = idosDisclaimersAccepted; state.credentialReusabilityConsentGiven = @@ -1121,7 +1150,10 @@ export class KycController extends BaseController< idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }): Promise { - const { email, acceptedDisclaimerIds } = this.state; + const { email } = this.state; + const acceptedDisclaimerIds = ironDisclaimerIds( + this.state.vendorDisclaimersAccepted, + ); if (!email) { this.#fail('Missing email for consents session.'); return; @@ -1377,7 +1409,12 @@ export class KycController extends BaseController< * Creates a vendor session from the currently stored terms + email. */ async #createSession(): Promise { - const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state; + const { email } = this.state; + const termsAcceptedAt = + this.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt; + const acceptedDisclaimerIds = this.state.disclaimers.map( + (disclaimer) => disclaimer.id, + ); if (!email) { this.#fail('Missing email for session creation.'); return; @@ -1448,7 +1485,10 @@ export class KycController extends BaseController< */ clearSavedTerms(): void { this.#applyUpdate((state) => { - this.#clearAcceptedTerms(state); + state.vendorDisclaimersAccepted = getDefaultKycVendorDisclaimersAccepted(); + state.sumsubDisclaimersAccepted = null; + state.idosDisclaimersAccepted = null; + state.credentialReusabilityConsentGiven = null; }); } @@ -1460,11 +1500,22 @@ export class KycController extends BaseController< * rest of the flow (geolocation, disclaimers, phase) untouched. * * @param state - The state to mutate. + * @param vendor - Vendor whose acceptance to clear. Defaults to + * `state.activeVendor`. */ - #clearAcceptedTerms(state: KycControllerState): void { - state.termsAcceptedAt = null; - state.acceptedDisclaimerIds = []; - state.termsAcceptedVendor = null; + #clearAcceptedTerms(state: KycControllerState, vendor?: KycVendor): void { + const targetVendor = vendor ?? state.activeVendor; + if (targetVendor === 'moonpay') { + state.vendorDisclaimersAccepted = { + ...state.vendorDisclaimersAccepted, + moonpay: null, + }; + } else if (targetVendor === 'iron') { + state.vendorDisclaimersAccepted = { + ...state.vendorDisclaimersAccepted, + iron: null, + }; + } state.sumsubDisclaimersAccepted = null; state.idosDisclaimersAccepted = null; state.credentialReusabilityConsentGiven = null; @@ -1484,39 +1535,6 @@ export class KycController extends BaseController< state.accessToken = null; } - /** - * Drops persisted terms acceptance when it does not belong to `vendor`. - * Callers must invoke this only after the vendor switch has committed - * (e.g. `createVendorCustomer` succeeded) so a failed or reset switch - * cannot erase another vendor's stored acceptance. - * - * @param vendor - The vendor that now owns the flow. - */ - #dropTermsUnlessForVendor(vendor: KycVendor): void { - if (this.#hasTermsForVendor(vendor)) { - return; - } - this.#applyUpdate((state) => { - this.#clearAcceptedTerms(state); - }); - } - - /** - * Determines whether the stored terms acceptance belongs to the given - * vendor. Acceptance persisted before `termsAcceptedVendor` existed - * (indicated by `null`) is invalidated to force reacceptance, ensuring users - * re-review vendor terms after the multi-vendor upgrade. - * - * @param vendor - The vendor about to drive the flow. - * @returns `true` when the stored acceptance can be reused for `vendor`. - */ - #hasTermsForVendor(vendor: KycVendor): boolean { - if (this.state.termsAcceptedVendor === null) { - return false; - } - return this.state.termsAcceptedVendor === vendor; - } - /** * Handles a message posted by a Check/Auth frame and advances the flow. * diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index fdfb9d3fe7b..560d10eddde 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -1,6 +1,7 @@ export { KycController, getDefaultKycControllerState, + getDefaultKycVendorDisclaimersAccepted, controllerName, } from './KycController.js'; export type { @@ -109,6 +110,9 @@ export type { KycUserStatus, KycUserStatusResponse, KycVendor, + KycIronVendorDisclaimersAccepted, + KycMoonpayVendorDisclaimersAccepted, + KycVendorDisclaimersAccepted, KycVendorSigning, } from './types.js'; diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 240f8d6eb0f..4d7124e1ed4 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -192,6 +192,32 @@ export type KycConsentRecord = { version: string; }; +/** + * MoonPay vendor T&C1 acceptance persisted under + * {@link KycVendorDisclaimersAccepted.moonpay}. + */ +export type KycMoonpayVendorDisclaimersAccepted = { + /** ISO-8601 timestamp of terms acceptance for MoonPay. */ + termsAcceptedAt: string; +}; + +/** + * Iron vendor T&C1 acceptance persisted under + * {@link KycVendorDisclaimersAccepted.iron}. + */ +export type KycIronVendorDisclaimersAccepted = { + /** IDs of Iron vendor disclaimers the customer accepted. */ + disclaimerIds: string[]; +}; + +/** + * Persisted vendor-disclaimer acceptance with fixed `moonpay` and `iron` keys. + */ +export type KycVendorDisclaimersAccepted = { + moonpay: KycMoonpayVendorDisclaimersAccepted | null; + iron: KycIronVendorDisclaimersAccepted | null; +}; + /** * idOS / KYC-provider disclaimer catalog returned by * `GET /disclaimers?country=` (no session — no credential-reuse consent state). From 84ba0c0d9e860ae63823af94762eeb4aa24eea0e Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 13:38:54 -0700 Subject: [PATCH 04/10] change sumsubDisclaimersAccepted to providerDisclaimersAccepted --- packages/kyc-controller/ARCHITECTURE.md | 8 +- packages/kyc-controller/CHANGELOG.md | 2 +- .../src/KycController-method-action-types.ts | 2 +- .../kyc-controller/src/KycController.test.ts | 106 +++++++++--------- packages/kyc-controller/src/KycController.ts | 64 ++++++----- packages/kyc-controller/src/index.ts | 2 + packages/kyc-controller/src/types.ts | 8 ++ 7 files changed, 110 insertions(+), 82 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index c5c5535141b..1c4e6e40ca1 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -205,7 +205,7 @@ classDiagram State metadata highlights (`kycControllerMetadata`): - **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, - `sumsubDisclaimersAccepted`, `idosDisclaimersAccepted`, + `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow can skip already-accepted terms and reuse cached results. Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory @@ -239,7 +239,7 @@ stateDiagram-v2 idle --> terms : initialize() (no saved terms) idle --> session : initialize() (saved terms + email) - terms --> session : acceptTermsAndStartSession({ sumsubDisclaimersAccepted, idosDisclaimersAccepted }) + terms --> session : acceptTermsAndStartSession({ providerDisclaimersAccepted, idosDisclaimersAccepted }) session --> check : createSession() ok session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) @@ -277,7 +277,7 @@ stateDiagram-v2 > or SDK close without completion) rewinds to `terms` instead of forcing `done`. > A terminal UKYC rejection after the SDK reported `Completed` still finishes as > `done` so `refreshKycStatus` can surface the decision. -> `acceptTermsAndStartSession` requires `sumsubDisclaimersAccepted` and `idosDisclaimersAccepted` +> `acceptTermsAndStartSession` requires `providerDisclaimersAccepted` and `idosDisclaimersAccepted` > (T&C2) for every vendor; omitted flags fail the flow instead of defaulting to > `true`. Those flags are mapped onto the session catalog's `idOS` / > `kycProvider` document records; `credentialReusabilityConsentGiven` is @@ -345,7 +345,7 @@ sequenceDiagram Svc->>API: GET /vendors/moonpay/disclaimers?country= Ctrl-->>UI: phase = terms (+ disclaimers) - User->>Ctrl: acceptTermsAndStartSession({ email, sumsubDisclaimersAccepted, idosDisclaimersAccepted }) + User->>Ctrl: acceptTermsAndStartSession({ email, providerDisclaimersAccepted, idosDisclaimersAccepted }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) Svc->>API: POST /sessions Ctrl-->>UI: phase = check (+ sessionToken) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index d930a6f7930..e6162f1a8fb 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). - **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. -- **BREAKING:** Rename persisted `sumsubTncAccepted` to `sumsubDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `sumsubDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. +- **BREAKING:** Rename persisted `sumsubTncAccepted` to `providerDisclaimersAccepted`: a fixed map with `sumsub: KycConsentRecord[] | null` (default `{ sumsub: null }`). `acceptTermsAndStartSession` now takes `providerDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index cf56ad19a92..18d0f49778c 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -62,7 +62,7 @@ export type KycControllerLoadDisclaimersAction = { * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. - * @param params.sumsubDisclaimersAccepted - Sumsub disclaimer documents the + * @param params.providerDisclaimersAccepted - Sumsub disclaimer documents the * customer accepted (`{ key, version }` records). Required for every vendor * so callers explicitly declare acceptance. * @param params.idosDisclaimersAccepted - idOS disclaimer documents the diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 917a647994e..35b6dd84148 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -396,7 +396,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'ramps', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -446,7 +446,7 @@ describe('KycController', () => { handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); await controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -454,7 +454,7 @@ describe('KycController', () => { controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBeDefined(); expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); - expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual( + expect(controller.state.providerDisclaimersAccepted.sumsub).toStrictEqual( MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, ); expect(controller.state.idosDisclaimersAccepted).toStrictEqual( @@ -501,7 +501,7 @@ describe('KycController', () => { // Creating a new session must invalidate the carried-over auth. await controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -536,7 +536,7 @@ describe('KycController', () => { ); const pending = controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -573,7 +573,7 @@ describe('KycController', () => { handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -610,7 +610,7 @@ describe('KycController', () => { ); const pending = controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -645,7 +645,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ product: 'ramps', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -666,7 +666,7 @@ describe('KycController', () => { }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -680,7 +680,7 @@ describe('KycController', () => { await withController(async ({ controller }) => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -2430,7 +2430,9 @@ describe('KycController', () => { moonpay: null, iron: { disclaimerIds: ['1'] }, }, - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: { + sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + }, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], disclaimersError: 'stale disclaimers error', @@ -2717,7 +2719,9 @@ describe('KycController', () => { options: { state: { ...VENDOR_TERMS_IRON, - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: { + sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + }, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }, userStatusPollIntervalMs: 60_000, @@ -2785,7 +2789,7 @@ describe('KycController', () => { state: { ...VENDOR_TERMS_IRON, // T&C2 flags are null, simulating pre-migration state - sumsubDisclaimersAccepted: null, + providerDisclaimersAccepted: { sumsub: null }, idosDisclaimersAccepted: null, }, }, @@ -2800,7 +2804,7 @@ describe('KycController', () => { // T&C2 flags were null; reacceptance required. expect(controller.state.phase).toBe('terms'); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); - expect(controller.state.sumsubDisclaimersAccepted).toBeNull(); + expect(controller.state.providerDisclaimersAccepted.sumsub).toBeNull(); expect(controller.state.idosDisclaimersAccepted).toBeNull(); }, ); @@ -2929,12 +2933,12 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); - expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual( + expect(controller.state.providerDisclaimersAccepted.sumsub).toStrictEqual( MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, ); expect(controller.state.idosDisclaimersAccepted).toStrictEqual( @@ -3086,7 +3090,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3155,7 +3159,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3213,7 +3217,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3261,7 +3265,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: [], }); @@ -3306,7 +3310,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, }); @@ -3343,7 +3347,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3394,7 +3398,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: [ { key: 'idos-tos', version: '1' }, { key: 'idos-privacy', version: '2' }, @@ -3443,7 +3447,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3494,7 +3498,7 @@ describe('KycController', () => { // @ts-expect-error both T&C2 flags are required await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, }); expect(controller.state.phase).toBe('error'); @@ -3524,12 +3528,12 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: [], + providerDisclaimersAccepted: [], idosDisclaimersAccepted: [], }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.sumsubDisclaimersAccepted).toStrictEqual([]); + expect(controller.state.providerDisclaimersAccepted.sumsub).toStrictEqual([]); expect(controller.state.idosDisclaimersAccepted).toStrictEqual([]); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', @@ -3553,7 +3557,7 @@ describe('KycController', () => { }, async ({ controller }) => { await controller.acceptTermsAndStartSession({ - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3577,7 +3581,7 @@ describe('KycController', () => { async ({ controller }) => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3607,7 +3611,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3634,7 +3638,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3664,7 +3668,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3703,7 +3707,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3743,7 +3747,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3778,7 +3782,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); controller.reset(); @@ -3814,7 +3818,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); // Session create + session disclaimers run first; wait until launch @@ -3854,7 +3858,7 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); while (handlers.createUkycSession.mock.calls.length === 0) { @@ -3892,7 +3896,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'money', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3933,7 +3937,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3961,7 +3965,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -3989,7 +3993,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4020,7 +4024,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4051,7 +4055,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4084,7 +4088,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4119,7 +4123,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4164,7 +4168,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4194,7 +4198,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4222,7 +4226,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4250,7 +4254,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4628,7 +4632,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); @@ -4657,7 +4661,7 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', - sumsubDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 5d2ce0946da..b31cdded109 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -29,6 +29,7 @@ import type { KycDisclaimer, KycPhase, KycProduct, + KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, KycSumSubLauncher, @@ -157,12 +158,13 @@ export type KycControllerState = { */ vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; /** - * Sumsub disclaimer documents the customer accepted during the last terms - * acceptance (persisted `{ key, version }` records). Consents-path vendors - * require this when resuming a session. `null` for acceptance recorded - * before this field existed (treated as requiring reacceptance). + * KYC-provider disclaimer documents the customer accepted during the last + * terms acceptance (persisted `{ key, version }` records under `sumsub`). + * Consents-path vendors require this when resuming a session. `null` for + * acceptance recorded before this field existed (treated as requiring + * reacceptance). */ - sumsubDisclaimersAccepted: KycConsentRecord[] | null; + providerDisclaimersAccepted: KycProviderDisclaimersAccepted; /** * idOS disclaimer documents the customer accepted during the last terms * acceptance (persisted `{ key, version }` records). Consents-path vendors @@ -276,7 +278,7 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, - sumsubDisclaimersAccepted: { + providerDisclaimersAccepted: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, @@ -395,6 +397,10 @@ export function getDefaultKycVendorDisclaimersAccepted(): KycVendorDisclaimersAc return { moonpay: null, iron: null }; } +export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaimersAccepted { + return { sumsub: null }; +} + /** * Constructs the default {@link KycController} state. * @@ -407,7 +413,7 @@ export function getDefaultKycControllerState(): KycControllerState { error: null, email: null, vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), - sumsubDisclaimersAccepted: null, + providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, disclaimers: [], @@ -933,9 +939,12 @@ export class KycController extends BaseController< if (usesConsentsFlow(vendor)) { // Consents-path vendors require T&C2 flags; if they weren't persisted // (i.e. null from pre-migration state), require reacceptance. - const { sumsubDisclaimersAccepted, idosDisclaimersAccepted } = + const { providerDisclaimersAccepted, idosDisclaimersAccepted } = this.state; - if (sumsubDisclaimersAccepted === null || idosDisclaimersAccepted === null) { + if ( + providerDisclaimersAccepted.sumsub === null || + idosDisclaimersAccepted === null + ) { this.#applyUpdate((state) => { this.#clearAcceptedTerms(state); state.phase = 'terms'; @@ -944,7 +953,7 @@ export class KycController extends BaseController< return; } await this.#startConsentsSession({ - sumsubDisclaimersAccepted, + providerDisclaimersAccepted: providerDisclaimersAccepted.sumsub, idosDisclaimersAccepted, credentialReusabilityConsentGiven: this.state.credentialReusabilityConsentGiven ?? false, @@ -1067,7 +1076,7 @@ export class KycController extends BaseController< * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. - * @param params.sumsubDisclaimersAccepted - Sumsub disclaimer documents the + * @param params.providerDisclaimersAccepted - Sumsub disclaimer documents the * customer accepted (`{ key, version }` records). Required for every vendor * so callers explicitly declare acceptance. * @param params.idosDisclaimersAccepted - idOS disclaimer documents the @@ -1080,14 +1089,14 @@ export class KycController extends BaseController< async acceptTermsAndStartSession(params?: { email?: string; product?: KycProduct; - sumsubDisclaimersAccepted: KycConsentRecord[]; + providerDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven?: boolean; }): Promise { - const sumsubDisclaimersAccepted = params?.sumsubDisclaimersAccepted; + const providerDisclaimersAccepted = params?.providerDisclaimersAccepted; const idosDisclaimersAccepted = params?.idosDisclaimersAccepted; if ( - !isValidConsentRecordList(sumsubDisclaimersAccepted) || + !isValidConsentRecordList(providerDisclaimersAccepted) || !isValidConsentRecordList(idosDisclaimersAccepted) ) { this.#fail('Missing T&C2 acceptance flags.'); @@ -1118,14 +1127,17 @@ export class KycController extends BaseController< iron: { disclaimerIds }, }; } - state.sumsubDisclaimersAccepted = sumsubDisclaimersAccepted; + state.providerDisclaimersAccepted = { + ...state.providerDisclaimersAccepted, + sumsub: providerDisclaimersAccepted, + }; state.idosDisclaimersAccepted = idosDisclaimersAccepted; state.credentialReusabilityConsentGiven = credentialReusabilityConsentGiven; }); if (usesConsentsFlow(this.state.activeVendor)) { await this.#startConsentsSession({ - sumsubDisclaimersAccepted, + providerDisclaimersAccepted, idosDisclaimersAccepted, credentialReusabilityConsentGiven, }); @@ -1140,13 +1152,13 @@ export class KycController extends BaseController< * SumSub — skipping MoonPay Check/Auth frames. * * @param consents - T&C2 flags mapped onto the session disclaimer catalog. - * @param consents.sumsubDisclaimersAccepted - Accepted Sumsub disclaimer records. + * @param consents.providerDisclaimersAccepted - Accepted Sumsub disclaimer records. * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. */ async #startConsentsSession(consents: { - sumsubDisclaimersAccepted: KycConsentRecord[]; + providerDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }): Promise { @@ -1300,7 +1312,7 @@ export class KycController extends BaseController< * * @param sessionId - The UKYC session id. * @param consents - T&C2 flags mapped onto catalog documents. - * @param consents.sumsubDisclaimersAccepted - Accepted Sumsub disclaimer records. + * @param consents.providerDisclaimersAccepted - Accepted Sumsub disclaimer records. * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. @@ -1309,7 +1321,7 @@ export class KycController extends BaseController< async #recordSessionDisclaimers( sessionId: string, consents: { - sumsubDisclaimersAccepted: KycConsentRecord[]; + providerDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }, @@ -1334,7 +1346,7 @@ export class KycController extends BaseController< ) || isAcceptedCategoryEmpty( catalog.kycProvider, - consents.sumsubDisclaimersAccepted, + consents.providerDisclaimersAccepted, ) ) { throw new Error( @@ -1348,7 +1360,7 @@ export class KycController extends BaseController< ); const kycProvider = consentRecordsFromAcceptedList( catalog.kycProvider, - consents.sumsubDisclaimersAccepted, + consents.providerDisclaimersAccepted, ); const reuseUnchanged = catalog.credentialReusabilityConsentGiven === @@ -1394,7 +1406,7 @@ export class KycController extends BaseController< ); const stillMissingProvider = acceptedCategoryStillMissing( latest.kycProvider, - consents.sumsubDisclaimersAccepted, + consents.providerDisclaimersAccepted, ); const stillMissingReuse = consents.credentialReusabilityConsentGiven && @@ -1486,7 +1498,8 @@ export class KycController extends BaseController< clearSavedTerms(): void { this.#applyUpdate((state) => { state.vendorDisclaimersAccepted = getDefaultKycVendorDisclaimersAccepted(); - state.sumsubDisclaimersAccepted = null; + state.providerDisclaimersAccepted = + getDefaultKycProviderDisclaimersAccepted(); state.idosDisclaimersAccepted = null; state.credentialReusabilityConsentGiven = null; }); @@ -1516,7 +1529,8 @@ export class KycController extends BaseController< iron: null, }; } - state.sumsubDisclaimersAccepted = null; + state.providerDisclaimersAccepted = + getDefaultKycProviderDisclaimersAccepted(); state.idosDisclaimersAccepted = null; state.credentialReusabilityConsentGiven = null; } diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 560d10eddde..6992d8dcfc3 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -1,6 +1,7 @@ export { KycController, getDefaultKycControllerState, + getDefaultKycProviderDisclaimersAccepted, getDefaultKycVendorDisclaimersAccepted, controllerName, } from './KycController.js'; @@ -102,6 +103,7 @@ export type { KycDisclaimersCatalog, KycPhase, KycProduct, + KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, KycSumSubLaunchParams, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 4d7124e1ed4..db2b340f350 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -210,6 +210,14 @@ export type KycIronVendorDisclaimersAccepted = { disclaimerIds: string[]; }; +/** + * Persisted KYC-provider disclaimer acceptance (T&C2) with a fixed `sumsub` + * key. + */ +export type KycProviderDisclaimersAccepted = { + sumsub: KycConsentRecord[] | null; +}; + /** * Persisted vendor-disclaimer acceptance with fixed `moonpay` and `iron` keys. */ From da165946d02302ef8631362f0116dec51b123f27 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 13:48:17 -0700 Subject: [PATCH 05/10] rename sessionToken to moonpaySessionToken --- packages/kyc-controller/ARCHITECTURE.md | 10 +-- packages/kyc-controller/CHANGELOG.md | 1 + .../kyc-controller/src/KycController.test.ts | 82 +++++++++---------- packages/kyc-controller/src/KycController.ts | 24 +++--- 4 files changed, 59 insertions(+), 58 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 1c4e6e40ca1..39ff4a14c39 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -181,7 +181,7 @@ classDiagram +KycDisclaimer[] disclaimers +string disclaimersError +string geoCountry - +string sessionToken [secret] + +string moonpaySessionToken [secret] +string accessToken [secret] +string moonpayCustomerId +KycProduct activeProduct @@ -198,7 +198,7 @@ classDiagram KycControllerState --> SumSubState : sumsub ``` -> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> Note: nullable fields (`error`, `email`, `moonpaySessionToken`, …) are typed as > `T | null` in the source; `Record` is `Partial>`. > Types are simplified above for diagram readability. @@ -216,7 +216,7 @@ State metadata highlights (`kycControllerMetadata`): vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. -- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, +- **Secrets, never persisted / never logged**: `moonpaySessionToken`, `accessToken`, `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot @@ -288,7 +288,7 @@ stateDiagram-v2 > `form`, `submit`), a repeat `initialize` or `createVendorCustomer` is a > **no-op** — it will not create a new session, switch `activeVendor`, clear > tokens, or reset `activeProduct`. Call `reset()` first to start over. -> When a switch away from MoonPay is allowed, leftover `sessionToken`, +> When a switch away from MoonPay is allowed, leftover `moonpaySessionToken`, > `accessToken`, `moonpayCustomerId`, and `#authClientToken` are cleared so > Check/Auth URLs cannot outlive the MoonPay session. Check/Auth `complete` > messages are also ignored unless `activeVendor` is `moonpay`, so a @@ -348,7 +348,7 @@ sequenceDiagram User->>Ctrl: acceptTermsAndStartSession({ email, providerDisclaimersAccepted, idosDisclaimersAccepted }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) Svc->>API: POST /sessions - Ctrl-->>UI: phase = check (+ sessionToken) + Ctrl-->>UI: phase = check (+ moonpaySessionToken) UI->>Ctrl: buildCheckFrameUrl() Ctrl-->>UI: URL (sessionToken + publicKey) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index e6162f1a8fb..bf211d4a71a 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Rename `KycController` state field `sessionToken` to `moonpaySessionToken` (MoonPay Check-frame session token; the Check-frame URL query parameter remains `sessionToken`). - **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). - **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. - **BREAKING:** Rename persisted `sumsubTncAccepted` to `providerDisclaimersAccepted`: a fixed map with `sumsub: KycConsentRecord[] | null` (default `{ sumsub: null }`). `acceptTermsAndStartSession` now takes `providerDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 35b6dd84148..3d44dc4b505 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -196,7 +196,7 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co' }); expect(controller.state.geoCountry).toBe('USA'); - expect(controller.state.sessionToken).toBe('sess'); + expect(controller.state.moonpaySessionToken).toBe('sess'); expect(controller.state.phase).toBe('check'); }, ); @@ -245,7 +245,7 @@ describe('KycController', () => { state: { phase: 'check', email: 'a@b.co', - sessionToken: 'live-session', + moonpaySessionToken: 'live-session', ...VENDOR_TERMS_MOONPAY, activeProduct: 'ramps', activeVendor: 'moonpay', @@ -267,7 +267,7 @@ describe('KycController', () => { expect(handlers.getGeoCountry).not.toHaveBeenCalled(); expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); expect(controller.state.phase).toBe('check'); - expect(controller.state.sessionToken).toBe('live-session'); + expect(controller.state.moonpaySessionToken).toBe('live-session'); expect(controller.state.activeProduct).toBe('ramps'); expect(controller.state.email).toBe('a@b.co'); expect(controller.state.activeVendor).toBe('moonpay'); @@ -473,7 +473,7 @@ describe('KycController', () => { state: { phase: 'check', email: 'a@b.co', - sessionToken: 'old-session', + moonpaySessionToken: 'old-session', accessToken: 'stale-access', disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, @@ -507,7 +507,7 @@ describe('KycController', () => { expect(controller.state.accessToken).toBeNull(); expect(controller.buildAuthFrameUrl()).toBeNull(); - expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.state.moonpaySessionToken).toBe('new-session'); }, ); }); @@ -518,7 +518,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - sessionToken: 'old-session', + moonpaySessionToken: 'old-session', disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, @@ -543,13 +543,13 @@ describe('KycController', () => { // While the request is in flight (phase `session`) the stale token // must already be gone so no Check frame URL can be built for it. expect(controller.state.phase).toBe('session'); - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); releaseSession({ sessionToken: 'new-session' }); await pending; - expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.state.moonpaySessionToken).toBe('new-session'); expect(controller.buildCheckFrameUrl()).toContain( 'sessionToken=new-session', ); @@ -563,7 +563,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - sessionToken: 'old-session', + moonpaySessionToken: 'old-session', disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, @@ -582,7 +582,7 @@ describe('KycController', () => { expect(controller.state.error).toMatch(/Session creation failed/u); // A failed creation must not leave the old session token behind, so // the Check frame cannot be built against an invalid session. - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); }, ); @@ -594,7 +594,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - sessionToken: 'old-session', + moonpaySessionToken: 'old-session', disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, @@ -767,7 +767,7 @@ describe('KycController', () => { // to an idle phase) means the Check frame is no longer active; a late or // duplicate `ch_1` completion must not resurrect tokens or rewind phase. await withController( - { options: { state: { phase: 'done', sessionToken: 'tok' } } }, + { options: { state: { phase: 'done', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { accessToken: 'access-1' }); const result = await controller.handleFrameMessage({ @@ -796,7 +796,7 @@ describe('KycController', () => { state: { phase: 'check', activeVendor: 'iron', - sessionToken: 'tok', + moonpaySessionToken: 'tok', }, }, }, @@ -824,7 +824,7 @@ describe('KycController', () => { it('fails when credential decryption throws', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { await controller.handleFrameMessage({ message: { @@ -842,7 +842,7 @@ describe('KycController', () => { describe('check frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { accessToken: 'access-1', @@ -862,7 +862,7 @@ describe('KycController', () => { it('moves to auth on connectionRequired and enables the auth frame URL', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { clientToken: 'client-1', @@ -891,7 +891,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', ...VENDOR_TERMS_MOONPAY, }, }, @@ -912,7 +912,7 @@ describe('KycController', () => { it('fails on an unexpected status', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { await controller.handleFrameMessage({ message: { @@ -930,7 +930,7 @@ describe('KycController', () => { describe('auth frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { phase: 'auth', sessionToken: 'tok' } } }, + { options: { state: { phase: 'auth', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { accessToken: 'access-2', @@ -987,7 +987,7 @@ describe('KycController', () => { await withController( { options: { - state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' }, + state: { phase: 'check', moonpaySessionToken: 'tok', geoCountry: 'USA' }, }, }, async ({ controller, handlers }) => { @@ -1013,7 +1013,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', }, @@ -1049,7 +1049,7 @@ describe('KycController', () => { options: { state: { phase: 'auth', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'card', geoCountry: 'FRA', }, @@ -1084,7 +1084,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', }, @@ -1115,7 +1115,7 @@ describe('KycController', () => { options: { state: { phase: 'auth', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'card', geoCountry: 'FRA', }, @@ -1165,7 +1165,7 @@ describe('KycController', () => { state: { phase: 'check', email: 'a@b.co', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', // Persisted terms so a post-reset `initialize` auto-recreates the @@ -1242,7 +1242,7 @@ describe('KycController', () => { options: { state: { phase: 'check', - sessionToken: 'tok', + moonpaySessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', }, @@ -1276,7 +1276,7 @@ describe('KycController', () => { it('builds the check frame URL with a session', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { moonpaySessionToken: 'tok' } } }, ({ controller }) => { const url = controller.buildCheckFrameUrl() as string; expect(url).toContain('sessionToken=tok'); @@ -1290,7 +1290,7 @@ describe('KycController', () => { await withController( { options: { - state: { sessionToken: 'tok', activeVendor: 'iron' }, + state: { moonpaySessionToken: 'tok', activeVendor: 'iron' }, }, }, ({ controller }) => { @@ -1482,7 +1482,7 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', + moonpaySessionToken: 'tok', accessToken: 'access-1', }, }, @@ -1491,7 +1491,7 @@ describe('KycController', () => { await controller.initialize({ vendor: 'iron' }); expect(controller.state.moonpayCustomerId).toBeNull(); - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.accessToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); @@ -1506,7 +1506,7 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', + moonpaySessionToken: 'tok', accessToken: 'access-1', }, }, @@ -1515,7 +1515,7 @@ describe('KycController', () => { await controller.initialize({ vendor: 'moonpay' }); expect(controller.state.moonpayCustomerId).toBe('cust-1'); - expect(controller.state.sessionToken).toBe('tok'); + expect(controller.state.moonpaySessionToken).toBe('tok'); expect(controller.state.accessToken).toBe('access-1'); expect(controller.buildCheckFrameUrl()).toContain('sessionToken=tok'); }, @@ -1529,7 +1529,7 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', + moonpaySessionToken: 'tok', accessToken: 'access-1', }, }, @@ -1541,7 +1541,7 @@ describe('KycController', () => { }); expect(controller.state.moonpayCustomerId).toBeNull(); - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.accessToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); @@ -1569,7 +1569,7 @@ describe('KycController', () => { state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', - sessionToken: 'tok', + moonpaySessionToken: 'tok', accessToken: 'access-1', }, }, @@ -1587,7 +1587,7 @@ describe('KycController', () => { }); expect(controller.state.moonpayCustomerId).toBe('cust-1'); - expect(controller.state.sessionToken).toBe('tok'); + expect(controller.state.moonpaySessionToken).toBe('tok'); expect(controller.state.accessToken).toBe('access-1'); }, ); @@ -2372,7 +2372,7 @@ describe('KycController', () => { options: { state: { phase: 'form', - sessionToken: 'tok', + moonpaySessionToken: 'tok', accessToken: 'a', activeProduct: 'ramps', ...VENDOR_TERMS_MOONPAY, @@ -2383,7 +2383,7 @@ describe('KycController', () => { ({ controller }) => { controller.reset(); expect(controller.state.phase).toBe('idle'); - expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.accessToken).toBeNull(); expect(controller.state.activeProduct).toBeNull(); expect( @@ -2437,7 +2437,7 @@ describe('KycController', () => { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], disclaimersError: 'stale disclaimers error', geoCountry: 'USA', - sessionToken: 'tok', + moonpaySessionToken: 'tok', accessToken: 'a', moonpayCustomerId: 'cus-1', activeVendor: 'iron', @@ -2491,7 +2491,7 @@ describe('KycController', () => { it('drops the auth-frame client token', async () => { await withController( - { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { clientToken: 'client-1', @@ -2891,7 +2891,7 @@ describe('KycController', () => { phase, activeVendor: 'moonpay', moonpayCustomerId: 'cust-1', - sessionToken: 'tok', + moonpaySessionToken: 'tok', }, }, }, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index b31cdded109..17e8e6391f2 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -194,8 +194,8 @@ export type KycControllerState = { /** Resolved ISO 3166-1 alpha-3 country code. */ geoCountry: string | null; - /** Vendor session token (not persisted, not logged). */ - sessionToken: string | null; + /** MoonPay session token (not persisted, not logged). */ + moonpaySessionToken: string | null; /** Vendor access token (not persisted, not logged). */ accessToken: string | null; /** Vendor customer id, used for the SumSub hand-off. */ @@ -320,7 +320,7 @@ const kycControllerMetadata = { persist: false, usedInUi: true, }, - sessionToken: { + moonpaySessionToken: { includeInDebugSnapshot: false, includeInStateLogs: false, persist: false, @@ -420,7 +420,7 @@ export function getDefaultKycControllerState(): KycControllerState { disclaimersError: null, sessionDisclaimers: null, geoCountry: null, - sessionToken: null, + moonpaySessionToken: null, accessToken: null, moonpayCustomerId: null, activeVendor: 'moonpay', @@ -873,7 +873,7 @@ export class KycController extends BaseController< } state.activeVendor = vendor; // MoonPay Check/Auth artifacts must not survive a switch to another - // vendor: leftover `sessionToken` would keep `buildCheckFrameUrl` alive, + // vendor: leftover `moonpaySessionToken` would keep `buildCheckFrameUrl` alive, // leftover `accessToken` / `#authClientToken` would keep Auth / KYC // calls bound to MoonPay, and leftover `moonpayCustomerId` would make // `getCustomerIdentity` report a MoonPay id under the wrong vendor. @@ -1442,7 +1442,7 @@ export class KycController extends BaseController< // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot // return a URL tied to an old client token, and `checkKycRequired` cannot // run with an access token from an earlier authentication. The Check/Auth - // frames re-populate these for the new session. Because `sessionToken` is + // frames re-populate these for the new session. Because `moonpaySessionToken` is // cleared here and only re-set on success, a failed creation leaves it // `null` rather than resurrecting the previous session. // Capture the flow generation so a `reset()` landing while the create @@ -1455,7 +1455,7 @@ export class KycController extends BaseController< state.error = null; state.phase = 'session'; state.statusMessage = 'Creating session...'; - state.sessionToken = null; + state.moonpaySessionToken = null; state.accessToken = null; }); @@ -1465,7 +1465,7 @@ export class KycController extends BaseController< { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, ); this.#updateIfCurrent(generation, (state) => { - state.sessionToken = sessionToken; + state.moonpaySessionToken = sessionToken; state.phase = 'check'; state.statusMessage = 'Authenticating via Check frame...'; }); @@ -1545,7 +1545,7 @@ export class KycController extends BaseController< */ #clearMoonPaySession(state: KycControllerState): void { state.moonpayCustomerId = null; - state.sessionToken = null; + state.moonpaySessionToken = null; state.accessToken = null; } @@ -1761,11 +1761,11 @@ export class KycController extends BaseController< * @returns The Check-frame URL or `null`. */ buildCheckFrameUrl(): string | null { - if (this.state.activeVendor !== 'moonpay' || !this.state.sessionToken) { + if (this.state.activeVendor !== 'moonpay' || !this.state.moonpaySessionToken) { return null; } const url = new URL(`${FRAMES_BASE_URL}/check-connection`); - url.searchParams.set('sessionToken', this.state.sessionToken); + url.searchParams.set('sessionToken', this.state.moonpaySessionToken); url.searchParams.set('publicKey', this.#keypair.publicKeyHex); url.searchParams.set('channelId', CHANNEL_CHECK); url.searchParams.set('skipKyc', 'true'); @@ -2518,7 +2518,7 @@ export class KycController extends BaseController< state.disclaimersError = null; state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; - state.sessionToken = null; + state.moonpaySessionToken = null; state.accessToken = null; state.moonpayCustomerId = null; state.activeVendor = 'moonpay'; From 016ab7eb9be9d8da5084b01c0d0fa87aa7894694 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 13:52:18 -0700 Subject: [PATCH 06/10] rename accessToken to moonpayAccessToken --- packages/kyc-controller/ARCHITECTURE.md | 8 +-- packages/kyc-controller/CHANGELOG.md | 1 + .../kyc-controller/src/KycController.test.ts | 50 +++++++++---------- packages/kyc-controller/src/KycController.ts | 30 +++++------ 4 files changed, 45 insertions(+), 44 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 39ff4a14c39..dffb1199e5f 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -182,7 +182,7 @@ classDiagram +string disclaimersError +string geoCountry +string moonpaySessionToken [secret] - +string accessToken [secret] + +string moonpayAccessToken [secret] +string moonpayCustomerId +KycProduct activeProduct +Record kycRequiredByProduct [persisted] @@ -216,7 +216,7 @@ State metadata highlights (`kycControllerMetadata`): vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. -- **Secrets, never persisted / never logged**: `moonpaySessionToken`, `accessToken`, +- **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot @@ -289,7 +289,7 @@ stateDiagram-v2 > **no-op** — it will not create a new session, switch `activeVendor`, clear > tokens, or reset `activeProduct`. Call `reset()` first to start over. > When a switch away from MoonPay is allowed, leftover `moonpaySessionToken`, -> `accessToken`, `moonpayCustomerId`, and `#authClientToken` are cleared so +> `moonpayAccessToken`, `moonpayCustomerId`, and `#authClientToken` are cleared so > Check/Auth URLs cannot outlive the MoonPay session. Check/Auth `complete` > messages are also ignored unless `activeVendor` is `moonpay`, so a > still-mounted MoonPay frame cannot recapture `moonpayCustomerId` under @@ -368,7 +368,7 @@ sequenceDiagram UI->>Ctrl: handleFrameMessage(complete) end - Ctrl-->>UI: phase = form (accessToken set) + Ctrl-->>UI: phase = form (moonpayAccessToken set) Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index bf211d4a71a..e487cb90968 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Rename `KycController` state field `accessToken` to `moonpayAccessToken` (MoonPay Auth-frame access token; `KycService.checkKycRequired` still accepts an `accessToken` parameter). - **BREAKING:** Rename `KycController` state field `sessionToken` to `moonpaySessionToken` (MoonPay Check-frame session token; the Check-frame URL query parameter remains `sessionToken`). - **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). - **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3d44dc4b505..64e87bb6b4b 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -320,7 +320,7 @@ describe('KycController', () => { it('lets a later checkKycRequired reuse the overridden country without an override', async () => { await withController( - { options: { state: { accessToken: 'a' } } }, + { options: { state: { moonpayAccessToken: 'a' } } }, async ({ controller, handlers }) => { handlers.fetchVendorDisclaimers.mockResolvedValue([]); handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); @@ -474,7 +474,7 @@ describe('KycController', () => { phase: 'check', email: 'a@b.co', moonpaySessionToken: 'old-session', - accessToken: 'stale-access', + moonpayAccessToken: 'stale-access', disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, @@ -505,7 +505,7 @@ describe('KycController', () => { idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.buildAuthFrameUrl()).toBeNull(); expect(controller.state.moonpaySessionToken).toBe('new-session'); }, @@ -783,7 +783,7 @@ describe('KycController', () => { }); expect(result).toStrictEqual({}); expect(controller.state.phase).toBe('done'); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.moonpayCustomerId).toBeNull(); }, ); @@ -815,7 +815,7 @@ describe('KycController', () => { expect(result).toStrictEqual({}); expect(controller.state.phase).toBe('check'); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, @@ -855,7 +855,7 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('form'); - expect(controller.state.accessToken).toBe('access-1'); + expect(controller.state.moonpayAccessToken).toBe('access-1'); }, ); }); @@ -943,7 +943,7 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('form'); - expect(controller.state.accessToken).toBe('access-2'); + expect(controller.state.moonpayAccessToken).toBe('access-2'); }, ); }); @@ -1318,13 +1318,13 @@ describe('KycController', () => { expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( false, ); - expect(controller.state.error).toMatch(/Missing accessToken/u); + expect(controller.state.error).toMatch(/Missing moonpayAccessToken/u); }); }); it('fails without a country', async () => { await withController( - { options: { state: { accessToken: 'a' } } }, + { options: { state: { moonpayAccessToken: 'a' } } }, async ({ controller }) => { expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( false, @@ -1336,7 +1336,7 @@ describe('KycController', () => { it('caches the result on success (cached country)', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); @@ -1351,7 +1351,7 @@ describe('KycController', () => { it('accepts a country override', async () => { await withController( - { options: { state: { accessToken: 'a' } } }, + { options: { state: { moonpayAccessToken: 'a' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); @@ -1371,7 +1371,7 @@ describe('KycController', () => { it('fails when the service throws', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockRejectedValue(new Error('down')); @@ -1385,7 +1385,7 @@ describe('KycController', () => { it('discards a successful result when reset() runs while the check is in flight', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockImplementation(async () => { // Simulate a reset() landing while the HTTP call is in flight. @@ -1407,7 +1407,7 @@ describe('KycController', () => { it('discards an error when reset() runs while the check is in flight', async () => { await withController( - { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + { options: { state: { moonpayAccessToken: 'a', geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.checkKycRequired.mockImplementation(async () => { controller.reset(); @@ -1483,7 +1483,7 @@ describe('KycController', () => { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', moonpaySessionToken: 'tok', - accessToken: 'access-1', + moonpayAccessToken: 'access-1', }, }, }, @@ -1492,7 +1492,7 @@ describe('KycController', () => { expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.state.moonpaySessionToken).toBeNull(); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, @@ -1507,7 +1507,7 @@ describe('KycController', () => { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', moonpaySessionToken: 'tok', - accessToken: 'access-1', + moonpayAccessToken: 'access-1', }, }, }, @@ -1516,7 +1516,7 @@ describe('KycController', () => { expect(controller.state.moonpayCustomerId).toBe('cust-1'); expect(controller.state.moonpaySessionToken).toBe('tok'); - expect(controller.state.accessToken).toBe('access-1'); + expect(controller.state.moonpayAccessToken).toBe('access-1'); expect(controller.buildCheckFrameUrl()).toContain('sessionToken=tok'); }, ); @@ -1530,7 +1530,7 @@ describe('KycController', () => { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', moonpaySessionToken: 'tok', - accessToken: 'access-1', + moonpayAccessToken: 'access-1', }, }, }, @@ -1542,7 +1542,7 @@ describe('KycController', () => { expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.state.moonpaySessionToken).toBeNull(); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, @@ -1570,7 +1570,7 @@ describe('KycController', () => { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay', moonpaySessionToken: 'tok', - accessToken: 'access-1', + moonpayAccessToken: 'access-1', }, }, }, @@ -1588,7 +1588,7 @@ describe('KycController', () => { expect(controller.state.moonpayCustomerId).toBe('cust-1'); expect(controller.state.moonpaySessionToken).toBe('tok'); - expect(controller.state.accessToken).toBe('access-1'); + expect(controller.state.moonpayAccessToken).toBe('access-1'); }, ); }); @@ -2373,7 +2373,7 @@ describe('KycController', () => { state: { phase: 'form', moonpaySessionToken: 'tok', - accessToken: 'a', + moonpayAccessToken: 'a', activeProduct: 'ramps', ...VENDOR_TERMS_MOONPAY, kycRequiredByProduct: { ramps: true }, @@ -2384,7 +2384,7 @@ describe('KycController', () => { controller.reset(); expect(controller.state.phase).toBe('idle'); expect(controller.state.moonpaySessionToken).toBeNull(); - expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.activeProduct).toBeNull(); expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, @@ -2438,7 +2438,7 @@ describe('KycController', () => { disclaimersError: 'stale disclaimers error', geoCountry: 'USA', moonpaySessionToken: 'tok', - accessToken: 'a', + moonpayAccessToken: 'a', moonpayCustomerId: 'cus-1', activeVendor: 'iron', activeProduct: 'ramps', diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 17e8e6391f2..71d45c741d6 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -196,8 +196,8 @@ export type KycControllerState = { /** MoonPay session token (not persisted, not logged). */ moonpaySessionToken: string | null; - /** Vendor access token (not persisted, not logged). */ - accessToken: string | null; + /** MoonPay access token (not persisted, not logged). */ + moonpayAccessToken: string | null; /** Vendor customer id, used for the SumSub hand-off. */ moonpayCustomerId: string | null; @@ -326,7 +326,7 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, - accessToken: { + moonpayAccessToken: { includeInDebugSnapshot: false, includeInStateLogs: false, persist: false, @@ -421,7 +421,7 @@ export function getDefaultKycControllerState(): KycControllerState { sessionDisclaimers: null, geoCountry: null, moonpaySessionToken: null, - accessToken: null, + moonpayAccessToken: null, moonpayCustomerId: null, activeVendor: 'moonpay', activeProduct: null, @@ -874,7 +874,7 @@ export class KycController extends BaseController< state.activeVendor = vendor; // MoonPay Check/Auth artifacts must not survive a switch to another // vendor: leftover `moonpaySessionToken` would keep `buildCheckFrameUrl` alive, - // leftover `accessToken` / `#authClientToken` would keep Auth / KYC + // leftover `moonpayAccessToken` / `#authClientToken` would keep Auth / KYC // calls bound to MoonPay, and leftover `moonpayCustomerId` would make // `getCustomerIdentity` report a MoonPay id under the wrong vendor. if (vendor !== 'moonpay') { @@ -1456,7 +1456,7 @@ export class KycController extends BaseController< state.phase = 'session'; state.statusMessage = 'Creating session...'; state.moonpaySessionToken = null; - state.accessToken = null; + state.moonpayAccessToken = null; }); try { @@ -1546,7 +1546,7 @@ export class KycController extends BaseController< #clearMoonPaySession(state: KycControllerState): void { state.moonpayCustomerId = null; state.moonpaySessionToken = null; - state.accessToken = null; + state.moonpayAccessToken = null; } /** @@ -1654,7 +1654,7 @@ export class KycController extends BaseController< ): Promise { if (status === 'active' && accessToken) { this.#applyUpdate((state) => { - state.accessToken = accessToken; + state.moonpayAccessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Already authenticated. Review to submit.'; }); @@ -1688,7 +1688,7 @@ export class KycController extends BaseController< ): Promise { if (status === 'active' && accessToken) { this.#applyUpdate((state) => { - state.accessToken = accessToken; + state.moonpayAccessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Authenticated. Review to submit.'; }); @@ -1811,9 +1811,9 @@ export class KycController extends BaseController< product: KycProduct; country?: string; }): Promise { - const { accessToken } = this.state; - if (!accessToken) { - this.#fail('Missing accessToken — repeat the authentication step.'); + const { moonpayAccessToken } = this.state; + if (!moonpayAccessToken) { + this.#fail('Missing moonpayAccessToken — repeat the authentication step.'); return false; } const country = params.country ?? this.state.geoCountry; @@ -1834,7 +1834,7 @@ export class KycController extends BaseController< try { const { kycRequired } = await this.messenger.call( 'KycService:checkKycRequired', - { accessToken, country, capabilities: [{ product: params.product }] }, + { accessToken: moonpayAccessToken, country, capabilities: [{ product: params.product }] }, ); // The flow was reset while the check was in flight; discard the result // rather than resurrecting a done/cached state on an idle controller. @@ -1911,7 +1911,7 @@ export class KycController extends BaseController< return { vendor: 'moonpay', vendorMetadata: { - moonPayAccessToken: this.state.accessToken, + moonPayAccessToken: this.state.moonpayAccessToken, moonPayUserId: this.state.moonpayCustomerId, }, }; @@ -2519,7 +2519,7 @@ export class KycController extends BaseController< state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; state.moonpaySessionToken = null; - state.accessToken = null; + state.moonpayAccessToken = null; state.moonpayCustomerId = null; state.activeVendor = 'moonpay'; state.activeProduct = null; From 4f7c0382ecaeb05beecd2cdc73ee1edcc0c8307a Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 14:02:50 -0700 Subject: [PATCH 07/10] rename disclaimers to vendorDisclaimers. Rename disclaimersError to vendorError --- packages/kyc-controller/ARCHITECTURE.md | 8 +- packages/kyc-controller/CHANGELOG.md | 2 +- .../kyc-controller/src/KycController.test.ts | 102 +++++++++--------- packages/kyc-controller/src/KycController.ts | 32 +++--- packages/kyc-controller/src/KycService.ts | 2 +- 5 files changed, 73 insertions(+), 73 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index dffb1199e5f..027babd6ed3 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -178,8 +178,8 @@ classDiagram +string error +string email +KycVendorDisclaimersAccepted vendorDisclaimersAccepted [persisted] - +KycDisclaimer[] disclaimers - +string disclaimersError + +KycDisclaimer[] vendorDisclaimers + +string vendorError +string geoCountry +string moonpaySessionToken [secret] +string moonpayAccessToken [secret] @@ -217,7 +217,7 @@ State metadata highlights (`kycControllerMetadata`): path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. - **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, - `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. + `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` sub-tree. Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot return a MoonPay URL while `activeVendor` is a consents-path vendor. @@ -343,7 +343,7 @@ sequenceDiagram Note over Svc: map alpha-2 → alpha-3 locally Ctrl->>Svc: fetchVendorDisclaimers({ country }) Svc->>API: GET /vendors/moonpay/disclaimers?country= - Ctrl-->>UI: phase = terms (+ disclaimers) + Ctrl-->>UI: phase = terms (+ vendorDisclaimers) User->>Ctrl: acceptTermsAndStartSession({ email, providerDisclaimersAccepted, idosDisclaimersAccepted }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index e487cb90968..f5be46ed9be 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Rename `KycController` state field `sessionToken` to `moonpaySessionToken` (MoonPay Check-frame session token; the Check-frame URL query parameter remains `sessionToken`). - **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). - **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. -- **BREAKING:** Rename persisted `sumsubTncAccepted` to `providerDisclaimersAccepted`: a fixed map with `sumsub: KycConsentRecord[] | null` (default `{ sumsub: null }`). `acceptTermsAndStartSession` now takes `providerDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. +- **BREAKING:** Rename `KycController` state field `disclaimers` to `vendorDisclaimers` (vendor T&Cs fetched for the current country) and `disclaimersError` to `vendorError`. - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 64e87bb6b4b..a1ae5720d86 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -209,7 +209,7 @@ describe('KycController', () => { await controller.initialize(); expect(controller.state.phase).toBe('terms'); - expect(controller.state.disclaimersError).toMatch(/Failed to load/u); + expect(controller.state.vendorError).toMatch(/Failed to load/u); }); }); @@ -303,7 +303,7 @@ describe('KycController', () => { await controller.loadDisclaimers({ country: 'USA' }); - expect(controller.state.disclaimers).toStrictEqual(disclaimers); + expect(controller.state.vendorDisclaimers).toStrictEqual(disclaimers); expect(handlers.getGeoCountry).not.toHaveBeenCalled(); }); }); @@ -377,7 +377,7 @@ describe('KycController', () => { await controller.loadDisclaimers({ country: 'USA' }); - expect(controller.state.disclaimersError).toMatch(/boom/u); + expect(controller.state.vendorError).toMatch(/boom/u); }); }); }); @@ -387,7 +387,7 @@ describe('KycController', () => { await withController( { options: { - state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + state: { vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, }, }, async ({ controller, handlers }) => { @@ -416,7 +416,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -438,7 +438,7 @@ describe('KycController', () => { options: { state: { email: 'a@b.co', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -475,7 +475,7 @@ describe('KycController', () => { email: 'a@b.co', moonpaySessionToken: 'old-session', moonpayAccessToken: 'stale-access', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -519,7 +519,7 @@ describe('KycController', () => { state: { email: 'a@b.co', moonpaySessionToken: 'old-session', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -564,7 +564,7 @@ describe('KycController', () => { state: { email: 'a@b.co', moonpaySessionToken: 'old-session', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -595,7 +595,7 @@ describe('KycController', () => { state: { email: 'a@b.co', moonpaySessionToken: 'old-session', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -635,7 +635,7 @@ describe('KycController', () => { state: { email: 'a@b.co', activeProduct: 'card', - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, }, @@ -661,7 +661,7 @@ describe('KycController', () => { await withController( { options: { - state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + state: { vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, }, }, async ({ controller }) => { @@ -2434,8 +2434,8 @@ describe('KycController', () => { sumsub: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, }, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], - disclaimersError: 'stale disclaimers error', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + vendorError: 'stale disclaimers error', geoCountry: 'USA', moonpaySessionToken: 'tok', moonpayAccessToken: 'a', @@ -2613,7 +2613,7 @@ describe('KycController', () => { expect(controller.state.activeVendor).toBe('iron'); expect(controller.state.activeProduct).toBe('money'); expect(controller.state.phase).toBe('terms'); - expect(controller.state.disclaimers).toHaveLength(1); + expect(controller.state.vendorDisclaimers).toHaveLength(1); }); }); @@ -2920,7 +2920,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3060,7 +3060,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3145,7 +3145,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3182,7 +3182,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3235,7 +3235,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3282,7 +3282,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3328,7 +3328,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3364,7 +3364,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3422,7 +3422,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3464,7 +3464,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3490,7 +3490,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3514,7 +3514,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3551,7 +3551,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3574,7 +3574,7 @@ describe('KycController', () => { state: { activeVendor: 'iron', email: 'a@b.co', - disclaimers: [], + vendorDisclaimers: [], }, }, }, @@ -3599,7 +3599,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3628,7 +3628,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3654,7 +3654,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3688,7 +3688,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3733,7 +3733,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3764,7 +3764,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3801,7 +3801,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3842,7 +3842,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3880,7 +3880,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -3917,7 +3917,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3953,7 +3953,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -3981,7 +3981,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4012,7 +4012,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4040,7 +4040,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4074,7 +4074,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4109,7 +4109,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4143,7 +4143,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4186,7 +4186,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4214,7 +4214,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4242,7 +4242,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, }, }, @@ -4619,7 +4619,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, @@ -4649,7 +4649,7 @@ describe('KycController', () => { options: { state: { activeVendor: 'iron', - disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, userStatusPollIntervalMs: 60_000, }, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 71d45c741d6..1cdbaa38d90 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -180,10 +180,10 @@ export type KycControllerState = { */ credentialReusabilityConsentGiven: boolean | null; - /** Disclaimers fetched for the current country. */ - disclaimers: KycDisclaimer[]; - /** Error encountered while loading disclaimers, or `null`. */ - disclaimersError: string | null; + /** Vendor disclaimers fetched for the current country. */ + vendorDisclaimers: KycDisclaimer[]; + /** Error encountered while loading vendor disclaimers, or `null`. */ + vendorError: string | null; /** * idOS / KYC-provider disclaimer catalog from `GET /disclaimers` or * `GET /sessions/{sessionId}/disclaimers`. `null` until the catalog has @@ -296,13 +296,13 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, - disclaimers: { + vendorDisclaimers: { includeInDebugSnapshot: false, includeInStateLogs: false, persist: false, usedInUi: true, }, - disclaimersError: { + vendorError: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: false, @@ -416,8 +416,8 @@ export function getDefaultKycControllerState(): KycControllerState { providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), idosDisclaimersAccepted: null, credentialReusabilityConsentGiven: null, - disclaimers: [], - disclaimersError: null, + vendorDisclaimers: [], + vendorError: null, sessionDisclaimers: null, geoCountry: null, moonpaySessionToken: null, @@ -961,7 +961,7 @@ export class KycController extends BaseController< } else { if ( vendor === 'moonpay' && - this.state.disclaimers.length === 0 + this.state.vendorDisclaimers.length === 0 ) { await this.loadDisclaimers(); if (this.#generation !== generation) { @@ -1057,12 +1057,12 @@ export class KycController extends BaseController< }, ); this.#updateIfCurrent(generation, (state) => { - state.disclaimers = disclaimers; - state.disclaimersError = null; + state.vendorDisclaimers = disclaimers; + state.vendorError = null; }); } catch (error) { this.#updateIfCurrent(generation, (state) => { - state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; + state.vendorError = `Failed to load disclaimers: ${String(error)}`; }); } } @@ -1106,7 +1106,7 @@ export class KycController extends BaseController< params?.credentialReusabilityConsentGiven ?? false; const termsAcceptedAt = new Date().toISOString(); - const disclaimerIds = this.state.disclaimers.map( + const disclaimerIds = this.state.vendorDisclaimers.map( (disclaimer) => disclaimer.id, ); this.#applyUpdate((state) => { @@ -1424,7 +1424,7 @@ export class KycController extends BaseController< const { email } = this.state; const termsAcceptedAt = this.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt; - const acceptedDisclaimerIds = this.state.disclaimers.map( + const acceptedDisclaimerIds = this.state.vendorDisclaimers.map( (disclaimer) => disclaimer.id, ); if (!email) { @@ -2514,8 +2514,8 @@ export class KycController extends BaseController< state.phase = 'idle'; state.statusMessage = ''; state.error = null; - state.disclaimers = []; - state.disclaimersError = null; + state.vendorDisclaimers = []; + state.vendorError = null; state.sessionDisclaimers = null; state.credentialReusabilityConsentGiven = null; state.moonpaySessionToken = null; diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 484e59a8077..44c05016027 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -492,7 +492,7 @@ export class KycService extends BaseDataService< // Guard nullish/empty geolocation with the documented domain error rather // than letting `assert(location, string())` surface a superstruct // assertion error (which would change how the failure reads in - // `disclaimersError`). + // `vendorError`). const alpha2 = typeof location === 'string' ? location.split('-')[0].toUpperCase() : ''; if (!alpha2 || alpha2 === 'UNKNOWN') { From 597a808658c0b1a2bc408a0685411505c9991ed6 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Tue, 1 Sep 2026 14:50:07 -0700 Subject: [PATCH 08/10] fix lint, formatting, changelog and coverage gaps Extract vendor disclaimer acceptance helpers into their own module so the branch-per-vendor logic can be covered directly, add PR links to the breaking-change changelog entries, and resolve the eslint and prettier violations that were failing CI. Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 11 +- .../kyc-controller/src/KycController.test.ts | 238 +++++++++++++++--- packages/kyc-controller/src/KycController.ts | 91 +++---- .../src/vendorDisclaimerAcceptance.test.ts | 131 ++++++++++ .../src/vendorDisclaimerAcceptance.ts | 83 ++++++ 5 files changed, 459 insertions(+), 95 deletions(-) create mode 100644 packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts create mode 100644 packages/kyc-controller/src/vendorDisclaimerAcceptance.ts diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 3633ed955bb..21d121b75a9 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,11 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Rename `KycController` state field `accessToken` to `moonpayAccessToken` (MoonPay Auth-frame access token; `KycService.checkKycRequired` still accepts an `accessToken` parameter). -- **BREAKING:** Rename `KycController` state field `sessionToken` to `moonpaySessionToken` (MoonPay Check-frame session token; the Check-frame URL query parameter remains `sessionToken`). -- **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). -- **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. -- **BREAKING:** Rename `KycController` state field `disclaimers` to `vendorDisclaimers` (vendor T&Cs fetched for the current country) and `disclaimersError` to `vendorError`. +- **BREAKING:** Rename `KycController` state field `accessToken` to `moonpayAccessToken` (MoonPay Auth-frame access token; `KycService.checkKycRequired` still accepts an `accessToken` parameter). ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename `KycController` state field `sessionToken` to `moonpaySessionToken` (MoonPay Check-frame session token; the Check-frame URL query parameter remains `sessionToken`). ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Replace persisted `termsAcceptedAt`, `acceptedDisclaimerIds`, and `termsAcceptedVendor` with `vendorDisclaimersAccepted`: a fixed map with `moonpay: { termsAcceptedAt } | null` and `iron: { disclaimerIds } | null` keys (default `{ moonpay: null, iron: null }`). ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename persisted `idosTncAccepted` to `idosDisclaimersAccepted` and change its type from `boolean | null` to `KycConsentRecord[] | null` (`{ key, version }[]`). `acceptTermsAndStartSession` now takes `idosDisclaimersAccepted: KycConsentRecord[]` instead of `idosTncSigned: boolean`. ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename persisted `sumsubTncAccepted` to `providerDisclaimersAccepted` (`{ sumsub: KycConsentRecord[] | null }`) and change `acceptTermsAndStartSession` to take `providerDisclaimersAccepted: KycConsentRecord[]` instead of `sumsubTncSigned: boolean`. ([#10062](https://github.com/MetaMask/core/pull/10062)) +- **BREAKING:** Rename `KycController` state field `disclaimers` to `vendorDisclaimers` (vendor T&Cs fetched for the current country) and `disclaimersError` to `vendorError`. ([#10062](https://github.com/MetaMask/core/pull/10062)) - Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index a1ae5720d86..1d6d46ce1e4 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -20,6 +20,7 @@ import { import type { KycControllerMessenger } from './KycController.js'; import type { KycConsentRecord, + KycDisclaimer, KycSessionDisclaimers, KycSumSubLauncher, } from './types.js'; @@ -202,6 +203,30 @@ describe('KycController', () => { ); }); + it('auto-creates a session without reloading disclaimers when they are already present', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_MOONPAY, + email: 'a@b.co', + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.initialize(); + + expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.moonpaySessionToken).toBe('sess'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + it('falls back to the terms phase and loads disclaimers when geo fails and no terms exist', async () => { await withController(async ({ controller, handlers }) => { handlers.getGeoCountry.mockRejectedValue(new Error('geo down')); @@ -293,6 +318,72 @@ describe('KycController', () => { }, ); }); + + it('does not auto-create a session when reset() lands during disclaimer loading', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_MOONPAY, + email: 'a@b.co', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + let release: (disclaimers: KycDisclaimer[]) => void = () => { + // placeholder + }; + handlers.fetchVendorDisclaimers.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize(); + while (handlers.fetchVendorDisclaimers.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + release([{ id: '1', display_name: 'T', url: 'u' }]); + await pending; + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('idle'); + }, + ); + }); + + it('requires reacceptance when Iron T&C2 flags were not persisted', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_IRON, + email: 'a@b.co', + providerDisclaimersAccepted: { sumsub: null }, + idosDisclaimersAccepted: null, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createVendorCustomer.mockResolvedValue({ + id: '1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'T', url: 'u' }, + ]); + + await controller.initialize({ vendor: 'iron' }); + + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); }); describe('loadDisclaimers', () => { @@ -387,7 +478,9 @@ describe('KycController', () => { await withController( { options: { - state: { vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + state: { + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, }, }, async ({ controller, handlers }) => { @@ -403,7 +496,9 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBeDefined(); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); expect(controller.state.activeProduct).toBe('ramps'); expect(controller.state.phase).toBe('check'); }, @@ -453,10 +548,12 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBeDefined(); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); - expect(controller.state.providerDisclaimersAccepted.sumsub).toStrictEqual( - MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - ); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual(MOCK_SUMSUB_DISCLAIMERS_ACCEPTED); expect(controller.state.idosDisclaimersAccepted).toStrictEqual( MOCK_IDOS_DISCLAIMERS_ACCEPTED, ); @@ -661,7 +758,9 @@ describe('KycController', () => { await withController( { options: { - state: { vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + state: { + vendorDisclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, }, }, async ({ controller }) => { @@ -709,6 +808,59 @@ describe('KycController', () => { }); }); + describe('acceptTermsAndStartSession (iron)', () => { + it('persists Iron disclaimer ids for vendor disclaimer submission', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.submitVendorDisclaimers.mockRejectedValue(new Error('stop')); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['d1'], + }); + }, + ); + }); + + it('clears Iron acceptance when session creation 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 controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); + }, + ); + }); + }); + describe('handleFrameMessage', () => { it('acks a handshake', async () => { await withController(async ({ controller }) => { @@ -842,7 +994,9 @@ describe('KycController', () => { describe('check frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, + { + options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, + }, async ({ controller }) => { const envelope = envelopeFor(controller, { accessToken: 'access-1', @@ -862,7 +1016,9 @@ describe('KycController', () => { it('moves to auth on connectionRequired and enables the auth frame URL', async () => { await withController( - { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, + { + options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, + }, async ({ controller }) => { const envelope = envelopeFor(controller, { clientToken: 'client-1', @@ -905,14 +1061,18 @@ describe('KycController', () => { }, }); expect(controller.state.phase).toBe('terms'); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeNull(); }, ); }); it('fails on an unexpected status', async () => { await withController( - { options: { state: { phase: 'check', moonpaySessionToken: 'tok' } } }, + { + options: { state: { phase: 'check', moonpaySessionToken: 'tok' } }, + }, async ({ controller }) => { await controller.handleFrameMessage({ message: { @@ -987,7 +1147,11 @@ describe('KycController', () => { await withController( { options: { - state: { phase: 'check', moonpaySessionToken: 'tok', geoCountry: 'USA' }, + state: { + phase: 'check', + moonpaySessionToken: 'tok', + geoCountry: 'USA', + }, }, }, async ({ controller, handlers }) => { @@ -2650,7 +2814,9 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBe('t'); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -2679,7 +2845,9 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBe('t'); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -2771,7 +2939,9 @@ describe('KycController', () => { expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); expect( controller.state.vendorDisclaimersAccepted.moonpay, - ).toStrictEqual(VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay); + ).toStrictEqual( + VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay, + ); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', @@ -2804,7 +2974,9 @@ describe('KycController', () => { // T&C2 flags were null; reacceptance required. expect(controller.state.phase).toBe('terms'); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); - expect(controller.state.providerDisclaimersAccepted.sumsub).toBeNull(); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toBeNull(); expect(controller.state.idosDisclaimersAccepted).toBeNull(); }, ); @@ -2823,9 +2995,9 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'moonpay' }); expect(handlers.createSession).not.toHaveBeenCalled(); - expect( - controller.state.vendorDisclaimersAccepted.iron, - ).toStrictEqual(VENDOR_TERMS_IRON_D1.vendorDisclaimersAccepted.iron); + expect(controller.state.vendorDisclaimersAccepted.iron).toStrictEqual( + VENDOR_TERMS_IRON_D1.vendorDisclaimersAccepted.iron, + ); expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeNull(); expect(controller.state.phase).toBe('terms'); }, @@ -2849,7 +3021,9 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay, - ).toStrictEqual(VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay); + ).toStrictEqual( + VENDOR_TERMS_MOONPAY_D1.vendorDisclaimersAccepted.moonpay, + ); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); }, ); @@ -2873,9 +3047,7 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, - ).toStrictEqual([ - 'iron-d1', - ]); + ).toStrictEqual(['iron-d1']); expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); }, ); @@ -2938,9 +3110,9 @@ describe('KycController', () => { }); expect(controller.state.vendorDisclaimersAccepted.iron).toBeDefined(); - expect(controller.state.providerDisclaimersAccepted.sumsub).toStrictEqual( - MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - ); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual(MOCK_SUMSUB_DISCLAIMERS_ACCEPTED); expect(controller.state.idosDisclaimersAccepted).toStrictEqual( MOCK_IDOS_DISCLAIMERS_ACCEPTED, ); @@ -2985,7 +3157,9 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBe('t'); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -3025,7 +3199,9 @@ describe('KycController', () => { expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBe('t'); - expect(controller.state.vendorDisclaimersAccepted.moonpay).toBeDefined(); + expect( + controller.state.vendorDisclaimersAccepted.moonpay, + ).toBeDefined(); }, ); }); @@ -3468,7 +3644,7 @@ describe('KycController', () => { }, }, }, - async ({ controller, handlers }) => { + async ({ controller }) => { // @ts-expect-error T&C2 flags are required await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3533,7 +3709,9 @@ describe('KycController', () => { }); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.providerDisclaimersAccepted.sumsub).toStrictEqual([]); + expect( + controller.state.providerDisclaimersAccepted.sumsub, + ).toStrictEqual([]); expect(controller.state.idosDisclaimersAccepted).toStrictEqual([]); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 7724df4b08d..4d38a9716a5 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -49,6 +49,12 @@ import { signStorageAccessToken, } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { + clearVendorDisclaimerAcceptance, + hasVendorDisclaimerAcceptance, + ironDisclaimerIds, + recordVendorDisclaimerAcceptance, +} from './vendorDisclaimerAcceptance.js'; // === GENERAL === @@ -470,29 +476,6 @@ function isConsentConflictError(error: unknown): boolean { ); } -function hasVendorDisclaimerAcceptance( - accepted: KycVendorDisclaimersAccepted, - vendor: KycVendor, -): boolean { - if (vendor === 'moonpay') { - return Boolean(accepted.moonpay?.termsAcceptedAt); - } - if (vendor === 'iron') { - return Boolean(accepted.iron?.disclaimerIds.length); - } - return false; -} - -/** - * Returns persisted Iron disclaimer ids, if any. - * - * @param accepted - Vendor-disclaimer acceptance map. - * @returns The accepted disclaimer ids, or an empty array. - */ -function ironDisclaimerIds(accepted: KycVendorDisclaimersAccepted): string[] { - return accepted.iron?.disclaimerIds ?? []; -} - /** * * @param value - The value to validate. @@ -960,10 +943,7 @@ export class KycController extends BaseController< this.state.credentialReusabilityConsentGiven ?? false, }); } else { - if ( - vendor === 'moonpay' && - this.state.vendorDisclaimers.length === 0 - ) { + if (vendor === 'moonpay' && this.state.vendorDisclaimers.length === 0) { await this.loadDisclaimers(); if (this.#generation !== generation) { return; @@ -1018,9 +998,6 @@ export class KycController extends BaseController< vendor: params.vendor, email: params.email, }); - if (this.#generation !== generation) { - return; - } } catch (error) { if (this.#generation !== generation) { return; @@ -1117,17 +1094,11 @@ export class KycController extends BaseController< if (params?.product) { state.activeProduct = params.product; } - if (state.activeVendor === 'moonpay') { - state.vendorDisclaimersAccepted = { - ...state.vendorDisclaimersAccepted, - moonpay: { termsAcceptedAt }, - }; - } else if (state.activeVendor === 'iron') { - state.vendorDisclaimersAccepted = { - ...state.vendorDisclaimersAccepted, - iron: { disclaimerIds }, - }; - } + state.vendorDisclaimersAccepted = recordVendorDisclaimerAcceptance( + state.vendorDisclaimersAccepted, + state.activeVendor, + { termsAcceptedAt, disclaimerIds }, + ); state.providerDisclaimersAccepted = { ...state.providerDisclaimersAccepted, sumsub: providerDisclaimersAccepted, @@ -1341,10 +1312,7 @@ export class KycController extends BaseController< }); if ( - isAcceptedCategoryEmpty( - catalog.idOS, - consents.idosDisclaimersAccepted, - ) || + isAcceptedCategoryEmpty(catalog.idOS, consents.idosDisclaimersAccepted) || isAcceptedCategoryEmpty( catalog.kycProvider, consents.providerDisclaimersAccepted, @@ -1498,7 +1466,8 @@ export class KycController extends BaseController< */ clearSavedTerms(): void { this.#applyUpdate((state) => { - state.vendorDisclaimersAccepted = getDefaultKycVendorDisclaimersAccepted(); + state.vendorDisclaimersAccepted = + getDefaultKycVendorDisclaimersAccepted(); state.providerDisclaimersAccepted = getDefaultKycProviderDisclaimersAccepted(); state.idosDisclaimersAccepted = null; @@ -1519,17 +1488,10 @@ export class KycController extends BaseController< */ #clearAcceptedTerms(state: KycControllerState, vendor?: KycVendor): void { const targetVendor = vendor ?? state.activeVendor; - if (targetVendor === 'moonpay') { - state.vendorDisclaimersAccepted = { - ...state.vendorDisclaimersAccepted, - moonpay: null, - }; - } else if (targetVendor === 'iron') { - state.vendorDisclaimersAccepted = { - ...state.vendorDisclaimersAccepted, - iron: null, - }; - } + state.vendorDisclaimersAccepted = clearVendorDisclaimerAcceptance( + state.vendorDisclaimersAccepted, + targetVendor, + ); state.providerDisclaimersAccepted = getDefaultKycProviderDisclaimersAccepted(); state.idosDisclaimersAccepted = null; @@ -1762,7 +1724,10 @@ export class KycController extends BaseController< * @returns The Check-frame URL or `null`. */ buildCheckFrameUrl(): string | null { - if (this.state.activeVendor !== 'moonpay' || !this.state.moonpaySessionToken) { + if ( + this.state.activeVendor !== 'moonpay' || + !this.state.moonpaySessionToken + ) { return null; } const url = new URL(`${FRAMES_BASE_URL}/check-connection`); @@ -1814,7 +1779,9 @@ export class KycController extends BaseController< }): Promise { const { moonpayAccessToken } = this.state; if (!moonpayAccessToken) { - this.#fail('Missing moonpayAccessToken — repeat the authentication step.'); + this.#fail( + 'Missing moonpayAccessToken — repeat the authentication step.', + ); return false; } const country = params.country ?? this.state.geoCountry; @@ -1835,7 +1802,11 @@ export class KycController extends BaseController< try { const { kycRequired } = await this.messenger.call( 'KycService:checkKycRequired', - { accessToken: moonpayAccessToken, country, capabilities: [{ product: params.product }] }, + { + accessToken: moonpayAccessToken, + country, + capabilities: [{ product: params.product }], + }, ); // The flow was reset while the check was in flight; discard the result // rather than resurrecting a done/cached state on an idle controller. diff --git a/packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts b/packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts new file mode 100644 index 00000000000..3458b3d8cce --- /dev/null +++ b/packages/kyc-controller/src/vendorDisclaimerAcceptance.test.ts @@ -0,0 +1,131 @@ +import { + clearVendorDisclaimerAcceptance, + hasVendorDisclaimerAcceptance, + ironDisclaimerIds, + recordVendorDisclaimerAcceptance, +} from './vendorDisclaimerAcceptance.js'; + +describe('vendorDisclaimerAcceptance', () => { + describe('hasVendorDisclaimerAcceptance', () => { + it('returns true when MoonPay terms are persisted', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: { termsAcceptedAt: 't' }, iron: null }, + 'moonpay', + ), + ).toBe(true); + }); + + it('returns false when MoonPay terms are missing', () => { + expect( + hasVendorDisclaimerAcceptance({ moonpay: null, iron: null }, 'moonpay'), + ).toBe(false); + }); + + it('returns true when Iron disclaimer ids are persisted', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: null, iron: { disclaimerIds: ['d1'] } }, + 'iron', + ), + ).toBe(true); + }); + + it('returns false when Iron disclaimer ids are empty', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: null, iron: { disclaimerIds: [] } }, + 'iron', + ), + ).toBe(false); + }); + + it('returns false for an unknown vendor at runtime', () => { + expect( + hasVendorDisclaimerAcceptance( + { moonpay: null, iron: null }, + 'unknown' as 'moonpay', + ), + ).toBe(false); + }); + }); + + describe('ironDisclaimerIds', () => { + it('returns persisted Iron disclaimer ids', () => { + expect( + ironDisclaimerIds({ + moonpay: null, + iron: { disclaimerIds: ['d1'] }, + }), + ).toStrictEqual(['d1']); + }); + + it('returns an empty array when Iron acceptance is missing', () => { + expect( + ironDisclaimerIds({ + moonpay: null, + iron: null, + }), + ).toStrictEqual([]); + }); + }); + + describe('recordVendorDisclaimerAcceptance', () => { + it('records MoonPay acceptance', () => { + expect( + recordVendorDisclaimerAcceptance( + { moonpay: null, iron: null }, + 'moonpay', + { termsAcceptedAt: 't', disclaimerIds: [] }, + ), + ).toStrictEqual({ moonpay: { termsAcceptedAt: 't' }, iron: null }); + }); + + it('records Iron acceptance', () => { + expect( + recordVendorDisclaimerAcceptance( + { moonpay: null, iron: null }, + 'iron', + { termsAcceptedAt: 't', disclaimerIds: ['d1'] }, + ), + ).toStrictEqual({ moonpay: null, iron: { disclaimerIds: ['d1'] } }); + }); + + it('leaves acceptance unchanged for an unknown vendor at runtime', () => { + const accepted = { moonpay: null, iron: null }; + expect( + recordVendorDisclaimerAcceptance(accepted, 'unknown' as 'moonpay', { + termsAcceptedAt: 't', + disclaimerIds: ['d1'], + }), + ).toBe(accepted); + }); + }); + + describe('clearVendorDisclaimerAcceptance', () => { + it('clears MoonPay acceptance', () => { + expect( + clearVendorDisclaimerAcceptance( + { moonpay: { termsAcceptedAt: 't' }, iron: null }, + 'moonpay', + ), + ).toStrictEqual({ moonpay: null, iron: null }); + }); + + it('clears Iron acceptance', () => { + expect( + clearVendorDisclaimerAcceptance( + { moonpay: null, iron: { disclaimerIds: ['d1'] } }, + 'iron', + ), + ).toStrictEqual({ moonpay: null, iron: null }); + }); + + it('leaves acceptance unchanged for an unknown vendor at runtime', () => { + const accepted = { moonpay: null, iron: { disclaimerIds: ['d1'] } }; + expect( + clearVendorDisclaimerAcceptance(accepted, 'unknown' as 'moonpay'), + ).toBe(accepted); + }); + }); +}); diff --git a/packages/kyc-controller/src/vendorDisclaimerAcceptance.ts b/packages/kyc-controller/src/vendorDisclaimerAcceptance.ts new file mode 100644 index 00000000000..4ded773efe4 --- /dev/null +++ b/packages/kyc-controller/src/vendorDisclaimerAcceptance.ts @@ -0,0 +1,83 @@ +import type { KycVendor, KycVendorDisclaimersAccepted } from './types.js'; + +/** + * Returns whether persisted vendor disclaimer acceptance exists for `vendor`. + * + * @param accepted - Vendor-disclaimer acceptance map. + * @param vendor - Identity vendor to inspect. + * @returns Whether acceptance is present for the vendor. + */ +export function hasVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, +): boolean { + if (vendor === 'moonpay') { + return Boolean(accepted.moonpay?.termsAcceptedAt); + } + if (vendor === 'iron') { + return Boolean(accepted.iron?.disclaimerIds.length); + } + return false; +} + +/** + * Returns persisted Iron disclaimer ids, if any. + * + * @param accepted - Vendor-disclaimer acceptance map. + * @returns The accepted disclaimer ids, or an empty array. + */ +export function ironDisclaimerIds( + accepted: KycVendorDisclaimersAccepted, +): string[] { + return accepted.iron?.disclaimerIds ?? []; +} + +/** + * Records vendor disclaimer acceptance for the active vendor. + * + * @param accepted - Existing vendor-disclaimer acceptance map. + * @param vendor - Identity vendor being accepted. + * @param params - Acceptance payload for the vendor. + * @param params.termsAcceptedAt - MoonPay acceptance timestamp. + * @param params.disclaimerIds - Iron disclaimer ids. + * @returns The updated acceptance map. + */ +export function recordVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, + params: { termsAcceptedAt: string; disclaimerIds: string[] }, +): KycVendorDisclaimersAccepted { + if (vendor === 'moonpay') { + return { + ...accepted, + moonpay: { termsAcceptedAt: params.termsAcceptedAt }, + }; + } + if (vendor === 'iron') { + return { + ...accepted, + iron: { disclaimerIds: params.disclaimerIds }, + }; + } + return accepted; +} + +/** + * Clears persisted vendor disclaimer acceptance for one vendor. + * + * @param accepted - Existing vendor-disclaimer acceptance map. + * @param vendor - Identity vendor whose acceptance should be cleared. + * @returns The updated acceptance map. + */ +export function clearVendorDisclaimerAcceptance( + accepted: KycVendorDisclaimersAccepted, + vendor: KycVendor, +): KycVendorDisclaimersAccepted { + if (vendor === 'moonpay') { + return { ...accepted, moonpay: null }; + } + if (vendor === 'iron') { + return { ...accepted, iron: null }; + } + return accepted; +} From 917e5fb4516558e82d7f4acc018389b5f14d0c6e Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 2 Sep 2026 13:35:15 -0700 Subject: [PATCH 09/10] add comment --- packages/kyc-controller/src/KycController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index d794633bc20..cae736abdd2 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -1378,6 +1378,7 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.sessionDisclaimers = latest; }); + // TODO: Should we really be doing client side validation of these? const stillMissingIdos = acceptedCategoryStillMissing( latest.idOS, consents.idosDisclaimersAccepted, From 4ac21d4cf152a3abc175d12c54fcc7dfd91ea2a2 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 2 Sep 2026 13:39:28 -0700 Subject: [PATCH 10/10] add comment --- packages/kyc-controller/src/KycController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index cae736abdd2..38c8dc3a0d4 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -951,6 +951,7 @@ export class KycController extends BaseController< this.state.credentialReusabilityConsentGiven ?? false, }); } else { + // TODO: should this be here? or should it exist at all? if (vendor === 'moonpay' && this.state.vendorDisclaimers.length === 0) { await this.loadDisclaimers(); if (this.#generation !== generation) {