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