diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 1d6305d889c..97852002a37 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -76,6 +76,9 @@
## Product Safety Team
/packages/phishing-controller @MetaMask/product-safety
+## Universal KYC Team
+/packages/kyc-controller @MetaMask/universal-kyc
+
## Swaps-Bridge Team
/packages/bridge-controller @MetaMask/swaps-engineers
/packages/bridge-status-controller @MetaMask/swaps-engineers
diff --git a/README.md b/README.md
index 55139eccac1..47555a1d465 100644
--- a/README.md
+++ b/README.md
@@ -426,6 +426,12 @@ linkStyle default opacity:0.5
keyring_controller --> base_controller;
keyring_controller --> controller_utils;
keyring_controller --> messenger;
+ kyc_controller --> base_controller;
+ kyc_controller --> base_data_service;
+ kyc_controller --> controller_utils;
+ kyc_controller --> geolocation_controller;
+ kyc_controller --> messenger;
+ kyc_controller --> profile_sync_controller;
logging_controller --> base_controller;
logging_controller --> messenger;
message_manager --> base_controller;
diff --git a/codeowners.ts b/codeowners.ts
index ba3c00c2aef..5c4218cba36 100644
--- a/codeowners.ts
+++ b/codeowners.ts
@@ -514,6 +514,10 @@ function buildTeamSections(): CodeownersSection[] {
title: 'Product Safety Team',
rules: [buildRuleForPackage('phishing-controller')],
},
+ {
+ title: 'Universal KYC Team',
+ rules: [buildRuleForPackage('kyc-controller')],
+ },
{
title: 'Swaps-Bridge Team',
rules: [
diff --git a/eslint.config.mjs b/eslint.config.mjs
index faf5e8039aa..4275fa3d871 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -314,6 +314,15 @@ const config = createConfig([
'no-restricted-globals': 'off',
},
},
+ {
+ // The UKYC test-token minter is a dev-only Node CLI, so it may use Node
+ // builtins and globals unlike the platform-agnostic package source.
+ files: ['packages/kyc-controller/scripts/**/*.ts'],
+ rules: {
+ 'import-x/no-nodejs-modules': 'off',
+ 'no-restricted-globals': 'off',
+ },
+ },
{
files: [
'packages/wallet-cli/src/**/*.test.{js,ts}',
diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md
new file mode 100644
index 00000000000..265a538fbcf
--- /dev/null
+++ b/packages/kyc-controller/ARCHITECTURE.md
@@ -0,0 +1,663 @@
+## Architecture
+
+`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns
+the end-to-end KYC / identity-verification flow used across MetaMask clients
+(mobile, extension, web). It hides the vendor implementation (currently
+**MoonPay** for identity + **SumSub** for document verification) behind a
+vendor-neutral, per-product surface consumed by features such as **ramps** and
+**card**.
+
+This document explains:
+
+- The package's internal building blocks and responsibilities.
+- How the pieces communicate (messenger actions, injected adapters).
+- The identity flow as a state machine and an end-to-end sequence.
+- The encrypted frame message protocol and crypto.
+- How the **metamask-mobile** client wires everything together on the client
+ side.
+
+---
+
+### 1. Design principles
+
+The package is built around a few deliberate constraints:
+
+| Principle | How it shows up in the code |
+| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. |
+| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. |
+| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. |
+| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. |
+| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. |
+
+---
+
+### 2. Component overview
+
+The package splits cleanly into a **stateful orchestrator** (`KycController`), a
+**stateless HTTP client** (`KycService`), and supporting modules (crypto,
+selectors, types).
+
+```mermaid
+graph TB
+ subgraph pkg["@metamask/kyc-controller"]
+ direction TB
+ Controller["KycController
(BaseController)
state + orchestration + frame protocol"]
+ 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, ..."]
+ Country["countryCodes.ts
alpha-2 → alpha-3"]
+ end
+
+ subgraph deps["External MetaMask dependencies"]
+ Base["@metamask/base-controller"]
+ Msgr["@metamask/messenger"]
+ CU["@metamask/controller-utils
createServicePolicy, HttpError"]
+ Geo["GeolocationController"]
+ Auth["AuthenticationController
(profile-sync)"]
+ end
+
+ subgraph vendor["Vendor backends (HTTP / frames)"]
+ UKYC["Universal KYC API
kyc-api.cx.metamask.io"]
+ Frames["MoonPay frames
blocks.moonpay.com"]
+ SumSubSDK["SumSub SDK
(native / web)"]
+ end
+
+ Controller -->|"decryptCredentials()"| Crypto
+ Controller -->|"messenger.call(KycService:*)"| Service
+ Controller -.->|"injected launcher"| SumSubSDK
+ Controller -->|"builds frame URLs
handles frame messages"| Frames
+
+ Service -->|"createServicePolicy / HttpError"| CU
+ Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo
+ Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth
+ Service -->|"fetch()"| UKYC
+
+ Controller --- Base
+ Controller --- Msgr
+ Service --- Msgr
+ Selectors -.->|"read"| Controller
+```
+
+#### 2.1 `KycController`
+
+- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`.
+- Holds **all flow state** (see [§3](#3-state-shape)).
+- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction —
+ never persisted, used only for the frame key exchange.
+- Registers its public methods as messenger actions via
+ `registerMethodActionHandlers`.
+- Calls `KycService` exclusively **through the messenger** (`KycService:*`
+ actions), never a direct reference.
+- Delegates SumSub SDK presentation to an injected `sumsubLauncher`
+ (`KycSumSubLauncher`).
+- When the flow is scoped to a product (passed to `initialize` /
+ `acceptTermsAndStartSession` and stored as `activeProduct`), automatically
+ runs the KYC-required check once authenticated and chains into document
+ verification when KYC is required — no extra consumer calls needed.
+
+Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`):
+
+`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`,
+`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`,
+`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`,
+`startSumSub`, `reset`.
+
+#### 2.2 `KycService`
+
+- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC)
+ backend.
+- Base URL derived from `env` (`production` / `development`) or an explicit
+ `baseUrl` override.
+- Every request is wrapped in a **service policy** (`createServicePolicy`) for
+ retries/circuit-breaking, and carries a **bearer token** obtained from
+ `AuthenticationController:getBearerToken`.
+- Every response is validated with **superstruct** before being returned;
+ malformed responses throw a descriptive error.
+- Resolves the customer's country from `GeolocationController:getGeolocation`
+ and maps alpha-2 → alpha-3.
+
+Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`):
+
+`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`,
+`createUkycSession`, `createJourney`.
+
+Endpoints:
+
+| Method | HTTP | Endpoint | Purpose |
+| ------------------- | ------ | --------------------------------------- | ----------------------------------------------------------------------- |
+| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country |
+| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept |
+| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session |
+| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) |
+| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) |
+| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token |
+
+### 2.3 `crypto.ts`
+
+Implements the Check/Auth frame credential decryption:
+
+1. Client generates an X25519 keypair; the public key (hex) is added to the
+ frame URL.
+2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`.
+3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then
+ `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the
+ ciphertext (which includes the 16-byte tag). IV must be 12 bytes.
+
+It tolerates envelopes delivered as an object, a JSON string, or base64(JSON),
+and hex-or-base64 binary fields.
+
+#### 2.4 `selectors.ts`
+
+Memoized `reselect` selectors over `KycControllerState`:
+`selectKycPhase`, `selectKycSumSub`, and the parametric
+`selectIsKycRequiredForProduct(product)`.
+
+---
+
+### 3. State shape
+
+```mermaid
+classDiagram
+ class KycControllerState {
+ +KycPhase phase
+ +string statusMessage
+ +string error
+ +string email
+ +string termsAcceptedAt [persisted]
+ +string[] acceptedDisclaimerIds [persisted]
+ +KycDisclaimer[] disclaimers
+ +string disclaimersError
+ +string geoCountry
+ +string sessionToken [secret]
+ +string accessToken [secret]
+ +string moonpayCustomerId
+ +KycProduct activeProduct
+ +Record kycRequiredByProduct [persisted]
+ +string lastCheckedAt [persisted]
+ +SumSubState sumsub
+ }
+ class SumSubState {
+ +KycSumSubStatus status
+ +Json result
+ +string sessionId
+ +string applicantAccessToken
+ }
+ KycControllerState --> SumSubState : sumsub
+```
+
+> Note: nullable fields (`error`, `email`, `sessionToken`, …) 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`): `termsAcceptedAt`, `acceptedDisclaimerIds`,
+ `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow
+ can skip already-accepted terms and reuse cached results.
+- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`,
+ `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree.
+- Additional non-state secrets kept **off** the state object entirely: the
+ X25519 private key (`#keypair`) and the Auth-frame client token
+ (`#authClientToken`).
+
+---
+
+### 4. The identity flow (phase state machine)
+
+`KycPhase` models the linear identity flow. Each transition is driven by a
+controller method or an incoming frame message.
+
+```mermaid
+stateDiagram-v2
+ [*] --> idle
+ idle --> terms : initialize() (no saved terms)
+ idle --> session : initialize() (saved terms + email)
+
+ terms --> session : acceptTermsAndStartSession()
+ session --> check : createSession() ok
+ session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens)
+
+ check --> form : Check frame → active (already authenticated)
+ check --> auth : Check frame → connectionRequired (needs OTP)
+ check --> terms : Check frame → termsAcceptanceRequired
+
+ auth --> form : Auth frame → active (OTP verified)
+ auth --> terms : Auth frame → termsAcceptanceRequired
+
+ form --> submit : checkKycRequired()
(auto when a product is set)
+ submit --> done : kyc-required response ok
+ submit --> error : request failed
+
+ check --> error : unexpected status / decrypt failure
+ auth --> error : unexpected status
+ done --> [*]
+ error --> idle : reset()
+ done --> idle : reset()
+```
+
+> When the flow is scoped to a product (a `product` is passed to `initialize`
+> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the
+> KYC-required check (`form → submit → done`) with no user interaction, and — if
+> KYC is required — automatically launches the SumSub document-verification
+> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops
+> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually.
+
+> **`initialize` never tears down an active flow.** If `phase` is already one of
+> the in-progress phases (`session`, `check`, `auth`, `form`, `submit`), a
+> repeat `initialize` is a **no-op** — it will not create a new session, clear
+> tokens, or reset `activeProduct`. Call `reset()` first to start over.
+
+> **`reset()` is callable from any phase and supersedes in-flight work.** In
+> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`,
+> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any
+> still-pending async step (geolocation, disclaimers, session creation, the
+> KYC-required check, or the SumSub sub-flow) discards its result instead of
+> writing it onto the now-idle controller.
+
+Phase meanings (from `types.ts`):
+
+| Phase | Meaning |
+| --------- | ----------------------------------------------------------------------------------------------------------- |
+| `idle` | Nothing started. |
+| `terms` | Waiting for the customer to accept vendor terms. |
+| `session` | Creating the vendor session. |
+| `check` | Running the **invisible** connection-check frame. |
+| `auth` | Running the **visible** authentication (email OTP) frame. |
+| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. |
+| `submit` | Submitting the KYC-required check. |
+| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. |
+| `error` | Halted — see `error`. |
+
+---
+
+### 5. End-to-end sequence
+
+This sequence shows the full happy path including the two frames and the SumSub
+hand-off. The **client transport** (WebView on mobile, iframe on web) is
+generic — it only forwards raw frame messages to `handleFrameMessage` and posts
+back any returned `reply`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User
+ participant UI as Client UI + transport
(WebView/iframe)
+ participant Ctrl as KycController
+ participant Svc as KycService
+ participant Geo as GeolocationController
+ participant API as UKYC API
+ participant Frame as MoonPay Check/Auth frame
+ participant Launcher as SumSub launcher (injected)
+
+ User->>Ctrl: initialize({ email, product })
+ Ctrl->>Svc: getGeoCountry()
+ Svc->>Geo: getGeolocation()
+ Note over Svc: map alpha-2 → alpha-3 locally
+ Ctrl->>Svc: fetchDisclaimers({ country })
+ Svc->>API: GET /disclaimers
+ Ctrl-->>UI: phase = terms (+ disclaimers)
+
+ User->>Ctrl: acceptTermsAndStartSession({ email })
+ Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds })
+ Svc->>API: POST /sessions
+ Ctrl-->>UI: phase = check (+ sessionToken)
+
+ UI->>Ctrl: buildCheckFrameUrl()
+ Ctrl-->>UI: URL (sessionToken + publicKey)
+ UI->>Frame: load Check frame (invisible)
+ Frame-->>UI: handshake
+ UI->>Ctrl: handleFrameMessage(handshake)
+ Ctrl-->>UI: reply = ack
+ UI->>Frame: post ack
+ Frame-->>UI: complete (status + encrypted credentials)
+ UI->>Ctrl: handleFrameMessage(complete)
+ Note over Ctrl: decryptCredentials() → accessToken / clientToken
+
+ alt Check → connectionRequired
+ Ctrl-->>UI: phase = auth
+ UI->>Frame: load Auth frame (visible, OTP)
+ Frame-->>UI: complete (active + credentials)
+ UI->>Ctrl: handleFrameMessage(complete)
+ end
+
+ Ctrl-->>UI: phase = form (accessToken set)
+
+ Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action)
+ Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities })
+ Svc->>API: POST /kyc-required
+ Ctrl-->>UI: phase = done (kycRequiredByProduct[product])
+
+ opt kycRequired === true → auto-launch document verification
+ Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata, wrappedEncryptionKey, ukycCapabilityToken })
+ Svc->>API: POST /sessions
+ Ctrl->>Svc: createJourney(sessionId)
+ Svc->>API: POST /sessions/{id}/journey
+ Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange })
+ Launcher-->>Ctrl: SDK result
+ Ctrl-->>UI: sumsub.status = complete (+ result)
+ end
+```
+
+> The KYC-required check and the document-verification launch after `form` are
+> driven by the controller itself, not the user — the flow captures the
+> `product` at `initialize` and continues automatically. If `initialize` is
+> called without a `product`, the flow stops at `form` and the consumer triggers
+> `checkKycRequired` (and later `startSumSub`) explicitly.
+
+---
+
+### 6. Frame message protocol & crypto
+
+The Check, Auth and Reset frames all speak a small `postMessage` protocol.
+`KycController.handleFrameMessage` implements the identity portion; the client
+transport is responsible only for delivering messages and injecting replies.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Frame as MoonPay frame
+ participant UI as Client transport
+ participant Ctrl as KycController
+
+ Frame->>UI: { kind: "handshake", meta:{channelId} }
+ UI->>Ctrl: handleFrameMessage({ message })
+ Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } }
+ UI->>Frame: postMessage(ack)
+
+ Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } }
+ UI->>Ctrl: handleFrameMessage({ message })
+ Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth)
+ Ctrl->>Ctrl: apply outcome → next phase
+```
+
+Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset.
+
+> **Phase-guarded intake.** A `complete` is only processed when the flow is
+> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2`
+> while `phase === 'auth'`. Because both outcome handlers advance `phase` to
+> `form` synchronously, a stale, duplicate, or post-`reset()` `complete`
+> (delivered once the flow has moved on) is dropped before any state is touched,
+> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`.
+> Frame messages are external input and are not covered by the `#generation`
+> guard used for the controller's own async steps, so this boundary check is how
+> late frame posts are neutralized.
+
+Credential decryption (`crypto.ts`):
+
+```mermaid
+graph LR
+ A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"]
+ B --> C["HKDF-SHA256
key (32 bytes)"]
+ C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"]
+ D --> E["JSON credentials
{ accessToken?, clientToken? }"]
+```
+
+Check-frame outcomes (`#handleCheckOutcome`):
+
+- `active` + `accessToken` → phase `form` (already authenticated).
+- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`.
+- `termsAcceptanceRequired` → clear saved terms, phase `terms`.
+- anything else → `error`.
+
+Auth-frame outcomes (`#handleAuthOutcome`):
+
+- `active` + `accessToken` → phase `form`.
+- `termsAcceptanceRequired` → clear saved terms, phase `terms`.
+- anything else → `error`.
+
+---
+
+### 7. SumSub sub-flow
+
+The document-verification sub-flow tracks its own status independently of the
+identity `phase`, and delegates the actual SDK presentation to the injected
+launcher.
+
+```mermaid
+stateDiagram-v2
+ [*] --> idle
+ idle --> creatingSession : startSumSub()
+ creatingSession --> fetchingToken : createUkycSession() ok
+ creatingSession --> vendorProcessing : createUkycSession() kycStatus=approved, finalStatus=pending
+ fetchingToken --> launching : createJourney() ok
+ launching --> inProgress : onStatusChange (non-Completed)
+ launching --> complete : onStatusChange = Completed
+ inProgress --> complete : onStatusChange = Completed
+ launching --> failed : resolves without a Completed status
+ inProgress --> failed : resolves without a Completed status
+ creatingSession --> failed : error
+ fetchingToken --> failed : error
+ launching --> failed : launcher unavailable / error
+```
+
+> **Already processing on the vendor.** A user who already finished the journey
+> can return to a session the relay has approved (`kycStatus: approved`) while
+> the vendor is still finalizing its decision (`finalStatus: pending`). When
+> session creation reports this, the sub-flow stops at `vendorProcessing`
+> (setting `statusMessage`) instead of launching the SDK, so an already-approved
+> applicant is not asked to verify again.
+
+> **Completion is status-driven, not resolution-driven.** A resolved `launch`
+> is only recorded as `complete` when the SDK reported the `Completed` status
+> via `onStatusChange` at least once. If `launch` resolves without ever having
+> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success
+> outcome), the controller records `failed` — so consumers never mistake an
+> unfinished flow for a verified one.
+
+The `KycSumSubLauncher` interface (injected per client):
+
+```ts
+type KycSumSubLauncher = {
+ isAvailable(): boolean;
+ launch(params: KycSumSubLaunchParams): Promise>;
+};
+```
+
+`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the
+controller re-runs `createJourney` to refresh — but **refuses to refresh
+after a `reset()`**, throwing instead so a still-open SDK cannot keep an
+orphaned UKYC session alive), and an `onStatusChange` callback that the
+controller maps into `sumsub.status`.
+
+---
+
+### 8. Messenger wiring
+
+Both classes are messenger-driven. The controller depends on the service's
+actions; the service depends on auth + geolocation actions from other
+controllers.
+
+```mermaid
+graph LR
+ subgraph CtrlMsgr["KycControllerMessenger"]
+ C_own["Own actions:
KycController:getState + 12 methods"]
+ C_ext["Allowed (delegated):
KycService:*"]
+ end
+ subgraph SvcMsgr["KycServiceMessenger"]
+ S_own["Own actions:
KycService: 6 methods"]
+ S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"]
+ end
+
+ C_ext -.delegates.-> S_own
+ S_ext -.delegates.-> Auth["AuthenticationController"]
+ S_ext -.delegates.-> Geo["GeolocationController"]
+```
+
+- `KycController` emits `KycController:stateChange` and exposes
+ `KycController:getState` plus its method actions.
+- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call
+ the service.
+- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation
+ actions.
+
+---
+
+### 9. Client-side usage (metamask-mobile)
+
+The mobile app is a reference consumer. It wires the controller/service into the
+Engine, injects a React Native SumSub launcher, bridges WebView frame messages,
+and reads state through Redux selectors. The **package stays free of any of
+this** — all React/native/WebView code lives in the app.
+
+```mermaid
+graph TB
+ subgraph app["metamask-mobile"]
+ direction TB
+ subgraph engine["Engine wiring"]
+ CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"]
+ SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"]
+ CMsgr["kyc-controller-messenger.ts
delegates KycService:*"]
+ SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"]
+ Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"]
+ end
+ subgraph ui["UI layer"]
+ Hook["useKycFlow.ts
binds controller ↔ React"]
+ Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"]
+ Reset["useMoonpayReset.ts
Reset frame"]
+ Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"]
+ end
+ subgraph redux["Redux"]
+ Sel["selectors/kycController.ts
wraps core selectors"]
+ end
+ end
+
+ subgraph core["@metamask/kyc-controller"]
+ KC["KycController"]
+ KS["KycService"]
+ end
+
+ CInit --> KC
+ SInit --> KS
+ CInit --> Launcher
+ Launcher -. injected .-> KC
+ CMsgr --> KC
+ SMsgr --> KS
+
+ Hook -->|"Engine.context.KycController.*"| KC
+ Hook -->|"useSelector"| Sel
+ Sel -->|"state.engine.backgroundState.KycController"| KC
+ Frame -->|"raw frame message"| Hook
+ Hook -->|"handleFrameMessage()"| KC
+ Demo --> Hook
+ Demo --> Frame
+ Demo --> Reset
+```
+
+#### 9.1 Engine wiring
+
+- **`kyc-controller-init.ts`** constructs `KycController` with the persisted
+ state slice and injects `reactNativeSumSubLauncher`.
+- **`kyc-service-init.ts`** constructs `KycService` with the global `fetch`, an
+ `env` derived from `isProduction()`, and (currently) a dev `baseUrl` override.
+- **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to
+ the controller's messenger.
+- **`kyc-service-messenger.ts`** delegates
+ `AuthenticationController:getBearerToken` and
+ `GeolocationController:getGeolocation` to the service's messenger.
+
+#### 9.2 SumSub launcher adapter
+
+`reactNativeSumSubLauncher` implements `KycSumSubLauncher`:
+
+- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`).
+- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so
+ merely wiring the controller never loads the native module — important for
+ Jest / Expo Go), initializes the SDK with the applicant token, and forwards
+ `onStatusChanged` / token-expiration callbacks back to the controller.
+
+#### 9.3 React binding — `useKycFlow`
+
+A thin hook that:
+
+- Reads controller state from Redux via the `selectors/kycController.ts`
+ selectors.
+- Forwards user intents to controller actions through
+ `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`,
+ `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`).
+- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as
+ the phase changes.
+- Bridges WebView frame messages into `handleFrameMessage` and posts back the
+ returned `reply`.
+- Keeps view-only concerns (email input, debug log, frame visibility) in local
+ React state.
+
+#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame`
+
+- Injects a `postMessage` bridge into the frame that forwards the frame's
+ outbound messages to React Native via `window.ReactNativeWebView.postMessage`.
+- **Validates the origin** (`https://blocks.moonpay.com`) before handing a
+ message to the controller.
+- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on
+ both `document` and `window` (platform quirk between iOS WKWebView and Android
+ System WebView).
+- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user
+ toggles it in the debug panel; the Auth frame is rendered visibly for OTP.
+
+#### 9.5 Redux selectors
+
+`selectors/kycController.ts` wraps the package's core selectors and reads the
+slice at `state.engine.backgroundState.KycController`, exposing app-friendly
+selectors (`selectKycPhase`, `selectKycSumSub`,
+`selectIsKycRequiredForProduct(product)`, plus per-field selectors).
+
+---
+
+### 10. Boundaries & responsibilities summary
+
+```mermaid
+graph LR
+ subgraph shared["Shared package (platform-agnostic)"]
+ A1["Flow orchestration + state"]
+ A2["HTTP + response validation"]
+ A3["Crypto (X25519 / AES-GCM)"]
+ A4["Frame message protocol"]
+ A5["Selectors + vendor-neutral types"]
+ end
+ subgraph client["Client (per platform)"]
+ B1["Engine/DI wiring"]
+ B2["WebView / iframe transport"]
+ B3["SumSub SDK launcher"]
+ B4["Auth token + geolocation providers"]
+ B5["UI + Redux binding"]
+ end
+ shared -. injected adapters .- client
+```
+
+| Concern | Owner |
+| ------------------------------------ | ------------------------------------------- |
+| Flow phase machine & state | `KycController` (shared) |
+| UKYC HTTP + validation + retries | `KycService` (shared) |
+| Credential decryption / key exchange | `crypto.ts` (shared) |
+| Frame message semantics | `KycController.handleFrameMessage` (shared) |
+| Frame **transport** (WebView/iframe) | Client |
+| SumSub SDK presentation | Client (via `KycSumSubLauncher`) |
+| Auth bearer token / geolocation | Other controllers (via messenger) |
+| Persistence of state | Client (base-controller persistence) |
+
+---
+
+### Appendix — key source files
+
+| File | Responsibility |
+| ---------------------- | ----------------------------------------------------- |
+| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. |
+| `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). |
+
+Reference client (metamask-mobile):
+
+| File | Responsibility |
+| -------------------------------------------------------------- | --------------------------------------- |
+| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. |
+| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. |
+| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. |
+| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. |
+| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. |
+| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. |
+| `app/selectors/kycController.ts` | Redux selectors. |
diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md
index 3790c256428..a6c6828a594 100644
--- a/packages/kyc-controller/CHANGELOG.md
+++ b/packages/kyc-controller/CHANGELOG.md
@@ -10,5 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781))
+- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#9847](https://github.com/MetaMask/core/pull/9847))
+- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9847](https://github.com/MetaMask/core/pull/9847))
+- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615))
+ - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter.
+ - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger.
+ - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors.
+ - Add automatic post-authentication continuation to `KycController`
+ - Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API
+ - Add UKYC session-status polling to `KycController`
+ - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor
[Unreleased]: https://github.com/MetaMask/core/
diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md
index e182b37a067..32ee194a139 100644
--- a/packages/kyc-controller/README.md
+++ b/packages/kyc-controller/README.md
@@ -1,4 +1,4 @@
-# `@metamask/kyc-controller`
+# KYC Controller `@metamask/kyc-controller`
Shared KYC / identity verification controller used across MetaMask clients
@@ -10,6 +10,14 @@ or
`npm install @metamask/kyc-controller`
+## Development
+
+To rebuild the package automatically whenever you change a source file, run the `build:watch` script from core repository root folder:
+
+`yarn workspace @metamask/kyc-controller run build:watch`
+
+This watches `src/**/*.ts` and re-runs the build on each change (it also performs an initial build on start), which is useful when developing against a client that consumes this package locally.
+
## Contributing
-This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
+This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
\ No newline at end of file
diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json
index 32c279f8a06..22967e56612 100644
--- a/packages/kyc-controller/package.json
+++ b/packages/kyc-controller/package.json
@@ -42,24 +42,46 @@
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
"build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
"build:docs": "typedoc",
+ "build:watch": "yarn build && chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial",
"changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller",
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller",
"lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts",
"lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix",
- "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check",
- "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate",
+ "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check",
+ "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate",
+ "mint:ukyc-token": "tsx scripts/mint-ukyc-test-token.ts",
"since-latest-release": "../../scripts/since-latest-release.sh",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
+ "dependencies": {
+ "@metamask/base-controller": "^9.1.0",
+ "@metamask/base-data-service": "^0.1.3",
+ "@metamask/controller-utils": "^12.3.0",
+ "@metamask/geolocation-controller": "^1.0.0",
+ "@metamask/keyring-controller": "^27.1.1",
+ "@metamask/messenger": "^2.0.0",
+ "@metamask/profile-sync-controller": "^29.0.0",
+ "@metamask/superstruct": "^3.4.1",
+ "@metamask/utils": "^11.11.0",
+ "@noble/ciphers": "^1.3.0",
+ "@noble/curves": "^1.9.2",
+ "@noble/hashes": "^1.8.0",
+ "@scure/base": "^1.2.6",
+ "@tanstack/query-core": "^4.43.0",
+ "reselect": "^5.1.1",
+ "tweetnacl": "^1.0.3"
+ },
"devDependencies": {
"@metamask/auto-changelog": "^6.1.0",
"@ts-bridge/cli": "^0.6.4",
"@types/jest": "^30.0.0",
+ "chokidar-cli": "^3.0.0",
"deepmerge": "^4.2.2",
"jest": "^30.4.2",
+ "nock": "^13.3.1",
"ts-jest": "^29.4.11",
"tsx": "^4.20.5",
"typedoc": "^0.25.13",
diff --git a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts
new file mode 100644
index 00000000000..82b51801acd
--- /dev/null
+++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts
@@ -0,0 +1,106 @@
+/**
+ * CLI to mint a UKYC `storage_access_token` for testing UKYC Storage.
+ *
+ * All real logic lives in the tested `mintUkycTestToken`; this is a thin
+ * argument-parsing wrapper that prints the result as JSON.
+ *
+ * Usage (from the package root, via the `mint:ukyc-token` script):
+ * yarn workspace @metamask/kyc-controller run mint:ukyc-token -- \
+ * --operations read,write --expires-in 4h [--secret ] \
+ * [--presenter client|idos-relay] [--session-id ]
+ *
+ * Reuse the printed `localUserSecret` (pass it back via --secret) to keep the
+ * same `storageId` and controlling key across runs.
+ */
+import process from 'node:process';
+
+import type {
+ UkycStorageOperation,
+ UkycTokenPresenter,
+} from '../src/ukyc/storageAccessToken.js';
+import { mintUkycTestToken } from '../src/ukyc/testToken.js';
+import type { MintUkycTestTokenParams } from '../src/ukyc/testToken.js';
+
+/**
+ * Parses `--flag value` and `--flag=value` pairs into a map. Flags without a
+ * following value are treated as booleans (`"true"`).
+ *
+ * @param argv - Raw CLI arguments (typically `process.argv.slice(2)`).
+ * @returns The parsed flags keyed by name (without the leading `--`).
+ */
+function parseFlags(argv: string[]): Record {
+ const flags: Record = {};
+ let i = 0;
+ while (i < argv.length) {
+ const arg = argv[i];
+ if (!arg.startsWith('--')) {
+ i += 1;
+ continue;
+ }
+ const body = arg.slice(2);
+ const eq = body.indexOf('=');
+ if (eq !== -1) {
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
+ i += 1;
+ continue;
+ }
+ const next = argv[i + 1];
+ if (next !== undefined && !next.startsWith('--')) {
+ flags[body] = next;
+ i += 2;
+ } else {
+ flags[body] = 'true';
+ i += 1;
+ }
+ }
+ return flags;
+}
+
+/**
+ * Parses a duration like `4h`, `30m`, `90s`, or a bare number of seconds.
+ *
+ * @param value - The duration string.
+ * @returns The duration in milliseconds.
+ */
+function parseDurationMs(value: string): number {
+ const match = /^(\d+)(s|m|h|d)?$/u.exec(value);
+ if (!match) {
+ throw new Error(`invalid --expires-in duration: ${value}`);
+ }
+ const amount = Number(match[1]);
+ const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
+ return amount * unitMs[(match[2] ?? 's') as keyof typeof unitMs];
+}
+
+const flags = parseFlags(process.argv.slice(2));
+
+const params: MintUkycTestTokenParams = {};
+
+if (flags.secret) {
+ params.localUserSecret = flags.secret;
+}
+if (flags.operations) {
+ params.operations = flags.operations
+ .split(',')
+ .map((op) => op.trim()) as UkycStorageOperation[];
+}
+if (flags.presenter) {
+ params.presenter = flags.presenter as UkycTokenPresenter;
+}
+if (flags['session-id']) {
+ params.sessionId = flags['session-id'];
+}
+if (flags['issued-at']) {
+ params.issuedAt = new Date(flags['issued-at']);
+}
+if (flags['expires-at']) {
+ params.expiresAt = new Date(flags['expires-at']);
+} else if (flags['expires-in']) {
+ const issuedAt = params.issuedAt ?? new Date();
+ params.issuedAt = issuedAt;
+ params.expiresAt = new Date(issuedAt.getTime() + parseDurationMs(flags['expires-in']));
+}
+
+const result = mintUkycTestToken(params);
+
+console.log(JSON.stringify(result, null, 2));
diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts
new file mode 100644
index 00000000000..7f27b8c202e
--- /dev/null
+++ b/packages/kyc-controller/src/KycController-method-action-types.ts
@@ -0,0 +1,214 @@
+/**
+ * This file is auto generated.
+ * Do not edit manually.
+ */
+
+import type { KycController } from './KycController.js';
+
+/**
+ * Resolves persisted terms + geolocation, and auto-creates a session when
+ * terms are already accepted and an email is available.
+ *
+ * @param params - Optional parameters.
+ * @param params.email - The account email to associate with the session.
+ * @param params.product - The consuming feature the flow runs for. When
+ * provided, the controller automatically runs the KYC-required check once
+ * authentication completes (and chains into document verification when KYC
+ * is required). When omitted, the flow stops at `form` and the consumer must
+ * call `checkKycRequired` manually.
+ */
+export type KycControllerInitializeAction = {
+ type: `KycController:initialize`;
+ handler: KycController['initialize'];
+};
+
+/**
+ * Loads the disclaimers for the resolved (or provided) country.
+ *
+ * @param params - Optional parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code override.
+ */
+export type KycControllerLoadDisclaimersAction = {
+ type: `KycController:loadDisclaimers`;
+ handler: KycController['loadDisclaimers'];
+};
+
+/**
+ * Captures terms acceptance for the currently loaded disclaimers and creates
+ * a session.
+ *
+ * @param params - Optional parameters.
+ * @param params.email - The account email to associate with the session.
+ * @param params.product - The consuming feature the flow runs for. See
+ * {@link initialize} for how the product drives the automatic post
+ * authentication continuation.
+ */
+export type KycControllerAcceptTermsAndStartSessionAction = {
+ type: `KycController:acceptTermsAndStartSession`;
+ handler: KycController['acceptTermsAndStartSession'];
+};
+
+/**
+ * Clears the persisted terms acceptance.
+ */
+export type KycControllerClearSavedTermsAction = {
+ type: `KycController:clearSavedTerms`;
+ handler: KycController['clearSavedTerms'];
+};
+
+/**
+ * Handles a message posted by a Check/Auth frame and advances the flow.
+ *
+ * The transport-agnostic caller (WebView on mobile, iframe on web) forwards
+ * the raw message and injects the returned `reply` back into the frame.
+ *
+ * @param params - The parameters.
+ * @param params.message - The raw message posted by the frame.
+ * @returns An object whose optional `reply` should be posted back.
+ */
+export type KycControllerHandleFrameMessageAction = {
+ type: `KycController:handleFrameMessage`;
+ handler: KycController['handleFrameMessage'];
+};
+
+/**
+ * Builds the Check-frame URL, or `null` when no session exists yet.
+ *
+ * @returns The Check-frame URL or `null`.
+ */
+export type KycControllerBuildCheckFrameUrlAction = {
+ type: `KycController:buildCheckFrameUrl`;
+ handler: KycController['buildCheckFrameUrl'];
+};
+
+/**
+ * Builds the Auth-frame URL, or `null` when no client token is available.
+ *
+ * @returns The Auth-frame URL or `null`.
+ */
+export type KycControllerBuildAuthFrameUrlAction = {
+ type: `KycController:buildAuthFrameUrl`;
+ handler: KycController['buildAuthFrameUrl'];
+};
+
+/**
+ * Builds the Reset-frame URL.
+ *
+ * @returns The Reset-frame URL.
+ */
+export type KycControllerBuildResetFrameUrlAction = {
+ type: `KycController:buildResetFrameUrl`;
+ handler: KycController['buildResetFrameUrl'];
+};
+
+/**
+ * Checks whether KYC is required for a product and caches the result.
+ *
+ * @param params - The parameters.
+ * @param params.product - The consuming feature.
+ * @param params.country - Optional alpha-3 country override.
+ * @returns Whether KYC is required.
+ */
+export type KycControllerCheckKycRequiredAction = {
+ type: `KycController:checkKycRequired`;
+ handler: KycController['checkKycRequired'];
+};
+
+/**
+ * Reads the cached "is KYC required" result for a product.
+ *
+ * @param params - The parameters.
+ * @param params.product - The consuming feature.
+ * @returns The cached value, or `undefined` if not yet checked.
+ */
+export type KycControllerGetKycStatusAction = {
+ type: `KycController:getKycStatus`;
+ handler: KycController['getKycStatus'];
+};
+
+/**
+ * Runs the SumSub document-verification sub-flow end to end:
+ *
+ * 1. requests a per-session wrapping key from the UKYC backend;
+ * 2. verifies its `jwtChain` against the Fractal JWKS and confirms the
+ * attested session server public key;
+ * 3. derives the `data_encryption_key` from the wallet's UKYC
+ * `local_user_secret` and wraps it for the session server;
+ * 4. mints a client-signed, read-only `ukyc_capability_token` and creates
+ * the UKYC session (handing over the wrapped key and the token);
+ * 5. fetches the SumSub applicant access token; and
+ * 6. presents the SDK via the injected launcher.
+ *
+ * If session creation reports the applicant is already approved on the relay
+ * while the vendor is still finalizing (`kycStatus: approved`,
+ * `finalStatus: pending`), the sub-flow stops at step 4 with a
+ * `vendorProcessing` status and a message rather than launching the SDK.
+ *
+ * @param params - Optional parameters.
+ * @param params.locale - BCP-47 locale for the SDK UI.
+ * @param params.debug - Enables SDK debug logging.
+ * @returns The SDK result.
+ */
+export type KycControllerStartSumSubAction = {
+ type: `KycController:startSumSub`;
+ handler: KycController['startSumSub'];
+};
+
+/**
+ * Fetches the current UKYC session status for the active sub-flow and records
+ * it on state. Useful for a one-off refresh outside the automatic polling
+ * loop that {@link startSumSub} runs.
+ *
+ * @returns The fetched session status.
+ * @throws If there is no active SumSub session to query.
+ */
+export type KycControllerGetSessionStatusAction = {
+ type: `KycController:getSessionStatus`;
+ handler: KycController['getSessionStatus'];
+};
+
+/**
+ * Registers a Money Account wallet with MoonPay Iron.
+ *
+ * Consumers provide only the Monad address. The controller reuses the Iron
+ * customer id captured from MoonPay's hosted frame when available, otherwise
+ * it resolves the id from the authenticated MetaMask profile via KycService.
+ * Message construction, signing, submission, and ambiguous-write
+ * reconciliation stay internal to KYC.
+ *
+ * @param params - Money Account wallet registration parameters.
+ * @param params.address - Monad Money Account address.
+ * @returns The successful registration state.
+ */
+export type KycControllerRegisterMoneyAccountWalletAction = {
+ type: `KycController:registerMoneyAccountWallet`;
+ handler: KycController['registerMoneyAccountWallet'];
+};
+
+/**
+ * Resets the flow to idle, clearing session tokens and sub-flow state while
+ * preserving persisted terms acceptance and the per-product cache.
+ */
+export type KycControllerResetAction = {
+ type: `KycController:reset`;
+ handler: KycController['reset'];
+};
+
+/**
+ * Union of all KycController action types.
+ */
+export type KycControllerMethodActions =
+ | KycControllerInitializeAction
+ | KycControllerLoadDisclaimersAction
+ | KycControllerAcceptTermsAndStartSessionAction
+ | KycControllerClearSavedTermsAction
+ | KycControllerHandleFrameMessageAction
+ | KycControllerBuildCheckFrameUrlAction
+ | KycControllerBuildAuthFrameUrlAction
+ | KycControllerBuildResetFrameUrlAction
+ | KycControllerCheckKycRequiredAction
+ | KycControllerGetKycStatusAction
+ | KycControllerStartSumSubAction
+ | KycControllerGetSessionStatusAction
+ | KycControllerRegisterMoneyAccountWalletAction
+ | KycControllerResetAction;
diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts
new file mode 100644
index 00000000000..2f2fbaf2458
--- /dev/null
+++ b/packages/kyc-controller/src/KycController.test.ts
@@ -0,0 +1,2198 @@
+import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
+import type {
+ MockAnyNamespace,
+ MessengerActions,
+ MessengerEvents,
+} from '@metamask/messenger';
+import { gcm } from '@noble/ciphers/aes';
+import { x25519 } from '@noble/curves/ed25519';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
+
+import { KycController } from './KycController.js';
+import type { KycControllerMessenger } from './KycController.js';
+import type { KycSumSubLauncher } from './types.js';
+import { verifyJwtChain } from './ukyc/jwtChain.js';
+import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js';
+import { WalletRegistrationError } from './wallet-registration-service.js';
+
+// `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing)
+// need a real signed chain / valid keys, so they are stubbed here; the rest of
+// the UKYC layer (local-user-secret storage adapter, client-material
+// derivation) runs for real so the controller's messenger wiring is exercised.
+// Return values are (re)configured per test in `withController` because the
+// shared jest config enables `resetMocks`.
+jest.mock('./ukyc/jwtChain', () => {
+ const actual = jest.requireActual('./ukyc/jwtChain');
+ return {
+ ...actual,
+ verifyJwtChain: jest.fn(),
+ };
+});
+jest.mock('./ukyc/wrapEncryptionKey', () => {
+ const actual = jest.requireActual('./ukyc/wrapEncryptionKey');
+ return {
+ ...actual,
+ wrapEncryptionKey: jest.fn(),
+ };
+});
+
+const mockVerifyJwtChain = verifyJwtChain as jest.MockedFunction<
+ typeof verifyJwtChain
+>;
+const mockWrapEncryptionKey = wrapEncryptionKey as jest.MockedFunction<
+ typeof wrapEncryptionKey
+>;
+
+/**
+ * Builds an encrypted envelope for a recipient's X25519 public key.
+ *
+ * @param publicKey - The recipient's public key bytes.
+ * @param credentials - The plaintext credentials to encrypt.
+ * @returns The encrypted envelope.
+ */
+function makeEnvelope(
+ publicKey: Uint8Array,
+ credentials: Record,
+): { ephemeralPublicKey: string; iv: string; ciphertext: string } {
+ const ephemeralPrivate = x25519.utils.randomSecretKey();
+ const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate);
+ const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey);
+ const key = hkdf(sha256, shared, undefined, undefined, 32);
+ const iv = new Uint8Array(12).fill(7);
+ const ciphertext = gcm(key, iv).encrypt(
+ utf8ToBytes(JSON.stringify(credentials)),
+ );
+ return {
+ ephemeralPublicKey: bytesToHex(ephemeralPublic),
+ iv: bytesToHex(iv),
+ ciphertext: bytesToHex(ciphertext),
+ };
+}
+
+/**
+ * Extracts the controller's ephemeral public key from the Check-frame URL and
+ * builds a decryptable credentials envelope for it.
+ *
+ * @param controller - The controller under test (must have a session token).
+ * @param credentials - The plaintext credentials to encrypt.
+ * @returns The encrypted envelope.
+ */
+function envelopeFor(
+ controller: KycController,
+ credentials: Record,
+): { ephemeralPublicKey: string; iv: string; ciphertext: string } {
+ const url = controller.buildCheckFrameUrl();
+ const publicKeyHex = new URL(url as string).searchParams.get(
+ 'publicKey',
+ ) as string;
+ return makeEnvelope(hexToBytes(publicKeyHex), credentials);
+}
+
+describe('KycController', () => {
+ describe('constructor', () => {
+ it('accepts initial state merged over defaults', async () => {
+ await withController(
+ { options: { state: { phase: 'form' } } },
+ ({ controller }) => {
+ expect(controller.state.phase).toBe('form');
+ expect(controller.state.sumsub.status).toBe('idle');
+ },
+ );
+ });
+ });
+
+ describe('initialize', () => {
+ it('auto-creates a session when terms and email are present', async () => {
+ await withController(
+ {
+ options: {
+ state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('USA');
+ handlers.createSession.mockResolvedValue({ sessionToken: 'sess' });
+
+ await controller.initialize({ email: 'a@b.co' });
+
+ expect(controller.state.geoCountry).toBe('USA');
+ expect(controller.state.sessionToken).toBe('sess');
+ expect(controller.state.phase).toBe('check');
+ },
+ );
+ });
+
+ it('falls back to the terms phase and loads disclaimers when geo fails and no terms exist', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockRejectedValue(new Error('geo down'));
+
+ await controller.initialize();
+
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.disclaimersError).toMatch(/Failed to load/u);
+ });
+ });
+
+ it('captures the active product for the automatic post-auth continuation', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('USA');
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.initialize({ product: 'card' });
+
+ expect(controller.state.activeProduct).toBe('card');
+ });
+ });
+
+ it('clears a stale active product when re-initialized without one', async () => {
+ await withController(
+ { options: { state: { activeProduct: 'card' } } },
+ async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('USA');
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.initialize({ email: 'a@b.co' });
+
+ expect(controller.state.activeProduct).toBeNull();
+ },
+ );
+ });
+
+ it('does not restart an in-progress session flow', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ email: 'a@b.co',
+ sessionToken: 'live-session',
+ termsAcceptedAt: 't',
+ acceptedDisclaimerIds: ['1'],
+ activeProduct: 'ramps',
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ await controller.initialize({ email: 'other@b.co', product: 'card' });
+
+ // A repeat initialize mid-flow must be a no-op: no new session, no
+ // token/phase teardown, and no clobbering of the active product.
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(handlers.getGeoCountry).not.toHaveBeenCalled();
+ expect(controller.state.phase).toBe('check');
+ expect(controller.state.sessionToken).toBe('live-session');
+ expect(controller.state.activeProduct).toBe('ramps');
+ expect(controller.state.email).toBe('a@b.co');
+ },
+ );
+ });
+
+ it('stays on terms when terms exist but no email is available', async () => {
+ await withController(
+ {
+ options: {
+ state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('USA');
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.initialize();
+
+ expect(controller.state.phase).toBe('terms');
+ },
+ );
+ });
+ });
+
+ describe('loadDisclaimers', () => {
+ it('loads disclaimers for a provided country', async () => {
+ await withController(async ({ controller, handlers }) => {
+ const disclaimers = [{ id: '1', display_name: 'T', url: 'u' }];
+ handlers.fetchDisclaimers.mockResolvedValue(disclaimers);
+
+ await controller.loadDisclaimers({ country: 'USA' });
+
+ expect(controller.state.disclaimers).toStrictEqual(disclaimers);
+ expect(handlers.getGeoCountry).not.toHaveBeenCalled();
+ });
+ });
+
+ it('caches the provided country override in geoCountry', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.loadDisclaimers({ country: 'USA' });
+
+ expect(controller.state.geoCountry).toBe('USA');
+ });
+ });
+
+ it('lets a later checkKycRequired reuse the overridden country without an override', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a' } } },
+ async ({ controller, handlers }) => {
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: true });
+
+ await controller.loadDisclaimers({ country: 'USA' });
+ await controller.checkKycRequired({ product: 'ramps' });
+
+ expect(handlers.getGeoCountry).not.toHaveBeenCalled();
+ expect(handlers.checkKycRequired).toHaveBeenCalledWith({
+ accessToken: 'a',
+ country: 'USA',
+ capabilities: [{ product: 'ramps' }],
+ });
+ expect(controller.state.error).toBeNull();
+ },
+ );
+ });
+
+ it('uses the cached geoCountry when no country is provided', async () => {
+ await withController(
+ { options: { state: { geoCountry: 'USA' } } },
+ async ({ controller, handlers }) => {
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.loadDisclaimers();
+
+ expect(handlers.getGeoCountry).not.toHaveBeenCalled();
+ expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({
+ country: 'USA',
+ });
+ },
+ );
+ });
+
+ it('resolves the country when neither param nor cache is available', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('FRA');
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.loadDisclaimers();
+
+ expect(controller.state.geoCountry).toBe('FRA');
+ expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({
+ country: 'FRA',
+ });
+ });
+ });
+
+ it('records an error when loading fails', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.fetchDisclaimers.mockRejectedValue(new Error('boom'));
+
+ await controller.loadDisclaimers({ country: 'USA' });
+
+ expect(controller.state.disclaimersError).toMatch(/boom/u);
+ });
+ });
+ });
+
+ describe('acceptTermsAndStartSession', () => {
+ it('captures terms and creates a session', async () => {
+ await withController(
+ {
+ options: {
+ state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createSession.mockResolvedValue({ sessionToken: 'sess' });
+
+ await controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ product: 'ramps',
+ });
+
+ expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']);
+ expect(controller.state.termsAcceptedAt).not.toBeNull();
+ expect(controller.state.activeProduct).toBe('ramps');
+ expect(controller.state.phase).toBe('check');
+ },
+ );
+ });
+
+ it('clears stale auth tokens when a new session is created', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ email: 'a@b.co',
+ sessionToken: 'old-session',
+ accessToken: 'stale-access',
+ disclaimers: [{ id: '1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createSession.mockResolvedValue({
+ sessionToken: 'new-session',
+ });
+
+ // Establish an auth-frame client token from a prior authentication.
+ const envelope = envelopeFor(controller, {
+ clientToken: 'old-client',
+ });
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'connectionRequired', credentials: envelope },
+ },
+ });
+ expect(controller.buildAuthFrameUrl()).toContain(
+ 'clientToken=old-client',
+ );
+
+ // Creating a new session must invalidate the carried-over auth.
+ await controller.acceptTermsAndStartSession();
+
+ expect(controller.state.accessToken).toBeNull();
+ expect(controller.buildAuthFrameUrl()).toBeNull();
+ expect(controller.state.sessionToken).toBe('new-session');
+ },
+ );
+ });
+
+ it('clears the old session token while a new session is being created', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ email: 'a@b.co',
+ sessionToken: 'old-session',
+ disclaimers: [{ id: '1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let releaseSession: (value: {
+ sessionToken: string;
+ }) => void = () => {
+ // no-op placeholder until the deferred promise is wired up
+ };
+ handlers.createSession.mockReturnValue(
+ new Promise<{ sessionToken: string }>((resolve) => {
+ releaseSession = resolve;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession();
+
+ // While the request is in flight (phase `session`) the stale token
+ // must already be gone so no Check frame URL can be built for it.
+ expect(controller.state.phase).toBe('session');
+ expect(controller.state.sessionToken).toBeNull();
+ expect(controller.buildCheckFrameUrl()).toBeNull();
+
+ releaseSession({ sessionToken: 'new-session' });
+ await pending;
+
+ expect(controller.state.sessionToken).toBe('new-session');
+ expect(controller.buildCheckFrameUrl()).toContain(
+ 'sessionToken=new-session',
+ );
+ },
+ );
+ });
+
+ it('reverts to terms when session creation fails', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ email: 'a@b.co',
+ sessionToken: 'old-session',
+ disclaimers: [{ id: '1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createSession.mockRejectedValue(new Error('nope'));
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.acceptTermsAndStartSession();
+
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.termsAcceptedAt).toBeNull();
+ expect(controller.state.error).toMatch(/Session creation failed/u);
+ // A failed creation must not leave the old session token behind, so
+ // the Check frame cannot be built against an invalid session.
+ expect(controller.state.sessionToken).toBeNull();
+ expect(controller.buildCheckFrameUrl()).toBeNull();
+ },
+ );
+ });
+
+ it('leaves the controller idle when reset() runs before session creation fails', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ email: 'a@b.co',
+ sessionToken: 'old-session',
+ disclaimers: [{ id: '1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let rejectSession: (reason: Error) => void = () => {
+ // no-op placeholder until the deferred promise is wired up
+ };
+ handlers.createSession.mockReturnValue(
+ new Promise<{ sessionToken: string }>((_resolve, reject) => {
+ rejectSession = reject;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession();
+
+ // Reset while the create request is in flight, then let it fail. The
+ // superseded flow must not force the now-idle controller back to
+ // `terms` or re-run disclaimer loading.
+ controller.reset();
+ rejectSession(new Error('nope'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ expect(handlers.fetchDisclaimers).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('clears the active product when session creation fails', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ email: 'a@b.co',
+ activeProduct: 'card',
+ disclaimers: [{ id: '1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createSession.mockRejectedValue(new Error('nope'));
+ handlers.fetchDisclaimers.mockResolvedValue([]);
+
+ await controller.acceptTermsAndStartSession({ product: 'ramps' });
+
+ // The failed flow must not leave a lingering product behind that a
+ // later product-less `acceptTermsAndStartSession` would auto-run.
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.activeProduct).toBeNull();
+ },
+ );
+ });
+
+ it('fails when no email is available', async () => {
+ await withController(
+ {
+ options: {
+ state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] },
+ },
+ },
+ async ({ controller }) => {
+ await controller.acceptTermsAndStartSession();
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(/Missing email/u);
+ },
+ );
+ });
+
+ it('fails when no disclaimers were accepted', async () => {
+ await withController(async ({ controller }) => {
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(/Missing terms acceptance/u);
+ });
+ });
+ });
+
+ describe('clearSavedTerms', () => {
+ it('clears persisted terms', async () => {
+ await withController(
+ {
+ options: {
+ state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] },
+ },
+ },
+ ({ controller }) => {
+ controller.clearSavedTerms();
+ expect(controller.state.termsAcceptedAt).toBeNull();
+ expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]);
+ },
+ );
+ });
+ });
+
+ describe('handleFrameMessage', () => {
+ it('acks a handshake', async () => {
+ await withController(async ({ controller }) => {
+ const result = await controller.handleFrameMessage({
+ message: { kind: 'handshake', meta: { channelId: 'ch_1' } },
+ });
+ expect(result).toStrictEqual({
+ reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' },
+ });
+ });
+ });
+
+ it('ignores undefined and non-complete messages', async () => {
+ await withController(async ({ controller }) => {
+ expect(
+ await controller.handleFrameMessage({ message: undefined }),
+ ).toStrictEqual({});
+ expect(
+ await controller.handleFrameMessage({ message: { kind: 'other' } }),
+ ).toStrictEqual({});
+ });
+ });
+
+ it('captures the customer id and ignores a status-less complete message', async () => {
+ await withController(
+ { options: { state: { phase: 'check' } } },
+ async ({ controller }) => {
+ const result = await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { customer: { id: 'cust-1' } },
+ },
+ });
+ expect(result).toStrictEqual({});
+ expect(controller.state.moonpayCustomerId).toBe('cust-1');
+ },
+ );
+ });
+
+ it('ignores messages on an unknown channel', async () => {
+ await withController(async ({ controller }) => {
+ const result = await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_unknown' },
+ payload: { status: 'active' },
+ },
+ });
+ expect(result).toStrictEqual({});
+ });
+ });
+
+ it('ignores a stale completion for a frame the flow is no longer waiting on', async () => {
+ // Phase `done` (e.g. after a completed flow or a `reset()` that returns
+ // to an idle phase) means the Check frame is no longer active; a late or
+ // duplicate `ch_1` completion must not resurrect tokens or rewind phase.
+ await withController(
+ { options: { state: { phase: 'done', sessionToken: 'tok' } } },
+ async ({ controller }) => {
+ const envelope = envelopeFor(controller, { accessToken: 'access-1' });
+ const result = await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: {
+ status: 'active',
+ credentials: envelope,
+ customer: { id: 'cust-late' },
+ },
+ },
+ });
+ expect(result).toStrictEqual({});
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.accessToken).toBeNull();
+ expect(controller.state.moonpayCustomerId).toBeNull();
+ },
+ );
+ });
+
+ it('fails when credential decryption throws', async () => {
+ await withController(
+ { options: { state: { phase: 'check', sessionToken: 'tok' } } },
+ async ({ controller }) => {
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials: 'not-decryptable' },
+ },
+ });
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(/Failed to decrypt/u);
+ },
+ );
+ });
+
+ describe('check frame', () => {
+ it('moves to form on an active status with an access token', async () => {
+ await withController(
+ { options: { state: { phase: 'check', sessionToken: 'tok' } } },
+ async ({ controller }) => {
+ const envelope = envelopeFor(controller, {
+ accessToken: 'access-1',
+ });
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+ expect(controller.state.phase).toBe('form');
+ expect(controller.state.accessToken).toBe('access-1');
+ },
+ );
+ });
+
+ it('moves to auth on connectionRequired and enables the auth frame URL', async () => {
+ await withController(
+ { options: { state: { phase: 'check', sessionToken: 'tok' } } },
+ async ({ controller }) => {
+ const envelope = envelopeFor(controller, {
+ clientToken: 'client-1',
+ });
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: {
+ status: 'connectionRequired',
+ credentials: envelope,
+ },
+ },
+ });
+ expect(controller.state.phase).toBe('auth');
+ expect(controller.buildAuthFrameUrl()).toContain(
+ 'clientToken=client-1',
+ );
+ },
+ );
+ });
+
+ it('requires re-acceptance on termsAcceptanceRequired', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ sessionToken: 'tok',
+ termsAcceptedAt: 't',
+ acceptedDisclaimerIds: ['1'],
+ },
+ },
+ },
+ async ({ controller }) => {
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'termsAcceptanceRequired' },
+ },
+ });
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.termsAcceptedAt).toBeNull();
+ },
+ );
+ });
+
+ it('fails on an unexpected status', async () => {
+ await withController(
+ { options: { state: { phase: 'check', sessionToken: 'tok' } } },
+ async ({ controller }) => {
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'failed' },
+ },
+ });
+ expect(controller.state.phase).toBe('error');
+ },
+ );
+ });
+ });
+
+ describe('auth frame', () => {
+ it('moves to form on an active status with an access token', async () => {
+ await withController(
+ { options: { state: { phase: 'auth', sessionToken: 'tok' } } },
+ async ({ controller }) => {
+ const envelope = envelopeFor(controller, {
+ accessToken: 'access-2',
+ });
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_2' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+ expect(controller.state.phase).toBe('form');
+ expect(controller.state.accessToken).toBe('access-2');
+ },
+ );
+ });
+
+ it('requires re-acceptance on termsAcceptanceRequired', async () => {
+ await withController(
+ { options: { state: { phase: 'auth' } } },
+ async ({ controller }) => {
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_2' },
+ payload: { status: 'termsAcceptanceRequired' },
+ },
+ });
+ expect(controller.state.phase).toBe('terms');
+ },
+ );
+ });
+
+ it('fails on an unexpected status', async () => {
+ await withController(
+ { options: { state: { phase: 'auth' } } },
+ async ({ controller }) => {
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_2' },
+ payload: { status: 'unavailable' },
+ },
+ });
+ expect(controller.state.phase).toBe('error');
+ },
+ );
+ });
+ });
+ });
+
+ describe('automatic post-authentication continuation', () => {
+ it('stays at form and does not run the check when no product is set', async () => {
+ await withController(
+ {
+ options: {
+ state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' },
+ },
+ },
+ async ({ controller, handlers }) => {
+ const envelope = envelopeFor(controller, { accessToken: 'access-1' });
+
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+
+ expect(controller.state.phase).toBe('form');
+ expect(handlers.checkKycRequired).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('auto-runs the KYC check on reaching form and stops at done when KYC is not required', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ sessionToken: 'tok',
+ activeProduct: 'ramps',
+ geoCountry: 'USA',
+ },
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: false });
+ const envelope = envelopeFor(controller, { accessToken: 'access-1' });
+
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+
+ expect(handlers.checkKycRequired).toHaveBeenCalledWith({
+ accessToken: 'access-1',
+ country: 'USA',
+ capabilities: [{ product: 'ramps' }],
+ });
+ expect(controller.state.kycRequiredByProduct.ramps).toBe(false);
+ expect(controller.state.phase).toBe('done');
+ expect(launcher.launch).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('auto-chains into document verification when KYC is required (via the auth frame)', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'auth',
+ sessionToken: 'tok',
+ activeProduct: 'card',
+ geoCountry: 'FRA',
+ },
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: true });
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ const envelope = envelopeFor(controller, { accessToken: 'access-2' });
+
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_2' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+
+ expect(controller.state.kycRequiredByProduct.card).toBe(true);
+ expect(launcher.launch).toHaveBeenCalledTimes(1);
+ expect(controller.state.sumsub.status).toBe('complete');
+ },
+ );
+ });
+
+ it('records a failed sub-flow without throwing when verification is required but the SDK is unavailable', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ sessionToken: 'tok',
+ activeProduct: 'ramps',
+ geoCountry: 'USA',
+ },
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: true });
+ launcher.isAvailable.mockReturnValue(false);
+ const envelope = envelopeFor(controller, { accessToken: 'access-1' });
+
+ const result = await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+
+ expect(result).toStrictEqual({});
+ expect(controller.state.sumsub.status).toBe('failed');
+ },
+ );
+ });
+
+ it('ignores a duplicate completion while a prior continuation is in flight', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'auth',
+ sessionToken: 'tok',
+ activeProduct: 'card',
+ geoCountry: 'FRA',
+ },
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ // Hold the KYC-required check open so the first continuation is still
+ // in flight when the second (duplicate) completion arrives. The first
+ // completion moves `phase` to `form` synchronously, so the duplicate
+ // is dropped by the frame-phase guard before it can re-run the check.
+ let releaseCheck: (value: { kycRequired: boolean }) => void = () => {
+ // no-op placeholder until the deferred promise is wired up
+ };
+ handlers.checkKycRequired.mockReturnValue(
+ new Promise<{ kycRequired: boolean }>((resolve) => {
+ releaseCheck = resolve;
+ }),
+ );
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ const envelope = envelopeFor(controller, { accessToken: 'access-1' });
+ const message = {
+ kind: 'complete',
+ meta: { channelId: 'ch_2' },
+ payload: { status: 'active', credentials: envelope },
+ };
+
+ const first = controller.handleFrameMessage({ message });
+ const second = controller.handleFrameMessage({ message });
+
+ releaseCheck({ kycRequired: true });
+ await Promise.all([first, second]);
+
+ expect(handlers.checkKycRequired).toHaveBeenCalledTimes(1);
+ expect(launcher.launch).toHaveBeenCalledTimes(1);
+ expect(controller.state.sumsub.status).toBe('complete');
+ },
+ );
+ });
+
+ it('allows a fresh flow to continue after a reset interrupts an in-flight continuation', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ email: 'a@b.co',
+ sessionToken: 'tok',
+ activeProduct: 'ramps',
+ geoCountry: 'USA',
+ // Persisted terms so a post-reset `initialize` auto-recreates the
+ // session (reaching phase `check`) for the second completion.
+ termsAcceptedAt: 't',
+ acceptedDisclaimerIds: ['1'],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ // The keypair is stable across reset, so both envelopes can be built
+ // up front while the session token (used only to derive the public
+ // key here) is still present.
+ const envelope1 = envelopeFor(controller, {
+ accessToken: 'access-1',
+ });
+ const envelope2 = envelopeFor(controller, {
+ accessToken: 'access-2',
+ });
+ const messageFor = (
+ credentials: unknown,
+ ): {
+ kind: string;
+ meta: { channelId: string };
+ payload: { status: string; credentials: unknown };
+ } => ({
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials },
+ });
+
+ // Hold the first continuation open so a reset can land while it is
+ // still in flight.
+ let releaseCheck: (value: { kycRequired: boolean }) => void = () => {
+ // no-op placeholder until the deferred promise is wired up
+ };
+ handlers.checkKycRequired.mockReturnValueOnce(
+ new Promise<{ kycRequired: boolean }>((resolve) => {
+ releaseCheck = resolve;
+ }),
+ );
+
+ const first = controller.handleFrameMessage({
+ message: messageFor(envelope1),
+ });
+
+ // Reset while the continuation is awaiting the check. Its result is
+ // discarded by the generation guard (the check belongs to the
+ // superseded generation) rather than written onto the idle flow.
+ controller.reset();
+ releaseCheck({ kycRequired: false });
+ await first;
+
+ // Re-establish a product-scoped flow (auto-creates a session and
+ // returns to phase `check`) and confirm the next completion continues
+ // again rather than being blocked forever by a stuck guard.
+ await controller.initialize({ product: 'ramps' });
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: false });
+ await controller.handleFrameMessage({
+ message: messageFor(envelope2),
+ });
+
+ expect(handlers.checkKycRequired).toHaveBeenCalledTimes(2);
+ },
+ );
+ });
+
+ it('does not launch verification when the auto-run check fails', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'check',
+ sessionToken: 'tok',
+ activeProduct: 'ramps',
+ geoCountry: 'USA',
+ },
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ handlers.checkKycRequired.mockRejectedValue(new Error('down'));
+ const envelope = envelopeFor(controller, { accessToken: 'access-1' });
+
+ await controller.handleFrameMessage({
+ message: {
+ kind: 'complete',
+ meta: { channelId: 'ch_1' },
+ payload: { status: 'active', credentials: envelope },
+ },
+ });
+
+ expect(controller.state.phase).toBe('error');
+ expect(launcher.launch).not.toHaveBeenCalled();
+ },
+ );
+ });
+ });
+
+ describe('frame URL builders', () => {
+ it('returns null for the check frame without a session', async () => {
+ await withController(({ controller }) => {
+ expect(controller.buildCheckFrameUrl()).toBeNull();
+ });
+ });
+
+ it('builds the check frame URL with a session', async () => {
+ await withController(
+ { options: { state: { sessionToken: 'tok' } } },
+ ({ controller }) => {
+ const url = controller.buildCheckFrameUrl() as string;
+ expect(url).toContain('sessionToken=tok');
+ expect(url).toContain('channelId=ch_1');
+ expect(url).toContain('skipKyc=true');
+ },
+ );
+ });
+
+ it('returns null for the auth frame without a client token', async () => {
+ await withController(({ controller }) => {
+ expect(controller.buildAuthFrameUrl()).toBeNull();
+ });
+ });
+
+ it('builds the reset frame URL', async () => {
+ await withController(({ controller }) => {
+ expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset');
+ });
+ });
+ });
+
+ describe('checkKycRequired', () => {
+ it('fails without an access token', async () => {
+ await withController(async ({ controller }) => {
+ expect(await controller.checkKycRequired({ product: 'ramps' })).toBe(
+ false,
+ );
+ expect(controller.state.error).toMatch(/Missing accessToken/u);
+ });
+ });
+
+ it('fails without a country', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a' } } },
+ async ({ controller }) => {
+ expect(await controller.checkKycRequired({ product: 'ramps' })).toBe(
+ false,
+ );
+ expect(controller.state.error).toMatch(/Missing country/u);
+ },
+ );
+ });
+
+ it('caches the result on success (cached country)', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a', geoCountry: 'USA' } } },
+ async ({ controller, handlers }) => {
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: true });
+
+ expect(await controller.checkKycRequired({ product: 'ramps' })).toBe(
+ true,
+ );
+ expect(controller.state.kycRequiredByProduct.ramps).toBe(true);
+ expect(controller.state.phase).toBe('done');
+ },
+ );
+ });
+
+ it('accepts a country override', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a' } } },
+ async ({ controller, handlers }) => {
+ handlers.checkKycRequired.mockResolvedValue({ kycRequired: false });
+
+ await controller.checkKycRequired({
+ product: 'card',
+ country: 'FRA',
+ });
+
+ expect(handlers.checkKycRequired).toHaveBeenCalledWith({
+ accessToken: 'a',
+ country: 'FRA',
+ capabilities: [{ product: 'card' }],
+ });
+ },
+ );
+ });
+
+ it('fails when the service throws', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a', geoCountry: 'USA' } } },
+ async ({ controller, handlers }) => {
+ handlers.checkKycRequired.mockRejectedValue(new Error('down'));
+
+ expect(await controller.checkKycRequired({ product: 'ramps' })).toBe(
+ false,
+ );
+ expect(controller.state.error).toMatch(/KYC check failed/u);
+ },
+ );
+ });
+
+ it('discards a successful result when reset() runs while the check is in flight', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a', geoCountry: 'USA' } } },
+ async ({ controller, handlers }) => {
+ handlers.checkKycRequired.mockImplementation(async () => {
+ // Simulate a reset() landing while the HTTP call is in flight.
+ controller.reset();
+ return { kycRequired: true };
+ });
+
+ const result = await controller.checkKycRequired({
+ product: 'ramps',
+ });
+
+ expect(result).toBe(false);
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.kycRequiredByProduct.ramps).toBeUndefined();
+ expect(controller.state.lastCheckedAt).toBeNull();
+ },
+ );
+ });
+
+ it('discards an error when reset() runs while the check is in flight', async () => {
+ await withController(
+ { options: { state: { accessToken: 'a', geoCountry: 'USA' } } },
+ async ({ controller, handlers }) => {
+ handlers.checkKycRequired.mockImplementation(async () => {
+ controller.reset();
+ throw new Error('down');
+ });
+
+ const result = await controller.checkKycRequired({
+ product: 'ramps',
+ });
+
+ expect(result).toBe(false);
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ },
+ );
+ });
+ });
+
+ describe('getKycStatus', () => {
+ it('returns the cached value or undefined', async () => {
+ await withController(
+ { options: { state: { kycRequiredByProduct: { ramps: true } } } },
+ ({ controller }) => {
+ expect(controller.getKycStatus({ product: 'ramps' })).toBe(true);
+ expect(controller.getKycStatus({ product: 'card' })).toBeUndefined();
+ },
+ );
+ });
+ });
+
+ describe('startSumSub', () => {
+ it('throws and marks failed when the SDK is unavailable', async () => {
+ await withController(async ({ controller, launcher }) => {
+ launcher.isAvailable.mockReturnValue(false);
+
+ await expect(controller.startSumSub()).rejects.toThrow(
+ /not available/u,
+ );
+ expect(controller.state.sumsub.status).toBe('failed');
+ });
+ });
+
+ it('runs the full sub-flow and completes', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ launcher.launch.mockImplementation(
+ async ({ onStatusChange, onTokenExpiration }) => {
+ onStatusChange?.('idle', 'InProgress');
+ onStatusChange?.('InProgress', 'Completed');
+ await onTokenExpiration();
+ return { ok: true };
+ },
+ );
+
+ const result = await controller.startSumSub({
+ locale: 'fr',
+ debug: true,
+ });
+
+ expect(result).toStrictEqual({ ok: true });
+ expect(controller.state.sumsub.status).toBe('complete');
+ expect(controller.state.sumsub.applicantAccessToken).toBe('aat');
+ // The wrapped key and a read-only capability token are handed over
+ // once at session creation.
+ expect(handlers.createUkycSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ wrappedEncryptionKey: expect.objectContaining({
+ sessionId: 'wk',
+ encryptedKey: 'enc',
+ }),
+ ukycCapabilityToken: expect.objectContaining({
+ payload: expect.objectContaining({
+ operations: ['read'],
+ presenter: 'client',
+ }),
+ signature: expect.any(String),
+ }),
+ }),
+ );
+ // onTokenExpiration re-fetches the applicant access token.
+ expect(handlers.createJourney).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('stops with a vendorProcessing status when the relay approved but the vendor is still pending', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ // The applicant already finished the journey: the relay reports
+ // `approved` while the vendor is still finalizing (`pending`).
+ handlers.createUkycSession.mockResolvedValue({
+ sessionId: 'sid',
+ kycStatus: 'approved',
+ finalStatus: 'pending',
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({
+ kycStatus: 'approved',
+ finalStatus: 'pending',
+ });
+ expect(controller.state.sumsub.status).toBe('vendorProcessing');
+ expect(controller.state.sumsub.sessionId).toBe('sid');
+ expect(controller.state.statusMessage).toMatch(
+ /being processed by the vendor/u,
+ );
+ // The SDK is never launched and no journey is created for an
+ // already-approved applicant.
+ expect(handlers.createJourney).not.toHaveBeenCalled();
+ expect(launcher.launch).not.toHaveBeenCalled();
+ });
+ });
+
+ it('continues the flow when approved and the vendor is not pending', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ // A terminal vendor status (not `pending`) must not short-circuit.
+ handlers.createUkycSession.mockResolvedValue({
+ sessionId: 'sid',
+ kycStatus: 'approved',
+ finalStatus: 'approved',
+ });
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved'));
+
+ await controller.startSumSub();
+
+ expect(handlers.createJourney).toHaveBeenCalled();
+ expect(launcher.launch).toHaveBeenCalled();
+ });
+ });
+
+ it('does not write vendorProcessing state when reset() runs while creating the session', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ handlers.createUkycSession.mockImplementation(async () => {
+ controller.reset();
+ return {
+ sessionId: 'sid',
+ kycStatus: 'approved',
+ finalStatus: 'pending',
+ };
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({});
+ expect(controller.state.sumsub.status).toBe('idle');
+ expect(controller.state.sumsub.sessionId).toBeNull();
+ expect(launcher.launch).not.toHaveBeenCalled();
+ });
+ });
+
+ it('aborts when the attested session server public key does not match', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ handlers.getWrappingKey.mockResolvedValue({
+ id: 'wk',
+ jwtChain: 'jwt.chain.sig',
+ sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'tampered' },
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toMatchObject({
+ error: expect.stringContaining(
+ 'sessionServerPublicKey does not match',
+ ),
+ });
+ expect(controller.state.sumsub.status).toBe('failed');
+ expect(launcher.launch).not.toHaveBeenCalled();
+ });
+ });
+
+ it('defaults locale and debug when no params are given', async () => {
+ await withController(async ({ controller, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+
+ await controller.startSumSub();
+
+ expect(launcher.launch).toHaveBeenCalledWith(
+ expect.objectContaining({ locale: 'en', debug: false }),
+ );
+ expect(controller.state.sumsub.status).toBe('complete');
+ });
+ });
+
+ it('marks failed when launch resolves without a Completed status', async () => {
+ await withController(async ({ controller, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ // The applicant abandons the flow: the SDK reports progress but never
+ // a Completed status, yet `launch` still resolves.
+ onStatusChange?.('idle', 'InProgress');
+ return { ok: false };
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({ ok: false });
+ expect(controller.state.sumsub.status).toBe('failed');
+ expect(controller.state.sumsub.result).toStrictEqual({ ok: false });
+ });
+ });
+
+ it('marks failed and returns the error when a step throws', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(new Error('ukyc down'));
+
+ const result = await controller.startSumSub();
+
+ expect(result).toMatchObject({
+ error: expect.stringContaining('ukyc down'),
+ });
+ expect(controller.state.sumsub.status).toBe('failed');
+ });
+ });
+
+ it('aborts without launching the SDK when reset() runs while in flight', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ // Simulate a reset() landing while the UKYC session is being created.
+ handlers.createUkycSession.mockImplementation(async () => {
+ controller.reset();
+ return {
+ sessionId: 'sid',
+ };
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({});
+ expect(launcher.launch).not.toHaveBeenCalled();
+ // The interrupted step must not write stale sub-flow state.
+ expect(controller.state.sumsub.status).toBe('idle');
+ expect(controller.state.sumsub.sessionId).toBeNull();
+ expect(controller.state.phase).toBe('idle');
+ });
+ });
+
+ it('aborts without launching the SDK when reset() runs just before launch', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ // A reset() lands during the final token exchange, i.e. after the
+ // session is prepared but before the SDK is presented.
+ handlers.createJourney.mockImplementation(async () => {
+ controller.reset();
+ return { status: 'ok', applicantAccessToken: 'aat' };
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({});
+ // The SDK must not be opened on a flow that was reset to idle, and the
+ // `launching` status must not be written.
+ expect(launcher.launch).not.toHaveBeenCalled();
+ expect(controller.state.sumsub.status).toBe('idle');
+ expect(controller.state.sumsub.applicantAccessToken).toBeNull();
+ expect(controller.state.phase).toBe('idle');
+ });
+ });
+
+ it('refuses to refresh the token via onTokenExpiration after a reset', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ let refreshError: unknown;
+ launcher.launch.mockImplementation(async ({ onTokenExpiration }) => {
+ // The SDK stays open across a reset, then asks for a fresh token.
+ controller.reset();
+ // Only the initial createJourney (session setup) should
+ // have run.
+ const callsBeforeRefresh = handlers.createJourney.mock.calls.length;
+ try {
+ await onTokenExpiration();
+ } catch (error) {
+ refreshError = error;
+ }
+ // The refresh must not hit the stale UKYC session.
+ expect(handlers.createJourney.mock.calls).toHaveLength(
+ callsBeforeRefresh,
+ );
+ return { ok: true };
+ });
+
+ await controller.startSumSub();
+
+ expect(refreshError).toBeInstanceOf(Error);
+ expect((refreshError as Error).message).toMatch(/flow was reset/u);
+ });
+ });
+
+ it('suppresses status and terminal writes when reset() runs during the SDK launch', async () => {
+ await withController(async ({ controller, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ // First status arrives on the active flow, then a reset() lands and
+ // a later status + the resolved result must not resurrect state.
+ onStatusChange?.('idle', 'InProgress');
+ controller.reset();
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({ ok: true });
+ expect(controller.state.sumsub.status).toBe('idle');
+ expect(controller.state.sumsub.result).toBeNull();
+ expect(controller.state.phase).toBe('idle');
+ });
+ });
+ });
+
+ describe('session status polling', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ /**
+ * Makes the launcher report a successful SDK completion so the sub-flow
+ * proceeds into session-status polling.
+ *
+ * @param launcher - The mocked launcher.
+ */
+ function completeSdk(launcher: Launcher): void {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ }
+
+ it('polls the session status after completion and completes on an approved status', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved'));
+
+ await controller.startSumSub();
+
+ expect(handlers.getSessionStatus).toHaveBeenCalledWith({
+ sessionId: 'sid',
+ });
+ expect(controller.state.sumsub.status).toBe('complete');
+ expect(controller.state.sumsub.sessionStatus).toStrictEqual(
+ sessionStatus('approved'),
+ );
+ });
+ });
+
+ it('maps a rejected terminal status to a failed sub-flow', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ handlers.getSessionStatus.mockResolvedValue(sessionStatus('rejected'));
+
+ await controller.startSumSub();
+
+ expect(controller.state.sumsub.status).toBe('failed');
+ expect(controller.state.sumsub.sessionStatus).toStrictEqual(
+ sessionStatus('rejected'),
+ );
+ });
+ });
+
+ it('treats SDK completion as final when the UKYC session has no id to poll', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ // A session created without an id leaves nothing to poll against.
+ handlers.createUkycSession.mockResolvedValue({ sessionId: '' });
+ completeSdk(launcher);
+
+ await controller.startSumSub();
+
+ expect(handlers.getSessionStatus).not.toHaveBeenCalled();
+ expect(controller.state.sumsub.status).toBe('complete');
+ });
+ });
+
+ it('does not poll when the SDK did not report completion', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ // The applicant abandons the flow: `launch` resolves without ever
+ // reporting a Completed status.
+ launcher.launch.mockResolvedValue({ ok: false });
+
+ await controller.startSumSub();
+
+ expect(controller.state.sumsub.status).toBe('failed');
+ expect(handlers.getSessionStatus).not.toHaveBeenCalled();
+ });
+ });
+
+ it('keeps polling on a transient error, preserving the last good status', async () => {
+ jest.useFakeTimers();
+ await withController(
+ { options: { sessionStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ handlers.getSessionStatus
+ .mockResolvedValueOnce(sessionStatus('pending'))
+ .mockRejectedValueOnce(new Error('network blip'))
+ .mockResolvedValueOnce(sessionStatus('approved'));
+
+ await controller.startSumSub();
+
+ // First poll: non-terminal, keeps polling.
+ expect(controller.state.sumsub.status).toBe('polling');
+ expect(controller.state.sumsub.sessionStatus).toStrictEqual(
+ sessionStatus('pending'),
+ );
+
+ // Second poll fails transiently: the last good status is preserved
+ // and the loop keeps going.
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(controller.state.sumsub.status).toBe('polling');
+ expect(controller.state.sumsub.sessionStatus).toStrictEqual(
+ sessionStatus('pending'),
+ );
+
+ // Third poll reaches a terminal status.
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(controller.state.sumsub.status).toBe('complete');
+ expect(controller.state.sumsub.sessionStatus).toStrictEqual(
+ sessionStatus('approved'),
+ );
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3);
+ },
+ );
+ });
+
+ it('stops polling once a terminal status is reached', async () => {
+ jest.useFakeTimers();
+ await withController(
+ { options: { sessionStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ handlers.getSessionStatus.mockResolvedValue(
+ sessionStatus('approved'),
+ );
+
+ await controller.startSumSub();
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
+
+ // No further polls after a terminal status.
+ await jest.advanceTimersByTimeAsync(5000);
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
+ },
+ );
+ });
+
+ it('stops polling when reset() is called', async () => {
+ jest.useFakeTimers();
+ await withController(
+ { options: { sessionStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending'));
+
+ await controller.startSumSub();
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
+
+ controller.reset();
+ await jest.advanceTimersByTimeAsync(5000);
+
+ // The scheduled poll was cancelled by reset().
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
+ expect(controller.state.sumsub.status).toBe('idle');
+ },
+ );
+ });
+
+ it('discards a poll result when reset() runs while the request is in flight', async () => {
+ await withController(async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ // Simulate a reset() landing while the status request is in flight.
+ handlers.getSessionStatus.mockImplementation(async () => {
+ controller.reset();
+ return sessionStatus('approved');
+ });
+
+ await controller.startSumSub();
+
+ expect(controller.state.sumsub.status).toBe('idle');
+ expect(controller.state.sumsub.sessionStatus).toBeNull();
+ });
+ });
+
+ it('supersedes a prior polling loop when a new sub-flow starts', async () => {
+ jest.useFakeTimers();
+ await withController(
+ { options: { sessionStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers, launcher }) => {
+ completeSdk(launcher);
+ // First sub-flow polls a never-terminal status.
+ handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending'));
+
+ await controller.startSumSub();
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1);
+
+ // A second sub-flow reaches a terminal status on its first poll and
+ // must cancel the first loop's scheduled poll.
+ handlers.getSessionStatus.mockResolvedValue(
+ sessionStatus('approved'),
+ );
+ await controller.startSumSub();
+ expect(controller.state.sumsub.status).toBe('complete');
+
+ const callsAfterSecondFlow =
+ handlers.getSessionStatus.mock.calls.length;
+ await jest.advanceTimersByTimeAsync(5000);
+
+ // No stray polls from the superseded first loop.
+ expect(handlers.getSessionStatus).toHaveBeenCalledTimes(
+ callsAfterSecondFlow,
+ );
+ },
+ );
+ });
+ });
+
+ describe('getSessionStatus', () => {
+ it('fetches and records the session status on demand', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ sumsub: {
+ status: 'complete',
+ result: null,
+ sessionId: 'sid',
+ applicantAccessToken: null,
+ sessionStatus: null,
+ },
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.getSessionStatus.mockResolvedValue(
+ sessionStatus('approved'),
+ );
+
+ const result = await controller.getSessionStatus();
+
+ expect(handlers.getSessionStatus).toHaveBeenCalledWith({
+ sessionId: 'sid',
+ });
+ expect(result).toStrictEqual(sessionStatus('approved'));
+ expect(controller.state.sumsub.sessionStatus).toStrictEqual(
+ sessionStatus('approved'),
+ );
+ },
+ );
+ });
+
+ it('throws when there is no active SumSub session', async () => {
+ await withController(async ({ controller }) => {
+ await expect(controller.getSessionStatus()).rejects.toThrow(
+ /no active SumSub session/u,
+ );
+ });
+ });
+ });
+
+ describe('registerMoneyAccountWallet', () => {
+ const registration = {
+ id: 'wallet-1',
+ address: '0xabc',
+ blockchain: 'Monad' as const,
+ disabled: false,
+ isSelf: true,
+ };
+
+ it('returns an existing active registration without signing', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getWalletRegistrationStatus.mockResolvedValue({
+ type: 'active',
+ registration,
+ });
+
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toStrictEqual({
+ type: 'alreadyRegistered',
+ registration,
+ });
+ expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ it('returns an existing disabled registration without signing', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getWalletRegistrationStatus.mockResolvedValue({
+ type: 'disabled',
+ registration: { ...registration, disabled: true },
+ });
+
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toMatchObject({ type: 'registeredDisabled' });
+ expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ it('prefers the customer id captured from the MoonPay frame', async () => {
+ await withController(
+ { options: { state: { moonpayCustomerId: 'frame-customer' } } },
+ async ({ controller, handlers }) => {
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toMatchObject({ type: 'registered' });
+
+ expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled();
+ expect(handlers.signPersonalMessage).toHaveBeenCalledWith({
+ data: expect.stringContaining('as customer frame-customer.'),
+ from: '0xabc',
+ });
+ expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ address: '0xabc',
+ customerId: 'frame-customer',
+ signature: '0xsig',
+ }),
+ );
+ },
+ );
+ });
+
+ it('falls back to resolving the customer id from the proxy', async () => {
+ await withController(async ({ controller, handlers }) => {
+ await controller.registerMoneyAccountWallet({ address: '0xabc' });
+
+ expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1);
+ expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ customerId: 'iron-customer-fallback',
+ }),
+ );
+ });
+ });
+
+ it('reconciles an ambiguous conflict as already registered', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getWalletRegistrationStatus
+ .mockResolvedValueOnce({ type: 'absent' })
+ .mockResolvedValueOnce({ type: 'active', registration });
+ handlers.registerSelfHostedWallet.mockRejectedValue(
+ new WalletRegistrationError('conflict', { httpStatus: 409 }),
+ );
+
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toStrictEqual({
+ type: 'alreadyRegistered',
+ registration,
+ });
+ });
+ });
+
+ it('rethrows a transient failure when reconciliation remains absent', async () => {
+ await withController(async ({ controller, handlers }) => {
+ const error = new WalletRegistrationError('transient', {
+ httpStatus: 502,
+ });
+ handlers.registerSelfHostedWallet.mockRejectedValue(error);
+
+ await expect(
+ controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).rejects.toBe(error);
+ expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4);
+ expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ it('rebuilds and re-signs after a UTC date rollover', async () => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z'));
+ try {
+ await withController(async ({ controller, handlers }) => {
+ handlers.registerSelfHostedWallet
+ .mockImplementationOnce(async () => {
+ jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z'));
+ throw new WalletRegistrationError('validation', {
+ httpStatus: 400,
+ });
+ })
+ .mockResolvedValueOnce({
+ type: 'registered',
+ registration,
+ });
+
+ await controller.registerMoneyAccountWallet({ address: '0xabc' });
+
+ expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2);
+ expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain(
+ 'signed on 12/08/2026',
+ );
+ expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain(
+ 'signed on 13/08/2026',
+ );
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it.each([
+ new WalletRegistrationError('validation', { httpStatus: 400 }),
+ new WalletRegistrationError('rateLimited', { httpStatus: 429 }),
+ new WalletRegistrationError('unauthorized', { httpStatus: 401 }),
+ new Error('unexpected'),
+ ])('rethrows terminal registration failure %#', async (error) => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.registerSelfHostedWallet.mockRejectedValue(error);
+
+ await expect(
+ controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).rejects.toBe(error);
+ expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('rethrows an initial lookup failure without signing', async () => {
+ await withController(async ({ controller, handlers }) => {
+ const error = new Error('lookup failed');
+ handlers.getWalletRegistrationStatus.mockRejectedValue(error);
+
+ await expect(
+ controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).rejects.toBe(error);
+ expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ it('rethrows a signing failure without submitting', async () => {
+ await withController(async ({ controller, handlers }) => {
+ const error = new Error('signing failed');
+ handlers.signPersonalMessage.mockRejectedValue(error);
+
+ await expect(
+ controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).rejects.toBe(error);
+ expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('reset', () => {
+ it('clears session state but preserves persisted terms', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ phase: 'form',
+ sessionToken: 'tok',
+ accessToken: 'a',
+ activeProduct: 'ramps',
+ termsAcceptedAt: 't',
+ acceptedDisclaimerIds: ['1'],
+ kycRequiredByProduct: { ramps: true },
+ },
+ },
+ },
+ ({ controller }) => {
+ controller.reset();
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.sessionToken).toBeNull();
+ expect(controller.state.accessToken).toBeNull();
+ expect(controller.state.activeProduct).toBeNull();
+ expect(controller.state.termsAcceptedAt).toBe('t');
+ expect(controller.state.kycRequiredByProduct.ramps).toBe(true);
+ },
+ );
+ });
+ });
+
+ describe('messenger actions', () => {
+ it('exposes methods as messenger actions', async () => {
+ await withController(({ rootMessenger }) => {
+ expect(
+ rootMessenger.call('KycController:buildResetFrameUrl'),
+ ).toContain('ch_reset');
+ });
+ });
+ });
+});
+
+type RootMessenger = Messenger<
+ MockAnyNamespace,
+ MessengerActions,
+ MessengerEvents
+>;
+
+type ServiceHandlers = {
+ getGeoCountry: jest.Mock;
+ fetchDisclaimers: jest.Mock;
+ createSession: jest.Mock;
+ checkKycRequired: jest.Mock;
+ getWrappingKey: jest.Mock;
+ fetchJwks: jest.Mock;
+ createUkycSession: jest.Mock;
+ createJourney: jest.Mock;
+ getSessionStatus: jest.Mock;
+ getMoonpayCustomerId: jest.Mock;
+ getWalletRegistrationStatus: jest.Mock;
+ registerSelfHostedWallet: jest.Mock;
+ signPersonalMessage: jest.Mock;
+ performGetStorage: jest.Mock;
+ performSetStorage: jest.Mock;
+};
+
+type Launcher = {
+ isAvailable: jest.Mock;
+ launch: jest.Mock;
+};
+
+type WithControllerCallback = (payload: {
+ controller: KycController;
+ rootMessenger: RootMessenger;
+ handlers: ServiceHandlers;
+ launcher: Launcher;
+}) => Promise | ReturnValue;
+
+type WithControllerOptions = {
+ options: Partial[0]>;
+};
+
+const SERVICE_ACTIONS = [
+ 'KycService:getGeoCountry',
+ 'KycService:fetchDisclaimers',
+ 'KycService:createSession',
+ 'KycService:checkKycRequired',
+ 'KycService:getWrappingKey',
+ 'KycService:fetchJwks',
+ 'KycService:createUkycSession',
+ 'KycService:createJourney',
+ 'KycService:getSessionStatus',
+ 'KycService:getMoonpayCustomerId',
+ 'KycService:getWalletRegistrationStatus',
+ 'KycService:registerSelfHostedWallet',
+ 'KeyringController:signPersonalMessage',
+ 'UserStorageController:performGetStorage',
+ 'UserStorageController:performSetStorage',
+] as const;
+
+/**
+ * Builds a UKYC session status payload with a given `finalStatus`.
+ *
+ * @param finalStatus - The overall session status.
+ * @returns A complete session status object.
+ */
+function sessionStatus(finalStatus: string): {
+ finalStatus: string;
+ externalUserId: string;
+ kycStatus: string;
+ vendor: string;
+ vendorStatus: string;
+} {
+ return {
+ finalStatus,
+ externalUserId: 'ext-1',
+ kycStatus: finalStatus,
+ vendor: 'sumsub',
+ vendorStatus: finalStatus,
+ };
+}
+
+/**
+ * Wraps a test with a fully-wired controller, mocked service handlers, and a
+ * mocked SumSub launcher.
+ *
+ * @param args - Either a callback, or an options bag and a callback.
+ * @returns The callback's return value.
+ */
+function withController(
+ ...args:
+ | [WithControllerCallback]
+ | [WithControllerOptions, WithControllerCallback]
+): ReturnValue | Promise {
+ const [{ options = {} }, testFunction] =
+ args.length === 2 ? args : [{}, args[0]];
+
+ const rootMessenger: RootMessenger = new Messenger({
+ namespace: MOCK_ANY_NAMESPACE,
+ captureException: jest.fn(),
+ });
+ const messenger: KycControllerMessenger = new Messenger({
+ namespace: 'KycController',
+ parent: rootMessenger,
+ });
+ rootMessenger.delegate({
+ actions: SERVICE_ACTIONS,
+ events: [],
+ messenger,
+ });
+
+ const handlers: ServiceHandlers = {
+ getGeoCountry: jest.fn().mockResolvedValue('USA'),
+ fetchDisclaimers: jest.fn().mockResolvedValue([]),
+ createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }),
+ checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }),
+ getWrappingKey: jest.fn().mockResolvedValue({
+ id: 'wk',
+ jwtChain: 'jwt.chain.sig',
+ // Matches the `sessionServerPublicKeyX` returned by the mocked
+ // `verifyJwtChain`, so the attestation check passes.
+ sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' },
+ }),
+ fetchJwks: jest.fn().mockResolvedValue({ keys: [] }),
+ createUkycSession: jest.fn().mockResolvedValue({
+ sessionId: 'sid',
+ }),
+ createJourney: jest
+ .fn()
+ .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }),
+ getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')),
+ getMoonpayCustomerId: jest.fn().mockResolvedValue('iron-customer-fallback'),
+ getWalletRegistrationStatus: jest
+ .fn()
+ .mockResolvedValue({ type: 'absent' }),
+ registerSelfHostedWallet: jest.fn().mockResolvedValue({
+ type: 'registered',
+ registration: {
+ id: 'wallet-1',
+ address: '0xabc',
+ blockchain: 'Monad',
+ disabled: false,
+ isSelf: true,
+ },
+ }),
+ signPersonalMessage: jest.fn().mockResolvedValue('0xsig'),
+ performGetStorage: jest.fn().mockResolvedValue(null),
+ performSetStorage: jest.fn().mockResolvedValue(undefined),
+ };
+ rootMessenger.registerActionHandler(
+ 'KycService:getGeoCountry',
+ handlers.getGeoCountry,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:fetchDisclaimers',
+ handlers.fetchDisclaimers,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:createSession',
+ handlers.createSession,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:checkKycRequired',
+ handlers.checkKycRequired,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:getWrappingKey',
+ handlers.getWrappingKey,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:fetchJwks',
+ handlers.fetchJwks,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:createUkycSession',
+ handlers.createUkycSession,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:createJourney',
+ handlers.createJourney,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:getSessionStatus',
+ handlers.getSessionStatus,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:getMoonpayCustomerId',
+ handlers.getMoonpayCustomerId,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:getWalletRegistrationStatus',
+ handlers.getWalletRegistrationStatus,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:registerSelfHostedWallet',
+ handlers.registerSelfHostedWallet,
+ );
+ rootMessenger.registerActionHandler(
+ 'KeyringController:signPersonalMessage',
+ handlers.signPersonalMessage,
+ );
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:performGetStorage',
+ handlers.performGetStorage,
+ );
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:performSetStorage',
+ handlers.performSetStorage,
+ );
+
+ // Configure the mocked UKYC crypto for this test (reset before each test by
+ // the shared jest config).
+ mockVerifyJwtChain.mockReturnValue({
+ sessionServerPublicKeyX: 'spk-x',
+ nonce: 'n',
+ });
+ mockWrapEncryptionKey.mockReturnValue({
+ encryptedKey: 'enc',
+ nonce: 'nonce',
+ });
+
+ const launcher: Launcher = {
+ isAvailable: jest.fn().mockReturnValue(true),
+ launch: jest.fn().mockResolvedValue({ ok: true }),
+ };
+
+ const controller = new KycController({
+ messenger,
+ sumsubLauncher: launcher as unknown as KycSumSubLauncher,
+ ...options,
+ });
+
+ return testFunction({ controller, rootMessenger, handlers, launcher });
+}
diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts
new file mode 100644
index 00000000000..39a9e9e7acf
--- /dev/null
+++ b/packages/kyc-controller/src/KycController.ts
@@ -0,0 +1,1665 @@
+import type {
+ ControllerGetStateAction,
+ ControllerStateChangeEvent,
+ StateMetadata,
+} from '@metamask/base-controller';
+import { BaseController } from '@metamask/base-controller';
+import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller';
+import type { Messenger } from '@metamask/messenger';
+import type {
+ UserStorageControllerPerformGetStorageAction,
+ UserStorageControllerPerformSetStorageAction,
+} from '@metamask/profile-sync-controller/user-storage';
+import type { Hex, Json } from '@metamask/utils';
+import { x25519 } from '@noble/curves/ed25519';
+
+import { decryptCredentials, generateKeyPair } from './crypto.js';
+import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js';
+import { toBase64Url } from './encoding.js';
+import type { KycControllerMethodActions } from './KycController-method-action-types.js';
+import type { KycServiceMethodActions } from './KycService-method-action-types.js';
+import { buildOwnershipMessage } from './ownership-message.js';
+import type {
+ KycDisclaimer,
+ KycPhase,
+ KycProduct,
+ KycSessionStatus,
+ KycSumSubLauncher,
+ KycSumSubStatus,
+} from './types.js';
+import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js';
+import { verifyJwtChain } from './ukyc/jwtChain.js';
+import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js';
+import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js';
+import { signStorageAccessToken } from './ukyc/storageAccessToken.js';
+import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js';
+import {
+ createInitialState,
+ transition as transitionWalletRegistration,
+} from './wallet-registration-machine.js';
+import type {
+ RegistrationStatus,
+ SelfHostedRegistration,
+} from './wallet-registration-service.js';
+import { WalletRegistrationError } from './wallet-registration-service.js';
+
+// === GENERAL ===
+
+export const controllerName = 'KycController';
+
+const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1';
+const CHANNEL_CHECK = 'ch_1';
+const CHANNEL_AUTH = 'ch_2';
+const CHANNEL_RESET = 'ch_reset';
+
+// Placeholder credentials for the SumSub sub-flow. These are demo values that
+// must be replaced with real UKYC-issued material before production use.
+const MOCK_JWT_TOKEN = 'mock-jwt-token';
+
+// Lifetime of the read-only `ukyc_capability_token` minted when creating a
+// UKYC session. The storage-and-auth spec requires the token's `expires_at` to
+// cover the KYC session's expected lifetime — including the provider journey —
+// rather than a fixed short window, so this is a session-scoped window.
+const UKYC_CAPABILITY_TOKEN_TTL_MS = 4 * 60 * 60 * 1000;
+
+// The SumSub SDK status that signals the applicant finished the flow
+// successfully. Any other resolution (abandonment, failure, or a non-success
+// outcome) must not be recorded as `complete`.
+const SUMSUB_COMPLETED_STATUS = 'Completed';
+
+// Phases that represent an active vendor-session flow (tokens issued and/or
+// Check/Auth frames in progress). A repeat `initialize` while in one of these
+// must not restart the session and disrupt the in-flight flow.
+const IN_PROGRESS_PHASES: KycPhase[] = [
+ 'session',
+ 'check',
+ 'auth',
+ 'form',
+ 'submit',
+];
+
+// How often to poll the UKYC session status after the SumSub SDK completes,
+// until a terminal status is reached. Overridable via the constructor.
+const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000;
+
+// UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus`
+// (the vendor-side outcome) draw from the same vocabulary, so they are defined
+// once here and composed into the sets/checks below rather than repeated as
+// literals.
+const KYC_STATUSES = {
+ approved: 'approved',
+ completed: 'completed',
+ rejected: 'rejected',
+ failed: 'failed',
+ blocked: 'blocked',
+ pending: 'pending',
+} as const;
+
+// `finalStatus` values that end the polling loop. Anything else (e.g.
+// `KYC_STATUSES.pending`) keeps polling.
+const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([
+ KYC_STATUSES.approved,
+ KYC_STATUSES.completed,
+ KYC_STATUSES.rejected,
+ KYC_STATUSES.failed,
+ KYC_STATUSES.blocked,
+]);
+
+// Terminal `finalStatus` values that represent a successful verification. Any
+// other terminal status resolves the sub-flow to `failed`.
+const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([
+ KYC_STATUSES.approved,
+ KYC_STATUSES.completed,
+]);
+
+// Session creation can report that the applicant is already approved on the
+// relay (`kycStatus === KYC_STATUSES.approved`) while the vendor is still
+// finalizing its decision (`finalStatus === KYC_STATUSES.pending`, a
+// non-terminal status). In that case there is nothing left for the applicant
+// to do, so the sub-flow stops before launching the SDK and surfaces this
+// message.
+const VENDOR_PROCESSING_MESSAGE =
+ 'Your KYC has been submitted and is being processed by the vendor.';
+
+// === STATE ===
+
+/**
+ * Describes the shape of the state object for {@link KycController}.
+ */
+export type KycControllerState = {
+ /** Current phase of the identity flow. */
+ phase: KycPhase;
+ /** Human-readable status message for the current phase. */
+ statusMessage: string;
+ /** The current error message, or `null`. */
+ error: string | null;
+
+ /** Email associated with the session (sourced from the account). */
+ email: string | null;
+
+ /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */
+ termsAcceptedAt: string | null;
+ /** IDs of the disclaimers the customer accepted (persisted). */
+ acceptedDisclaimerIds: string[];
+
+ /** Disclaimers fetched for the current country. */
+ disclaimers: KycDisclaimer[];
+ /** Error encountered while loading disclaimers, or `null`. */
+ disclaimersError: string | null;
+
+ /** Resolved ISO 3166-1 alpha-3 country code. */
+ geoCountry: string | null;
+
+ /** Vendor session token (not persisted, not logged). */
+ sessionToken: string | null;
+ /** Vendor access token (not persisted, not logged). */
+ accessToken: string | null;
+ /** Vendor customer id, used for the SumSub hand-off. */
+ moonpayCustomerId: string | null;
+
+ /**
+ * The product the current flow is running for. Captured at `initialize`
+ * (or `acceptTermsAndStartSession`) and used to automatically run the
+ * KYC-required check once authentication completes. `null` outside a
+ * product-scoped flow (in which case the flow stops at `form` and the
+ * consumer drives the check manually).
+ */
+ activeProduct: KycProduct | null;
+
+ /** Cached "is KYC required" result per product (persisted). */
+ kycRequiredByProduct: Partial>;
+ /** ISO-8601 timestamp of the last KYC-required check (persisted). */
+ lastCheckedAt: string | null;
+
+ /** SumSub document-verification sub-flow state. */
+ sumsub: {
+ status: KycSumSubStatus;
+ result: Json | null;
+ sessionId: string | null;
+ applicantAccessToken: string | null;
+ /**
+ * The latest UKYC session status, populated while polling after the SDK
+ * completes. `null` until the first successful poll.
+ */
+ sessionStatus: KycSessionStatus | null;
+ };
+};
+
+const kycControllerMetadata = {
+ phase: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ statusMessage: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ error: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ email: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: false,
+ usedInUi: false,
+ },
+ termsAcceptedAt: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: false,
+ },
+ acceptedDisclaimerIds: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: false,
+ },
+ disclaimers: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: false,
+ usedInUi: true,
+ },
+ disclaimersError: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ geoCountry: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ sessionToken: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: false,
+ usedInUi: false,
+ },
+ accessToken: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: false,
+ usedInUi: false,
+ },
+ moonpayCustomerId: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: false,
+ usedInUi: false,
+ },
+ activeProduct: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ kycRequiredByProduct: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: true,
+ },
+ lastCheckedAt: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: false,
+ },
+ sumsub: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: false,
+ usedInUi: true,
+ },
+} satisfies StateMetadata;
+
+/**
+ * Constructs the default {@link KycController} state.
+ *
+ * @returns The default state.
+ */
+export function getDefaultKycControllerState(): KycControllerState {
+ return {
+ phase: 'idle',
+ statusMessage: '',
+ error: null,
+ email: null,
+ termsAcceptedAt: null,
+ acceptedDisclaimerIds: [],
+ disclaimers: [],
+ disclaimersError: null,
+ geoCountry: null,
+ sessionToken: null,
+ accessToken: null,
+ moonpayCustomerId: null,
+ activeProduct: null,
+ kycRequiredByProduct: {},
+ lastCheckedAt: null,
+ sumsub: {
+ status: 'idle',
+ result: null,
+ sessionId: null,
+ applicantAccessToken: null,
+ sessionStatus: null,
+ },
+ };
+}
+
+// === MESSENGER ===
+
+const MESSENGER_EXPOSED_METHODS = [
+ 'initialize',
+ 'loadDisclaimers',
+ 'acceptTermsAndStartSession',
+ 'clearSavedTerms',
+ 'handleFrameMessage',
+ 'buildCheckFrameUrl',
+ 'buildAuthFrameUrl',
+ 'buildResetFrameUrl',
+ 'checkKycRequired',
+ 'getKycStatus',
+ 'startSumSub',
+ 'getSessionStatus',
+ 'registerMoneyAccountWallet',
+ 'reset',
+] as const;
+
+export type KycControllerGetStateAction = ControllerGetStateAction<
+ typeof controllerName,
+ KycControllerState
+>;
+
+export type KycControllerActions =
+ | KycControllerGetStateAction
+ | KycControllerMethodActions;
+
+type AllowedActions =
+ | KycServiceMethodActions
+ | KeyringControllerSignPersonalMessageAction
+ | UserStorageControllerPerformGetStorageAction
+ | UserStorageControllerPerformSetStorageAction;
+
+export type KycControllerStateChangeEvent = ControllerStateChangeEvent<
+ typeof controllerName,
+ KycControllerState
+>;
+
+export type KycControllerEvents = KycControllerStateChangeEvent;
+
+type AllowedEvents = never;
+
+export type KycControllerMessenger = Messenger<
+ typeof controllerName,
+ KycControllerActions | AllowedActions,
+ KycControllerEvents | AllowedEvents
+>;
+
+/**
+ * Options for constructing a {@link KycController}.
+ */
+export type KycControllerOptions = {
+ messenger: KycControllerMessenger;
+ state?: Partial;
+ /**
+ * Platform adapter that presents the SumSub SDK. Injected by each client so
+ * the controller stays platform-agnostic.
+ */
+ sumsubLauncher: KycSumSubLauncher;
+ /**
+ * How often, in milliseconds, to poll the UKYC session status after the
+ * SumSub SDK completes. Defaults to
+ * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}.
+ */
+ sessionStatusPollIntervalMs?: number;
+};
+
+export type MoneyAccountWalletRegistrationResult =
+ | {
+ type: 'registered' | 'alreadyRegistered';
+ registration: SelfHostedRegistration;
+ }
+ | {
+ type: 'registeredDisabled';
+ registration: SelfHostedRegistration;
+ };
+
+/**
+ * The shape of a message posted by a Check/Auth frame.
+ */
+type FrameMessage = {
+ meta?: { channelId?: string };
+ kind?: string;
+ payload?: {
+ status?:
+ | 'active'
+ | 'connectionRequired'
+ | 'termsAcceptanceRequired'
+ | 'pending'
+ | 'unavailable'
+ | 'failed';
+ credentials?: EncryptedCredentialsEnvelope | string;
+ customer?: { id?: string };
+ };
+};
+
+// === CONTROLLER DEFINITION ===
+
+/**
+ * `KycController` orchestrates the vendor-backed KYC / identity-verification
+ * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per
+ * product surface used by ramps and card. It owns all state, HTTP
+ * orchestration (via `KycService`), crypto, and the frame message protocol;
+ * platform-specific presentation (WebView/iframe, SumSub SDK) is injected.
+ */
+export class KycController extends BaseController<
+ typeof controllerName,
+ KycControllerState,
+ KycControllerMessenger
+> {
+ readonly #sumsubLauncher: KycSumSubLauncher;
+
+ /** Ephemeral X25519 keypair for the frame key exchange (never persisted). */
+ readonly #keypair: X25519KeyPair;
+
+ /** Auth-frame client token, kept out of state. */
+ #authClientToken: string | null = null;
+
+ /**
+ * Monotonic flow generation. Incremented by {@link reset} so in-flight async
+ * work (e.g. the KYC-required check) can detect that it was superseded and
+ * avoid writing stale results onto a reset controller.
+ */
+ #generation = 0;
+
+ /** Interval, in milliseconds, between session-status polls. */
+ readonly #sessionStatusPollIntervalMs: number;
+
+ /** Handle for the scheduled next session-status poll, or `null`. */
+ #pollTimer: ReturnType | null = null;
+
+ /**
+ * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a
+ * new sub-flow, and once a terminal status is reached) so an in-flight poll
+ * `tick` can detect it was superseded and neither write state nor schedule a
+ * follow-up. This closes the gap where clearing the timer alone would still
+ * let an already-awaiting request finish and reschedule.
+ */
+ #pollToken = 0;
+
+ /**
+ * Constructs a new {@link KycController}.
+ *
+ * @param options - The constructor options.
+ * @param options.messenger - The messenger suited for this controller.
+ * @param options.state - Partial initial state; merged over defaults.
+ * @param options.sumsubLauncher - The platform SumSub launcher adapter.
+ * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC
+ * session status after the SumSub SDK completes.
+ */
+ constructor({
+ messenger,
+ state,
+ sumsubLauncher,
+ sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS,
+ }: KycControllerOptions) {
+ super({
+ messenger,
+ metadata: kycControllerMetadata,
+ name: controllerName,
+ state: { ...getDefaultKycControllerState(), ...state },
+ });
+
+ this.#sumsubLauncher = sumsubLauncher;
+ this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs;
+ this.#keypair = generateKeyPair();
+
+ this.messenger.registerMethodActionHandlers(
+ this,
+ MESSENGER_EXPOSED_METHODS,
+ );
+ }
+
+ /**
+ * Builds an adapter over `UserStorageController` that the platform-agnostic
+ * `getOrCreateLocalUserSecret` helper uses to persist/load the UKYC
+ * `local_user_secret`.
+ *
+ * @returns The Encrypted User Storage adapter.
+ */
+ #localUserSecretStore(): UkycLocalUserSecretStore {
+ return {
+ get: async (
+ path: string,
+ entropySourceId?: string,
+ ): Promise =>
+ this.messenger.call(
+ 'UserStorageController:performGetStorage',
+ path as `${string}.${string}`,
+ entropySourceId,
+ ),
+ set: async (
+ path: string,
+ value: string,
+ entropySourceId?: string,
+ ): Promise =>
+ this.messenger.call(
+ 'UserStorageController:performSetStorage',
+ path as `${string}.${string}`,
+ value,
+ entropySourceId,
+ ),
+ };
+ }
+
+ /**
+ * Resolves persisted terms + geolocation, and auto-creates a session when
+ * terms are already accepted and an email is available.
+ *
+ * @param params - Optional parameters.
+ * @param params.email - The account email to associate with the session.
+ * @param params.product - The consuming feature the flow runs for. When
+ * provided, the controller automatically runs the KYC-required check once
+ * authentication completes (and chains into document verification when KYC
+ * is required). When omitted, the flow stops at `form` and the consumer must
+ * call `checkKycRequired` manually.
+ */
+ async initialize(params?: {
+ email?: string;
+ product?: KycProduct;
+ }): Promise {
+ // A repeat `initialize` while a session flow is already in progress must
+ // not tear it down: creating a new vendor session clears the tokens and
+ // forces `phase` back through `session`/`check`, breaking an in-flight
+ // Check/Auth frame flow. Leave the active flow untouched and let the
+ // consumer drive it (or call `reset` first to start over).
+ if (IN_PROGRESS_PHASES.includes(this.state.phase)) {
+ return;
+ }
+
+ // `initialize` starts a fresh flow, so `activeProduct` is always reset to
+ // this call's product (or `null`). Otherwise a prior run's product could
+ // linger and cause `#continueAfterAuthentication` to auto-run the check /
+ // sub-flow when the caller intended the manual (product-less) flow.
+ this.#applyUpdate((state) => {
+ if (params?.email) {
+ state.email = params.email;
+ }
+ state.activeProduct = params?.product ?? null;
+ });
+
+ // Capture the flow generation so a `reset()` landing while the async
+ // geolocation / session steps below are in flight cannot write results
+ // onto an idle controller.
+ const generation = this.#generation;
+
+ // Resolve country for display; non-blocking.
+ try {
+ const country = await this.messenger.call('KycService:getGeoCountry');
+ this.#updateIfCurrent(generation, (state) => {
+ state.geoCountry = country;
+ });
+ } catch {
+ // Ignore; disclaimers loading will surface a country error if needed.
+ }
+
+ const hasTerms =
+ Boolean(this.state.termsAcceptedAt) &&
+ this.state.acceptedDisclaimerIds.length > 0;
+
+ if (hasTerms && this.state.email) {
+ await this.#createSession();
+ return;
+ }
+
+ this.#applyUpdate((state) => {
+ state.phase = 'terms';
+ });
+ await this.loadDisclaimers();
+ }
+
+ /**
+ * Loads the disclaimers for the resolved (or provided) country.
+ *
+ * @param params - Optional parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code override.
+ */
+ async loadDisclaimers(params?: { country?: string }): Promise {
+ // Capture the flow generation so a `reset()` landing while the geo /
+ // disclaimers requests are in flight cannot write results onto an idle
+ // controller.
+ const generation = this.#generation;
+ try {
+ const country =
+ params?.country ??
+ this.state.geoCountry ??
+ (await this.messenger.call('KycService:getGeoCountry'));
+ if (country !== this.state.geoCountry) {
+ this.#updateIfCurrent(generation, (state) => {
+ state.geoCountry = country;
+ });
+ }
+ const disclaimers = await this.messenger.call(
+ 'KycService:fetchDisclaimers',
+ { country },
+ );
+ this.#updateIfCurrent(generation, (state) => {
+ state.disclaimers = disclaimers;
+ state.disclaimersError = null;
+ });
+ } catch (error) {
+ this.#updateIfCurrent(generation, (state) => {
+ state.disclaimersError = `Failed to load disclaimers: ${String(error)}`;
+ });
+ }
+ }
+
+ /**
+ * Captures terms acceptance for the currently loaded disclaimers and creates
+ * a session.
+ *
+ * @param params - Optional parameters.
+ * @param params.email - The account email to associate with the session.
+ * @param params.product - The consuming feature the flow runs for. See
+ * {@link initialize} for how the product drives the automatic post
+ * authentication continuation.
+ */
+ async acceptTermsAndStartSession(params?: {
+ email?: string;
+ product?: KycProduct;
+ }): Promise {
+ const termsAcceptedAt = new Date().toISOString();
+ const disclaimerIds = this.state.disclaimers.map(
+ (disclaimer) => disclaimer.id,
+ );
+ this.#applyUpdate((state) => {
+ if (params?.email) {
+ state.email = params.email;
+ }
+ if (params?.product) {
+ state.activeProduct = params.product;
+ }
+ state.termsAcceptedAt = termsAcceptedAt;
+ state.acceptedDisclaimerIds = disclaimerIds;
+ });
+ await this.#createSession();
+ }
+
+ /**
+ * Creates a vendor session from the currently stored terms + email.
+ */
+ async #createSession(): Promise {
+ const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state;
+ if (!email) {
+ this.#fail('Missing email for session creation.');
+ return;
+ }
+ if (!termsAcceptedAt || acceptedDisclaimerIds.length === 0) {
+ this.#fail('Missing terms acceptance for session creation.');
+ return;
+ }
+
+ // A new session invalidates any authentication carried over from a prior
+ // session. Clear the stale session token, access token, and auth-frame
+ // client token so `buildCheckFrameUrl` cannot return a URL bound to an old
+ // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot
+ // return a URL tied to an old client token, and `checkKycRequired` cannot
+ // run with an access token from an earlier authentication. The Check/Auth
+ // frames re-populate these for the new session. Because `sessionToken` is
+ // cleared here and only re-set on success, a failed creation leaves it
+ // `null` rather than resurrecting the previous session.
+ // Capture the flow generation so a `reset()` landing while the create
+ // request is in flight cannot resurrect a session (success) or overwrite
+ // the now-idle controller (failure). The synchronous update below runs
+ // before any `await`, so it needs no guard.
+ const generation = this.#generation;
+ this.#authClientToken = null;
+ this.#applyUpdate((state) => {
+ state.error = null;
+ state.phase = 'session';
+ state.statusMessage = 'Creating session...';
+ state.sessionToken = null;
+ state.accessToken = null;
+ });
+
+ try {
+ const { sessionToken } = await this.messenger.call(
+ 'KycService:createSession',
+ { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds },
+ );
+ this.#updateIfCurrent(generation, (state) => {
+ state.sessionToken = sessionToken;
+ state.phase = 'check';
+ state.statusMessage = 'Authenticating via Check frame...';
+ });
+ } catch (error) {
+ console.error('Session creation failed:', error);
+ // A reset() superseded this flow while the request was in flight; leave
+ // the idle controller alone rather than forcing it back to `terms`.
+ if (this.#generation !== generation) {
+ return;
+ }
+ // Invalidate the stored acceptance so the customer can retry. Also clear
+ // `activeProduct` so a later `acceptTermsAndStartSession` that omits a
+ // product cannot auto-run the KYC check / SumSub chain for this failed
+ // flow's product — matching how `initialize` starts from a clean product.
+ this.#applyUpdate((state) => {
+ this.#clearAcceptedTerms(state);
+ state.activeProduct = null;
+ state.error = `Session creation failed: ${String(error)}`;
+ state.statusMessage =
+ 'Session creation failed — accept the terms to try again.';
+ state.phase = 'terms';
+ });
+ await this.loadDisclaimers();
+ }
+ }
+
+ /**
+ * Clears the persisted terms acceptance.
+ */
+ clearSavedTerms(): void {
+ this.#applyUpdate((state) => {
+ this.#clearAcceptedTerms(state);
+ });
+ }
+
+ /**
+ * Clears the stored terms acceptance on the given draft state. Shared by the
+ * paths that must invalidate acceptance — explicit clear, vendor terms
+ * update, and session-creation failure — so they stay in sync. This is a
+ * targeted invalidation and, unlike {@link reset}, deliberately leaves the
+ * rest of the flow (geolocation, disclaimers, phase) untouched.
+ *
+ * @param state - The state to mutate.
+ */
+ #clearAcceptedTerms(state: KycControllerState): void {
+ state.termsAcceptedAt = null;
+ state.acceptedDisclaimerIds = [];
+ }
+
+ /**
+ * Handles a message posted by a Check/Auth frame and advances the flow.
+ *
+ * The transport-agnostic caller (WebView on mobile, iframe on web) forwards
+ * the raw message and injects the returned `reply` back into the frame.
+ *
+ * @param params - The parameters.
+ * @param params.message - The raw message posted by the frame.
+ * @returns An object whose optional `reply` should be posted back.
+ */
+ async handleFrameMessage(params: {
+ message: unknown;
+ }): Promise<{ reply?: unknown }> {
+ const payload = params.message as FrameMessage | undefined;
+
+ if (!payload) {
+ return {};
+ }
+
+ if (payload.kind === 'handshake') {
+ const channelId = payload.meta?.channelId;
+ return { reply: { version: 2, meta: { channelId }, kind: 'ack' } };
+ }
+
+ if (payload.kind !== 'complete') {
+ return {};
+ }
+
+ const channelId = payload.meta?.channelId;
+
+ // Only honor a Check/Auth `complete` for the frame the flow is currently
+ // waiting on. This drops stale or duplicate messages — e.g. a late post
+ // after `reset()` (phase `idle`) or after the flow already advanced past
+ // this frame — so they cannot resurrect tokens or rewind `phase` on a
+ // controller that has moved on. Frame messages are external input and,
+ // unlike the async steps, are not covered by the `#generation` guard.
+ let expectedPhase: KycPhase | null = null;
+ if (channelId === CHANNEL_CHECK) {
+ expectedPhase = 'check';
+ } else if (channelId === CHANNEL_AUTH) {
+ expectedPhase = 'auth';
+ }
+ if (!expectedPhase || this.state.phase !== expectedPhase) {
+ return {};
+ }
+
+ const status = payload.payload?.status;
+ const credsEnvelope = payload.payload?.credentials;
+
+ const customerId = payload.payload?.customer?.id ?? null;
+ if (customerId) {
+ this.#applyUpdate((state) => {
+ state.moonpayCustomerId = customerId;
+ });
+ }
+
+ if (!status) {
+ return {};
+ }
+
+ let accessToken: string | undefined;
+ let clientToken: string | undefined;
+ if (credsEnvelope) {
+ try {
+ const { credentials } = decryptCredentials(
+ credsEnvelope,
+ this.#keypair.privateKey,
+ );
+ accessToken = credentials.accessToken;
+ clientToken = credentials.clientToken;
+ } catch (error) {
+ this.#fail(`Failed to decrypt frame credentials: ${String(error)}`);
+ return {};
+ }
+ }
+
+ if (channelId === CHANNEL_CHECK) {
+ await this.#handleCheckOutcome(status, accessToken, clientToken);
+ return {};
+ }
+
+ // channelId === CHANNEL_AUTH, guaranteed by the expectedPhase guard above.
+ await this.#handleAuthOutcome(status, accessToken);
+ return {};
+ }
+
+ /**
+ * Applies a Check-frame outcome.
+ *
+ * @param status - The frame status.
+ * @param accessToken - The decrypted access token, if any.
+ * @param clientToken - The decrypted client token, if any.
+ */
+ async #handleCheckOutcome(
+ status: NonNullable['status'],
+ accessToken?: string,
+ clientToken?: string,
+ ): Promise {
+ if (status === 'active' && accessToken) {
+ this.#applyUpdate((state) => {
+ state.accessToken = accessToken;
+ state.phase = 'form';
+ state.statusMessage = 'Already authenticated. Review to submit.';
+ });
+ await this.#continueAfterAuthentication();
+ return;
+ }
+ if (status === 'connectionRequired' && clientToken) {
+ this.#authClientToken = clientToken;
+ this.#applyUpdate((state) => {
+ state.phase = 'auth';
+ state.statusMessage = 'Verify your email via OTP in the Auth frame.';
+ });
+ return;
+ }
+ if (status === 'termsAcceptanceRequired') {
+ this.#requireTermsReacceptance();
+ return;
+ }
+ this.#fail(`Check frame returned status: ${status}`);
+ }
+
+ /**
+ * Applies an Auth-frame outcome.
+ *
+ * @param status - The frame status.
+ * @param accessToken - The decrypted access token, if any.
+ */
+ async #handleAuthOutcome(
+ status: NonNullable['status'],
+ accessToken?: string,
+ ): Promise {
+ if (status === 'active' && accessToken) {
+ this.#applyUpdate((state) => {
+ state.accessToken = accessToken;
+ state.phase = 'form';
+ state.statusMessage = 'Authenticated. Review to submit.';
+ });
+ await this.#continueAfterAuthentication();
+ return;
+ }
+ if (status === 'termsAcceptanceRequired') {
+ this.#requireTermsReacceptance();
+ return;
+ }
+ this.#fail(`Auth frame returned status: ${status}`);
+ }
+
+ /**
+ * Continues the flow once authentication has completed (phase `form`).
+ *
+ * When the flow is scoped to a product (see {@link initialize}), the
+ * KYC-required check runs automatically, and — when KYC is required — the
+ * document-verification sub-flow is launched. When no product is set, this is
+ * a no-op and the flow stays at `form` for the consumer to drive manually.
+ *
+ * Errors are already recorded on state by `checkKycRequired` (`error`
+ * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows
+ * them so it can be awaited safely from the frame-message handler.
+ */
+ async #continueAfterAuthentication(): Promise {
+ const product = this.state.activeProduct;
+ if (!product) {
+ return;
+ }
+
+ // Re-entry protection lives at the frame boundary: `handleFrameMessage`
+ // only honors a Check/Auth `complete` while `phase` matches, and both
+ // outcome handlers move `phase` to `form` before awaiting this method. A
+ // duplicate or late `complete` therefore lands after the phase moved on and
+ // is dropped before it can start a second continuation. Any writes here are
+ // additionally guarded by `#generation` (see `checkKycRequired` /
+ // `startSumSub`) so a `reset()` mid-continuation cannot corrupt state.
+ const kycRequired = await this.checkKycRequired({ product });
+ if (!kycRequired) {
+ return;
+ }
+
+ try {
+ await this.startSumSub();
+ } catch {
+ // `startSumSub` already records `sumsub.status = 'failed'`; swallow the
+ // rethrown error (e.g. SDK unavailable) so the awaited continuation
+ // resolves cleanly rather than surfacing as an unhandled rejection.
+ }
+ }
+
+ /**
+ * Invalidates stored terms and returns to the terms phase.
+ */
+ #requireTermsReacceptance(): void {
+ this.#applyUpdate((state) => {
+ this.#clearAcceptedTerms(state);
+ state.phase = 'terms';
+ state.statusMessage =
+ 'The vendor updated its Terms of Use — please re-accept.';
+ });
+ }
+
+ /**
+ * Builds the Check-frame URL, or `null` when no session exists yet.
+ *
+ * @returns The Check-frame URL or `null`.
+ */
+ buildCheckFrameUrl(): string | null {
+ if (!this.state.sessionToken) {
+ return null;
+ }
+ const url = new URL(`${FRAMES_BASE_URL}/check-connection`);
+ url.searchParams.set('sessionToken', this.state.sessionToken);
+ url.searchParams.set('publicKey', this.#keypair.publicKeyHex);
+ url.searchParams.set('channelId', CHANNEL_CHECK);
+ url.searchParams.set('skipKyc', 'true');
+ return url.toString();
+ }
+
+ /**
+ * Builds the Auth-frame URL, or `null` when no client token is available.
+ *
+ * @returns The Auth-frame URL or `null`.
+ */
+ buildAuthFrameUrl(): string | null {
+ if (!this.#authClientToken) {
+ return null;
+ }
+ const url = new URL(`${FRAMES_BASE_URL}/auth`);
+ url.searchParams.set('clientToken', this.#authClientToken);
+ url.searchParams.set('publicKey', this.#keypair.publicKeyHex);
+ url.searchParams.set('channelId', CHANNEL_AUTH);
+ return url.toString();
+ }
+
+ /**
+ * Builds the Reset-frame URL.
+ *
+ * @returns The Reset-frame URL.
+ */
+ buildResetFrameUrl(): string {
+ const url = new URL(`${FRAMES_BASE_URL}/reset`);
+ url.searchParams.set('channelId', CHANNEL_RESET);
+ return url.toString();
+ }
+
+ /**
+ * Checks whether KYC is required for a product and caches the result.
+ *
+ * @param params - The parameters.
+ * @param params.product - The consuming feature.
+ * @param params.country - Optional alpha-3 country override.
+ * @returns Whether KYC is required.
+ */
+ async checkKycRequired(params: {
+ product: KycProduct;
+ country?: string;
+ }): Promise {
+ const { accessToken } = this.state;
+ if (!accessToken) {
+ this.#fail('Missing accessToken — repeat the authentication step.');
+ return false;
+ }
+ const country = params.country ?? this.state.geoCountry;
+ if (!country) {
+ this.#fail('Missing country for KYC-required check.');
+ return false;
+ }
+
+ // Capture the flow generation so we can detect a `reset()` that happens
+ // while the HTTP call is in flight and avoid writing stale results.
+ const generation = this.#generation;
+
+ this.#applyUpdate((state) => {
+ state.phase = 'submit';
+ state.statusMessage = 'Checking KYC status...';
+ });
+
+ try {
+ const { kycRequired } = await this.messenger.call(
+ 'KycService:checkKycRequired',
+ { accessToken, country, capabilities: [{ product: params.product }] },
+ );
+ // The flow was reset while the check was in flight; discard the result
+ // rather than resurrecting a done/cached state on an idle controller.
+ const applied = this.#updateIfCurrent(generation, (state) => {
+ state.kycRequiredByProduct[params.product] = kycRequired;
+ state.lastCheckedAt = new Date().toISOString();
+ state.phase = 'done';
+ state.statusMessage = 'KYC check complete.';
+ });
+ if (!applied) {
+ return false;
+ }
+ return kycRequired;
+ } catch (error) {
+ if (this.#generation !== generation) {
+ return false;
+ }
+ this.#fail(`KYC check failed: ${String(error)}`);
+ return false;
+ }
+ }
+
+ /**
+ * Reads the cached "is KYC required" result for a product.
+ *
+ * @param params - The parameters.
+ * @param params.product - The consuming feature.
+ * @returns The cached value, or `undefined` if not yet checked.
+ */
+ getKycStatus(params: { product: KycProduct }): boolean | undefined {
+ return this.state.kycRequiredByProduct[params.product];
+ }
+
+ /**
+ * Runs the SumSub document-verification sub-flow end to end:
+ *
+ * 1. requests a per-session wrapping key from the UKYC backend;
+ * 2. verifies its `jwtChain` against the Fractal JWKS and confirms the
+ * attested session server public key;
+ * 3. derives the `data_encryption_key` from the wallet's UKYC
+ * `local_user_secret` and wraps it for the session server;
+ * 4. mints a client-signed, read-only `ukyc_capability_token` and creates
+ * the UKYC session (handing over the wrapped key and the token);
+ * 5. fetches the SumSub applicant access token; and
+ * 6. presents the SDK via the injected launcher.
+ *
+ * If session creation reports the applicant is already approved on the relay
+ * while the vendor is still finalizing (`kycStatus: approved`,
+ * `finalStatus: pending`), the sub-flow stops at step 4 with a
+ * `vendorProcessing` status and a message rather than launching the SDK.
+ *
+ * @param params - Optional parameters.
+ * @param params.locale - BCP-47 locale for the SDK UI.
+ * @param params.debug - Enables SDK debug logging.
+ * @returns The SDK result.
+ */
+ async startSumSub(params?: {
+ locale?: string;
+ debug?: boolean;
+ }): Promise> {
+ // A new sub-flow supersedes any polling still running from a prior run.
+ this.#stopPolling();
+
+ if (!this.#sumsubLauncher.isAvailable()) {
+ const error = 'SumSub SDK is not available in this runtime.';
+ this.#applyUpdate((state) => {
+ state.sumsub.status = 'failed';
+ state.sumsub.result = { error };
+ });
+ throw new Error(error);
+ }
+
+ // Capture the flow generation so each async step can detect a `reset()`
+ // that lands mid-flight and avoid writing stale sub-flow state (or, worse,
+ // presenting the SDK) on a controller that is now idle.
+ const generation = this.#generation;
+
+ try {
+ this.#applyUpdate((state) => {
+ state.sumsub.status = 'creatingSession';
+ state.sumsub.result = null;
+ state.sumsub.sessionStatus = null;
+ });
+
+ const jwtToken = MOCK_JWT_TOKEN;
+
+ // Establish a per-session X25519 keypair and exchange our public half for
+ // the server's wrapping key. The private half stays on the device and is
+ // used to derive the shared secret that seals the data_encryption_key.
+ const sessionClientPrivateKey = x25519.utils.randomSecretKey();
+ const sessionClientPublicKey = x25519.getPublicKey(
+ sessionClientPrivateKey,
+ );
+ const wrappingKey = await this.messenger.call(
+ 'KycService:getWrappingKey',
+ { sessionClientPublicKey: toBase64Url(sessionClientPublicKey) },
+ );
+
+ // Verify the jwtChain against Fractal's JWKS, then confirm the returned
+ // sessionServerPublicKey matches the value attested inside the verified
+ // JWT payload before trusting it for key wrapping.
+ const { keys } = await this.messenger.call('KycService:fetchJwks');
+ const jwtChainPayload = verifyJwtChain(keys, wrappingKey.jwtChain);
+ if (
+ jwtChainPayload.sessionServerPublicKeyX !==
+ wrappingKey.sessionServerPublicKey.x
+ ) {
+ throw new Error(
+ 'sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).',
+ );
+ }
+
+ // Derive the data_encryption_key from the local_user_secret and wrap it
+ // for the session server. Only the wrapped (encrypted) key ever leaves
+ // the device.
+ const localUserSecret = await getOrCreateLocalUserSecret(
+ this.#localUserSecretStore(),
+ );
+ const clientMaterial = deriveClientMaterial(localUserSecret);
+ const wrappedEncryptionKey = {
+ sessionId: wrappingKey.id,
+ ...wrapEncryptionKey(
+ sessionClientPrivateKey,
+ wrappingKey.sessionServerPublicKey.x,
+ clientMaterial.dataEncryptionKey,
+ ),
+ };
+
+ // Mint a read-only `ukyc_capability_token` for the session. Only the
+ // client holds the signing key derived from `local_user_secret`, so only
+ // the client can mint it; scoping it to `read` means it authorizes later
+ // storage reads without granting write or delete access.
+ const ukycCapabilityToken = signStorageAccessToken({
+ material: clientMaterial,
+ operations: ['read'],
+ expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS),
+ });
+
+ const { sessionId, kycStatus, finalStatus } = await this.messenger.call(
+ 'KycService:createUkycSession',
+ {
+ jwtToken,
+ vendorMetadata: {
+ moonPayAccessToken: this.state.accessToken,
+ moonPayUserId: this.state.moonpayCustomerId,
+ },
+ wrappedEncryptionKey,
+ ukycCapabilityToken,
+ },
+ );
+
+ // A user who already finished the journey can return to a session the
+ // relay has already approved (`kycStatus`) while the vendor is still
+ // finalizing its own decision (`finalStatus`). There is nothing left to
+ // verify, so stop here and surface a message rather than launching the
+ // SDK again.
+ if (
+ kycStatus === KYC_STATUSES.approved &&
+ finalStatus === KYC_STATUSES.pending
+ ) {
+ const stillCurrent = this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'vendorProcessing';
+ state.sumsub.sessionId = sessionId;
+ state.statusMessage = VENDOR_PROCESSING_MESSAGE;
+ });
+ return stillCurrent ? { kycStatus, finalStatus } : {};
+ }
+
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'fetchingToken';
+ state.sumsub.sessionId = sessionId;
+ });
+
+ const { applicantAccessToken } = await this.messenger.call(
+ 'KycService:createJourney',
+ sessionId,
+ );
+
+ // A reset() may have landed while the session/token was being prepared.
+ // Gate the `launching` write and the decision to open the SDK behind a
+ // single generation check: `#updateIfCurrent` only writes when still
+ // current and reports whether it did. Since there is no `await` between
+ // this check and `launch` below, a successful result guarantees the SDK
+ // is never presented on a flow that a concurrent reset() returned to idle.
+ const stillCurrent = this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'launching';
+ state.sumsub.applicantAccessToken = applicantAccessToken;
+ });
+ if (!stillCurrent) {
+ return {};
+ }
+
+ // Track whether the SDK ever reported a successful completion. A resolved
+ // `launch` alone does not imply success — the applicant may have
+ // abandoned the flow or the SDK may have reported a non-success outcome.
+ let reachedCompletion = false;
+
+ const result = await this.#sumsubLauncher.launch({
+ applicantAccessToken,
+ onTokenExpiration: async () => {
+ // A reset() may have superseded this flow while the SDK stayed open.
+ // Refuse to refresh against the now-stale UKYC session rather than
+ // silently keeping an orphaned SDK alive.
+ if (this.#generation !== generation) {
+ throw new Error(
+ 'KYC flow was reset; SumSub session is no longer active.',
+ );
+ }
+ const refreshed = await this.messenger.call(
+ 'KycService:createJourney',
+ sessionId,
+ );
+ return refreshed.applicantAccessToken;
+ },
+ onStatusChange: (_prev, next) => {
+ if (next === SUMSUB_COMPLETED_STATUS) {
+ reachedCompletion = true;
+ }
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status =
+ next === SUMSUB_COMPLETED_STATUS ? 'complete' : 'inProgress';
+ });
+ },
+ locale: params?.locale ?? 'en',
+ debug: params?.debug ?? false,
+ });
+
+ // A resolved `launch` alone is not the final outcome: only a SDK-reported
+ // completion is worth polling for a verification decision. Anything else
+ // (abandonment, non-success) is `failed` and must not be polled.
+ const applied = this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = reachedCompletion ? 'polling' : 'failed';
+ state.sumsub.result = result as Json;
+ });
+
+ // Once the SDK completes, the authoritative verification decision comes
+ // from the UKYC backend, not the SDK result. Poll the session status
+ // until it reaches a terminal decision. Guard on `applied` so a `reset()`
+ // that landed during `launch` cannot start polling on an idle flow.
+ if (applied && reachedCompletion) {
+ if (sessionId) {
+ await this.#startSessionStatusPolling(sessionId);
+ } else {
+ // No session id to poll against; fall back to treating the SDK
+ // completion as the final outcome.
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'complete';
+ });
+ }
+ }
+ return result;
+ } catch (error) {
+ const result = { error: String(error) };
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'failed';
+ state.sumsub.result = result;
+ });
+ return result;
+ }
+ }
+
+ /**
+ * Fetches the current UKYC session status for the active sub-flow and records
+ * it on state. Useful for a one-off refresh outside the automatic polling
+ * loop that {@link startSumSub} runs.
+ *
+ * @returns The fetched session status.
+ * @throws If there is no active SumSub session to query.
+ */
+ async getSessionStatus(): Promise {
+ const { sessionId } = this.state.sumsub;
+ if (!sessionId) {
+ throw new Error('Cannot fetch session status: no active SumSub session.');
+ }
+
+ // Capture the flow generation so a `reset()` landing while the request is
+ // in flight cannot write the result onto an idle controller.
+ const generation = this.#generation;
+ const sessionStatus = await this.messenger.call(
+ 'KycService:getSessionStatus',
+ { sessionId },
+ );
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.sessionStatus = sessionStatus;
+ });
+ return sessionStatus;
+ }
+
+ /**
+ * Begins polling the UKYC session status until a terminal decision is
+ * reached. The first poll runs immediately (and is awaited by
+ * {@link startSumSub}); subsequent polls are scheduled every
+ * `#sessionStatusPollIntervalMs`.
+ *
+ * @param sessionId - The UKYC session id to poll.
+ * @returns A promise that resolves once the first poll settles.
+ */
+ async #startSessionStatusPolling(sessionId: string): Promise {
+ // Supersede any prior loop and claim a fresh token for this one. Because
+ // `#stopPolling` bumps the token, any in-flight poll from a previous loop
+ // sees a mismatch and neither writes state nor reschedules.
+ this.#stopPolling();
+ const token = this.#pollToken;
+
+ const tick = async (): Promise => {
+ const shouldStop = await this.#pollSessionStatusOnce(sessionId, token);
+ if (shouldStop) {
+ return;
+ }
+ this.#pollTimer = setTimeout(() => {
+ this.#pollTimer = null;
+ // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and
+ // therefore never rejects, so this fire-and-forget scheduled poll
+ // cannot surface as an unhandled rejection.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ tick();
+ }, this.#sessionStatusPollIntervalMs);
+ };
+
+ await tick();
+ }
+
+ /**
+ * Performs a single session-status poll: fetches the status, records it, and
+ * resolves the sub-flow when the status is terminal.
+ *
+ * Transient errors are swallowed so the loop keeps polling; the last good
+ * `sessionStatus` is deliberately preserved rather than being overwritten
+ * with the error.
+ *
+ * @param sessionId - The UKYC session id to poll.
+ * @param token - The polling token captured when the loop started.
+ * @returns `true` when the loop should stop (terminal status or superseded
+ * by a reset / new sub-flow), `false` when it should keep polling.
+ */
+ async #pollSessionStatusOnce(
+ sessionId: string,
+ token: number,
+ ): Promise {
+ try {
+ const sessionStatus = await this.messenger.call(
+ 'KycService:getSessionStatus',
+ { sessionId },
+ );
+ // Superseded while the request was in flight — drop the result.
+ if (this.#pollToken !== token) {
+ return true;
+ }
+ const isTerminal = TERMINAL_SESSION_STATUSES.has(
+ sessionStatus.finalStatus,
+ );
+ this.#applyUpdate((state) => {
+ state.sumsub.sessionStatus = sessionStatus;
+ if (isTerminal) {
+ state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has(
+ sessionStatus.finalStatus,
+ )
+ ? 'complete'
+ : 'failed';
+ }
+ });
+ if (isTerminal) {
+ this.#stopPolling();
+ }
+ return isTerminal;
+ } catch {
+ // Keep polling on transient errors, preserving the last good status.
+ // Stop only when a reset / new sub-flow superseded this loop.
+ return this.#pollToken !== token;
+ }
+ }
+
+ /**
+ * Stops the session-status polling loop: bumps the polling token (so any
+ * in-flight `tick` bows out) and clears any scheduled poll.
+ */
+ #stopPolling(): void {
+ this.#pollToken += 1;
+ if (this.#pollTimer !== null) {
+ clearTimeout(this.#pollTimer);
+ this.#pollTimer = null;
+ }
+ }
+
+ /**
+ * Registers a Money Account wallet with MoonPay Iron.
+ *
+ * Consumers provide only the Monad address. The controller reuses the Iron
+ * customer id captured from MoonPay's hosted frame when available, otherwise
+ * it resolves the id from the authenticated MetaMask profile via KycService.
+ * Message construction, signing, submission, and ambiguous-write
+ * reconciliation stay internal to KYC.
+ *
+ * @param params - Money Account wallet registration parameters.
+ * @param params.address - Monad Money Account address.
+ * @returns The successful registration state.
+ */
+ async registerMoneyAccountWallet({
+ address,
+ }: {
+ address: Hex;
+ }): Promise {
+ let machine = transitionWalletRegistration(createInitialState(), {
+ type: 'START',
+ });
+
+ const toExistingResult = (
+ status: RegistrationStatus,
+ ): MoneyAccountWalletRegistrationResult | undefined => {
+ if (status.type === 'active') {
+ return { type: 'alreadyRegistered', registration: status.registration };
+ }
+ if (status.type === 'disabled') {
+ return {
+ type: 'registeredDisabled',
+ registration: status.registration,
+ };
+ }
+ return undefined;
+ };
+
+ const lookup = async (): Promise => {
+ try {
+ return await this.messenger.call(
+ 'KycService:getWalletRegistrationStatus',
+ { address },
+ );
+ } catch (error) {
+ machine = transitionWalletRegistration(machine, {
+ type: 'LOOKUP_FAILED',
+ });
+ throw error;
+ }
+ };
+
+ const applyLookup = (
+ status: RegistrationStatus,
+ ): MoneyAccountWalletRegistrationResult | undefined => {
+ let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' =
+ 'LOOKUP_ABSENT';
+ if (status.type === 'active') {
+ eventType = 'LOOKUP_ACTIVE';
+ } else if (status.type === 'disabled') {
+ eventType = 'LOOKUP_DISABLED';
+ }
+ machine = transitionWalletRegistration(machine, {
+ type: eventType,
+ });
+ return toExistingResult(status);
+ };
+
+ const existingStatus = await lookup();
+ const existingResult = applyLookup(existingStatus);
+ if (existingResult) {
+ return existingResult;
+ }
+
+ const customerId =
+ this.state.moonpayCustomerId ??
+ (await this.messenger.call('KycService:getMoonpayCustomerId'));
+
+ while (true) {
+ const message = buildOwnershipMessage({
+ address,
+ customerId,
+ now: new Date(),
+ });
+ let signature: string;
+ try {
+ signature = await this.messenger.call(
+ 'KeyringController:signPersonalMessage',
+ { data: message, from: address },
+ );
+ machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' });
+ } catch (error) {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SIGN_FAILED',
+ retryable: false,
+ });
+ throw error;
+ }
+
+ try {
+ const result = await this.messenger.call(
+ 'KycService:registerSelfHostedWallet',
+ {
+ address,
+ customerId,
+ message,
+ signature,
+ },
+ );
+ machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' });
+ return result;
+ } catch (error) {
+ if (!(error instanceof WalletRegistrationError)) {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SUBMIT_TERMINAL',
+ });
+ throw error;
+ }
+
+ if (error.kind === 'conflict') {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SUBMIT_CONFLICT',
+ });
+ } else if (error.kind === 'transient') {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SUBMIT_TRANSIENT',
+ });
+ } else if (error.kind === 'validation') {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SUBMIT_VALIDATION',
+ utcRollover:
+ buildOwnershipMessage({
+ address,
+ customerId,
+ now: new Date(),
+ }) !== message,
+ });
+ } else if (error.kind === 'rateLimited') {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SUBMIT_RATE_LIMITED',
+ });
+ } else {
+ machine = transitionWalletRegistration(machine, {
+ type: 'SUBMIT_TERMINAL',
+ });
+ }
+
+ if (
+ machine.status === 'disambiguate409' ||
+ machine.status === 'checkThenRetry'
+ ) {
+ const reconciledResult = applyLookup(await lookup());
+ if (reconciledResult) {
+ return reconciledResult;
+ }
+ }
+
+ if (machine.status !== 'signing') {
+ throw error;
+ }
+ }
+ }
+ }
+
+ /**
+ * Resets the flow to idle, clearing session tokens and sub-flow state while
+ * preserving persisted terms acceptance and the per-product cache.
+ */
+ reset(): void {
+ this.#authClientToken = null;
+ // Stop any session-status polling so a late poll cannot write onto the
+ // now-idle controller.
+ this.#stopPolling();
+ // Invalidate any in-flight async work started before this reset so its
+ // results are discarded rather than written onto the now-idle controller.
+ this.#generation += 1;
+ this.#applyUpdate((state) => {
+ state.phase = 'idle';
+ state.statusMessage = '';
+ state.error = null;
+ state.disclaimers = [];
+ state.disclaimersError = null;
+ state.sessionToken = null;
+ state.accessToken = null;
+ state.moonpayCustomerId = null;
+ state.activeProduct = null;
+ state.sumsub = {
+ status: 'idle',
+ result: null,
+ sessionId: null,
+ applicantAccessToken: null,
+ sessionStatus: null,
+ };
+ });
+ }
+
+ /**
+ * Applies a state update only when the flow has not been reset since
+ * `generation` was captured. Prevents an in-flight async step from writing
+ * stale results onto a controller that a concurrent {@link reset} has
+ * returned to idle.
+ *
+ * @param generation - The flow generation captured before the async work.
+ * @param updater - The state mutation to apply when still current.
+ * @returns `true` if the update was applied, `false` if it was superseded.
+ */
+ #updateIfCurrent(
+ generation: number,
+ updater: (state: KycControllerState) => void,
+ ): boolean {
+ if (this.#generation !== generation) {
+ return false;
+ }
+ this.#applyUpdate(updater);
+ return true;
+ }
+
+ /**
+ * The single state-update path for this controller. All mutations go through
+ * here (rather than calling `this.update` directly) so the mechanism stays
+ * consistent and one subtlety is handled in a single place:
+ *
+ * `sumsub.result` is typed as the recursive `Json`, and expanding
+ * `Draft` (which happens whenever an updater touches `sumsub.result`)
+ * can trip TypeScript's "type instantiation is excessively deep" guard. By
+ * typing the callback parameter as the plain {@link KycControllerState}
+ * instead of Immer's `Draft`, we avoid expanding the draft type while keeping
+ * the same mutate-in-place semantics (the underlying value is still the Immer
+ * draft at runtime).
+ *
+ * @param updater - The state mutation to apply.
+ */
+ #applyUpdate(updater: (state: KycControllerState) => void): void {
+ this.update((state) => {
+ // `@ts-expect-error` cannot be used: ts-bridge does not surface
+ // TS2589, so the directive is unused and fails the build.
+ // type issue only happens at the IDE level.
+ updater(state);
+ });
+ }
+
+ /**
+ * Transitions to the error phase with a message.
+ *
+ * @param message - The error message.
+ */
+ #fail(message: string): void {
+ this.#applyUpdate((state) => {
+ state.error = message;
+ state.phase = 'error';
+ });
+ }
+}
diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts
new file mode 100644
index 00000000000..344ae092e5c
--- /dev/null
+++ b/packages/kyc-controller/src/KycService-method-action-types.ts
@@ -0,0 +1,177 @@
+/**
+ * This file is auto generated.
+ * Do not edit manually.
+ */
+
+import type { KycService } from './KycService.js';
+
+/**
+ * Resolves the customer's country from the geolocation source and converts it
+ * to an ISO 3166-1 alpha-3 code.
+ *
+ * @returns The alpha-3 country code.
+ * @throws If the country cannot be determined or mapped.
+ */
+export type KycServiceGetGeoCountryAction = {
+ type: `KycService:getGeoCountry`;
+ handler: KycService['getGeoCountry'];
+};
+
+/**
+ * Resolves Iron's internal customer id from the authenticated MetaMask
+ * profile.
+ *
+ * @returns Iron's internal customer id.
+ */
+export type KycServiceGetMoonpayCustomerIdAction = {
+ type: `KycService:getMoonpayCustomerId`;
+ handler: KycService['getMoonpayCustomerId'];
+};
+
+/**
+ * Checks whether a Monad Money Account address is already registered.
+ *
+ * @param params - The address to check.
+ * @param params.address - Money Account address.
+ * @returns Active, disabled, or absent registration status.
+ */
+export type KycServiceGetWalletRegistrationStatusAction = {
+ type: `KycService:getWalletRegistrationStatus`;
+ handler: KycService['getWalletRegistrationStatus'];
+};
+
+/**
+ * Submits a signed Monad Money Account ownership proof.
+ *
+ * @param params - Signed ownership proof.
+ * @returns Registered wallet record.
+ */
+export type KycServiceRegisterSelfHostedWalletAction = {
+ type: `KycService:registerSelfHostedWallet`;
+ handler: KycService['registerSelfHostedWallet'];
+};
+
+/**
+ * Fetches the disclaimers the customer must accept before a session is
+ * created.
+ *
+ * @param params - The parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code.
+ * @returns The disclaimers.
+ */
+export type KycServiceFetchDisclaimersAction = {
+ type: `KycService:fetchDisclaimers`;
+ handler: KycService['fetchDisclaimers'];
+};
+
+/**
+ * Creates a vendor session via the UKYC backend.
+ *
+ * @param params - The session parameters.
+ * @returns The created session token.
+ */
+export type KycServiceCreateSessionAction = {
+ type: `KycService:createSession`;
+ handler: KycService['createSession'];
+};
+
+/**
+ * Checks whether KYC is required for the given access token, country, and
+ * capabilities.
+ *
+ * @param params - The check parameters.
+ * @returns Whether KYC is required.
+ */
+export type KycServiceCheckKycRequiredAction = {
+ type: `KycService:checkKycRequired`;
+ handler: KycService['checkKycRequired'];
+};
+
+/**
+ * Requests a per-session wrapping key from the UKYC backend.
+ *
+ * The client sends its ephemeral X25519 public key; the backend responds with
+ * its session public key (`sessionServerPublicKey`) and a `jwtChain` that
+ * attests it. The caller must verify `jwtChain` against the Fractal JWKS
+ * (see {@link KycService.fetchJwks}) before trusting the key to wrap the
+ * `data_encryption_key`.
+ *
+ * @param params - The parameters.
+ * @param params.sessionClientPublicKey - Our ephemeral X25519 public key
+ * (base64url).
+ * @returns The wrapping key id, `jwtChain`, and session server public key.
+ */
+export type KycServiceGetWrappingKeyAction = {
+ type: `KycService:getWrappingKey`;
+ handler: KycService['getWrappingKey'];
+};
+
+/**
+ * Fetches the Fractal encryption service JWKS used to verify the `jwtChain`
+ * returned by {@link KycService.getWrappingKey}.
+ *
+ * This is an unauthenticated request to a well-known path on the Fractal
+ * host, distinct from the UKYC base URL.
+ *
+ * @returns The JWKS keys.
+ */
+export type KycServiceFetchJwksAction = {
+ type: `KycService:fetchJwks`;
+ handler: KycService['fetchJwks'];
+};
+
+/**
+ * Creates a UKYC session for the SumSub document-verification sub-flow,
+ * handing over the wrapped `data_encryption_key` and the client-signed,
+ * read-only `ukyc_capability_token` that authorizes later storage access for
+ * the session.
+ *
+ * @param params - The session parameters.
+ * @returns The UKYC session identifiers.
+ */
+export type KycServiceCreateUkycSessionAction = {
+ type: `KycService:createUkycSession`;
+ handler: KycService['createUkycSession'];
+};
+
+/**
+ * Creates (or refreshes) the SumSub verification journey for a UKYC session,
+ * returning the applicant access token used to launch the SDK.
+ *
+ * @param sessionId - The UKYC session id from `createUkycSession`.
+ * @returns The applicant access token and status.
+ */
+export type KycServiceCreateJourneyAction = {
+ type: `KycService:createJourney`;
+ handler: KycService['createJourney'];
+};
+
+/**
+ * Fetches the current status of a UKYC session. Polled after the SumSub SDK
+ * completes to determine the final verification decision.
+ *
+ * @param params - The parameters.
+ * @param params.sessionId - The UKYC session id.
+ * @returns The session status.
+ */
+export type KycServiceGetSessionStatusAction = {
+ type: `KycService:getSessionStatus`;
+ handler: KycService['getSessionStatus'];
+};
+
+/**
+ * Union of all KycService action types.
+ */
+export type KycServiceMethodActions =
+ | KycServiceGetGeoCountryAction
+ | KycServiceGetMoonpayCustomerIdAction
+ | KycServiceGetWalletRegistrationStatusAction
+ | KycServiceRegisterSelfHostedWalletAction
+ | KycServiceFetchDisclaimersAction
+ | KycServiceCreateSessionAction
+ | KycServiceCheckKycRequiredAction
+ | KycServiceGetWrappingKeyAction
+ | KycServiceFetchJwksAction
+ | KycServiceCreateUkycSessionAction
+ | KycServiceCreateJourneyAction
+ | KycServiceGetSessionStatusAction;
diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts
new file mode 100644
index 00000000000..b537d96fa0a
--- /dev/null
+++ b/packages/kyc-controller/src/KycService.test.ts
@@ -0,0 +1,595 @@
+import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
+import type {
+ MockAnyNamespace,
+ MessengerActions,
+ MessengerEvents,
+} from '@metamask/messenger';
+import nock, { cleanAll } from 'nock';
+
+import type { KycServiceMessenger } from './KycService.js';
+import { KycService } from './KycService.js';
+import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './ukyc/constants.js';
+import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js';
+import {
+ encodeStorageAccessTokenForHeader,
+ signStorageAccessToken,
+} from './ukyc/storageAccessToken.js';
+
+const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io';
+const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io';
+
+describe('KycService', () => {
+ afterEach(() => {
+ cleanAll();
+ });
+
+ describe('getGeoCountry', () => {
+ it('maps the geolocation to an ISO alpha-3 country code', async () => {
+ const { service } = getService({ geolocation: 'US-NY' });
+ expect(await service.getGeoCountry()).toBe('USA');
+ });
+
+ it('throws when the location is unknown', async () => {
+ const { service } = getService({ geolocation: 'UNKNOWN' });
+ await expect(service.getGeoCountry()).rejects.toThrow(
+ /Unable to determine country/u,
+ );
+ });
+
+ it('throws when the country cannot be mapped to alpha-3', async () => {
+ const { service } = getService({ geolocation: 'ZZ' });
+ await expect(service.getGeoCountry()).rejects.toThrow(
+ /Unable to map country code "ZZ"/u,
+ );
+ });
+
+ it('throws when the location resolves to a nullish value', async () => {
+ const { service } = getService({ geolocation: null });
+ await expect(service.getGeoCountry()).rejects.toThrow(
+ /Unable to determine country/u,
+ );
+ });
+
+ it('constructs with the default service policy options', async () => {
+ const { service } = getService({
+ defaultPolicy: true,
+ geolocation: 'US',
+ });
+ expect(await service.getGeoCountry()).toBe('USA');
+ });
+ });
+
+ describe('Money Account wallet registration', () => {
+ it('resolves the Iron customer id', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/moonpay/customer')
+ .matchHeader('authorization', 'Bearer test-bearer')
+ .reply(200, { customerId: 'iron-customer-1' });
+
+ const { service } = getService();
+
+ expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1');
+ });
+
+ it('checks Monad wallet registration status', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/moonpay/self-hosted-wallets')
+ .reply(200, []);
+
+ const { service } = getService();
+
+ expect(
+ await service.getWalletRegistrationStatus({ address: '0xabc' }),
+ ).toStrictEqual({ type: 'absent' });
+ });
+
+ it('submits a signed Monad wallet ownership proof', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/moonpay/self-hosted-wallets', {
+ customer_id: 'iron-customer-1',
+ address: '0xabc',
+ blockchain: 'Monad',
+ message: 'ownership message',
+ signature: '0xsig',
+ })
+ .reply(200, {
+ id: 'wallet-1',
+ address: '0xabc',
+ disabled: false,
+ });
+
+ const { service } = getService();
+
+ expect(
+ await service.registerSelfHostedWallet({
+ customerId: 'iron-customer-1',
+ address: '0xabc',
+ message: 'ownership message',
+ signature: '0xsig',
+ }),
+ ).toMatchObject({
+ type: 'registered',
+ registration: { id: 'wallet-1', blockchain: 'Monad' },
+ });
+ });
+ });
+
+ describe('fetchDisclaimers', () => {
+ it('returns the disclaimers for a country', async () => {
+ const disclaimers = [
+ { id: '1', display_name: 'Terms', url: 'https://t' },
+ ];
+ nock(MOCK_API_URL)
+ .get('/vendors/moonpay/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, disclaimers);
+ const { service } = getService();
+
+ expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual(
+ disclaimers,
+ );
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/moonpay/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, [{ id: 1 }]);
+ const { service } = getService();
+
+ await expect(
+ service.fetchDisclaimers({ 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.fetchDisclaimers({ country: 'USA' }),
+ ).rejects.toThrow(/Unable to obtain an authentication bearer token/u);
+ });
+
+ it('throws an HttpError on a non-ok response', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/moonpay/disclaimers')
+ .query({ country: 'USA' })
+ .reply(500);
+ const { service } = getService();
+
+ await expect(
+ service.fetchDisclaimers({ country: 'USA' }),
+ ).rejects.toThrow(/failed with status '500'/u);
+ });
+ });
+
+ describe('createSession', () => {
+ it('creates a session and returns the token', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/moonpay/sessions')
+ .reply(200, { sessionToken: 'session-1' });
+ const { service } = getService();
+
+ expect(
+ await service.createSession({
+ email: 'a@b.co',
+ termsAcceptedAt: '2026-01-01T00:00:00.000Z',
+ disclaimerIds: ['1'],
+ }),
+ ).toStrictEqual({ sessionToken: 'session-1' });
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/vendors/moonpay/sessions').reply(200, {});
+ const { service } = getService();
+
+ await expect(
+ service.createSession({
+ email: 'a@b.co',
+ termsAcceptedAt: '2026-01-01T00:00:00.000Z',
+ disclaimerIds: ['1'],
+ }),
+ ).rejects.toThrow(/Malformed response received from sessions API/u);
+ });
+ });
+
+ describe('checkKycRequired', () => {
+ it('returns whether KYC is required (default capabilities)', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/moonpay/kyc-required', {
+ accessToken: 'access-1',
+ country: 'USA',
+ capabilities: [{ product: 'ramps' }],
+ })
+ .reply(200, { required: true });
+ const { service } = getService();
+
+ expect(
+ await service.checkKycRequired({
+ accessToken: 'access-1',
+ country: 'USA',
+ }),
+ ).toStrictEqual({ kycRequired: true });
+ });
+
+ it('passes provided capabilities', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/moonpay/kyc-required', {
+ accessToken: 'access-1',
+ country: 'USA',
+ capabilities: [{ product: 'card' }],
+ })
+ .reply(200, { required: false });
+ const { service } = getService();
+
+ expect(
+ await service.checkKycRequired({
+ accessToken: 'access-1',
+ country: 'USA',
+ capabilities: [{ product: 'card' }],
+ }),
+ ).toStrictEqual({ kycRequired: false });
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/vendors/moonpay/kyc-required').reply(200, {});
+ const { service } = getService();
+
+ await expect(
+ service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }),
+ ).rejects.toThrow(/Malformed response received from kyc-required API/u);
+ });
+
+ it('surfaces the specific field mismatch and payload in the error', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/moonpay/kyc-required')
+ .reply(200, { required: 'yes' });
+ const { service } = getService();
+
+ await expect(
+ service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }),
+ ).rejects.toThrow(
+ /Malformed response received from kyc-required API:.*required.*received: \{"required":"yes"\}/su,
+ );
+ });
+ });
+
+ describe('getWrappingKey', () => {
+ it('requests a wrapping key and returns the attested server key', async () => {
+ const response = {
+ id: 'wk',
+ jwtChain: 'jwt.chain.sig',
+ sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' },
+ };
+ nock(MOCK_API_URL)
+ .post('/wrapping-key', { sessionClientPublicKey: 'cpk' })
+ .reply(200, response);
+ const { service } = getService();
+
+ expect(
+ await service.getWrappingKey({ sessionClientPublicKey: 'cpk' }),
+ ).toStrictEqual(response);
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/wrapping-key').reply(200, { id: 'wk' });
+ const { service } = getService();
+
+ await expect(
+ service.getWrappingKey({ sessionClientPublicKey: 'cpk' }),
+ ).rejects.toThrow(/Malformed response received from wrapping-key API/u);
+ });
+ });
+
+ describe('fetchJwks', () => {
+ it('fetches the JWKS from the Fractal well-known path', async () => {
+ const response = {
+ keys: [{ kty: 'OKP', crv: 'Ed25519', x: 'pub', kid: 'k1' }],
+ };
+ nock(MOCK_FRACTAL_URL).get('/.well-known/jwks.json').reply(200, response);
+ const { service } = getService();
+
+ expect(await service.fetchJwks()).toStrictEqual(response);
+ });
+
+ it('throws when no Fractal base URL is configured', async () => {
+ // Omit the option entirely so the constructor falls back to ''.
+ const { service } = getService({ fractalEncryptionBaseUrl: null });
+
+ await expect(service.fetchJwks()).rejects.toThrow(
+ /fractalEncryptionBaseUrl is not configured/u,
+ );
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_FRACTAL_URL)
+ .get('/.well-known/jwks.json')
+ .reply(200, { keys: [{ kty: 'OKP' }] });
+ const { service } = getService();
+
+ await expect(service.fetchJwks()).rejects.toThrow(
+ /Malformed response received from JWKS API/u,
+ );
+ });
+ });
+
+ describe('createUkycSession', () => {
+ const wrappedEncryptionKey = {
+ sessionId: 'wk',
+ encryptedKey: 'enc',
+ nonce: 'nonce',
+ };
+
+ // A genuinely signed, read-only capability token: minted from real derived
+ // client material and signed with the Ed25519 `signingKey`, not a
+ // hand-written plain object.
+ const material = deriveClientMaterial(
+ new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(42),
+ );
+ const ukycCapabilityToken = signStorageAccessToken({
+ material,
+ operations: ['read'],
+ issuedAt: new Date('2026-07-07T00:00:00.000Z'),
+ expiresAt: new Date('2026-07-07T04:00:00.000Z'),
+ });
+ // The service base64url-encodes the envelope into a compact string before
+ // sending it in the request body.
+ const encodedCapabilityToken =
+ encodeStorageAccessTokenForHeader(ukycCapabilityToken);
+
+ it('creates a UKYC session and forwards the wrapped key and capability token', async () => {
+ const response = {
+ sessionId: 'sid',
+ };
+ nock(MOCK_API_URL)
+ .post(
+ '/sessions',
+ (body) =>
+ body.wrappedEncryptionKey !== undefined &&
+ body.ukycCapabilityToken === encodedCapabilityToken,
+ )
+ .reply(200, response);
+ const { service } = getService();
+
+ expect(
+ await service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorMetadata: { foo: 'bar' },
+ wrappedEncryptionKey,
+ ukycCapabilityToken,
+ }),
+ ).toStrictEqual(response);
+ });
+
+ it('returns the relay and vendor statuses when present', async () => {
+ const response = {
+ sessionId: 'sid',
+ kycStatus: 'approved',
+ finalStatus: 'pending',
+ };
+ nock(MOCK_API_URL).post('/sessions').reply(200, response);
+ const { service } = getService();
+
+ expect(
+ await service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorMetadata: { foo: 'bar' },
+ wrappedEncryptionKey,
+ ukycCapabilityToken,
+ }),
+ ).toStrictEqual(response);
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/sessions').reply(200, { unexpected: true });
+ const { service } = getService();
+
+ await expect(
+ service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorMetadata: {},
+ wrappedEncryptionKey,
+ ukycCapabilityToken,
+ }),
+ ).rejects.toThrow(/Malformed response received from UKYC sessions API/u);
+ });
+ });
+
+ describe('createJourney', () => {
+ it('fetches the applicant access token for a session', async () => {
+ const response = { status: 'ok', applicantAccessToken: 'aat' };
+ nock(MOCK_API_URL).post('/sessions/sid/journey').reply(200, response);
+ const { service } = getService();
+
+ expect(await service.createJourney('sid')).toStrictEqual(response);
+ });
+
+ it('does not send a Content-Type header since it has no body', async () => {
+ const response = { status: 'ok', applicantAccessToken: 'aat' };
+ nock(MOCK_API_URL)
+ .post('/sessions/sid/journey')
+ .matchHeader('content-type', (value) => value === undefined)
+ .reply(200, response);
+ const { service } = getService();
+
+ expect(await service.createJourney('sid')).toStrictEqual(response);
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL)
+ .post('/sessions/sid/journey')
+ .reply(200, { status: 'ok' });
+ const { service } = getService();
+
+ await expect(service.createJourney('sid')).rejects.toThrow(
+ /Malformed response received from journey API/u,
+ );
+ });
+ });
+
+ describe('getSessionStatus', () => {
+ it('returns the session status', async () => {
+ const response = {
+ finalStatus: 'approved',
+ statusMessage: 'All good',
+ externalUserId: 'ext-1',
+ kycStatus: 'approved',
+ vendor: 'sumsub',
+ vendorStatus: 'GREEN',
+ };
+ nock(MOCK_API_URL).get('/sessions/sid/status').reply(200, response);
+ const { service } = getService();
+
+ expect(
+ await service.getSessionStatus({ sessionId: 'sid' }),
+ ).toStrictEqual(response);
+ });
+
+ it('url-encodes the session id', async () => {
+ const response = {
+ finalStatus: 'pending',
+ externalUserId: 'ext-1',
+ kycStatus: 'pending',
+ vendor: 'sumsub',
+ vendorStatus: 'YELLOW',
+ };
+ nock(MOCK_API_URL).get('/sessions/a%2Fb/status').reply(200, response);
+ const { service } = getService();
+
+ expect(
+ await service.getSessionStatus({ sessionId: 'a/b' }),
+ ).toStrictEqual(response);
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(200, { finalStatus: 'approved' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/Malformed response received from session status API/u);
+ });
+
+ it('throws an HttpError on a non-ok response', async () => {
+ nock(MOCK_API_URL).get('/sessions/sid/status').reply(404);
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/failed with status '404'/u);
+ });
+ });
+
+ describe('baseUrl', () => {
+ it('uses the provided baseUrl for requests', async () => {
+ const customUrl = 'https://kyc-api.local.test';
+ const disclaimers = [
+ { id: '1', display_name: 'Terms', url: 'https://t' },
+ ];
+ nock(customUrl)
+ .get('/vendors/moonpay/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, disclaimers);
+ const { service } = getService({ baseUrl: customUrl });
+
+ expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual(
+ disclaimers,
+ );
+ });
+
+ it('throws when baseUrl is empty', () => {
+ expect(() => getService({ baseUrl: '' })).toThrow(
+ 'KycService: baseUrl is required',
+ );
+ });
+ });
+
+ describe('messenger actions', () => {
+ it('exposes methods as messenger actions', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/moonpay/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, []);
+ const { rootMessenger } = getService();
+
+ expect(
+ await rootMessenger.call('KycService:fetchDisclaimers', {
+ country: 'USA',
+ }),
+ ).toStrictEqual([]);
+ });
+ });
+});
+
+type RootMessenger = Messenger<
+ MockAnyNamespace,
+ MessengerActions,
+ MessengerEvents
+>;
+
+/**
+ * Constructs the service under test with mocked auth + geo handlers.
+ *
+ * @param args - Options.
+ * @param args.bearerToken - The bearer token the auth handler returns.
+ * @param args.geolocation - The location the geolocation handler returns.
+ * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults.
+ * @param args.baseUrl - Base URL of the KYC API.
+ * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the
+ * option so the service falls back to an empty string.
+ * @returns The service, root messenger, and service messenger.
+ */
+function getService({
+ bearerToken = 'test-bearer',
+ geolocation = 'US-NY',
+ defaultPolicy = false,
+ baseUrl = MOCK_API_URL,
+ // `null` means "omit the option entirely" (exercises the constructor's
+ // `?? ''` fallback); omitting the field defaults to the mock Fractal URL.
+ fractalEncryptionBaseUrl = MOCK_FRACTAL_URL,
+}: {
+ bearerToken?: string;
+ geolocation?: string | null;
+ defaultPolicy?: boolean;
+ baseUrl?: string;
+ fractalEncryptionBaseUrl?: string | null;
+} = {}): {
+ service: KycService;
+ rootMessenger: RootMessenger;
+ messenger: KycServiceMessenger;
+} {
+ const rootMessenger: RootMessenger = new Messenger({
+ namespace: MOCK_ANY_NAMESPACE,
+ });
+ const messenger: KycServiceMessenger = new Messenger({
+ namespace: 'KycService',
+ parent: rootMessenger,
+ });
+ rootMessenger.delegate({
+ actions: [
+ 'AuthenticationController:getBearerToken',
+ 'GeolocationController:getGeolocation',
+ ],
+ events: [],
+ messenger,
+ });
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getBearerToken',
+ async () => bearerToken,
+ );
+ rootMessenger.registerActionHandler(
+ 'GeolocationController:getGeolocation',
+ async () => geolocation as string,
+ );
+
+ const service = new KycService({
+ fetch,
+ messenger,
+ baseUrl,
+ ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }),
+ ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }),
+ });
+
+ return { service, rootMessenger, messenger };
+}
diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts
new file mode 100644
index 00000000000..1674a0d3cbf
--- /dev/null
+++ b/packages/kyc-controller/src/KycService.ts
@@ -0,0 +1,779 @@
+import { BaseDataService } from '@metamask/base-data-service';
+import type {
+ DataServiceCacheUpdatedEvent,
+ DataServiceGranularCacheUpdatedEvent,
+ DataServiceInvalidateQueriesAction,
+} from '@metamask/base-data-service';
+import type { CreateServicePolicyOptions } from '@metamask/controller-utils';
+import { HttpError } from '@metamask/controller-utils';
+import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller';
+import type { Messenger } from '@metamask/messenger';
+import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth';
+import type { Infer, Struct } from '@metamask/superstruct';
+import {
+ array,
+ assert,
+ boolean,
+ optional,
+ string,
+ StructError,
+ type,
+} from '@metamask/superstruct';
+import type { Json } from '@metamask/utils';
+import { Duration, inMilliseconds } from '@metamask/utils';
+import type { QueryClientConfig } from '@tanstack/query-core';
+
+import { alpha2ToAlpha3 } from './countryCodes.js';
+import type { KycServiceMethodActions } from './KycService-method-action-types.js';
+import type { KycDisclaimer, KycSessionStatus } from './types.js';
+import { UKYC_JWKS_PATH } from './ukyc/constants.js';
+import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js';
+import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js';
+import { WalletRegistrationService } from './wallet-registration-service.js';
+import type {
+ RegistrationOutcome,
+ RegistrationStatus,
+} from './wallet-registration-service.js';
+
+// === GENERAL ===
+
+/**
+ * The name of the {@link KycService}, used to namespace the service's actions.
+ */
+export const serviceName = 'KycService';
+
+// === MESSENGER ===
+
+const MESSENGER_EXPOSED_METHODS = [
+ 'getGeoCountry',
+ 'fetchDisclaimers',
+ 'createSession',
+ 'checkKycRequired',
+ 'getWrappingKey',
+ 'fetchJwks',
+ 'createUkycSession',
+ 'createJourney',
+ 'getSessionStatus',
+ 'getMoonpayCustomerId',
+ 'getWalletRegistrationStatus',
+ 'registerSelfHostedWallet',
+] as const;
+
+/**
+ * Invalidates cached queries serviced by {@link KycService}.
+ */
+export type KycServiceInvalidateQueriesAction =
+ DataServiceInvalidateQueriesAction;
+
+/**
+ * Actions that {@link KycService} exposes to other consumers.
+ */
+export type KycServiceActions =
+ | KycServiceMethodActions
+ | KycServiceInvalidateQueriesAction;
+
+/**
+ * Actions from other messengers that {@link KycService} calls.
+ */
+type AllowedActions =
+ | AuthenticationControllerGetBearerTokenAction
+ | GeolocationControllerGetGeolocationAction;
+
+/**
+ * Published when {@link KycService}'s cache is updated.
+ */
+export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent<
+ typeof serviceName
+>;
+
+/**
+ * Published when a single key within {@link KycService}'s cache is updated.
+ */
+export type KycServiceGranularCacheUpdatedEvent =
+ DataServiceGranularCacheUpdatedEvent;
+
+/**
+ * Events that {@link KycService} exposes to other consumers.
+ */
+export type KycServiceEvents =
+ | KycServiceCacheUpdatedEvent
+ | KycServiceGranularCacheUpdatedEvent;
+
+/**
+ * Events from other messengers that {@link KycService} subscribes to.
+ */
+type AllowedEvents = never;
+
+/**
+ * The messenger restricted to actions and events accessed by
+ * {@link KycService}.
+ */
+export type KycServiceMessenger = Messenger<
+ typeof serviceName,
+ KycServiceActions | AllowedActions,
+ KycServiceEvents | AllowedEvents
+>;
+
+/**
+ * Options for constructing a {@link KycService}.
+ */
+export type KycServiceOptions = {
+ messenger: KycServiceMessenger;
+ fetch: typeof fetch;
+ /**
+ * Mandatory value that sets the base url to KYC api
+ */
+ baseUrl: string;
+ /**
+ * Base URL of the Fractal encryption service, from which the JWKS used to
+ * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is
+ * fetched. Required to run the wrapping-key exchange in
+ * {@link KycService.fetchJwks}.
+ */
+ fractalEncryptionBaseUrl?: string;
+ /**
+ * Shared configuration applied to all queries exposed by the service (e.g. a
+ * default `staleTime`/`cacheTime`). Each data service gets its own
+ * `QueryClient`.
+ */
+ queryClientConfig?: QueryClientConfig;
+ policyOptions?: CreateServicePolicyOptions;
+};
+
+// === API RESPONSE SCHEMAS ===
+
+const DisclaimerStruct = type({
+ id: string(),
+ display_name: string(),
+ url: string(),
+});
+const DisclaimersResponseStruct = array(DisclaimerStruct);
+
+const CreateSessionResponseStruct = type({ sessionToken: string() });
+
+// The live KYC API returns the flag under `required`; the service normalizes
+// this to `kycRequired` for consumers (see `checkKycRequired`).
+const KycRequiredResponseStruct = type({ required: boolean() });
+
+// The session server's X25519 public key, in JWK-like form, returned by
+// `/wrapping-key`. `x` is the base64url public key used to wrap the user key.
+const SessionServerPublicKeyStruct = type({
+ kty: string(),
+ crv: string(),
+ x: string(),
+});
+
+const WrappingKeyResponseStruct = type({
+ id: string(),
+ jwtChain: string(),
+ sessionServerPublicKey: SessionServerPublicKeyStruct,
+});
+export type WrappingKeyResponse = Infer;
+
+// A single Ed25519 (OKP) JWK. `type` (not `object`) keeps optional/extra JWK
+// fields (`use`, `alg`) from failing validation.
+const JwkStruct = type({
+ kty: string(),
+ crv: string(),
+ x: string(),
+ kid: string(),
+});
+const JwksResponseStruct = type({ keys: array(JwkStruct) });
+export type JwksResponse = Infer;
+
+const UkycSessionResponseStruct = type({
+ sessionId: string(),
+ // The relay-side KYC decision (e.g. `approved`) and the vendor-side final
+ // status (e.g. `pending`) at session-creation time. Present when the applicant
+ // already has a session in flight; absent for a brand-new session.
+ kycStatus: optional(string()),
+ finalStatus: optional(string()),
+});
+export type UkycSessionResponse = Infer;
+
+const ApplicantAccessTokenResponseStruct = type({
+ status: string(),
+ applicantAccessToken: string(),
+});
+export type ApplicantAccessTokenResponse = Infer<
+ typeof ApplicantAccessTokenResponseStruct
+>;
+
+const SessionStatusResponseStruct = type({
+ finalStatus: string(),
+ statusMessage: optional(string()),
+ externalUserId: string(),
+ kycStatus: string(),
+ vendor: string(),
+ vendorStatus: string(),
+});
+
+// === PARAM TYPES ===
+
+export type CreateSessionParams = {
+ email: string;
+ termsAcceptedAt: string;
+ disclaimerIds: string[];
+};
+
+export type CheckKycRequiredParams = {
+ accessToken: string;
+ country: string;
+ capabilities?: { product: string }[];
+};
+
+export type GetWrappingKeyParams = {
+ sessionClientPublicKey: string;
+};
+
+/**
+ * The wrapped `data_encryption_key` sent to the UKYC backend when creating a
+ * session. `encryptedKey` and `nonce` are produced by `wrapEncryptionKey`;
+ * `sessionId` is the wrapping key id returned by `getWrappingKey`.
+ */
+export type WrappedEncryptionKey = {
+ sessionId: string;
+ encryptedKey: string;
+ nonce: string;
+};
+
+export type CreateUkycSessionParams = {
+ jwtToken: string;
+ vendorMetadata: Record;
+ wrappedEncryptionKey: WrappedEncryptionKey;
+ /**
+ * The client-signed `ukyc_capability_token` (envelope: payload + Ed25519
+ * signature) authorizing later storage access for this session. It is minted
+ * by the client with `read`-only scope — see the UKYC storage-and-auth spec
+ * for how it is formed. Only the client holds the signing key, so only the
+ * client can mint it. The envelope is base64url-encoded into a compact string
+ * before it is sent to the backend.
+ */
+ ukycCapabilityToken: UkycStorageAccessToken;
+};
+
+export type GetSessionStatusParams = {
+ sessionId: string;
+};
+
+export type RegisterSelfHostedWalletParams = {
+ customerId: string;
+ address: string;
+ message: string;
+ signature: string;
+};
+
+// === SERVICE DEFINITION ===
+
+/**
+ * `KycService` communicates with the Universal KYC (UKYC) backend to drive the
+ * identity + document-verification flow. It is stateless and platform-agnostic:
+ * HTTP is performed through an injected `fetch`, and the auth bearer token and
+ * geolocation come from other controllers via the messenger.
+ *
+ * It extends {@link BaseDataService}, so every request is routed through
+ * `fetchQuery`: it is wrapped in the shared service policy (retries, circuit
+ * breaker) and its result is exposed via the service's `QueryClient`. Read-only
+ * endpoints (`fetchDisclaimers`, `fetchJwks`) are cached with a `staleTime`;
+ * the session-creating and status-polling endpoints opt out of caching
+ * (`staleTime`/`cacheTime` of `0`) so they never serve a stale result.
+ */
+export class KycService extends BaseDataService<
+ typeof serviceName,
+ KycServiceMessenger
+> {
+ readonly #fetch: typeof fetch;
+
+ readonly #baseUrl: string;
+
+ readonly #fractalEncryptionBaseUrl: string;
+
+ readonly #walletRegistrationService: WalletRegistrationService;
+
+ /**
+ * Constructs a new KycService.
+ *
+ * @param options - The constructor options.
+ * @param options.messenger - The messenger suited for this service.
+ * @param options.fetch - A function used to make HTTP requests.
+ * @param options.baseUrl - Base URL of the KYC API
+ * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal
+ * encryption service, from which the JWKS used to verify the wrapping-key
+ * `jwtChain` is fetched.
+ * @param options.queryClientConfig - Shared configuration for all queries
+ * exposed by the service.
+ * @param options.policyOptions - Options for the request service policy.
+ */
+ constructor({
+ messenger,
+ fetch: fetchFunction,
+ baseUrl,
+ fractalEncryptionBaseUrl,
+ queryClientConfig = {},
+ policyOptions = {},
+ }: KycServiceOptions) {
+ super({
+ name: serviceName,
+ messenger,
+ queryClientConfig,
+ policyOptions,
+ });
+ this.#fetch = fetchFunction;
+ if (!baseUrl) {
+ throw new Error('KycService: baseUrl is required');
+ }
+ this.#baseUrl = baseUrl;
+ this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? '';
+ this.#walletRegistrationService = new WalletRegistrationService({
+ fetch: fetchFunction,
+ baseUrl,
+ getAuthToken: async (): Promise => this.#getBearerToken(),
+ });
+ this.messenger.registerMethodActionHandlers(
+ this,
+ MESSENGER_EXPOSED_METHODS,
+ );
+ }
+
+ /**
+ * Resolves the customer's country from the geolocation source and converts it
+ * to an ISO 3166-1 alpha-3 code.
+ *
+ * @returns The alpha-3 country code.
+ * @throws If the country cannot be determined or mapped.
+ */
+ async getGeoCountry(): Promise {
+ const location = await this.messenger.call(
+ 'GeolocationController:getGeolocation',
+ );
+ // Guard nullish/empty geolocation with the documented domain error rather
+ // than letting `assert(location, string())` surface a superstruct
+ // assertion error (which would change how the failure reads in
+ // `disclaimersError`).
+ const alpha2 =
+ typeof location === 'string' ? location.split('-')[0].toUpperCase() : '';
+ if (!alpha2 || alpha2 === 'UNKNOWN') {
+ throw new Error(
+ `Unable to determine country from geolocation (got "${String(
+ location,
+ )}").`,
+ );
+ }
+ const alpha3 = alpha2ToAlpha3(alpha2);
+ if (!alpha3) {
+ throw new Error(
+ `Unable to map country code "${alpha2}" to an ISO 3166-1 alpha-3 code.`,
+ );
+ }
+ return alpha3;
+ }
+
+ /**
+ * Resolves Iron's internal customer id from the authenticated MetaMask
+ * profile.
+ *
+ * @returns Iron's internal customer id.
+ */
+ async getMoonpayCustomerId(): Promise {
+ return await this.#walletRegistrationService.getMoonpayCustomerId();
+ }
+
+ /**
+ * Checks whether a Monad Money Account address is already registered.
+ *
+ * @param params - The address to check.
+ * @param params.address - Money Account address.
+ * @returns Active, disabled, or absent registration status.
+ */
+ async getWalletRegistrationStatus({
+ address,
+ }: {
+ address: string;
+ }): Promise {
+ return await this.#walletRegistrationService.getRegistrationStatus({
+ address,
+ blockchain: 'Monad',
+ });
+ }
+
+ /**
+ * Submits a signed Monad Money Account ownership proof.
+ *
+ * @param params - Signed ownership proof.
+ * @returns Registered wallet record.
+ */
+ async registerSelfHostedWallet(
+ params: RegisterSelfHostedWalletParams,
+ ): Promise {
+ return await this.#walletRegistrationService.registerSelfHostedWallet({
+ ...params,
+ blockchain: 'Monad',
+ });
+ }
+
+ /**
+ * Fetches the disclaimers the customer must accept before a session is
+ * created.
+ *
+ * @param params - The parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code.
+ * @returns The disclaimers.
+ */
+ async fetchDisclaimers({
+ country,
+ }: {
+ country: string;
+ }): Promise {
+ const url = new URL('/vendors/moonpay/disclaimers', this.#baseUrl);
+ url.searchParams.set('country', country);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchDisclaimers`, country],
+ queryFn: async () => this.#requestJson(url, { method: 'GET' }),
+ staleTime: inMilliseconds(5, Duration.Minute),
+ });
+ return this.#validateResponse(
+ data,
+ DisclaimersResponseStruct,
+ 'disclaimers',
+ ) as KycDisclaimer[];
+ }
+
+ /**
+ * Creates a vendor session via the UKYC backend.
+ *
+ * @param params - The session parameters.
+ * @returns The created session token.
+ */
+ async createSession(
+ params: CreateSessionParams,
+ ): Promise> {
+ const url = new URL('/vendors/moonpay/sessions', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [
+ `${this.name}:createSession`,
+ params.email,
+ params.termsAcceptedAt,
+ params.disclaimerIds,
+ ],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify(params),
+ }),
+ // A session-creating mutation must never serve a stale/cached result.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ CreateSessionResponseStruct,
+ 'sessions',
+ );
+ }
+
+ /**
+ * Checks whether KYC is required for the given access token, country, and
+ * capabilities.
+ *
+ * @param params - The check parameters.
+ * @returns Whether KYC is required.
+ */
+ async checkKycRequired(
+ params: CheckKycRequiredParams,
+ ): Promise<{ kycRequired: boolean }> {
+ const url = new URL('/vendors/moonpay/kyc-required', this.#baseUrl);
+ const capabilities = params.capabilities ?? [{ product: 'ramps' }];
+ const data = await this.fetchQuery({
+ queryKey: [
+ `${this.name}:checkKycRequired`,
+ params.accessToken,
+ params.country,
+ capabilities,
+ ],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({
+ accessToken: params.accessToken,
+ country: params.country,
+ capabilities,
+ }),
+ }),
+ // The requirement can change server-side, so always re-check.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ const { required } = this.#validateResponse(
+ data,
+ KycRequiredResponseStruct,
+ 'kyc-required',
+ );
+ return { kycRequired: required };
+ }
+
+ /**
+ * Requests a per-session wrapping key from the UKYC backend.
+ *
+ * The client sends its ephemeral X25519 public key; the backend responds with
+ * its session public key (`sessionServerPublicKey`) and a `jwtChain` that
+ * attests it. The caller must verify `jwtChain` against the Fractal JWKS
+ * (see {@link KycService.fetchJwks}) before trusting the key to wrap the
+ * `data_encryption_key`.
+ *
+ * @param params - The parameters.
+ * @param params.sessionClientPublicKey - Our ephemeral X25519 public key
+ * (base64url).
+ * @returns The wrapping key id, `jwtChain`, and session server public key.
+ */
+ async getWrappingKey(
+ params: GetWrappingKeyParams,
+ ): Promise {
+ const url = new URL('/wrapping-key', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:getWrappingKey`, params.sessionClientPublicKey],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({
+ sessionClientPublicKey: params.sessionClientPublicKey,
+ }),
+ }),
+ // A per-session key exchange must always run fresh.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ WrappingKeyResponseStruct,
+ 'wrapping-key',
+ );
+ }
+
+ /**
+ * Fetches the Fractal encryption service JWKS used to verify the `jwtChain`
+ * returned by {@link KycService.getWrappingKey}.
+ *
+ * This is an unauthenticated request to a well-known path on the Fractal
+ * host, distinct from the UKYC base URL.
+ *
+ * @returns The JWKS keys.
+ */
+ async fetchJwks(): Promise {
+ if (!this.#fractalEncryptionBaseUrl) {
+ throw new Error(
+ 'KycService: fractalEncryptionBaseUrl is not configured; cannot fetch JWKS to verify the wrapping key.',
+ );
+ }
+ const url = new URL(UKYC_JWKS_PATH, this.#fractalEncryptionBaseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchJwks`, this.#fractalEncryptionBaseUrl],
+ queryFn: async () =>
+ this.#requestJson(url, { method: 'GET' }, { authenticated: false }),
+ staleTime: inMilliseconds(1, Duration.Hour),
+ });
+ return this.#validateResponse(data, JwksResponseStruct, 'JWKS');
+ }
+
+ /**
+ * Creates a UKYC session for the SumSub document-verification sub-flow,
+ * handing over the wrapped `data_encryption_key` and the client-signed,
+ * read-only `ukyc_capability_token` that authorizes later storage access for
+ * the session.
+ *
+ * @param params - The session parameters.
+ * @returns The UKYC session identifiers.
+ */
+ async createUkycSession(
+ params: CreateUkycSessionParams,
+ ): Promise {
+ const url = new URL('/sessions', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [
+ `${this.name}:createUkycSession`,
+ params.wrappedEncryptionKey.sessionId,
+ ],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({
+ vendorId: 'moonpay',
+ vendorUserId: 'mockedId',
+ jwtToken: params.jwtToken,
+ vendorMetadata: params.vendorMetadata,
+ wrappedEncryptionKey: params.wrappedEncryptionKey,
+ ukycCapabilityToken: encodeStorageAccessTokenForHeader(
+ params.ukycCapabilityToken,
+ ),
+ }),
+ }),
+ // A session-creating mutation must never serve a stale/cached result.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ UkycSessionResponseStruct,
+ 'UKYC sessions',
+ );
+ }
+
+ /**
+ * Creates (or refreshes) the SumSub verification journey for a UKYC session,
+ * returning the applicant access token used to launch the SDK.
+ *
+ * @param sessionId - The UKYC session id from `createUkycSession`.
+ * @returns The applicant access token and status.
+ */
+ async createJourney(
+ sessionId: string,
+ ): Promise {
+ const url = new URL(
+ `/sessions/${encodeURIComponent(sessionId)}/journey`,
+ this.#baseUrl,
+ );
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:createJourney`, sessionId],
+ queryFn: async () => this.#requestJson(url, { method: 'POST' }),
+ // Journeys are (re)created on demand; do not reuse a cached token.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ ApplicantAccessTokenResponseStruct,
+ 'journey',
+ );
+ }
+
+ /**
+ * Fetches the current status of a UKYC session. Polled after the SumSub SDK
+ * completes to determine the final verification decision.
+ *
+ * @param params - The parameters.
+ * @param params.sessionId - The UKYC session id.
+ * @returns The session status.
+ */
+ async getSessionStatus(
+ params: GetSessionStatusParams,
+ ): Promise {
+ const url = new URL(
+ `/sessions/${encodeURIComponent(params.sessionId)}/status`,
+ this.#baseUrl,
+ );
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:getSessionStatus`, params.sessionId],
+ queryFn: async () => this.#requestJson(url, { method: 'GET' }),
+ // Status is polled for a terminal decision, so it must always be fresh.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ SessionStatusResponseStruct,
+ 'session status',
+ );
+ }
+
+ /**
+ * Validates a parsed API response against a superstruct schema, throwing a
+ * descriptive error when the response does not match.
+ *
+ * Unlike a bare `Struct.is` check, this surfaces exactly which field was
+ * missing or had the wrong type, which is essential for diagnosing shape
+ * mismatches between the client and the live API.
+ *
+ * @param data - The parsed response body.
+ * @param struct - The superstruct schema the body is expected to satisfy.
+ * @param apiName - A human-readable name of the API, used in the error message.
+ * @returns The validated, typed response.
+ * @throws If `data` does not match `struct`.
+ */
+ #validateResponse(
+ data: unknown,
+ struct: Struct,
+ apiName: string,
+ ): Type {
+ try {
+ assert(data, struct);
+ return data;
+ } catch (error) {
+ const detail =
+ error instanceof StructError
+ ? `${error.message} (received: ${JSON.stringify(data)})`
+ : // `assert` only ever throws `StructError` for the plain structs used
+ // here, so this is a defensive fallback that is not exercised.
+ /* istanbul ignore next */
+ String(error);
+ throw new Error(
+ `Malformed response received from ${apiName} API: ${detail}`,
+ );
+ }
+ }
+
+ /**
+ * Gets the authenticated wallet bearer token.
+ *
+ * @returns The bearer token.
+ */
+ async #getBearerToken(): Promise {
+ const bearerToken = await this.messenger.call(
+ 'AuthenticationController:getBearerToken',
+ );
+ assert(bearerToken, string());
+ if (!bearerToken) {
+ throw new Error(
+ 'Unable to obtain an authentication bearer token - is the wallet signed in?',
+ );
+ }
+ return bearerToken;
+ }
+
+ /**
+ * Performs a single JSON request.
+ *
+ * This is meant to be used as the `queryFn` for {@link fetchQuery}, which
+ * wraps it in the shared service policy (retries, circuit breaker). Requests
+ * are authenticated with the wallet bearer token by default; pass
+ * `{ authenticated: false }` for calls to services that do not expect it
+ * (e.g. the Fractal JWKS endpoint).
+ *
+ * @param url - The request URL.
+ * @param init - The request init (method, body).
+ * @param options - Request options.
+ * @param options.authenticated - Whether to attach the bearer token. Defaults
+ * to `true`.
+ * @returns The parsed JSON response.
+ */
+ async #requestJson(
+ url: URL,
+ init: RequestInit,
+ options: { authenticated?: boolean } = {},
+ ): Promise {
+ const { authenticated = true } = options;
+
+ const headers: Record = {};
+
+ // Only advertise a JSON body when one is actually sent; bodyless requests
+ // (e.g. `createJourney`) must not carry a `Content-Type`.
+ if (init.body !== undefined && init.body !== null) {
+ headers['Content-Type'] = 'application/json';
+ }
+
+ if (authenticated) {
+ headers.Authorization = `Bearer ${await this.#getBearerToken()}`;
+ }
+
+ const response = await this.#fetch(url.toString(), {
+ ...init,
+ headers,
+ });
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ `Fetching '${url.toString()}' failed with status '${response.status}'`,
+ );
+ }
+
+ return (await response.json()) as Json;
+ }
+}
diff --git a/packages/kyc-controller/src/countryCodes.test.ts b/packages/kyc-controller/src/countryCodes.test.ts
new file mode 100644
index 00000000000..9188868966d
--- /dev/null
+++ b/packages/kyc-controller/src/countryCodes.test.ts
@@ -0,0 +1,19 @@
+import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes.js';
+
+describe('countryCodes', () => {
+ it('exposes the alpha-2 to alpha-3 map', () => {
+ expect(ALPHA2_TO_ALPHA3.US).toBe('USA');
+ });
+
+ it('maps a known uppercase alpha-2 code', () => {
+ expect(alpha2ToAlpha3('GB')).toBe('GBR');
+ });
+
+ it('is case-insensitive', () => {
+ expect(alpha2ToAlpha3('fr')).toBe('FRA');
+ });
+
+ it('returns undefined for an unknown code', () => {
+ expect(alpha2ToAlpha3('ZZ')).toBeUndefined();
+ });
+});
diff --git a/packages/kyc-controller/src/countryCodes.ts b/packages/kyc-controller/src/countryCodes.ts
new file mode 100644
index 00000000000..a5712d24109
--- /dev/null
+++ b/packages/kyc-controller/src/countryCodes.ts
@@ -0,0 +1,270 @@
+/**
+ * ISO 3166-1 alpha-2 to alpha-3 country code mapping.
+ *
+ * The geolocation source returns ISO 3166-2 codes whose leading segment is an
+ * alpha-2 country code (e.g. "US", "US-NY"). The identity vendor APIs
+ * (disclaimers, kyc-required) expect alpha-3 codes (e.g. "USA"). This map
+ * bridges the two.
+ */
+export const ALPHA2_TO_ALPHA3: Record = {
+ AD: 'AND',
+ AE: 'ARE',
+ AF: 'AFG',
+ AG: 'ATG',
+ AI: 'AIA',
+ AL: 'ALB',
+ AM: 'ARM',
+ AO: 'AGO',
+ AQ: 'ATA',
+ AR: 'ARG',
+ AS: 'ASM',
+ AT: 'AUT',
+ AU: 'AUS',
+ AW: 'ABW',
+ AX: 'ALA',
+ AZ: 'AZE',
+ BA: 'BIH',
+ BB: 'BRB',
+ BD: 'BGD',
+ BE: 'BEL',
+ BF: 'BFA',
+ BG: 'BGR',
+ BH: 'BHR',
+ BI: 'BDI',
+ BJ: 'BEN',
+ BL: 'BLM',
+ BM: 'BMU',
+ BN: 'BRN',
+ BO: 'BOL',
+ BQ: 'BES',
+ BR: 'BRA',
+ BS: 'BHS',
+ BT: 'BTN',
+ BV: 'BVT',
+ BW: 'BWA',
+ BY: 'BLR',
+ BZ: 'BLZ',
+ CA: 'CAN',
+ CC: 'CCK',
+ CD: 'COD',
+ CF: 'CAF',
+ CG: 'COG',
+ CH: 'CHE',
+ CI: 'CIV',
+ CK: 'COK',
+ CL: 'CHL',
+ CM: 'CMR',
+ CN: 'CHN',
+ CO: 'COL',
+ CR: 'CRI',
+ CU: 'CUB',
+ CV: 'CPV',
+ CW: 'CUW',
+ CX: 'CXR',
+ CY: 'CYP',
+ CZ: 'CZE',
+ DE: 'DEU',
+ DJ: 'DJI',
+ DK: 'DNK',
+ DM: 'DMA',
+ DO: 'DOM',
+ DZ: 'DZA',
+ EC: 'ECU',
+ EE: 'EST',
+ EG: 'EGY',
+ EH: 'ESH',
+ ER: 'ERI',
+ ES: 'ESP',
+ ET: 'ETH',
+ FI: 'FIN',
+ FJ: 'FJI',
+ FK: 'FLK',
+ FM: 'FSM',
+ FO: 'FRO',
+ FR: 'FRA',
+ GA: 'GAB',
+ GB: 'GBR',
+ GD: 'GRD',
+ GE: 'GEO',
+ GF: 'GUF',
+ GG: 'GGY',
+ GH: 'GHA',
+ GI: 'GIB',
+ GL: 'GRL',
+ GM: 'GMB',
+ GN: 'GIN',
+ GP: 'GLP',
+ GQ: 'GNQ',
+ GR: 'GRC',
+ GS: 'SGS',
+ GT: 'GTM',
+ GU: 'GUM',
+ GW: 'GNB',
+ GY: 'GUY',
+ HK: 'HKG',
+ HM: 'HMD',
+ HN: 'HND',
+ HR: 'HRV',
+ HT: 'HTI',
+ HU: 'HUN',
+ ID: 'IDN',
+ IE: 'IRL',
+ IL: 'ISR',
+ IM: 'IMN',
+ IN: 'IND',
+ IO: 'IOT',
+ IQ: 'IRQ',
+ IR: 'IRN',
+ IS: 'ISL',
+ IT: 'ITA',
+ JE: 'JEY',
+ JM: 'JAM',
+ JO: 'JOR',
+ JP: 'JPN',
+ KE: 'KEN',
+ KG: 'KGZ',
+ KH: 'KHM',
+ KI: 'KIR',
+ KM: 'COM',
+ KN: 'KNA',
+ KP: 'PRK',
+ KR: 'KOR',
+ KW: 'KWT',
+ KY: 'CYM',
+ KZ: 'KAZ',
+ LA: 'LAO',
+ LB: 'LBN',
+ LC: 'LCA',
+ LI: 'LIE',
+ LK: 'LKA',
+ LR: 'LBR',
+ LS: 'LSO',
+ LT: 'LTU',
+ LU: 'LUX',
+ LV: 'LVA',
+ LY: 'LBY',
+ MA: 'MAR',
+ MC: 'MCO',
+ MD: 'MDA',
+ ME: 'MNE',
+ MF: 'MAF',
+ MG: 'MDG',
+ MH: 'MHL',
+ MK: 'MKD',
+ ML: 'MLI',
+ MM: 'MMR',
+ MN: 'MNG',
+ MO: 'MAC',
+ MP: 'MNP',
+ MQ: 'MTQ',
+ MR: 'MRT',
+ MS: 'MSR',
+ MT: 'MLT',
+ MU: 'MUS',
+ MV: 'MDV',
+ MW: 'MWI',
+ MX: 'MEX',
+ MY: 'MYS',
+ MZ: 'MOZ',
+ NA: 'NAM',
+ NC: 'NCL',
+ NE: 'NER',
+ NF: 'NFK',
+ NG: 'NGA',
+ NI: 'NIC',
+ NL: 'NLD',
+ NO: 'NOR',
+ NP: 'NPL',
+ NR: 'NRU',
+ NU: 'NIU',
+ NZ: 'NZL',
+ OM: 'OMN',
+ PA: 'PAN',
+ PE: 'PER',
+ PF: 'PYF',
+ PG: 'PNG',
+ PH: 'PHL',
+ PK: 'PAK',
+ PL: 'POL',
+ PM: 'SPM',
+ PN: 'PCN',
+ PR: 'PRI',
+ PS: 'PSE',
+ PT: 'PRT',
+ PW: 'PLW',
+ PY: 'PRY',
+ QA: 'QAT',
+ RE: 'REU',
+ RO: 'ROU',
+ RS: 'SRB',
+ RU: 'RUS',
+ RW: 'RWA',
+ SA: 'SAU',
+ SB: 'SLB',
+ SC: 'SYC',
+ SD: 'SDN',
+ SE: 'SWE',
+ SG: 'SGP',
+ SH: 'SHN',
+ SI: 'SVN',
+ SJ: 'SJM',
+ SK: 'SVK',
+ SL: 'SLE',
+ SM: 'SMR',
+ SN: 'SEN',
+ SO: 'SOM',
+ SR: 'SUR',
+ SS: 'SSD',
+ ST: 'STP',
+ SV: 'SLV',
+ SX: 'SXM',
+ SY: 'SYR',
+ SZ: 'SWZ',
+ TC: 'TCA',
+ TD: 'TCD',
+ TF: 'ATF',
+ TG: 'TGO',
+ TH: 'THA',
+ TJ: 'TJK',
+ TK: 'TKL',
+ TL: 'TLS',
+ TM: 'TKM',
+ TN: 'TUN',
+ TO: 'TON',
+ TR: 'TUR',
+ TT: 'TTO',
+ TV: 'TUV',
+ TW: 'TWN',
+ TZ: 'TZA',
+ UA: 'UKR',
+ UG: 'UGA',
+ UM: 'UMI',
+ US: 'USA',
+ UY: 'URY',
+ UZ: 'UZB',
+ VA: 'VAT',
+ VC: 'VCT',
+ VE: 'VEN',
+ VG: 'VGB',
+ VI: 'VIR',
+ VN: 'VNM',
+ VU: 'VUT',
+ WF: 'WLF',
+ WS: 'WSM',
+ YE: 'YEM',
+ YT: 'MYT',
+ ZA: 'ZAF',
+ ZM: 'ZMB',
+ ZW: 'ZWE',
+};
+
+/**
+ * Converts an ISO 3166-1 alpha-2 country code (e.g. "US") to its alpha-3
+ * equivalent (e.g. "USA"). Returns `undefined` for unknown codes.
+ *
+ * @param alpha2 - The ISO 3166-1 alpha-2 country code.
+ * @returns The alpha-3 code, or `undefined` if the input is not recognized.
+ */
+export function alpha2ToAlpha3(alpha2: string): string | undefined {
+ return ALPHA2_TO_ALPHA3[alpha2.toUpperCase()];
+}
diff --git a/packages/kyc-controller/src/crypto.test.ts b/packages/kyc-controller/src/crypto.test.ts
new file mode 100644
index 00000000000..45bfc7b190b
--- /dev/null
+++ b/packages/kyc-controller/src/crypto.test.ts
@@ -0,0 +1,204 @@
+import { gcm } from '@noble/ciphers/aes';
+import { x25519 } from '@noble/curves/ed25519';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
+import { base64 } from '@scure/base';
+
+import type { EncryptedCredentialsEnvelope } from './crypto.js';
+import { decryptCredentials, generateKeyPair } from './crypto.js';
+
+/**
+ * Builds an encrypted-credentials envelope that `decryptCredentials` can
+ * reverse with `ourPublicKey`'s matching private key.
+ *
+ * @param ourPublicKey - The recipient's X25519 public key.
+ * @param credentials - The plaintext credentials to encrypt.
+ * @param options - Encoding options.
+ * @param options.encoding - `'hex'` (default) or `'base64'`.
+ * @param options.ivLength - IV length in bytes (default 12).
+ * @param options.useNonceField - Emit `nonce` instead of `iv`.
+ * @returns The encrypted envelope.
+ */
+function makeEnvelope(
+ ourPublicKey: Uint8Array,
+ credentials: Record,
+ {
+ encoding = 'hex' as 'hex' | 'base64',
+ ivLength = 12,
+ useNonceField = false,
+ } = {},
+): EncryptedCredentialsEnvelope {
+ const ephemeralPrivate = x25519.utils.randomSecretKey();
+ const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate);
+ const shared = x25519.getSharedSecret(ephemeralPrivate, ourPublicKey);
+ const key = hkdf(sha256, shared, undefined, undefined, 32);
+ const iv = new Uint8Array(ivLength).fill(7);
+ const ciphertext = gcm(key, iv).encrypt(
+ utf8ToBytes(JSON.stringify(credentials)),
+ );
+ const encode = (bytes: Uint8Array): string =>
+ encoding === 'hex' ? bytesToHex(bytes) : base64.encode(bytes);
+ const envelope: EncryptedCredentialsEnvelope = {
+ ephemeralPublicKey: encode(ephemeralPublic),
+ ciphertext: encode(ciphertext),
+ };
+ if (useNonceField) {
+ envelope.nonce = encode(iv);
+ } else {
+ envelope.iv = encode(iv);
+ }
+ return envelope;
+}
+
+describe('crypto', () => {
+ describe('generateKeyPair', () => {
+ it('produces a 32-byte keypair with a hex public key', () => {
+ const keypair = generateKeyPair();
+ expect(keypair.privateKey).toHaveLength(32);
+ expect(keypair.publicKey).toHaveLength(32);
+ expect(keypair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/u);
+ });
+ });
+
+ describe('decryptCredentials', () => {
+ it('decrypts a hex-encoded envelope object', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(keypair.publicKey, {
+ accessToken: 'access-1',
+ });
+
+ const { credentials, method } = decryptCredentials(
+ envelope,
+ keypair.privateKey,
+ );
+
+ expect(credentials.accessToken).toBe('access-1');
+ expect(method).toBe('aes-256-gcm/hkdf-sha256');
+ });
+
+ it('decrypts a base64-encoded envelope', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(
+ keypair.publicKey,
+ { clientToken: 'client-1' },
+ { encoding: 'base64' },
+ );
+
+ const { credentials } = decryptCredentials(envelope, keypair.privateKey);
+
+ expect(credentials.clientToken).toBe('client-1');
+ });
+
+ it('honors an explicit base64 encoding hint', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(
+ keypair.publicKey,
+ { accessToken: 'access-2' },
+ { encoding: 'base64' },
+ );
+ envelope.encoding = 'base64';
+
+ const { credentials } = decryptCredentials(envelope, keypair.privateKey);
+
+ expect(credentials.accessToken).toBe('access-2');
+ });
+
+ it('accepts a `nonce` field as an alias for `iv`', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(
+ keypair.publicKey,
+ { accessToken: 'access-3' },
+ { useNonceField: true },
+ );
+
+ const { credentials } = decryptCredentials(envelope, keypair.privateKey);
+
+ expect(credentials.accessToken).toBe('access-3');
+ });
+
+ it('decrypts an envelope delivered as a JSON string', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(keypair.publicKey, {
+ accessToken: 'access-4',
+ });
+
+ const { credentials } = decryptCredentials(
+ JSON.stringify(envelope),
+ keypair.privateKey,
+ );
+
+ expect(credentials.accessToken).toBe('access-4');
+ });
+
+ it('decrypts an envelope delivered as base64(JSON)', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(keypair.publicKey, {
+ accessToken: 'access-5',
+ });
+ const base64Json = base64.encode(utf8ToBytes(JSON.stringify(envelope)));
+
+ const { credentials } = decryptCredentials(
+ base64Json,
+ keypair.privateKey,
+ );
+
+ expect(credentials.accessToken).toBe('access-5');
+ });
+
+ it('throws for a JSON string that fails to parse', () => {
+ const keypair = generateKeyPair();
+ expect(() =>
+ decryptCredentials('{ not valid json', keypair.privateKey),
+ ).toThrow(/looked like JSON but failed to parse/u);
+ });
+
+ it('throws for base64 that decodes to non-JSON starting with a brace', () => {
+ const keypair = generateKeyPair();
+ const bad = base64.encode(utf8ToBytes('{ still not json'));
+ expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow(
+ /base64-decoded to non-JSON/u,
+ );
+ });
+
+ it('throws for an opaque string that is neither JSON nor base64(JSON)', () => {
+ const keypair = generateKeyPair();
+ const bad = base64.encode(utf8ToBytes('hello world'));
+ expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow(
+ /opaque string/u,
+ );
+ });
+
+ it('throws for an object missing required fields', () => {
+ const keypair = generateKeyPair();
+ expect(() =>
+ decryptCredentials(
+ { ephemeralPublicKey: 'aa' } as EncryptedCredentialsEnvelope,
+ keypair.privateKey,
+ ),
+ ).toThrow(/missing required fields/u);
+ });
+
+ it('reports the value type for a non-object input', () => {
+ const keypair = generateKeyPair();
+ expect(() =>
+ decryptCredentials(
+ 123 as unknown as EncryptedCredentialsEnvelope,
+ keypair.privateKey,
+ ),
+ ).toThrow(/Got: number/u);
+ });
+
+ it('throws when the IV length is not 12 bytes', () => {
+ const keypair = generateKeyPair();
+ const envelope = makeEnvelope(
+ keypair.publicKey,
+ { accessToken: 'x' },
+ { ivLength: 16 },
+ );
+ expect(() => decryptCredentials(envelope, keypair.privateKey)).toThrow(
+ /Unexpected IV length 16/u,
+ );
+ });
+ });
+});
diff --git a/packages/kyc-controller/src/crypto.ts b/packages/kyc-controller/src/crypto.ts
new file mode 100644
index 00000000000..50012f016d3
--- /dev/null
+++ b/packages/kyc-controller/src/crypto.ts
@@ -0,0 +1,238 @@
+/**
+ * Check / Auth frame key exchange and credential decryption.
+ *
+ * The identity vendor's Check and Auth frames return encrypted credentials.
+ * The confirmed protocol is X25519 ECDH + AES-256-GCM (an "ECDH-ES" pattern
+ * signalled by a 12-byte IV):
+ *
+ * 1. Client generates an X25519 keypair, sends `publicKey` (hex) into the
+ * frame as a URL param.
+ * 2. Frame generates its own ephemeral X25519 keypair and encrypts the
+ * credentials, returning `{ ephemeralPublicKey, iv, ciphertext }`.
+ * 3. Client reverses:
+ * shared = X25519(ourPrivate, theirEphemeralPublic)
+ * key = HKDF-SHA256(shared, salt=none, info=none, 32 bytes)
+ * plain = AES-256-GCM.decrypt(key, iv, ciphertext || 16-byte tag)
+ *
+ * This module is platform-agnostic: it uses `@noble/*` and `@metamask/utils`
+ * (via the shared encoding helpers) and avoids `Buffer` / `atob` so it runs
+ * unchanged on mobile, extension, and web.
+ */
+
+import { gcm } from '@noble/ciphers/aes';
+import { x25519 } from '@noble/curves/ed25519';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
+
+import { base64UrlToBytes } from './encoding.js';
+
+/**
+ * An X25519 keypair used for the Check/Auth frame key exchange.
+ */
+export type X25519KeyPair = {
+ /** Raw 32-byte X25519 private (scalar) key. Never leaves the device. */
+ privateKey: Uint8Array;
+ /** Raw 32-byte X25519 public key. */
+ publicKey: Uint8Array;
+ /** Hex-encoded public key, ready to drop into a Check/Auth frame URL. */
+ publicKeyHex: string;
+};
+
+/**
+ * The encrypted-credentials envelope returned by the Check/Auth frames. Binary
+ * fields may be hex or base64; the IV field may be named `iv` or `nonce`.
+ */
+export type EncryptedCredentialsEnvelope = {
+ /** Ephemeral public key produced by the frame for this exchange (32 bytes). */
+ ephemeralPublicKey: string;
+ /** Per-message IV. May be provided as `iv` or `nonce`. */
+ iv?: string;
+ nonce?: string;
+ /** Ciphertext (plaintext + 16-byte GCM auth tag). */
+ ciphertext: string;
+ /** Optional explicit encoding hint. Defaults to auto-detect. */
+ encoding?: 'hex' | 'base64';
+};
+
+/**
+ * Decrypted Check/Auth frame credentials.
+ *
+ * - `accessToken` is the Bearer token for the identity API.
+ * - `clientToken` is the short-lived token consumed by the Auth frame when the
+ * Check frame returns `connectionRequired`.
+ */
+export type DecryptedCredentials = {
+ accessToken?: string;
+ clientToken?: string;
+ [key: string]: unknown;
+};
+
+/**
+ * Result of a successful decryption — the credentials plus the `method` that
+ * authenticated.
+ */
+export type DecryptResult = {
+ credentials: DecryptedCredentials;
+ method: string;
+};
+
+/**
+ * Generate a fresh X25519 keypair. The private key never leaves the device;
+ * only `publicKeyHex` is sent to the vendor via the frame URL.
+ *
+ * @returns The generated keypair.
+ */
+export function generateKeyPair(): X25519KeyPair {
+ const privateKey = x25519.utils.randomSecretKey();
+ const publicKey = x25519.getPublicKey(privateKey);
+ return {
+ privateKey,
+ publicKey,
+ publicKeyHex: bytesToHex(publicKey),
+ };
+}
+
+/**
+ * Decode a binary envelope field that may be hex or base64.
+ *
+ * @param value - The encoded field.
+ * @param encoding - Optional explicit encoding; auto-detected when omitted.
+ * @returns The decoded bytes.
+ */
+function decodeBinary(value: string, encoding?: 'hex' | 'base64'): Uint8Array {
+ const isHex =
+ encoding === 'hex' ||
+ (encoding === undefined && /^[0-9a-fA-F]+$/u.test(value));
+ if (isHex) {
+ return hexToBytes(value);
+ }
+ return base64UrlToBytes(value);
+}
+
+/**
+ * Coerce the `credentials` field into a structured envelope. The frame may
+ * deliver it as an object, a JSON string, or base64(JSON).
+ *
+ * @param input - The raw credentials value.
+ * @returns The normalized envelope.
+ * @throws If the value is not a structured or base64(JSON) envelope, or is
+ * missing required fields.
+ */
+function normalizeEnvelope(
+ input: EncryptedCredentialsEnvelope | string,
+): EncryptedCredentialsEnvelope {
+ let value: unknown = input;
+
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (trimmed.startsWith('{')) {
+ try {
+ value = JSON.parse(trimmed);
+ } catch {
+ throw new Error(
+ `credentials looked like JSON but failed to parse (preview: "${trimmed.slice(
+ 0,
+ 64,
+ )}").`,
+ );
+ }
+ } else {
+ let decodedText: string | null = null;
+ try {
+ decodedText = new TextDecoder().decode(base64UrlToBytes(trimmed));
+ } catch {
+ decodedText = null;
+ }
+ const decodedTrimmed = decodedText?.trim();
+ if (decodedTrimmed?.startsWith('{')) {
+ try {
+ value = JSON.parse(decodedTrimmed);
+ } catch {
+ throw new Error(
+ `credentials base64-decoded to non-JSON (preview: "${decodedTrimmed.slice(
+ 0,
+ 64,
+ )}").`,
+ );
+ }
+ } else {
+ throw new Error(
+ `credentials is an opaque string, not a structured or base64(JSON) envelope (preview: "${trimmed.slice(
+ 0,
+ 64,
+ )}").`,
+ );
+ }
+ }
+ }
+
+ const env = value as Partial;
+ if (!env.ephemeralPublicKey || !(env.iv ?? env.nonce) || !env.ciphertext) {
+ const keys =
+ value && typeof value === 'object'
+ ? Object.keys(value).join(', ')
+ : typeof value;
+ throw new Error(
+ `credentials envelope missing required fields (ephemeralPublicKey/iv/ciphertext). Got: ${keys}`,
+ );
+ }
+ return env as EncryptedCredentialsEnvelope;
+}
+
+/**
+ * X25519 ECDH to AES-256-GCM decryption.
+ *
+ * @param theirPublicKey - The frame's ephemeral public key.
+ * @param iv - The 12-byte GCM IV.
+ * @param ciphertext - The ciphertext including the 16-byte auth tag.
+ * @param ourPrivateKey - Our X25519 private key.
+ * @returns The decrypted credentials and method.
+ */
+function aesGcmDecrypt(
+ theirPublicKey: Uint8Array,
+ iv: Uint8Array,
+ ciphertext: Uint8Array,
+ ourPrivateKey: Uint8Array,
+): DecryptResult {
+ const shared = x25519.getSharedSecret(ourPrivateKey, theirPublicKey);
+ const key = hkdf(sha256, shared, undefined, undefined, 32);
+ const plaintext = gcm(key, iv).decrypt(ciphertext);
+ const text = new TextDecoder().decode(plaintext);
+ return {
+ credentials: JSON.parse(text) as DecryptedCredentials,
+ method: 'aes-256-gcm/hkdf-sha256',
+ };
+}
+
+/**
+ * Decrypt a Check/Auth frame credentials envelope using our X25519 private
+ * key.
+ *
+ * @param rawEnvelope - The raw envelope (object, JSON string, or base64(JSON)).
+ * @param ourPrivateKey - Our X25519 private key.
+ * @returns The parsed credentials and the method that authenticated.
+ * @throws If the envelope is malformed or the IV length is not 12 bytes.
+ */
+export function decryptCredentials(
+ rawEnvelope: EncryptedCredentialsEnvelope | string,
+ ourPrivateKey: Uint8Array,
+): DecryptResult {
+ const envelope = normalizeEnvelope(rawEnvelope);
+ const theirPublicKey = decodeBinary(
+ envelope.ephemeralPublicKey,
+ envelope.encoding,
+ );
+ // `normalizeEnvelope` guarantees one of `iv` / `nonce` is present.
+ const ivField = (envelope.iv ?? envelope.nonce) as string;
+ const iv = decodeBinary(ivField, envelope.encoding);
+ const ciphertext = decodeBinary(envelope.ciphertext, envelope.encoding);
+
+ if (iv.length !== 12) {
+ throw new Error(
+ `Unexpected IV length ${iv.length} (expected 12 for AES-256-GCM).`,
+ );
+ }
+
+ return aesGcmDecrypt(theirPublicKey, iv, ciphertext, ourPrivateKey);
+}
diff --git a/packages/kyc-controller/src/encoding.test.ts b/packages/kyc-controller/src/encoding.test.ts
new file mode 100644
index 00000000000..bd21da29df3
--- /dev/null
+++ b/packages/kyc-controller/src/encoding.test.ts
@@ -0,0 +1,32 @@
+import { areUint8ArraysEqual } from '@metamask/utils';
+
+import { base64UrlToBytes, toBase64Url } from './encoding.js';
+
+describe('encoding', () => {
+ describe('toBase64Url', () => {
+ it('produces unpadded, url-safe base64', () => {
+ // 0xFB 0xFF encodes to "+/8=" in standard base64, exercising both the
+ // `+`->`-`, `/`->`_`, and padding-stripping substitutions.
+ const encoded = toBase64Url(new Uint8Array([0xfb, 0xff]));
+
+ expect(encoded).toBe('-_8');
+ expect(encoded).not.toContain('=');
+ });
+ });
+
+ describe('base64UrlToBytes', () => {
+ it('round-trips arbitrary bytes through toBase64Url', () => {
+ const bytes = new Uint8Array([0x00, 0x01, 0xfb, 0xff, 0x10, 0x2a, 0x7f]);
+
+ const roundTripped = base64UrlToBytes(toBase64Url(bytes));
+
+ expect(areUint8ArraysEqual(roundTripped, bytes)).toBe(true);
+ });
+
+ it('decodes an already-padded standard base64url string', () => {
+ const bytes = new Uint8Array([0xfb, 0xff]);
+
+ expect(areUint8ArraysEqual(base64UrlToBytes('-_8='), bytes)).toBe(true);
+ });
+ });
+});
diff --git a/packages/kyc-controller/src/encoding.ts b/packages/kyc-controller/src/encoding.ts
new file mode 100644
index 00000000000..f9fb9f265a2
--- /dev/null
+++ b/packages/kyc-controller/src/encoding.ts
@@ -0,0 +1,39 @@
+import { base64ToBytes, bytesToBase64 } from '@metamask/utils';
+
+/**
+ * Shared base64url encoding helpers used by frame crypto and UKYC modules.
+ *
+ * These are platform-agnostic: they rely on `@metamask/utils` rather than
+ * `Buffer` / `atob`, so they run unchanged on mobile, extension, and web.
+ */
+
+/**
+ * Encodes bytes as unpadded base64url (RFC 4648 §5). This is the wire shape
+ * used for `storage_id`, `signing_public_key`, and Ed25519 signatures in the
+ * UKYC storage API.
+ *
+ * @param bytes - The bytes to encode.
+ * @returns The base64url string without `=` padding.
+ */
+export function toBase64Url(bytes: Uint8Array): string {
+ return bytesToBase64(bytes)
+ .replace(/\+/gu, '-')
+ .replace(/\//gu, '_')
+ .replace(/[=]+$/u, '');
+}
+
+/**
+ * Decodes an unpadded (or padded) base64url string back to bytes. Inverse of
+ * {@link toBase64Url}.
+ *
+ * @param value - The base64url string.
+ * @returns The decoded bytes.
+ */
+export function base64UrlToBytes(value: string): Uint8Array {
+ return base64ToBytes(
+ value
+ .replace(/-/gu, '+')
+ .replace(/_/gu, '/')
+ .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='),
+ );
+}
diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts
index baa030ac10f..3bbd3b4c258 100644
--- a/packages/kyc-controller/src/index.test.ts
+++ b/packages/kyc-controller/src/index.test.ts
@@ -1,9 +1,20 @@
-import greeter from './index.js';
+import * as packageExports from './index.js';
-describe('Test', () => {
- it('greets', () => {
- const name = 'Huey';
- const result = greeter(name);
- expect(result).toBe('Hello, Huey!');
+describe('@metamask/kyc-controller', () => {
+ it('exports the controller, service, selectors, and helpers', () => {
+ expect(packageExports).toMatchObject({
+ KycController: expect.any(Function),
+ KycService: expect.any(Function),
+ getDefaultKycControllerState: expect.any(Function),
+ selectKycPhase: expect.any(Function),
+ selectKycSumSub: expect.any(Function),
+ selectIsKycRequiredForProduct: expect.any(Function),
+ alpha2ToAlpha3: expect.any(Function),
+ generateKeyPair: expect.any(Function),
+ decryptCredentials: expect.any(Function),
+ WalletRegistrationError: expect.any(Function),
+ controllerName: 'KycController',
+ serviceName: 'KycService',
+ });
});
});
diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts
index 6972c117292..f0deafb95b0 100644
--- a/packages/kyc-controller/src/index.ts
+++ b/packages/kyc-controller/src/index.ts
@@ -1,9 +1,133 @@
-/**
- * Example function that returns a greeting for the given name.
- *
- * @param name - The name to greet.
- * @returns The greeting.
- */
-export default function greeter(name: string): string {
- return `Hello, ${name}!`;
-}
+export {
+ KycController,
+ getDefaultKycControllerState,
+ controllerName,
+} from './KycController.js';
+export type {
+ KycControllerActions,
+ KycControllerEvents,
+ KycControllerGetStateAction,
+ KycControllerMessenger,
+ MoneyAccountWalletRegistrationResult,
+ KycControllerOptions,
+ KycControllerState,
+ KycControllerStateChangeEvent,
+} from './KycController.js';
+export type {
+ KycControllerAcceptTermsAndStartSessionAction,
+ KycControllerBuildAuthFrameUrlAction,
+ KycControllerBuildCheckFrameUrlAction,
+ KycControllerBuildResetFrameUrlAction,
+ KycControllerCheckKycRequiredAction,
+ KycControllerClearSavedTermsAction,
+ KycControllerGetKycStatusAction,
+ KycControllerGetSessionStatusAction,
+ KycControllerHandleFrameMessageAction,
+ KycControllerInitializeAction,
+ KycControllerLoadDisclaimersAction,
+ KycControllerResetAction,
+ KycControllerRegisterMoneyAccountWalletAction,
+ KycControllerStartSumSubAction,
+} from './KycController-method-action-types.js';
+
+export { KycService, serviceName } from './KycService.js';
+export type {
+ ApplicantAccessTokenResponse,
+ CheckKycRequiredParams,
+ CreateSessionParams,
+ CreateUkycSessionParams,
+ GetSessionStatusParams,
+ GetWrappingKeyParams,
+ JwksResponse,
+ KycServiceActions,
+ KycServiceCacheUpdatedEvent,
+ KycServiceEvents,
+ KycServiceGranularCacheUpdatedEvent,
+ KycServiceInvalidateQueriesAction,
+ KycServiceMessenger,
+ KycServiceOptions,
+ RegisterSelfHostedWalletParams,
+ UkycSessionResponse,
+ WrappedEncryptionKey,
+ WrappingKeyResponse,
+} from './KycService.js';
+export type {
+ KycServiceCheckKycRequiredAction,
+ KycServiceCreateJourneyAction,
+ KycServiceCreateSessionAction,
+ KycServiceCreateUkycSessionAction,
+ KycServiceFetchDisclaimersAction,
+ KycServiceFetchJwksAction,
+ KycServiceGetGeoCountryAction,
+ KycServiceGetSessionStatusAction,
+ KycServiceGetWrappingKeyAction,
+ KycServiceGetMoonpayCustomerIdAction,
+ KycServiceGetWalletRegistrationStatusAction,
+ KycServiceRegisterSelfHostedWalletAction,
+} from './KycService-method-action-types.js';
+
+export {
+ selectIsKycRequiredForProduct,
+ selectKycPhase,
+ selectKycSumSub,
+} from './selectors.js';
+
+export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes.js';
+export { decryptCredentials, generateKeyPair } from './crypto.js';
+export type {
+ DecryptedCredentials,
+ DecryptResult,
+ EncryptedCredentialsEnvelope,
+ X25519KeyPair,
+} from './crypto.js';
+
+export type {
+ KycDisclaimer,
+ KycPhase,
+ KycProduct,
+ KycSessionStatus,
+ KycSumSubLaunchParams,
+ KycSumSubLauncher,
+ KycSumSubStatus,
+ KycVendor,
+} from './types.js';
+
+// UKYC storage-access-token utilities. Exported so a signed capability token can
+// be minted for testing UKYC Storage (see `mintUkycTestToken`).
+export {
+ UKYC_CAPABILITY_AUTH_SCHEME,
+ UKYC_KWIL_AUDIENCE,
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE,
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES,
+ UKYC_STORAGE_ACCESS_TOKEN_VERSION,
+} from './ukyc/constants.js';
+export {
+ deriveClientMaterial,
+ encodeClientMaterial,
+} from './ukyc/deriveClientMaterial.js';
+export type {
+ EncodedUkycClientMaterial,
+ UkycClientMaterial,
+} from './ukyc/deriveClientMaterial.js';
+export {
+ encodeStorageAccessTokenForHeader,
+ signStorageAccessToken,
+} from './ukyc/storageAccessToken.js';
+export type {
+ SignStorageAccessTokenParams,
+ UkycStorageAccessToken,
+ UkycStorageAccessTokenPayload,
+ UkycStorageOperation,
+ UkycTokenPresenter,
+} from './ukyc/storageAccessToken.js';
+export { mintUkycTestToken } from './ukyc/testToken.js';
+export type {
+ MintedUkycTestToken,
+ MintUkycTestTokenParams,
+} from './ukyc/testToken.js';
+
+export type {
+ SelfHostedRegistration,
+ WalletRegistrationErrorKind,
+} from './wallet-registration-service.js';
+export { WalletRegistrationError } from './wallet-registration-service.js';
diff --git a/packages/kyc-controller/src/ownership-message.test.ts b/packages/kyc-controller/src/ownership-message.test.ts
new file mode 100644
index 00000000000..071144a4642
--- /dev/null
+++ b/packages/kyc-controller/src/ownership-message.test.ts
@@ -0,0 +1,66 @@
+import { buildOwnershipMessage } from './ownership-message.js';
+
+describe('buildOwnershipMessage', () => {
+ it('builds the exact MoonPay ownership sentence', () => {
+ const result = buildOwnershipMessage({
+ address: '0xAbCdEf1234567890',
+ customerId: 'customer-123',
+ now: new Date('2026-08-12T15:30:00.000Z'),
+ });
+
+ expect(result).toBe(
+ 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.',
+ );
+ });
+
+ it('formats the date in UTC across a local date boundary', () => {
+ const result = buildOwnershipMessage({
+ address: '0x1234',
+ customerId: 'customer-123',
+ now: new Date('2027-01-01T00:30:00.000Z'),
+ });
+
+ expect(result).toContain('signed on 01/01/2027');
+ });
+
+ it('preserves the exact supplied address casing', () => {
+ const result = buildOwnershipMessage({
+ address: '0xAbCdEf',
+ customerId: 'customer-123',
+ now: new Date('2026-08-12T15:30:00.000Z'),
+ });
+
+ expect(result).toContain('wallet address 0xAbCdEf as customer');
+ });
+
+ it('does not add surrounding whitespace or a trailing newline', () => {
+ const result = buildOwnershipMessage({
+ address: '0x1234',
+ customerId: 'customer-123',
+ now: new Date('2026-08-12T15:30:00.000Z'),
+ });
+
+ expect(result).toBe(result.trim());
+ expect(result.endsWith('\n')).toBe(false);
+ });
+
+ it('builds a fresh message after UTC midnight', () => {
+ const request = {
+ address: '0x1234',
+ customerId: 'customer-123',
+ };
+
+ const beforeMidnight = buildOwnershipMessage({
+ ...request,
+ now: new Date('2026-08-12T23:59:59.999Z'),
+ });
+ const afterMidnight = buildOwnershipMessage({
+ ...request,
+ now: new Date('2026-08-13T00:00:00.000Z'),
+ });
+
+ expect(beforeMidnight).toContain('signed on 12/08/2026');
+ expect(afterMidnight).toContain('signed on 13/08/2026');
+ expect(afterMidnight).not.toBe(beforeMidnight);
+ });
+});
diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/kyc-controller/src/ownership-message.ts
new file mode 100644
index 00000000000..539d5e3aac3
--- /dev/null
+++ b/packages/kyc-controller/src/ownership-message.ts
@@ -0,0 +1,32 @@
+export type BuildOwnershipMessageRequest = {
+ address: string;
+ customerId: string;
+ now: Date;
+};
+
+/**
+ * Builds the proof-of-ownership message required to register a self-hosted
+ * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`).
+ *
+ * The returned string is the exact sentence that must be both signed (EIP-191
+ * `personal_sign`) and sent, byte-for-byte, in the registration request body.
+ * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced
+ * just before UTC midnight is not reused with a stale date after rollover.
+ *
+ * @param request - Values embedded in the ownership message.
+ * @param request.address - Wallet address, kept verbatim (no re-casing).
+ * @param request.customerId - Iron customer id; must match the request body.
+ * @param request.now - Reference time used to derive the UTC calendar date.
+ * @returns The exact message to sign and submit.
+ */
+export function buildOwnershipMessage({
+ address,
+ customerId,
+ now,
+}: BuildOwnershipMessageRequest): string {
+ const day = String(now.getUTCDate()).padStart(2, '0');
+ const month = String(now.getUTCMonth() + 1).padStart(2, '0');
+ const year = now.getUTCFullYear();
+
+ return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`;
+}
diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts
new file mode 100644
index 00000000000..5eee934acd0
--- /dev/null
+++ b/packages/kyc-controller/src/selectors.test.ts
@@ -0,0 +1,33 @@
+import { getDefaultKycControllerState } from './KycController.js';
+import {
+ selectIsKycRequiredForProduct,
+ selectKycPhase,
+ selectKycSumSub,
+} from './selectors.js';
+
+describe('selectors', () => {
+ it('selectKycPhase returns the current phase', () => {
+ const state = { ...getDefaultKycControllerState(), phase: 'form' as const };
+ expect(selectKycPhase(state)).toBe('form');
+ });
+
+ it('selectKycSumSub returns the sub-flow state', () => {
+ const state = getDefaultKycControllerState();
+ expect(selectKycSumSub(state)).toStrictEqual(state.sumsub);
+ });
+
+ describe('selectIsKycRequiredForProduct', () => {
+ it('returns the cached requirement for a product', () => {
+ const state = {
+ ...getDefaultKycControllerState(),
+ kycRequiredByProduct: { ramps: true },
+ };
+ expect(selectIsKycRequiredForProduct('ramps')(state)).toBe(true);
+ });
+
+ it('returns undefined when the product has not been checked', () => {
+ const state = getDefaultKycControllerState();
+ expect(selectIsKycRequiredForProduct('card')(state)).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts
new file mode 100644
index 00000000000..6247e01796f
--- /dev/null
+++ b/packages/kyc-controller/src/selectors.ts
@@ -0,0 +1,42 @@
+import { createSelector } from 'reselect';
+
+import type { KycControllerState } from './KycController.js';
+import type { KycProduct } from './types.js';
+
+const selectKycRequiredByProduct = (
+ state: KycControllerState,
+): KycControllerState['kycRequiredByProduct'] => state.kycRequiredByProduct;
+
+/**
+ * Selects the current flow phase.
+ *
+ * @param state - The KycController state.
+ * @returns The current phase.
+ */
+export const selectKycPhase = (
+ state: KycControllerState,
+): KycControllerState['phase'] => state.phase;
+
+/**
+ * Selects the SumSub sub-flow state.
+ *
+ * @param state - The KycController state.
+ * @returns The SumSub state.
+ */
+export const selectKycSumSub = (
+ state: KycControllerState,
+): KycControllerState['sumsub'] => state.sumsub;
+
+/**
+ * Creates a selector that returns whether KYC is required for a product.
+ *
+ * @param product - The consuming feature.
+ * @returns A selector returning the cached requirement, or `undefined`.
+ */
+export const selectIsKycRequiredForProduct = (
+ product: KycProduct,
+): ((state: KycControllerState) => boolean | undefined) =>
+ createSelector(
+ [selectKycRequiredByProduct],
+ (map): boolean | undefined => map[product],
+ );
diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts
new file mode 100644
index 00000000000..1323aea0c1b
--- /dev/null
+++ b/packages/kyc-controller/src/types.ts
@@ -0,0 +1,156 @@
+/**
+ * Shared types for the KYC controller and service.
+ *
+ * The KYC flow is vendor-backed (currently MoonPay for identity + SumSub for
+ * document verification) but the surface exposed to consumers (ramps, card) is
+ * 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 and card can share one controller.
+ */
+export type KycProduct = 'ramps' | 'card';
+
+/**
+ * Identity vendors supported behind the KYC surface.
+ */
+export type KycVendor = 'moonpay';
+
+/**
+ * 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.
+ * - `check` — running the invisible connection-check frame.
+ * - `auth` — running the visible authentication (OTP) frame.
+ * - `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`.
+ * - `submit` — submitting the KYC-required check.
+ * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. 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';
+
+/**
+ * Progress of the SumSub document-verification sub-flow.
+ *
+ * - `polling` — the SDK finished and the controller is polling the UKYC
+ * backend for the session's final decision (see `KycSessionStatus`). The
+ * sub-flow resolves to `complete` or `failed` once a terminal status arrives.
+ * - `vendorProcessing` — session creation reported that the applicant is
+ * already approved on the relay (`kycStatus`) while the vendor is still
+ * finalizing its own decision (`finalStatus`). There is nothing left for the
+ * applicant to do, so the SDK is not launched; see `statusMessage`.
+ */
+export type KycSumSubStatus =
+ | 'idle'
+ | 'creatingSession'
+ | 'fetchingToken'
+ | 'launching'
+ | 'inProgress'
+ | 'polling'
+ | 'complete'
+ | 'failed'
+ | 'vendorProcessing';
+
+/**
+ * The status of a UKYC session, returned by the `GET /sessions/{id}/status`
+ * endpoint and polled after the SumSub SDK completes to determine the final
+ * verification decision.
+ */
+export type KycSessionStatus = {
+ /**
+ * The overall status of the session. Terminal values (e.g. `approved`,
+ * `completed`, `rejected`, `failed`, `blocked`) end polling; any other value
+ * keeps polling.
+ */
+ finalStatus: string;
+ /** Optional human-readable message describing the status. */
+ statusMessage?: string;
+ /** The vendor-agnostic external user id associated with the session. */
+ externalUserId: string;
+ /** The KYC decision status. */
+ kycStatus: string;
+ /** The identity vendor that handled the session. */
+ vendor: string;
+ /** The vendor-specific status. */
+ vendorStatus: string;
+};
+
+/**
+ * A single disclaimer/term the customer must accept before a session is
+ * created.
+ */
+export type KycDisclaimer = {
+ id: string;
+ // Mirrors the vendor API response field, which is snake_case.
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ display_name: string;
+ url: string;
+};
+
+/**
+ * Parameters passed to a platform SumSub launcher.
+ */
+export type KycSumSubLaunchParams = {
+ /**
+ * The applicant access token used to initialize the SumSub SDK.
+ */
+ applicantAccessToken: string;
+
+ /**
+ * Called by the SDK when the access token expires; must resolve with a fresh
+ * applicant access token.
+ */
+ onTokenExpiration: () => Promise;
+
+ /**
+ * Called when the SDK reports a status transition.
+ */
+ onStatusChange?: (prevStatus: string, newStatus: string) => void;
+
+ /**
+ * BCP-47 locale for the SDK UI.
+ */
+ locale?: string;
+
+ /**
+ * Enables SDK debug logging.
+ */
+ debug?: boolean;
+};
+
+/**
+ * Platform adapter that launches the native/web SumSub SDK.
+ *
+ * The KYC controller is platform-agnostic and does not import any SDK; each
+ * client (mobile / extension / web) injects an implementation of this
+ * interface. The controller owns all orchestration (session creation, token
+ * exchange, token refresh, state) and only delegates the actual SDK
+ * presentation to `launch`.
+ */
+export type KycSumSubLauncher = {
+ /**
+ * Whether the underlying SDK is available in the current runtime (e.g. the
+ * native module is linked). When `false`, `startSumSub` fails fast.
+ */
+ isAvailable(): boolean;
+
+ /**
+ * Presents the SumSub verification flow and resolves with the SDK result.
+ */
+ launch(params: KycSumSubLaunchParams): Promise>;
+};
diff --git a/packages/kyc-controller/src/ukyc/constants.ts b/packages/kyc-controller/src/ukyc/constants.ts
new file mode 100644
index 00000000000..c75c850c521
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/constants.ts
@@ -0,0 +1,82 @@
+/**
+ * Constants for the UKYC client-derived key material and storage-authorization
+ * layer. See the architecture doc, section "Client-Derived Material".
+ */
+
+/**
+ * Fully-qualified key path for the `local_user_secret` in Encrypted User
+ * Storage.
+ */
+export const UKYC_LOCAL_USER_SECRET_PATH = `ukyc.local_user_secret` as const;
+
+/**
+ * Size of the `local_user_secret` in bytes. 32 bytes (256 bits) provides high
+ * entropy and matches the input length expected by the HKDF-SHA256 derivations
+ * below.
+ */
+export const UKYC_LOCAL_USER_SECRET_SIZE_BYTES = 32;
+
+/**
+ * Byte length of each value derived from `local_user_secret`.
+ *
+ * `signingKey` is 32 bytes because it is used directly as the Ed25519
+ * private key (for Ed25519 the 32-byte seed *is* the private key).
+ */
+export const UKYC_DERIVED_KEY_SIZES = {
+ storageId: 32,
+ dataEncryptionKey: 32,
+ signingKey: 32,
+ relayTunnelKey: 32,
+} as const;
+
+/**
+ * HKDF `info` labels providing domain separation between the values derived
+ * from `local_user_secret`.
+ */
+export const UKYC_KDF_INFO = {
+ storageId: 'metamask.ukyc.storage.v1.storage_id',
+ dataEncryptionKey: 'metamask.ukyc.storage.v1.data_encryption_key',
+ signingKey: 'metamask.ukyc.storage.v1.signing_key',
+ relayTunnelKey: 'metamask.ukyc.storage.v1.relay_tunnel_key',
+} as const;
+
+/**
+ * Version bound into every `storage_access_token` payload.
+ */
+export const UKYC_STORAGE_ACCESS_TOKEN_VERSION = 1;
+
+/**
+ * Audience identifying the UKYC user-storage service. Required by UKYC Storage
+ * when it verifies a `storage_access_token`.
+ */
+export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE =
+ 'metamask:user-storage:ukyc' as const;
+
+/**
+ * Audience identifying the idOS Kwil credential-registry nodes. Required by
+ * idOS Kwil when it verifies a `storage_access_token`.
+ */
+export const UKYC_KWIL_AUDIENCE = 'idos:kwil' as const;
+
+/**
+ * Full audience list bound into every `storage_access_token` payload. `aud`
+ * lists every verifier that may accept the token, so both UKYC Storage and
+ * idOS Kwil can each find their own entry.
+ */
+export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES = [
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE,
+ UKYC_KWIL_AUDIENCE,
+] as const;
+
+/**
+ * Authorization scheme under which a signed `storage_access_token` envelope is
+ * carried to UKYC Storage: `Authorization: AccessToken `.
+ * The credentials portion is what `encodeStorageAccessTokenForHeader` returns.
+ */
+export const UKYC_CAPABILITY_AUTH_SCHEME = 'AccessToken' as const;
+
+/**
+ * Standard well-known path where the Fractal encryption service publishes its
+ * JWKS (the Ed25519 public keys used to sign the `jwtChain`).
+ */
+export const UKYC_JWKS_PATH = '/.well-known/jwks.json';
diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts
new file mode 100644
index 00000000000..17ae0da5bdb
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts
@@ -0,0 +1,109 @@
+import { areUint8ArraysEqual } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+
+import {
+ UKYC_DERIVED_KEY_SIZES,
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+} from './constants.js';
+import {
+ deriveClientMaterial,
+ encodeClientMaterial,
+} from './deriveClientMaterial.js';
+
+const LOCAL_USER_SECRET = new Uint8Array(
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+).fill(42);
+const OTHER_LOCAL_USER_SECRET = new Uint8Array(
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+).fill(43);
+
+describe('UKYC deriveClientMaterial', () => {
+ it('derives each value at the documented length', () => {
+ const material = deriveClientMaterial(LOCAL_USER_SECRET);
+
+ expect(material.storageId).toHaveLength(UKYC_DERIVED_KEY_SIZES.storageId);
+ expect(material.dataEncryptionKey).toHaveLength(
+ UKYC_DERIVED_KEY_SIZES.dataEncryptionKey,
+ );
+ expect(material.signingKey).toHaveLength(UKYC_DERIVED_KEY_SIZES.signingKey);
+ expect(material.relayTunnelKey).toHaveLength(
+ UKYC_DERIVED_KEY_SIZES.relayTunnelKey,
+ );
+ // Ed25519 public keys are 32 bytes.
+ expect(material.signingPublicKey).toHaveLength(32);
+ });
+
+ it('is deterministic for the same local_user_secret', () => {
+ const a = deriveClientMaterial(LOCAL_USER_SECRET);
+ const b = deriveClientMaterial(LOCAL_USER_SECRET);
+
+ expect(a).toStrictEqual(b);
+ });
+
+ it('produces different material for a different local_user_secret', () => {
+ const a = deriveClientMaterial(LOCAL_USER_SECRET);
+ const b = deriveClientMaterial(OTHER_LOCAL_USER_SECRET);
+
+ expect(areUint8ArraysEqual(a.storageId, b.storageId)).toBe(false);
+ expect(areUint8ArraysEqual(a.dataEncryptionKey, b.dataEncryptionKey)).toBe(
+ false,
+ );
+ expect(areUint8ArraysEqual(a.signingKey, b.signingKey)).toBe(false);
+ expect(areUint8ArraysEqual(a.relayTunnelKey, b.relayTunnelKey)).toBe(false);
+ });
+
+ it('domain-separates the derived values from one another', () => {
+ const { storageId, dataEncryptionKey, signingKey, relayTunnelKey } =
+ deriveClientMaterial(LOCAL_USER_SECRET);
+ const values = [storageId, dataEncryptionKey, signingKey, relayTunnelKey];
+
+ for (let i = 0; i < values.length; i++) {
+ for (let j = i + 1; j < values.length; j++) {
+ expect(areUint8ArraysEqual(values[i], values[j])).toBe(false);
+ }
+ }
+ });
+
+ it('derives a signing public key that matches the signing key', () => {
+ const material = deriveClientMaterial(LOCAL_USER_SECRET);
+
+ expect(material.signingPublicKey).toStrictEqual(
+ ed25519.getPublicKey(material.signingKey),
+ );
+ });
+
+ it('produces a working Ed25519 keypair for storage authorization', () => {
+ const material = deriveClientMaterial(LOCAL_USER_SECRET);
+ const message = new TextEncoder().encode('storage-authorization-payload');
+
+ const signature = ed25519.sign(message, material.signingKey);
+
+ expect(ed25519.verify(signature, message, material.signingPublicKey)).toBe(
+ true,
+ );
+ });
+});
+
+describe('UKYC encodeClientMaterial', () => {
+ it('encodes storage_id and signing public key as unpadded base64url', () => {
+ const material = deriveClientMaterial(LOCAL_USER_SECRET);
+
+ const encoded = encodeClientMaterial(material);
+
+ expect(encoded.storageId).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(encoded.signingPublicKey).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(encoded.storageId).not.toContain('=');
+ expect(encoded.signingPublicKey).not.toContain('=');
+ });
+
+ it('omits secret material from the encoded output', () => {
+ const material = deriveClientMaterial(LOCAL_USER_SECRET);
+
+ const encoded = encodeClientMaterial(material);
+
+ expect(Object.keys(encoded).sort()).toStrictEqual([
+ 'signingPublicKey',
+ 'storageId',
+ ]);
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts
new file mode 100644
index 00000000000..59ffa68d024
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts
@@ -0,0 +1,128 @@
+import { stringToBytes } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+
+import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js';
+import { toBase64Url } from '../encoding.js';
+
+/**
+ * Derives UKYC client material from the root `local_user_secret` using
+ * HKDF-SHA256 with domain-separated `info` labels — see the architecture doc,
+ * section "Client-Derived Material".
+ */
+
+/**
+ * The set of values derived from `local_user_secret`.
+ */
+export type UkycClientMaterial = {
+ /** Opaque lookup key for the encrypted KYC object. */
+ storageId: Uint8Array;
+ /** Symmetric key that encrypts `encrypted_kyc_data` (or wraps per-blob keys). */
+ dataEncryptionKey: Uint8Array;
+ /**
+ * Ed25519 private key used to sign `storage_access_token` capabilities. For
+ * Ed25519 the 32-byte HKDF output *is* the private key. The private half
+ * never leaves the device.
+ */
+ signingKey: Uint8Array;
+ /** Public half of `signingKey`, registered with the object on first write. */
+ signingPublicKey: Uint8Array;
+ /** Key for establishing/authenticating the encrypted tunnel to idOS, if needed. */
+ relayTunnelKey: Uint8Array;
+};
+
+/**
+ * The same material with byte fields base64url-encoded, matching the wire
+ * shapes in the architecture doc (`storage_id` as an opaque id, public key as a
+ * "base64url public key"). Secret material is intentionally omitted.
+ */
+export type EncodedUkycClientMaterial = {
+ storageId: string;
+ signingPublicKey: string;
+};
+
+/**
+ * Derives a single labeled value from `local_user_secret`.
+ *
+ * No salt is used: `local_user_secret` is already a high-entropy
+ * uniformly-random secret, so per-output domain separation comes entirely from
+ * the `info` label.
+ *
+ * @param localUserSecret - The root `local_user_secret` bytes.
+ * @param info - Domain-separation label for this output.
+ * @param length - Desired output length in bytes.
+ * @returns The derived bytes.
+ */
+function deriveLabeled(
+ localUserSecret: Uint8Array,
+ info: string,
+ length: number,
+): Uint8Array {
+ return hkdf(sha256, localUserSecret, undefined, stringToBytes(info), length);
+}
+
+/**
+ * Derives all UKYC client material from the root `local_user_secret`.
+ *
+ * This is a pure function of `local_user_secret`: the same input always yields
+ * the same outputs, which is what makes `storage_id` and `signing_key` stable
+ * across sessions and devices.
+ *
+ * @param localUserSecret - The `local_user_secret` produced by
+ * `getOrCreateLocalUserSecret`.
+ * @returns The derived {@link UkycClientMaterial}.
+ */
+export function deriveClientMaterial(
+ localUserSecret: Uint8Array,
+): UkycClientMaterial {
+ const storageId = deriveLabeled(
+ localUserSecret,
+ UKYC_KDF_INFO.storageId,
+ UKYC_DERIVED_KEY_SIZES.storageId,
+ );
+
+ const dataEncryptionKey = deriveLabeled(
+ localUserSecret,
+ UKYC_KDF_INFO.dataEncryptionKey,
+ UKYC_DERIVED_KEY_SIZES.dataEncryptionKey,
+ );
+
+ const signingKey = deriveLabeled(
+ localUserSecret,
+ UKYC_KDF_INFO.signingKey,
+ UKYC_DERIVED_KEY_SIZES.signingKey,
+ );
+
+ const relayTunnelKey = deriveLabeled(
+ localUserSecret,
+ UKYC_KDF_INFO.relayTunnelKey,
+ UKYC_DERIVED_KEY_SIZES.relayTunnelKey,
+ );
+
+ const signingPublicKey = ed25519.getPublicKey(signingKey);
+
+ return {
+ storageId,
+ dataEncryptionKey,
+ signingKey,
+ signingPublicKey,
+ relayTunnelKey,
+ };
+}
+
+/**
+ * Encodes the non-secret client material into the base64url wire shapes used by
+ * the UKYC storage API (`storage_id` and `signing_public_key`).
+ *
+ * @param material - The derived client material.
+ * @returns The base64url-encoded, non-secret fields.
+ */
+export function encodeClientMaterial(
+ material: UkycClientMaterial,
+): EncodedUkycClientMaterial {
+ return {
+ storageId: toBase64Url(material.storageId),
+ signingPublicKey: toBase64Url(material.signingPublicKey),
+ };
+}
diff --git a/packages/kyc-controller/src/ukyc/jwtChain.test.ts b/packages/kyc-controller/src/ukyc/jwtChain.test.ts
new file mode 100644
index 00000000000..735da283d21
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/jwtChain.test.ts
@@ -0,0 +1,100 @@
+import { stringToBytes } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+
+import { toBase64Url } from '../encoding.js';
+import type { Jwk } from './jwtChain.js';
+import { verifyJwtChain } from './jwtChain.js';
+
+const KID = 'key-1';
+const PAYLOAD = { sessionServerPublicKeyX: 'spk-x', nonce: 'nonce-1' };
+
+const SIGNING_PRIVATE_KEY = ed25519.utils.randomSecretKey();
+const SIGNING_PUBLIC_KEY = ed25519.getPublicKey(SIGNING_PRIVATE_KEY);
+
+const JWK: Jwk = {
+ kty: 'OKP',
+ crv: 'Ed25519',
+ x: toBase64Url(SIGNING_PUBLIC_KEY),
+ kid: KID,
+};
+
+/**
+ * Builds a compact EdDSA JWT signed with the module's signing key.
+ *
+ * @param options - Overrides.
+ * @param options.header - The protected header (defaults to a valid EdDSA one).
+ * @param options.payload - The payload (defaults to {@link PAYLOAD}).
+ * @param options.privateKey - The signing key (defaults to the module key).
+ * @param options.tamper - When true, corrupts the signature.
+ * @returns The compact-serialized JWT.
+ */
+function buildJwt({
+ header = { alg: 'EdDSA', kid: KID },
+ payload = PAYLOAD,
+ privateKey = SIGNING_PRIVATE_KEY,
+ tamper = false,
+}: {
+ header?: Record;
+ payload?: Record;
+ privateKey?: Uint8Array;
+ tamper?: boolean;
+} = {}): string {
+ const headerSegment = toBase64Url(stringToBytes(JSON.stringify(header)));
+ const payloadSegment = toBase64Url(stringToBytes(JSON.stringify(payload)));
+ const signature = ed25519.sign(
+ new TextEncoder().encode(`${headerSegment}.${payloadSegment}`),
+ privateKey,
+ );
+ if (tamper) {
+ signature[0] = signature[0] === 0 ? 1 : 0;
+ }
+ return `${headerSegment}.${payloadSegment}.${toBase64Url(signature)}`;
+}
+
+describe('UKYC verifyJwtChain', () => {
+ it('returns the payload for a validly-signed jwtChain', () => {
+ expect(verifyJwtChain([JWK], buildJwt())).toStrictEqual(PAYLOAD);
+ });
+
+ it('rejects a jwtChain that is not three segments', () => {
+ expect(() => verifyJwtChain([JWK], 'only.two')).toThrow(
+ 'not a well-formed JWT',
+ );
+ });
+
+ it('rejects a non-EdDSA algorithm', () => {
+ const jwt = buildJwt({ header: { alg: 'RS256', kid: KID } });
+
+ expect(() => verifyJwtChain([JWK], jwt)).toThrow('expected EdDSA');
+ });
+
+ it('rejects when no JWKS key matches the kid', () => {
+ const jwt = buildJwt({ header: { alg: 'EdDSA', kid: 'other' } });
+
+ expect(() => verifyJwtChain([JWK], jwt)).toThrow('no JWKS key matches');
+ });
+
+ it('rejects a JWKS key that is not an Ed25519 OKP key', () => {
+ const badJwk: Jwk = { ...JWK, crv: 'X25519' };
+
+ expect(() => verifyJwtChain([badJwk], buildJwt())).toThrow(
+ 'is not an Ed25519 OKP key',
+ );
+ });
+
+ it('rejects a tampered signature', () => {
+ expect(() => verifyJwtChain([JWK], buildJwt({ tamper: true }))).toThrow(
+ 'signature verification failed',
+ );
+ });
+
+ it('rejects a malformed (non-JSON) header segment', () => {
+ const jwt = `not-json.${toBase64Url(
+ stringToBytes(JSON.stringify(PAYLOAD)),
+ )}.sig`;
+
+ expect(() => verifyJwtChain([JWK], jwt)).toThrow(
+ 'failed to decode jwtChain header',
+ );
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts
new file mode 100644
index 00000000000..ebb40d93d48
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/jwtChain.ts
@@ -0,0 +1,113 @@
+import { bytesToString } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+
+import { base64UrlToBytes } from '../encoding.js';
+
+/**
+ * Verifies the `jwtChain` returned by the Fractal encryption service against
+ * its published JWKS.
+ *
+ * The signature check is done with `@noble/curves` (rather than WebCrypto
+ * `subtle`) because not every MetaMask runtime exposes a `subtle`
+ * implementation for Ed25519; JWT parsing is a plain base64url/JSON decode, so
+ * no `jose` dependency is required.
+ */
+
+/**
+ * A single Ed25519 (OKP) JSON Web Key from the Fractal JWKS.
+ */
+export type Jwk = {
+ kty: string;
+ crv: string;
+ x: string;
+ kid: string;
+ use?: string;
+ alg?: string;
+};
+
+/**
+ * The verified `jwtChain` payload. `sessionServerPublicKeyX` attests the
+ * server's X25519 public key so the client can confirm the value returned
+ * out-of-band by `getWrappingKey` was not tampered with.
+ */
+export type JwtChainPayload = {
+ sessionServerPublicKeyX: string;
+ nonce: string;
+};
+
+/**
+ * The protected header of a compact JWT.
+ */
+type JwtHeader = {
+ alg?: string;
+ kid?: string;
+};
+
+/**
+ * Decodes a base64url JWT segment into a parsed JSON object.
+ *
+ * @param segment - The base64url-encoded segment.
+ * @param label - Human-readable segment name for error messages.
+ * @returns The parsed JSON object.
+ */
+function decodeJsonSegment(segment: string, label: string): Type {
+ try {
+ return JSON.parse(bytesToString(base64UrlToBytes(segment))) as Type;
+ } catch (error) {
+ throw new Error(
+ `UKYC: failed to decode jwtChain ${label}: ${String(error)}`,
+ );
+ }
+}
+
+/**
+ * Verifies `jwtChain` against `keys`: matches the JWT header `kid` to a
+ * published Ed25519 signing key and checks the EdDSA signature over the
+ * `header.payload` input. Returns the decoded, verified payload.
+ *
+ * @param keys - The JWKS keys published by the Fractal encryption service.
+ * @param jwtChain - The compact-serialized EdDSA JWT from `getWrappingKey`.
+ * @returns The verified JWT payload.
+ */
+export function verifyJwtChain(keys: Jwk[], jwtChain: string): JwtChainPayload {
+ const [headerSegment, payloadSegment, signatureSegment] = jwtChain.split('.');
+ if (!headerSegment || !payloadSegment || !signatureSegment) {
+ throw new Error(
+ 'UKYC: jwtChain is not a well-formed JWT (expected 3 segments).',
+ );
+ }
+
+ const header = decodeJsonSegment(headerSegment, 'header');
+ if (header.alg !== 'EdDSA') {
+ throw new Error(
+ `UKYC: unsupported jwtChain alg "${String(
+ header.alg,
+ )}" (expected EdDSA).`,
+ );
+ }
+
+ const jwk = keys.find((key) => key.kid === header.kid);
+ if (!jwk) {
+ throw new Error(
+ `UKYC: no JWKS key matches jwtChain kid "${String(header.kid)}".`,
+ );
+ }
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') {
+ throw new Error(
+ `UKYC: JWKS key ${jwk.kid} is not an Ed25519 OKP key (kty=${jwk.kty}, crv=${jwk.crv}).`,
+ );
+ }
+
+ const isValid = ed25519.verify(
+ base64UrlToBytes(signatureSegment),
+ new TextEncoder().encode(`${headerSegment}.${payloadSegment}`),
+ base64UrlToBytes(jwk.x),
+ );
+ if (!isValid) {
+ throw new Error(
+ 'UKYC: jwtChain signature verification failed against JWKS.',
+ );
+ }
+
+ return decodeJsonSegment(payloadSegment, 'payload');
+}
diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.test.ts b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts
new file mode 100644
index 00000000000..b2e7869813d
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts
@@ -0,0 +1,148 @@
+import { base64ToBytes, bytesToBase64 } from '@metamask/utils';
+
+import {
+ UKYC_LOCAL_USER_SECRET_PATH,
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+} from './constants.js';
+import type { UkycLocalUserSecretStore } from './localUserSecret.js';
+import {
+ getOrCreateLocalUserSecret,
+ hasLocalUserSecret,
+ loadLocalUserSecret,
+} from './localUserSecret.js';
+
+const SECRET_BYTES = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(7);
+const SECRET_BASE64 = bytesToBase64(SECRET_BYTES);
+
+/**
+ * Builds a stateful in-memory store adapter backed by a single value.
+ *
+ * @param initial - The initial stored base64 value.
+ * @returns The store plus jest spies for `get` / `set`.
+ */
+function makeStore(initial: string | null = null): {
+ store: UkycLocalUserSecretStore;
+ get: jest.Mock;
+ set: jest.Mock;
+} {
+ let value = initial;
+ const get = jest.fn(async () => value);
+ const set = jest.fn(async (_path: string, next: string) => {
+ value = next;
+ });
+ return { store: { get, set }, get, set };
+}
+
+describe('UKYC localUserSecret', () => {
+ describe('loadLocalUserSecret', () => {
+ it('returns null when no local_user_secret is stored', async () => {
+ const { store, get } = makeStore(null);
+
+ expect(await loadLocalUserSecret(store)).toBeNull();
+ expect(get).toHaveBeenCalledWith(UKYC_LOCAL_USER_SECRET_PATH, undefined);
+ });
+
+ it('decodes and returns the stored local_user_secret', async () => {
+ const { store } = makeStore(SECRET_BASE64);
+
+ expect(await loadLocalUserSecret(store)).toStrictEqual(SECRET_BYTES);
+ });
+
+ it('forwards the entropy source id', async () => {
+ const { store, get } = makeStore(SECRET_BASE64);
+
+ await loadLocalUserSecret(store, 'entropy-1');
+
+ expect(get).toHaveBeenCalledWith(
+ UKYC_LOCAL_USER_SECRET_PATH,
+ 'entropy-1',
+ );
+ });
+
+ it('throws when the stored local_user_secret has an unexpected length', async () => {
+ const { store } = makeStore(bytesToBase64(new Uint8Array(16)));
+
+ await expect(loadLocalUserSecret(store)).rejects.toThrow(
+ 'unexpected length',
+ );
+ });
+ });
+
+ describe('getOrCreateLocalUserSecret', () => {
+ it('returns the existing local_user_secret without generating a new one', async () => {
+ const { store, set } = makeStore(SECRET_BASE64);
+
+ expect(await getOrCreateLocalUserSecret(store)).toStrictEqual(
+ SECRET_BYTES,
+ );
+ expect(set).not.toHaveBeenCalled();
+ });
+
+ it('generates and persists a new local_user_secret on first enrollment', async () => {
+ const { store, set } = makeStore(null);
+
+ const result = await getOrCreateLocalUserSecret(store);
+
+ expect(set).toHaveBeenCalledTimes(1);
+ const [path, persisted] = set.mock.calls[0];
+ expect(path).toBe(UKYC_LOCAL_USER_SECRET_PATH);
+ // The persisted value round-trips to the returned bytes.
+ expect(result).toStrictEqual(base64ToBytes(persisted));
+ expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES);
+ });
+
+ it('converges on a competing value that won the write race', async () => {
+ const competing = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(
+ 9,
+ );
+ // First read (existence check) misses; the re-read after our write sees a
+ // value another writer landed first.
+ const get = jest
+ .fn()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(bytesToBase64(competing));
+ const set = jest.fn().mockResolvedValue(undefined);
+
+ const result = await getOrCreateLocalUserSecret({ get, set });
+
+ expect(result).toStrictEqual(competing);
+ });
+
+ it('deduplicates concurrent create calls into a single generation', async () => {
+ const { store, set } = makeStore(null);
+
+ const [a, b] = await Promise.all([
+ getOrCreateLocalUserSecret(store),
+ getOrCreateLocalUserSecret(store),
+ ]);
+
+ expect(a).toStrictEqual(b);
+ expect(set).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to the generated secret if the re-read returns nothing', async () => {
+ // `get` always misses, even after the write, so the helper falls back to
+ // the value it just generated.
+ const get = jest.fn().mockResolvedValue(null);
+ const set = jest.fn().mockResolvedValue(undefined);
+
+ const result = await getOrCreateLocalUserSecret({ get, set });
+
+ expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES);
+ });
+ });
+
+ describe('hasLocalUserSecret', () => {
+ it('returns true when a local_user_secret exists', async () => {
+ const { store } = makeStore(SECRET_BASE64);
+
+ expect(await hasLocalUserSecret(store)).toBe(true);
+ });
+
+ it('returns false when no local_user_secret exists', async () => {
+ const { store } = makeStore(null);
+
+ expect(await hasLocalUserSecret(store)).toBe(false);
+ });
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.ts b/packages/kyc-controller/src/ukyc/localUserSecret.ts
new file mode 100644
index 00000000000..46583684e96
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/localUserSecret.ts
@@ -0,0 +1,161 @@
+import { base64ToBytes, bytesToBase64 } from '@metamask/utils';
+import { randomBytes } from '@noble/hashes/utils';
+
+import {
+ UKYC_LOCAL_USER_SECRET_PATH,
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+} from './constants.js';
+
+/**
+ * Orchestrates creation and loading of the UKYC `local_user_secret`.
+ *
+ * The `local_user_secret` is the root secret for all UKYC client-derived
+ * material. It is generated once, on first enrollment, and persisted to
+ * MetaMask Encrypted User Storage. It is never transmitted off the device, not
+ * even to the idOS Relay. Every subsequent value (`storage_id`,
+ * `data_encryption_key`, `signing_key`, `relay_tunnel_key`) is derived from it
+ * via HKDF — see `deriveClientMaterial`.
+ *
+ * This module is platform-agnostic: the Encrypted User Storage backing is
+ * injected as a {@link UkycLocalUserSecretStore} so the controller (which owns
+ * the messenger) supplies the concrete `UserStorageController` calls.
+ */
+
+/**
+ * The Encrypted User Storage operations this module needs. On MetaMask clients
+ * these are backed by `UserStorageController:performGetStorage` /
+ * `performSetStorage`.
+ */
+export type UkycLocalUserSecretStore = {
+ /**
+ * Reads the base64 string stored at `path`, or `null` if none exists.
+ */
+ get: (path: string, entropySourceId?: string) => Promise;
+ /**
+ * Writes the base64 string `value` at `path`.
+ */
+ set: (path: string, value: string, entropySourceId?: string) => Promise;
+};
+
+/**
+ * In-flight `getOrCreateLocalUserSecret` calls, keyed by entropy source.
+ * Deduplicates concurrent enrollments in a single client session so we never
+ * generate and persist two competing `local_user_secret`s for the same source.
+ */
+const inFlightCreations = new Map>();
+
+/**
+ * Loads the persisted `local_user_secret` from Encrypted User Storage, if one
+ * exists.
+ *
+ * @param store - The Encrypted User Storage adapter.
+ * @param entropySourceId - Optional HD keyring entropy source id, used to scope
+ * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP.
+ * @returns The decoded `local_user_secret` bytes, or `null` if none has been
+ * enrolled.
+ */
+export async function loadLocalUserSecret(
+ store: UkycLocalUserSecretStore,
+ entropySourceId?: string,
+): Promise {
+ const stored = await store.get(UKYC_LOCAL_USER_SECRET_PATH, entropySourceId);
+
+ if (!stored) {
+ return null;
+ }
+
+ const localUserSecret = base64ToBytes(stored);
+
+ if (localUserSecret.length !== UKYC_LOCAL_USER_SECRET_SIZE_BYTES) {
+ throw new Error(
+ `UKYC: stored local_user_secret has unexpected length ${localUserSecret.length}, expected ${UKYC_LOCAL_USER_SECRET_SIZE_BYTES}.`,
+ );
+ }
+
+ return localUserSecret;
+}
+
+/**
+ * Persists a freshly generated `local_user_secret` to Encrypted User Storage.
+ *
+ * @param store - The Encrypted User Storage adapter.
+ * @param localUserSecret - The `local_user_secret` bytes to persist.
+ * @param entropySourceId - Optional HD keyring entropy source id.
+ */
+async function persistLocalUserSecret(
+ store: UkycLocalUserSecretStore,
+ localUserSecret: Uint8Array,
+ entropySourceId?: string,
+): Promise {
+ await store.set(
+ UKYC_LOCAL_USER_SECRET_PATH,
+ bytesToBase64(localUserSecret),
+ entropySourceId,
+ );
+}
+
+/**
+ * Creates the UKYC `local_user_secret` if it does not already exist, otherwise
+ * loads the existing one. This is the single entry point used on UKYC
+ * enrollment.
+ *
+ * The operation is idempotent and safe against concurrent callers in the same
+ * session: repeated or parallel calls resolve to the same `local_user_secret`
+ * and never generate more than one secret for a given entropy source.
+ *
+ * @param store - The Encrypted User Storage adapter.
+ * @param entropySourceId - Optional HD keyring entropy source id, used to scope
+ * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP.
+ * @returns The `local_user_secret` bytes (existing or newly created).
+ */
+export async function getOrCreateLocalUserSecret(
+ store: UkycLocalUserSecretStore,
+ entropySourceId?: string,
+): Promise {
+ const cacheKey = entropySourceId ?? '';
+
+ const pending = inFlightCreations.get(cacheKey);
+ if (pending) {
+ return pending;
+ }
+
+ const creation = (async (): Promise => {
+ const existing = await loadLocalUserSecret(store, entropySourceId);
+ if (existing) {
+ return existing;
+ }
+
+ const localUserSecret = randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES);
+ await persistLocalUserSecret(store, localUserSecret, entropySourceId);
+
+ // Re-read after persisting so that all callers converge on whatever value
+ // actually landed in storage (defends against a competing write that may
+ // have won the race, e.g. from another device syncing the same feature).
+ return (
+ (await loadLocalUserSecret(store, entropySourceId)) ?? localUserSecret
+ );
+ })();
+
+ inFlightCreations.set(cacheKey, creation);
+
+ try {
+ return await creation;
+ } finally {
+ inFlightCreations.delete(cacheKey);
+ }
+}
+
+/**
+ * Whether a `local_user_secret` has already been enrolled for the given entropy
+ * source.
+ *
+ * @param store - The Encrypted User Storage adapter.
+ * @param entropySourceId - Optional HD keyring entropy source id.
+ * @returns `true` if a `local_user_secret` exists in Encrypted User Storage.
+ */
+export async function hasLocalUserSecret(
+ store: UkycLocalUserSecretStore,
+ entropySourceId?: string,
+): Promise {
+ return (await loadLocalUserSecret(store, entropySourceId)) !== null;
+}
diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts
new file mode 100644
index 00000000000..d0299e7c9ef
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts
@@ -0,0 +1,241 @@
+import { base64ToBytes } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+
+import {
+ UKYC_KWIL_AUDIENCE,
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE,
+ UKYC_STORAGE_ACCESS_TOKEN_VERSION,
+} from './constants.js';
+import { deriveClientMaterial } from './deriveClientMaterial.js';
+import {
+ canonicalizeJson,
+ encodeStorageAccessTokenForHeader,
+ signStorageAccessToken,
+} from './storageAccessToken.js';
+
+const LOCAL_USER_SECRET = new Uint8Array(
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+).fill(42);
+const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET);
+
+const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z');
+const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z');
+
+/**
+ * Decodes an unpadded base64url string back to bytes.
+ *
+ * @param value - The base64url string.
+ * @returns The decoded bytes.
+ */
+function fromBase64Url(value: string): Uint8Array {
+ const padded = value.padEnd(Math.ceil(value.length / 4) * 4, '=');
+ return base64ToBytes(padded.replace(/-/gu, '+').replace(/_/gu, '/'));
+}
+
+describe('UKYC canonicalizeJson', () => {
+ it('sorts object keys by code unit', () => {
+ expect(canonicalizeJson({ b: 1, a: 2, c: 3 })).toBe('{"a":2,"b":1,"c":3}');
+ });
+
+ it('preserves array order and emits no whitespace', () => {
+ expect(canonicalizeJson({ z: [3, 2, 1], a: 'x' })).toBe(
+ '{"a":"x","z":[3,2,1]}',
+ );
+ });
+
+ it('drops undefined members', () => {
+ expect(canonicalizeJson({ a: 1, b: undefined, c: 2 })).toBe(
+ '{"a":1,"c":2}',
+ );
+ });
+
+ it('serializes primitives', () => {
+ expect(canonicalizeJson(null)).toBe('null');
+ expect(canonicalizeJson(true)).toBe('true');
+ expect(canonicalizeJson(false)).toBe('false');
+ expect(canonicalizeJson('hi')).toBe('"hi"');
+ expect(canonicalizeJson(7)).toBe('7');
+ });
+
+ it('rejects non-integer numbers', () => {
+ expect(() => canonicalizeJson(1.5)).toThrow('non-integer');
+ });
+});
+
+describe('UKYC signStorageAccessToken', () => {
+ it('mints a client-presented token with the expected payload', () => {
+ const token = signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['delete'],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ expect(token.payload).toStrictEqual({
+ version: UKYC_STORAGE_ACCESS_TOKEN_VERSION,
+ aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE],
+ storage_id: expect.stringMatching(/^[A-Za-z0-9_-]+$/u),
+ signing_public_key: expect.stringMatching(/^[A-Za-z0-9_-]+$/u),
+ operations: ['delete'],
+ presenter: 'client',
+ issued_at: '2026-07-07T00:00:00Z',
+ expires_at: '2026-07-07T04:00:00Z',
+ });
+ expect(token.payload).not.toHaveProperty('session_id');
+ });
+
+ it('formats timestamps as RFC 3339 with whole seconds, truncating sub-second precision', () => {
+ const token = signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read'],
+ issuedAt: new Date('2026-07-07T00:00:00.715Z'),
+ expiresAt: new Date('2026-07-07T04:00:00.999Z'),
+ });
+
+ expect(token.payload.issued_at).toBe('2026-07-07T00:00:00Z');
+ expect(token.payload.expires_at).toBe('2026-07-07T04:00:00Z');
+ });
+
+ it('produces a signature that verifies against the signing public key', () => {
+ const token = signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read', 'write'],
+ presenter: 'idos-relay',
+ sessionId: 'session-1',
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ const message = new TextEncoder().encode(canonicalizeJson(token.payload));
+ const signature = fromBase64Url(token.signature);
+
+ expect(ed25519.verify(signature, message, MATERIAL.signingPublicKey)).toBe(
+ true,
+ );
+ });
+
+ it('binds session_id for Relay-presented tokens', () => {
+ const token = signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read'],
+ presenter: 'idos-relay',
+ sessionId: 'session-42',
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ expect(token.payload.session_id).toBe('session-42');
+ expect(token.payload.presenter).toBe('idos-relay');
+ });
+
+ it('is deterministic for the same inputs', () => {
+ const params = {
+ material: MATERIAL,
+ operations: ['read' as const],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ };
+
+ expect(signStorageAccessToken(params)).toStrictEqual(
+ signStorageAccessToken(params),
+ );
+ });
+
+ it('rejects a Relay presenter without a session_id', () => {
+ expect(() =>
+ signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read'],
+ presenter: 'idos-relay',
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('requires a session_id');
+ });
+
+ it('rejects delegating a delete token to the Relay', () => {
+ expect(() =>
+ signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['delete'],
+ presenter: 'idos-relay',
+ sessionId: 'session-1',
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('cannot be delegated to the Relay');
+ });
+
+ it('rejects delete combined with other operations', () => {
+ expect(() =>
+ signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['delete', 'read'],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('must contain only "delete"');
+ });
+
+ it('rejects an empty operations list', () => {
+ expect(() =>
+ signStorageAccessToken({
+ material: MATERIAL,
+ operations: [],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('at least one operation');
+ });
+
+ it('rejects duplicate operations', () => {
+ expect(() =>
+ signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read', 'read'],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('must be unique');
+ });
+
+ it('rejects an expiry at or before issued_at', () => {
+ expect(() =>
+ signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read'],
+ issuedAt: EXPIRES_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('expires_at must be after issued_at');
+ });
+
+ it('defaults issuedAt to now when omitted', () => {
+ const token = signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read'],
+ expiresAt: new Date(Date.now() + 60_000),
+ });
+
+ expect(token.payload.issued_at).toStrictEqual(expect.any(String));
+ });
+});
+
+describe('UKYC encodeStorageAccessTokenForHeader', () => {
+ it('encodes the envelope as unpadded base64url that round-trips', () => {
+ const token = signStorageAccessToken({
+ material: MATERIAL,
+ operations: ['read'],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ const header = encodeStorageAccessTokenForHeader(token);
+
+ expect(header).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(
+ JSON.parse(new TextDecoder().decode(fromBase64Url(header))),
+ ).toStrictEqual(token);
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts
new file mode 100644
index 00000000000..abc9455a1e3
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts
@@ -0,0 +1,262 @@
+import { stringToBytes } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+
+import {
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES,
+ UKYC_STORAGE_ACCESS_TOKEN_VERSION,
+} from './constants.js';
+import type { UkycClientMaterial } from './deriveClientMaterial.js';
+import { toBase64Url } from '../encoding.js';
+
+/**
+ * Mints `storage_access_token` capabilities — the client-signed, scoped,
+ * session-bound proofs that authorize UKYC storage operations. See the
+ * architecture doc, section "Storage Authentication".
+ *
+ * The token is Ed25519 over RFC 8785 (JCS) canonical JSON of the payload. Only
+ * the client holds the private `signing_key`, so only the client can mint a
+ * token; a `read`/`write`-scoped token may then be handed to the Relay to
+ * present, but `delete` is never delegated.
+ */
+
+/**
+ * Storage operations a `storage_access_token` can authorize.
+ */
+export type UkycStorageOperation = 'read' | 'write' | 'delete';
+
+/**
+ * Who presents the token to UKYC storage. The Relay may only present
+ * `read`/`write` tokens; `delete` is always client-presented.
+ */
+export type UkycTokenPresenter = 'client' | 'idos-relay';
+
+/**
+ * The signed `storage_access_token` payload. Field names are snake_case because
+ * they are canonicalized and hashed exactly as they appear on the wire.
+ */
+export type UkycStorageAccessTokenPayload = {
+ version: number;
+ /** Every verifier that may accept the token, e.g. UKYC Storage and idOS Kwil. */
+ aud: string[];
+ // Wire-shape fields are snake_case; they are canonicalized and signed exactly
+ // as they appear on the wire.
+ /* eslint-disable @typescript-eslint/naming-convention */
+ storage_id: string;
+ signing_public_key: string;
+ operations: UkycStorageOperation[];
+ presenter: UkycTokenPresenter;
+ /** UKYC session id. Required (and only present) when presenter is `idos-relay`. */
+ session_id?: string;
+ issued_at: string;
+ expires_at: string;
+ /* eslint-enable @typescript-eslint/naming-convention */
+};
+
+/**
+ * The on-the-wire envelope: the payload plus its detached Ed25519 signature
+ * (base64url) over the JCS canonicalization of the payload.
+ */
+export type UkycStorageAccessToken = {
+ payload: UkycStorageAccessTokenPayload;
+ signature: string;
+};
+
+/**
+ * Inputs for minting a `storage_access_token`.
+ */
+export type SignStorageAccessTokenParams = {
+ /** Client material derived from `local_user_secret`. */
+ material: UkycClientMaterial;
+ /** Operations the token authorizes. `delete` must be the sole operation. */
+ operations: UkycStorageOperation[];
+ /** Who will present the token. Defaults to `client`. */
+ presenter?: UkycTokenPresenter;
+ /** UKYC session id. Required when presenter is `idos-relay`. */
+ sessionId?: string;
+ /** Token issue time. Defaults to now. */
+ issuedAt?: Date;
+ /** Token expiry. Must be strictly after `issuedAt`. */
+ expiresAt: Date;
+};
+
+type JsonValue =
+ | null
+ | boolean
+ | number
+ | string
+ | JsonValue[]
+ | { [key: string]: JsonValue | undefined };
+
+/**
+ * Serializes a JSON value to RFC 8785 (JCS) canonical form.
+ *
+ * Scope note: this implementation covers the JSON shapes used by UKYC storage
+ * payloads — objects, arrays, strings, integers, booleans, and null. Object
+ * members are sorted by their UTF-16 code units (matching JS default string
+ * ordering, which is what JCS requires) and `undefined` members are dropped.
+ * Non-finite and non-integer numbers are rejected, since the payloads never
+ * contain them and correct JCS number formatting for the general case is
+ * intentionally out of scope here.
+ *
+ * @param value - The value to canonicalize.
+ * @returns The canonical JSON string.
+ */
+export function canonicalizeJson(value: JsonValue): string {
+ if (value === null) {
+ return 'null';
+ }
+
+ if (typeof value === 'boolean') {
+ return value ? 'true' : 'false';
+ }
+
+ if (typeof value === 'number') {
+ if (!Number.isInteger(value)) {
+ throw new Error(
+ 'UKYC: cannot canonicalize a non-integer number for JCS.',
+ );
+ }
+ return JSON.stringify(value);
+ }
+
+ if (typeof value === 'string') {
+ return JSON.stringify(value);
+ }
+
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => canonicalizeJson(item)).join(',')}]`;
+ }
+
+ const entries = Object.keys(value)
+ .sort()
+ .reduce((acc, key) => {
+ const child = value[key];
+ if (child !== undefined) {
+ acc.push(`${JSON.stringify(key)}:${canonicalizeJson(child)}`);
+ }
+ return acc;
+ }, []);
+
+ return `{${entries.join(',')}}`;
+}
+
+/**
+ * Formats a date as RFC 3339 UTC with whole-second precision (e.g.
+ * `2026-07-07T00:00:00Z`). `Date.prototype.toISOString` always emits
+ * milliseconds (`...:00.000Z`); the `storage_access_token` wire format omits
+ * fractional seconds, so the sub-second component is truncated (not rounded).
+ *
+ * @param date - The date to format.
+ * @returns The RFC 3339 timestamp without fractional seconds.
+ */
+function toRfc3339Seconds(date: Date): string {
+ return `${date.toISOString().slice(0, 19)}Z`;
+}
+
+/**
+ * Builds and signs a `storage_access_token`.
+ *
+ * @param params - See {@link SignStorageAccessTokenParams}.
+ * @returns The signed token envelope.
+ */
+export function signStorageAccessToken(
+ params: SignStorageAccessTokenParams,
+): UkycStorageAccessToken {
+ const {
+ material,
+ operations,
+ presenter = 'client',
+ sessionId,
+ issuedAt = new Date(),
+ expiresAt,
+ } = params;
+
+ assertValidOperations(operations);
+
+ if (expiresAt.getTime() <= issuedAt.getTime()) {
+ throw new Error(
+ 'UKYC: storage_access_token expires_at must be after issued_at.',
+ );
+ }
+
+ const isDelete = operations.includes('delete');
+
+ if (presenter === 'idos-relay' && isDelete) {
+ throw new Error(
+ 'UKYC: a delete-scoped storage_access_token cannot be delegated to the Relay.',
+ );
+ }
+
+ if (presenter === 'idos-relay' && !sessionId) {
+ throw new Error(
+ 'UKYC: a Relay-presented storage_access_token requires a session_id.',
+ );
+ }
+
+ const payload: UkycStorageAccessTokenPayload = {
+ version: UKYC_STORAGE_ACCESS_TOKEN_VERSION,
+ aud: [...UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES],
+ storage_id: toBase64Url(material.storageId),
+ signing_public_key: toBase64Url(material.signingPublicKey),
+ operations,
+ presenter,
+ issued_at: toRfc3339Seconds(issuedAt),
+ expires_at: toRfc3339Seconds(expiresAt),
+ };
+
+ // Only bind session_id for Relay-presented tokens; omit the key entirely for
+ // client-presented tokens so it does not appear in the canonicalized payload.
+ if (presenter === 'idos-relay') {
+ payload.session_id = sessionId;
+ }
+
+ const message = stringToBytes(canonicalizeJson(payload));
+ const signature = ed25519.sign(message, material.signingKey);
+
+ return {
+ payload,
+ signature: toBase64Url(signature),
+ };
+}
+
+/**
+ * Serializes a signed token to a compact string suitable for header transport
+ * (`Authorization: AccessToken `, see `UKYC_CAPABILITY_AUTH_SCHEME`). The
+ * complete envelope is base64url-encoded; the private `signing_key` is never
+ * included.
+ *
+ * @param token - The signed token envelope.
+ * @returns The base64url-encoded envelope string.
+ */
+export function encodeStorageAccessTokenForHeader(
+ token: UkycStorageAccessToken,
+): string {
+ return toBase64Url(stringToBytes(JSON.stringify(token)));
+}
+
+/**
+ * Validates that an operations list is one storage understands: a non-empty set
+ * of `read`/`write`, or exactly `['delete']`. `delete` is never combined with
+ * other operations.
+ *
+ * @param operations - The requested operations.
+ */
+function assertValidOperations(operations: UkycStorageOperation[]): void {
+ if (operations.length === 0) {
+ throw new Error(
+ 'UKYC: storage_access_token requires at least one operation.',
+ );
+ }
+
+ const unique = new Set(operations);
+
+ if (unique.size !== operations.length) {
+ throw new Error('UKYC: storage_access_token operations must be unique.');
+ }
+
+ if (unique.has('delete') && operations.length > 1) {
+ throw new Error(
+ 'UKYC: a delete-scoped storage_access_token must contain only "delete".',
+ );
+ }
+}
diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts
new file mode 100644
index 00000000000..2784d87ba28
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/testToken.test.ts
@@ -0,0 +1,133 @@
+import { ed25519 } from '@noble/curves/ed25519';
+import { hexToBytes, stringToBytes } from '@metamask/utils';
+
+import {
+ UKYC_CAPABILITY_AUTH_SCHEME,
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE,
+ UKYC_KWIL_AUDIENCE,
+} from './constants.js';
+import { canonicalizeJson } from './storageAccessToken.js';
+import { mintUkycTestToken } from './testToken.js';
+import { base64UrlToBytes } from '../encoding.js';
+
+// A fixed 32-byte secret (all 0x42), as hex, so storage_id and keys are stable.
+const SECRET_HEX = '42'.repeat(32);
+const ISSUED_AT = new Date('2026-07-07T00:00:00Z');
+const EXPIRES_AT = new Date('2026-07-07T04:00:00Z');
+
+/**
+ * Splits an `AccessToken ` header and decodes the credentials into the
+ * envelope, the way UKYC Storage does on the wire.
+ *
+ * @param header - The full Authorization header value.
+ * @returns The decoded token envelope.
+ */
+function decodeHeader(header: string): {
+ payload: Record;
+ signature: string;
+} {
+ const [scheme, creds] = header.split(' ');
+ expect(scheme).toBe(UKYC_CAPABILITY_AUTH_SCHEME);
+ return JSON.parse(new TextDecoder().decode(base64UrlToBytes(creds)));
+}
+
+describe('UKYC mintUkycTestToken', () => {
+ it('mints a client token from a hex secret with the derived identifiers', () => {
+ const result = mintUkycTestToken({
+ localUserSecret: SECRET_HEX,
+ operations: ['read', 'write'],
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ expect(result.localUserSecret).toBe(SECRET_HEX);
+ expect(result.token.payload).toMatchObject({
+ version: 1,
+ aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE],
+ operations: ['read', 'write'],
+ presenter: 'client',
+ issued_at: '2026-07-07T00:00:00Z',
+ expires_at: '2026-07-07T04:00:00Z',
+ });
+ // storage_id / signing_public_key are the client-derived values, not
+ // anything a server fills in.
+ expect(result.storageId).toBe(result.token.payload.storage_id);
+ expect(result.signingPublicKey).toBe(
+ result.token.payload.signing_public_key,
+ );
+ expect(result.token.payload).not.toHaveProperty('session_id');
+ });
+
+ it('produces an Authorization header whose signature verifies (as UKYC Storage checks it)', () => {
+ const result = mintUkycTestToken({
+ localUserSecret: SECRET_HEX,
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ const envelope = decodeHeader(result.authorizationHeader);
+ const message = stringToBytes(canonicalizeJson(envelope.payload));
+ const signature = base64UrlToBytes(envelope.signature);
+ const publicKey = base64UrlToBytes(result.signingPublicKey);
+
+ expect(ed25519.verify(signature, message, publicKey)).toBe(true);
+ });
+
+ it('defaults operations to ["read"] and expiry to issued_at + 4h', () => {
+ const result = mintUkycTestToken({
+ localUserSecret: SECRET_HEX,
+ issuedAt: ISSUED_AT,
+ });
+
+ expect(result.token.payload.operations).toStrictEqual(['read']);
+ expect(result.token.payload.issued_at).toBe('2026-07-07T00:00:00Z');
+ expect(result.token.payload.expires_at).toBe('2026-07-07T04:00:00Z');
+ });
+
+ it('accepts a raw byte secret and is deterministic for the same inputs', () => {
+ const secret = hexToBytes(SECRET_HEX);
+ const params = {
+ localUserSecret: secret,
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ };
+
+ expect(mintUkycTestToken(params)).toStrictEqual(mintUkycTestToken(params));
+ });
+
+ it('generates a fresh random secret when none is supplied', () => {
+ const result = mintUkycTestToken();
+
+ // 32 bytes hex-encoded.
+ expect(result.localUserSecret).toMatch(/^[0-9a-f]{64}$/u);
+ expect(result.token.payload.operations).toStrictEqual(['read']);
+ expect(result.authorizationHeader.startsWith(
+ `${UKYC_CAPABILITY_AUTH_SCHEME} `,
+ )).toBe(true);
+ });
+
+ it('binds session_id for a Relay-presented token', () => {
+ const result = mintUkycTestToken({
+ localUserSecret: SECRET_HEX,
+ operations: ['read', 'write'],
+ presenter: 'idos-relay',
+ sessionId: 'session-1',
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+
+ expect(result.token.payload.presenter).toBe('idos-relay');
+ expect(result.token.payload.session_id).toBe('session-1');
+ });
+
+ it('rejects a Relay presenter without a session_id', () => {
+ expect(() =>
+ mintUkycTestToken({
+ localUserSecret: SECRET_HEX,
+ presenter: 'idos-relay',
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ }),
+ ).toThrow('requires a session_id');
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/testToken.ts b/packages/kyc-controller/src/ukyc/testToken.ts
new file mode 100644
index 00000000000..055d1b08448
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/testToken.ts
@@ -0,0 +1,132 @@
+import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils';
+
+import {
+ UKYC_CAPABILITY_AUTH_SCHEME,
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+} from './constants.js';
+import {
+ deriveClientMaterial,
+ encodeClientMaterial,
+} from './deriveClientMaterial.js';
+import {
+ encodeStorageAccessTokenForHeader,
+ signStorageAccessToken,
+} from './storageAccessToken.js';
+import type {
+ UkycStorageAccessToken,
+ UkycStorageOperation,
+ UkycTokenPresenter,
+} from './storageAccessToken.js';
+
+/**
+ * Mints ready-to-use UKYC `storage_access_token`s for testing UKYC Storage.
+ *
+ * This composes the same pure functions the MetaMask client uses in production
+ * (`deriveClientMaterial` + `signStorageAccessToken`), so a third party such as
+ * idOS can produce a valid, signed token without deriving one on-device. No
+ * server "fills in" the `signing_public_key`: it is derived from a
+ * `local_user_secret` the caller controls and registered on first write, so
+ * reusing the same secret yields a stable `storage_id` and controlling key.
+ */
+
+/** Default token lifetime when `expiresAt` is not supplied (4 hours). */
+const DEFAULT_TOKEN_LIFETIME_MS = 4 * 60 * 60 * 1000;
+
+/**
+ * Inputs for {@link mintUkycTestToken}. All fields are optional; the only value
+ * a caller usually pins is `localUserSecret`, so `storage_id` and the signing
+ * key stay stable across runs.
+ */
+export type MintUkycTestTokenParams = {
+ /**
+ * The root `local_user_secret`, as raw 32 bytes or a hex string. When omitted
+ * a fresh random secret is generated (returned in the result so it can be
+ * reused).
+ */
+ localUserSecret?: Uint8Array | string;
+ /** Operations the token authorizes. Defaults to `['read']`. */
+ operations?: UkycStorageOperation[];
+ /** Who will present the token. Defaults to `client`. */
+ presenter?: UkycTokenPresenter;
+ /** UKYC session id. Required when `presenter` is `idos-relay`. */
+ sessionId?: string;
+ /** Token issue time. Defaults to now. */
+ issuedAt?: Date;
+ /** Token expiry. Defaults to `issuedAt` + 4 hours. */
+ expiresAt?: Date;
+};
+
+/**
+ * The minted token plus everything needed to exercise UKYC Storage with it.
+ */
+export type MintedUkycTestToken = {
+ /** The `local_user_secret` used, hex-encoded, so the caller can reuse it. */
+ localUserSecret: string;
+ /** base64url `storage_id` — use it as the `{storage_id}` path segment. */
+ storageId: string;
+ /** base64url `signing_public_key` registered on first write. */
+ signingPublicKey: string;
+ /** The signed token envelope (payload + signature). */
+ token: UkycStorageAccessToken;
+ /**
+ * The full `Authorization` header value, e.g.
+ * `AccessToken `, ready to send to UKYC Storage.
+ */
+ authorizationHeader: string;
+};
+
+/**
+ * Resolves the caller-supplied secret into raw bytes, generating a random one
+ * when none is provided.
+ *
+ * @param secret - Raw 32 bytes, a hex string, or undefined for a random secret.
+ * @returns The `local_user_secret` bytes.
+ */
+function resolveLocalUserSecret(secret?: Uint8Array | string): Uint8Array {
+ if (secret === undefined) {
+ return randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES);
+ }
+ return typeof secret === 'string' ? hexToBytes(secret) : secret;
+}
+
+/**
+ * Mints a signed UKYC `storage_access_token` for testing.
+ *
+ * @param params - See {@link MintUkycTestTokenParams}.
+ * @returns The token, its `Authorization` header, and the derived identifiers.
+ */
+export function mintUkycTestToken(
+ params: MintUkycTestTokenParams = {},
+): MintedUkycTestToken {
+ const {
+ operations = ['read'],
+ presenter,
+ sessionId,
+ issuedAt = new Date(),
+ expiresAt = new Date(issuedAt.getTime() + DEFAULT_TOKEN_LIFETIME_MS),
+ } = params;
+
+ const localUserSecret = resolveLocalUserSecret(params.localUserSecret);
+ const material = deriveClientMaterial(localUserSecret);
+
+ const token = signStorageAccessToken({
+ material,
+ operations,
+ presenter,
+ sessionId,
+ issuedAt,
+ expiresAt,
+ });
+
+ const { storageId, signingPublicKey } = encodeClientMaterial(material);
+
+ return {
+ localUserSecret: bytesToHex(localUserSecret),
+ storageId,
+ signingPublicKey,
+ token,
+ authorizationHeader: `${UKYC_CAPABILITY_AUTH_SCHEME} ${encodeStorageAccessTokenForHeader(
+ token,
+ )}`,
+ };
+}
diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts
new file mode 100644
index 00000000000..bfd3225d27b
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts
@@ -0,0 +1,90 @@
+import { areUint8ArraysEqual } from '@metamask/utils';
+import { box } from 'tweetnacl';
+
+import { base64UrlToBytes, toBase64Url } from '../encoding.js';
+import { wrapEncryptionKey } from './wrapEncryptionKey.js';
+
+const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7);
+
+/**
+ * Reverses {@link wrapEncryptionKey} from the server's perspective: opens the
+ * NaCl box using the server private key + client public key.
+ *
+ * @param serverPrivateKey - The server's X25519 private key.
+ * @param clientPublicKey - The client's X25519 public key.
+ * @param encryptedKey - The base64url ciphertext (+ tag).
+ * @param nonce - The base64url nonce.
+ * @returns The recovered plaintext key.
+ */
+function unwrap(
+ serverPrivateKey: Uint8Array,
+ clientPublicKey: Uint8Array,
+ encryptedKey: string,
+ nonce: string,
+): Uint8Array {
+ const recovered = box.open(
+ base64UrlToBytes(encryptedKey),
+ base64UrlToBytes(nonce),
+ clientPublicKey,
+ serverPrivateKey,
+ );
+ if (recovered === null) {
+ throw new Error('Failed to open NaCl box');
+ }
+ return recovered;
+}
+
+describe('UKYC wrapEncryptionKey', () => {
+ it('wraps a key the session server can recover', () => {
+ const serverKeyPair = box.keyPair();
+ const clientKeyPair = box.keyPair();
+
+ const { encryptedKey, nonce } = wrapEncryptionKey(
+ clientKeyPair.secretKey,
+ toBase64Url(serverKeyPair.publicKey),
+ DATA_ENCRYPTION_KEY,
+ );
+
+ const recovered = unwrap(
+ serverKeyPair.secretKey,
+ clientKeyPair.publicKey,
+ encryptedKey,
+ nonce,
+ );
+ expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true);
+ });
+
+ it('emits base64url fields', () => {
+ const serverPublicKey = box.keyPair().publicKey;
+ const clientPrivateKey = box.keyPair().secretKey;
+
+ const { encryptedKey, nonce } = wrapEncryptionKey(
+ clientPrivateKey,
+ toBase64Url(serverPublicKey),
+ DATA_ENCRYPTION_KEY,
+ );
+
+ expect(encryptedKey).toMatch(/^[A-Za-z0-9\-_]+$/u);
+ expect(nonce).toMatch(/^[A-Za-z0-9\-_]+$/u);
+ });
+
+ it('uses a fresh nonce per call', () => {
+ const serverPublicKey = box.keyPair().publicKey;
+ const clientPrivateKey = box.keyPair().secretKey;
+ const serverPublicKeyB64 = toBase64Url(serverPublicKey);
+
+ const first = wrapEncryptionKey(
+ clientPrivateKey,
+ serverPublicKeyB64,
+ DATA_ENCRYPTION_KEY,
+ );
+ const second = wrapEncryptionKey(
+ clientPrivateKey,
+ serverPublicKeyB64,
+ DATA_ENCRYPTION_KEY,
+ );
+
+ expect(first.nonce).not.toBe(second.nonce);
+ expect(first.encryptedKey).not.toBe(second.encryptedKey);
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts
new file mode 100644
index 00000000000..04c6a10cfc4
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts
@@ -0,0 +1,56 @@
+import { randomBytes, box } from 'tweetnacl';
+
+import { base64UrlToBytes, toBase64Url } from '../encoding.js';
+
+/**
+ * Wraps the `data_encryption_key` for the UKYC session server using NaCl's
+ * `crypto_box` (X25519 + XSalsa20-Poly1305) established with the server's
+ * per-session wrapping key.
+ *
+ * Unlike {@link wrapUserKey} (which generates a fresh ephemeral keypair per
+ * call), this uses the session client keypair whose public half was already
+ * handed to the server via `POST /wrapping-key`. The server therefore already
+ * knows our public key and can derive the same shared secret from its session
+ * private key, so only `{ encryptedKey, nonce }` need be transmitted.
+ */
+
+/**
+ * The transmitted portion of a wrapped encryption key: the `crypto_box`
+ * ciphertext (which includes the 16-byte Poly1305 auth tag) and the nonce,
+ * both unpadded base64url-encoded.
+ */
+export type WrappedEncryptionKeyParts = {
+ encryptedKey: string;
+ nonce: string;
+};
+
+/**
+ * Wraps `keyToWrap` for the UKYC session server.
+ *
+ * The box is sealed with NaCl's `crypto_box`, keyed by the X25519 shared secret
+ * between our session client private key and the session server public key
+ * returned by `getWrappingKey`.
+ *
+ * @param sessionClientPrivateKey - Our session's X25519 private key.
+ * @param sessionServerPublicKey - The server's X25519 public key (base64url).
+ * @param keyToWrap - The raw symmetric key bytes to encrypt.
+ * @returns The base64url `encryptedKey` (ciphertext + tag) and `nonce`.
+ */
+export function wrapEncryptionKey(
+ sessionClientPrivateKey: Uint8Array,
+ sessionServerPublicKey: string,
+ keyToWrap: Uint8Array,
+): WrappedEncryptionKeyParts {
+ const serverPublicKey = base64UrlToBytes(sessionServerPublicKey);
+ const nonce = randomBytes(box.nonceLength);
+ const encryptedKey = box(
+ keyToWrap,
+ nonce,
+ serverPublicKey,
+ sessionClientPrivateKey,
+ );
+ return {
+ encryptedKey: toBase64Url(encryptedKey),
+ nonce: toBase64Url(nonce),
+ };
+}
diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts
new file mode 100644
index 00000000000..3842792c254
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts
@@ -0,0 +1,114 @@
+import { areUint8ArraysEqual, base64ToBytes } from '@metamask/utils';
+import { gcm } from '@noble/ciphers/aes';
+import { x25519 } from '@noble/curves/ed25519';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+import { bytesToHex } from '@noble/hashes/utils';
+
+import { wrapUserKey } from './wrapUserKey.js';
+
+const X25519_KEY_SIZE_BYTES = 32;
+const IV_SIZE_BYTES = 12;
+
+const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7);
+
+/**
+ * Decodes the unpadded base64url blob produced by {@link wrapUserKey}.
+ *
+ * @param value - The base64url-encoded wrapped key.
+ * @returns The raw blob bytes.
+ */
+function fromBase64Url(value: string): Uint8Array {
+ return base64ToBytes(
+ value
+ .replace(/-/gu, '+')
+ .replace(/_/gu, '/')
+ .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='),
+ );
+}
+
+/**
+ * Reverses {@link wrapUserKey} with the recipient's private key.
+ *
+ * @param blob - The base64url wrapped key.
+ * @param recipientPrivateKey - The recipient's X25519 private key.
+ * @returns The recovered plaintext key bytes.
+ */
+function unwrapUserKey(
+ blob: string,
+ recipientPrivateKey: Uint8Array,
+): Uint8Array {
+ const bytes = fromBase64Url(blob);
+ const ephemeralPublicKey = bytes.slice(0, X25519_KEY_SIZE_BYTES);
+ const iv = bytes.slice(
+ X25519_KEY_SIZE_BYTES,
+ X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES,
+ );
+ const ciphertext = bytes.slice(X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES);
+
+ const shared = x25519.getSharedSecret(
+ recipientPrivateKey,
+ ephemeralPublicKey,
+ );
+ const aeadKey = hkdf(sha256, shared, undefined, undefined, 32);
+ return gcm(aeadKey, iv).decrypt(ciphertext);
+}
+
+describe('UKYC wrapUserKey', () => {
+ it('produces an unpadded base64url blob', () => {
+ const privateKey = x25519.utils.randomSecretKey();
+ const publicKey = x25519.getPublicKey(privateKey);
+
+ const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY);
+
+ expect(wrapped).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(wrapped).not.toContain('=');
+ });
+
+ it('wraps a key the recipient can recover (hex public key)', () => {
+ const privateKey = x25519.utils.randomSecretKey();
+ const publicKey = x25519.getPublicKey(privateKey);
+
+ const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY);
+ const recovered = unwrapUserKey(wrapped, privateKey);
+
+ expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true);
+ });
+
+ it('accepts a base64url-encoded public key', () => {
+ const privateKey = x25519.utils.randomSecretKey();
+ const publicKey = x25519.getPublicKey(privateKey);
+ const publicKeyBase64Url = Buffer.from(publicKey)
+ .toString('base64')
+ .replace(/\+/gu, '-')
+ .replace(/\//gu, '_')
+ .replace(/[=]+$/u, '');
+
+ const wrapped = wrapUserKey(publicKeyBase64Url, DATA_ENCRYPTION_KEY);
+ const recovered = unwrapUserKey(wrapped, privateKey);
+
+ expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true);
+ });
+
+ it('produces a fresh ephemeral key (non-deterministic output) per call', () => {
+ const privateKey = x25519.utils.randomSecretKey();
+ const publicKey = x25519.getPublicKey(privateKey);
+
+ const first = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY);
+ const second = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY);
+
+ expect(first).not.toStrictEqual(second);
+ expect(
+ areUint8ArraysEqual(
+ unwrapUserKey(first, privateKey),
+ unwrapUserKey(second, privateKey),
+ ),
+ ).toBe(true);
+ });
+
+ it('rejects a public key of the wrong length', () => {
+ expect(() => wrapUserKey('abcd', DATA_ENCRYPTION_KEY)).toThrow(
+ 'unexpected length',
+ );
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts
new file mode 100644
index 00000000000..f694d6310e8
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrapUserKey.ts
@@ -0,0 +1,101 @@
+import { base64ToBytes } from '@metamask/utils';
+import { gcm } from '@noble/ciphers/aes';
+import { x25519 } from '@noble/curves/ed25519';
+import { hkdf } from '@noble/hashes/hkdf';
+import { sha256 } from '@noble/hashes/sha2';
+import { hexToBytes, randomBytes } from '@noble/hashes/utils';
+
+import { toBase64Url } from '../encoding.js';
+
+/**
+ * Wraps (encrypts) a symmetric key so that only the holder of a given X25519
+ * private key can recover it — used to seal the `data_encryption_key` for the
+ * idOS Relay before it is handed to the UKYC API as `wrappedUserKey`.
+ */
+
+/** X25519 public/private keys and AES-256-GCM keys are all 32 bytes. */
+const X25519_KEY_SIZE_BYTES = 32;
+
+/** 96-bit IV, the AES-GCM standard nonce size. */
+const IV_SIZE_BYTES = 12;
+
+/**
+ * Decodes an X25519 public key presented as either hex or base64/base64url.
+ *
+ * @param recipientPublicKey - The recipient's 32-byte X25519 public key.
+ * @returns The raw 32-byte public key.
+ */
+function decodePublicKey(recipientPublicKey: string): Uint8Array {
+ const isHex =
+ recipientPublicKey.length === X25519_KEY_SIZE_BYTES * 2 &&
+ /^[0-9a-fA-F]+$/u.test(recipientPublicKey);
+
+ const bytes = isHex
+ ? hexToBytes(recipientPublicKey)
+ : base64ToBytes(
+ recipientPublicKey
+ .replace(/-/gu, '+')
+ .replace(/_/gu, '/')
+ .padEnd(
+ recipientPublicKey.length +
+ ((4 - (recipientPublicKey.length % 4)) % 4),
+ '=',
+ ),
+ );
+
+ if (bytes.length !== X25519_KEY_SIZE_BYTES) {
+ throw new Error(
+ `UKYC: wrappingPublicKey has unexpected length ${bytes.length}, expected ${X25519_KEY_SIZE_BYTES}.`,
+ );
+ }
+ return bytes;
+}
+
+/**
+ * Wraps `keyToWrap` for the holder of `recipientPublicKey` using an
+ * ephemeral-static ECDH + AES-256-GCM sealed-box scheme:
+ *
+ * ephemeral = fresh X25519 keypair (one per call)
+ * shared = X25519(ephemeralPrivate, recipientPublic)
+ * aeadKey = HKDF-SHA256(shared, 32 bytes)
+ * iv = 12 random bytes
+ * ct = AES-256-GCM(aeadKey, iv).encrypt(keyToWrap) // ct includes tag
+ *
+ * The recipient reverses it with their private key:
+ *
+ * shared = X25519(recipientPrivate, ephemeralPublic)
+ * aeadKey = HKDF-SHA256(shared, 32 bytes)
+ * key = AES-256-GCM(aeadKey, iv).decrypt(ct)
+ *
+ * This mirrors the X25519 + AES-256-GCM/HKDF decryption used for MoonPay
+ * Check/Auth-frame credentials, so both directions share one primitive.
+ *
+ * @param recipientPublicKey - The recipient's X25519 public key (hex or base64).
+ * @param keyToWrap - The raw symmetric key bytes to encrypt (e.g. the
+ * `data_encryption_key`).
+ * @returns Base64url of `ephemeralPublicKey(32) || iv(12) || ciphertext+tag`.
+ */
+export function wrapUserKey(
+ recipientPublicKey: string,
+ keyToWrap: Uint8Array,
+): string {
+ const recipient = decodePublicKey(recipientPublicKey);
+
+ const ephemeralPrivateKey = x25519.utils.randomSecretKey();
+ const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey);
+
+ const shared = x25519.getSharedSecret(ephemeralPrivateKey, recipient);
+ const aeadKey = hkdf(sha256, shared, undefined, undefined, 32);
+
+ const iv = randomBytes(IV_SIZE_BYTES);
+ const ciphertext = gcm(aeadKey, iv).encrypt(keyToWrap);
+
+ const blob = new Uint8Array(
+ ephemeralPublicKey.length + iv.length + ciphertext.length,
+ );
+ blob.set(ephemeralPublicKey, 0);
+ blob.set(iv, ephemeralPublicKey.length);
+ blob.set(ciphertext, ephemeralPublicKey.length + iv.length);
+
+ return toBase64Url(blob);
+}
diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts
new file mode 100644
index 00000000000..bfc422dd7fa
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts
@@ -0,0 +1,90 @@
+import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants.js';
+import { deriveClientMaterial } from './deriveClientMaterial.js';
+import type { UkycStorageAccessToken } from './storageAccessToken.js';
+import { signStorageAccessToken } from './storageAccessToken.js';
+import { buildWrappedRelayPayload } from './wrappedRelayPayload.js';
+
+const LOCAL_USER_SECRET = new Uint8Array(
+ UKYC_LOCAL_USER_SECRET_SIZE_BYTES,
+).fill(42);
+const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET);
+const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z');
+const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z');
+
+/**
+ * Mints a token with a given presenter/operations for the tests below.
+ *
+ * @param presenter - The token presenter.
+ * @param operations - The token operations.
+ * @returns The signed token.
+ */
+function tokenFor(
+ presenter: 'client' | 'idos-relay',
+ operations: ('read' | 'write' | 'delete')[],
+): UkycStorageAccessToken {
+ return signStorageAccessToken({
+ material: MATERIAL,
+ operations,
+ presenter,
+ sessionId: presenter === 'idos-relay' ? 'session-1' : undefined,
+ issuedAt: ISSUED_AT,
+ expiresAt: EXPIRES_AT,
+ });
+}
+
+describe('UKYC buildWrappedRelayPayload', () => {
+ it('bundles the Relay-facing material with the token', () => {
+ const token = tokenFor('idos-relay', ['read', 'write']);
+
+ const payload = buildWrappedRelayPayload(MATERIAL, token);
+
+ expect(payload.storage_id).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(payload.data_encryption_key).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(payload.signing_public_key).toMatch(/^[A-Za-z0-9_-]+$/u);
+ expect(payload.storage_access_token).toBe(token);
+ });
+
+ it('shares the data_encryption_key with the Relay', () => {
+ const token = tokenFor('idos-relay', ['read']);
+
+ const payload = buildWrappedRelayPayload(MATERIAL, token);
+
+ // The DEK is intentionally included so the Relay can encrypt/decrypt.
+ expect(payload.data_encryption_key.length).toBeGreaterThan(0);
+ });
+
+ it('never leaks the local secret or private signing key', () => {
+ const token = tokenFor('idos-relay', ['read']);
+
+ const payload = buildWrappedRelayPayload(MATERIAL, token);
+
+ expect(Object.keys(payload).sort()).toStrictEqual([
+ 'data_encryption_key',
+ 'signing_public_key',
+ 'storage_access_token',
+ 'storage_id',
+ ]);
+ });
+
+ it('rejects a client-presented token', () => {
+ const token = tokenFor('client', ['read']);
+
+ expect(() => buildWrappedRelayPayload(MATERIAL, token)).toThrow(
+ 'requires a Relay-presented storage_access_token',
+ );
+ });
+
+ it('rejects a delete-scoped token', () => {
+ // A delete token cannot be Relay-presented, so craft one that slips past
+ // signing by mutating the presenter after the fact.
+ const token = tokenFor('client', ['delete']);
+ const relayDeleteToken = {
+ ...token,
+ payload: { ...token.payload, presenter: 'idos-relay' as const },
+ };
+
+ expect(() => buildWrappedRelayPayload(MATERIAL, relayDeleteToken)).toThrow(
+ 'must not carry a delete-scoped storage_access_token',
+ );
+ });
+});
diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts
new file mode 100644
index 00000000000..ae28ec4ec2d
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts
@@ -0,0 +1,65 @@
+import type { UkycClientMaterial } from './deriveClientMaterial.js';
+import { toBase64Url } from '../encoding.js';
+import type { UkycStorageAccessToken } from './storageAccessToken.js';
+
+/**
+ * Builds the `wrapped_relay_payload` — the only client-derived material that
+ * leaves the device. It is sent to the idOS Relay (through the UKYC API) so the
+ * Relay can encrypt/decrypt and store KYC payloads on the user's behalf. See the
+ * architecture doc, section "Client-Derived Material".
+ *
+ * Neither `local_user_secret` nor the private `signing_key` are ever included.
+ * The `data_encryption_key` *is* included: it is intentionally shared with the
+ * Relay so it can encrypt/decrypt payloads transiently.
+ */
+
+/**
+ * The Relay-facing bundle. `data_encryption_key` is base64url-encoded secret
+ * material shared with the Relay; the other fields are non-secret.
+ */
+export type UkycWrappedRelayPayload = {
+ // Wire-shape fields are snake_case.
+ /* eslint-disable @typescript-eslint/naming-convention */
+ storage_id: string;
+ data_encryption_key: string;
+ signing_public_key: string;
+ storage_access_token: UkycStorageAccessToken;
+ /* eslint-enable @typescript-eslint/naming-convention */
+};
+
+/**
+ * Assembles the `wrapped_relay_payload` from derived client material and a
+ * Relay-presented `storage_access_token`.
+ *
+ * The token must be scoped for the Relay to present (`presenter: 'idos-relay'`)
+ * and must not authorize `delete`, which is never delegated to the Relay.
+ *
+ * @param material - Client material derived from `local_user_secret`.
+ * @param storageAccessToken - A `read`/`write`-scoped, Relay-presented token.
+ * @returns The `wrapped_relay_payload` to send to the Relay via the UKYC API.
+ */
+export function buildWrappedRelayPayload(
+ material: UkycClientMaterial,
+ storageAccessToken: UkycStorageAccessToken,
+): UkycWrappedRelayPayload {
+ const { presenter, operations } = storageAccessToken.payload;
+
+ if (presenter !== 'idos-relay') {
+ throw new Error(
+ 'UKYC: wrapped_relay_payload requires a Relay-presented storage_access_token.',
+ );
+ }
+
+ if (operations.includes('delete')) {
+ throw new Error(
+ 'UKYC: wrapped_relay_payload must not carry a delete-scoped storage_access_token.',
+ );
+ }
+
+ return {
+ storage_id: toBase64Url(material.storageId),
+ data_encryption_key: toBase64Url(material.dataEncryptionKey),
+ signing_public_key: toBase64Url(material.signingPublicKey),
+ storage_access_token: storageAccessToken,
+ };
+}
diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/kyc-controller/src/wallet-registration-machine.test.ts
new file mode 100644
index 00000000000..de58a81ef12
--- /dev/null
+++ b/packages/kyc-controller/src/wallet-registration-machine.test.ts
@@ -0,0 +1,297 @@
+import {
+ createInitialState,
+ transition,
+} from './wallet-registration-machine.js';
+import type {
+ WalletRegistrationEvent,
+ WalletRegistrationState,
+} from './wallet-registration-machine.js';
+
+const run = (
+ events: WalletRegistrationEvent[],
+ initial: WalletRegistrationState = createInitialState(),
+): WalletRegistrationState =>
+ events.reduce((state, event) => transition(state, event), initial);
+
+describe('wallet registration machine: lookup', () => {
+ it('starts idle', () => {
+ expect(createInitialState().status).toBe('idle');
+ });
+
+ it('start moves idle to preparing', () => {
+ expect(run([{ type: 'START' }]).status).toBe('preparing');
+ });
+
+ it('an active existing registration skips signing and completes', () => {
+ const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]);
+ expect(state.status).toBe('alreadyRegistered');
+ });
+
+ it('a disabled existing registration enters registeredDisabled', () => {
+ const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]);
+ expect(state.status).toBe('registeredDisabled');
+ });
+
+ it('an absent registration proceeds to signing', () => {
+ const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]);
+ expect(state.status).toBe('signing');
+ });
+
+ it('a failed lookup enters lookupUnavailable and never assumes absent', () => {
+ const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]);
+ expect(state.status).toBe('lookupUnavailable');
+ });
+});
+
+describe('wallet registration machine: signing', () => {
+ const atSigning = (): WalletRegistrationState =>
+ run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]);
+
+ it('a locked keyring during signing waits then resumes the same attempt', () => {
+ const locked = transition(atSigning(), { type: 'WALLET_LOCKED' });
+ expect(locked.status).toBe('awaitingUnlock');
+
+ const resumed = transition(locked, { type: 'WALLET_UNLOCKED' });
+ expect(resumed.status).toBe('signing');
+ });
+
+ it('successful signing moves to submitting', () => {
+ expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe(
+ 'submitting',
+ );
+ });
+
+ it('explicit user rejection reaches cancelled', () => {
+ expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe(
+ 'cancelled',
+ );
+ });
+
+ it('classifies signing failures as retryable or terminal', () => {
+ expect(
+ transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status,
+ ).toBe('failedRetryable');
+ expect(
+ transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status,
+ ).toBe('failedTerminal');
+ });
+
+ it('cancellation during signing aborts without failing', () => {
+ expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe(
+ 'cancelled',
+ );
+ });
+});
+
+describe('wallet registration machine: submitting outcomes', () => {
+ const atSubmitting = (): WalletRegistrationState =>
+ run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]);
+
+ it('200 reaches registered', () => {
+ expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe(
+ 'registered',
+ );
+ });
+
+ it('any 409 enters disambiguate409', () => {
+ expect(
+ transition(atSubmitting(), {
+ type: 'SUBMIT_CONFLICT',
+ }).status,
+ ).toBe('disambiguate409');
+ });
+
+ it('timeout / 5xx enters checkThenRetry', () => {
+ expect(
+ transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status,
+ ).toBe('checkThenRetry');
+ });
+
+ it('a UTC-rollover 400 rebuilds and re-signs once', () => {
+ expect(
+ transition(atSubmitting(), {
+ type: 'SUBMIT_VALIDATION',
+ utcRollover: true,
+ }).status,
+ ).toBe('signing');
+ });
+
+ it('a non-rollover 400 is terminal', () => {
+ expect(
+ transition(atSubmitting(), {
+ type: 'SUBMIT_VALIDATION',
+ utcRollover: false,
+ }).status,
+ ).toBe('failedTerminal');
+ });
+
+ it('401 / 403 / 404 are terminal', () => {
+ expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe(
+ 'failedTerminal',
+ );
+ });
+
+ it('429 becomes retryable', () => {
+ expect(
+ transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status,
+ ).toBe('failedRetryable');
+ });
+
+ it('cancellation during submitting aborts without failing', () => {
+ expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe(
+ 'cancelled',
+ );
+ });
+});
+
+describe('wallet registration machine: 409 disambiguation', () => {
+ const atDisambiguate = (): WalletRegistrationState =>
+ run([
+ { type: 'START' },
+ { type: 'LOOKUP_ABSENT' },
+ { type: 'SIGN_OK' },
+ { type: 'SUBMIT_CONFLICT' },
+ ]);
+
+ it('an active list match after 409 completes as alreadyRegistered', () => {
+ expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe(
+ 'alreadyRegistered',
+ );
+ });
+
+ it('a disabled list match after 409 enters registeredDisabled', () => {
+ expect(
+ transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status,
+ ).toBe('registeredDisabled');
+ });
+
+ it('a 409 plus GET miss is retryable', () => {
+ expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe(
+ 'failedRetryable',
+ );
+ });
+
+ it('a failed GET during disambiguation is lookupUnavailable', () => {
+ expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe(
+ 'lookupUnavailable',
+ );
+ });
+
+ it('cancellation during disambiguation does not become a failure', () => {
+ expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe(
+ 'cancelled',
+ );
+ });
+});
+
+describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => {
+ const atCheck = (
+ initial?: WalletRegistrationState,
+ ): WalletRegistrationState =>
+ run(
+ [
+ { type: 'START' },
+ { type: 'LOOKUP_ABSENT' },
+ { type: 'SIGN_OK' },
+ { type: 'SUBMIT_TRANSIENT' },
+ ],
+ initial,
+ );
+
+ it('a GET showing the resource completes without another POST', () => {
+ expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe(
+ 'alreadyRegistered',
+ );
+ });
+
+ it('a disabled GET result enters registeredDisabled', () => {
+ expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe(
+ 'registeredDisabled',
+ );
+ });
+
+ it('an absent GET result retries signing within the attempt ceiling', () => {
+ expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe(
+ 'signing',
+ );
+ });
+
+ it('a failed GET during reconciliation is lookupUnavailable', () => {
+ expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe(
+ 'lookupUnavailable',
+ );
+ });
+
+ it('stops retrying once the attempt ceiling is reached', () => {
+ let state = createInitialState();
+ state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state);
+ // Loop sign -> transient -> absent until the ceiling flips to retryable.
+ for (let i = 0; i < 5; i++) {
+ if (state.status === 'signing') {
+ state = transition(state, { type: 'SIGN_OK' });
+ state = transition(state, { type: 'SUBMIT_TRANSIENT' });
+ state = transition(state, { type: 'LOOKUP_ABSENT' });
+ }
+ }
+ expect(state.status).toBe('failedRetryable');
+ });
+
+ it('cancellation during checkThenRetry does not become a failure', () => {
+ expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled');
+ });
+});
+
+describe('wallet registration machine: retry, resume, and concurrency', () => {
+ it('retry from failedRetryable re-checks server state via preparing', () => {
+ const state = run([
+ { type: 'START' },
+ { type: 'LOOKUP_ABSENT' },
+ { type: 'SIGN_OK' },
+ { type: 'SUBMIT_RATE_LIMITED' },
+ { type: 'RETRY' },
+ ]);
+ expect(state.status).toBe('preparing');
+ });
+
+ it('retry from lookupUnavailable re-checks server state via preparing', () => {
+ const state = run([
+ { type: 'START' },
+ { type: 'LOOKUP_FAILED' },
+ { type: 'RETRY' },
+ ]);
+ expect(state.status).toBe('preparing');
+ });
+
+ it('retry from cancelled restarts via preparing', () => {
+ const state = run([
+ { type: 'START' },
+ { type: 'LOOKUP_ABSENT' },
+ { type: 'CANCEL' },
+ { type: 'RETRY' },
+ ]);
+ expect(state.status).toBe('preparing');
+ });
+
+ it('a second START while in-flight is ignored (one operation)', () => {
+ const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]);
+ expect(inFlight.status).toBe('signing');
+ expect(transition(inFlight, { type: 'START' }).status).toBe('signing');
+ });
+
+ it('ignores events that do not apply to the current state', () => {
+ const preparing = run([{ type: 'START' }]);
+ expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe(
+ 'preparing',
+ );
+ });
+
+ it('terminal success states ignore further events', () => {
+ const registered = run([
+ { type: 'START' },
+ { type: 'LOOKUP_ABSENT' },
+ { type: 'SIGN_OK' },
+ { type: 'SUBMIT_OK' },
+ ]);
+ expect(transition(registered, { type: 'RETRY' }).status).toBe('registered');
+ });
+});
diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/kyc-controller/src/wallet-registration-machine.ts
new file mode 100644
index 00000000000..3251c56d02e
--- /dev/null
+++ b/packages/kyc-controller/src/wallet-registration-machine.ts
@@ -0,0 +1,221 @@
+/**
+ * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted
+ * wallet registration signing step. It follows the FSM convention used
+ * elsewhere in `core` (no XState dependency): a single pure `transition`
+ * reducer plus a data-driven transition table.
+ *
+ * Side effects (server lookups, signing, POSTing) live in the interpreter that
+ * drives this machine; every effect result is fed back in as an event, so the
+ * machine itself stays deterministic and trivially testable.
+ */
+
+/** Every state in the signing step. */
+export type WalletRegistrationStatus =
+ | 'idle'
+ | 'preparing'
+ | 'awaitingUnlock'
+ | 'signing'
+ | 'submitting'
+ | 'disambiguate409'
+ | 'checkThenRetry'
+ | 'lookupUnavailable'
+ | 'registered'
+ | 'alreadyRegistered'
+ | 'registeredDisabled'
+ | 'failedRetryable'
+ | 'failedTerminal'
+ | 'cancelled';
+
+/** Machine context carried across transitions. */
+export type WalletRegistrationContext = {
+ /** Number of sign attempts made so far (used for the retry ceiling). */
+ attempts: number;
+ /** Maximum number of sign attempts before a retryable failure is surfaced. */
+ maxAttempts: number;
+};
+
+export type WalletRegistrationState = {
+ status: WalletRegistrationStatus;
+ context: WalletRegistrationContext;
+};
+
+/** Events the interpreter dispatches into the machine. */
+export type WalletRegistrationEvent =
+ | { type: 'START' }
+ | { type: 'WALLET_LOCKED' }
+ | { type: 'WALLET_UNLOCKED' }
+ | { type: 'LOOKUP_ACTIVE' }
+ | { type: 'LOOKUP_DISABLED' }
+ | { type: 'LOOKUP_ABSENT' }
+ | { type: 'LOOKUP_FAILED' }
+ | { type: 'SIGN_OK' }
+ | { type: 'SIGN_REJECTED' }
+ | { type: 'SIGN_FAILED'; retryable: boolean }
+ | { type: 'SUBMIT_OK' }
+ | { type: 'SUBMIT_CONFLICT' }
+ | { type: 'SUBMIT_TRANSIENT' }
+ | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean }
+ | { type: 'SUBMIT_TERMINAL' }
+ | { type: 'SUBMIT_RATE_LIMITED' }
+ | { type: 'RETRY' }
+ | { type: 'CANCEL' };
+
+type EventType = WalletRegistrationEvent['type'];
+
+type Handler = (
+ state: WalletRegistrationState,
+ event: WalletRegistrationEvent,
+) => WalletRegistrationState;
+
+const DEFAULT_MAX_ATTEMPTS = 3;
+
+/**
+ * Creates the initial idle state.
+ *
+ * @param maxAttempts - Optional retry ceiling for sign attempts.
+ * @returns A fresh idle machine state.
+ */
+export function createInitialState(
+ maxAttempts: number = DEFAULT_MAX_ATTEMPTS,
+): WalletRegistrationState {
+ return { status: 'idle', context: { attempts: 0, maxAttempts } };
+}
+
+/**
+ * Builds a handler that moves to a status while preserving context.
+ *
+ * @param status - Target status.
+ * @returns A handler transitioning to `status`.
+ */
+function keep(status: WalletRegistrationStatus): Handler {
+ return (state) => ({ status, context: state.context });
+}
+
+/**
+ * Builds a handler that moves to a status and resets the retry context. Used
+ * when the user (or app resume) starts a fresh attempt from scratch.
+ *
+ * @param status - Target status.
+ * @returns A handler transitioning to `status` with reset context.
+ */
+function reset(status: WalletRegistrationStatus): Handler {
+ return (state) => ({
+ status,
+ context: { ...state.context, attempts: 0 },
+ });
+}
+
+/**
+ * Moves to `signing` and counts this as a new sign attempt.
+ *
+ * @param state - Current state.
+ * @returns The `signing` state with an incremented attempt count.
+ */
+const toSigning: Handler = (state) => ({
+ status: 'signing',
+ context: { ...state.context, attempts: state.context.attempts + 1 },
+});
+
+const toPreparing = reset('preparing');
+const toAlreadyRegistered = keep('alreadyRegistered');
+const toRegisteredDisabled = keep('registeredDisabled');
+const toLookupUnavailable = keep('lookupUnavailable');
+const toCancelled = keep('cancelled');
+
+const signFailed: Handler = (state, event) => {
+ const { retryable } = event as Extract<
+ WalletRegistrationEvent,
+ { type: 'SIGN_FAILED' }
+ >;
+ return retryable
+ ? keep('failedRetryable')(state, event)
+ : keep('failedTerminal')(state, event);
+};
+
+const submitValidation: Handler = (state, event) => {
+ const { utcRollover } = event as Extract<
+ WalletRegistrationEvent,
+ { type: 'SUBMIT_VALIDATION' }
+ >;
+ return utcRollover && state.context.attempts < state.context.maxAttempts
+ ? toSigning(state, event)
+ : keep('failedTerminal')(state, event);
+};
+
+const checkThenRetryAbsent: Handler = (state, event) =>
+ state.context.attempts < state.context.maxAttempts
+ ? toSigning(state, event)
+ : keep('failedRetryable')(state, event);
+
+const TABLE: Partial<
+ Record>>
+> = {
+ idle: {
+ START: toPreparing,
+ },
+ preparing: {
+ LOOKUP_ACTIVE: toAlreadyRegistered,
+ LOOKUP_DISABLED: toRegisteredDisabled,
+ LOOKUP_ABSENT: toSigning,
+ LOOKUP_FAILED: toLookupUnavailable,
+ },
+ awaitingUnlock: {
+ WALLET_UNLOCKED: keep('signing'),
+ },
+ signing: {
+ SIGN_OK: keep('submitting'),
+ SIGN_REJECTED: toCancelled,
+ SIGN_FAILED: signFailed,
+ WALLET_LOCKED: keep('awaitingUnlock'),
+ CANCEL: toCancelled,
+ },
+ submitting: {
+ SUBMIT_OK: keep('registered'),
+ SUBMIT_CONFLICT: keep('disambiguate409'),
+ SUBMIT_TRANSIENT: keep('checkThenRetry'),
+ SUBMIT_VALIDATION: submitValidation,
+ SUBMIT_TERMINAL: keep('failedTerminal'),
+ SUBMIT_RATE_LIMITED: keep('failedRetryable'),
+ CANCEL: toCancelled,
+ },
+ disambiguate409: {
+ LOOKUP_ACTIVE: toAlreadyRegistered,
+ LOOKUP_DISABLED: toRegisteredDisabled,
+ LOOKUP_ABSENT: keep('failedRetryable'),
+ LOOKUP_FAILED: toLookupUnavailable,
+ CANCEL: toCancelled,
+ },
+ checkThenRetry: {
+ LOOKUP_ACTIVE: toAlreadyRegistered,
+ LOOKUP_DISABLED: toRegisteredDisabled,
+ LOOKUP_ABSENT: checkThenRetryAbsent,
+ LOOKUP_FAILED: toLookupUnavailable,
+ CANCEL: toCancelled,
+ },
+ failedRetryable: {
+ RETRY: toPreparing,
+ },
+ lookupUnavailable: {
+ RETRY: toPreparing,
+ },
+ cancelled: {
+ RETRY: toPreparing,
+ },
+};
+
+/**
+ * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is
+ * how the machine enforces "one in-flight operation" (a second `START` while
+ * busy is ignored) and how terminal states stay put.
+ *
+ * @param state - Current machine state.
+ * @param event - Event to apply.
+ * @returns The next state (or the same state for unhandled events).
+ */
+export function transition(
+ state: WalletRegistrationState,
+ event: WalletRegistrationEvent,
+): WalletRegistrationState {
+ const handler = TABLE[state.status]?.[event.type];
+ return handler ? handler(state, event) : state;
+}
diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts
new file mode 100644
index 00000000000..a8ee7540d23
--- /dev/null
+++ b/packages/kyc-controller/src/wallet-registration-service.test.ts
@@ -0,0 +1,530 @@
+import {
+ WalletRegistrationError,
+ WalletRegistrationService,
+} from './wallet-registration-service.js';
+
+const BASE_URL = 'https://proxy.metamask.test';
+const AUTH_TOKEN = 'session-jwt-abc';
+
+type FetchInit = {
+ method?: string;
+ headers: Record;
+ body?: string;
+};
+
+type HttpResponse = {
+ ok: boolean;
+ status: number;
+ json: () => Promise;
+ text: () => Promise;
+};
+
+type FetchLike = (
+ url: string,
+ init?: {
+ method?: string;
+ headers?: Record;
+ body?: string;
+ signal?: unknown;
+ },
+) => Promise;
+
+const jsonResponse = (status: number, body: unknown): HttpResponse => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async (): Promise => body,
+ text: async (): Promise => JSON.stringify(body),
+});
+
+const textResponse = (status: number, body: string): HttpResponse => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async (): Promise => JSON.parse(body),
+ text: async (): Promise => body,
+});
+
+const invalidJsonResponse = (status: number): HttpResponse => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async (): Promise => {
+ throw new Error('invalid json');
+ },
+ text: async (): Promise => 'not json',
+});
+
+const buildService = (fetchImpl: FetchLike): WalletRegistrationService =>
+ new WalletRegistrationService({
+ fetch: fetchImpl,
+ baseUrl: BASE_URL,
+ getAuthToken: async (): Promise => AUTH_TOKEN,
+ });
+
+const verifiedAddress = (
+ overrides: Record = {},
+): Record => ({
+ id: 'addr-1',
+ wallet_address: '0xAbC0000000000000000000000000000000000001',
+ blockchain: 'Monad',
+ address_type: 'SelfHosted',
+ disabled: false,
+ is_self: true,
+ proof_message: 'I am verifying ownership...',
+ proof_signature: '0xsig',
+ created_at: '2026-08-12T10:00:00Z',
+ ...overrides,
+});
+
+const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001';
+
+describe('WalletRegistrationService.getMoonpayCustomerId', () => {
+ it('returns the Iron customer id from the authenticated proxy lookup', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, { customerId: 'iron-customer-1' }),
+ );
+
+ expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe(
+ 'iron-customer-1',
+ );
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${BASE_URL}/vendors/moonpay/customer`,
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ authorization: `Bearer ${AUTH_TOKEN}`,
+ }),
+ }),
+ );
+ });
+
+ it('maps a failed customer lookup to a typed HTTP error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(404, { code: 'iron_error', message: 'not found' }),
+ );
+
+ await expect(
+ buildService(fetchMock).getMoonpayCustomerId(),
+ ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 });
+ });
+
+ it('rejects malformed customer lookup responses', async () => {
+ await expect(
+ buildService(
+ jest.fn(async (): Promise => invalidJsonResponse(200)),
+ ).getMoonpayCustomerId(),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+
+ await expect(
+ buildService(
+ jest.fn(async (): Promise => jsonResponse(200, {})),
+ ).getMoonpayCustomerId(),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+ });
+});
+
+describe('WalletRegistrationService.getRegistrationStatus', () => {
+ it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => jsonResponse(200, []),
+ );
+ const service = buildService(fetchMock);
+
+ await service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit];
+ expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`);
+ expect(url).not.toContain('iron.xyz');
+ expect(init.method).toBe('GET');
+ expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`);
+ });
+
+ it('returns an active match parsed from wallet_address', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => jsonResponse(200, [verifiedAddress()]),
+ );
+ const service = buildService(fetchMock);
+
+ const status = await service.getRegistrationStatus({
+ address: '0xabc0000000000000000000000000000000000001',
+ blockchain: 'Monad',
+ });
+
+ expect(status).toMatchObject({
+ type: 'active',
+ registration: { address: EVM_ADDRESS, disabled: false },
+ });
+ });
+
+ it('returns a disabled result when the matching address is disabled', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, [verifiedAddress({ disabled: true })]),
+ );
+ const service = buildService(fetchMock);
+
+ const status = await service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ });
+
+ expect(status.type).toBe('disabled');
+ });
+
+ it('scopes matching per blockchain (same address, different chain is absent)', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, [verifiedAddress({ blockchain: 'Ethereum' })]),
+ );
+ const service = buildService(fetchMock);
+
+ const status = await service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ });
+
+ expect(status.type).toBe('absent');
+ });
+
+ it('skips entries whose wallet_address is not a string', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, [
+ { id: 'junk', wallet_address: 12345, blockchain: 'Monad' },
+ verifiedAddress(),
+ ]),
+ );
+ const service = buildService(fetchMock);
+
+ const status = await service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ });
+
+ expect(status.type).toBe('active');
+ });
+
+ it('throws malformedResponse when the list body is not valid JSON', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => invalidJsonResponse(200),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+ });
+
+ it('throws a lookupUnavailable error on a non-2xx list response', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => textResponse(500, 'boom'),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toMatchObject({ kind: 'lookupUnavailable' });
+ });
+
+ it('throws a lookupUnavailable error when the list body is malformed', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => jsonResponse(200, { nope: true }),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toBeInstanceOf(WalletRegistrationError);
+ });
+
+ it('never converts a network failure during lookup into "absent"', async () => {
+ const fetchMock = jest
+ .fn, unknown[]>()
+ .mockRejectedValue(new Error('network down'));
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toMatchObject({ kind: 'lookupUnavailable' });
+ });
+
+ it('handles a non-Error thrown during lookup', async () => {
+ const fetchMock = jest
+ .fn, unknown[]>()
+ .mockRejectedValue('string failure');
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.getRegistrationStatus({
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toMatchObject({ kind: 'lookupUnavailable' });
+ });
+});
+
+const registerRequest = {
+ customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7',
+ address: EVM_ADDRESS,
+ blockchain: 'Monad' as const,
+ message: 'I am verifying ownership ...',
+ signature: '0xdeadbeef',
+};
+
+const selfHostedResponse = (
+ overrides: Record = {},
+): Record => ({
+ id: 'wallet-1',
+ address: EVM_ADDRESS,
+ customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7',
+ disabled: false,
+ signature: '0xdeadbeef',
+ created_at: '2026-08-12T10:00:00Z',
+ ...overrides,
+});
+
+const errorEnvelope = (status: number, message: string): HttpResponse =>
+ jsonResponse(status, {
+ code: 'iron_error',
+ message,
+ });
+
+describe('WalletRegistrationService.registerSelfHostedWallet', () => {
+ it('sends the five contract fields via POST and returns registered on 200', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, selfHostedResponse()),
+ );
+ const service = buildService(fetchMock);
+
+ const outcome = await service.registerSelfHostedWallet(registerRequest);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit];
+ expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`);
+ expect(url).not.toContain('iron.xyz');
+ expect(init.method).toBe('POST');
+ expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`);
+ expect(JSON.parse(init.body ?? '{}')).toStrictEqual({
+ customer_id: registerRequest.customerId,
+ address: registerRequest.address,
+ blockchain: 'Monad',
+ message: registerRequest.message,
+ signature: registerRequest.signature,
+ });
+ expect(outcome.registration).toMatchObject({
+ id: 'wallet-1',
+ address: registerRequest.address,
+ disabled: false,
+ });
+ });
+
+ it('does not send an idempotency key (the backend derives it)', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, selfHostedResponse()),
+ );
+ const service = buildService(fetchMock);
+
+ await service.registerSelfHostedWallet(registerRequest);
+
+ const [, init] = fetchMock.mock.calls[0] as [string, FetchInit];
+ const headerKeys = Object.keys(init.headers).map((key) =>
+ key.toLowerCase(),
+ );
+ expect(headerKeys).not.toContain('idempotency-key');
+ expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey');
+ });
+
+ it('maps any 409 to an ambiguous conflict error carrying the body', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ errorEnvelope(
+ 409,
+ 'A crypto address with this wallet address already exists',
+ ),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({
+ kind: 'conflict',
+ httpStatus: 409,
+ body: 'A crypto address with this wallet address already exists',
+ });
+ });
+
+ it('maps 5xx to a transient error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => errorEnvelope(500, 'internal error'),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'transient', httpStatus: 500 });
+ });
+
+ it('maps a network failure / timeout to a transient error', async () => {
+ const fetchMock = jest
+ .fn, unknown[]>()
+ .mockRejectedValue(new Error('ETIMEDOUT'));
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'transient' });
+ });
+
+ it('maps a non-Error thrown during registration to transient', async () => {
+ const fetchMock = jest
+ .fn, unknown[]>()
+ .mockRejectedValue('socket hang up');
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'transient' });
+ });
+
+ it('maps 400 to a validation error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => errorEnvelope(400, 'bad message'),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'validation', httpStatus: 400 });
+ });
+
+ it('maps an unmapped 4xx (422) to a validation error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => errorEnvelope(422, 'unprocessable'),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'validation', httpStatus: 422 });
+ });
+
+ it('maps 401 to unauthorized', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => errorEnvelope(401, 'session expired'),
+ );
+
+ await expect(
+ buildService(fetchMock).registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'unauthorized' });
+ });
+
+ it('maps 403 to forbidden and 404 to notFound', async () => {
+ const forbiddenFetch = jest.fn(
+ async (): Promise => errorEnvelope(403, 'suspended'),
+ );
+ const notFoundFetch = jest.fn(
+ async (): Promise => errorEnvelope(404, 'not found'),
+ );
+
+ const forbidden = await buildService(forbiddenFetch)
+ .registerSelfHostedWallet(registerRequest)
+ .catch((error: unknown): WalletRegistrationError => {
+ return error as WalletRegistrationError;
+ });
+ const notFound = await buildService(notFoundFetch)
+ .registerSelfHostedWallet(registerRequest)
+ .catch((error: unknown): WalletRegistrationError => {
+ return error as WalletRegistrationError;
+ });
+
+ expect(forbidden).toMatchObject({ kind: 'forbidden' });
+ expect(notFound).toMatchObject({ kind: 'notFound' });
+ });
+
+ it('maps an error envelope without a code', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(403, { message: 'forbidden' }),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'forbidden' });
+ });
+
+ it('maps 429 to a rateLimited error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => errorEnvelope(429, 'slow down'),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'rateLimited' });
+ });
+
+ it('maps a malformed 200 body to malformedResponse', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => jsonResponse(200, { nope: true }),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+ });
+
+ it('rejects a success body that has an id but no address', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, { id: 'wallet-1', disabled: false }),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+ });
+
+ it('maps a non-JSON success body to malformedResponse', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => invalidJsonResponse(200),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+ });
+
+ it('maps a non-JSON error body to malformedResponse', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => invalidJsonResponse(400),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'malformedResponse' });
+ });
+});
diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts
new file mode 100644
index 00000000000..08d118d3b8c
--- /dev/null
+++ b/packages/kyc-controller/src/wallet-registration-service.ts
@@ -0,0 +1,372 @@
+/** The only blockchain supported by the Money Account POC. */
+export type Blockchain = 'Monad';
+
+/** Normalized view of a single registered self-hosted address. */
+export type SelfHostedRegistration = {
+ id: string;
+ address: string;
+ blockchain: Blockchain;
+ disabled: boolean;
+ isSelf: boolean;
+};
+
+/** Result of reconciling a wallet against the customer's registered addresses. */
+export type RegistrationStatus =
+ | { type: 'active'; registration: SelfHostedRegistration }
+ | { type: 'disabled'; registration: SelfHostedRegistration }
+ | { type: 'absent' };
+
+/**
+ * Discriminated error kinds surfaced to the state machine. Every non-success
+ * path maps to exactly one of these so the machine can decide deterministically.
+ */
+export type WalletRegistrationErrorKind =
+ | 'validation'
+ | 'unauthorized'
+ | 'forbidden'
+ | 'notFound'
+ | 'conflict'
+ | 'rateLimited'
+ | 'transient'
+ | 'lookupUnavailable'
+ | 'malformedResponse';
+
+/** Minimal HTTP response shape, so the service is environment-agnostic. */
+type HttpResponse = {
+ ok: boolean;
+ status: number;
+ json: () => Promise;
+ text: () => Promise;
+};
+
+/** Minimal `fetch`-like function the service depends on. */
+type FetchLike = (
+ url: string,
+ init?: {
+ method?: string;
+ headers?: Record;
+ body?: string;
+ },
+) => Promise;
+
+/** Typed error carrying enough context for state transitions. */
+export class WalletRegistrationError extends Error {
+ readonly kind: WalletRegistrationErrorKind;
+
+ readonly httpStatus?: number;
+
+ readonly body?: string;
+
+ constructor(
+ kind: WalletRegistrationErrorKind,
+ options: {
+ message?: string;
+ httpStatus?: number;
+ body?: string;
+ },
+ ) {
+ super(options.message ?? `wallet registration failed: ${kind}`);
+ this.name = 'WalletRegistrationError';
+ this.kind = kind;
+ this.httpStatus = options.httpStatus;
+ this.body = options.body;
+ }
+}
+
+export type WalletRegistrationServiceOptions = {
+ fetch: FetchLike;
+ baseUrl: string;
+ getAuthToken: () => Promise;
+};
+
+export type GetRegistrationStatusRequest = {
+ address: string;
+ blockchain: Blockchain;
+};
+
+export type RegisterSelfHostedWalletRequest = {
+ customerId: string;
+ address: string;
+ blockchain: Blockchain;
+ message: string;
+ signature: string;
+};
+
+/** Successful registration outcome. */
+export type RegistrationOutcome = {
+ type: 'registered';
+ registration: SelfHostedRegistration;
+};
+
+const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets';
+const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer';
+
+/**
+ * Normalizes a Monad EVM address for case-insensitive comparison.
+ *
+ * @param address - Raw address string.
+ * @returns The comparison key for the address.
+ */
+function normalizeAddress(address: string): string {
+ return address.toLowerCase();
+}
+
+/**
+ * Maps an HTTP status to the typed error kind the state machine reacts to.
+ *
+ * @param status - HTTP status code from the proxy/Iron response.
+ * @returns The corresponding error kind.
+ */
+function mapStatusToKind(status: number): WalletRegistrationErrorKind {
+ switch (status) {
+ case 400:
+ return 'validation';
+ case 401:
+ return 'unauthorized';
+ case 403:
+ return 'forbidden';
+ case 404:
+ return 'notFound';
+ case 409:
+ return 'conflict';
+ case 429:
+ return 'rateLimited';
+ default:
+ return status >= 500 ? 'transient' : 'validation';
+ }
+}
+
+/**
+ * Data service that talks to the MetaMask backend proxy for MoonPay Iron
+ * self-hosted wallet registration. It never calls Iron directly, so the Iron
+ * API key never ships in the client.
+ */
+export class WalletRegistrationService {
+ readonly #fetch: FetchLike;
+
+ readonly #baseUrl: string;
+
+ readonly #getAuthToken: () => Promise;
+
+ constructor(options: WalletRegistrationServiceOptions) {
+ this.#fetch = options.fetch;
+ this.#baseUrl = options.baseUrl.replace(/\/$/u, '');
+ this.#getAuthToken = options.getAuthToken;
+ }
+
+ /**
+ * Resolves Iron's internal customer id from the authenticated MetaMask
+ * profile. Used when the current KYC flow has not already received
+ * `customer.id` from MoonPay's hosted frame.
+ *
+ * @returns Iron's internal customer id.
+ */
+ async getMoonpayCustomerId(): Promise {
+ const token = await this.#getAuthToken();
+ const response = await this.#fetch(
+ `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`,
+ {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ },
+ );
+
+ if (!response.ok) {
+ throw await this.#toHttpError(response);
+ }
+
+ let payload: unknown;
+ try {
+ payload = await response.json();
+ } catch {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'MoonPay customer body was not valid JSON',
+ });
+ }
+
+ const { customerId } = payload as { customerId?: unknown };
+ if (typeof customerId !== 'string' || customerId.length === 0) {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'MoonPay customer body missing customerId',
+ });
+ }
+ return customerId;
+ }
+
+ /**
+ * Reconciles a wallet against the customer's registered self-hosted addresses.
+ * A failed or malformed lookup is reported as `lookupUnavailable` and never
+ * downgraded to `absent`.
+ *
+ * @param request - Monad address to reconcile.
+ * @returns The active / disabled / absent status for the address.
+ */
+ async getRegistrationStatus(
+ request: GetRegistrationStatusRequest,
+ ): Promise {
+ const { address, blockchain } = request;
+
+ let response: HttpResponse;
+ try {
+ const token = await this.#getAuthToken();
+ response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, {
+ method: 'GET',
+ headers: {
+ accept: 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ });
+ } catch (error) {
+ throw new WalletRegistrationError('lookupUnavailable', {
+ message: 'self-hosted address lookup failed',
+ body: error instanceof Error ? error.message : undefined,
+ });
+ }
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new WalletRegistrationError('lookupUnavailable', {
+ httpStatus: response.status,
+ body,
+ });
+ }
+
+ let payload: unknown;
+ try {
+ payload = await response.json();
+ } catch {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'self-hosted address list body was not valid JSON',
+ });
+ }
+ if (!Array.isArray(payload)) {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'expected an array of registered addresses',
+ });
+ }
+
+ const target = normalizeAddress(address);
+ const match = payload.find((entry) => {
+ const record = entry as Record;
+ const walletAddress = record.wallet_address;
+ if (typeof walletAddress !== 'string') {
+ return false;
+ }
+ return (
+ normalizeAddress(walletAddress) === target &&
+ record.blockchain === blockchain
+ );
+ }) as Record | undefined;
+
+ if (!match) {
+ return { type: 'absent' };
+ }
+
+ const registration = this.#toRegistration(match);
+ return registration.disabled
+ ? { type: 'disabled', registration }
+ : { type: 'active', registration };
+ }
+
+ /**
+ * Registers a self-hosted wallet through the MetaMask proxy. The proxy
+ * resolves the customer, derives the idempotency key, and attaches the API
+ * version, so the client never manages those. Every non-2xx response is
+ * mapped to a typed error; `409` is deliberately surfaced as an ambiguous
+ * `conflict` that the caller must reconcile with a follow-up status lookup.
+ *
+ * @param request - Customer id, address, blockchain, message, and signature.
+ * @returns The registered outcome on success.
+ */
+ async registerSelfHostedWallet(
+ request: RegisterSelfHostedWalletRequest,
+ ): Promise {
+ let response: HttpResponse;
+ try {
+ const token = await this.#getAuthToken();
+ response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, {
+ method: 'POST',
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ customer_id: request.customerId,
+ address: request.address,
+ blockchain: request.blockchain,
+ message: request.message,
+ signature: request.signature,
+ }),
+ });
+ } catch (error) {
+ throw new WalletRegistrationError('transient', {
+ message: 'self-hosted registration request failed',
+ body: error instanceof Error ? error.message : undefined,
+ });
+ }
+
+ if (!response.ok) {
+ throw await this.#toHttpError(response);
+ }
+
+ let payload: unknown;
+ try {
+ payload = await response.json();
+ } catch {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'registration success body was not valid JSON',
+ });
+ }
+
+ const record = payload as Record;
+ if (typeof record.id !== 'string' || typeof record.address !== 'string') {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'registration success body missing id/address',
+ });
+ }
+
+ return {
+ type: 'registered',
+ registration: {
+ id: record.id,
+ address: record.address,
+ blockchain: request.blockchain,
+ disabled: Boolean(record.disabled),
+ isSelf: true,
+ },
+ };
+ }
+
+ async #toHttpError(response: HttpResponse): Promise {
+ let envelope: { message?: string };
+ try {
+ envelope = (await response.json()) as { message?: string };
+ } catch {
+ return new WalletRegistrationError('malformedResponse', {
+ httpStatus: response.status,
+ message: 'error body was not valid JSON',
+ });
+ }
+
+ const { status } = response;
+ const kind = mapStatusToKind(status);
+ return new WalletRegistrationError(kind, {
+ httpStatus: status,
+ body: envelope.message,
+ });
+ }
+
+ #toRegistration(record: Record): SelfHostedRegistration {
+ return {
+ id: String(record.id),
+ address: String(record.wallet_address),
+ blockchain: 'Monad',
+ disabled: Boolean(record.disabled),
+ isSelf: Boolean(record.is_self),
+ };
+ }
+}
diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json
index 02a0eea03fe..d355169e16c 100644
--- a/packages/kyc-controller/tsconfig.build.json
+++ b/packages/kyc-controller/tsconfig.build.json
@@ -5,6 +5,13 @@
"outDir": "./dist",
"rootDir": "./src"
},
- "references": [],
+ "references": [
+ { "path": "../base-controller/tsconfig.build.json" },
+ { "path": "../base-data-service/tsconfig.build.json" },
+ { "path": "../controller-utils/tsconfig.build.json" },
+ { "path": "../geolocation-controller/tsconfig.build.json" },
+ { "path": "../messenger/tsconfig.build.json" },
+ { "path": "../profile-sync-controller/tsconfig.build.json" }
+ ],
"include": ["../../types", "./src"]
}
diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json
index 025ba2ef7f4..1079229158f 100644
--- a/packages/kyc-controller/tsconfig.json
+++ b/packages/kyc-controller/tsconfig.json
@@ -3,6 +3,13 @@
"compilerOptions": {
"baseUrl": "./"
},
- "references": [],
- "include": ["../../types", "./src"]
+ "references": [
+ { "path": "../base-controller" },
+ { "path": "../base-data-service" },
+ { "path": "../controller-utils" },
+ { "path": "../geolocation-controller" },
+ { "path": "../messenger" },
+ { "path": "../profile-sync-controller" }
+ ],
+ "include": ["../../types", "./src", "./scripts"]
}
diff --git a/yarn.lock b/yarn.lock
index c6b4cce7c4c..cc94a5d4955 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7684,12 +7684,30 @@ __metadata:
resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller"
dependencies:
"@metamask/auto-changelog": "npm:^6.1.0"
+ "@metamask/base-controller": "npm:^9.1.0"
+ "@metamask/base-data-service": "npm:^0.1.3"
+ "@metamask/controller-utils": "npm:^12.3.0"
+ "@metamask/geolocation-controller": "npm:^1.0.0"
+ "@metamask/keyring-controller": "npm:^27.1.1"
+ "@metamask/messenger": "npm:^2.0.0"
+ "@metamask/profile-sync-controller": "npm:^29.0.0"
+ "@metamask/superstruct": "npm:^3.4.1"
+ "@metamask/utils": "npm:^11.11.0"
+ "@noble/ciphers": "npm:^1.3.0"
+ "@noble/curves": "npm:^1.9.2"
+ "@noble/hashes": "npm:^1.8.0"
+ "@scure/base": "npm:^1.2.6"
+ "@tanstack/query-core": "npm:^4.43.0"
"@ts-bridge/cli": "npm:^0.6.4"
"@types/jest": "npm:^30.0.0"
+ chokidar-cli: "npm:^3.0.0"
deepmerge: "npm:^4.2.2"
jest: "npm:^30.4.2"
+ nock: "npm:^13.3.1"
+ reselect: "npm:^5.1.1"
ts-jest: "npm:^29.4.11"
tsx: "npm:^4.20.5"
+ tweetnacl: "npm:^1.0.3"
typedoc: "npm:^0.25.13"
typedoc-plugin-missing-exports: "npm:^2.0.0"
typescript: "npm:~5.3.3"
@@ -11014,7 +11032,7 @@ __metadata:
languageName: node
linkType: hard
-"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5":
+"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5":
version: 1.2.6
resolution: "@scure/base@npm:1.2.6"
checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187
@@ -13739,6 +13757,13 @@ __metadata:
languageName: node
linkType: hard
+"ansi-regex@npm:^4.1.0":
+ version: 4.1.1
+ resolution: "ansi-regex@npm:4.1.1"
+ checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888
+ languageName: node
+ linkType: hard
+
"ansi-regex@npm:^5.0.1":
version: 5.0.1
resolution: "ansi-regex@npm:5.0.1"
@@ -13760,6 +13785,15 @@ __metadata:
languageName: node
linkType: hard
+"ansi-styles@npm:^3.2.0":
+ version: 3.2.1
+ resolution: "ansi-styles@npm:3.2.1"
+ dependencies:
+ color-convert: "npm:^1.9.0"
+ checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665
+ languageName: node
+ linkType: hard
+
"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0":
version: 4.3.0
resolution: "ansi-styles@npm:4.3.0"
@@ -14727,7 +14761,7 @@ __metadata:
languageName: node
linkType: hard
-"camelcase@npm:^5.3.1":
+"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1":
version: 5.3.1
resolution: "camelcase@npm:5.3.1"
checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b
@@ -14881,7 +14915,21 @@ __metadata:
languageName: node
linkType: hard
-"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0":
+"chokidar-cli@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "chokidar-cli@npm:3.0.0"
+ dependencies:
+ chokidar: "npm:^3.5.2"
+ lodash.debounce: "npm:^4.0.8"
+ lodash.throttle: "npm:^4.1.1"
+ yargs: "npm:^13.3.0"
+ bin:
+ chokidar: index.js
+ checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9
+ languageName: node
+ linkType: hard
+
+"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
dependencies:
@@ -15069,6 +15117,17 @@ __metadata:
languageName: node
linkType: hard
+"cliui@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "cliui@npm:5.0.0"
+ dependencies:
+ string-width: "npm:^3.1.0"
+ strip-ansi: "npm:^5.2.0"
+ wrap-ansi: "npm:^5.1.0"
+ checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc
+ languageName: node
+ linkType: hard
+
"cliui@npm:^8.0.1":
version: 8.0.1
resolution: "cliui@npm:8.0.1"
@@ -15165,6 +15224,15 @@ __metadata:
languageName: node
linkType: hard
+"color-convert@npm:^1.9.0":
+ version: 1.9.3
+ resolution: "color-convert@npm:1.9.3"
+ dependencies:
+ color-name: "npm:1.1.3"
+ checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10
+ languageName: node
+ linkType: hard
+
"color-convert@npm:^2.0.1":
version: 2.0.1
resolution: "color-convert@npm:2.0.1"
@@ -15174,6 +15242,13 @@ __metadata:
languageName: node
linkType: hard
+"color-name@npm:1.1.3":
+ version: 1.1.3
+ resolution: "color-name@npm:1.1.3"
+ checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d
+ languageName: node
+ linkType: hard
+
"color-name@npm:~1.1.4":
version: 1.1.4
resolution: "color-name@npm:1.1.4"
@@ -15931,6 +16006,13 @@ __metadata:
languageName: node
linkType: hard
+"decamelize@npm:^1.2.0":
+ version: 1.2.0
+ resolution: "decamelize@npm:1.2.0"
+ checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa
+ languageName: node
+ linkType: hard
+
"decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
@@ -16367,6 +16449,13 @@ __metadata:
languageName: node
linkType: hard
+"emoji-regex@npm:^7.0.1":
+ version: 7.0.3
+ resolution: "emoji-regex@npm:7.0.3"
+ checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e
+ languageName: node
+ linkType: hard
+
"emoji-regex@npm:^8.0.0":
version: 8.0.0
resolution: "emoji-regex@npm:8.0.0"
@@ -17726,6 +17815,15 @@ __metadata:
languageName: node
linkType: hard
+"find-up@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "find-up@npm:3.0.0"
+ dependencies:
+ locate-path: "npm:^3.0.0"
+ checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9
+ languageName: node
+ linkType: hard
+
"find-up@npm:^4.0.0, find-up@npm:^4.1.0":
version: 4.1.0
resolution: "find-up@npm:4.1.0"
@@ -17996,7 +18094,7 @@ __metadata:
languageName: node
linkType: hard
-"get-caller-file@npm:^2.0.5":
+"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5":
version: 2.0.5
resolution: "get-caller-file@npm:2.0.5"
checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9
@@ -19303,6 +19401,13 @@ __metadata:
languageName: node
linkType: hard
+"is-fullwidth-code-point@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "is-fullwidth-code-point@npm:2.0.0"
+ checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8
+ languageName: node
+ linkType: hard
+
"is-fullwidth-code-point@npm:^3.0.0":
version: 3.0.0
resolution: "is-fullwidth-code-point@npm:3.0.0"
@@ -20568,6 +20673,16 @@ __metadata:
languageName: node
linkType: hard
+"locate-path@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "locate-path@npm:3.0.0"
+ dependencies:
+ p-locate: "npm:^3.0.0"
+ path-exists: "npm:^3.0.0"
+ checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11
+ languageName: node
+ linkType: hard
+
"locate-path@npm:^5.0.0":
version: 5.0.0
resolution: "locate-path@npm:5.0.0"
@@ -20644,6 +20759,13 @@ __metadata:
languageName: node
linkType: hard
+"lodash.throttle@npm:^4.1.1":
+ version: 4.1.1
+ resolution: "lodash.throttle@npm:4.1.1"
+ checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1
+ languageName: node
+ linkType: hard
+
"lodash.uniq@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.uniq@npm:4.5.0"
@@ -22793,7 +22915,7 @@ __metadata:
languageName: node
linkType: hard
-"p-limit@npm:^2.2.0":
+"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0":
version: 2.3.0
resolution: "p-limit@npm:2.3.0"
dependencies:
@@ -22820,6 +22942,15 @@ __metadata:
languageName: node
linkType: hard
+"p-locate@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "p-locate@npm:3.0.0"
+ dependencies:
+ p-limit: "npm:^2.0.0"
+ checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae
+ languageName: node
+ linkType: hard
+
"p-locate@npm:^4.1.0":
version: 4.1.0
resolution: "p-locate@npm:4.1.0"
@@ -23066,6 +23197,13 @@ __metadata:
languageName: node
linkType: hard
+"path-exists@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "path-exists@npm:3.0.0"
+ checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a
+ languageName: node
+ linkType: hard
+
"path-exists@npm:^4.0.0":
version: 4.0.0
resolution: "path-exists@npm:4.0.0"
@@ -25023,6 +25161,13 @@ __metadata:
languageName: node
linkType: hard
+"require-main-filename@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "require-main-filename@npm:2.0.0"
+ checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd
+ languageName: node
+ linkType: hard
+
"requires-port@npm:^1.0.0":
version: 1.0.0
resolution: "requires-port@npm:1.0.0"
@@ -25523,6 +25668,13 @@ __metadata:
languageName: node
linkType: hard
+"set-blocking@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "set-blocking@npm:2.0.0"
+ checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef
+ languageName: node
+ linkType: hard
+
"set-function-length@npm:^1.2.2":
version: 1.2.2
resolution: "set-function-length@npm:1.2.2"
@@ -26119,6 +26271,17 @@ __metadata:
languageName: node
linkType: hard
+"string-width@npm:^3.0.0, string-width@npm:^3.1.0":
+ version: 3.1.0
+ resolution: "string-width@npm:3.1.0"
+ dependencies:
+ emoji-regex: "npm:^7.0.1"
+ is-fullwidth-code-point: "npm:^2.0.0"
+ strip-ansi: "npm:^5.1.0"
+ checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2
+ languageName: node
+ linkType: hard
+
"string-width@npm:^5.0.1, string-width@npm:^5.1.2":
version: 5.1.2
resolution: "string-width@npm:5.1.2"
@@ -26178,6 +26341,15 @@ __metadata:
languageName: node
linkType: hard
+"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0":
+ version: 5.2.0
+ resolution: "strip-ansi@npm:5.2.0"
+ dependencies:
+ ansi-regex: "npm:^4.1.0"
+ checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46
+ languageName: node
+ linkType: hard
+
"strip-ansi@npm:^7.0.1":
version: 7.1.0
resolution: "strip-ansi@npm:7.1.0"
@@ -27865,6 +28037,13 @@ __metadata:
languageName: node
linkType: hard
+"which-module@npm:^2.0.0":
+ version: 2.0.1
+ resolution: "which-module@npm:2.0.1"
+ checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be
+ languageName: node
+ linkType: hard
+
"which@npm:^1.2.10":
version: 1.3.1
resolution: "which@npm:1.3.1"
@@ -27959,6 +28138,17 @@ __metadata:
languageName: node
linkType: hard
+"wrap-ansi@npm:^5.1.0":
+ version: 5.1.0
+ resolution: "wrap-ansi@npm:5.1.0"
+ dependencies:
+ ansi-styles: "npm:^3.2.0"
+ string-width: "npm:^3.0.0"
+ strip-ansi: "npm:^5.0.0"
+ checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914
+ languageName: node
+ linkType: hard
+
"wrap-ansi@npm:^6.2.0":
version: 6.2.0
resolution: "wrap-ansi@npm:6.2.0"
@@ -28132,6 +28322,13 @@ __metadata:
languageName: node
linkType: hard
+"y18n@npm:^4.0.0":
+ version: 4.0.3
+ resolution: "y18n@npm:4.0.3"
+ checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f
+ languageName: node
+ linkType: hard
+
"y18n@npm:^5.0.5":
version: 5.0.8
resolution: "y18n@npm:5.0.8"
@@ -28169,6 +28366,16 @@ __metadata:
languageName: node
linkType: hard
+"yargs-parser@npm:^13.1.2":
+ version: 13.1.2
+ resolution: "yargs-parser@npm:13.1.2"
+ dependencies:
+ camelcase: "npm:^5.0.0"
+ decamelize: "npm:^1.2.0"
+ checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8
+ languageName: node
+ linkType: hard
+
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"
@@ -28191,6 +28398,24 @@ __metadata:
languageName: node
linkType: hard
+"yargs@npm:^13.3.0":
+ version: 13.3.2
+ resolution: "yargs@npm:13.3.2"
+ dependencies:
+ cliui: "npm:^5.0.0"
+ find-up: "npm:^3.0.0"
+ get-caller-file: "npm:^2.0.1"
+ require-directory: "npm:^2.1.1"
+ require-main-filename: "npm:^2.0.0"
+ set-blocking: "npm:^2.0.0"
+ string-width: "npm:^3.0.0"
+ which-module: "npm:^2.0.0"
+ y18n: "npm:^4.0.0"
+ yargs-parser: "npm:^13.1.2"
+ checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be
+ languageName: node
+ linkType: hard
+
"yocto-queue@npm:^0.1.0":
version: 0.1.0
resolution: "yocto-queue@npm:0.1.0"