From 432c7bad437e7f1c78ca9a54ec2c2ad4d0dbf9c5 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 16 Sep 2026 14:57:43 -0700 Subject: [PATCH 1/6] Replace KycUserStatus with KycSession status. --- packages/kyc-controller/src/types.ts | 55 +++++++++++++--------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 3af974539c9..81ee2b9e676 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -38,28 +38,21 @@ export type KycCustomerIdentity = { }; /** - * User-keyed KYC status returned by `GET /kyc/status` and stored for toast / - * banner rendering. Collapses vendor + SumSub / relay state into the offsite - * contract. + * Simplified KYC session status derived from `GET /sessions/{id}/status` + * (`finalStatus`) and stored for toast / banner rendering. + * + * - `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`). */ -export type KycUserStatus = - | 'not-started' +export type KycSessionStatus = + | 'new' | 'pending' - | 'need-more-information' - | 'terminal-failure' - | 'completed'; - -/** - * Payload from `GET /kyc/status`, including optional fields that power the - * 3-state error contract (retryable SumSub vs terminal vs EDD). - */ -export type KycUserStatusResponse = { - status: KycUserStatus; - /** Present when the user can reopen a SumSub session (retryable path). */ - sumsubSessionId?: string; - /** Machine-readable error code for terminal / EDD UX. */ - errorCode?: string; -}; + | 'approved' + | 'rejected' + | 'retry'; /** * Phases of the end-to-end identity flow. @@ -76,8 +69,8 @@ export type KycUserStatusResponse = { * 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. + * `sessionStatus` ({@link KycSessionStatus}). When KYC is required, the + * document-verification sub-flow is launched automatically. * - `error` — flow halted; see `error`. */ export type KycPhase = @@ -95,8 +88,9 @@ export type KycPhase = * Progress of the SumSub document-verification sub-flow. * * - `polling` — the SDK finished and the controller is polling the UKYC - * backend for the session's final decision (see `KycSessionStatus`). The - * sub-flow resolves to `complete` or `failed` once a terminal status arrives. + * 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 @@ -146,15 +140,16 @@ export type KycSumSubSdkStatus = | 'Completed'; /** - * The status of a UKYC session, returned by the `GET /sessions/{id}/status` - * endpoint and polled after the SumSub SDK completes to determine the final - * verification decision. + * The UKYC session status payload returned by `GET /sessions/{id}/status` + * (and `POST /sessions/{id}/authorizations`). Distinct from + * {@link KycSessionStatus}, the simplified value derived from + * `sessionStatus.finalStatus`. */ -export type KycSessionStatus = { +export type KycSessionStatusResponse = { /** * The overall status of the session. Terminal values (e.g. `approved`, - * `completed`, `rejected`, `failed`, `blocked`) end polling; any other value - * keeps polling. + * `completed`, `rejected`, `failed`, `blocked`, `retry`) end polling; any + * other value keeps polling. */ finalStatus: string; /** Optional human-readable message describing the status. */ From c4252248ff7cdb37c158acd3506660ec9a4dc089 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 16 Sep 2026 14:57:55 -0700 Subject: [PATCH 2/6] remove fetchKycStatus --- .../src/KycService-method-action-types.ts | 12 ----- .../kyc-controller/src/KycService.test.ts | 24 ---------- packages/kyc-controller/src/KycService.ts | 45 ++----------------- 3 files changed, 3 insertions(+), 78 deletions(-) diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 975254525ec..4aba4a6f41f 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -133,17 +133,6 @@ export type KycServiceSubmitSessionDisclaimersAction = { handler: KycService['submitSessionDisclaimers']; }; -/** - * Fetches the user-keyed simplified KYC status used by Money toast / banner - * surfaces (`GET /kyc/status`). - * - * @returns The simplified status payload. - */ -export type KycServiceFetchKycStatusAction = { - type: `KycService:fetchKycStatus`; - handler: KycService['fetchKycStatus']; -}; - /** * Fetches the idOS enclave JWKS used to verify the * `encryptionDataKey` schema's `jwtChain` from @@ -244,7 +233,6 @@ export type KycServiceMethodActions = | KycServiceFetchSessionDisclaimersByCountryAction | KycServiceFetchSessionDisclaimersBySessionIdAction | KycServiceSubmitSessionDisclaimersAction - | KycServiceFetchKycStatusAction | KycServiceFetchIdosEnclaveJwksAction | KycServiceFetchIdosRelayJwksAction | KycServiceCreateUkycSessionAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index a6fc864b7bb..2cf7796aeeb 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -1022,30 +1022,6 @@ describe('KycService', () => { }); }); - describe('fetchKycStatus', () => { - it('returns the simplified user-keyed status', async () => { - nock(MOCK_API_URL).get('/kyc/status').reply(200, { - status: 'pending', - sumsubSessionId: 'ss-1', - }); - const { service } = getService(); - - expect(await service.fetchKycStatus()).toStrictEqual({ - status: 'pending', - sumsubSessionId: 'ss-1', - }); - }); - - it('throws on an unknown status value', async () => { - nock(MOCK_API_URL).get('/kyc/status').reply(200, { status: 'weird' }); - const { service } = getService(); - - await expect(service.fetchKycStatus()).rejects.toThrow( - /Malformed response received from kyc status API/u, - ); - }); - }); - describe('createUkycSession vendorId', () => { const encryptionSchema: EncryptionSchema = { serverPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index ea2afb80ccc..ec89041c845 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -14,7 +14,6 @@ import { array, assert, boolean, - enums, optional, string, StructError, @@ -31,8 +30,7 @@ import type { KycDisclaimer, KycDisclaimersCatalog, KycSessionDisclaimers, - KycSessionStatus, - KycUserStatusResponse, + KycSessionStatusResponse, KycVendor, KycVendorSigning, } from './types.js'; @@ -57,7 +55,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchSessionDisclaimersByCountry', 'fetchSessionDisclaimersBySessionId', 'submitSessionDisclaimers', - 'fetchKycStatus', 'fetchIdosEnclaveJwks', 'fetchIdosRelayJwks', 'createUkycSession', @@ -241,20 +238,6 @@ const VendorCustomerResponseStruct = type({ }); export type VendorCustomerResponse = Infer; -const KYC_USER_STATUSES = [ - 'not-started', - 'pending', - 'need-more-information', - 'terminal-failure', - 'completed', -] as const; - -const KycUserStatusResponseStruct = type({ - status: enums([...KYC_USER_STATUSES]), - sumsubSessionId: optional(string()), - errorCode: optional(string()), -}); - const CatalogDocumentFields = { key: string(), version: string(), @@ -774,28 +757,6 @@ export class KycService extends BaseDataService< ); } - /** - * Fetches the user-keyed simplified KYC status used by Money toast / banner - * surfaces (`GET /kyc/status`). - * - * @returns The simplified status payload. - */ - async fetchKycStatus(): Promise { - const url = new URL('/kyc/status', this.#baseUrl); - const data = await this.fetchQuery({ - queryKey: [`${this.name}:fetchKycStatus`], - queryFn: async () => this.#requestJson(url, { method: 'GET' }), - // Status is polled for toast flips, so it must always be fresh. - staleTime: 0, - gcTime: 0, - }); - return this.#validateResponse( - data, - KycUserStatusResponseStruct, - 'kyc status', - ); - } - /** * Fetches a well-known JWKS from `baseUrl`, caching the result for an hour. * @@ -908,7 +869,7 @@ export class KycService extends BaseDataService< */ async setAuthorizations( params: SetAuthorizationsParams, - ): Promise { + ): Promise { const url = new URL( `/sessions/${encodeURIComponent(params.sessionId)}/authorizations`, this.#baseUrl, @@ -959,7 +920,7 @@ export class KycService extends BaseDataService< */ async getSessionStatus( params: GetSessionStatusParams, - ): Promise { + ): Promise { const url = new URL( `/sessions/${encodeURIComponent(params.sessionId)}/status`, this.#baseUrl, From d5b75898f880b1619903dd1c0b031cf9d7d2f75e Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 16 Sep 2026 14:58:33 -0700 Subject: [PATCH 3/6] Combine polling loops. Write to the same sessionStatus state --- packages/kyc-controller/ARCHITECTURE.md | 9 +- packages/kyc-controller/CHANGELOG.md | 17 + .../src/KycController-method-action-types.ts | 18 +- .../kyc-controller/src/KycController.test.ts | 361 ++++++------ packages/kyc-controller/src/KycController.ts | 557 +++++++++--------- packages/kyc-controller/src/index.ts | 4 +- 6 files changed, 499 insertions(+), 467 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 0dac3cc4f3e..820bb95c69f 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -123,7 +123,7 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): `getGeoCountry`, `fetchVendorDisclaimers`, `createSession`, `checkKycRequired`, `createVendorCustomer`, `submitVendorDisclaimers`, `fetchSessionDisclaimersByCountry`, `fetchSessionDisclaimersBySessionId`, `submitSessionDisclaimers`, -`fetchKycStatus`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`, `createUkycSession`, `setAuthorizations`, +`fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`, `createUkycSession`, `setAuthorizations`, `createJourney`, `getSessionStatus`. Endpoints: @@ -139,12 +139,12 @@ Endpoints: | `fetchSessionDisclaimersByCountry` | `GET` | `/disclaimers?country=` | Global idOS + KYC-provider catalog (no consent state) | | `fetchSessionDisclaimersBySessionId` | `GET` | `/sessions/{id}/disclaimers` | Session-scoped catalog, with `consented` flags + credential-reuse flag | | `submitSessionDisclaimers` | `POST` | `/sessions/{id}/disclaimers` | Record `{ idOS, kycProvider, credentialReusabilityConsentGiven }` consents | -| `fetchKycStatus` | `GET` | `/kyc/status` | User-keyed simplified KYC status | | `fetchIdosEnclaveJwks` | `GET` | `{idosEnclaveBaseUrl}/.well-known/jwks.json` | idOS enclave JWKS for `encryptionDataKey` attestation | | `fetchIdosRelayJwks` | `GET` | `{idosRelayBaseUrl}/.well-known/jwks.json` | idOS relay JWKS for `ukycCapabilityToken` attestation | | `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow; registers session client public key; returns encryption schemas | | `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`) | ### 2.3 `crypto.ts` @@ -187,12 +187,13 @@ classDiagram +KycProduct activeProduct +Record kycRequiredByProduct [persisted] +string lastCheckedAt [persisted] + +string sessionId + +KycSessionStatusResponse sessionStatus +SumSubState sumsub } class SumSubState { +KycSumSubStatus status +Json result - +string sessionId +string applicantAccessToken } KycControllerState --> SumSubState : sumsub @@ -217,7 +218,7 @@ State metadata highlights (`kycControllerMetadata`): path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. - **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, - `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` sub-tree. + `moonpayCustomerId`, `email`, `vendorDisclaimers`, `sessionId`, 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. diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index af3ce152015..cdaa07692eb 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,8 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Move the active UKYC `sessionId` and `sessionStatus` from `sumsub` to the root of `KycControllerState`. + - Read `state.sessionId` / `state.sessionStatus` instead of `state.sumsub.sessionId` / `state.sumsub.sessionStatus`. Neither field is persisted. +- **BREAKING:** `KycController.refreshKycStatus` now loads status from `GET /sessions/{id}/status` (`getSessionStatus`) instead of `GET /kyc/status`. + - Requires an active `sessionId`. Returns and publishes the UKYC `sessionStatus` payload as-is (`null` when none is recorded). +- **BREAKING:** Replace `KycUserStatus` with `KycSessionStatus` (`new` | `pending` | `approved` | `rejected` | `retry`). + - `not-started` → `new`, `completed` → `approved`, `terminal-failure` → `rejected`. Drop `need-more-information`. +- **BREAKING:** Rename the `GET /sessions/{id}/status` payload type from `KycSessionStatus` to `KycSessionStatusResponse`. +- **BREAKING:** Remove `userStatus`, `userStatusSumsubSessionId`, and `userStatusErrorCode` from `KycControllerState`. + - Read `state.sessionStatus`, or use `refreshKycStatus` / `KycController:statusChanged`. +- **BREAKING:** Combine the session-status and user-status poll loops onto one timer. + - Both post-SDK decision waits and `refreshKycStatus` pending polls use `sessionStatusPollIntervalMs` (default 15s) against `GET /sessions/{id}/status`. - Bump `@metamask/profile-sync-controller` from `^32.1.0` to `^32.1.1` ([#10220](https://github.com/MetaMask/core/pull/10220)) +### Removed + +- **BREAKING:** Remove `KycService.fetchKycStatus` and the `KycService:fetchKycStatus` messenger action. +- **BREAKING:** Remove `KycControllerOptions.userStatusPollIntervalMs`. Use `sessionStatusPollIntervalMs` instead. +- **BREAKING:** Remove `KycUserStatusResponse`. Use `KycControllerStatusChangedEvent` / `refreshKycStatus`'s return payload instead. + ## [0.3.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 c03a617de6b..c881aed9047 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -230,15 +230,17 @@ export type KycControllerStartSumSubAction = { }; /** - * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, - * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and - * schedules short-interval polling while the status is `pending`. + * Refreshes KYC status from the active UKYC session + * (`GET /sessions/{id}/status`), stores it on state, publishes + * {@link KycControllerStatusChangedEvent}, and schedules short-interval + * polling while the status is not terminal. * - * Skipped when `userStatus` is already `completed`: a follow-up - * `GET /kyc/status` can still read a stale `pending` (for example after - * `session_not_in_valid_state`) and must not undo that decision. + * No-ops without an active `sessionId`. Skipped when the recorded + * session status is already successful (`approved` / `completed`): 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. * - * @returns The latest status payload. + * @returns The recorded session status, or `null` if none. */ export type KycControllerRefreshKycStatusAction = { type: `KycController:refreshKycStatus`; @@ -270,7 +272,7 @@ export type KycControllerResetAction = { /** * Restores the controller to its default state, discarding everything * {@link reset} deliberately keeps: the session email, the persisted terms - * acceptance, the per-product KYC-required cache and the user-keyed status. + * acceptance, and the per-product KYC-required cache. * * Intended for a full wallet reset, where no trace of the previous * customer may survive into the next wallet. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index e67e970bcf3..7f97208a453 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1073,7 +1073,7 @@ describe('KycController', () => { expect(controller.state.kycRequiredByProduct.card).toBe(true); expect(launcher.launch).toHaveBeenCalledTimes(1); expect(controller.state.sumsub.status).toBe('complete'); - expect(handlers.fetchKycStatus).toHaveBeenCalled(); + expect(handlers.getSessionStatus).toHaveBeenCalled(); }, ); }); @@ -1679,7 +1679,7 @@ describe('KycController', () => { finalStatus: 'pending', }); expect(controller.state.sumsub.status).toBe('vendorProcessing'); - expect(controller.state.sumsub.sessionId).toBe('sid'); + expect(controller.state.sessionId).toBe('sid'); expect(controller.state.statusMessage).toMatch( /being processed by the vendor/u, ); @@ -1722,7 +1722,7 @@ describe('KycController', () => { expect(result).toStrictEqual({}); expect(controller.state.sumsub.status).toBe('idle'); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(launcher.launch).not.toHaveBeenCalled(); expect(handlers.setAuthorizations).not.toHaveBeenCalled(); }); @@ -1775,7 +1775,7 @@ describe('KycController', () => { expect(result).toStrictEqual({}); expect(controller.state.sumsub.status).toBe('idle'); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(launcher.launch).not.toHaveBeenCalled(); }); }); @@ -1960,7 +1960,7 @@ describe('KycController', () => { expect(controller.state.sumsub.status).toBe('abandoned'); expect(controller.state.sumsub.result).toStrictEqual({ ok: false }); // No decision was reached, so consents-path callers still rewind. - expect(controller.state.sumsub.sessionStatus).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }); }); @@ -2028,7 +2028,7 @@ describe('KycController', () => { expect(handlers.getSessionStatus).toHaveBeenCalled(); expect(controller.state.sumsub.status).toBe('failed'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('rejected'), ); }); @@ -2062,7 +2062,7 @@ describe('KycController', () => { expect(launcher.launch).not.toHaveBeenCalled(); // The interrupted step must not write stale sub-flow state. expect(controller.state.sumsub.status).toBe('idle'); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(controller.state.phase).toBe('idle'); }); }); @@ -2140,11 +2140,11 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers, launcher }) => { - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); await controller.refreshKycStatus(); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); let releaseLaunch: (value: { ok: boolean }) => void = () => undefined; @@ -2158,17 +2158,16 @@ describe('KycController', () => { while (launcher.launch.mock.calls.length === 0) { await Promise.resolve(); } - handlers.fetchKycStatus.mockClear(); + handlers.getSessionStatus.mockClear(); await jest.advanceTimersByTimeAsync(3000); - expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); releaseLaunch({ ok: false }); await pending; - expect(handlers.fetchKycStatus).toHaveBeenCalled(); - handlers.fetchKycStatus.mockClear(); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); await jest.advanceTimersByTimeAsync(1000); - expect(handlers.fetchKycStatus).toHaveBeenCalled(); + expect(handlers.getSessionStatus).toHaveBeenCalled(); }, ); } finally { @@ -2180,9 +2179,9 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers, launcher }) => { - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); await controller.refreshKycStatus(); let releaseLaunch: (value: { ok: boolean }) => void = () => @@ -2200,10 +2199,10 @@ describe('KycController', () => { controller.reset(); releaseLaunch({ ok: true }); await pending; - handlers.fetchKycStatus.mockClear(); + handlers.getSessionStatus.mockClear(); await jest.advanceTimersByTimeAsync(3000); - expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }, ); } finally { @@ -2241,7 +2240,7 @@ describe('KycController', () => { sessionId: 'sid', }); expect(controller.state.sumsub.status).toBe('complete'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('approved'), ); }); @@ -2255,7 +2254,7 @@ describe('KycController', () => { await controller.startSumSub(); expect(controller.state.sumsub.status).toBe('failed'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('rejected'), ); }); @@ -2304,7 +2303,7 @@ describe('KycController', () => { // First poll: non-terminal, keeps polling. expect(controller.state.sumsub.status).toBe('polling'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('pending'), ); @@ -2312,14 +2311,14 @@ describe('KycController', () => { // and the loop keeps going. await jest.advanceTimersByTimeAsync(1000); expect(controller.state.sumsub.status).toBe('polling'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('pending'), ); // Third poll reaches a terminal status. await jest.advanceTimersByTimeAsync(1000); expect(controller.state.sumsub.status).toBe('complete'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('approved'), ); expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); @@ -2380,7 +2379,7 @@ describe('KycController', () => { await controller.startSumSub(); expect(controller.state.sumsub.status).toBe('idle'); - expect(controller.state.sumsub.sessionStatus).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); }); }); @@ -2423,12 +2422,12 @@ describe('KycController', () => { { options: { state: { + sessionId: 'sid', + sessionStatus: null, sumsub: { status: 'complete', result: null, - sessionId: 'sid', applicantAccessToken: null, - sessionStatus: null, }, }, }, @@ -2444,7 +2443,7 @@ describe('KycController', () => { sessionId: 'sid', }); expect(result).toStrictEqual(sessionStatus('approved')); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('approved'), ); }, @@ -2539,15 +2538,12 @@ describe('KycController', () => { activeProduct: 'ramps', kycRequiredByProduct: { ramps: true }, lastCheckedAt: 't', - userStatus: 'completed', - userStatusSumsubSessionId: 's1', - userStatusErrorCode: 'code', + sessionId: 'sess-1', + sessionStatus: sessionStatus('approved'), sumsub: { status: 'complete', result: { ok: true }, - sessionId: 'sess-1', applicantAccessToken: 'aat', - sessionStatus: sessionStatus('approved'), }, }, }, @@ -2627,17 +2623,17 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers }) => { - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); await controller.refreshKycStatus(); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); controller.clearState(); await jest.advanceTimersByTimeAsync(5000); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); expect(controller.state).toStrictEqual( getDefaultKycControllerState(), ); @@ -2806,7 +2802,7 @@ describe('KycController', () => { }, idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -2814,7 +2810,7 @@ describe('KycController', () => { onStatusChange?.('InProgress', 'Completed'); return { ok: true }; }); - handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); await controller.initialize({ email: 'a@b.co', @@ -3010,7 +3006,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, launcher }) => { @@ -3154,7 +3150,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3173,7 +3169,7 @@ describe('KycController', () => { consented: true, })), }); - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); launcher.launch.mockImplementation(async ({ onStatusChange }) => { onStatusChange?.('InProgress', 'Completed'); return { ok: true }; @@ -3223,9 +3219,9 @@ describe('KycController', () => { expect(launcher.launch).toHaveBeenCalled(); expect(controller.buildCheckFrameUrl()).toBeNull(); expect(controller.buildAuthFrameUrl()).toBeNull(); - expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); expect(controller.state.phase).toBe('done'); - expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.status).toBe('polling'); expect(controller.state.sessionDisclaimers?.idOS[0]?.consented).toBe( true, ); @@ -3242,7 +3238,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3279,7 +3275,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3334,7 +3330,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3465,7 +3461,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3523,7 +3519,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3615,7 +3611,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3783,11 +3779,13 @@ describe('KycController', () => { /was not finished/iu, ); // The spent session is still dropped. - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); // User-status polling resumes from startSumSub, but abandon is not // a verification decision so phase stays on terms. - expect(controller.state.userStatus).not.toBe('completed'); + expect(controller.state.sessionStatus?.finalStatus).not.toBe( + 'approved', + ); }, ); }, @@ -3833,7 +3831,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3846,7 +3844,6 @@ describe('KycController', () => { handlers.getSessionStatus.mockResolvedValue( sessionStatus('approved'), ); - handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ @@ -3858,7 +3855,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('done'); expect(controller.state.error).toBeNull(); expect(controller.state.sumsub.status).toBe('complete'); - expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); }, ); }); @@ -3871,7 +3868,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3882,9 +3879,6 @@ describe('KycController', () => { handlers.getSessionStatus.mockResolvedValue( sessionStatus('rejected'), ); - handlers.fetchKycStatus.mockResolvedValue({ - status: 'terminal-failure', - }); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3894,15 +3888,15 @@ describe('KycController', () => { expect(controller.state.phase).toBe('done'); expect(controller.state.sumsub.status).toBe('failed'); - expect(controller.state.sumsub.sessionStatus).toStrictEqual( + expect(controller.state.sessionStatus).toStrictEqual( sessionStatus('rejected'), ); expect( controller.state.vendorDisclaimersAccepted.iron?.disclaimerIds, ).toStrictEqual(['d1']); expect(controller.state.error).toBeNull(); - expect(handlers.fetchKycStatus).toHaveBeenCalled(); - expect(controller.state.userStatus).toBe('terminal-failure'); + expect(handlers.getSessionStatus).toHaveBeenCalled(); + expect(controller.state.sessionStatus?.finalStatus).toBe('rejected'); controller.reset(); }, ); @@ -3916,7 +3910,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3927,9 +3921,6 @@ describe('KycController', () => { handlers.getSessionStatus.mockResolvedValue( sessionStatus('rejected'), ); - handlers.fetchKycStatus.mockResolvedValue({ - status: 'terminal-failure', - }); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3940,7 +3931,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('done'); expect(controller.state.sumsub.status).toBe('failed'); expect(controller.state.error).toBeNull(); - expect(controller.state.userStatus).toBe('terminal-failure'); + expect(controller.state.sessionStatus?.finalStatus).toBe('rejected'); }, ); }); @@ -3953,7 +3944,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -3961,7 +3952,9 @@ describe('KycController', () => { onStatusChange?.('InProgress', 'Completed'); return { ok: true }; }); - handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('approved')) + .mockRejectedValue(new Error('status down')); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4021,7 +4014,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -4049,7 +4042,7 @@ describe('KycController', () => { await pending; expect(controller.state.phase).toBe('idle'); - expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }, ); }); @@ -4100,7 +4093,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, launcher }) => { @@ -4109,7 +4102,7 @@ describe('KycController', () => { kycStatus: 'approved', finalStatus: 'pending', }); - handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); + handlers.getSessionStatus.mockRejectedValue(new Error('status down')); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4223,7 +4216,7 @@ describe('KycController', () => { expect(controller.state.error).toMatch(/iron signings down/u); expect(handlers.createUkycSession).not.toHaveBeenCalled(); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); }, ); }); @@ -4284,7 +4277,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/Consents session failed/u); expect(controller.state.sessionDisclaimers).toBeNull(); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(controller.state.sumsub.status).toBe('idle'); }, ); @@ -4319,7 +4312,7 @@ describe('KycController', () => { /missing documents for an accepted category/u, ); expect(handlers.submitSessionDisclaimers).not.toHaveBeenCalled(); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(launcher.launch).not.toHaveBeenCalled(); }, ); @@ -4396,7 +4389,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/Consents session failed/u); - expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.sessionId).toBeNull(); expect(launcher.launch).not.toHaveBeenCalled(); }, ); @@ -4484,64 +4477,96 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('idle'); - expect(controller.state.userStatus).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); }, ); }); it('refreshKycStatus stores status and emits statusChanged', async () => { await withController( - { options: { userStatusPollIntervalMs: 60_000 } }, + { + options: { + state: { sessionId: 'sid' }, + sessionStatusPollIntervalMs: 60_000, + }, + }, async ({ controller, handlers, rootMessenger }) => { const listener = jest.fn(); rootMessenger.subscribe('KycController:statusChanged', listener); - handlers.fetchKycStatus.mockResolvedValue({ - status: 'completed', - sumsubSessionId: 'ss-1', - }); + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); const result = await controller.refreshKycStatus(); - expect(result).toStrictEqual({ - status: 'completed', - sumsubSessionId: 'ss-1', - errorCode: null, - }); - expect(controller.state.userStatus).toBe('completed'); - expect(listener).toHaveBeenCalledWith({ - status: 'completed', - sumsubSessionId: 'ss-1', - errorCode: null, - }); + expect(result).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); + expect(listener).toHaveBeenCalledWith(sessionStatus('approved')); }, ); }); - it('refreshKycStatus skips the fetch when userStatus is already completed', async () => { + it('refreshKycStatus returns completed finalStatus as-is', async () => { + await withController( + { + options: { + state: { sessionId: 'sid' }, + sessionStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('completed'), + ); + + const result = await controller.refreshKycStatus(); + + expect(result).toStrictEqual(sessionStatus('completed')); + expect(controller.state.sessionStatus?.finalStatus).toBe('completed'); + }, + ); + }); + + it('refreshKycStatus maps retry finalStatus to retry', async () => { + await withController( + { + options: { + state: { sessionId: 'sid' }, + sessionStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue(sessionStatus('retry')); + + const result = await controller.refreshKycStatus(); + + expect(result).toStrictEqual(sessionStatus('retry')); + expect(controller.state.sessionStatus?.finalStatus).toBe('retry'); + }, + ); + }); + + it('refreshKycStatus skips the fetch when session status is already approved', async () => { await withController( { options: { state: { - userStatus: 'completed', - userStatusSumsubSessionId: 'ss-1', + sessionId: 'ss-1', + sessionStatus: sessionStatus('approved'), }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers, rootMessenger }) => { const listener = jest.fn(); rootMessenger.subscribe('KycController:statusChanged', listener); - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); const result = await controller.refreshKycStatus(); - expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ - status: 'completed', - sumsubSessionId: 'ss-1', - errorCode: null, - }); - expect(controller.state.userStatus).toBe('completed'); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + expect(result).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); expect(listener).not.toHaveBeenCalled(); }, ); @@ -4551,25 +4576,25 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers }) => { - handlers.fetchKycStatus - .mockResolvedValueOnce({ status: 'pending' }) - .mockResolvedValueOnce({ status: 'pending' }) - .mockResolvedValueOnce({ status: 'completed' }); + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) + .mockResolvedValueOnce(sessionStatus('pending')) + .mockResolvedValueOnce(sessionStatus('approved')); await controller.refreshKycStatus(); - expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); // First tick stays pending and reschedules; second tick completes. await jest.advanceTimersByTimeAsync(1000); - expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.sessionStatus?.finalStatus).toBe('pending'); await jest.advanceTimersByTimeAsync(1000); - expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); // A second refresh while pending would no-op the timer start; then // reset clears any leftover handles. - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); await controller.refreshKycStatus(); await controller.refreshKycStatus(); controller.reset(); @@ -4584,14 +4609,14 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers }) => { let releaseTick: (value: { status: string }) => void = () => { // placeholder }; - handlers.fetchKycStatus + handlers.getSessionStatus // Initial refresh starts the loop. - .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce(sessionStatus('pending')) // The first scheduled tick hangs, so the timer handle is null // while the request is in flight. .mockImplementationOnce( @@ -4601,30 +4626,30 @@ describe('KycController', () => { }), ) // Any later poll stays pending so the loop keeps scheduling. - .mockResolvedValue({ status: 'pending' }); + .mockResolvedValue(sessionStatus('pending')); await controller.refreshKycStatus(); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); // Fire the scheduled tick; it clears the timer handle then awaits. jest.advanceTimersByTime(1000); await Promise.resolve(); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(2); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(2); // A concurrent refresh while the tick is in flight (timer handle // null) must not spin up a second loop on the same token. await controller.refreshKycStatus(); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(3); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); // Let the in-flight tick resolve and reschedule. - releaseTick({ status: 'pending' }); + releaseTick(sessionStatus('pending')); await Promise.resolve(); await Promise.resolve(); // A single loop means exactly one fetch per interval; a duplicated // loop would fire twice here. await jest.advanceTimersByTimeAsync(1000); - expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(4); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(4); controller.reset(); }, @@ -4638,13 +4663,13 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers }) => { let release: (value: { status: string }) => void = () => { // placeholder }; - handlers.fetchKycStatus - .mockResolvedValueOnce({ status: 'pending' }) + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) .mockImplementationOnce( async () => new Promise((resolve) => { @@ -4657,11 +4682,11 @@ describe('KycController', () => { await Promise.resolve(); await Promise.resolve(); controller.reset(); - release({ status: 'completed' }); + release(sessionStatus('approved')); await Promise.resolve(); await Promise.resolve(); - expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.sessionStatus).toBeNull(); }, ); } finally { @@ -4673,18 +4698,18 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers }) => { - handlers.fetchKycStatus - .mockResolvedValueOnce({ status: 'pending' }) + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) .mockRejectedValueOnce(new Error('transient')) - .mockResolvedValueOnce({ status: 'completed' }); + .mockResolvedValueOnce(sessionStatus('approved')); await controller.refreshKycStatus(); await jest.advanceTimersByTimeAsync(1000); await jest.advanceTimersByTimeAsync(1000); - expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); controller.reset(); }, ); @@ -4697,13 +4722,13 @@ describe('KycController', () => { jest.useFakeTimers(); try { await withController( - { options: { userStatusPollIntervalMs: 1000 } }, + { options: { state: { sessionId: 'sid' }, sessionStatusPollIntervalMs: 1000 } }, async ({ controller, handlers }) => { let release: (error: Error) => void = () => { // placeholder }; - handlers.fetchKycStatus - .mockResolvedValueOnce({ status: 'pending' }) + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) .mockImplementationOnce( async () => new Promise((_resolve, reject) => { @@ -4720,7 +4745,7 @@ describe('KycController', () => { await Promise.resolve(); await Promise.resolve(); - expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.sessionStatus).toBeNull(); }, ); } finally { @@ -4728,19 +4753,19 @@ describe('KycController', () => { } }); - it('returns cached user status when reset lands during refresh', async () => { + it('returns null session status when reset lands during refresh', async () => { await withController( { options: { - state: { userStatus: 'pending' }, - userStatusPollIntervalMs: 60_000, + state: { sessionId: 'sid', sessionStatus: sessionStatus('pending') }, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers }) => { let release: (value: { status: string }) => void = () => { // placeholder }; - handlers.fetchKycStatus.mockReturnValue( + handlers.getSessionStatus.mockReturnValue( new Promise((resolve) => { release = resolve; }), @@ -4748,10 +4773,10 @@ describe('KycController', () => { const pending = controller.refreshKycStatus(); controller.reset(); - release({ status: 'completed' }); + release(sessionStatus('approved')); const result = await pending; - expect(result.status).toBe('pending'); + expect(result).toBeNull(); }, ); }); @@ -4762,8 +4787,11 @@ describe('KycController', () => { await withController( { options: { - state: { userStatus: 'pending' }, - userStatusPollIntervalMs: 1000, + state: { + sessionId: 'sid', + sessionStatus: sessionStatus('pending'), + }, + sessionStatusPollIntervalMs: 1000, }, }, async ({ controller, handlers, rootMessenger }) => { @@ -4772,7 +4800,7 @@ describe('KycController', () => { let release: (value: { status: string }) => void = () => { // placeholder }; - handlers.fetchKycStatus.mockReturnValue( + handlers.getSessionStatus.mockReturnValue( new Promise((resolve) => { release = resolve; }), @@ -4780,12 +4808,12 @@ describe('KycController', () => { const pending = controller.refreshKycStatus(); controller.reset(); - release({ status: 'pending' }); + release(sessionStatus('pending')); await pending; - handlers.fetchKycStatus.mockClear(); + handlers.getSessionStatus.mockClear(); await jest.advanceTimersByTimeAsync(3000); - expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled(); }, ); @@ -4794,14 +4822,14 @@ describe('KycController', () => { } }); - it('defaults superseded refresh status to not-started when unset', async () => { + it('defaults superseded refresh status to null when unset', async () => { await withController( - { options: { userStatusPollIntervalMs: 60_000 } }, + { options: { sessionStatusPollIntervalMs: 60_000 } }, async ({ controller, handlers }) => { let release: (value: { status: string }) => void = () => { // placeholder }; - handlers.fetchKycStatus.mockReturnValue( + handlers.getSessionStatus.mockReturnValue( new Promise((resolve) => { release = resolve; }), @@ -4809,10 +4837,10 @@ describe('KycController', () => { const pending = controller.refreshKycStatus(); controller.reset(); - release({ status: 'completed' }); + release(sessionStatus('approved')); const result = await pending; - expect(result.status).toBe('not-started'); + expect(result).toBeNull(); }, ); }); @@ -4830,15 +4858,15 @@ describe('KycController', () => { "Fetching 'https://x' failed with status '409': session_not_in_valid_state", ), ); - handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); const result = await controller.startSumSub(); expect(result).toStrictEqual({ alreadyCompleted: true }); - expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); expect(controller.state.phase).toBe('done'); expect(controller.state.sumsub.status).toBe('complete'); - expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); }, ); }); @@ -4863,7 +4891,7 @@ describe('KycController', () => { rejectSession(new Error('session_not_in_valid_state')); expect(await pending).toStrictEqual({ alreadyCompleted: true }); - expect(controller.state.userStatus).toBeNull(); + expect(controller.state.sessionStatus).toBeNull(); expect(controller.state.phase).toBe('idle'); expect(controller.state.sumsub.status).toBe('idle'); }, @@ -4878,14 +4906,14 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers }) => { handlers.createUkycSession.mockRejectedValue( new Error('session_not_in_valid_state'), ); - handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4894,7 +4922,7 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('done'); - expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.sessionStatus?.finalStatus).toBe('approved'); controller.reset(); }, ); @@ -4908,7 +4936,7 @@ describe('KycController', () => { activeVendor: 'iron', vendorDisclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], }, - userStatusPollIntervalMs: 60_000, + sessionStatusPollIntervalMs: 60_000, }, }, async ({ controller, handlers }) => { @@ -4973,7 +5001,6 @@ type ServiceHandlers = { fetchSessionDisclaimersByCountry: jest.Mock; fetchSessionDisclaimersBySessionId: jest.Mock; submitSessionDisclaimers: jest.Mock; - fetchKycStatus: jest.Mock; fetchIdosEnclaveJwks: jest.Mock; fetchIdosRelayJwks: jest.Mock; createUkycSession: jest.Mock; @@ -5011,7 +5038,6 @@ const SERVICE_ACTIONS = [ 'KycService:fetchSessionDisclaimersByCountry', 'KycService:fetchSessionDisclaimersBySessionId', 'KycService:submitSessionDisclaimers', - 'KycService:fetchKycStatus', 'KycService:fetchIdosEnclaveJwks', 'KycService:fetchIdosRelayJwks', 'KycService:createUkycSession', @@ -5137,7 +5163,6 @@ function withController( consented: true, })), }), - fetchKycStatus: jest.fn().mockResolvedValue({ status: 'pending' }), fetchIdosEnclaveJwks: jest.fn().mockResolvedValue({ keys: [] }), fetchIdosRelayJwks: jest.fn().mockResolvedValue({ keys: [] }), createUkycSession: jest.fn().mockResolvedValue(ukycSessionResponse()), @@ -5185,10 +5210,6 @@ function withController( 'KycService:submitSessionDisclaimers', handlers.submitSessionDisclaimers, ); - rootMessenger.registerActionHandler( - 'KycService:fetchKycStatus', - handlers.fetchKycStatus, - ); rootMessenger.registerActionHandler( 'KycService:fetchIdosEnclaveJwks', handlers.fetchIdosEnclaveJwks, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 1553a8e41be..e878e4f0d80 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -32,10 +32,10 @@ import type { KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, + KycSessionStatusResponse, KycSumSubLauncher, KycSumSubSdkStatus, KycSumSubStatus, - KycUserStatus, KycVendor, KycVendorDisclaimersAccepted, } from './types.js'; @@ -147,6 +147,7 @@ const KYC_STATUSES = { failed: 'failed', blocked: 'blocked', pending: 'pending', + retry: 'retry', } as const; // `finalStatus` values that end the polling loop. Anything else (e.g. @@ -157,6 +158,7 @@ const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ KYC_STATUSES.rejected, KYC_STATUSES.failed, KYC_STATUSES.blocked, + KYC_STATUSES.retry, ]); // Terminal `finalStatus` values that represent a successful verification. Any @@ -166,6 +168,25 @@ const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ KYC_STATUSES.completed, ]); +/** + * Builds a UKYC session-status payload when we know the outcome but did not + * fetch `GET /sessions/{id}/status` (e.g. already-completed). + * + * @param status - Value to project onto `finalStatus`. + * @returns A session-status payload. + */ +function sessionStatusFromSimplified( + status: KycSessionStatus, +): KycSessionStatusResponse { + return { + finalStatus: status, + externalUserId: '', + kycStatus: status, + vendor: '', + vendorStatus: status, + }; +} + // 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 @@ -176,13 +197,9 @@ 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 `completed` user status for the Money toast surface. +// the simplified `approved` session status for the Money toast surface. const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; -// How often to refresh the user-keyed `GET /kyc/status` while the simplified -// status is still `pending`. Overridable via the constructor. -const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000; - // === STATE === /** @@ -271,27 +288,22 @@ export type KycControllerState = { lastCheckedAt: string | null; /** - * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the - * Money toast can render across cold starts). `null` until the first - * successful `refreshKycStatus`. + * Active UKYC session id from `createUkycSession`. `null` outside a + * document-verification sub-flow. Not persisted. + */ + sessionId: string | null; + /** + * 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. */ - userStatus: KycUserStatus | null; - /** Optional SumSub session id for the retryable error path. */ - userStatusSumsubSessionId: string | null; - /** Optional machine-readable error code for terminal / EDD UX. */ - userStatusErrorCode: string | null; + sessionStatus: KycSessionStatusResponse | null; /** SumSub document-verification sub-flow state. */ sumsub: { status: KycSumSubStatus; result: Json | null; - sessionId: string | null; applicantAccessToken: string | null; - /** - * The latest UKYC session status, populated while polling after the SDK - * completes. `null` until the first successful poll. - */ - sessionStatus: KycSessionStatus | null; }; }; @@ -410,22 +422,16 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, - userStatus: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, - usedInUi: true, - }, - userStatusSumsubSessionId: { + sessionId: { includeInDebugSnapshot: false, includeInStateLogs: false, - persist: true, + persist: false, usedInUi: true, }, - userStatusErrorCode: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: true, + sessionStatus: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, usedInUi: true, }, sumsub: { @@ -475,15 +481,12 @@ export function getDefaultKycControllerState(): KycControllerState { activeProduct: null, kycRequiredByProduct: {}, lastCheckedAt: null, - userStatus: null, - userStatusSumsubSessionId: null, - userStatusErrorCode: null, + sessionId: null, + sessionStatus: null, sumsub: { status: 'idle', result: null, - sessionId: null, applicantAccessToken: null, - sessionStatus: null, }, }; } @@ -674,17 +677,11 @@ export type KycControllerStateChangeEvent = ControllerStateChangeEvent< >; /** - * Published when the user-keyed simplified KYC status changes (Money toast). + * Published when {@link KycControllerState.sessionStatus} changes. */ export type KycControllerStatusChangedEvent = { type: `${typeof controllerName}:statusChanged`; - payload: [ - { - status: KycUserStatus; - sumsubSessionId: string | null; - errorCode: string | null; - }, - ]; + payload: [KycSessionStatusResponse]; }; export type KycControllerEvents = @@ -711,17 +708,12 @@ export type KycControllerOptions = { */ sumsubLauncher: KycSumSubLauncher; /** - * How often, in milliseconds, to poll the UKYC session status after the - * SumSub SDK completes. Defaults to + * How often, in milliseconds, to poll `GET /sessions/{id}/status` while the + * session is still pending. Used after SumSub submits and by + * {@link refreshKycStatus}. Defaults to * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. */ sessionStatusPollIntervalMs?: number; - /** - * How often, in milliseconds, to refresh `GET /kyc/status` while the - * simplified user status is `pending`. Defaults to - * {@link DEFAULT_USER_STATUS_POLL_INTERVAL_MS}. - */ - userStatusPollIntervalMs?: number; }; // === CONTROLLER DEFINITION === @@ -757,6 +749,22 @@ export class KycController extends BaseController< /** Handle for the scheduled next session-status poll, or `null`. */ #pollTimer: ReturnType | null = null; + /** + * Whether a session-status poll loop is currently active. Tracked separately + * from {@link #pollTimer} because a scheduled tick clears the timer handle + * before awaiting `getSessionStatus`; relying on the handle alone would let + * a concurrent {@link refreshKycStatus} start a second loop during that + * in-flight window. + */ + #polling = false; + + /** + * 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. + */ + #updateSumSubOnTerminal = false; + /** * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a * new sub-flow, and once a terminal status is reached) so an in-flight poll @@ -766,24 +774,6 @@ export class KycController extends BaseController< */ #pollToken = 0; - /** Interval, in milliseconds, between user-keyed status polls. */ - readonly #userStatusPollIntervalMs: number; - - /** Handle for the scheduled next user-status poll, or `null`. */ - #userStatusPollTimer: ReturnType | null = null; - - /** - * Whether a user-status poll loop is currently active. Tracked separately - * from {@link #userStatusPollTimer} because a scheduled tick clears the timer - * handle before awaiting `fetchKycStatus`; relying on the handle alone would - * let a concurrent {@link refreshKycStatus} start a second loop on the same - * token during that in-flight window. - */ - #userStatusPolling = false; - - /** Monotonic token for the user-status poll loop (see `#pollToken`). */ - #userStatusPollToken = 0; - /** * Constructs a new {@link KycController}. * @@ -791,17 +781,14 @@ export class KycController extends BaseController< * @param options.messenger - The messenger suited for this controller. * @param options.state - Partial initial state; merged over defaults. * @param options.sumsubLauncher - The platform SumSub launcher adapter. - * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC - * session status after the SumSub SDK completes. - * @param options.userStatusPollIntervalMs - How often to refresh the - * user-keyed KYC status while it is still `pending`. + * @param options.sessionStatusPollIntervalMs - How often to poll + * `GET /sessions/{id}/status` while the session is still pending. */ constructor({ messenger, state, sumsubLauncher, sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, - userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS, }: KycControllerOptions) { super({ messenger, @@ -812,7 +799,6 @@ export class KycController extends BaseController< this.#sumsubLauncher = sumsubLauncher; this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; - this.#userStatusPollIntervalMs = userStatusPollIntervalMs; this.#moonPayFrames = new MoonPayFrameHandler({ getState: (): KycControllerState => this.state, update: (updater): void => this.#applyUpdate(updater), @@ -1258,7 +1244,7 @@ export class KycController extends BaseController< state.statusMessage = 'Submitting consents...'; state.sumsub.status = 'creatingSession'; state.sumsub.result = null; - state.sumsub.sessionStatus = null; + state.sessionStatus = null; // Consents-path vendors have no MoonPay session/access tokens. clearMoonPaySession(state); }); @@ -1326,7 +1312,7 @@ export class KycController extends BaseController< // refresh user status and land on `done`. if ( this.state.sumsub.status === 'failed' && - this.state.sumsub.sessionStatus === null + this.state.sessionStatus === null ) { const sumsubError = sumsubResult?.error; throw new Error( @@ -1335,7 +1321,7 @@ export class KycController extends BaseController< : 'SumSub verification could not run.', ); } - // After SumSub, refresh user-keyed status for the Money toast and start + // After SumSub, refresh session status for the Money toast and start // polling while still pending. Soft-fail: toast refresh must not rewind // the consent / SumSub outcome. try { @@ -1354,11 +1340,7 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - this.#applyUserStatus({ - status: 'completed', - sumsubSessionId: null, - errorCode: null, - }); + this.#applySessionStatus(sessionStatusFromSimplified('approved')); this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'complete'; state.sumsub.result = { alreadyCompleted: true }; @@ -1408,6 +1390,8 @@ 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; state.sumsub = { ...getDefaultKycControllerState().sumsub }; if (keepSumSubStatus) { state.sumsub.status = keepSumSubStatus; @@ -1868,7 +1852,7 @@ export class KycController extends BaseController< /** * Creates a UKYC session, wraps the `data_encryption_key` and * `ukyc_capability_token` against the returned encryption schemas, and - * submits both via authorizations. Stores `sumsub.sessionId`. Returns `null` + * submits both via authorizations. Stores `sessionId`. Returns `null` * when a `reset()` superseded the flow. * * @param generation - Flow generation captured by the caller. @@ -1977,7 +1961,7 @@ export class KycController extends BaseController< finalStatus === KYC_STATUSES.pending; const stillCurrent = this.#updateIfCurrent(generation, (state) => { - state.sumsub.sessionId = sessionId; + state.sessionId = sessionId; if (vendorProcessing) { state.sumsub.status = 'vendorProcessing'; state.statusMessage = VENDOR_PROCESSING_MESSAGE; @@ -2021,14 +2005,11 @@ export class KycController extends BaseController< locale?: string; debug?: boolean; }): Promise> { - // A new sub-flow supersedes any polling still running from a prior run. + // 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`. this.#stopPolling(); - // Paused for the whole sub-flow: a tick landing while the SDK is on screen - // publishes `statusChanged`, pulling consumers (and their signing prompts) - // in front of a flow the applicant has not finished. Resumed in `finally` - // so abandonment, SDK failure, and callers that do not run - // `refreshKycStatus` (MoonPay post-auth) still restore the loop. - this.#stopUserStatusPolling(); // Capture the flow generation so each async step can detect a `reset()` // that lands mid-flight and avoid writing stale sub-flow state (or, worse, @@ -2046,11 +2027,11 @@ export class KycController extends BaseController< } try { - if (!this.state.sumsub.sessionId) { + if (!this.state.sessionId) { this.#applyUpdate((state) => { state.sumsub.status = 'creatingSession'; state.sumsub.result = null; - state.sumsub.sessionStatus = null; + state.sessionStatus = null; }); const created = await this.#createUkycSession(generation); @@ -2074,11 +2055,11 @@ export class KycController extends BaseController< // Empty string is a valid "no id to poll" session id used by tests and // must not be coalesced away as missing. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const sessionId = this.state.sumsub.sessionId || ''; + const sessionId = this.state.sessionId || ''; this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'fetchingToken'; - state.sumsub.sessionId = sessionId; + state.sessionId = sessionId; }); const { applicantAccessToken } = await this.messenger.call( @@ -2162,7 +2143,7 @@ export class KycController extends BaseController< // that landed during `launch` cannot start polling on an idle flow. if (applied && reachedCompletion) { if (sessionId) { - await this.#startSessionStatusPolling(sessionId); + await this.#startPolling({ updateSumSubStatus: true }); } else { // No session id to poll against; fall back to treating the SDK // completion as the final outcome. @@ -2181,11 +2162,7 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return { alreadyCompleted: true }; } - this.#applyUserStatus({ - status: 'completed', - sumsubSessionId: null, - errorCode: null, - }); + this.#applySessionStatus(sessionStatusFromSimplified('approved')); this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'complete'; state.sumsub.result = { alreadyCompleted: true }; @@ -2204,168 +2181,188 @@ export class KycController extends BaseController< } } finally { if (this.#generation === generation) { - try { - await this.refreshKycStatus(); - } catch (error) { - controllerLog('KYC status refresh failed:', error); + const { status } = this.state.sumsub; + // 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 skipRefresh = + this.state.sessionStatus !== null || + status === 'abandoned' || + status === 'failed'; + if (skipRefresh) { + if ( + this.state.sessionStatus !== null && + !TERMINAL_SESSION_STATUSES.has(this.state.sessionStatus.finalStatus) + ) { + this.#ensurePolling(); + } + } else { + try { + await this.refreshKycStatus(); + } catch (error) { + controllerLog('KYC status refresh failed:', error); + } } } } } /** - * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, - * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and - * schedules short-interval polling while the status is `pending`. + * Refreshes KYC status from the active UKYC session + * (`GET /sessions/{id}/status`), stores it on state, publishes + * {@link KycControllerStatusChangedEvent}, and schedules short-interval + * polling while the status is not terminal. * - * Skipped when `userStatus` is already `completed`: a follow-up - * `GET /kyc/status` can still read a stale `pending` (for example after - * `session_not_in_valid_state`) and must not undo that decision. + * No-ops without an active `sessionId`. Skipped when the recorded + * {@link sessionStatus} is already successful (`approved` / `completed`): 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. * - * @returns The latest status payload. + * @returns The recorded session status, or `null` if none. */ - async refreshKycStatus(): Promise<{ - status: KycUserStatus; - sumsubSessionId: string | null; - errorCode: string | null; - }> { - if (this.state.userStatus === 'completed') { - return { - status: 'completed', - sumsubSessionId: this.state.userStatusSumsubSessionId, - errorCode: this.state.userStatusErrorCode, - }; + async refreshKycStatus(): Promise { + if ( + this.state.sessionStatus && + SUCCESSFUL_SESSION_STATUSES.has(this.state.sessionStatus.finalStatus) + ) { + return this.state.sessionStatus; } const generation = this.#generation; - const payload = await this.#fetchAndApplyUserStatus(); + const sessionStatus = await this.#fetchAndRecordSessionStatus(); // A `reset()` landing while the request was in flight already stopped - // polling and left the flow idle, and the payload above is the pre-reset - // cached status. Starting a loop from it would poll — and publish - // `statusChanged` — on a torn-down flow. + // polling and left the flow idle. Starting a loop from the pre-reset + // status would poll — and publish `statusChanged` — on a torn-down flow. if (this.#generation !== generation) { - return payload; + return sessionStatus; } - if (payload.status === 'pending') { - this.#ensureUserStatusPolling(); + if ( + sessionStatus !== null && + !TERMINAL_SESSION_STATUSES.has(sessionStatus.finalStatus) + ) { + this.#ensurePolling(); } else { - this.#stopUserStatusPolling(); + this.#stopPolling(); } - return payload; + return sessionStatus; } /** - * Fetches `GET /kyc/status` and applies it to state without managing the - * poll loop (used by both {@link refreshKycStatus} and the poll tick). + * Fetches the active UKYC session status and records it without managing the + * poll loop (used by {@link refreshKycStatus}). * - * @returns The latest status payload. + * @returns The recorded session status, or `null` if none. */ - async #fetchAndApplyUserStatus(): Promise<{ - status: KycUserStatus; - sumsubSessionId: string | null; - errorCode: string | null; - }> { + async #fetchAndRecordSessionStatus(): Promise { + if (!this.state.sessionId) { + return this.state.sessionStatus; + } const generation = this.#generation; - const response = await this.messenger.call('KycService:fetchKycStatus'); - if (this.#generation !== generation) { - return { - status: this.state.userStatus ?? 'not-started', - sumsubSessionId: this.state.userStatusSumsubSessionId, - errorCode: this.state.userStatusErrorCode, - }; + try { + await this.getSessionStatus(); + } catch (error) { + if (this.#generation !== generation) { + return this.state.sessionStatus; + } + throw error; } - const payload = { - status: response.status, - sumsubSessionId: response.sumsubSessionId ?? null, - errorCode: response.errorCode ?? null, - }; - this.#applyUserStatus(payload); - return payload; + return this.state.sessionStatus; } /** - * Writes user-keyed status onto state and publishes `statusChanged` when the - * value actually changes. + * Writes {@link sessionStatus} unless the caller already did, and publishes + * `statusChanged` when `finalStatus` changes. * - * @param payload - The status payload to apply. - * @param payload.status - User-keyed KYC status from `GET /kyc/status`. - * @param payload.sumsubSessionId - Optional SumSub session id from status. - * @param payload.errorCode - Optional error code from status. + * @param sessionStatus - Status to record and publish. + * @param options - Write options. + * @param options.alreadyRecorded - When true, {@link sessionStatus} was + * already written and must not be overwritten. + * @param options.previous - Status from before the write. Required when + * `alreadyRecorded` is true so `statusChanged` still fires. */ - #applyUserStatus(payload: { - status: KycUserStatus; - sumsubSessionId: string | null; - errorCode: string | null; - }): void { - const previous = this.state.userStatus; - this.#applyUpdate((state) => { - state.userStatus = payload.status; - state.userStatusSumsubSessionId = payload.sumsubSessionId; - state.userStatusErrorCode = payload.errorCode; - }); - if (previous !== payload.status) { - this.messenger.publish(`${controllerName}:statusChanged`, payload); + #applySessionStatus( + sessionStatus: KycSessionStatusResponse, + options?: { + alreadyRecorded?: boolean; + previous?: KycSessionStatusResponse | null; + }, + ): void { + const previous = + options?.alreadyRecorded === true + ? (options.previous ?? null) + : this.state.sessionStatus; + if (options?.alreadyRecorded !== true) { + this.#applyUpdate((state) => { + state.sessionStatus = sessionStatus; + }); + } + if (previous?.finalStatus !== sessionStatus.finalStatus) { + this.messenger.publish(`${controllerName}:statusChanged`, sessionStatus); } } /** - * Starts the user-status poll loop when not already running and status is - * still `pending`. + * Starts the session-status poll loop when not already running. The first + * tick is delayed by {@link #sessionStatusPollIntervalMs} — callers that + * already fetched (e.g. {@link refreshKycStatus}) use this. */ - #ensureUserStatusPolling(): void { - if (this.#userStatusPolling) { + #ensurePolling(): void { + if (this.#polling || !this.state.sessionId) { return; } - this.#userStatusPolling = true; - const token = this.#userStatusPollToken; - const tick = async (): Promise => { - try { - const payload = await this.#fetchAndApplyUserStatus(); - // Race with `reset()` / `#stopUserStatusPolling` while the request was - // in flight — do not reschedule onto an idle controller. - /* istanbul ignore next */ - if (this.#userStatusPollToken !== token) { - return; - } - if (payload.status !== 'pending') { - this.#stopUserStatusPolling(); - return; - } - } catch { - // Keep polling on transient errors, unless the loop was superseded. - /* istanbul ignore next */ - if (this.#userStatusPollToken !== token) { - return; - } - } - this.#userStatusPollTimer = setTimeout(() => { - this.#userStatusPollTimer = null; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - tick(); - }, this.#userStatusPollIntervalMs); - // Allow the process to exit while a pending-status poll is scheduled. - // React Native / browser timers are numbers with no `unref`, hence the - // optional call. - this.#userStatusPollTimer.unref?.(); - }; - this.#userStatusPollTimer = setTimeout(() => { - this.#userStatusPollTimer = null; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - tick(); - }, this.#userStatusPollIntervalMs); - this.#userStatusPollTimer.unref?.(); + this.#polling = true; + this.#schedulePollTick(this.#pollToken); } /** - * Stops the user-keyed status poll loop. + * Begins polling immediately (awaited by {@link startSumSub} after the SDK + * submits). Subsequent ticks use {@link #sessionStatusPollIntervalMs}. + * + * @param options - Polling options. + * @param options.updateSumSubStatus - Whether terminal results resolve + * `sumsub.status`. */ - #stopUserStatusPolling(): void { - this.#userStatusPollToken += 1; - this.#userStatusPolling = false; - if (this.#userStatusPollTimer !== null) { - clearTimeout(this.#userStatusPollTimer); - this.#userStatusPollTimer = null; + async #startPolling(options: { updateSumSubStatus: boolean }): Promise { + this.#stopPolling(); + if (!this.state.sessionId) { + return; } + this.#updateSumSubOnTerminal = options.updateSumSubStatus; + this.#polling = true; + await this.#runPollTick(this.#pollToken); + } + + /** + * Runs one poll tick and either stops or schedules the next. + * + * @param token - The polling token captured when the loop started. + */ + async #runPollTick(token: number): Promise { + const shouldStop = await this.#pollOnce(token); + if (shouldStop) { + return; + } + this.#schedulePollTick(token); + } + + /** + * Schedules {@link #runPollTick} after the poll interval. + * + * @param token - The polling token captured when the loop started. + */ + #schedulePollTick(token: number): void { + this.#pollTimer = setTimeout(() => { + this.#pollTimer = null; + // `tick` swallows its own errors (see `#pollOnce`) and therefore never + // rejects, so this fire-and-forget scheduled poll cannot surface as an + // unhandled rejection. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#runPollTick(token); + }, this.#sessionStatusPollIntervalMs); + // Allow the process to exit while a pending-status poll is scheduled. + // React Native / browser timers are numbers with no `unref`, hence the + // optional call. + this.#pollTimer.unref?.(); } /** @@ -2376,8 +2373,8 @@ export class KycController extends BaseController< * @returns The fetched session status. * @throws If there is no active SumSub session to query. */ - async getSessionStatus(): Promise { - const { sessionId } = this.state.sumsub; + async getSessionStatus(): Promise { + const { sessionId } = this.state; if (!sessionId) { throw new Error('Cannot fetch session status: no active SumSub session.'); } @@ -2389,63 +2386,64 @@ export class KycController extends BaseController< 'KycService:getSessionStatus', { sessionId }, ); - this.#updateIfCurrent(generation, (state) => { - state.sumsub.sessionStatus = sessionStatus; - }); + if (this.#generation === generation) { + this.#recordSessionStatus(sessionStatus); + } return sessionStatus; } /** - * Begins polling the UKYC session status until a terminal decision is - * reached. The first poll runs immediately (and is awaited by - * {@link startSumSub}); subsequent polls are scheduled every - * `#sessionStatusPollIntervalMs`. + * 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. * - * @param sessionId - The UKYC session id to poll. - * @returns A promise that resolves once the first poll settles. + * @param sessionStatus - Status from `GET /sessions/{id}/status`. + * @param options - Recording options. + * @param options.updateSumSubStatus - Whether to set `sumsub.status` from + * a terminal `finalStatus`. */ - async #startSessionStatusPolling(sessionId: string): Promise { - // Supersede any prior loop and claim a fresh token for this one. Because - // `#stopPolling` bumps the token, any in-flight poll from a previous loop - // sees a mismatch and neither writes state nor reschedules. - this.#stopPolling(); - const token = this.#pollToken; - - const tick = async (): Promise => { - const shouldStop = await this.#pollSessionStatusOnce(sessionId, token); - if (shouldStop) { - return; + #recordSessionStatus( + sessionStatus: KycSessionStatusResponse, + options?: { updateSumSubStatus?: boolean }, + ): void { + const previous = this.state.sessionStatus; + const isTerminal = TERMINAL_SESSION_STATUSES.has(sessionStatus.finalStatus); + this.#applyUpdate((state) => { + state.sessionStatus = sessionStatus; + if (options?.updateSumSubStatus === true && isTerminal) { + state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ) + ? 'complete' + : 'failed'; } - this.#pollTimer = setTimeout(() => { - this.#pollTimer = null; - // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and - // therefore never rejects, so this fire-and-forget scheduled poll - // cannot surface as an unhandled rejection. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - tick(); - }, this.#sessionStatusPollIntervalMs); - }; - - await tick(); + }); + this.#applySessionStatus(sessionStatus, { + alreadyRecorded: true, + previous, + }); } /** * Performs a single session-status poll: fetches the status, records it, and - * resolves the sub-flow when the status is terminal. + * stops when the status is terminal. * * Transient errors are swallowed so the loop keeps polling; the last good * `sessionStatus` is deliberately preserved rather than being overwritten * with the error. * - * @param sessionId - The UKYC session id to poll. * @param token - The polling token captured when the loop started. * @returns `true` when the loop should stop (terminal status or superseded * by a reset / new sub-flow), `false` when it should keep polling. */ - async #pollSessionStatusOnce( - sessionId: string, - token: number, - ): Promise { + async #pollOnce(token: number): Promise { + const sessionId = this.state.sessionId; + if (!sessionId) { + this.#stopPolling(); + return true; + } try { const sessionStatus = await this.messenger.call( 'KycService:getSessionStatus', @@ -2458,15 +2456,8 @@ export class KycController extends BaseController< const isTerminal = TERMINAL_SESSION_STATUSES.has( sessionStatus.finalStatus, ); - this.#applyUpdate((state) => { - state.sumsub.sessionStatus = sessionStatus; - if (isTerminal) { - state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has( - sessionStatus.finalStatus, - ) - ? 'complete' - : 'failed'; - } + this.#recordSessionStatus(sessionStatus, { + updateSumSubStatus: this.#updateSumSubOnTerminal, }); if (isTerminal) { this.#stopPolling(); @@ -2480,11 +2471,14 @@ export class KycController extends BaseController< } /** - * Stops the session-status polling loop: bumps the polling token (so any - * in-flight `tick` bows out) and clears any scheduled poll. + * Stops the session-status poll loop: bumps the polling token (so any + * in-flight `tick` bows out), clears any scheduled poll, and drops the + * post-SDK `sumsub.status` flag. */ #stopPolling(): void { this.#pollToken += 1; + this.#polling = false; + this.#updateSumSubOnTerminal = false; if (this.#pollTimer !== null) { clearTimeout(this.#pollTimer); this.#pollTimer = null; @@ -2508,12 +2502,12 @@ export class KycController extends BaseController< clearMoonPaySession(state); state.activeVendor = 'moonpay'; state.activeProduct = null; + state.sessionId = null; + state.sessionStatus = null; state.sumsub = { status: 'idle', result: null, - sessionId: null, applicantAccessToken: null, - sessionStatus: null, }; }); } @@ -2521,7 +2515,7 @@ export class KycController extends BaseController< /** * Restores the controller to its default state, discarding everything * {@link reset} deliberately keeps: the session email, the persisted terms - * acceptance, the per-product KYC-required cache and the user-keyed status. + * acceptance, and the per-product KYC-required cache. * * Intended for a full wallet reset, where no trace of the previous * customer may survive into the next wallet. @@ -2535,15 +2529,14 @@ export class KycController extends BaseController< /** * Tears down everything that lives outside state: drops the MoonPay frame - * keypair and auth client token, stops both polling loops, and bumps the flow - * generation so async steps started earlier discard their results instead - * of writing them onto the controller. Shared by {@link reset} and + * keypair and auth client token, stops session-status polling, and bumps the + * flow generation so async steps started earlier discard their results + * instead of writing them onto the controller. Shared by {@link reset} and * {@link clearState}. */ #cancelPendingSession(): void { this.#moonPayFrames.clear(); this.#stopPolling(); - this.#stopUserStatusPolling(); this.#generation += 1; } diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 911f13e5957..016037085e9 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -71,7 +71,6 @@ export type { KycServiceCreateUkycSessionAction, KycServiceFetchIdosEnclaveJwksAction, KycServiceFetchIdosRelayJwksAction, - KycServiceFetchKycStatusAction, KycServiceFetchSessionDisclaimersByCountryAction, KycServiceFetchSessionDisclaimersBySessionIdAction, KycServiceFetchVendorDisclaimersAction, @@ -109,12 +108,11 @@ export type { KycProviderDisclaimersAccepted, KycSessionDisclaimers, KycSessionStatus, + KycSessionStatusResponse, KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubSdkStatus, KycSumSubStatus, - KycUserStatus, - KycUserStatusResponse, KycVendor, KycIronVendorDisclaimersAccepted, KycMoonpayVendorDisclaimersAccepted, From adea851000afcb4bc9000b6f85e699fd93860518 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 16 Sep 2026 15:03:54 -0700 Subject: [PATCH 4/6] spec --- .../kyc-controller/src/KycController.test.ts | 45 ++++++++++++++++++- packages/kyc-controller/src/KycController.ts | 9 ++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 7f97208a453..c07f27e1f46 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -2229,6 +2229,19 @@ describe('KycController', () => { }); } + it('swallows a failed session-status refresh after the SDK completes', async () => { + await withController( + { options: { sessionStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockRejectedValue(new Error('status down')); + + await controller.startSumSub(); + expect(controller.state.sumsub.status).toBe('polling'); + }, + ); + }); + it('polls the session status after completion and completes on an approved status', async () => { await withController(async ({ controller, handlers, launcher }) => { completeSdk(launcher); @@ -3953,7 +3966,7 @@ describe('KycController', () => { return { ok: true }; }); handlers.getSessionStatus - .mockResolvedValueOnce(sessionStatus('approved')) + .mockResolvedValueOnce(sessionStatus('pending')) .mockRejectedValue(new Error('status down')); await controller.acceptTermsAndStartSession({ @@ -3963,7 +3976,7 @@ describe('KycController', () => { }); expect(controller.state.phase).toBe('done'); - expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.status).toBe('polling'); controller.reset(); }, ); @@ -4753,6 +4766,34 @@ describe('KycController', () => { } }); + it('returns null when reset lands during a failed refresh fetch', async () => { + await withController( + { + options: { + state: { sessionId: 'sid', sessionStatus: sessionStatus('pending') }, + sessionStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.getSessionStatus.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release(new Error('late')); + const result = await pending; + + expect(result).toBeNull(); + }, + ); + }); + it('returns null session status when reset lands during refresh', async () => { await withController( { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index e878e4f0d80..c27d32e9d88 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -2324,9 +2324,6 @@ export class KycController extends BaseController< */ async #startPolling(options: { updateSumSubStatus: boolean }): Promise { this.#stopPolling(); - if (!this.state.sessionId) { - return; - } this.#updateSumSubOnTerminal = options.updateSumSubStatus; this.#polling = true; await this.#runPollTick(this.#pollToken); @@ -2439,7 +2436,11 @@ export class KycController extends BaseController< * by a reset / new sub-flow), `false` when it should keep polling. */ async #pollOnce(token: number): Promise { - const sessionId = this.state.sessionId; + const { sessionId } = this.state; + // Defensive: `#startPolling` / `#ensurePolling` require a session id, and + // `reset()` / `clearState()` cancel the loop. Keep this so a cleared + // session cannot be polled if a tick still lands. + /* istanbul ignore next */ if (!sessionId) { this.#stopPolling(); return true; From 97060ad7a067ddc119e63ea2d30fd057ce57f45a Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 16 Sep 2026 15:09:39 -0700 Subject: [PATCH 5/6] changelog --- packages/kyc-controller/CHANGELOG.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index cdaa07692eb..39de0176888 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,24 +9,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Move the active UKYC `sessionId` and `sessionStatus` from `sumsub` to the root of `KycControllerState`. +- **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`. Neither field is persisted. -- **BREAKING:** `KycController.refreshKycStatus` now loads status from `GET /sessions/{id}/status` (`getSessionStatus`) instead of `GET /kyc/status`. +- **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`. Returns and publishes the UKYC `sessionStatus` payload as-is (`null` when none is recorded). -- **BREAKING:** Replace `KycUserStatus` with `KycSessionStatus` (`new` | `pending` | `approved` | `rejected` | `retry`). + - Skips the network refresh when `finalStatus` is already successful (`approved` / `completed`); otherwise polls until a terminal status. +- **BREAKING:** Replace `KycUserStatus` with `KycSessionStatus` (`new` | `pending` | `approved` | `rejected` | `retry`). ([#10276](https://github.com/MetaMask/core/pull/10276)) - `not-started` → `new`, `completed` → `approved`, `terminal-failure` → `rejected`. Drop `need-more-information`. -- **BREAKING:** Rename the `GET /sessions/{id}/status` payload type from `KycSessionStatus` to `KycSessionStatusResponse`. -- **BREAKING:** Remove `userStatus`, `userStatusSumsubSessionId`, and `userStatusErrorCode` from `KycControllerState`. +- **BREAKING:** Rename the `GET /sessions/{id}/status` payload type from `KycSessionStatus` to `KycSessionStatusResponse`. ([#10276](https://github.com/MetaMask/core/pull/10276)) +- **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. +- **BREAKING:** Combine the session-status and user-status poll loops onto one timer. ([#10276](https://github.com/MetaMask/core/pull/10276)) - Both post-SDK decision waits and `refreshKycStatus` pending polls use `sessionStatusPollIntervalMs` (default 15s) against `GET /sessions/{id}/status`. - Bump `@metamask/profile-sync-controller` from `^32.1.0` to `^32.1.1` ([#10220](https://github.com/MetaMask/core/pull/10220)) ### Removed -- **BREAKING:** Remove `KycService.fetchKycStatus` and the `KycService:fetchKycStatus` messenger action. -- **BREAKING:** Remove `KycControllerOptions.userStatusPollIntervalMs`. Use `sessionStatusPollIntervalMs` instead. -- **BREAKING:** Remove `KycUserStatusResponse`. Use `KycControllerStatusChangedEvent` / `refreshKycStatus`'s return payload instead. +- **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)) ## [0.3.0] From c8e9d721fb7336f2cc4af38b926af519188dba86 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Wed, 16 Sep 2026 15:19:23 -0700 Subject: [PATCH 6/6] persist sessionId --- packages/kyc-controller/ARCHITECTURE.md | 13 ++++++----- packages/kyc-controller/CHANGELOG.md | 6 ++--- .../src/KycController-method-action-types.ts | 3 ++- .../kyc-controller/src/KycController.test.ts | 23 ++++++++++++++++++- packages/kyc-controller/src/KycController.ts | 11 +++++---- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 820bb95c69f..e177fccf5f0 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -187,7 +187,7 @@ classDiagram +KycProduct activeProduct +Record kycRequiredByProduct [persisted] +string lastCheckedAt [persisted] - +string sessionId + +string sessionId [persisted] +KycSessionStatusResponse sessionStatus +SumSubState sumsub } @@ -207,10 +207,11 @@ 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()`. + `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 + `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 @@ -218,7 +219,7 @@ State metadata highlights (`kycControllerMetadata`): path proceeds); a failed or reset switch leaves the previous vendor's acceptance in place. - **Secrets, never persisted / never logged**: `moonpaySessionToken`, `moonpayAccessToken`, - `moonpayCustomerId`, `email`, `vendorDisclaimers`, `sessionId`, and the whole `sumsub` sub-tree. + `moonpayCustomerId`, `email`, `vendorDisclaimers`, and the whole `sumsub` sub-tree. Switching away from MoonPay (`initialize` / `createVendorCustomer`) drops these MoonPay Check/Auth artifacts immediately so `buildCheckFrameUrl` cannot return a MoonPay URL while `activeVendor` is a consents-path vendor. diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 39de0176888..7eca53b2508 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,12 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **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`. Neither field is persisted. + - Read `state.sessionId` / `state.sessionStatus` instead of `state.sumsub.sessionId` / `state.sumsub.sessionStatus`. `sessionId` is persisted; `sessionStatus` is not. - **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`. Returns and publishes the UKYC `sessionStatus` payload as-is (`null` when none is recorded). - - Skips the network refresh when `finalStatus` is already successful (`approved` / `completed`); otherwise polls until a terminal status. + - 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)) - - `not-started` → `new`, `completed` → `approved`, `terminal-failure` → `rejected`. Drop `need-more-information`. - **BREAKING:** Rename the `GET /sessions/{id}/status` payload type from `KycSessionStatus` to `KycSessionStatusResponse`. ([#10276](https://github.com/MetaMask/core/pull/10276)) - **BREAKING:** Remove `userStatus`, `userStatusSumsubSessionId`, and `userStatusErrorCode` from `KycControllerState`. ([#10276](https://github.com/MetaMask/core/pull/10276)) - Read `state.sessionStatus`, or use `refreshKycStatus` / `KycController:statusChanged`. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index c881aed9047..1b3e246fe40 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -235,12 +235,13 @@ export type KycControllerStartSumSubAction = { * {@link KycControllerStatusChangedEvent}, and schedules short-interval * polling while the status is not terminal. * - * No-ops without an active `sessionId`. Skipped when the recorded + * Throws without an active `sessionId`. Skipped when the recorded * session status is already successful (`approved` / `completed`): 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. * * @returns The recorded session status, or `null` if none. + * @throws If there is no active UKYC session to query. */ export type KycControllerRefreshKycStatusAction = { type: `KycController:refreshKycStatus`; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index c07f27e1f46..c97422ed32b 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -181,6 +181,13 @@ describe('KycController', () => { }, ); }); + + it('persists sessionId across restarts', async () => { + await withController(({ controller }) => { + expect(controller.metadata.sessionId.persist).toBe(true); + expect(controller.metadata.sessionStatus.persist).toBe(false); + }); + }); }); describe('initialize', () => { @@ -4495,6 +4502,15 @@ 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, + ); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + }); + }); + it('refreshKycStatus stores status and emits statusChanged', async () => { await withController( { @@ -4865,7 +4881,12 @@ describe('KycController', () => { it('defaults superseded refresh status to null when unset', async () => { await withController( - { options: { sessionStatusPollIntervalMs: 60_000 } }, + { + options: { + state: { sessionId: 'sid' }, + sessionStatusPollIntervalMs: 60_000, + }, + }, async ({ controller, handlers }) => { let release: (value: { status: string }) => void = () => { // placeholder diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index c27d32e9d88..cf531faef4f 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -289,7 +289,8 @@ export type KycControllerState = { /** * Active UKYC session id from `createUkycSession`. `null` outside a - * document-verification sub-flow. Not persisted. + * document-verification sub-flow. Persisted so a restarted client can + * resume {@link KycController.refreshKycStatus} / polling. */ sessionId: string | null; /** @@ -425,7 +426,7 @@ const kycControllerMetadata = { sessionId: { includeInDebugSnapshot: false, includeInStateLogs: false, - persist: false, + persist: true, usedInUi: true, }, sessionStatus: { @@ -2213,12 +2214,13 @@ export class KycController extends BaseController< * {@link KycControllerStatusChangedEvent}, and schedules short-interval * polling while the status is not terminal. * - * No-ops without an active `sessionId`. Skipped when the recorded + * Throws without an active `sessionId`. Skipped when the recorded * {@link sessionStatus} is already successful (`approved` / `completed`): 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. * * @returns The recorded session status, or `null` if none. + * @throws If there is no active UKYC session to query. */ async refreshKycStatus(): Promise { if ( @@ -2252,10 +2254,11 @@ export class KycController extends BaseController< * poll loop (used by {@link refreshKycStatus}). * * @returns The recorded session status, or `null` if none. + * @throws If there is no active UKYC session to query. */ async #fetchAndRecordSessionStatus(): Promise { if (!this.state.sessionId) { - return this.state.sessionStatus; + throw new Error('Cannot fetch session status: no active session.'); } const generation = this.#generation; try {