From b0f56a72ed57e1aefdfe4bc14e1223284e2658c9 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Thu, 17 Sep 2026 10:59:55 -0700 Subject: [PATCH 1/5] Add getLatestSessionStatusForVendor to KycService --- .../src/KycService-method-action-types.ts | 19 ++++- .../kyc-controller/src/KycService.test.ts | 71 +++++++++++++++++++ packages/kyc-controller/src/KycService.ts | 50 +++++++++++++ packages/kyc-controller/src/index.ts | 2 + 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 4aba4a6f41f..56653be7a01 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -220,6 +220,22 @@ export type KycServiceGetSessionStatusAction = { handler: KycService['getSessionStatus']; }; +/** + * Fetches the latest UKYC session status for a vendor, if one exists + * (`GET /sessions/latest/status/{vendor}`). The payload matches + * {@link KycService.getSessionStatus}. Returns `null` when no session + * exists for that vendor (HTTP 404), so first-time users are not retried + * as failures. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor whose latest session to query. + * @returns The latest session status, or `null` if none exists. + */ +export type KycServiceGetLatestSessionStatusForVendorAction = { + type: `KycService:getLatestSessionStatusForVendor`; + handler: KycService['getLatestSessionStatusForVendor']; +}; + /** * Union of all KycService action types. */ @@ -238,4 +254,5 @@ export type KycServiceMethodActions = | KycServiceCreateUkycSessionAction | KycServiceSetAuthorizationsAction | KycServiceCreateJourneyAction - | KycServiceGetSessionStatusAction; + | KycServiceGetSessionStatusAction + | KycServiceGetLatestSessionStatusForVendorAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 2cf7796aeeb..3e3dcee387f 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -566,6 +566,77 @@ describe('KycService', () => { }); }); + describe('getLatestSessionStatusForVendor', () => { + it('returns the latest session status for a vendor', async () => { + const response = { + finalStatus: 'approved', + statusMessage: 'All good', + externalUserId: 'ext-1', + kycStatus: 'approved', + vendor: 'sumsub', + vendorStatus: 'GREEN', + sessionId: 'sid', + }; + nock(MOCK_API_URL) + .get('/sessions/latest/status/iron') + .reply(200, response); + const { service } = getService(); + + expect( + await service.getLatestSessionStatusForVendor({ vendor: 'iron' }), + ).toStrictEqual(response); + }); + + it('url-encodes the vendor', async () => { + const response = { + finalStatus: 'pending', + externalUserId: 'ext-1', + kycStatus: 'pending', + vendor: 'sumsub', + vendorStatus: 'YELLOW', + }; + nock(MOCK_API_URL) + .get('/sessions/latest/status/moonpay') + .reply(200, response); + const { service } = getService(); + + expect( + await service.getLatestSessionStatusForVendor({ vendor: 'moonpay' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/sessions/latest/status/iron') + .reply(200, { finalStatus: 'approved' }); + const { service } = getService(); + + await expect( + service.getLatestSessionStatusForVendor({ vendor: 'iron' }), + ).rejects.toThrow( + /Malformed response received from latest session status API/u, + ); + }); + + it('throws an HttpError on a non-ok response other than 404', async () => { + nock(MOCK_API_URL).get('/sessions/latest/status/iron').reply(500); + const { service } = getService(); + + await expect( + service.getLatestSessionStatusForVendor({ vendor: 'iron' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + + it('returns null when no session exists for the vendor', async () => { + nock(MOCK_API_URL).get('/sessions/latest/status/iron').reply(404); + const { service } = getService(); + + expect( + await service.getLatestSessionStatusForVendor({ vendor: 'iron' }), + ).toBeNull(); + }); + }); + describe('createVendorCustomer', () => { it('creates an Iron customer and returns the validated subset', async () => { nock(MOCK_API_URL) diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index ec89041c845..57abd53fea0 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -61,6 +61,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'setAuthorizations', 'createJourney', 'getSessionStatus', + 'getLatestSessionStatusForVendor', ] as const; /** @@ -227,6 +228,7 @@ const SessionStatusResponseStruct = type({ kycStatus: string(), vendor: string(), vendorStatus: string(), + sessionId: optional(string()), }); // Vendor customer subset — `type` (not `object`) keeps extra vendor fields from @@ -376,6 +378,11 @@ export type GetSessionStatusParams = { sessionId: string; }; +export type GetLatestSessionStatusForVendorParams = { + /** Identity vendor whose latest UKYC session should be queried. */ + vendor: KycVendor; +}; + // === SERVICE DEFINITION === /** @@ -939,6 +946,49 @@ export class KycService extends BaseDataService< ); } + /** + * Fetches the latest UKYC session status for a vendor, if one exists + * (`GET /sessions/latest/status/{vendor}`). The payload matches + * {@link KycService.getSessionStatus}. Returns `null` when no session + * exists for that vendor (HTTP 404), so first-time users are not retried + * as failures. + * + * @param params - The parameters. + * @param params.vendor - Identity vendor whose latest session to query. + * @returns The latest session status, or `null` if none exists. + */ + async getLatestSessionStatusForVendor( + params: GetLatestSessionStatusForVendorParams, + ): Promise { + const url = new URL( + `/sessions/latest/status/${encodeURIComponent(params.vendor)}`, + this.#baseUrl, + ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getLatestSessionStatusForVendor`, params.vendor], + queryFn: async () => { + try { + return await this.#requestJson(url, { method: 'GET' }); + } catch (error) { + if (error instanceof HttpError && error.httpStatus === 404) { + return null; + } + throw error; + } + }, + staleTime: 0, + gcTime: 0, + }); + if (data === null) { + return null; + } + return this.#validateResponse( + data, + SessionStatusResponseStruct, + 'latest session status', + ); + } + /** * Validates a parsed API response against a superstruct schema, throwing a * descriptive error when the response does not match. diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 016037085e9..b6c333af8f7 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -48,6 +48,7 @@ export type { EncryptionSchema, FetchSessionDisclaimersByCountryParams, FetchSessionDisclaimersBySessionIdParams, + GetLatestSessionStatusForVendorParams, GetSessionStatusParams, VendorCustomerResponse, JwksResponse, @@ -75,6 +76,7 @@ export type { KycServiceFetchSessionDisclaimersBySessionIdAction, KycServiceFetchVendorDisclaimersAction, KycServiceGetGeoCountryAction, + KycServiceGetLatestSessionStatusForVendorAction, KycServiceGetSessionStatusAction, KycServiceSetAuthorizationsAction, KycServiceSubmitSessionDisclaimersAction, From 5000009f1f3ac20064fb04f99152868ae0b942b2 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Thu, 17 Sep 2026 11:30:23 -0700 Subject: [PATCH 2/5] finalStatus enum updated --- packages/kyc-controller/src/KycService.ts | 9 ++++++++- packages/kyc-controller/src/types.ts | 14 +++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 57abd53fea0..1fbb3f034fa 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -14,6 +14,7 @@ import { array, assert, boolean, + enums, optional, string, StructError, @@ -222,7 +223,13 @@ export type ApplicantAccessTokenResponse = Infer< >; const SessionStatusResponseStruct = type({ - finalStatus: string(), + finalStatus: enums([ + 'new', + 'pending', + 'approved', + 'rejected', + 'retry', + ] as const), statusMessage: optional(string()), externalUserId: string(), kycStatus: string(), diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 81ee2b9e676..98bd768fe73 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -44,8 +44,8 @@ export type KycCustomerIdentity = { * - `new` — no session decision yet (including never started). * - `pending` — submitted or in review; keep polling. * - `approved` — verification succeeded. - * - `rejected` — terminal failure (`rejected`, `failed`, `blocked`, …). - * - `retry` — the applicant must resubmit (`retry`). + * - `rejected` — terminal failure. + * - `retry` — the applicant must resubmit. */ export type KycSessionStatus = | 'new' @@ -147,16 +147,16 @@ export type KycSumSubSdkStatus = */ export type KycSessionStatusResponse = { /** - * The overall status of the session. Terminal values (e.g. `approved`, - * `completed`, `rejected`, `failed`, `blocked`, `retry`) end polling; any - * other value keeps polling. + * Overall session status. Terminal values (`approved`, `rejected`, `retry`) + * end polling; `new` and `pending` keep polling. Controller decisions use + * this field only — not `kycStatus`. */ - finalStatus: string; + finalStatus: KycSessionStatus; /** Optional human-readable message describing the status. */ statusMessage?: string; /** The vendor-agnostic external user id associated with the session. */ externalUserId: string; - /** The KYC decision status. */ + /** Echoed KYC decision from the API. Not used for controller decisions. */ kycStatus: string; /** The identity vendor that handled the session. */ vendor: string; From 97634bad4b95720886c59e886cc1fde78acca4b8 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Thu, 17 Sep 2026 13:43:54 -0700 Subject: [PATCH 3/5] Fix status type --- packages/kyc-controller/src/KycController.ts | 24 +++++++------------ .../kyc-controller/src/KycService.test.ts | 2 +- packages/kyc-controller/src/KycService.ts | 2 +- packages/kyc-controller/src/types.ts | 19 ++++++++------- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index cf531faef4f..a7baf696fde 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -136,36 +136,28 @@ const IN_PROGRESS_PHASES: KycPhase[] = [ // until a terminal status is reached. Overridable via the constructor. const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000; -// UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus` -// (the vendor-side outcome) draw from the same vocabulary, so they are defined -// once here and composed into the sets/checks below rather than repeated as -// literals. +// `finalStatus` values from `GET /sessions/{id}/status`. Controller decisions +// use this field only. const KYC_STATUSES = { + new: 'new', + pending: 'pending', approved: 'approved', - completed: 'completed', rejected: 'rejected', - failed: 'failed', - blocked: 'blocked', - pending: 'pending', retry: 'retry', } as const; -// `finalStatus` values that end the polling loop. Anything else (e.g. -// `KYC_STATUSES.pending`) keeps polling. +// `finalStatus` values that end the polling loop. `new` and `pending` keep +// polling. const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ KYC_STATUSES.approved, - KYC_STATUSES.completed, KYC_STATUSES.rejected, - KYC_STATUSES.failed, - KYC_STATUSES.blocked, KYC_STATUSES.retry, ]); -// Terminal `finalStatus` values that represent a successful verification. Any -// other terminal status resolves the sub-flow to `failed`. +// Terminal `finalStatus` that represents a successful verification. Any other +// terminal status resolves the sub-flow to `failed`. const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ KYC_STATUSES.approved, - KYC_STATUSES.completed, ]); /** diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 3e3dcee387f..225a0d1d28c 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -575,7 +575,7 @@ describe('KycService', () => { kycStatus: 'approved', vendor: 'sumsub', vendorStatus: 'GREEN', - sessionId: 'sid', + id: 'sid', }; nock(MOCK_API_URL) .get('/sessions/latest/status/iron') diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 1fbb3f034fa..fec7ada290b 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -235,7 +235,7 @@ const SessionStatusResponseStruct = type({ kycStatus: string(), vendor: string(), vendorStatus: string(), - sessionId: optional(string()), + id: optional(string()), }); // Vendor customer subset — `type` (not `object`) keeps extra vendor fields from diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 98bd768fe73..d033f33fb1b 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -90,11 +90,7 @@ export type KycPhase = * - `polling` — the SDK finished and the controller is polling the UKYC * backend for the session's final decision (see * {@link KycSessionStatusResponse}). The sub-flow resolves to `complete` or - * `failed` once a terminal status arrives. - * - `vendorProcessing` — session creation reported that the applicant is - * already approved on the relay (`kycStatus`) while the vendor is still - * finalizing its own decision (`finalStatus`). There is nothing left for the - * applicant to do, so the SDK is not launched; see `statusMessage`. + * `failed` once a terminal `finalStatus` arrives. * - `abandoned` — the applicant closed the SDK before submitting. Unlike * `failed`, nothing went wrong, so `error` is left unset and consumers should * offer a retry rather than report a problem. @@ -108,8 +104,7 @@ export type KycSumSubStatus = | 'polling' | 'complete' | 'abandoned' - | 'failed' - | 'vendorProcessing'; + | 'failed'; /** * Status strings a SumSub SDK reports, through either the status-change @@ -140,8 +135,9 @@ export type KycSumSubSdkStatus = | 'Completed'; /** - * The UKYC session status payload returned by `GET /sessions/{id}/status` - * (and `POST /sessions/{id}/authorizations`). Distinct from + * The UKYC session status payload returned by `GET /sessions/{id}/status`, + * `GET /sessions/latest/status/{vendor}`, and + * `POST /sessions/{id}/authorizations`. Distinct from * {@link KycSessionStatus}, the simplified value derived from * `sessionStatus.finalStatus`. */ @@ -162,6 +158,11 @@ export type KycSessionStatusResponse = { vendor: string; /** The vendor-specific status. */ vendorStatus: string; + /** + * The UKYC session id. Present on `GET /sessions/latest/status/{vendor}` + * so an existing session can be resumed without creating another. + */ + id?: string; }; /** From f958e7ba0a3c69234c3072dd1bd3d5f6fbffeb78 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Thu, 17 Sep 2026 13:45:37 -0700 Subject: [PATCH 4/5] persist sessionStatus --- packages/kyc-controller/src/KycController.ts | 28 ++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index a7baf696fde..7e27a24e7bd 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -424,7 +424,7 @@ const kycControllerMetadata = { sessionStatus: { includeInDebugSnapshot: false, includeInStateLogs: false, - persist: false, + persist: true, usedInUi: true, }, sumsub: { @@ -611,6 +611,20 @@ function usesConsentsFlow(vendor: KycVendor): boolean { return vendor !== 'moonpay'; } +/** + * Drops the UKYC session id and its status together. `sessionStatus` is only + * meaningful for the current `sessionId`. + * + * @param state - Controller state to update. + */ +function clearUkycSession(state: { + sessionId: string | null; + sessionStatus: KycSessionStatusResponse | null; +}): void { + state.sessionId = null; + state.sessionStatus = null; +} + /** * Parameters for {@link KycController.fetchSessionDisclaimers}. Provide * exactly one of `sessionId` or `country`. @@ -1383,8 +1397,7 @@ export class KycController extends BaseController< state.sessionDisclaimers = null; // Session create ran before recording disclaimers. Drop the leftover // UKYC session so a later `startSumSub` cannot skip consent recording. - state.sessionId = null; - state.sessionStatus = null; + clearUkycSession(state); state.sumsub = { ...getDefaultKycControllerState().sumsub }; if (keepSumSubStatus) { state.sumsub.status = keepSumSubStatus; @@ -2207,7 +2220,7 @@ export class KycController extends BaseController< * polling while the status is not terminal. * * Throws without an active `sessionId`. Skipped when the recorded - * {@link sessionStatus} is already successful (`approved` / `completed`): a + * {@link sessionStatus} is already successful (`approved`): a * follow-up session status can still read a stale `pending` (for example * after `session_not_in_valid_state`) and must not undo that decision. * @@ -2388,8 +2401,8 @@ export class KycController extends BaseController< * Writes a fetched UKYC session status onto state and publishes * {@link KycControllerStatusChangedEvent} when `finalStatus` changes. * Optionally resolves `sumsub.status` when `finalStatus` is terminal — used - * by the post-SDK poll, not by a one-off refresh, so an abandoned / failed / - * vendor-processing sub-flow is not overwritten. + * by the post-SDK poll, not by a one-off refresh, so an abandoned / failed + * sub-flow is not overwritten. * * @param sessionStatus - Status from `GET /sessions/{id}/status`. * @param options - Recording options. @@ -2498,8 +2511,7 @@ export class KycController extends BaseController< clearMoonPaySession(state); state.activeVendor = 'moonpay'; state.activeProduct = null; - state.sessionId = null; - state.sessionStatus = null; + clearUkycSession(state); state.sumsub = { status: 'idle', result: null, From be070de68fec932fde3d0485060f5669fd39f854 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Thu, 17 Sep 2026 13:48:29 -0700 Subject: [PATCH 5/5] reuse existing session if available --- packages/kyc-controller/ARCHITECTURE.md | 48 +- packages/kyc-controller/CHANGELOG.md | 10 +- .../src/KycController-method-action-types.ts | 24 +- .../kyc-controller/src/KycController.test.ts | 624 +++++++++++++++++- packages/kyc-controller/src/KycController.ts | 234 +++++-- 5 files changed, 817 insertions(+), 123 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index e177fccf5f0..c21ceab733e 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -124,7 +124,7 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): `getGeoCountry`, `fetchVendorDisclaimers`, `createSession`, `checkKycRequired`, `createVendorCustomer`, `submitVendorDisclaimers`, `fetchSessionDisclaimersByCountry`, `fetchSessionDisclaimersBySessionId`, `submitSessionDisclaimers`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`, `createUkycSession`, `setAuthorizations`, -`createJourney`, `getSessionStatus`. +`createJourney`, `getSessionStatus`, `getLatestSessionStatusForVendor`. Endpoints: @@ -145,6 +145,7 @@ Endpoints: | `setAuthorizations` | `POST` | `/sessions/{id}/authorizations` | Submit wrapped `data_encryption_key` and wrapped `ukyc_capability_token` | | `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | | `getSessionStatus` | `GET` | `/sessions/{id}/status` | UKYC session status payload (`KycSessionStatusResponse`; stored on `sessionStatus`) | +| `getLatestSessionStatusForVendor` | `GET` | `/sessions/latest/status/{vendor}` | Same payload as `getSessionStatus`, or `null` when no session exists (HTTP 404) | ### 2.3 `crypto.ts` @@ -188,7 +189,7 @@ classDiagram +Record kycRequiredByProduct [persisted] +string lastCheckedAt [persisted] +string sessionId [persisted] - +KycSessionStatusResponse sessionStatus + +KycSessionStatusResponse sessionStatus [persisted] +SumSubState sumsub } class SumSubState { @@ -207,9 +208,11 @@ State metadata highlights (`kycControllerMetadata`): - **Persisted** (`persist: true`): `vendorDisclaimersAccepted`, `providerDisclaimersAccepted`, `idosDisclaimersAccepted`, - `kycRequiredByProduct`, `lastCheckedAt`, `sessionId`. These survive restarts - so the flow can skip already-accepted terms, reuse cached results, and - resume session-status refresh. Session-scoped `sessionDisclaimers` and + `kycRequiredByProduct`, `lastCheckedAt`, `sessionId`, `sessionStatus`. These + survive restarts so the flow can skip already-accepted terms, reuse cached + results, and resume session-status refresh. `sessionStatus` is always + cleared when `sessionId` is cleared (`reset`, consents rewind). + Session-scoped `sessionDisclaimers` and `credentialReusabilityConsentGiven` are in-memory only (`persist: false`) and are cleared on `reset()`. Acceptance is vendor-scoped: `initialize` (and `createVendorCustomer`) drops @@ -285,6 +288,15 @@ stateDiagram-v2 > `kycProvider` document records; `credentialReusabilityConsentGiven` is > forwarded as well (defaults to `false`). +> **`initialize` hydrates any existing UKYC session for the vendor when no +> `sessionId` is already on state.** After resolving geolocation, `initialize` +> calls `GET /sessions/latest/status/{vendor}`. A 404 continues as a first-time +> flow. Any existing session is reused (`sessionId` / `sessionStatus`). When +> `finalStatus` is already `approved`, `phase` goes to `done` and the rest of +> initialize (vendor customer, terms, MoonPay session, consents) is skipped. +> A non-404 lookup error fails the flow. A persisted `sessionId` skips this +> lookup; a persisted approved `sessionStatus` still finishes at `done`. +> > **`initialize` and `createVendorCustomer` never tear down an active flow.** If > `phase` is already one of the in-progress phases (`session`, `check`, `auth`, > `form`, `submit`), a repeat `initialize` or `createVendorCustomer` is a @@ -343,6 +355,10 @@ sequenceDiagram Ctrl->>Svc: getGeoCountry() Svc->>Geo: getGeolocation() Note over Svc: map alpha-2 → alpha-3 locally + Note over Ctrl: skipped when sessionId already set + Ctrl->>Svc: getLatestSessionStatusForVendor({ vendor }) + Svc->>API: GET /sessions/latest/status/{vendor} + Note over Ctrl: 404 → first-time flow;
approved → phase = done Ctrl->>Svc: fetchVendorDisclaimers({ country }) Svc->>API: GET /vendors/moonpay/disclaimers?country= Ctrl-->>UI: phase = terms (+ vendorDisclaimers) @@ -378,11 +394,15 @@ sequenceDiagram Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) opt kycRequired === true → auto-launch document verification - Ctrl->>Svc: createUkycSession({ jwtToken, sessionClientPublicKey, residenceCountry, vendorMetadata }) - Svc->>API: POST /sessions - Note over Ctrl: verify encryptionDataKey vs idOS enclave JWKS,
ukycCapabilityToken vs idOS relay JWKS;
wrap data_encryption_key and ukyc_capability_token - Ctrl->>Svc: setAuthorizations({ sessionId, wrappedEncryptionDataKey, wrappedUkycCapabilityToken }) - Svc->>API: POST /sessions/{id}/authorizations + Ctrl->>Svc: getLatestSessionStatusForVendor({ vendor }) + Svc->>API: GET /sessions/latest/status/{vendor} + alt no existing session for vendor + Ctrl->>Svc: createUkycSession({ jwtToken, sessionClientPublicKey, residenceCountry, vendorMetadata }) + Svc->>API: POST /sessions + Note over Ctrl: verify encryptionDataKey vs idOS enclave JWKS,
ukycCapabilityToken vs idOS relay JWKS;
wrap data_encryption_key and ukyc_capability_token + Ctrl->>Svc: setAuthorizations({ sessionId, wrappedEncryptionDataKey, wrappedUkycCapabilityToken }) + Svc->>API: POST /sessions/{id}/authorizations + end Ctrl->>Svc: createJourney(sessionId) Svc->>API: POST /sessions/{id}/journey Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) @@ -471,7 +491,6 @@ stateDiagram-v2 [*] --> idle idle --> creatingSession : startSumSub() creatingSession --> fetchingToken : setAuthorizations() ok - creatingSession --> vendorProcessing : setAuthorizations() kycStatus=approved, finalStatus=pending fetchingToken --> launching : createJourney() ok launching --> inProgress : onStatusChange (non-Completed) launching --> complete : onStatusChange = Completed @@ -483,13 +502,6 @@ stateDiagram-v2 launching --> failed : launcher unavailable / error ``` -> **Already processing on the vendor.** A user who already finished the journey -> can return to a session the relay has approved (`kycStatus: approved`) while -> the vendor is still finalizing its decision (`finalStatus: pending`). When -> authorizations report this, the sub-flow stops at `vendorProcessing` -> (setting `statusMessage`) instead of launching the SDK, so an already-approved -> applicant is not asked to verify again. - > **Completion is status-driven, not resolution-driven.** A resolved `launch` > is only recorded as `complete` when the SDK reported the `Completed` status > via `onStatusChange` at least once. If `launch` resolves without ever having diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 7eca53b2508..f1435fe760a 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -7,14 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `KycService.getLatestSessionStatusForVendor` (`GET /sessions/latest/status/{vendor}`), which returns the same payload as `getSessionStatus` or `null` when no session exists for that vendor (HTTP 404). `KycController.initialize` (when no `sessionId` is on state) and UKYC session creation check this and always reuse that session when one exists — a vendor cannot have more than one. The reused `sessionStatus` is recorded for any `finalStatus`, not only `approved`. An already-`approved` latest session finishes `initialize` at `done`. + ### Changed +- **BREAKING:** Drive KYC session decisions from `sessionStatus.finalStatus` only (`new` | `pending` | `approved` | `rejected` | `retry`). `kycStatus` is stored but ignored. + - Terminal values are `approved`, `rejected`, and `retry`. `approved` is the only successful status (`completed` is no longer treated as success). - **BREAKING:** Move the active UKYC `sessionId` and `sessionStatus` from `sumsub` to the root of `KycControllerState`. ([#10276](https://github.com/MetaMask/core/pull/10276)) - - Read `state.sessionId` / `state.sessionStatus` instead of `state.sumsub.sessionId` / `state.sumsub.sessionStatus`. `sessionId` is persisted; `sessionStatus` is not. + - Read `state.sessionId` / `state.sessionStatus` instead of `state.sumsub.sessionId` / `state.sumsub.sessionStatus`. Both are persisted. Clearing `sessionId` also clears `sessionStatus`. - **BREAKING:** `KycController.refreshKycStatus` now loads status from `GET /sessions/{id}/status` (`getSessionStatus`) instead of `GET /kyc/status`. ([#10276](https://github.com/MetaMask/core/pull/10276)) - Requires an active `sessionId` (throws if missing). Returns and publishes the UKYC `sessionStatus` payload as-is (`null` when none is recorded). - **BREAKING:** Replace `KycUserStatus` with `KycSessionStatus` (`new` | `pending` | `approved` | `rejected` | `retry`). ([#10276](https://github.com/MetaMask/core/pull/10276)) - **BREAKING:** Rename the `GET /sessions/{id}/status` payload type from `KycSessionStatus` to `KycSessionStatusResponse`. ([#10276](https://github.com/MetaMask/core/pull/10276)) + - The session id on that payload is `id`, not `sessionId`. Controller state still uses `sessionId`. - **BREAKING:** Remove `userStatus`, `userStatusSumsubSessionId`, and `userStatusErrorCode` from `KycControllerState`. ([#10276](https://github.com/MetaMask/core/pull/10276)) - Read `state.sessionStatus`, or use `refreshKycStatus` / `KycController:statusChanged`. - **BREAKING:** Combine the session-status and user-status poll loops onto one timer. ([#10276](https://github.com/MetaMask/core/pull/10276)) @@ -23,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **BREAKING:** Remove `vendorProcessing` from `KycSumSubStatus`. A pending `finalStatus` continues into the SumSub SDK instead of short-circuiting on `kycStatus: approved`. - **BREAKING:** Remove `KycService.fetchKycStatus` and the `KycService:fetchKycStatus` messenger action. ([#10276](https://github.com/MetaMask/core/pull/10276)) - **BREAKING:** Remove `KycControllerOptions.userStatusPollIntervalMs`. Use `sessionStatusPollIntervalMs` instead. ([#10276](https://github.com/MetaMask/core/pull/10276)) - **BREAKING:** Remove `KycUserStatusResponse`. Use `KycControllerStatusChangedEvent` / `refreshKycStatus`'s return payload instead. ([#10276](https://github.com/MetaMask/core/pull/10276)) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 1b3e246fe40..8a9f00f05bd 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -6,8 +6,15 @@ import type { KycController } from './KycController.js'; /** - * Resolves persisted terms + geolocation, and auto-creates a session when - * terms are already accepted and an email is available. + * Resolves persisted terms + geolocation, hydrates any existing UKYC + * session for the vendor, and auto-creates a session when terms are already + * accepted and an email is available. + * + * Looks up `GET /sessions/latest/status/{vendor}` after capturing the vendor + * when no `sessionId` is already on state. When a session exists it is reused + * (`sessionId` / `sessionStatus`). When `finalStatus` is already `approved` + * (including a persisted `sessionStatus`), the flow finishes at `done` + * instead of creating a customer or session. * * @param params - Optional parameters. * @param params.email - The account email to associate with the session. @@ -211,13 +218,12 @@ export type KycControllerGetCustomerIdentityAction = { * 5. fetches the SumSub applicant access token; and * 6. presents the SDK via the injected launcher. * - * If a UKYC session already exists (the consents path creates it before - * recording session disclaimers), steps 1–4 are skipped. + * If a UKYC session already exists for the vendor (`GET + * /sessions/latest/status/{vendor}`), or `sessionId` is already on state, + * steps 1–4 are skipped. A vendor cannot have more than one session. * - * If authorizations report the applicant is already approved on the relay - * while the vendor is still finalizing (`kycStatus: approved`, - * `finalStatus: pending`), the sub-flow stops at step 4 with a - * `vendorProcessing` status and a message rather than launching the SDK. + * If the existing session's `finalStatus` is already `approved`, the SDK is + * not launched. * * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. @@ -236,7 +242,7 @@ export type KycControllerStartSumSubAction = { * polling while the status is not terminal. * * Throws without an active `sessionId`. Skipped when the recorded - * session status is already successful (`approved` / `completed`): a + * {@link sessionStatus} is already successful (`approved`): a * follow-up session status can still read a stale `pending` (for example * after `session_not_in_valid_state`) and must not undo that decision. * diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index c97422ed32b..ace1c277966 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -182,10 +182,10 @@ describe('KycController', () => { ); }); - it('persists sessionId across restarts', async () => { + it('persists sessionId and sessionStatus across restarts', async () => { await withController(({ controller }) => { expect(controller.metadata.sessionId.persist).toBe(true); - expect(controller.metadata.sessionStatus.persist).toBe(false); + expect(controller.metadata.sessionStatus.persist).toBe(true); }); }); }); @@ -419,6 +419,216 @@ describe('KycController', () => { }, ); }); + + it('records an existing UKYC session for the vendor', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('pending'), + id: 'existing-sid', + }); + + await controller.initialize({ vendor: 'iron', email: 'a@b.co' }); + + expect(handlers.getLatestSessionStatusForVendor).toHaveBeenCalledWith({ + vendor: 'iron', + }); + expect(controller.state.sessionId).toBe('existing-sid'); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); + expect(controller.state.phase).toBe('terms'); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + }); + }); + + it('finishes when the vendor already has an approved session', async () => { + await withController( + { + options: { + state: { + ...VENDOR_TERMS_MOONPAY, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'done-sid', + }); + + await controller.initialize({ email: 'a@b.co', product: 'ramps' }); + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); + expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); + expect(controller.state.sessionId).toBe('done-sid'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.statusMessage).toBe('KYC already completed.'); + }, + ); + }); + + it('does not look up a latest session when a sessionId is already on state', async () => { + await withController( + { + options: { + state: { + sessionId: 'persisted-sid', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'other-sid', + }); + + await controller.initialize({ email: 'a@b.co' }); + + expect(handlers.getLatestSessionStatusForVendor).not.toHaveBeenCalled(); + expect(controller.state.sessionId).toBe('persisted-sid'); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('finishes from a persisted approved sessionStatus without a latest-session lookup', async () => { + await withController( + { + options: { + state: { + sessionId: 'persisted-sid', + sessionStatus: { + ...sessionStatus('approved'), + id: 'persisted-sid', + }, + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ email: 'a@b.co', product: 'ramps' }); + + expect(handlers.getLatestSessionStatusForVendor).not.toHaveBeenCalled(); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.sessionId).toBe('persisted-sid'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.statusMessage).toBe('KYC already completed.'); + }, + ); + }); + + it('does not look up a latest session when a flow is already in progress', async () => { + await withController( + { + options: { + state: { + phase: 'check', + moonpaySessionToken: 'live-session', + activeVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ vendor: 'moonpay' }); + + expect(handlers.getLatestSessionStatusForVendor).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not keep latest-session state when reset() lands during the write', async () => { + await withController( + async ({ controller, handlers, rootMessenger }) => { + rootMessenger.subscribe('KycController:stateChange', () => { + if (controller.state.sessionId === 'done-sid') { + controller.reset(); + } + }); + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'done-sid', + }); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionId).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); + expect(handlers.createSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('does not keep latest-session state when reset() lands during lookup', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + let release: (value: null) => void = () => { + // Replaced synchronously by the promise executor below. + }; + handlers.getLatestSessionStatusForVendor.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize({ vendor: 'iron' }); + while (handlers.getLatestSessionStatusForVendor.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + release(null); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionId).toBeNull(); + expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); + }); + }); + + it('fails initialize when latest-session lookup fails with a non-404 error', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getLatestSessionStatusForVendor.mockRejectedValue( + new HttpError(500, "Fetching latest status failed with status '500'"), + ); + + await controller.initialize({ vendor: 'iron', email: 'a@b.co' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/status '500'/u); + expect(handlers.createVendorCustomer).not.toHaveBeenCalled(); + }); + }); + + it('does not fail initialize when latest-session lookup rejects after reset', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + let rejectLookup: (error: Error) => void = () => undefined; + handlers.getLatestSessionStatusForVendor.mockReturnValue( + new Promise((_resolve, reject) => { + rejectLookup = reject; + }), + ); + + const pending = controller.initialize({ + vendor: 'iron', + email: 'a@b.co', + }); + while (handlers.getLatestSessionStatusForVendor.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + rejectLookup( + new HttpError(500, "Fetching latest status failed with status '500'"), + ); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); }); describe('loadDisclaimers', () => { @@ -1669,34 +1879,269 @@ describe('KycController', () => { }); }); - it('stops with a vendorProcessing status when the relay approved but the vendor is still pending', async () => { + it('continues to the SDK when authorizations report pending', async () => { await withController(async ({ controller, handlers, launcher }) => { - // The applicant already finished the journey: the relay reports - // `approved` while the vendor is still finalizing (`pending`). handlers.setAuthorizations.mockResolvedValue({ ...sessionStatus('pending'), kycStatus: 'approved', finalStatus: 'pending', }); + await controller.startSumSub(); + + expect(handlers.createJourney).toHaveBeenCalled(); + expect(launcher.launch).toHaveBeenCalled(); + expect(controller.state.sessionId).toBe('sid'); + }); + }); + + it('creates a UKYC session when the latest-status lookup finds none', async () => { + await withController(async ({ controller, handlers }) => { + await controller.startSumSub(); + + expect(handlers.getLatestSessionStatusForVendor).toHaveBeenCalledWith({ + vendor: 'moonpay', + }); + expect(handlers.createUkycSession).toHaveBeenCalledTimes(1); + }); + }); + + it('reuses an in-progress session instead of creating a new one', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('pending'), + id: 'existing-sid', + }); + + await controller.startSumSub(); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.setAuthorizations).not.toHaveBeenCalled(); + expect(handlers.createJourney).toHaveBeenCalledWith('existing-sid'); + expect(controller.state.sessionId).toBe('existing-sid'); + expect(controller.state.sessionStatus).toStrictEqual({ + ...sessionStatus('pending'), + id: 'existing-sid', + }); + expect(launcher.launch).toHaveBeenCalled(); + }); + }); + + it('skips the SDK when the latest session is already approved', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'done-sid', + }); + const result = await controller.startSumSub(); expect(result).toStrictEqual({ + finalStatus: 'approved', + }); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sessionId).toBe('done-sid'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + }); + }); + + it('skips the SDK when the latest session is approved without a session id', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue( + sessionStatus('approved'), + ); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ + finalStatus: 'approved', + }); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sessionId).toBeNull(); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + }); + }); + + it('reuses a pending session and continues to the SDK', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('pending'), kycStatus: 'approved', finalStatus: 'pending', + id: 'vp-sid', }); - expect(controller.state.sumsub.status).toBe('vendorProcessing'); - expect(controller.state.sessionId).toBe('sid'); - expect(controller.state.statusMessage).toMatch( - /being processed by the vendor/u, + + await controller.startSumSub(); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).toHaveBeenCalledWith('vp-sid'); + expect(launcher.launch).toHaveBeenCalled(); + expect(controller.state.sessionId).toBe('vp-sid'); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); + }); + }); + + it('reuses a rejected session instead of creating a new one', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('rejected'), + id: 'rejected-sid', + }); + + await controller.startSumSub(); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).toHaveBeenCalledWith('rejected-sid'); + expect(controller.state.sessionId).toBe('rejected-sid'); + expect(controller.state.sessionStatus?.finalStatus).toBe('rejected'); + expect(launcher.launch).toHaveBeenCalled(); + }); + }); + + it('reuses a pending session that has no session id in the payload', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('pending'), + kycStatus: 'approved', + finalStatus: 'pending', + }); + + await controller.startSumSub(); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).toHaveBeenCalledWith(''); + expect(launcher.launch).toHaveBeenCalled(); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); + }); + }); + + it('skips the SDK when a persisted session is already approved', async () => { + await withController( + { options: { state: { sessionId: 'persisted-sid' } } }, + async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'persisted-sid', + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ + finalStatus: 'approved', + }); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + }, + ); + }); + + it('reuses a persisted session when the latest status is rejected', async () => { + await withController( + { options: { state: { sessionId: 'old-sid' } } }, + async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue( + sessionStatus('rejected'), + ); + + await controller.startSumSub(); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).toHaveBeenCalledWith('old-sid'); + expect(controller.state.sessionId).toBe('old-sid'); + expect(controller.state.sessionStatus?.finalStatus).toBe('rejected'); + expect(launcher.launch).toHaveBeenCalled(); + }, + ); + }); + + it('uses a persisted session id when the vendor has no latest session payload', async () => { + await withController( + { options: { state: { sessionId: 'persisted-sid' } } }, + async ({ controller, handlers, launcher }) => { + await controller.startSumSub(); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.createJourney).toHaveBeenCalledWith('persisted-sid'); + expect(controller.state.sessionId).toBe('persisted-sid'); + expect(launcher.launch).toHaveBeenCalled(); + }, + ); + }); + + it('marks the sub-flow failed when latest-status lookup fails with a non-404 error', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockRejectedValue( + new HttpError(500, "Fetching latest status failed with status '500'"), ); - // The SDK is never launched and no journey is created for an - // already-approved applicant. - expect(handlers.createJourney).not.toHaveBeenCalled(); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ + error: expect.stringContaining("status '500'"), + }); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('failed'); }); }); + it('does not create a UKYC session when reset() runs during latest-status lookup', async () => { + await withController(async ({ controller, handlers, launcher }) => { + let release: (value: null) => void = () => { + // Replaced synchronously by the promise executor below. + }; + handlers.getLatestSessionStatusForVendor.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.startSumSub(); + while (handlers.getLatestSessionStatusForVendor.mock.calls.length === 0) { + await Promise.resolve(); + } + controller.reset(); + release(null); + const result = await pending; + + expect(result).toStrictEqual({}); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + }); + }); + + it('does not keep reused session state when reset() lands during the write', async () => { + await withController( + async ({ controller, handlers, launcher, rootMessenger }) => { + rootMessenger.subscribe('KycController:stateChange', () => { + if (controller.state.sessionId === 'done-sid') { + controller.reset(); + } + }); + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'done-sid', + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionId).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); + }, + ); + }); + it('continues the flow when approved and the vendor is not pending', async () => { await withController(async ({ controller, handlers, launcher }) => { // A terminal vendor status (not `pending`) must not short-circuit. @@ -1718,7 +2163,7 @@ describe('KycController', () => { }); }); - it('does not write vendorProcessing state when reset() runs while creating the session', async () => { + it('does not write session state when reset() runs while creating the session', async () => { await withController(async ({ controller, handlers, launcher }) => { handlers.createUkycSession.mockImplementation(async () => { controller.reset(); @@ -1767,7 +2212,7 @@ describe('KycController', () => { }); }); - it('does not write vendorProcessing state when reset() runs while setting authorizations', async () => { + it('does not keep session state when reset() runs while setting authorizations', async () => { await withController(async ({ controller, handlers, launcher }) => { handlers.setAuthorizations.mockImplementation(async () => { controller.reset(); @@ -2491,6 +2936,8 @@ describe('KycController', () => { activeProduct: 'ramps', ...VENDOR_TERMS_MOONPAY, kycRequiredByProduct: { ramps: true }, + sessionId: 'sid', + sessionStatus: sessionStatus('pending'), }, }, }, @@ -2500,6 +2947,8 @@ describe('KycController', () => { expect(controller.state.moonpaySessionToken).toBeNull(); expect(controller.state.moonpayAccessToken).toBeNull(); expect(controller.state.activeProduct).toBeNull(); + expect(controller.state.sessionId).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); expect( controller.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt, ).toBe('t'); @@ -3221,6 +3670,11 @@ describe('KycController', () => { expect(handlers.createUkycSession).toHaveBeenCalledTimes(1); expect( handlers.submitVendorDisclaimers.mock.invocationCallOrder[0], + ).toBeLessThan( + handlers.getLatestSessionStatusForVendor.mock.invocationCallOrder[0], + ); + expect( + handlers.getLatestSessionStatusForVendor.mock.invocationCallOrder[0], ).toBeLessThan( handlers.createUkycSession.mock.invocationCallOrder[0], ); @@ -3230,6 +3684,9 @@ describe('KycController', () => { handlers.fetchSessionDisclaimersBySessionId.mock .invocationCallOrder[0], ); + expect(handlers.getLatestSessionStatusForVendor).toHaveBeenCalledWith({ + vendor: 'iron', + }); expect(handlers.createUkycSession).toHaveBeenCalledWith( expect.objectContaining({ vendor: 'iron', @@ -3250,6 +3707,115 @@ describe('KycController', () => { ); }); + it('reuses an existing UKYC session instead of creating one', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + sessionStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('pending'), + id: 'existing-sid', + }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect(handlers.fetchSessionDisclaimersBySessionId).toHaveBeenCalledWith( + { sessionId: 'existing-sid' }, + ); + expect(handlers.submitSessionDisclaimers).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'existing-sid' }), + ); + expect(handlers.createJourney).toHaveBeenCalledWith('existing-sid'); + expect(launcher.launch).toHaveBeenCalled(); + controller.reset(); + }, + ); + }); + + it('finishes without SumSub when the latest session is already approved', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + sessionStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue({ + ...sessionStatus('approved'), + id: 'done-sid', + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + expect( + handlers.fetchSessionDisclaimersBySessionId, + ).toHaveBeenCalledWith({ sessionId: 'done-sid' }); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('done'); + expect(controller.state.statusMessage).toBe('KYC already completed.'); + }, + ); + }); + + it('skips session disclaimers when an approved latest session has no id', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + sessionStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.getLatestSessionStatusForVendor.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }); + + expect( + handlers.fetchSessionDisclaimersBySessionId, + ).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('done'); + }, + ); + }); + it('forwards credential reusability consent onto session disclaimers', async () => { await withController( { @@ -4105,7 +4671,7 @@ describe('KycController', () => { ); }); - it('skips SumSub when the consents-path session is already vendor-processing', async () => { + it('continues to SumSub when the consents-path session is pending', async () => { await withController( { options: { @@ -4122,7 +4688,10 @@ describe('KycController', () => { kycStatus: 'approved', finalStatus: 'pending', }); - handlers.getSessionStatus.mockRejectedValue(new Error('status down')); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4136,8 +4705,7 @@ describe('KycController', () => { ).toHaveBeenCalledWith({ sessionId: 'sid', }); - expect(launcher.launch).not.toHaveBeenCalled(); - expect(controller.state.sumsub.status).toBe('vendorProcessing'); + expect(launcher.launch).toHaveBeenCalled(); expect(controller.state.phase).toBe('done'); controller.reset(); }, @@ -4505,7 +5073,7 @@ describe('KycController', () => { it('refreshKycStatus throws when there is no active sessionId', async () => { await withController(async ({ controller, handlers }) => { await expect(controller.refreshKycStatus()).rejects.toThrow( - /no active SumSub session/u, + /no active session/u, ); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }); @@ -4535,7 +5103,7 @@ describe('KycController', () => { ); }); - it('refreshKycStatus returns completed finalStatus as-is', async () => { + it('refreshKycStatus records a pending finalStatus as-is', async () => { await withController( { options: { @@ -4545,13 +5113,13 @@ describe('KycController', () => { }, async ({ controller, handlers }) => { handlers.getSessionStatus.mockResolvedValue( - sessionStatus('completed'), + sessionStatus('pending'), ); const result = await controller.refreshKycStatus(); - expect(result).toStrictEqual(sessionStatus('completed')); - expect(controller.state.sessionStatus?.finalStatus).toBe('completed'); + expect(result).toStrictEqual(sessionStatus('pending')); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); }, ); }); @@ -4949,6 +5517,9 @@ describe('KycController', () => { ); const pending = controller.startSumSub(); + while (handlers.createUkycSession.mock.calls.length === 0) { + await Promise.resolve(); + } controller.reset(); rejectSession(new Error('session_not_in_valid_state')); @@ -5069,6 +5640,7 @@ type ServiceHandlers = { setAuthorizations: jest.Mock; createJourney: jest.Mock; getSessionStatus: jest.Mock; + getLatestSessionStatusForVendor: jest.Mock; performGetStorage: jest.Mock; performSetStorage: jest.Mock; }; @@ -5106,6 +5678,7 @@ const SERVICE_ACTIONS = [ 'KycService:setAuthorizations', 'KycService:createJourney', 'KycService:getSessionStatus', + 'KycService:getLatestSessionStatusForVendor', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', ] as const; @@ -5233,6 +5806,7 @@ function withController( .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), + getLatestSessionStatusForVendor: jest.fn().mockResolvedValue(null), performGetStorage: jest.fn().mockResolvedValue(null), performSetStorage: jest.fn().mockResolvedValue(undefined), }; @@ -5296,6 +5870,10 @@ function withController( 'KycService:getSessionStatus', handlers.getSessionStatus, ); + rootMessenger.registerActionHandler( + 'KycService:getLatestSessionStatusForVendor', + handlers.getLatestSessionStatusForVendor, + ); rootMessenger.registerActionHandler( 'UserStorageController:performGetStorage', handlers.performGetStorage, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 7e27a24e7bd..168dd58f807 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -179,15 +179,6 @@ function sessionStatusFromSimplified( }; } -// Session creation can report that the applicant is already approved on the -// relay (`kycStatus === KYC_STATUSES.approved`) while the vendor is still -// finalizing its decision (`finalStatus === KYC_STATUSES.pending`, a -// non-terminal status). In that case there is nothing left for the applicant -// to do, so the sub-flow stops before launching the SDK and surfaces this -// message. -const VENDOR_PROCESSING_MESSAGE = - 'Your KYC has been submitted and is being processed by the vendor.'; - // UKYC / relay error indicating the applicant already finished KYC. Mapped to // the simplified `approved` session status for the Money toast surface. const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; @@ -288,7 +279,8 @@ export type KycControllerState = { /** * The latest UKYC session status from `getSessionStatus` / session polling * (or a synthetic payload when KYC is already completed and no fetch ran). - * `null` until the first successful record. Not persisted. + * `null` until the first successful record. Persisted with {@link sessionId} + * and cleared whenever `sessionId` is cleared. */ sessionStatus: KycSessionStatusResponse | null; @@ -768,7 +760,7 @@ export class KycController extends BaseController< /** * When true, a terminal poll result also resolves `sumsub.status`. Set for * the post-SDK decision wait; left false for toast-only polling so an - * abandoned / failed / vendor-processing sub-flow is not overwritten. + * abandoned / failed sub-flow is not overwritten. */ #updateSumSubOnTerminal = false; @@ -854,8 +846,15 @@ export class KycController extends BaseController< } /** - * Resolves persisted terms + geolocation, and auto-creates a session when - * terms are already accepted and an email is available. + * Resolves persisted terms + geolocation, hydrates any existing UKYC + * session for the vendor, and auto-creates a session when terms are already + * accepted and an email is available. + * + * Looks up `GET /sessions/latest/status/{vendor}` after capturing the vendor + * when no `sessionId` is already on state. When a session exists it is reused + * (`sessionId` / `sessionStatus`). When `finalStatus` is already `approved` + * (including a persisted `sessionStatus`), the flow finishes at `done` + * instead of creating a customer or session. * * @param params - Optional parameters. * @param params.email - The account email to associate with the session. @@ -932,6 +931,37 @@ export class KycController extends BaseController< return; } + if (!this.state.sessionId) { + try { + const latest = await this.#fetchLatestSessionStatusForVendor(); + if (this.#generation !== generation) { + return; + } + if (latest) { + const reused = this.#reuseExistingUkycSession(latest, generation); + if (!reused) { + return; + } + } + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Fetching latest session status failed: ${String(error)}`); + return; + } + } + + if (this.#shouldFinishWithoutSumSub()) { + this.#updateIfCurrent(generation, (state) => { + state.phase = 'done'; + state.statusMessage = 'KYC already completed.'; + }); + return; + } + + + // TODO: Should this be happening as part of this method call or should the consumer have to explicitly call it? if (usesConsentsFlow(vendor) && this.state.email) { try { await this.messenger.call('KycService:createVendorCustomer', { @@ -1274,24 +1304,21 @@ export class KycController extends BaseController< return; } - await this.#recordSessionDisclaimers( - created.sessionId, - consents, - generation, - ); - if (this.#generation !== generation) { - return; + if (created.sessionId) { + await this.#recordSessionDisclaimers( + created.sessionId, + consents, + generation, + ); + if (this.#generation !== generation) { + return; + } } - if (created.vendorProcessing) { - try { - await this.refreshKycStatus(); - } catch (statusError) { - controllerLog('KYC status refresh failed:', statusError); - } + if (this.#shouldFinishWithoutSumSub()) { this.#updateIfCurrent(generation, (state) => { state.phase = 'done'; - state.statusMessage = VENDOR_PROCESSING_MESSAGE; + state.statusMessage = 'KYC already completed.'; }); return; } @@ -1861,15 +1888,29 @@ export class KycController extends BaseController< * submits both via authorizations. Stores `sessionId`. Returns `null` * when a `reset()` superseded the flow. * + * Checks `GET /sessions/latest/status/{vendor}` first. A vendor can have + * only one session: if one exists, creation is skipped and that session + * is used. + * * @param generation - Flow generation captured by the caller. - * @returns The created session, or `null` if superseded. + * @returns The created or reused session, or `null` if superseded. */ async #createUkycSession(generation: number): Promise<{ sessionId: string; - kycStatus?: string; - finalStatus?: string; - vendorProcessing: boolean; } | null> { + const latest = await this.#fetchLatestSessionStatusForVendor(); + if (this.#generation !== generation) { + return null; + } + if (latest) { + return this.#reuseExistingUkycSession(latest, generation); + } + if (this.state.sessionId) { + return { + sessionId: this.state.sessionId, + }; + } + const jwtToken = MOCK_JWT_TOKEN; // Establish a per-session X25519 keypair used to seal both secrets. The @@ -1953,30 +1994,75 @@ export class KycController extends BaseController< return null; } - const { kycStatus, finalStatus } = await this.messenger.call( - 'KycService:setAuthorizations', - { - sessionId, - wrappedEncryptionDataKey, - wrappedUkycCapabilityToken, - }, - ); - - const vendorProcessing = - kycStatus === KYC_STATUSES.approved && - finalStatus === KYC_STATUSES.pending; + await this.messenger.call('KycService:setAuthorizations', { + sessionId, + wrappedEncryptionDataKey, + wrappedUkycCapabilityToken, + }); const stillCurrent = this.#updateIfCurrent(generation, (state) => { state.sessionId = sessionId; - if (vendorProcessing) { - state.sumsub.status = 'vendorProcessing'; - state.statusMessage = VENDOR_PROCESSING_MESSAGE; - } }); if (!stillCurrent) { return null; } - return { sessionId, kycStatus, finalStatus, vendorProcessing }; + return { sessionId }; + } + + /** + * Loads the latest UKYC session for the active vendor, if one exists. + * + * @returns The latest session status, or `null` when the vendor has none. + */ + async #fetchLatestSessionStatusForVendor(): Promise { + return await this.messenger.call('KycService:getLatestSessionStatusForVendor', { + vendor: this.state.activeVendor, + }); + } + + /** + * Whether the recorded session `finalStatus` is already approved, so SumSub + * should not be launched. + * + * @returns `true` when the flow should finish without the SDK. + */ + #shouldFinishWithoutSumSub(): boolean { + const finalStatus = this.state.sessionStatus?.finalStatus; + return ( + finalStatus !== undefined && + SUCCESSFUL_SESSION_STATUSES.has(finalStatus) + ); + } + + /** + * Stores an existing UKYC session and its status instead of creating a new + * one. `sessionStatus` is recorded for any `finalStatus`; SumSub is skipped + * only when that status is already approved. + * + * @param sessionStatus - Latest session status from the API. + * @param generation - Flow generation captured by the caller. + * @returns The reused session, or `null` if superseded. + */ + #reuseExistingUkycSession( + sessionStatus: KycSessionStatusResponse, + generation: number, + ): { + sessionId: string; + } | null { + const previous = this.state.sessionStatus; + const sessionId = sessionStatus.id ?? this.state.sessionId ?? ''; + this.#applyUpdate((state) => { + state.sessionId = sessionId; + state.sessionStatus = sessionStatus; + }); + if (this.#generation !== generation) { + return null; + } + this.#applySessionStatus(sessionStatus, { + alreadyRecorded: true, + previous, + }); + return { sessionId }; } /** @@ -1994,13 +2080,12 @@ export class KycController extends BaseController< * 5. fetches the SumSub applicant access token; and * 6. presents the SDK via the injected launcher. * - * If a UKYC session already exists (the consents path creates it before - * recording session disclaimers), steps 1–4 are skipped. + * If a UKYC session already exists for the vendor (`GET + * /sessions/latest/status/{vendor}`), or `sessionId` is already on state, + * steps 1–4 are skipped. A vendor cannot have more than one session. * - * If authorizations report the applicant is already approved on the relay - * while the vendor is still finalizing (`kycStatus: approved`, - * `finalStatus: pending`), the sub-flow stops at step 4 with a - * `vendorProcessing` status and a message rather than launching the SDK. + * If the existing session's `finalStatus` is already `approved`, the SDK is + * not launched. * * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. @@ -2014,7 +2099,10 @@ export class KycController extends BaseController< // A new sub-flow supersedes any polling still running from a prior run, // and pauses toast polling while the SDK is on screen so a `statusChanged` // tick cannot pull consumers in front of a flow the applicant has not - // finished. Resumed in `finally` when session status is still `pending`. + // finished. Resumed in `finally` when session status is still `pending` + // and polling was already running (not merely because an in-progress + // session was reused). + const resumePollingAfterSdk = this.#polling; this.#stopPolling(); // Capture the flow generation so each async step can detect a `reset()` @@ -2039,24 +2127,20 @@ export class KycController extends BaseController< state.sumsub.result = null; state.sessionStatus = null; }); + } - const created = await this.#createUkycSession(generation); - if (!created) { - return {}; - } + const created = await this.#createUkycSession(generation); + if (!created) { + return {}; + } - // A user who already finished the journey can return to a session the - // relay has already approved (`kycStatus`) while the vendor is still - // finalizing its own decision (`finalStatus`). There is nothing left to - // verify, so stop here and surface a message rather than launching the - // SDK again. - if (created.vendorProcessing) { + // An existing session that is already approved has nothing left to + // verify, so stop here rather than launching the SDK again. + if (this.#shouldFinishWithoutSumSub()) { return { - kycStatus: created.kycStatus, - finalStatus: created.finalStatus, + finalStatus: this.state.sessionStatus?.finalStatus, }; } - } // Empty string is a valid "no id to poll" session id used by tests and // must not be coalesced away as missing. @@ -2191,14 +2275,20 @@ export class KycController extends BaseController< // Abandon / SDK failure is not a verification decision — do not map // session `finalStatus` onto toast status. The post-SDK poll already // recorded session status when a submission happened. + const abandonedOrFailed = + status === 'abandoned' || status === 'failed'; const skipRefresh = - this.state.sessionStatus !== null || - status === 'abandoned' || - status === 'failed'; + this.state.sessionStatus !== null || abandonedOrFailed; if (skipRefresh) { + // Reused in-progress `sessionStatus` must not start polling when the + // applicant abandoned or the SDK failed, unless polling was already + // running before this sub-flow (paused for the SDK). if ( this.state.sessionStatus !== null && - !TERMINAL_SESSION_STATUSES.has(this.state.sessionStatus.finalStatus) + !TERMINAL_SESSION_STATUSES.has( + this.state.sessionStatus.finalStatus, + ) && + (!abandonedOrFailed || resumePollingAfterSdk) ) { this.#ensurePolling(); }