diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 551ca8e12b2..b8d7e992c2a 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -46,7 +46,7 @@ graph TB Service["KycService
(stateless)
HTTP + response validation"] Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] Selectors["selectors.ts
memoized reselect selectors"] - Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] + Types["types.ts
KycVendor, KycSessionStatus,
KycSumSubLauncher, ..."] Country["countryCodes.ts
alpha-2 → alpha-3"] end @@ -162,69 +162,68 @@ and hex-or-base64 binary fields. #### 2.4 `selectors.ts` -Memoized `reselect` selectors over `KycControllerState`: -`selectKycPhase`, `selectKycSumSub`, and the parametric -`selectIsKycRequiredForProduct(product)`. +Selectors over `KycControllerState` that take the vendor: +`selectKycSession(state, vendor)` and `selectKycSessionStatus(state, vendor)`. +`sessionDecision.ts` exports the pure `getKycSessionDecision(entry)`, which +`KycController.getSessionDecision({ vendor })` wraps. --- ### 3. State shape +The controller holds one session per identity vendor, matching kyc-api, which +keys sessions by canonical user and vendor. There is no active vendor: every +session method takes `{ vendor }`, and features that use the same vendor share +its entry. + ```mermaid classDiagram class KycControllerState { - +KycPhase phase - +string statusMessage - +string error - +string email - +KycVendorDisclaimersAccepted vendorDisclaimersAccepted [persisted] - +KycDisclaimer[] vendorDisclaimers - +string vendorError - +string geoCountry - +string moonpaySessionToken [secret] - +string moonpayAccessToken [secret] - +string moonpayCustomerId - +KycProduct activeProduct - +Record kycRequiredByProduct [persisted] - +string lastCheckedAt [persisted] - +SumSubState sumsub + +Partial~Record~KycVendor, KycVendorSessionState~~ sessions + } + class KycVendorSessionState { + +KycSessionStatus sessionStatus + +KycProviderFlowStatus providerFlowStatus + +string residenceCountry + +KycVendorDisclaimersAcceptance vendorDisclaimersAccepted + +KycConsentRecord[] providerDisclaimersAccepted + +KycConsentRecord[] idosDisclaimersAccepted + +boolean credentialReusabilityConsentGiven } - class SumSubState { - +KycSumSubStatus status - +Json result - +string sessionId - +string applicantAccessToken + class KycVendorDisclaimersAcceptance { + +string[] disclaimerIds + +string acceptedAt + +boolean submitted } - KycControllerState --> SumSubState : sumsub + KycControllerState --> KycVendorSessionState : sessions[vendor] + KycVendorSessionState --> KycVendorDisclaimersAcceptance ``` -> Note: nullable fields (`error`, `email`, `moonpaySessionToken`, …) are typed as -> `T | null` in the source; `Record` is `Partial>`. -> Types are simplified above for diagram readability. - -State metadata highlights (`kycControllerMetadata`): - -- **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, - `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, - `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow - can skip already-accepted terms and reuse cached results. Session-scoped - `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory - only (`persist: false`) and are cleared on `reset()`. - Acceptance is vendor-scoped: `initialize` (and `createVendorCustomer`) drops - the stored acceptance when it belongs to a different vendor, so one vendor's - disclaimer ids are never submitted to another. The drop waits until the - vendor switch commits (`createVendorCustomer` succeeds, or the MoonPay - path proceeds); a failed or reset switch leaves the previous vendor's - acceptance in place. -- **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, - `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. -- Additional non-state secrets kept **off** the state object entirely: the - X25519 private key (`#keypair`) and the Auth-frame client token - (`#authClientToken`). The auth client token is cleared on the same vendor - switch. +> Nullable fields are typed as `T | null` in the source; the diagram drops that +> for readability. + +- An entry is created by whichever comes first: `acceptVendorDisclaimers` for + that vendor or `startSession` for that vendor. +- `residenceCountry` is the country the session was created with, or the + country the vendor's terms were fetched for before a session existed. + Vendor-disclaimer checks read the catalog for it. +- Vendor terms are shown before a session exists but can only be submitted + once one does. `acceptVendorDisclaimers` stores them with + `submitted: false`; `recordVendorDisclaimers({ vendor })` submits them and + sets `submitted: true`. `hasCompletedVendorDisclaimers` only counts + submitted acceptance. +- `getSessionDecision({ vendor })` reduces an entry to `none`, `new`, + `pending`, `approved`, `rejected` or `retry`. It is not a "ready" verdict: + approval says nothing about vendor work after it (e.g. Iron account + activation). +- The whole `sessions` map is persisted. `clearState` / `reset` empty it and + stop every vendor's polling loop. +- The UKYC `local_user_secret` lives in Encrypted User Storage, not on state, + and is shared by every vendor. +- The target model for generalising the flow across vendors is proposed in + [MetaMask/core#11252](https://github.com/MetaMask/core/pull/11252). The + phase-machine sections below describe the earlier MoonPay flow and predate + both. --- @@ -690,15 +689,15 @@ graph LR ### Appendix — key source files -| File | Responsibility | -| ---------------------- | ---------------------------------------------------- | -| `src/KycController.ts` | Stateful orchestrator, phase machine. | -| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | -| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | -| `src/selectors.ts` | Memoized selectors over controller state. | -| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | -| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | -| `src/index.ts` | Public exports (no barrel wildcards). | +| File | Responsibility | +| ---------------------- | ---------------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycVendor`, `KycSessionStatus`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | Reference client (metamask-mobile): diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 94a676c2459..6b831d5a494 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `KycController.getSessionDecision({ vendor })` and the `KycController:getSessionDecision` messenger action, which reduce a vendor's session to one of `none`, `new`, `pending`, `approved`, `rejected` or `retry` ([#11275](https://github.com/MetaMask/core/pull/11275)) + - Export the pure `getKycSessionDecision` function, the `KycSessionDecision` type and `KYC_SESSION_DECISIONS` + - Only the vendor's own `finalStatus: approved` counts as approved. It is not a "ready" verdict and says nothing about vendor work after approval +- Add `KycController.acceptVendorDisclaimers({ vendor, country, disclaimerIds })` and its messenger action, which store a vendor's terms acceptance before a session exists, marked not submitted ([#11275](https://github.com/MetaMask/core/pull/11275)) +- Add `selectKycSession(state, vendor)`, `getDefaultKycVendorSessionState` and the `KycVendorSessionState` and `KycVendorDisclaimersAcceptance` types ([#11275](https://github.com/MetaMask/core/pull/11275)) + +### Changed + +- **BREAKING:** Replace the single-session state with `sessions`, keyed by vendor ([#11275](https://github.com/MetaMask/core/pull/11275)) + - Each entry holds `sessionStatus`, `providerFlowStatus`, `residenceCountry`, `vendorDisclaimersAccepted`, `providerDisclaimersAccepted`, `idosDisclaimersAccepted` and `credentialReusabilityConsentGiven` for that vendor + - The `email`, `vendor` and `geoCountry` fields are removed. Clients must migrate persisted state +- **BREAKING:** Session methods take the vendor: `getProviderFlowStatus`, `readSessionStatus`, `refreshSessionStatus`, `startSessionStatusPolling`, `hasCompletedSessionDisclaimers` and `hasCompletedVendorDisclaimers` take `{ vendor }`; `recordSessionDisclaimers` and `launchProviderFlow` take `vendor` alongside their other parameters ([#11275](https://github.com/MetaMask/core/pull/11275)) +- **BREAKING:** `fetchSessionStatusOnce` takes `{ vendor }` instead of a session id, and `fetchSessionDisclaimers` takes `{ vendor }` instead of `{ sessionId }` ([#11275](https://github.com/MetaMask/core/pull/11275)) +- **BREAKING:** `recordVendorDisclaimers` takes `{ vendor }` and submits the acceptance stored by `acceptVendorDisclaimers`, instead of taking `disclaimerIds`. It needs an existing session ([#11275](https://github.com/MetaMask/core/pull/11275)) +- **BREAKING:** `hasCompletedVendorDisclaimers` only counts acceptance that was submitted to the vendor, checked against the catalog for the entry's `residenceCountry` ([#11275](https://github.com/MetaMask/core/pull/11275)) +- **BREAKING:** `vendor` is required on `KycService.fetchVendorDisclaimers` and `KycService.createUkycSession`, which no longer default to `moonpay` ([#11275](https://github.com/MetaMask/core/pull/11275)) +- `startSession` no longer throws when called for a vendor, email or country that differs from an earlier session, and only requires a verified email when it creates a session ([#11275](https://github.com/MetaMask/core/pull/11275)) +- `launchProviderFlow` returns `failed` without writing state while another vendor's SumSub flow is on screen ([#11275](https://github.com/MetaMask/core/pull/11275)) +- `getDataShares` reads the external user id from any vendor's stored session ([#11275](https://github.com/MetaMask/core/pull/11275)) + +### Removed + +- **BREAKING:** Remove `selectKycVendor`, `getDefaultKycVendorDisclaimersAccepted`, `getDefaultKycProviderDisclaimersAccepted` and the `KycPhase`, `KycProduct`, `KycVendorDisclaimersAccepted`, `KycProviderDisclaimersAccepted`, `KycIronVendorDisclaimersAccepted` and `KycMoonpayVendorDisclaimersAccepted` types ([#11275](https://github.com/MetaMask/core/pull/11275)) + ## [0.15.0] ### Added diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 1654d27fde5..f7af8fb70db 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -31,12 +31,12 @@ export type KycControllerEnsureLocalUserSecretAction = { /** * Starts a KYC session for the given vendor. Reuses a latest vendor session - * when one exists; otherwise creates a UKYC session. + * when one exists; otherwise creates a UKYC session. Sessions for other + * vendors are left untouched. * - * The verified email stored on controller state comes from the partner - * identity token. When that request returns HTTP 422, a stored email is - * kept for an existing session. Creating a vendor customer and UKYC session - * requires the token, which both requests send as `partnerIdentityToken`. + * Creating a vendor customer and UKYC session requires a partner identity + * token carrying a verified email; both requests send it as + * `partnerIdentityToken`. Resuming an existing session does not. * * Session creation derives client material from the UKYC * `local_user_secret`. Call {@link ensureLocalUserSecret} after the user @@ -64,7 +64,8 @@ export type KycControllerResetAction = { }; /** - * Restores the controller to its default state. + * Stops every vendor's session-status polling and removes every session + * entry. */ export type KycControllerClearStateAction = { type: `KycController:clearState`; @@ -86,11 +87,11 @@ export type KycControllerGetSessionStatusForVendorAction = { * Fetches the vendors this customer's verified identity data has been shared * with, newest first. * - * The external user id comes from the session status on state. When no - * session has been stored yet, the latest session for the vendor on state is - * fetched first (without writing it to state). An unresolvable session — no - * vendor on state, or no latest session for it — yields an empty list rather - * than an error, so consumers can render an empty "shared with" list. + * The external user id is the same for every vendor, so it is read from any + * stored session. When none is stored, the latest session for each vendor + * with an entry is fetched (without writing it to state). An unresolvable + * session yields an empty list rather than an error, so consumers can render + * an empty "shared with" list. * * @returns The data shares, newest first; empty when no session can be * resolved. @@ -101,9 +102,12 @@ export type KycControllerGetDataSharesAction = { }; /** - * Returns the durable outcome of the identity-provider flow. + * Returns the durable outcome of a vendor's identity-provider flow. * - * @returns The latest provider-flow status. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The latest provider-flow status, or `not_started` when the vendor + * has no entry. */ export type KycControllerGetProviderFlowStatusAction = { type: `KycController:getProviderFlowStatus`; @@ -111,14 +115,16 @@ export type KycControllerGetProviderFlowStatusAction = { }; /** - * Returns the session already stored on state. + * Returns the vendor's session already stored on state. * - * Starts session-status polling when `finalStatus` is not `approved`, - * `rejected`, or `retry`. Does not fetch status or refresh capability - * authorization. + * Starts that vendor's session-status polling when `finalStatus` is not + * `approved`, `rejected`, or `retry`. Does not fetch status or refresh + * capability authorization. * + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns The session status stored at call time. - * @throws If there is no session on state. + * @throws If the vendor has no session on state. */ export type KycControllerRefreshSessionStatusAction = { type: `KycController:refreshSessionStatus`; @@ -126,11 +132,13 @@ export type KycControllerRefreshSessionStatusAction = { }; /** - * Returns the persisted session status. + * Returns the vendor's persisted session status. * * Does not call the network and does not start session-status polling. * - * @returns The session on state, or `null` when none is stored. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The vendor's session on state, or `null` when none is stored. */ export type KycControllerReadSessionStatusAction = { type: `KycController:readSessionStatus`; @@ -138,12 +146,15 @@ export type KycControllerReadSessionStatusAction = { }; /** - * Fetches `GET /sessions/{id}/status` once and writes the result onto state. + * Fetches `GET /sessions/{id}/status` once for the vendor's stored session + * and writes the result onto that vendor's entry. * * Does not start the session-status polling timer. * - * @param sessionId - UKYC session id. + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns The fetched session status. + * @throws If the vendor has no session on state. */ export type KycControllerFetchSessionStatusOnceAction = { type: `KycController:fetchSessionStatusOnce`; @@ -151,15 +162,17 @@ export type KycControllerFetchSessionStatusOnceAction = { }; /** - * Starts polling `GET /sessions/{id}/status` for - * {@link KycControllerState.sessionStatus}'s current `id`. Each tick writes - * the result onto state only when the payload changed. The first poll runs + * Starts polling `GET /sessions/{id}/status` for the vendor's stored + * session. Each vendor has its own loop. Each tick writes the result onto + * that vendor's entry only when the payload changed. The first poll runs * immediately. Each later wait starts at 10 seconds and grows by 10 seconds * after every poll, including a failed one. Starting the loop again resets * that delay. The loop stops once `finalStatus` is `approved`, `rejected`, * or `retry`, or when {@link reset} / {@link clearState} runs. * - * @throws If there is no current session id to poll. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @throws If the vendor has no session id to poll. */ export type KycControllerStartSessionStatusPollingAction = { type: `KycController:startSessionStatusPolling`; @@ -168,18 +181,16 @@ export type KycControllerStartSessionStatusPollingAction = { /** * Fetches the idOS + KYC-provider disclaimer catalog. Pass exactly one of - * `sessionId` or `country`: + * `vendor` or `country`: * - * - `{ sessionId }` → {@link KycService.fetchSessionDisclaimersBySessionId} - * (`GET /sessions/{sessionId}/disclaimers`) + * - `{ vendor }` → {@link KycService.fetchSessionDisclaimersBySessionId} + * for that vendor's session (`GET /sessions/{sessionId}/disclaimers`) * - `{ country }` → {@link KycService.fetchSessionDisclaimersByCountry} * (`GET /disclaimers?country=`) * - * A session-id fetch also writes the catalog to `sessionDisclaimers`. - * - * @param params - The parameters. Provide exactly one of `sessionId` or + * @param params - The parameters. Provide exactly one of `vendor` or * `country`. - * @param params.sessionId - The UKYC session id. + * @param params.vendor - Identity vendor whose session catalog to fetch. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The catalog. Session fetches include consent state; country * fetches do not. @@ -190,12 +201,13 @@ export type KycControllerFetchSessionDisclaimersAction = { }; /** - * Fetches the session-scoped disclaimer catalog and records consents - * derived from the T&C2 flags. Already-consented catalog rows are omitted - * from the POST. A 409 is re-checked with a GET: continue only when every - * accepted document is now consented, otherwise fail closed. + * Fetches the vendor's session-scoped disclaimer catalog and records + * consents derived from the T&C2 flags. Already-consented catalog rows are + * omitted from the POST. A 409 is re-checked with a GET: continue only when + * every accepted document is now consented, otherwise fail closed. * * @param params - Accepted session-disclaimer records. + * @param params.vendor - The identity vendor. * @param params.providerDisclaimersAccepted - Accepted Sumsub disclaimer records. * @param params.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param params.credentialReusabilityConsentGiven - Whether credential @@ -207,10 +219,13 @@ export type KycControllerRecordSessionDisclaimersAction = { }; /** - * Fetches the current session status and reports whether consent was given. + * Fetches the vendor's current session status and reports whether consent + * was given. * + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns Whether `consentStatus` is `given`. - * @throws If there is no session on state. + * @throws If the vendor has no session on state. */ export type KycControllerHasCompletedSessionDisclaimersAction = { type: `KycController:hasCompletedSessionDisclaimers`; @@ -222,7 +237,7 @@ export type KycControllerHasCompletedSessionDisclaimersAction = { * created (`GET /vendors/{vendor}/disclaimers?country=`). * * @param params - The parameters. - * @param params.vendor - Identity vendor. Defaults to `moonpay`. + * @param params.vendor - Identity vendor. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ @@ -232,13 +247,34 @@ export type KycControllerFetchVendorDisclaimersAction = { }; /** - * Records vendor T&C acceptance via {@link KycService.submitVendorDisclaimers} - * and persists the accepted ids on state. + * Stores the customer's acceptance of a vendor's T&Cs on the device, before + * the vendor has it. Makes no network call, so it works before a session + * exists; {@link recordVendorDisclaimers} submits it once one does. Creates + * the vendor's entry when it has none. + * + * Accepting again replaces earlier acceptance and marks it not submitted. * * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @param params.country - ISO 3166-1 alpha-3 country the terms were fetched + * for. Recorded as the entry's residence country until a session exists. * @param params.disclaimerIds - Accepted vendor T&C ids. + */ +export type KycControllerAcceptVendorDisclaimersAction = { + type: `KycController:acceptVendorDisclaimers`; + handler: KycController['acceptVendorDisclaimers']; +}; + +/** + * Submits the vendor T&C acceptance stored by + * {@link acceptVendorDisclaimers} via + * {@link KycService.submitVendorDisclaimers}, then marks it submitted. + * + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns The vendor signing records. - * @throws If `vendor` is missing from state. + * @throws If the vendor has no stored acceptance, or no session yet (the + * vendor records terms against a customer that session creation creates). */ export type KycControllerRecordVendorDisclaimersAction = { type: `KycController:recordVendorDisclaimers`; @@ -246,17 +282,32 @@ export type KycControllerRecordVendorDisclaimersAction = { }; /** - * Fetches the current vendor T&C catalog and returns whether every document - * is already recorded in {@link KycControllerState.vendorDisclaimersAccepted}. + * Fetches the vendor's current T&C catalog for the entry's residence country + * and returns whether submitted acceptance covers every document. * - * @returns Whether persisted acceptance covers the fetched catalog. - * @throws If `vendor` or `geoCountry` is missing from state. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns Whether submitted acceptance covers the fetched catalog. + * @throws If the vendor has no entry or no residence country. */ export type KycControllerHasCompletedVendorDisclaimersAction = { type: `KycController:hasCompletedVendorDisclaimers`; handler: KycController['hasCompletedVendorDisclaimers']; }; +/** + * Returns one verdict for the vendor's KYC session. See + * {@link KycSessionDecision}. + * + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The session decision; `none` when the vendor has no session. + */ +export type KycControllerGetSessionDecisionAction = { + type: `KycController:getSessionDecision`; + handler: KycController['getSessionDecision']; +}; + /** * Deletes the caller's encrypted KYC blobs from MetaMask servers. * @@ -271,9 +322,14 @@ export type KycControllerDeleteEncryptedStorageAction = { }; /** - * Presents the identity-provider verification UI. Currently launches SumSub. + * Presents the identity-provider verification UI for the vendor's session. + * Currently launches SumSub. + * + * Only one flow is presented at a time. A launch while another flow is on + * screen returns `failed` without writing state. * - * @param params - Optional SDK presentation options. + * @param params - The vendor and optional SDK presentation options. + * @param params.vendor - The identity vendor. * @param params.locale - BCP-47 locale for the SDK UI. * @param params.debug - Enables SDK debug logging. * @returns The durable provider-flow outcome. @@ -302,7 +358,9 @@ export type KycControllerMethodActions = | KycControllerRecordSessionDisclaimersAction | KycControllerHasCompletedSessionDisclaimersAction | KycControllerFetchVendorDisclaimersAction + | KycControllerAcceptVendorDisclaimersAction | KycControllerRecordVendorDisclaimersAction | KycControllerHasCompletedVendorDisclaimersAction + | KycControllerGetSessionDecisionAction | KycControllerDeleteEncryptedStorageAction | KycControllerLaunchProviderFlowAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 701c3889504..f800e21609a 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -18,9 +18,14 @@ import { getKycAnalyticsStatus } from './analytics.js'; import { base64UrlToBytes, toBase64Url } from './encoding.js'; import { getDefaultKycControllerState, + getDefaultKycVendorSessionState, KycController, } from './KycController.js'; -import type { KycControllerMessenger } from './KycController.js'; +import type { + FetchSessionDisclaimersParams, + KycControllerMessenger, + KycVendorSessionState, +} from './KycController.js'; import type { KycSumSubLauncher } from './providers/sumsub.js'; import { KYC_CURRENT_STEPS } from './types.js'; import type { @@ -102,11 +107,11 @@ describe('KycController', () => { describe('constructor', () => { it('accepts initial state merged over defaults', async () => { await withController( - { options: { state: { email: 'a@b.co', vendor: 'iron' } } }, + { options: { state: { sessions: { iron: vendorSession({}) } } } }, ({ controller }) => { - expect(controller.state.email).toBe('a@b.co'); - expect(controller.state.vendor).toBe('iron'); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron).toStrictEqual( + vendorSession({}), + ); }, ); }); @@ -163,10 +168,10 @@ describe('KycController', () => { ).toBeLessThan(handlers.createUkycSession.mock.invocationCallOrder[0]); expect(handlers.setAuthorizations).toHaveBeenCalled(); expect(result).toStrictEqual(sessionStatus('approved')); - expect(controller.state.sessionStatus).toStrictEqual(result); - expect(controller.state.email).toBe('a@b.co'); - expect(controller.state.vendor).toBe('iron'); - expect(controller.state.geoCountry).toBe('USA'); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + result, + ); + expect(controller.state.sessions.iron?.residenceCountry).toBe('USA'); }); }); @@ -183,7 +188,7 @@ describe('KycController', () => { expect(handlers.createUkycSession).not.toHaveBeenCalled(); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); expect(result).toStrictEqual(sessionStatus('pending')); - expect(controller.state.sessionStatus).toStrictEqual( + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( sessionStatus('pending'), ); }); @@ -194,10 +199,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -229,12 +236,14 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending', { - capabilityAuthorizationStatus: 'stored', - }), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending', { + capabilityAuthorizationStatus: 'stored', + }), + }), + }, }, }, }, @@ -253,7 +262,9 @@ describe('KycController', () => { expect(handlers.createUkycSession).not.toHaveBeenCalled(); expectResetWrappingKeys(handlers.resetWrappingKeys, 'sid'); expect(handlers.setAuthorizations).toHaveBeenCalled(); - expect(controller.state.sessionStatus).toStrictEqual(refreshed); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + refreshed, + ); }, ); }); @@ -269,10 +280,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -302,10 +315,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -333,10 +348,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('approved'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('approved'), + }), + }, }, }, }, @@ -384,10 +401,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -411,10 +430,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -430,50 +451,24 @@ describe('KycController', () => { ); }); - it('throws when email does not match the initialized session', async () => { - await withController( - { options: { state: { email: 'a@b.co' } } }, - async ({ controller, handlers }) => { - handlers.getPartnerIdentityToken.mockResolvedValue( - unsignedJwt({ - ext: { email: 'other@b.co', email_verified: true }, - }), - ); - - await expect( - controller.startSession({ vendor: 'iron' }), - ).rejects.toThrow( - 'KycController already initialized with a different email', - ); - }, - ); - }); - - it('throws when vendor does not match the initialized session', async () => { + it('keeps the stored residence country when the resolved country differs', async () => { await withController( - { options: { state: { vendor: 'moonpay' } } }, - async ({ controller }) => { - await expect( - controller.startSession({ vendor: 'iron' }), - ).rejects.toThrow( - 'KycController already initialized with a different vendor', - ); + { + options: { + state: { + sessions: { iron: vendorSession({ residenceCountry: 'USA' }) }, + }, + }, }, - ); - }); - - it('throws when the resolved country does not match the initialized session', async () => { - await withController( - { options: { state: { geoCountry: 'USA' } } }, async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('DEU'); - - await expect( - controller.startSession({ vendor: 'iron' }), - ).rejects.toThrow( - 'KycController already initialized with a different geoCountry', + handlers.getSessionStatusForVendor.mockResolvedValue( + sessionStatus('pending'), ); - expect(handlers.getSessionStatusForVendor).not.toHaveBeenCalled(); + + await controller.startSession({ vendor: 'iron' }); + + expect(controller.state.sessions.iron?.residenceCountry).toBe('USA'); }, ); }); @@ -485,7 +480,7 @@ describe('KycController', () => { ); await expect( - controller.startSession({ vendor: 'iron' }), + controller.startSession({ vendor: 'iron', aal2Token: AAL2_TOKEN }), ).rejects.toThrow('KycController missing verified email for vendor'); }); }); @@ -495,10 +490,12 @@ describe('KycController', () => { { options: { state: { - email: 'stored@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -513,14 +510,13 @@ describe('KycController', () => { await controller.startSession({ vendor: 'iron' }), ).toStrictEqual(sessionStatus('pending')); expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); - expect(controller.state.email).toBe('stored@b.co'); }, ); }); - it('does not create a customer from stored email when the partner identity token is missing', async () => { + it('does not create a customer when the partner identity token is missing', async () => { await withController( - { options: { state: { email: 'stored@b.co' } } }, + { options: { state: { sessions: {} } } }, async ({ controller, handlers }) => { handlers.getPartnerIdentityToken.mockRejectedValue( Object.assign(new Error('email_required'), { status: 422 }), @@ -532,9 +528,7 @@ describe('KycController', () => { vendor: 'iron', aal2Token: AAL2_TOKEN, }), - ).rejects.toThrow( - 'KycController missing partner identity token for vendor', - ); + ).rejects.toThrow('KycController missing verified email for vendor'); expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); expect(handlers.createUkycSession).not.toHaveBeenCalled(); }, @@ -615,7 +609,7 @@ describe('KycController', () => { ).rejects.toThrow(/aal2Token is required to create a KYC session/u); expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); expect(handlers.createUkycSession).not.toHaveBeenCalled(); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron?.sessionStatus).toBeNull(); }); }); @@ -633,7 +627,7 @@ describe('KycController', () => { }), ).rejects.toThrow('customer failed'); expect(handlers.createUkycSession).not.toHaveBeenCalled(); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron?.sessionStatus).toBeNull(); }); }); @@ -656,7 +650,7 @@ describe('KycController', () => { cause: fetchError, }); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron?.sessionStatus).toBeNull(); }); }); @@ -681,7 +675,7 @@ describe('KycController', () => { cause: verificationError, }); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron?.sessionStatus).toBeNull(); }); }); }); @@ -692,10 +686,12 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - vendor: 'iron', - geoCountry: 'USA', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + sessionStatus: sessionStatus('pending'), + }), + }, }, }, }, @@ -735,7 +731,17 @@ describe('KycController', () => { it('uses the external user id from the session on state', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('approved') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('approved'), + }), + }, + }, + }, + }, async ({ controller, handlers }) => { handlers.getDataShares.mockResolvedValue(dataShares); @@ -750,7 +756,17 @@ describe('KycController', () => { it('returns an empty list when nothing has been shared', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('approved') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('approved'), + }), + }, + }, + }, + }, async ({ controller }) => { expect(await controller.getDataShares()).toStrictEqual([]); }, @@ -759,7 +775,7 @@ describe('KycController', () => { it('resolves the latest session for the vendor when state is cold', async () => { await withController( - { options: { state: { vendor: 'iron' } } }, + { options: { state: { sessions: { iron: vendorSession({}) } } } }, async ({ controller, handlers }) => { handlers.getSessionStatusForVendor.mockResolvedValue( sessionStatus('approved', { externalUserId: 'ext-cold' }), @@ -774,7 +790,7 @@ describe('KycController', () => { externalUserId: 'ext-cold', }); // The resolved session is not written to state. - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron?.sessionStatus).toBeNull(); }, ); }); @@ -789,7 +805,7 @@ describe('KycController', () => { it('returns an empty list when the vendor has no latest session', async () => { await withController( - { options: { state: { vendor: 'iron' } } }, + { options: { state: { sessions: { iron: vendorSession({}) } } } }, async ({ controller, handlers }) => { handlers.getSessionStatusForVendor.mockResolvedValue(null); @@ -801,7 +817,17 @@ describe('KycController', () => { it('is exposed as a messenger action', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('approved') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('approved'), + }), + }, + }, + }, + }, async ({ rootMessenger, handlers }) => { handlers.getDataShares.mockResolvedValue(dataShares); @@ -820,9 +846,9 @@ describe('KycController', () => { it('throws when no session is available', async () => { await withController(async ({ controller }) => { - expect(() => controller.refreshSessionStatus()).toThrow( - 'No session was found', - ); + expect(() => + controller.refreshSessionStatus({ vendor: 'iron' }), + ).toThrow('No session was found'); }); }); @@ -835,15 +861,23 @@ describe('KycController', () => { }); await withController( { - options: { state: { sessionStatus: current } }, + options: { + state: { + sessions: { iron: vendorSession({ sessionStatus: current }) }, + }, + }, }, async ({ controller, handlers }) => { - expect(controller.refreshSessionStatus()).toStrictEqual(current); + expect( + controller.refreshSessionStatus({ vendor: 'iron' }), + ).toStrictEqual(current); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); - expect(controller.state.sessionStatus).toStrictEqual(current); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + current, + ); await vi.advanceTimersByTimeAsync(30_000); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); @@ -860,19 +894,27 @@ describe('KycController', () => { const latest = sessionStatus('pending', { kycStatus: 'in_review' }); await withController( { - options: { state: { sessionStatus: current } }, + options: { + state: { + sessions: { iron: vendorSession({ sessionStatus: current }) }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(latest); - expect(controller.refreshSessionStatus()).toStrictEqual(current); + expect( + controller.refreshSessionStatus({ vendor: 'iron' }), + ).toStrictEqual(current); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); expect(handlers.getSessionStatus).toHaveBeenCalledWith({ sessionId: 'sid', }); - expect(controller.state.sessionStatus).toStrictEqual(latest); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + latest, + ); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); @@ -886,7 +928,7 @@ describe('KycController', () => { describe('readSessionStatus', () => { it('returns null when no session is stored', async () => { await withController(({ controller, handlers }) => { - expect(controller.readSessionStatus()).toBeNull(); + expect(controller.readSessionStatus({ vendor: 'iron' })).toBeNull(); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }); }); @@ -894,12 +936,20 @@ describe('KycController', () => { it('returns the persisted session without fetching', async () => { await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, ({ controller, handlers }) => { - expect(controller.readSessionStatus()).toStrictEqual( - sessionStatus('pending'), - ); + expect( + controller.readSessionStatus({ vendor: 'iron' }), + ).toStrictEqual(sessionStatus('pending')); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }, ); @@ -915,17 +965,26 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue( sessionStatus('approved'), ); - expect(await controller.fetchSessionStatusOnce('sid')).toStrictEqual( - sessionStatus('approved'), - ); - expect(controller.state.sessionStatus).toMatchInlineSnapshot(` + expect( + await controller.fetchSessionStatusOnce({ vendor: 'iron' }), + ).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sessions.iron?.sessionStatus) + .toMatchInlineSnapshot(` { "currentStep": "approved", "externalUserId": "ext-1", @@ -955,16 +1014,24 @@ describe('KycController', () => { it('throws when no session id is available', async () => { await withController(({ controller }) => { - expect(() => controller.startSessionStatusPolling()).toThrow( - 'No session was found', - ); + expect(() => + controller.startSessionStatusPolling({ vendor: 'iron' }), + ).toThrow('No session was found'); }); }); it('updates state when the fetched session status differs', async () => { await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValueOnce({ @@ -972,13 +1039,15 @@ describe('KycController', () => { kycStatus: 'in_review', }); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledWith({ sessionId: 'sid', }); - expect(controller.state.sessionStatus?.kycStatus).toBe('in_review'); + expect(controller.state.sessions.iron?.sessionStatus?.kycStatus).toBe( + 'in_review', + ); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); }, @@ -993,15 +1062,25 @@ describe('KycController', () => { }); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(fetched); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); - expect(controller.state.sessionStatus).toStrictEqual(fetched); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + fetched, + ); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); }, @@ -1015,18 +1094,24 @@ describe('KycController', () => { }); await withController( { - options: { state: { sessionStatus: current } }, + options: { + state: { + sessions: { iron: vendorSession({ sessionStatus: current }) }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(current); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); - expect(controller.state.sessionStatus).toStrictEqual(current); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + current, + ); }, ); }); @@ -1037,15 +1122,25 @@ describe('KycController', () => { }); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(pendingAuth); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); - expect(controller.state.sessionStatus).toStrictEqual(pendingAuth); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + pendingAuth, + ); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); }, @@ -1058,15 +1153,25 @@ describe('KycController', () => { }); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(approvedExpired); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); - expect(controller.state.sessionStatus).toStrictEqual(approvedExpired); + expect(controller.state.sessions.iron?.sessionStatus).toStrictEqual( + approvedExpired, + ); expect(handlers.resetWrappingKeys).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); }, @@ -1079,14 +1184,22 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue( sessionStatus(finalStatus), ); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); @@ -1101,12 +1214,20 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); @@ -1132,17 +1253,25 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); await vi.advanceTimersByTimeAsync(10_000); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(2); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); @@ -1166,20 +1295,32 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus .mockRejectedValueOnce(new Error('network down')) .mockResolvedValue(sessionStatus('approved')); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); - expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); + expect( + controller.state.sessions.iron?.sessionStatus?.finalStatus, + ).toBe('pending'); await vi.advanceTimersByTimeAsync(10_000); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(2); - expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect( + controller.state.sessions.iron?.sessionStatus?.finalStatus, + ).toBe('approved'); }, ); }); @@ -1188,14 +1329,22 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockRejectedValue( new Error('network down'), ); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); @@ -1214,12 +1363,20 @@ describe('KycController', () => { vi.useFakeTimers(); await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); @@ -1234,7 +1391,15 @@ describe('KycController', () => { it('discards an in-flight poll after reset', async () => { await withController( { - options: { state: { sessionStatus: sessionStatus('pending') } }, + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockImplementation(async () => { @@ -1242,25 +1407,38 @@ describe('KycController', () => { return sessionStatus('approved'); }); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await flushPromises(); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron).toBeUndefined(); }, ); }); }); describe('fetchSessionDisclaimers', () => { - it('fetches by session id', async () => { - await withController(async ({ controller, handlers }) => { - expect( - await controller.fetchSessionDisclaimers({ sessionId: 'sid' }), - ).toStrictEqual(MOCK_SESSION_DISCLAIMERS); - expect( - handlers.fetchSessionDisclaimersBySessionId, - ).toHaveBeenCalledWith({ sessionId: 'sid' }); - }); + it('fetches the vendor session catalog', async () => { + await withController( + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, + async ({ controller, handlers }) => { + expect( + await controller.fetchSessionDisclaimers({ vendor: 'iron' }), + ).toStrictEqual(MOCK_SESSION_DISCLAIMERS); + expect( + handlers.fetchSessionDisclaimersBySessionId, + ).toHaveBeenCalledWith({ sessionId: 'sid' }); + }, + ); }); it('fetches by country', async () => { @@ -1277,14 +1455,14 @@ describe('KycController', () => { }); }); - it('throws when both sessionId and country are provided', async () => { + it('throws when both vendor and country are provided', async () => { await withController(async ({ controller, handlers }) => { await expect( controller.fetchSessionDisclaimers({ - sessionId: 'sid', + vendor: 'iron', country: 'USA', - } as unknown as { sessionId: string } | { country: string }), - ).rejects.toThrow(/provide exactly one of sessionId or country/u); + } as unknown as FetchSessionDisclaimersParams), + ).rejects.toThrow(/provide exactly one of vendor or country/u); expect( handlers.fetchSessionDisclaimersBySessionId, ).not.toHaveBeenCalled(); @@ -1294,13 +1472,13 @@ describe('KycController', () => { }); }); - it('throws when neither sessionId nor country is provided', async () => { + it('throws when neither vendor nor country is provided', async () => { await withController(async ({ controller }) => { await expect( controller.fetchSessionDisclaimers( - {} as { sessionId: string } | { country: string }, + {} as FetchSessionDisclaimersParams, ), - ).rejects.toThrow(/provide exactly one of sessionId or country/u); + ).rejects.toThrow(/provide exactly one of vendor or country/u); }); }); }); @@ -1310,6 +1488,7 @@ describe('KycController', () => { await withController(async ({ controller }) => { await expect( controller.recordSessionDisclaimers({ + vendor: 'iron', providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, @@ -1320,9 +1499,20 @@ describe('KycController', () => { it('submits consents and persists accepted records', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers }) => { await controller.recordSessionDisclaimers({ + vendor: 'iron', providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, credentialReusabilityConsentGiven: true, @@ -1335,12 +1525,14 @@ describe('KycController', () => { credentialReusabilityConsentGiven: true, }); expect( - controller.state.providerDisclaimersAccepted.sumsub, + controller.state.sessions.iron?.providerDisclaimersAccepted, ).toStrictEqual(MOCK_SUMSUB_DISCLAIMERS_ACCEPTED); - expect(controller.state.idosDisclaimersAccepted).toStrictEqual( - MOCK_IDOS_DISCLAIMERS_ACCEPTED, - ); - expect(controller.state.credentialReusabilityConsentGiven).toBe(true); + expect( + controller.state.sessions.iron?.idosDisclaimersAccepted, + ).toStrictEqual(MOCK_IDOS_DISCLAIMERS_ACCEPTED); + expect( + controller.state.sessions.iron?.credentialReusabilityConsentGiven, + ).toBe(true); }, ); }); @@ -1350,21 +1542,33 @@ describe('KycController', () => { it('throws when no session exists', async () => { await withController(async ({ controller }) => { await expect( - controller.hasCompletedSessionDisclaimers(), + controller.hasCompletedSessionDisclaimers({ vendor: 'iron' }), ).rejects.toThrow('No session was found'); }); }); it('returns true when the fetched session consentStatus is given', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue({ ...sessionStatus('pending'), consentStatus: 'given', }); - expect(await controller.hasCompletedSessionDisclaimers()).toBe(true); + expect( + await controller.hasCompletedSessionDisclaimers({ vendor: 'iron' }), + ).toBe(true); expect(handlers.getSessionStatus).toHaveBeenCalledWith({ sessionId: 'sid', }); @@ -1377,14 +1581,26 @@ describe('KycController', () => { it('returns false when the fetched session consentStatus is not given', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue({ ...sessionStatus('pending'), consentStatus: 'pending', }); - expect(await controller.hasCompletedSessionDisclaimers()).toBe(false); + expect( + await controller.hasCompletedSessionDisclaimers({ vendor: 'iron' }), + ).toBe(false); expect(handlers.getSessionStatus).toHaveBeenCalledWith({ sessionId: 'sid', }); @@ -1414,74 +1630,68 @@ describe('KycController', () => { }); describe('recordVendorDisclaimers', () => { - it('throws when vendor is missing', async () => { - await withController( - { options: { state: { email: 'a@b.co' } } }, - async ({ controller, handlers }) => { - await expect( - controller.recordVendorDisclaimers({ disclaimerIds: ['d1'] }), - ).rejects.toThrow('No vendor was found'); - expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); - }, - ); - }); - - it('throws when email is missing', async () => { - await withController( - { options: { state: { vendor: 'iron' } } }, - async ({ controller, handlers }) => { - await expect( - controller.recordVendorDisclaimers({ disclaimerIds: ['d1'] }), - ).rejects.toThrow('No email was found'); - expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); - }, - ); - }); - - it('submits accepted ids and persists them', async () => { + it('submits the stored acceptance for the vendor', async () => { const signings = [ { id: 'sign-1', customer_id: 'cust-1', content_id: 'd1' }, ]; await withController( - { options: { state: { vendor: 'iron', email: 'a@b.co' } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers }) => { handlers.submitVendorDisclaimers.mockResolvedValue(signings); + controller.acceptVendorDisclaimers({ + vendor: 'iron', + country: 'USA', + disclaimerIds: ['d1'], + }); expect( - await controller.recordVendorDisclaimers({ disclaimerIds: ['d1'] }), + await controller.recordVendorDisclaimers({ vendor: 'iron' }), ).toStrictEqual(signings); expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', disclaimerIds: ['d1'], }); - expect( - controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, - ).toStrictEqual(['d1']); }, ); }); }); describe('hasCompletedVendorDisclaimers', () => { - it('throws when vendor is missing', async () => { + it('throws when the vendor has no entry', async () => { await withController( - { options: { state: { geoCountry: 'USA' } } }, + { + options: { + state: { + sessions: { iron: vendorSession({ residenceCountry: 'USA' }) }, + }, + }, + }, async ({ controller }) => { await expect( - controller.hasCompletedVendorDisclaimers(), - ).rejects.toThrow('No vendor was found'); + controller.hasCompletedVendorDisclaimers({ vendor: 'moonpay' }), + ).rejects.toThrow('No session was found'); }, ); }); - it('throws when geoCountry is missing', async () => { + it('throws when the residence country is missing', async () => { await withController( - { options: { state: { vendor: 'iron' } } }, + { options: { state: { sessions: { iron: vendorSession({}) } } } }, async ({ controller, handlers }) => { await expect( - controller.hasCompletedVendorDisclaimers(), - ).rejects.toThrow('No geoCountry was found'); + controller.hasCompletedVendorDisclaimers({ vendor: 'iron' }), + ).rejects.toThrow('No residence country was found'); expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); }, ); @@ -1492,11 +1702,15 @@ describe('KycController', () => { { options: { state: { - vendor: 'iron', - geoCountry: 'USA', - vendorDisclaimersAccepted: { - moonpay: null, - iron: { disclaimerIds: ['d1'] }, + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + vendorDisclaimersAccepted: { + disclaimerIds: ['d1'], + acceptedAt: null, + submitted: true, + }, + }), }, }, }, @@ -1507,21 +1721,27 @@ describe('KycController', () => { { id: 'd2', display_name: 'T2', url: 'u2' }, ]); - expect(await controller.hasCompletedVendorDisclaimers()).toBe(false); + expect( + await controller.hasCompletedVendorDisclaimers({ vendor: 'iron' }), + ).toBe(false); }, ); }); - it('returns true when persisted Iron ids cover the fetched catalog', async () => { + it('returns true when submitted ids cover the fetched catalog', async () => { await withController( { options: { state: { - vendor: 'iron', - geoCountry: 'USA', - vendorDisclaimersAccepted: { - moonpay: null, - iron: { disclaimerIds: ['d1', 'd2'] }, + sessions: { + iron: vendorSession({ + residenceCountry: 'USA', + vendorDisclaimersAccepted: { + disclaimerIds: ['d1', 'd2'], + acceptedAt: null, + submitted: true, + }, + }), }, }, }, @@ -1532,7 +1752,9 @@ describe('KycController', () => { { id: 'd2', display_name: 'T2', url: 'u2' }, ]); - expect(await controller.hasCompletedVendorDisclaimers()).toBe(true); + expect( + await controller.hasCompletedVendorDisclaimers({ vendor: 'iron' }), + ).toBe(true); }, ); }); @@ -1541,11 +1763,22 @@ describe('KycController', () => { describe('launchProviderFlow', () => { it('launches SumSub when a session exists', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers, launcher }) => { launcher.launch.mockResolvedValue({ status: 'Completed' }); const result = await controller.launchProviderFlow({ + vendor: 'iron', locale: 'en', debug: false, }); @@ -1553,42 +1786,82 @@ describe('KycController', () => { expect(result).toBe('submitted'); expect(handlers.createJourney).toHaveBeenCalledWith('sid'); expect(launcher.launch).toHaveBeenCalled(); - expect(controller.state.providerFlowStatus).toBe('submitted'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'submitted', + ); }, ); }); it('records abandonment when SumSub closes before submission', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, launcher }) => { launcher.launch.mockResolvedValue({ status: 'Incomplete' }); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('abandoned'); - expect(controller.state.providerFlowStatus).toBe('abandoned'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'abandoned', + ); }, ); }); it('records failure when SumSub reports that it could not run', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, launcher }) => { launcher.launch.mockResolvedValue({ status: 'Failed' }); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('failed'); - expect(controller.state.providerFlowStatus).toBe('failed'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'failed', + ); }, ); }); it('records failure when SumSub reports Failed through a status change', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, launcher }) => { launcher.launch.mockImplementation( async (params: { @@ -1599,17 +1872,29 @@ describe('KycController', () => { }, ); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('failed'); - expect(controller.state.providerFlowStatus).toBe('failed'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'failed', + ); }, ); }); it('marks the session pending and starts polling once the SDK completes', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('retry') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('retry') }), + }, + }, + }, + }, async ({ controller, handlers, launcher }) => { launcher.launch.mockImplementation( async (params: { @@ -1624,17 +1909,21 @@ describe('KycController', () => { ); handlers.getSessionStatus.mockRejectedValue(new Error('unavailable')); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('submitted'); expect(launcher.launch).toHaveBeenCalledWith( expect.objectContaining({ locale: 'en', debug: false }), ); expect(handlers.createJourney).toHaveBeenCalledTimes(2); - expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); - expect(controller.state.sessionStatus?.currentStep).toBe( - KYC_CURRENT_STEPS.processing, - ); + expect( + controller.state.sessions.iron?.sessionStatus?.finalStatus, + ).toBe('pending'); + expect( + controller.state.sessions.iron?.sessionStatus?.currentStep, + ).toBe(KYC_CURRENT_STEPS.processing); expect(handlers.getSessionStatus).toHaveBeenCalledWith({ sessionId: 'sid', }); @@ -1644,16 +1933,26 @@ describe('KycController', () => { it('leaves the session alone when it was cleared while the SDK ran', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers, launcher }) => { launcher.launch.mockImplementation(async () => { await controller.reset(); return { status: 'Completed' }; }); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); - expect(controller.state.sessionStatus).toBeNull(); + expect(controller.state.sessions.iron).toBeUndefined(); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }, ); @@ -1661,69 +1960,121 @@ describe('KycController', () => { it('does nothing when the SumSub SDK is unavailable', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, handlers, launcher }) => { launcher.isAvailable.mockReturnValue(false); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('failed'); expect(handlers.createJourney).not.toHaveBeenCalled(); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); expect(launcher.launch).not.toHaveBeenCalled(); - expect(controller.state.providerFlowStatus).toBe('failed'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'failed', + ); }, ); }); it('does nothing when there is no session', async () => { await withController(async ({ controller, handlers, launcher }) => { - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ vendor: 'iron' }); expect(result).toBe('failed'); expect(handlers.createJourney).not.toHaveBeenCalled(); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); expect(launcher.launch).not.toHaveBeenCalled(); - expect(controller.state.providerFlowStatus).toBe('failed'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'failed', + ); }); }); it('records failure when the provider launcher throws', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, launcher }) => { launcher.launch.mockRejectedValue(new Error('launcher unavailable')); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('failed'); - expect(controller.state.providerFlowStatus).toBe('failed'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'failed', + ); }, ); }); it('does not update a reset session when the provider launcher throws', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('pending') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, async ({ controller, launcher }) => { launcher.launch.mockImplementation(async () => { controller.clearState(); throw new Error('launcher unavailable'); }); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('failed'); - expect(controller.state.providerFlowStatus).toBe('not_started'); + expect(controller.state.sessions.iron).toBeUndefined(); }, ); }); it('returns the persisted provider flow status', async () => { await withController( - { options: { state: { providerFlowStatus: 'abandoned' } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ providerFlowStatus: 'abandoned' }), + }, + }, + }, + }, ({ controller }) => { - expect(controller.getProviderFlowStatus()).toBe('abandoned'); + expect(controller.getProviderFlowStatus({ vendor: 'iron' })).toBe( + 'abandoned', + ); }, ); }); @@ -1863,7 +2214,11 @@ describe('KycController', () => { await withController( { options: { - state: { vendor: 'iron', sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -1871,7 +2226,7 @@ describe('KycController', () => { launcher.launch.mockResolvedValue({ status: 'Completed' }); handlers.getSessionStatus.mockRejectedValue(new Error('unavailable')); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); expect(analytics.verification).toStrictEqual([ { @@ -1898,21 +2253,25 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, async ({ controller, launcher }) => { launcher.launch.mockResolvedValue({ status: 'Incomplete' }); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); expect(analytics.verification).toStrictEqual([ { status: 'abandoned', kycStatus: 'new', providerFlowStatus: 'abandoned', - vendor: null, + vendor: 'iron', }, ]); expect(analytics.status).toStrictEqual([]); @@ -1925,17 +2284,21 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, async ({ controller, launcher }) => { launcher.launch.mockResolvedValueOnce({ status: 'Failed' }); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); launcher.launch.mockRejectedValueOnce(new Error('launcher threw')); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); launcher.isAvailable.mockReturnValueOnce(false); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); expect( analytics.verification.map((event) => event.status), @@ -1950,15 +2313,19 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, async ({ controller, launcher }) => { launcher.launch.mockResolvedValue({ status: 'Incomplete' }); - await controller.launchProviderFlow({}); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); + await controller.launchProviderFlow({ vendor: 'iron' }); expect( analytics.verification.map(({ status }) => status), @@ -1972,7 +2339,11 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -1982,15 +2353,15 @@ describe('KycController', () => { return { status: 'Completed' }; }); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); - expect(controller.state.providerFlowStatus).toBe('not_started'); + expect(controller.state.sessions.iron).toBeUndefined(); expect(analytics.verification).toStrictEqual([ { status: 'failed', kycStatus: 'none', providerFlowStatus: 'not_started', - vendor: null, + vendor: 'iron', }, ]); expect(analytics.status).toStrictEqual([]); @@ -2003,7 +2374,11 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -2013,7 +2388,7 @@ describe('KycController', () => { throw new Error('launcher unavailable'); }); - await controller.launchProviderFlow({}); + await controller.launchProviderFlow({ vendor: 'iron' }); expect( analytics.verification.map(({ status }) => status), @@ -2028,7 +2403,13 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('pending') }, + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -2037,7 +2418,7 @@ describe('KycController', () => { sessionStatus('approved'), ); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await vi.advanceTimersByTimeAsync(60_000); expect(analytics.status).toStrictEqual([ @@ -2045,7 +2426,7 @@ describe('KycController', () => { previousKycStatus: 'pending', kycStatus: 'approved', providerFlowStatus: 'not_started', - vendor: null, + vendor: 'iron', }, ]); expect(analytics.verification).toStrictEqual([]); @@ -2063,7 +2444,13 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('pending') }, + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -2072,7 +2459,7 @@ describe('KycController', () => { sessionStatus(finalStatus), ); - await controller.fetchSessionStatusOnce('sid'); + await controller.fetchSessionStatusOnce({ vendor: 'iron' }); expect( analytics.status.map((event) => event.kycStatus), @@ -2089,8 +2476,11 @@ describe('KycController', () => { { options: { state: { - email: 'a@b.co', - sessionStatus: sessionStatus('pending'), + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, }, trackKycAnalytics: analytics.trackKycAnalytics, }, @@ -2138,7 +2528,13 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('mystery') }, + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('mystery'), + }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -2147,14 +2543,14 @@ describe('KycController', () => { sessionStatus('approved'), ); - await controller.fetchSessionStatusOnce('sid'); + await controller.fetchSessionStatusOnce({ vendor: 'iron' }); expect(analytics.status).toStrictEqual([ { previousKycStatus: 'none', kycStatus: 'approved', providerFlowStatus: 'not_started', - vendor: null, + vendor: 'iron', }, ]); }, @@ -2172,7 +2568,9 @@ describe('KycController', () => { await controller.startSession({ vendor: 'iron' }); - expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect( + controller.state.sessions.iron?.sessionStatus?.finalStatus, + ).toBe('approved'); expect(analytics.status).toStrictEqual([]); expect(analytics.verification).toStrictEqual([]); }, @@ -2184,7 +2582,13 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('approved') }, + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('approved'), + }), + }, + }, trackKycAnalytics: analytics.trackKycAnalytics, }, }, @@ -2193,7 +2597,7 @@ describe('KycController', () => { sessionStatus('approved', { vendorStatus: 'still-approved' }), ); - await controller.fetchSessionStatusOnce('sid'); + await controller.fetchSessionStatusOnce({ vendor: 'iron' }); expect(analytics.status).toStrictEqual([]); }, @@ -2206,7 +2610,13 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('pending') }, + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, trackKycAnalytics: { verificationOutcome: vi.fn(), statusChanged: () => { @@ -2217,12 +2627,12 @@ describe('KycController', () => { }, async ({ controller, handlers }) => { readFinalStatus = (): string | undefined => - controller.state.sessionStatus?.finalStatus; + controller.state.sessions.iron?.sessionStatus?.finalStatus; handlers.getSessionStatus.mockResolvedValue( sessionStatus('approved'), ); - await controller.fetchSessionStatusOnce('sid'); + await controller.fetchSessionStatusOnce({ vendor: 'iron' }); expect(statusAtTrack).toStrictEqual(['approved']); }, @@ -2233,7 +2643,11 @@ describe('KycController', () => { await withController( { options: { - state: { vendor: 'iron', sessionStatus: sessionStatus('new') }, + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, trackKycAnalytics: { verificationOutcome: () => { throw new Error('analytics down'); @@ -2248,11 +2662,17 @@ describe('KycController', () => { launcher.launch.mockResolvedValue({ status: 'Completed' }); handlers.getSessionStatus.mockRejectedValue(new Error('unavailable')); - const result = await controller.launchProviderFlow({}); + const result = await controller.launchProviderFlow({ + vendor: 'iron', + }); expect(result).toBe('submitted'); - expect(controller.state.providerFlowStatus).toBe('submitted'); - expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); + expect(controller.state.sessions.iron?.providerFlowStatus).toBe( + 'submitted', + ); + expect( + controller.state.sessions.iron?.sessionStatus?.finalStatus, + ).toBe('pending'); expect(handlers.getSessionStatus).toHaveBeenCalled(); }, ); @@ -2263,7 +2683,13 @@ describe('KycController', () => { await withController( { options: { - state: { sessionStatus: sessionStatus('pending') }, + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, trackKycAnalytics: { verificationOutcome: vi.fn(), statusChanged: () => { @@ -2277,10 +2703,12 @@ describe('KycController', () => { sessionStatus('approved'), ); - controller.startSessionStatusPolling(); + controller.startSessionStatusPolling({ vendor: 'iron' }); await vi.advanceTimersByTimeAsync(60_000); - expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect( + controller.state.sessions.iron?.sessionStatus?.finalStatus, + ).toBe('approved'); expect(handlers.getSessionStatus).toHaveBeenCalledOnce(); }, ); @@ -2288,11 +2716,21 @@ describe('KycController', () => { it('leaves KYC behavior unchanged when no tracker is passed', async () => { await withController( - { options: { state: { sessionStatus: sessionStatus('new') } } }, + { + options: { + state: { + sessions: { + iron: vendorSession({ sessionStatus: sessionStatus('new') }), + }, + }, + }, + }, async ({ controller, launcher }) => { launcher.launch.mockResolvedValue({ status: 'Incomplete' }); - expect(await controller.launchProviderFlow({})).toBe('abandoned'); + expect(await controller.launchProviderFlow({ vendor: 'iron' })).toBe( + 'abandoned', + ); }, ); }); @@ -2320,17 +2758,30 @@ describe('KycController', () => { describe('messenger actions', () => { it('exposes fetchSessionDisclaimers', async () => { - await withController(async ({ rootMessenger, handlers }) => { - handlers.fetchSessionDisclaimersBySessionId.mockResolvedValue( - MOCK_SESSION_DISCLAIMERS, - ); + await withController( + { + options: { + state: { + sessions: { + iron: vendorSession({ + sessionStatus: sessionStatus('pending'), + }), + }, + }, + }, + }, + async ({ rootMessenger, handlers }) => { + handlers.fetchSessionDisclaimersBySessionId.mockResolvedValue( + MOCK_SESSION_DISCLAIMERS, + ); - expect( - await rootMessenger.call('KycController:fetchSessionDisclaimers', { - sessionId: 'sid', - }), - ).toStrictEqual(MOCK_SESSION_DISCLAIMERS); - }); + expect( + await rootMessenger.call('KycController:fetchSessionDisclaimers', { + vendor: 'iron', + }), + ).toStrictEqual(MOCK_SESSION_DISCLAIMERS); + }, + ); }); }); @@ -2441,6 +2892,267 @@ describe('KycController', () => { }); }); +describe('KycController per-vendor sessions', () => { + const ironEntry = ( + overrides: Partial = {}, + ): KycVendorSessionState => ({ + ...getDefaultKycVendorSessionState(), + residenceCountry: 'BRA', + sessionStatus: sessionStatus('pending'), + ...overrides, + }); + + it('starts a second vendor without touching the first', async () => { + await withController( + { options: { state: { sessions: { iron: ironEntry() } } } }, + async ({ controller, handlers }) => { + handlers.getSessionStatusForVendor.mockResolvedValue( + sessionStatus('new', { id: 'moonpay-sid' }), + ); + + await controller.startSession({ vendor: 'moonpay' }); + + expect(controller.state.sessions.iron).toStrictEqual(ironEntry()); + expect(controller.state.sessions.moonpay?.sessionStatus?.id).toBe( + 'moonpay-sid', + ); + expect(controller.state.sessions.moonpay?.residenceCountry).toBe('USA'); + }, + ); + }); + + it('creating a session fails without a verified email', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getPartnerIdentityToken.mockRejectedValue({ status: 422 }); + + await expect( + controller.startSession({ vendor: 'iron', aal2Token: AAL2_TOKEN }), + ).rejects.toThrow('KycController missing verified email for vendor'); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + }); + }); + + it('resuming a session works without a verified email', async () => { + await withController( + { options: { state: { sessions: { iron: ironEntry() } } } }, + async ({ controller, handlers }) => { + handlers.getPartnerIdentityToken.mockRejectedValue({ status: 422 }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + const result = await controller.startSession({ vendor: 'iron' }); + + expect(result.finalStatus).toBe('pending'); + }, + ); + }); + + it('clearState removes every vendor', async () => { + await withController( + { + options: { + state: { sessions: { iron: ironEntry(), moonpay: ironEntry() } }, + }, + }, + ({ controller }) => { + controller.clearState(); + expect(controller.state).toStrictEqual(getDefaultKycControllerState()); + }, + ); + }); + + it('reads status and decision per vendor', async () => { + await withController( + { + options: { + state: { + sessions: { + iron: ironEntry({ sessionStatus: sessionStatus('approved') }), + }, + }, + }, + }, + ({ controller }) => { + expect(controller.getSessionDecision({ vendor: 'iron' })).toBe( + 'approved', + ); + expect(controller.getSessionDecision({ vendor: 'moonpay' })).toBe( + 'none', + ); + expect(controller.readSessionStatus({ vendor: 'moonpay' })).toBeNull(); + expect(controller.getProviderFlowStatus({ vendor: 'moonpay' })).toBe( + 'not_started', + ); + }, + ); + }); + + it('fetchSessionStatusOnce writes to the vendor entry', async () => { + await withController( + { options: { state: { sessions: { iron: ironEntry() } } } }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.fetchSessionStatusOnce({ vendor: 'iron' }); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(controller.state.sessions.iron?.sessionStatus?.finalStatus).toBe( + 'approved', + ); + }, + ); + }); + + describe('vendor terms', () => { + it('acceptVendorDisclaimers stores unsubmitted acceptance without a session', async () => { + await withController(({ controller, handlers }) => { + controller.acceptVendorDisclaimers({ + vendor: 'iron', + country: 'BRA', + disclaimerIds: ['t1'], + }); + + const entry = controller.state.sessions.iron; + expect(entry?.sessionStatus).toBeNull(); + expect(entry?.residenceCountry).toBe('BRA'); + expect(entry?.vendorDisclaimersAccepted).toStrictEqual({ + disclaimerIds: ['t1'], + acceptedAt: expect.any(String), + submitted: false, + }); + expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); + }); + }); + + it('recordVendorDisclaimers needs stored acceptance', async () => { + await withController( + { options: { state: { sessions: { iron: ironEntry() } } } }, + async ({ controller }) => { + await expect( + controller.recordVendorDisclaimers({ vendor: 'iron' }), + ).rejects.toThrow('No vendor disclaimer acceptance was found'); + }, + ); + }); + + it('recordVendorDisclaimers needs a session and leaves acceptance unsubmitted', async () => { + await withController(async ({ controller, handlers }) => { + controller.acceptVendorDisclaimers({ + vendor: 'iron', + country: 'BRA', + disclaimerIds: ['t1'], + }); + + await expect( + controller.recordVendorDisclaimers({ vendor: 'iron' }), + ).rejects.toThrow('No session was found'); + expect(handlers.submitVendorDisclaimers).not.toHaveBeenCalled(); + expect( + controller.state.sessions.iron?.vendorDisclaimersAccepted?.submitted, + ).toBe(false); + }); + }); + + it('recordVendorDisclaimers submits stored acceptance and marks it submitted', async () => { + await withController( + { options: { state: { sessions: { iron: ironEntry() } } } }, + async ({ controller, handlers }) => { + handlers.fetchVendorDisclaimers.mockResolvedValue([ + { id: 't1', display_name: 'T1', url: 'https://t1' }, + ]); + controller.acceptVendorDisclaimers({ + vendor: 'iron', + country: 'BRA', + disclaimerIds: ['t1'], + }); + expect( + await controller.hasCompletedVendorDisclaimers({ vendor: 'iron' }), + ).toBe(false); + + await controller.recordVendorDisclaimers({ vendor: 'iron' }); + + expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + disclaimerIds: ['t1'], + }); + expect( + controller.state.sessions.iron?.vendorDisclaimersAccepted + ?.submitted, + ).toBe(true); + expect( + await controller.hasCompletedVendorDisclaimers({ vendor: 'iron' }), + ).toBe(true); + expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + country: 'BRA', + }); + }, + ); + }); + }); + + it('does not recreate a vendor entry cleared while terms were submitting', async () => { + await withController( + { options: { state: { sessions: { iron: ironEntry() } } } }, + async ({ controller, handlers }) => { + controller.acceptVendorDisclaimers({ + vendor: 'iron', + country: 'BRA', + disclaimerIds: ['t1'], + }); + handlers.submitVendorDisclaimers.mockImplementation(async () => { + controller.clearState(); + return []; + }); + + await controller.recordVendorDisclaimers({ vendor: 'iron' }); + + expect(controller.state.sessions.iron).toBeUndefined(); + }, + ); + }); + + it('presents one SumSub flow at a time', async () => { + await withController( + { + options: { + state: { + sessions: { + iron: ironEntry(), + moonpay: ironEntry({ + sessionStatus: sessionStatus('pending', { id: 'mp-sid' }), + }), + }, + }, + }, + }, + async ({ controller, launcher }) => { + let finishLaunch: (value: { status: string }) => void = () => { + // Replaced below. + }; + launcher.launch.mockReturnValue( + new Promise((resolve) => { + finishLaunch = resolve; + }), + ); + + const first = controller.launchProviderFlow({ vendor: 'iron' }); + await flushPromises(); + const moonpayBefore = controller.state.sessions.moonpay; + + expect(await controller.launchProviderFlow({ vendor: 'moonpay' })).toBe( + 'failed', + ); + expect(controller.state.sessions.moonpay).toStrictEqual(moonpayBefore); + + finishLaunch({ status: 'Incomplete' }); + expect(await first).toBe('abandoned'); + }, + ); + }); +}); + type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, @@ -2489,6 +3201,18 @@ type WithControllerOptions = { options: Partial[0]>; }; +/** + * Builds a vendor session entry over the defaults. + * + * @param overrides - Fields to set on the entry. + * @returns The vendor session entry. + */ +function vendorSession( + overrides: Partial = {}, +): KycVendorSessionState { + return { ...getDefaultKycVendorSessionState(), ...overrides }; +} + /** * Builds an unsigned JWT whose payload is the given JSON object. * diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index c37326a8cee..ff46d3ba3bd 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -39,6 +39,8 @@ import { isSumSubFlowFailed, } from './providers/sumsub.js'; import type { KycSumSubLauncher } from './providers/sumsub.js'; +import { getKycSessionDecision } from './sessionDecision.js'; +import type { KycSessionDecision } from './sessionDecision.js'; import { consentRecordsFromAcceptedList } from './sessionDisclaimers.js'; import { needsCapabilityAuthorizationRefresh, @@ -51,11 +53,10 @@ import type { KycDataShare, KycDisclaimer, KycDisclaimersCatalog, - KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, KycVendor, - KycVendorDisclaimersAccepted, + KycVendorDisclaimersAcceptance, KycVendorSigning, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; @@ -72,10 +73,7 @@ import { } from './ukyc/storageAccessToken.js'; import type { UkycStorageOperation } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; -import { - areVendorDisclaimersCompleted, - recordVendorDisclaimerAcceptance, -} from './vendorDisclaimers.js'; +import { areVendorDisclaimersCompleted } from './vendorDisclaimers.js'; // === GENERAL === @@ -113,127 +111,81 @@ const SESSION_STATUS_POLL_INTERVAL_MS = 10_000; // === STATE === /** - * Describes the shape of the state object for {@link KycController}. + * One vendor's KYC session and everything recorded for it. Sessions are keyed + * by vendor, matching kyc-api: a customer holds at most one session per vendor, + * and features that use the same vendor share it. */ -export type KycControllerState = { - /** Email associated with the session (sourced from the account). */ - email: string | null; - - vendor: KycVendor | null; - - /** Resolved ISO 3166-1 alpha-3 country code. */ - geoCountry: string | null; - - /** Latest UKYC session status, or `null` when none has been fetched. */ +export type KycVendorSessionState = { + /** Latest UKYC session status, or `null` before a session exists. */ sessionStatus: KycSessionStatus | null; /** Last durable identity-provider flow outcome for this session. */ providerFlowStatus: KycProviderFlowStatus; - // TODO: Check if we need truly need to persist these accepted disclaimers /** - * Persisted vendor-disclaimer acceptance (T&C1) with fixed `moonpay` and - * `iron` keys. MoonPay stores only `termsAcceptedAt`; Iron stores - * `disclaimerIds`. + * ISO 3166-1 alpha-3 country this vendor's session was created with, or the + * country its terms were accepted for before a session existed. Vendor + * disclaimer checks read the catalog for this country. */ - vendorDisclaimersAccepted: KycVendorDisclaimersAccepted; + residenceCountry: string | null; + /** - * 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). + * The vendor's T&C acceptance (T&C1). Stored on the device before a session + * exists, then marked submitted once the vendor records it. */ - providerDisclaimersAccepted: KycProviderDisclaimersAccepted; + vendorDisclaimersAccepted: KycVendorDisclaimersAcceptance | null; + /** - * 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). + * KYC-provider (SumSub) disclaimer documents accepted for this session, as + * `{ key, version }` records. `null` when none were recorded. + */ + providerDisclaimersAccepted: KycConsentRecord[] | null; + + /** + * idOS disclaimer documents accepted for this session, as `{ key, version }` + * records. `null` when none were recorded. */ idosDisclaimersAccepted: KycConsentRecord[] | null; + /** - * Whether the customer consented to reuse existing idOS credentials - * during this session. Applied when recording session-scoped disclaimers. - * Not persisted: a new UKYC session must collect reuse consent again. - * `null` when never set (treated as `false`). + * Whether the customer consented to reuse existing idOS credentials for this + * session. `null` when never set (treated as `false`). */ credentialReusabilityConsentGiven: boolean | null; }; +/** + * Describes the shape of the state object for {@link KycController}. + */ +export type KycControllerState = { + /** Session entries keyed by identity vendor. */ + sessions: Partial>; +}; + const kycControllerMetadata = { - email: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: true, - usedInUi: false, - }, - vendor: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: true, - }, - geoCountry: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: true, - }, - sessionStatus: { + sessions: { includeInDebugSnapshot: true, includeInStateLogs: true, persist: true, usedInUi: true, }, - providerFlowStatus: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: true, - }, - vendorDisclaimersAccepted: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - providerDisclaimersAccepted: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - idosDisclaimersAccepted: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, - credentialReusabilityConsentGiven: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, } 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 KycProviderDisclaimersAccepted} value. + * Constructs an empty {@link KycVendorSessionState}. * - * @returns The default provider-disclaimer acceptance map. + * @returns A vendor session entry with no session and nothing accepted. */ -export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaimersAccepted { - return { sumsub: null }; +export function getDefaultKycVendorSessionState(): KycVendorSessionState { + return { + sessionStatus: null, + providerFlowStatus: 'not_started', + residenceCountry: null, + vendorDisclaimersAccepted: null, + providerDisclaimersAccepted: null, + idosDisclaimersAccepted: null, + credentialReusabilityConsentGiven: null, + }; } /** @@ -242,17 +194,7 @@ export function getDefaultKycProviderDisclaimersAccepted(): KycProviderDisclaime * @returns The default state. */ export function getDefaultKycControllerState(): KycControllerState { - return { - email: null, - vendor: null, - geoCountry: null, - sessionStatus: null, - providerFlowStatus: 'not_started', - vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(), - providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(), - idosDisclaimersAccepted: null, - credentialReusabilityConsentGiven: null, - }; + return { sessions: {} }; } /** @@ -261,14 +203,14 @@ export function getDefaultKycControllerState(): KycControllerState { */ export type FetchSessionDisclaimersParams = | { - /** UKYC session id from `KycService.createUkycSession`. */ - sessionId: string; + /** Identity vendor whose session catalog to fetch. */ + vendor: KycVendor; country?: never; } | { /** ISO 3166-1 alpha-3 country code for `GET /disclaimers?country=`. */ country: string; - sessionId?: never; + vendor?: never; }; // === MESSENGER === @@ -289,8 +231,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'recordSessionDisclaimers', 'hasCompletedSessionDisclaimers', 'fetchVendorDisclaimers', + 'acceptVendorDisclaimers', 'recordVendorDisclaimers', 'hasCompletedVendorDisclaimers', + 'getSessionDecision', 'launchProviderFlow', 'deleteEncryptedStorage', ] as const; @@ -364,14 +308,20 @@ export class KycController extends BaseController< readonly #trackKycAnalytics?: KycAnalyticsTrackers; - /** Handle for the scheduled next session-status poll, or `null`. */ - #sessionStatusPollTimer: ReturnType | null = null; + /** Scheduled next session-status poll for each vendor that is polling. */ + readonly #sessionStatusPollTimers = new Map< + KycVendor, + ReturnType + >(); /** - * Monotonic polling token. Bumped when the loop stops so an in-flight fetch - * neither writes state nor schedules a follow-up. + * Monotonic polling token per vendor. Bumped when that vendor's loop stops + * so an in-flight fetch neither writes state nor schedules a follow-up. */ - #sessionStatusPollToken = 0; + readonly #sessionStatusPollTokens = new Map(); + + /** Vendor whose SumSub flow is on screen, or `null`. */ + #presentingVendor: KycVendor | null = null; /** * Constructs a new {@link KycController}. @@ -467,12 +417,12 @@ export class KycController extends BaseController< /** * Starts a KYC session for the given vendor. Reuses a latest vendor session - * when one exists; otherwise creates a UKYC session. + * when one exists; otherwise creates a UKYC session. Sessions for other + * vendors are left untouched. * - * The verified email stored on controller state comes from the partner - * identity token. When that request returns HTTP 422, a stored email is - * kept for an existing session. Creating a vendor customer and UKYC session - * requires the token, which both requests send as `partnerIdentityToken`. + * Creating a vendor customer and UKYC session requires a partner identity + * token carrying a verified email; both requests send it as + * `partnerIdentityToken`. Resuming an existing session does not. * * Session creation derives client material from the UKYC * `local_user_secret`. Call {@link ensureLocalUserSecret} after the user @@ -490,53 +440,28 @@ export class KycController extends BaseController< vendor: KycVendor; aal2Token?: string; }): Promise { - const partnerIdentity = await this.#getPartnerIdentityForVendor( - params.vendor, - ); - const email = partnerIdentity?.email ?? this.state.email; - if (!email) { - throw new Error('KycController missing verified email for vendor'); - } - if (this.state.email !== null && this.state.email !== email) { - throw new Error( - 'KycController already initialized with a different email', - ); - } - if (this.state.vendor !== null && this.state.vendor !== params.vendor) { - throw new Error( - 'KycController already initialized with a different vendor', - ); - } - + const { vendor } = params; + const partnerIdentity = await this.#getPartnerIdentityForVendor(vendor); const geoCountry = await this.messenger.call('KycService:getGeoCountry'); - if ( - this.state.geoCountry !== null && - this.state.geoCountry !== geoCountry - ) { - throw new Error( - 'KycController already initialized with a different geoCountry', - ); - } - this.update((state) => { - state.email = email; - state.vendor = params.vendor; - state.geoCountry = geoCountry; - if (state.sessionStatus === null) { - state.providerFlowStatus = 'not_started'; + this.#updateVendorSession(vendor, (entry) => { + entry.residenceCountry ??= geoCountry; + if (entry.sessionStatus === null) { + entry.providerFlowStatus = 'not_started'; } }); - if (this.state.sessionStatus === null) { + const storedSession = this.state.sessions[vendor]?.sessionStatus ?? null; + if (storedSession === null) { const sessionStatus = await this.messenger.call( 'KycService:getSessionStatusForVendor', - params.vendor, + vendor, ); if (sessionStatus !== null) { - this.update((state) => { - state.sessionStatus = sessionStatus; + this.#updateVendorSession(vendor, (entry) => { + entry.sessionStatus = sessionStatus; }); - return this.#refreshAuthorizationsIfNeeded(); + return this.#refreshAuthorizationsIfNeeded(vendor); } // TODO: Probably move this into it's own method? Feels like startSession is a little overloaded. @@ -548,58 +473,50 @@ export class KycController extends BaseController< } if (!partnerIdentity) { - throw new Error( - 'KycController missing partner identity token for vendor', - ); + throw new Error('KycController missing verified email for vendor'); } await this.messenger.call('KycService:createVendorCustomer', { - vendor: params.vendor, + vendor, partnerIdentityToken: partnerIdentity.partnerIdentityToken, }); - return this.#createUkycSession({ - vendor: params.vendor, + const createdSession = await this.#createUkycSession({ + vendor, geoCountry, aal2Token: params.aal2Token, partnerIdentityToken: partnerIdentity.partnerIdentityToken, }); + this.#updateVendorSession(vendor, (entry) => { + entry.residenceCountry = geoCountry; + }); + return createdSession; } const sessionStatus = await this.messenger.call( 'KycService:getSessionStatus', - { sessionId: this.state.sessionStatus.id }, + { sessionId: storedSession.id }, ); - this.#updateSessionStatusAndTrackAnalytics(sessionStatus); + this.#updateSessionStatusAndTrackAnalytics(vendor, sessionStatus); // TODO: Should we resume polling here? - return this.#refreshAuthorizationsIfNeeded(); + return this.#refreshAuthorizationsIfNeeded(vendor); } /** * Stops session-status polling and restores default controller state. */ async reset(): Promise { - this.#stopSessionStatusPolling(); this.clearState(); } /** - * Restores the controller to its default state. + * Stops every vendor's session-status polling and removes every session + * entry. */ clearState(): void { - this.#stopSessionStatusPolling(); + this.#stopAllSessionStatusPolling(); this.update((state) => { - state.email = null; - state.vendor = null; - state.geoCountry = null; - state.sessionStatus = null; - state.providerFlowStatus = 'not_started'; - state.vendorDisclaimersAccepted = - getDefaultKycVendorDisclaimersAccepted(); - state.providerDisclaimersAccepted = - getDefaultKycProviderDisclaimersAccepted(); - state.idosDisclaimersAccepted = null; - state.credentialReusabilityConsentGiven = null; + state.sessions = {}; }); } @@ -619,130 +536,156 @@ export class KycController extends BaseController< * Fetches the vendors this customer's verified identity data has been shared * with, newest first. * - * The external user id comes from the session status on state. When no - * session has been stored yet, the latest session for the vendor on state is - * fetched first (without writing it to state). An unresolvable session — no - * vendor on state, or no latest session for it — yields an empty list rather - * than an error, so consumers can render an empty "shared with" list. + * The external user id is the same for every vendor, so it is read from any + * stored session. When none is stored, the latest session for each vendor + * with an entry is fetched (without writing it to state). An unresolvable + * session yields an empty list rather than an error, so consumers can render + * an empty "shared with" list. * * @returns The data shares, newest first; empty when no session can be * resolved. */ async getDataShares(): Promise { - let externalUserId = this.state.sessionStatus?.externalUserId; + const entries = Object.values(this.state.sessions); + let externalUserId = entries.find((entry) => entry?.sessionStatus) + ?.sessionStatus?.externalUserId; if (!externalUserId) { - const { vendor } = this.state; - if (!vendor) { - return []; - } - const sessionStatus = await this.messenger.call( - 'KycService:getSessionStatusForVendor', - vendor, - ); - if (!sessionStatus) { - return []; + for (const vendor of Object.keys(this.state.sessions) as KycVendor[]) { + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatusForVendor', + vendor, + ); + if (sessionStatus) { + externalUserId = sessionStatus.externalUserId; + break; + } } - externalUserId = sessionStatus.externalUserId; } + if (!externalUserId) { + return []; + } return this.messenger.call('KycService:getDataShares', { externalUserId }); } /** - * Returns the durable outcome of the identity-provider flow. + * Returns the durable outcome of a vendor's identity-provider flow. * - * @returns The latest provider-flow status. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The latest provider-flow status, or `not_started` when the vendor + * has no entry. */ - getProviderFlowStatus(): KycProviderFlowStatus { - return this.state.providerFlowStatus; + getProviderFlowStatus({ + vendor, + }: { + vendor: KycVendor; + }): KycProviderFlowStatus { + return this.state.sessions[vendor]?.providerFlowStatus ?? 'not_started'; } // TODO: Perhaps this should be renamed to resumeSession()? /** - * Returns the session already stored on state. + * Returns the vendor's session already stored on state. * - * Starts session-status polling when `finalStatus` is not `approved`, - * `rejected`, or `retry`. Does not fetch status or refresh capability - * authorization. + * Starts that vendor's session-status polling when `finalStatus` is not + * `approved`, `rejected`, or `retry`. Does not fetch status or refresh + * capability authorization. * + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns The session status stored at call time. - * @throws If there is no session on state. + * @throws If the vendor has no session on state. */ - refreshSessionStatus(): KycSessionStatus { - if (!this.state.sessionStatus) { - throw new Error('No session was found'); + refreshSessionStatus({ vendor }: { vendor: KycVendor }): KycSessionStatus { + const sessionStatus = this.#requireSessionStatus(vendor); + if (!FINAL_STATUSES_TO_STOP_POLLING.has(sessionStatus.finalStatus)) { + this.startSessionStatusPolling({ vendor }); } - if ( - !FINAL_STATUSES_TO_STOP_POLLING.has(this.state.sessionStatus.finalStatus) - ) { - this.startSessionStatusPolling(); - } - return this.state.sessionStatus; + return sessionStatus; } /** - * Returns the persisted session status. + * Returns the vendor's persisted session status. * * Does not call the network and does not start session-status polling. * - * @returns The session on state, or `null` when none is stored. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The vendor's session on state, or `null` when none is stored. */ - readSessionStatus(): KycSessionStatus | null { - return this.state.sessionStatus; + readSessionStatus({ + vendor, + }: { + vendor: KycVendor; + }): KycSessionStatus | null { + return this.state.sessions[vendor]?.sessionStatus ?? null; } /** - * Fetches `GET /sessions/{id}/status` once and writes the result onto state. + * Fetches `GET /sessions/{id}/status` once for the vendor's stored session + * and writes the result onto that vendor's entry. * * Does not start the session-status polling timer. * - * @param sessionId - UKYC session id. + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns The fetched session status. + * @throws If the vendor has no session on state. */ - async fetchSessionStatusOnce(sessionId: string): Promise { + async fetchSessionStatusOnce({ + vendor, + }: { + vendor: KycVendor; + }): Promise { + const { id: sessionId } = this.#requireSessionStatus(vendor); const sessionStatus = await this.messenger.call( 'KycService:getSessionStatus', { sessionId }, ); - this.#updateSessionStatusAndTrackAnalytics(sessionStatus); + this.#updateSessionStatusAndTrackAnalytics(vendor, sessionStatus); return sessionStatus; } /** - * Starts polling `GET /sessions/{id}/status` for - * {@link KycControllerState.sessionStatus}'s current `id`. Each tick writes - * the result onto state only when the payload changed. The first poll runs + * Starts polling `GET /sessions/{id}/status` for the vendor's stored + * session. Each vendor has its own loop. Each tick writes the result onto + * that vendor's entry only when the payload changed. The first poll runs * immediately. Each later wait starts at 10 seconds and grows by 10 seconds * after every poll, including a failed one. Starting the loop again resets * that delay. The loop stops once `finalStatus` is `approved`, `rejected`, * or `retry`, or when {@link reset} / {@link clearState} runs. * - * @throws If there is no current session id to poll. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @throws If the vendor has no session id to poll. */ - startSessionStatusPolling(): void { - const sessionId = this.state.sessionStatus?.id; - if (!sessionId) { - throw new Error('No session was found'); - } + startSessionStatusPolling({ vendor }: { vendor: KycVendor }): void { + const { id: sessionId } = this.#requireSessionStatus(vendor); - this.#stopSessionStatusPolling(); - const token = this.#sessionStatusPollToken; + this.#stopSessionStatusPolling(vendor); + const token = this.#getSessionStatusPollToken(vendor); let completedPolls = 0; const tick = async (): Promise => { - const shouldStop = await this.#pollSessionStatusOnce(sessionId, token); + const shouldStop = await this.#pollSessionStatusOnce( + vendor, + sessionId, + token, + ); if (shouldStop) { return; } completedPolls += 1; - this.#sessionStatusPollTimer = setTimeout(() => { - this.#sessionStatusPollTimer = null; + const timer = setTimeout(() => { + this.#sessionStatusPollTimers.delete(vendor); // `tick` swallows its own errors and therefore never rejects. // eslint-disable-next-line @typescript-eslint/no-floating-promises tick(); }, SESSION_STATUS_POLL_INTERVAL_MS * completedPolls); - this.#sessionStatusPollTimer.unref?.(); + timer.unref?.(); + this.#sessionStatusPollTimers.set(vendor, timer); }; // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -750,14 +693,16 @@ export class KycController extends BaseController< } /** - * Fetches session status for the polled session and records it when it - * differs from state. + * Fetches session status for the polled session and records it on the + * vendor's entry when it differs from state. * + * @param vendor - Vendor the loop belongs to. * @param sessionId - Session the loop started for. * @param token - Polling token captured when the loop started. * @returns Whether the loop should stop. */ async #pollSessionStatusOnce( + vendor: KycVendor, sessionId: string, token: number, ): Promise { @@ -766,26 +711,66 @@ export class KycController extends BaseController< 'KycService:getSessionStatus', { sessionId }, ); - if (this.#sessionStatusPollToken !== token) { + if (this.#getSessionStatusPollToken(vendor) !== token) { + return true; + } + const entry = this.state.sessions[vendor]; + /* istanbul ignore next: clearState bumps the token before removing + entries, so a live loop always finds its entry. */ + if (!entry) { return true; } // TODO: This check seems fragile if ( - JSON.stringify(this.state.sessionStatus) !== - JSON.stringify(sessionStatus) + JSON.stringify(entry.sessionStatus) !== JSON.stringify(sessionStatus) ) { - this.#updateSessionStatusAndTrackAnalytics(sessionStatus); + this.#updateSessionStatusAndTrackAnalytics(vendor, sessionStatus); } if (FINAL_STATUSES_TO_STOP_POLLING.has(sessionStatus.finalStatus)) { - this.#stopSessionStatusPolling(); + this.#stopSessionStatusPolling(vendor); return true; } return false; } catch { - return this.#sessionStatusPollToken !== token; + return this.#getSessionStatusPollToken(vendor) !== token; + } + } + + // === VENDOR SESSION ENTRIES === + + /** + * Applies a write to the vendor's session entry, creating the entry first + * when the vendor has none. + * + * @param vendor - The identity vendor. + * @param write - Mutates the vendor's entry. + */ + #updateVendorSession( + vendor: KycVendor, + write: (entry: KycVendorSessionState) => void, + ): void { + this.update((state) => { + const entry = state.sessions[vendor] ?? getDefaultKycVendorSessionState(); + write(entry); + state.sessions[vendor] = entry; + }); + } + + /** + * Returns the vendor's stored session status. + * + * @param vendor - The identity vendor. + * @returns The vendor's session status. + * @throws If the vendor has no session on state, or it has no id. + */ + #requireSessionStatus(vendor: KycVendor): KycSessionStatus { + const sessionStatus = this.state.sessions[vendor]?.sessionStatus; + if (!sessionStatus?.id) { + throw new Error('No session was found'); } + return sessionStatus; } // === ANALYTICS === @@ -797,12 +782,15 @@ export class KycController extends BaseController< // call site. /** - * Computes the analytics KYC status from the current state. + * Computes the analytics KYC status for a vendor from the current state. * + * @param vendor - The identity vendor. * @returns The analytics KYC status. */ - #getKycAnalyticsStatus(): KycAnalyticsStatus { - return getKycAnalyticsStatus(this.state.sessionStatus); + #getKycAnalyticsStatus(vendor: KycVendor): KycAnalyticsStatus { + return getKycAnalyticsStatus( + this.state.sessions[vendor]?.sessionStatus ?? null, + ); } /** @@ -811,17 +799,19 @@ export class KycController extends BaseController< * A callback exception is ignored. The KYC outcome is already stored, and * analytics must not replace it or skip the polling that follows a submit. * + * @param vendor - The identity vendor. * @param status - The launch outcome. */ #trackAnalyticsVerificationOutcome( + vendor: KycVendor, status: KycAnalyticsVerificationOutcomePayload['status'], ): void { try { this.#trackKycAnalytics?.verificationOutcome({ status, - kycStatus: this.#getKycAnalyticsStatus(), - providerFlowStatus: this.state.providerFlowStatus, - vendor: this.state.vendor, + kycStatus: this.#getKycAnalyticsStatus(vendor), + providerFlowStatus: this.getProviderFlowStatus({ vendor }), + vendor, }); } catch { // The host's tracker failed. The launch outcome stays as written. @@ -835,12 +825,14 @@ export class KycController extends BaseController< * A callback exception is ignored, so a tracker failure cannot keep a * finished session polling. * + * @param vendor - The identity vendor. * @param previousAnalyticsStatus - Analytics KYC status before the causing write. */ #trackAnalyticsStatusIfChanged( + vendor: KycVendor, previousAnalyticsStatus: KycAnalyticsStatus, ): void { - const kycStatus = this.#getKycAnalyticsStatus(); + const kycStatus = this.#getKycAnalyticsStatus(vendor); if (kycStatus === previousAnalyticsStatus) { return; } @@ -848,8 +840,8 @@ export class KycController extends BaseController< this.#trackKycAnalytics?.statusChanged({ previousKycStatus: previousAnalyticsStatus, kycStatus, - providerFlowStatus: this.state.providerFlowStatus, - vendor: this.state.vendor, + providerFlowStatus: this.getProviderFlowStatus({ vendor }), + vendor, }); } catch { // The host's tracker failed. The stored session stays as written. @@ -857,61 +849,94 @@ export class KycController extends BaseController< } /** - * Replaces the stored session status, then reports an analytics KYC status - * change when one occurred. State is written before the callback runs. + * Replaces the vendor's stored session status, then reports an analytics KYC + * status change when one occurred. State is written before the callback + * runs. * * Writes that load a session into an empty slot must not use this helper, so * an already-decided session found on a fresh install is not reported. * + * @param vendor - The identity vendor. * @param sessionStatus - The session status to store. */ - #updateSessionStatusAndTrackAnalytics(sessionStatus: KycSessionStatus): void { - const previousAnalyticsStatus = this.#getKycAnalyticsStatus(); - this.update((state) => { - state.sessionStatus = sessionStatus; + #updateSessionStatusAndTrackAnalytics( + vendor: KycVendor, + sessionStatus: KycSessionStatus, + ): void { + const previousAnalyticsStatus = this.#getKycAnalyticsStatus(vendor); + this.#updateVendorSession(vendor, (entry) => { + entry.sessionStatus = sessionStatus; }); - this.#trackAnalyticsStatusIfChanged(previousAnalyticsStatus); + this.#trackAnalyticsStatusIfChanged(vendor, previousAnalyticsStatus); } /** * Applies the state write for one SumSub launch attempt, then reports the * launch outcome and, if the write changed the analytics KYC status (a * submitted launch moves it to `pending`), the status change. Every attempt - * is reported, including a repeat of the previous outcome. Pass no `update` + * is reported, including a repeat of the previous outcome. Pass no `write` * for failures that must not write state. * + * @param vendor - The identity vendor. * @param status - The launch outcome. - * @param update - Optional state write applied before the callbacks run. + * @param write - Optional write to the vendor's entry applied before the + * callbacks run. */ #recordLaunchOutcomeAndTrackAnalytics( + vendor: KycVendor, status: 'submitted' | 'abandoned' | 'failed', - update?: (state: KycControllerState) => void, + write?: (entry: KycVendorSessionState) => void, ): void { - const previousAnalyticsStatus = this.#getKycAnalyticsStatus(); - if (update) { - this.update(update); + const previousAnalyticsStatus = this.#getKycAnalyticsStatus(vendor); + if (write) { + this.#updateVendorSession(vendor, write); } - this.#trackAnalyticsVerificationOutcome(status); - this.#trackAnalyticsStatusIfChanged(previousAnalyticsStatus); + this.#trackAnalyticsVerificationOutcome(vendor, status); + this.#trackAnalyticsStatusIfChanged(vendor, previousAnalyticsStatus); } /** - * Stops the session-status polling loop. + * Returns the vendor's current polling token. + * + * @param vendor - The identity vendor. + * @returns The polling token. */ - #stopSessionStatusPolling(): void { - this.#sessionStatusPollToken += 1; - if (this.#sessionStatusPollTimer !== null) { - clearTimeout(this.#sessionStatusPollTimer); - this.#sessionStatusPollTimer = null; + #getSessionStatusPollToken(vendor: KycVendor): number { + return this.#sessionStatusPollTokens.get(vendor) ?? 0; + } + + /** + * Stops the vendor's session-status polling loop. + * + * @param vendor - The identity vendor. + */ + #stopSessionStatusPolling(vendor: KycVendor): void { + this.#sessionStatusPollTokens.set( + vendor, + this.#getSessionStatusPollToken(vendor) + 1, + ); + const timer = this.#sessionStatusPollTimers.get(vendor); + if (timer !== undefined) { + clearTimeout(timer); + this.#sessionStatusPollTimers.delete(vendor); + } + } + + /** + * Stops every vendor's session-status polling loop. + */ + #stopAllSessionStatusPolling(): void { + for (const vendor of this.#sessionStatusPollTokens.keys()) { + this.#stopSessionStatusPolling(vendor); } } /** * Creates or reuses a UKYC session, wraps the `data_encryption_key` and * `ukyc_capability_token` against the returned encryption schemas, and - * submits both via authorizations. Stores the resulting session status. - * kyc-api returns the existing session if one with the same vendor and - * canonicalUserId already exists. + * submits both via authorizations. Stores the resulting session status on + * the vendor's entry. kyc-api returns the existing session if one with the + * same vendor and canonicalUserId already exists. * * @param params - Vendor, country, and tokens used to create the session. * @param params.vendor - Identity vendor for the UKYC session. @@ -936,9 +961,6 @@ export class KycController extends BaseController< const sessionClientPublicKey = toBase64Url( x25519.getPublicKey(sessionClientPrivateKey), ); - // Residence is the ISO 3166-1 alpha-3 country already resolved for - // disclaimers / KYC-required; fetch it if this sub-flow started without - // that earlier step. const { sessionId, encryptionDataKey: encryptionDataKeySchema, @@ -952,6 +974,7 @@ export class KycController extends BaseController< }); return this.#setAuthorizations({ + vendor: params.vendor, sessionId, sessionClientPrivateKey, sessionClientPublicKey, @@ -963,9 +986,11 @@ export class KycController extends BaseController< /** * Verifies encryption schemas, wraps the `data_encryption_key` and * `ukyc_capability_token`, and submits both via - * {@link KycService.setAuthorizations}. Stores the resulting session status. + * {@link KycService.setAuthorizations}. Stores the resulting session status + * on the vendor's entry. * * @param params - Session key material and encryption schemas. + * @param params.vendor - Identity vendor that owns the session. * @param params.sessionId - UKYC session id to authorize. * @param params.sessionClientPrivateKey - Per-session X25519 private key * used to seal both secrets. @@ -978,6 +1003,7 @@ export class KycController extends BaseController< * @returns The session status after authorizations are applied. */ async #setAuthorizations(params: { + vendor: KycVendor; sessionId: string; sessionClientPrivateKey: Uint8Array; sessionClientPublicKey: string; @@ -985,6 +1011,7 @@ export class KycController extends BaseController< capabilityTokenSchema: EncryptionSchema; }): Promise { const { + vendor, sessionId, sessionClientPrivateKey, encryptionDataKeySchema, @@ -1012,23 +1039,21 @@ export class KycController extends BaseController< }, ); - this.#updateSessionStatusAndTrackAnalytics(sessionStatus); + this.#updateSessionStatusAndTrackAnalytics(vendor, sessionStatus); return sessionStatus; } /** * Mints a new per-session X25519 keypair, reissues wrapping keys for the - * current session, and submits wrapped authorizations. + * vendor's session, and submits wrapped authorizations. * + * @param vendor - The identity vendor. * @returns The session status after authorizations are applied. - * @throws If there is no session on state. + * @throws If the vendor has no session on state. */ - async #refreshAuthorizations(): Promise { - const sessionId = this.state.sessionStatus?.id; - if (!sessionId) { - throw new Error('No session was found'); - } + async #refreshAuthorizations(vendor: KycVendor): Promise { + const { id: sessionId } = this.#requireSessionStatus(vendor); const sessionClientPrivateKey = x25519.utils.randomSecretKey(); const sessionClientPublicKey = toBase64Url( @@ -1044,6 +1069,7 @@ export class KycController extends BaseController< }); return this.#setAuthorizations({ + vendor, sessionId, sessionClientPrivateKey, sessionClientPublicKey, @@ -1053,41 +1079,39 @@ export class KycController extends BaseController< } /** - * Refreshes and resubmits the UKYC capability authorization when the current - * session reports it as `new` or `expired`. Uses the `sessionStatus` already - * on state (callers are responsible for refreshing status when needed). - * No-ops when KYC has already finished (`finalStatus` is `approved` or - * `rejected`) or the capability token is `pending`, `stored`, or `wiped`. + * Refreshes and resubmits the UKYC capability authorization when the + * vendor's session reports it as `new` or `expired`. Uses the session status + * already on state (callers are responsible for refreshing status when + * needed). No-ops when KYC has already finished (`finalStatus` is `approved` + * or `rejected`) or the capability token is `pending`, `stored`, or `wiped`. * + * @param vendor - The identity vendor. * @returns The current or refreshed session status. - * @throws If there is no session on state when a refresh is required. + * @throws If the vendor has no session on state. */ - async #refreshAuthorizationsIfNeeded(): Promise { - const { sessionStatus } = this.state; - if (!sessionStatus) { - throw new Error('No session was found'); - } + async #refreshAuthorizationsIfNeeded( + vendor: KycVendor, + ): Promise { + const sessionStatus = this.#requireSessionStatus(vendor); if (!needsCapabilityAuthorizationRefresh(sessionStatus)) { return sessionStatus; } - return this.#refreshAuthorizations(); + return this.#refreshAuthorizations(vendor); } /** * Fetches the idOS + KYC-provider disclaimer catalog. Pass exactly one of - * `sessionId` or `country`: + * `vendor` or `country`: * - * - `{ sessionId }` → {@link KycService.fetchSessionDisclaimersBySessionId} - * (`GET /sessions/{sessionId}/disclaimers`) + * - `{ vendor }` → {@link KycService.fetchSessionDisclaimersBySessionId} + * for that vendor's session (`GET /sessions/{sessionId}/disclaimers`) * - `{ country }` → {@link KycService.fetchSessionDisclaimersByCountry} * (`GET /disclaimers?country=`) * - * A session-id fetch also writes the catalog to `sessionDisclaimers`. - * - * @param params - The parameters. Provide exactly one of `sessionId` or + * @param params - The parameters. Provide exactly one of `vendor` or * `country`. - * @param params.sessionId - The UKYC session id. + * @param params.vendor - Identity vendor whose session catalog to fetch. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The catalog. Session fetches include consent state; country * fetches do not. @@ -1095,13 +1119,13 @@ export class KycController extends BaseController< async fetchSessionDisclaimers( params: FetchSessionDisclaimersParams, ): Promise { - const { sessionId, country } = params as { - sessionId?: string; + const { vendor, country } = params as { + vendor?: KycVendor; country?: string; }; - if (sessionId && country) { + if (vendor && country) { throw new Error( - 'KycController.fetchSessionDisclaimers: provide exactly one of sessionId or country.', + 'KycController.fetchSessionDisclaimers: provide exactly one of vendor or country.', ); } @@ -1112,44 +1136,47 @@ export class KycController extends BaseController< ); } - if (!sessionId) { + if (!vendor) { throw new Error( - 'KycController.fetchSessionDisclaimers: provide exactly one of sessionId or country.', + 'KycController.fetchSessionDisclaimers: provide exactly one of vendor or country.', ); } - const catalog = await this.messenger.call( + const { id: sessionId } = this.#requireSessionStatus(vendor); + return this.messenger.call( 'KycService:fetchSessionDisclaimersBySessionId', - { sessionId }, + { + sessionId, + }, ); - return catalog; } /** - * Fetches the session-scoped disclaimer catalog and records consents - * derived from the T&C2 flags. Already-consented catalog rows are omitted - * from the POST. A 409 is re-checked with a GET: continue only when every - * accepted document is now consented, otherwise fail closed. + * Fetches the vendor's session-scoped disclaimer catalog and records + * consents derived from the T&C2 flags. Already-consented catalog rows are + * omitted from the POST. A 409 is re-checked with a GET: continue only when + * every accepted document is now consented, otherwise fail closed. * * @param params - Accepted session-disclaimer records. + * @param params.vendor - The identity vendor. * @param params.providerDisclaimersAccepted - Accepted Sumsub disclaimer records. * @param params.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param params.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. */ async recordSessionDisclaimers(params: { + vendor: KycVendor; providerDisclaimersAccepted: KycConsentRecord[]; idosDisclaimersAccepted: KycConsentRecord[]; credentialReusabilityConsentGiven: boolean; }): Promise { - if (!this.state.sessionStatus) { - throw new Error('No session was found'); - } + const { vendor } = params; + const { id: sessionId } = this.#requireSessionStatus(vendor); // TODO: Check if these can simply be mapped to `{key, version}` pairs instead const disclaimers = await this.messenger.call( 'KycService:fetchSessionDisclaimersBySessionId', - { sessionId: this.state.sessionStatus.id }, + { sessionId }, ); const idOS = consentRecordsFromAcceptedList( @@ -1163,7 +1190,7 @@ export class KycController extends BaseController< try { await this.messenger.call('KycService:submitSessionDisclaimers', { - sessionId: this.state.sessionStatus.id, + sessionId, idOS, kycProvider, credentialReusabilityConsentGiven: @@ -1172,33 +1199,36 @@ export class KycController extends BaseController< } catch { // A 409 means the session already recorded these consents. } - this.update((state) => { - // TODO: This shouldn't be hardcoded to sumsub - state.providerDisclaimersAccepted.sumsub = - params.providerDisclaimersAccepted; - state.idosDisclaimersAccepted = params.idosDisclaimersAccepted; - state.credentialReusabilityConsentGiven = + this.#updateVendorSession(vendor, (entry) => { + entry.providerDisclaimersAccepted = params.providerDisclaimersAccepted; + entry.idosDisclaimersAccepted = params.idosDisclaimersAccepted; + entry.credentialReusabilityConsentGiven = params.credentialReusabilityConsentGiven; }); } /** - * Fetches the current session status and reports whether consent was given. + * Fetches the vendor's current session status and reports whether consent + * was given. * + * @param params - The parameters. + * @param params.vendor - The identity vendor. * @returns Whether `consentStatus` is `given`. - * @throws If there is no session on state. + * @throws If the vendor has no session on state. */ - async hasCompletedSessionDisclaimers(): Promise { - if (!this.state.sessionStatus) { - throw new Error('No session was found'); - } + async hasCompletedSessionDisclaimers({ + vendor, + }: { + vendor: KycVendor; + }): Promise { + const { id: sessionId } = this.#requireSessionStatus(vendor); // TODO: remove this explicit call to the service and use the state directly // after we have established the pattern for resuming a session after wallet // restart more concretely const sessionStatus = await this.messenger.call( 'KycService:getSessionStatus', - { sessionId: this.state.sessionStatus.id }, + { sessionId }, ); return sessionStatus.consentStatus === 'given'; @@ -1209,89 +1239,135 @@ export class KycController extends BaseController< * created (`GET /vendors/{vendor}/disclaimers?country=`). * * @param params - The parameters. - * @param params.vendor - Identity vendor. Defaults to `moonpay`. + * @param params.vendor - Identity vendor. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ async fetchVendorDisclaimers(params: { - vendor?: KycVendor; + vendor: KycVendor; country: string; }): Promise { return this.messenger.call('KycService:fetchVendorDisclaimers', params); } /** - * Records vendor T&C acceptance via {@link KycService.submitVendorDisclaimers} - * and persists the accepted ids on state. + * Stores the customer's acceptance of a vendor's T&Cs on the device, before + * the vendor has it. Makes no network call, so it works before a session + * exists; {@link recordVendorDisclaimers} submits it once one does. Creates + * the vendor's entry when it has none. + * + * Accepting again replaces earlier acceptance and marks it not submitted. * * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @param params.country - ISO 3166-1 alpha-3 country the terms were fetched + * for. Recorded as the entry's residence country until a session exists. * @param params.disclaimerIds - Accepted vendor T&C ids. - * @returns The vendor signing records. - * @throws If `vendor` is missing from state. */ - async recordVendorDisclaimers(params: { + acceptVendorDisclaimers(params: { + vendor: KycVendor; + country: string; disclaimerIds: string[]; - }): Promise { - if (!this.state.vendor) { - throw new Error('No vendor was found'); - } + }): void { + this.#updateVendorSession(params.vendor, (entry) => { + entry.vendorDisclaimersAccepted = { + disclaimerIds: [...params.disclaimerIds], + acceptedAt: new Date().toISOString(), + submitted: false, + }; + if (entry.sessionStatus === null) { + entry.residenceCountry = params.country; + } + }); + } - if (!this.state.email) { - throw new Error('No email was found'); + /** + * Submits the vendor T&C acceptance stored by + * {@link acceptVendorDisclaimers} via + * {@link KycService.submitVendorDisclaimers}, then marks it submitted. + * + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The vendor signing records. + * @throws If the vendor has no stored acceptance, or no session yet (the + * vendor records terms against a customer that session creation creates). + */ + async recordVendorDisclaimers({ + vendor, + }: { + vendor: KycVendor; + }): Promise { + const accepted = this.state.sessions[vendor]?.vendorDisclaimersAccepted; + if (!accepted) { + throw new Error('No vendor disclaimer acceptance was found'); } - - const { vendor } = this.state; + this.#requireSessionStatus(vendor); const signings = await this.messenger.call( 'KycService:submitVendorDisclaimers', { vendor, - disclaimerIds: params.disclaimerIds, + disclaimerIds: accepted.disclaimerIds, }, ); - // TODO: this only works for Iron right now + // The entry can be cleared while the request is in flight; do not + // recreate it. this.update((state) => { - state.vendorDisclaimersAccepted = recordVendorDisclaimerAcceptance( - state.vendorDisclaimersAccepted, - vendor, - { - disclaimerIds: params.disclaimerIds, - }, - ); + const entry = state.sessions[vendor]; + if (entry?.vendorDisclaimersAccepted) { + entry.vendorDisclaimersAccepted.submitted = true; + } }); return signings; } /** - * Fetches the current vendor T&C catalog and returns whether every document - * is already recorded in {@link KycControllerState.vendorDisclaimersAccepted}. + * Fetches the vendor's current T&C catalog for the entry's residence country + * and returns whether submitted acceptance covers every document. * - * @returns Whether persisted acceptance covers the fetched catalog. - * @throws If `vendor` or `geoCountry` is missing from state. + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns Whether submitted acceptance covers the fetched catalog. + * @throws If the vendor has no entry or no residence country. */ - async hasCompletedVendorDisclaimers(): Promise { - if (!this.state.vendor) { - throw new Error('No vendor was found'); + async hasCompletedVendorDisclaimers({ + vendor, + }: { + vendor: KycVendor; + }): Promise { + const entry = this.state.sessions[vendor]; + if (!entry) { + throw new Error('No session was found'); } - if (!this.state.geoCountry) { - throw new Error('No geoCountry was found'); + if (!entry.residenceCountry) { + throw new Error('No residence country was found'); } const fetched = await this.fetchVendorDisclaimers({ - vendor: this.state.vendor, - country: this.state.geoCountry, + vendor, + country: entry.residenceCountry, }); - // TODO: this only works for Iron right now return areVendorDisclaimersCompleted( - this.state.vendorDisclaimersAccepted, - this.state.vendor, + entry.vendorDisclaimersAccepted, fetched, ); } + /** + * Returns one verdict for the vendor's KYC session. See + * {@link KycSessionDecision}. + * + * @param params - The parameters. + * @param params.vendor - The identity vendor. + * @returns The session decision; `none` when the vendor has no session. + */ + getSessionDecision({ vendor }: { vendor: KycVendor }): KycSessionDecision { + return getKycSessionDecision(this.state.sessions[vendor]); + } + /** * Deletes the caller's encrypted KYC blobs from MetaMask servers. * @@ -1329,47 +1405,66 @@ export class KycController extends BaseController< } /** - * Presents the identity-provider verification UI. Currently launches SumSub. + * Presents the identity-provider verification UI for the vendor's session. + * Currently launches SumSub. + * + * Only one flow is presented at a time. A launch while another flow is on + * screen returns `failed` without writing state. * - * @param params - Optional SDK presentation options. + * @param params - The vendor and optional SDK presentation options. + * @param params.vendor - The identity vendor. * @param params.locale - BCP-47 locale for the SDK UI. * @param params.debug - Enables SDK debug logging. * @returns The durable provider-flow outcome. */ - launchProviderFlow({ + async launchProviderFlow({ + vendor, locale, debug, }: { + vendor: KycVendor; locale?: string; debug?: boolean; }): Promise { - // Currently only sumsub is supported and must be used for Iron - return this.#launchSumsubFlow({ locale, debug }); + if (this.#presentingVendor !== null) { + return 'failed'; + } + this.#presentingVendor = vendor; + try { + // Currently only sumsub is supported and must be used for Iron + return await this.#launchSumsubFlow(vendor, { locale, debug }); + } finally { + this.#presentingVendor = null; + } } /** - * Runs the SumSub document-verification sub-flow end to end: + * Runs the SumSub document-verification sub-flow end to end for the + * vendor's session: * * 1. fetches the SumSub applicant access token; and * 2. presents the SDK via the injected launcher. * - * Capability authorization must already be valid on - * {@link KycControllerState.sessionStatus} (see - * {@link KycController.#refreshAuthorizationsIfNeeded}). + * Capability authorization must already be valid on the vendor's session + * (see {@link KycController.#refreshAuthorizationsIfNeeded}). * + * @param vendor - The identity vendor. * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. * @param params.debug - Enables SDK debug logging. * @returns The durable provider-flow outcome. */ - async #launchSumsubFlow(params?: { - locale?: string; - debug?: boolean; - }): Promise { - const sessionId = this.state.sessionStatus?.id; + async #launchSumsubFlow( + vendor: KycVendor, + params?: { + locale?: string; + debug?: boolean; + }, + ): Promise { + const sessionId = this.state.sessions[vendor]?.sessionStatus?.id; if (!this.#sumsubLauncher.isAvailable() || !sessionId) { - this.#recordLaunchOutcomeAndTrackAnalytics('failed', (state) => { - state.providerFlowStatus = 'failed'; + this.#recordLaunchOutcomeAndTrackAnalytics(vendor, 'failed', (entry) => { + entry.providerFlowStatus = 'failed'; }); return 'failed'; } @@ -1414,47 +1509,63 @@ export class KycController extends BaseController< reachedCompletion ||= isSumSubFlowCompleted(result.status); sdkFailed ||= isSumSubFlowFailed(result.status); - const currentSessionStatus = this.state.sessionStatus; - if (currentSessionStatus?.id !== sessionId) { - this.#recordLaunchOutcomeAndTrackAnalytics('failed'); + const currentSessionStatus = this.state.sessions[vendor]?.sessionStatus; + if (!currentSessionStatus || currentSessionStatus.id !== sessionId) { + this.#recordLaunchOutcomeAndTrackAnalytics(vendor, 'failed'); return 'failed'; } if (sdkFailed) { - this.#recordLaunchOutcomeAndTrackAnalytics('failed', (state) => { - state.providerFlowStatus = 'failed'; - }); + this.#recordLaunchOutcomeAndTrackAnalytics( + vendor, + 'failed', + (entry) => { + entry.providerFlowStatus = 'failed'; + }, + ); return 'failed'; } if (reachedCompletion) { // Once the SDK completes, the authoritative verification decision comes // from the UKYC backend, not the SDK result. - this.#recordLaunchOutcomeAndTrackAnalytics('submitted', (state) => { - state.sessionStatus = { - ...currentSessionStatus, - finalStatus: KYC_STATUSES.pending, - // The applicant has submitted documents, so the session is in - // vendor processing rather than still on the `kyc` step. - currentStep: KYC_CURRENT_STEPS.processing, - }; - state.providerFlowStatus = 'submitted'; - }); - this.startSessionStatusPolling(); + this.#recordLaunchOutcomeAndTrackAnalytics( + vendor, + 'submitted', + (entry) => { + entry.sessionStatus = { + ...currentSessionStatus, + finalStatus: KYC_STATUSES.pending, + // The applicant has submitted documents, so the session is in + // vendor processing rather than still on the `kyc` step. + currentStep: KYC_CURRENT_STEPS.processing, + }; + entry.providerFlowStatus = 'submitted'; + }, + ); + this.startSessionStatusPolling({ vendor }); return 'submitted'; } - this.#recordLaunchOutcomeAndTrackAnalytics('abandoned', (state) => { - state.providerFlowStatus = 'abandoned'; - }); + this.#recordLaunchOutcomeAndTrackAnalytics( + vendor, + 'abandoned', + (entry) => { + entry.providerFlowStatus = 'abandoned'; + }, + ); return 'abandoned'; } catch { - if (this.state.sessionStatus?.id === sessionId) { - this.#recordLaunchOutcomeAndTrackAnalytics('failed', (state) => { - state.providerFlowStatus = 'failed'; - }); + if (this.state.sessions[vendor]?.sessionStatus?.id === sessionId) { + this.#recordLaunchOutcomeAndTrackAnalytics( + vendor, + 'failed', + (entry) => { + entry.providerFlowStatus = 'failed'; + }, + ); } else { - this.#recordLaunchOutcomeAndTrackAnalytics('failed'); + this.#recordLaunchOutcomeAndTrackAnalytics(vendor, 'failed'); } return 'failed'; } diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 409292813bd..8013041e0c1 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -22,7 +22,7 @@ export type KycServiceGetGeoCountryAction = { * created. * * @param params - The parameters. - * @param params.vendor - Identity vendor. Defaults to `moonpay`. + * @param params.vendor - Identity vendor. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index d4496fc3bad..0cf573baa2b 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -40,7 +40,10 @@ describe('KycService', () => { const { service } = getService({ omitFetch: true }); expect( - await service.fetchVendorDisclaimers({ country: 'USA' }), + await service.fetchVendorDisclaimers({ + vendor: 'moonpay', + country: 'USA', + }), ).toStrictEqual(disclaimers); }); @@ -115,7 +118,10 @@ describe('KycService', () => { const { service } = getService(); expect( - await service.fetchVendorDisclaimers({ country: 'USA' }), + await service.fetchVendorDisclaimers({ + vendor: 'moonpay', + country: 'USA', + }), ).toStrictEqual(disclaimers); }); @@ -127,14 +133,14 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchVendorDisclaimers({ country: 'USA' }), + service.fetchVendorDisclaimers({ vendor: 'moonpay', country: 'USA' }), ).rejects.toThrow(/Malformed response received from disclaimers API/u); }); it('throws when no bearer token is available', async () => { const { service } = getService({ bearerToken: '' }); await expect( - service.fetchVendorDisclaimers({ country: 'USA' }), + service.fetchVendorDisclaimers({ vendor: 'moonpay', country: 'USA' }), ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); }); @@ -146,7 +152,7 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchVendorDisclaimers({ country: 'USA' }), + service.fetchVendorDisclaimers({ vendor: 'moonpay', country: 'USA' }), ).rejects.toThrow(/failed with status '500'/u); }); }); @@ -368,6 +374,7 @@ describe('KycService', () => { expect( await service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: { foo: 'bar' }, @@ -388,6 +395,7 @@ describe('KycService', () => { expect( await service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, aal2Token: AAL2_TOKEN, @@ -407,6 +415,7 @@ describe('KycService', () => { expect( await service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, aal2Token: AAL2_TOKEN, @@ -426,6 +435,7 @@ describe('KycService', () => { expect( await service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, aal2Token: AAL2_TOKEN, @@ -438,6 +448,7 @@ describe('KycService', () => { await expect( service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, aal2Token: '', @@ -451,6 +462,7 @@ describe('KycService', () => { await expect( service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: {}, @@ -1476,7 +1488,7 @@ describe('KycService', () => { jwtChain: 'jwt.chain.sig', }; - it('defaults vendorId to moonpay and forwards vendorMetadata', async () => { + it('sends vendorId and forwards vendorMetadata', async () => { const response = { sessionId: 'sid', encryptionDataKey: encryptionSchema, @@ -1498,6 +1510,7 @@ describe('KycService', () => { expect( await service.createUkycSession({ + vendor: 'moonpay', sessionClientPublicKey: SESSION_CLIENT_PUBLIC_KEY, residenceCountry: RESIDENCE_COUNTRY, vendorMetadata: { moonPayAccessToken: 'tok' }, @@ -1548,7 +1561,10 @@ describe('KycService', () => { const { service } = getService({ baseUrl: customUrl }); expect( - await service.fetchVendorDisclaimers({ country: 'USA' }), + await service.fetchVendorDisclaimers({ + vendor: 'moonpay', + country: 'USA', + }), ).toStrictEqual(disclaimers); }); @@ -1569,6 +1585,7 @@ describe('KycService', () => { expect( await rootMessenger.call('KycService:fetchVendorDisclaimers', { + vendor: 'moonpay', country: 'USA', }), ).toStrictEqual([]); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 4388136b287..cb18057db9a 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -349,11 +349,10 @@ export type CreateUkycSessionParams = { */ residenceCountry: string; /** - * Identity vendor for the UKYC session. Defaults to `moonpay` for the - * existing Check/Auth flow. Pass a non-MoonPay vendor (e.g. `iron`) for - * the consents path (no MoonPay metadata required). + * Identity vendor for the UKYC session. MoonPay sessions also need + * `vendorMetadata`; other vendors (e.g. `iron`) use the consents path. */ - vendor?: KycVendor; + vendor: KycVendor; /** * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` / * `moonPayUserId`); optional / omitted for other vendors. @@ -555,15 +554,15 @@ export class KycService extends BaseDataService< * created. * * @param params - The parameters. - * @param params.vendor - Identity vendor. Defaults to `moonpay`. + * @param params.vendor - Identity vendor. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ async fetchVendorDisclaimers({ - vendor = 'moonpay', + vendor, country, }: { - vendor?: KycVendor; + vendor: KycVendor; country: string; }): Promise { const url = new URL(`/vendors/${vendor}/disclaimers`, this.#baseUrl); @@ -858,7 +857,7 @@ export class KycService extends BaseDataService< { method: 'POST', body: JSON.stringify({ - vendorId: params.vendor ?? 'moonpay', + vendorId: params.vendor, vendorUserId: 'mockedId', sessionClientPublicKey: params.sessionClientPublicKey, residenceCountry: params.residenceCountry, diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index b4da7a7d234..dbe73cd852d 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -8,7 +8,9 @@ describe('@metamask/kyc-controller', () => { KycController: expect.any(Function), KycService: expect.any(Function), getDefaultKycControllerState: expect.any(Function), - selectKycVendor: expect.any(Function), + getDefaultKycVendorSessionState: expect.any(Function), + selectKycSession: expect.any(Function), + getKycSessionDecision: expect.any(Function), selectKycSessionStatus: expect.any(Function), needsCapabilityAuthorizationRefresh: expect.any(Function), CAPABILITY_AUTHORIZATION_STATUSES: expect.any(Object), diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 826ed01c410..c3beed81cc9 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -1,8 +1,7 @@ export { KycController, getDefaultKycControllerState, - getDefaultKycProviderDisclaimersAccepted, - getDefaultKycVendorDisclaimersAccepted, + getDefaultKycVendorSessionState, KYC_PROVIDER_FLOW_STATUSES, controllerName, } from './KycController.js'; @@ -16,8 +15,10 @@ export type { KycControllerStateChangeEvent, FetchSessionDisclaimersParams, KycProviderFlowStatus, + KycVendorSessionState, } from './KycController.js'; export type { + KycControllerAcceptVendorDisclaimersAction, KycControllerClearStateAction, KycControllerDeleteEncryptedStorageAction, KycControllerEnsureLocalUserSecretAction, @@ -25,6 +26,7 @@ export type { KycControllerFetchVendorDisclaimersAction, KycControllerGetDataSharesAction, KycControllerGetProviderFlowStatusAction, + KycControllerGetSessionDecisionAction, KycControllerGetSessionStatusForVendorAction, KycControllerHasCompletedSessionDisclaimersAction, KycControllerHasCompletedVendorDisclaimersAction, @@ -100,7 +102,13 @@ export type { KycServiceSubmitVendorDisclaimersAction, } from './KycService-method-action-types.js'; -export { selectKycSessionStatus, selectKycVendor } from './selectors.js'; +export { selectKycSession, selectKycSessionStatus } from './selectors.js'; + +export { + KYC_SESSION_DECISIONS, + getKycSessionDecision, +} from './sessionDecision.js'; +export type { KycSessionDecision } from './sessionDecision.js'; export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes.js'; export { decryptCredentials, generateKeyPair } from './crypto.js'; @@ -124,15 +132,10 @@ export type { KycDataShare, KycDisclaimer, KycDisclaimersCatalog, - KycPhase, - KycProduct, - KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, KycVendor, - KycIronVendorDisclaimersAccepted, - KycMoonpayVendorDisclaimersAccepted, - KycVendorDisclaimersAccepted, + KycVendorDisclaimersAcceptance, KycVendorSigning, } from './types.js'; export { diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts index d06fa8f6e10..1ffa1ae6827 100644 --- a/packages/kyc-controller/src/selectors.test.ts +++ b/packages/kyc-controller/src/selectors.test.ts @@ -1,19 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { getDefaultKycControllerState } from './KycController.js'; -import { selectKycSessionStatus, selectKycVendor } from './selectors.js'; +import { + getDefaultKycControllerState, + getDefaultKycVendorSessionState, +} from './KycController.js'; +import { selectKycSession, selectKycSessionStatus } from './selectors.js'; describe('selectors', () => { - it('selectKycVendor returns the current vendor', () => { - const state = { - ...getDefaultKycControllerState(), - vendor: 'iron' as const, - }; - expect(selectKycVendor(state)).toBe('iron'); + it('selectKycSession returns the vendor entry', () => { + const entry = getDefaultKycVendorSessionState(); + const state = { sessions: { iron: entry } }; + expect(selectKycSession(state, 'iron')).toBe(entry); + expect(selectKycSession(state, 'moonpay')).toBeUndefined(); }); - it('selectKycSessionStatus returns the session status', () => { - const state = getDefaultKycControllerState(); - expect(selectKycSessionStatus(state)).toBeNull(); + it('selectKycSessionStatus returns null when the vendor has no session', () => { + expect( + selectKycSessionStatus(getDefaultKycControllerState(), 'iron'), + ).toBeNull(); }); }); diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts index 660097cc1a7..2a7f4521890 100644 --- a/packages/kyc-controller/src/selectors.ts +++ b/packages/kyc-controller/src/selectors.ts @@ -1,21 +1,29 @@ -import type { KycControllerState } from './KycController.js'; -import type { KycVendor } from './types.js'; +import type { + KycControllerState, + KycVendorSessionState, +} from './KycController.js'; +import type { KycSessionStatus, KycVendor } from './types.js'; /** - * Selects the identity vendor on the current KYC session. + * Selects a vendor's session entry. * * @param state - The KycController state. - * @returns The current vendor, or `null`. + * @param vendor - The identity vendor. + * @returns The vendor's entry, or `undefined` when none exists. */ -export const selectKycVendor = (state: KycControllerState): KycVendor | null => - state.vendor; +export const selectKycSession = ( + state: KycControllerState, + vendor: KycVendor, +): KycVendorSessionState | undefined => state.sessions[vendor]; /** - * Selects the latest UKYC session status. + * Selects a vendor's latest UKYC session status. * * @param state - The KycController state. - * @returns The current session status, or `null`. + * @param vendor - The identity vendor. + * @returns The vendor's session status, or `null`. */ export const selectKycSessionStatus = ( state: KycControllerState, -): KycControllerState['sessionStatus'] => state.sessionStatus; + vendor: KycVendor, +): KycSessionStatus | null => state.sessions[vendor]?.sessionStatus ?? null; diff --git a/packages/kyc-controller/src/sessionDecision.test.ts b/packages/kyc-controller/src/sessionDecision.test.ts new file mode 100644 index 00000000000..6e04f73475b --- /dev/null +++ b/packages/kyc-controller/src/sessionDecision.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { getDefaultKycVendorSessionState } from './KycController.js'; +import type { + KycProviderFlowStatus, + KycVendorSessionState, +} from './KycController.js'; +import { getKycSessionDecision } from './sessionDecision.js'; + +const entry = ( + finalStatus: string, + kycStatus: string, + providerFlowStatus: KycProviderFlowStatus = 'not_started', +): KycVendorSessionState => ({ + ...getDefaultKycVendorSessionState(), + providerFlowStatus, + sessionStatus: { + id: 'sid', + finalStatus, + kycStatus, + externalUserId: 'ext', + vendor: 'iron', + vendorStatus: finalStatus, + }, +}); + +describe('getKycSessionDecision', () => { + it('is none without an entry or session', () => { + expect(getKycSessionDecision(undefined)).toBe('none'); + expect(getKycSessionDecision(getDefaultKycVendorSessionState())).toBe( + 'none', + ); + }); + + // Parity with the ramps controller's former `resolveVbaKycStatus`. + it.each([ + ['approved', 'approved', 'not_started', 'approved'], + ['approved', 'pending', 'not_started', 'approved'], + ['pending', 'approved', 'not_started', 'pending'], + ['rejected', 'rejected', 'not_started', 'rejected'], + ['rejected', 'pending', 'submitted', 'rejected'], + ['retry', 'retry', 'submitted', 'retry'], + ['pending', 'pending', 'not_started', 'pending'], + ['new', 'pending', 'not_started', 'pending'], + ['new', 'new', 'submitted', 'pending'], + ['new', 'new', 'not_started', 'new'], + ['new', 'new', 'abandoned', 'new'], + ['something-else', 'new', 'not_started', 'none'], + ] as const)( + 'finalStatus %s, kycStatus %s, flow %s → %s', + (finalStatus, kycStatus, flow, expected) => { + expect(getKycSessionDecision(entry(finalStatus, kycStatus, flow))).toBe( + expected, + ); + }, + ); +}); diff --git a/packages/kyc-controller/src/sessionDecision.ts b/packages/kyc-controller/src/sessionDecision.ts new file mode 100644 index 00000000000..854ad450afe --- /dev/null +++ b/packages/kyc-controller/src/sessionDecision.ts @@ -0,0 +1,62 @@ +import type { KycVendorSessionState } from './KycController.js'; + +/** + * One verdict for a vendor's KYC session, derived from the session's + * `finalStatus` / `kycStatus` pair and the provider-flow outcome. + * + * - `none` — no session, or a status this client does not recognise. + * - `new` — a session exists but nothing has been submitted. + * - `pending` — submitted, or the vendor / relay is still processing. + * - `approved` — the vendor approved the session. + * - `rejected` / `retry` — the vendor's final decision. + * + * This is deliberately not a "ready" verdict: `approved` says nothing about + * work a vendor needs after approval (e.g. Iron account activation). + */ +export const KYC_SESSION_DECISIONS = [ + 'none', + 'new', + 'pending', + 'approved', + 'rejected', + 'retry', +] as const; + +export type KycSessionDecision = (typeof KYC_SESSION_DECISIONS)[number]; + +const KYC_SESSION_DECISION_SET = new Set(KYC_SESSION_DECISIONS); + +/** + * Reduces a vendor's session entry to a {@link KycSessionDecision}. + * + * Only the vendor's own `finalStatus: approved` counts as approved; a relay + * `kycStatus: approved` alone does not. A non-final session whose provider + * flow was submitted, or whose status is pending, is `pending`. + * + * @param entry - The vendor's session entry, if any. + * @returns The session decision. + */ +export function getKycSessionDecision( + entry: KycVendorSessionState | undefined, +): KycSessionDecision { + const session = entry?.sessionStatus; + if (!session) { + return 'none'; + } + if (session.finalStatus === 'approved') { + return 'approved'; + } + if (session.finalStatus === 'rejected' || session.finalStatus === 'retry') { + return session.finalStatus; + } + if ( + session.finalStatus === 'pending' || + session.kycStatus === 'pending' || + entry.providerFlowStatus === 'submitted' + ) { + return 'pending'; + } + return KYC_SESSION_DECISION_SET.has(session.finalStatus) + ? (session.finalStatus as KycSessionDecision) + : 'none'; +} diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index cac47675ea1..0395ca13c24 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -6,12 +6,6 @@ * intentionally vendor-neutral so a future vendor swap does not ripple out. */ -/** - * A MetaMask feature that consumes KYC. Used to key the per-product - * "is KYC required" cache so ramps, card, and money can share one controller. - */ -export type KycProduct = 'ramps' | 'card' | 'money'; - /** * Identity vendors supported behind the KYC surface. * @@ -21,36 +15,6 @@ export type KycProduct = 'ramps' | 'card' | 'money'; */ export type KycVendor = 'moonpay' | 'iron'; -/** - * Phases of the end-to-end identity flow. - * - * - `idle` — nothing started. - * - `terms` — waiting for the customer to accept the vendor terms. - * - `session` — creating the vendor session (MoonPay) or creating the UKYC - * session and recording session-scoped disclaimers (non-MoonPay vendors). - * - `check` — running the invisible connection-check frame (MoonPay only). - * - `auth` — running the visible authentication (OTP) frame (MoonPay only). - * - `form` — authenticated. When the flow is scoped to a product, the - * KYC-required check runs automatically from here; otherwise the consumer - * drives it manually via `checkKycRequired`. Consents-path vendors skip - * this phase. - * - `submit` — submitting the KYC-required check / launching SumSub. - * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` / - * `userStatus`. When KYC is required, the document-verification sub-flow is - * launched automatically. - * - `error` — flow halted; see `error`. - */ -export type KycPhase = - | 'idle' - | 'terms' - | 'session' - | 'check' - | 'auth' - | 'form' - | 'submit' - | 'done' - | 'error'; - /** * UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus` * (the vendor-side outcome) draw from the same vocabulary, so they are defined @@ -321,37 +285,22 @@ export type KycConsentRecord = { }; /** - * 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}. + * A vendor's T&C acceptance, stored on that vendor's session entry. + * + * Terms are shown before a session exists and can only be submitted to the + * vendor once one does, so acceptance is recorded on the device first + * (`submitted: false`) and marked submitted once the vendor has it. */ -export type KycIronVendorDisclaimersAccepted = { - /** IDs of Iron vendor disclaimers the customer accepted. */ +export type KycVendorDisclaimersAcceptance = { + /** IDs of the vendor disclaimers the customer accepted. */ 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. - */ -export type KycVendorDisclaimersAccepted = { - moonpay: KycMoonpayVendorDisclaimersAccepted | null; - iron: KycIronVendorDisclaimersAccepted | null; + /** + * ISO-8601 timestamp of the acceptance. `null` for acceptance migrated from + * a version that did not record when it happened. + */ + acceptedAt: string | null; + /** Whether the vendor has recorded the acceptance. */ + submitted: boolean; }; /** diff --git a/packages/kyc-controller/src/vendorDisclaimers.test.ts b/packages/kyc-controller/src/vendorDisclaimers.test.ts index 4c8867ec9d1..633683a4f96 100644 --- a/packages/kyc-controller/src/vendorDisclaimers.test.ts +++ b/packages/kyc-controller/src/vendorDisclaimers.test.ts @@ -1,200 +1,45 @@ import { describe, expect, it } from 'vitest'; -import { - acceptedVendorDisclaimerIds, - areVendorDisclaimersCompleted, - clearVendorDisclaimerAcceptance, - hasVendorDisclaimerAcceptance, - ironDisclaimerIds, - recordVendorDisclaimerAcceptance, -} from './vendorDisclaimers.js'; +import { areVendorDisclaimersCompleted } from './vendorDisclaimers.js'; -describe('vendorDisclaimers', () => { - describe('hasVendorDisclaimerAcceptance', () => { - it('returns true when MoonPay terms are persisted', () => { - expect( - hasVendorDisclaimerAcceptance( - { moonpay: { termsAcceptedAt: 't' }, iron: null }, - 'moonpay', - ), - ).toBe(true); - }); +const catalog = [ + { id: 'a', display_name: 'A', url: 'https://a' }, + { id: 'b', display_name: 'B', url: 'https://b' }, +]; - 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('areVendorDisclaimersCompleted', () => { + it('treats an empty catalog as complete', () => { + expect(areVendorDisclaimersCompleted(null, [])).toBe(true); }); - 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([]); - }); + it('is incomplete without acceptance', () => { + expect(areVendorDisclaimersCompleted(null, catalog)).toBe(false); }); - describe('acceptedVendorDisclaimerIds', () => { - it('returns Iron disclaimer ids', () => { - expect( - acceptedVendorDisclaimerIds( - { moonpay: null, iron: { disclaimerIds: ['d1', 'd2'] } }, - 'iron', - ), - ).toStrictEqual(['d1', 'd2']); - }); - - it('returns an empty list for MoonPay', () => { - expect( - acceptedVendorDisclaimerIds( - { moonpay: { termsAcceptedAt: 't' }, iron: null }, - 'moonpay', - ), - ).toStrictEqual([]); - }); + it('does not count acceptance that was not submitted', () => { + expect( + areVendorDisclaimersCompleted( + { disclaimerIds: ['a', 'b'], acceptedAt: null, submitted: false }, + catalog, + ), + ).toBe(false); }); - describe('areVendorDisclaimersCompleted', () => { - const catalog = [ - { id: 'd1', display_name: 'T1', url: 'u1' }, - { id: 'd2', display_name: 'T2', url: 'u2' }, - ]; - - it('returns true when every fetched Iron disclaimer id is accepted', () => { - expect( - areVendorDisclaimersCompleted( - { moonpay: null, iron: { disclaimerIds: ['d1', 'd2', 'extra'] } }, - 'iron', - catalog, - ), - ).toBe(true); - }); - - it('returns false when a fetched Iron disclaimer id is missing', () => { - expect( - areVendorDisclaimersCompleted( - { moonpay: null, iron: { disclaimerIds: ['d1'] } }, - 'iron', - catalog, - ), - ).toBe(false); - }); - - it('returns true when the fetched catalog is empty', () => { - expect( - areVendorDisclaimersCompleted( - { moonpay: null, iron: null }, - 'iron', - [], - ), - ).toBe(true); - }); - - it('returns false for MoonPay when the catalog has documents', () => { - expect( - areVendorDisclaimersCompleted( - { moonpay: { termsAcceptedAt: 't' }, iron: null }, - 'moonpay', - catalog, - ), - ).toBe(false); - }); + it('is complete when submitted acceptance covers the catalog', () => { + expect( + areVendorDisclaimersCompleted( + { disclaimerIds: ['a', 'b', 'old'], acceptedAt: null, submitted: true }, + catalog, + ), + ).toBe(true); }); - describe('recordVendorDisclaimerAcceptance', () => { - it('leaves MoonPay acceptance unchanged', () => { - const accepted = { moonpay: null, iron: null }; - expect( - recordVendorDisclaimerAcceptance(accepted, 'moonpay', { - disclaimerIds: [], - }), - ).toBe(accepted); - }); - - it('records Iron acceptance', () => { - expect( - recordVendorDisclaimerAcceptance( - { moonpay: null, iron: null }, - 'iron', - { 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', { - 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); - }); + it('is incomplete when a catalog document is missing', () => { + expect( + areVendorDisclaimersCompleted( + { disclaimerIds: ['a'], acceptedAt: null, submitted: true }, + catalog, + ), + ).toBe(false); }); }); diff --git a/packages/kyc-controller/src/vendorDisclaimers.ts b/packages/kyc-controller/src/vendorDisclaimers.ts index 5c2ce0164e6..cd1239534b9 100644 --- a/packages/kyc-controller/src/vendorDisclaimers.ts +++ b/packages/kyc-controller/src/vendorDisclaimers.ts @@ -1,118 +1,25 @@ -import type { - KycDisclaimer, - KycVendor, - KycVendorDisclaimersAccepted, -} from './types.js'; +import type { KycDisclaimer, KycVendorDisclaimersAcceptance } from './types.js'; /** - * Returns whether persisted vendor disclaimer acceptance exists for `vendor`. + * Returns whether every fetched vendor disclaimer is covered by acceptance the + * vendor has already recorded. Acceptance stored on the device but not yet + * submitted does not count. Extra accepted ids that are no longer in the + * catalog still count as complete, and an empty catalog is always complete. * - * @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 ?? []; -} - -/** - * Returns persisted accepted vendor-disclaimer ids for `vendor`. - * - * MoonPay stores a timestamp rather than ids, so the list is empty. - * - * @param accepted - Vendor-disclaimer acceptance map. - * @param vendor - Identity vendor to inspect. - * @returns The accepted disclaimer ids. - */ -export function acceptedVendorDisclaimerIds( - accepted: KycVendorDisclaimersAccepted, - vendor: KycVendor, -): string[] { - if (vendor === 'iron') { - return ironDisclaimerIds(accepted); - } - return []; -} - -/** - * Returns whether every fetched vendor disclaimer is already recorded in - * persisted acceptance for `vendor`. Extra accepted ids that are no longer - * in the catalog still count as complete. - * - * @param accepted - Vendor-disclaimer acceptance map. - * @param vendor - Identity vendor the catalog belongs to. + * @param accepted - The vendor's stored acceptance, if any. * @param fetched - Current vendor T&C catalog. - * @returns Whether every fetched disclaimer id is accepted. + * @returns Whether every fetched disclaimer id is accepted and submitted. */ export function areVendorDisclaimersCompleted( - accepted: KycVendorDisclaimersAccepted, - vendor: KycVendor, + accepted: KycVendorDisclaimersAcceptance | null, fetched: KycDisclaimer[], ): boolean { - const acceptedIds = new Set(acceptedVendorDisclaimerIds(accepted, vendor)); - return fetched.every((disclaimer) => acceptedIds.has(disclaimer.id)); -} - -/** - * 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.disclaimerIds - Iron disclaimer ids. - * @returns The updated acceptance map. - */ -export function recordVendorDisclaimerAcceptance( - accepted: KycVendorDisclaimersAccepted, - vendor: KycVendor, - params: { disclaimerIds: string[] }, -): KycVendorDisclaimersAccepted { - 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 (fetched.length === 0) { + return true; } - if (vendor === 'iron') { - return { ...accepted, iron: null }; + if (!accepted?.submitted) { + return false; } - return accepted; + const acceptedIds = new Set(accepted.disclaimerIds); + return fetched.every((disclaimer) => acceptedIds.has(disclaimer.id)); } diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index ae202621b1e..ededcf03d8a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `RampsController` requires the per-vendor `KycController` actions from `@metamask/kyc-controller` `^0.16.0` ([#11275](https://github.com/MetaMask/core/pull/11275)) + - `KycController:readSessionStatus`, `fetchSessionStatusOnce`, `getProviderFlowStatus`, `hasCompletedVendorDisclaimers` and `hasCompletedSessionDisclaimers` are called with `{ vendor: 'iron' }`, and `KycController:getSessionDecision` is added to `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` + - `hydrateVbaOnboarding` reads `kycStatus` from `KycController:getSessionDecision`. `VbaKycStatus` and `VbaOnboardingSnapshot` are unchanged - **BREAKING:** `NeoBankService` now extends `BaseDataService` from `@metamask/base-data-service`, so reads and writes share one circuit breaker (previously each had its own) and repeated read failures can now block writes ([#10882](https://github.com/MetaMask/core/pull/10882)) - `NeoBankServiceMessenger` now includes the `NeoBankService:invalidateQueries` action and `NeoBankService:cacheUpdated` events. Messengers built from `NeoBankServiceActions` / `NeoBankServiceEvents` pick these up automatically. - `NeoBankService.getAutorampQuote`, `getAutorampQuoteForAutoramp`, `getCustomerByExternalId`, `registerPixAddress` and `attachAutorampQuote` now return `Promise` instead of `Promise` ([#10882](https://github.com/MetaMask/core/pull/10882)) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 2eaadd5b2d9..facad281d24 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10959,6 +10959,7 @@ describe('RampsController', () => { getProviderFlowStatus: Mock; hasCompletedVendorDisclaimers: Mock; hasCompletedSessionDisclaimers: Mock; + getSessionDecision: Mock; clearState: Mock; getSessionProfile: Mock; getCustomerByExternalId: Mock; @@ -10991,6 +10992,49 @@ describe('RampsController', () => { externalUserId: CANONICAL_PROFILE_ID, }; + /** + * Mirrors `getKycSessionDecision` in `@metamask/kyc-controller`, so the + * hydrate tests keep asserting the decision for each session shape. + * + * @param session - The session the controller holds, if any. + * @param providerFlowStatus - The durable provider-flow outcome. + * @returns The session decision. + */ + const sessionDecision = ( + session: KycSession | null | undefined, + providerFlowStatus: KycValues['providerFlowStatus'], + ): string => { + if (!session) { + return 'none'; + } + if (session.finalStatus === 'approved') { + return 'approved'; + } + if ( + session.finalStatus === 'rejected' || + session.finalStatus === 'retry' + ) { + return session.finalStatus; + } + if ( + session.finalStatus === 'pending' || + session.kycStatus === 'pending' || + providerFlowStatus === 'submitted' + ) { + return 'pending'; + } + return [ + 'none', + 'new', + 'pending', + 'approved', + 'rejected', + 'retry', + ].includes(session.finalStatus) + ? session.finalStatus + : 'none'; + }; + const registerKycHandlers = ( rootMessenger: RootMessenger, overrides: Partial = {}, @@ -11020,6 +11064,19 @@ describe('RampsController', () => { hasCompletedSessionDisclaimers: vi .fn() .mockResolvedValue(values.sessionDisclaimersCompleted), + // Reads the session the KYC controller would hold: the last fetched + // status when the one-shot GET succeeded, otherwise the persisted one. + getSessionDecision: vi.fn(() => { + const fetched = handlers.fetchSessionStatusOnce.mock.settledResults + .filter((result) => result.type === 'fulfilled') + .at(-1)?.value as KycSession | undefined; + const persisted = handlers.readSessionStatus.mock.results.at(-1) + ?.value as KycSession | null | undefined; + return sessionDecision( + fetched ?? persisted, + handlers.getProviderFlowStatus() as KycValues['providerFlowStatus'], + ); + }), clearState: vi.fn(), getSessionProfile: vi .fn() @@ -11064,6 +11121,10 @@ describe('RampsController', () => { 'KycController:hasCompletedSessionDisclaimers' as never, handlers.hasCompletedSessionDisclaimers as never, ); + rootMessenger.registerActionHandler( + 'KycController:getSessionDecision' as never, + handlers.getSessionDecision as never, + ); rootMessenger.registerActionHandler( 'KycController:clearState' as never, handlers.clearState as never, @@ -11206,6 +11267,12 @@ describe('RampsController', () => { ).toStrictEqual(factsSnapshot({ kycStatus: 'new' })); expect(handlers.readSessionStatus).toHaveBeenCalledTimes(1); + expect(handlers.readSessionStatus).toHaveBeenCalledWith({ + vendor: 'iron', + }); + expect(handlers.getSessionDecision).toHaveBeenCalledWith({ + vendor: 'iron', + }); expect(handlers.fetchSessionStatusOnce).not.toHaveBeenCalled(); expect(handlers.getAutoramps).not.toHaveBeenCalled(); expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); @@ -11240,9 +11307,9 @@ describe('RampsController', () => { ); expect(handlers.fetchSessionStatusOnce).toHaveBeenCalledTimes(1); - expect(handlers.fetchSessionStatusOnce).toHaveBeenCalledWith( - 'session-1', - ); + expect(handlers.fetchSessionStatusOnce).toHaveBeenCalledWith({ + vendor: 'iron', + }); expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); expect(handlers.getAutoramps).not.toHaveBeenCalled(); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index d776a5a6ab1..5af0c2d954c 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -263,6 +263,7 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'KycController:fetchSessionStatusOnce', 'KycController:hasCompletedVendorDisclaimers', 'KycController:hasCompletedSessionDisclaimers', + 'KycController:getSessionDecision', 'KycController:clearState', 'RemoteFeatureFlagController:getState', 'UserStorageController:getState', @@ -293,6 +294,15 @@ type KycControllerSessionStatus = { externalUserId: string; }; +/** + * Identity vendors behind the KYC controller. Mirrors `KycVendor` in + * `@metamask/kyc-controller`. + */ +type KycControllerVendor = 'moonpay' | 'iron'; + +/** Identity vendor for VBA onboarding. */ +const VBA_KYC_VENDOR: KycControllerVendor = 'iron'; + /** * `finalStatus` values that end a KYC session. Mirrors * `TERMINAL_SESSION_STATUSES` in `@metamask/kyc-controller`. @@ -310,27 +320,40 @@ const TERMINAL_VBA_KYC_FINAL_STATUSES: ReadonlySet = new Set([ */ export type KycControllerReadSessionStatusAction = { type: 'KycController:readSessionStatus'; - handler: () => KycControllerSessionStatus | null; + handler: (params: { + vendor: KycControllerVendor; + }) => KycControllerSessionStatus | null; }; export type KycControllerFetchSessionStatusOnceAction = { type: 'KycController:fetchSessionStatusOnce'; - handler: (sessionId: string) => Promise; + handler: (params: { + vendor: KycControllerVendor; + }) => Promise; }; export type KycControllerGetProviderFlowStatusAction = { type: 'KycController:getProviderFlowStatus'; - handler: () => VbaProviderFlowStatus; + handler: (params: { vendor: KycControllerVendor }) => VbaProviderFlowStatus; }; export type KycControllerHasCompletedVendorDisclaimersAction = { type: 'KycController:hasCompletedVendorDisclaimers'; - handler: () => Promise; + handler: (params: { vendor: KycControllerVendor }) => Promise; }; export type KycControllerHasCompletedSessionDisclaimersAction = { type: 'KycController:hasCompletedSessionDisclaimers'; - handler: () => Promise; + handler: (params: { vendor: KycControllerVendor }) => Promise; +}; + +/** + * One verdict for a vendor's KYC session. Its values match + * {@link VbaKycStatus}. + */ +export type KycControllerGetSessionDecisionAction = { + type: 'KycController:getSessionDecision'; + handler: (params: { vendor: KycControllerVendor }) => VbaKycStatus; }; export type KycControllerClearStateAction = { @@ -453,8 +476,6 @@ const EMPTY_VBA_ONBOARDING_SNAPSHOT: VbaOnboardingSnapshot = { autorampStatus: 'not_ready', }; -const VBA_KYC_STATUS_SET = new Set(VBA_KYC_STATUSES); - type MoonpayCustomerRecord = { id: string; status: string | null; @@ -482,53 +503,6 @@ function readMoonpayCustomerRecord( }; } -/** - * Maps a vendor status string onto the snapshot vocabulary. - * - * @param value - Raw KYC status from the session. - * @returns A known {@link VbaKycStatus}, or `'none'` when missing/unknown. - */ -function toVbaKycStatus(value: string | undefined): VbaKycStatus { - if (value && VBA_KYC_STATUS_SET.has(value)) { - return value as VbaKycStatus; - } - return 'none'; -} - -/** - * Maps a KYC session onto the snapshot vocabulary. - * - * Only provider approval (`finalStatus: 'approved'`) is final. Relay approval - * (`kycStatus: 'approved'`) can represent completed document review while the - * provider is still deciding, so that state remains `pending`. A non-approved - * session whose provider flow was submitted, or whose status is pending, is - * also `pending`. - * - * @param session - Persisted or freshly fetched KYC session. - * @param providerFlowStatus - Durable provider-flow outcome. - * @returns The snapshot KYC status. - */ -function resolveVbaKycStatus( - session: KycControllerSessionStatus, - providerFlowStatus: VbaProviderFlowStatus, -): VbaKycStatus { - if (session.finalStatus === 'approved') { - return 'approved'; - } - const mapped = toVbaKycStatus(session.finalStatus); - if (mapped === 'rejected' || mapped === 'retry') { - return mapped; - } - if ( - session.finalStatus === 'pending' || - session.kycStatus === 'pending' || - providerFlowStatus === 'submitted' - ) { - return 'pending'; - } - return mapped; -} - /** * Idempotency key for a standing fiat to mUSD autoramp on Monad. * @@ -1195,6 +1169,7 @@ type AllowedActions = | KycControllerGetProviderFlowStatusAction | KycControllerHasCompletedVendorDisclaimersAction | KycControllerHasCompletedSessionDisclaimersAction + | KycControllerGetSessionDecisionAction | KycControllerClearStateAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction @@ -4313,7 +4288,11 @@ export class RampsController extends BaseController< refreshKyc: boolean; refreshAutoramps: boolean; }): Promise { - let session = this.messenger.call('KycController:readSessionStatus'); + const vendorParams = { vendor: VBA_KYC_VENDOR }; + let session = this.messenger.call( + 'KycController:readSessionStatus', + vendorParams, + ); // A persisted session can outlive the identity that created it. Reusing it // makes session-scoped calls fail closed. Discard it before any fetch, with @@ -4339,7 +4318,7 @@ export class RampsController extends BaseController< try { session = await this.messenger.call( 'KycController:fetchSessionStatusOnce', - session.id, + vendorParams, ); } catch (error) { if (error instanceof HttpError && error.httpStatus === 404) { @@ -4357,12 +4336,15 @@ export class RampsController extends BaseController< const vendorDisclaimersComplete = await this.messenger.call( 'KycController:hasCompletedVendorDisclaimers', + vendorParams, ); const sessionDisclaimersComplete = await this.messenger.call( 'KycController:hasCompletedSessionDisclaimers', + vendorParams, ); const providerFlowStatus = this.messenger.call( 'KycController:getProviderFlowStatus', + vendorParams, ); const snapshot: VbaOnboardingSnapshot = { @@ -4370,7 +4352,10 @@ export class RampsController extends BaseController< vendorDisclaimersComplete, sessionDisclaimersComplete, providerFlowStatus, - kycStatus: resolveVbaKycStatus(session, providerFlowStatus), + kycStatus: this.messenger.call( + 'KycController:getSessionDecision', + vendorParams, + ), autorampStatus: 'not_ready', };