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..5100e8483a8 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;
@@ -643,6 +649,7 @@ linkStyle default opacity:0.5
transaction_pay_controller --> gas_fee_controller;
transaction_pay_controller --> keyring_controller;
transaction_pay_controller --> messenger;
+ transaction_pay_controller --> money_account_utils;
transaction_pay_controller --> network_controller;
transaction_pay_controller --> ramps_controller;
transaction_pay_controller --> remote_feature_flag_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..27ceff651a6
--- /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..34e465f3d5a 100644
--- a/packages/kyc-controller/CHANGELOG.md
+++ b/packages/kyc-controller/CHANGELOG.md
@@ -9,6 +9,26 @@ 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))
+- Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. ([#9853](https://github.com/MetaMask/core/pull/9853))
+- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852), [#9853](https://github.com/MetaMask/core/pull/9853))
+- 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), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615), [#9853](https://github.com/MetaMask/core/pull/9853))
+ - `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
+
+### Removed
+
+- Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. ([#9853](https://github.com/MetaMask/core/pull/9853))
+
+### Fixed
+
+- Clear `moonpayCustomerId` when the active vendor changes, so `getCustomerIdentity()` can no longer report a MoonPay customer id under another vendor. The id is dropped when `initialize` starts a non-MoonPay flow and when `createIronCustomer` switches to Iron. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Call `unref()` on the user-status poll timer only when it exists. React Native and browser timers are numbers, so the unconditional call threw when Money status polling started outside Node. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Skip the `session_not_in_valid_state` completion write when a `reset()` superseded the SumSub flow, so a late vendor response can no longer force `userStatus` to `completed` (and publish `statusChanged`) on an idle controller. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853))
[Unreleased]: https://github.com/MetaMask/core/
diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md
index e182b37a067..7aaf642375e 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).
diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json
index 32c279f8a06..5b0c23f7fff 100644
--- a/packages/kyc-controller/package.json
+++ b/packages/kyc-controller/package.json
@@ -42,24 +42,45 @@
"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/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..10b47b1ea4c
--- /dev/null
+++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts
@@ -0,0 +1,108 @@
+/**
+ * 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..b2aa85cca42
--- /dev/null
+++ b/packages/kyc-controller/src/KycController-method-action-types.ts
@@ -0,0 +1,246 @@
+/**
+ * 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.
+ * @param params.vendor - Identity vendor for this flow. Pass `iron` for the
+ * Money/VBA path (no MoonPay Check/Auth frames). Defaults to `moonpay`.
+ */
+export type KycControllerInitializeAction = {
+ type: `KycController:initialize`;
+ handler: KycController['initialize'];
+};
+
+/**
+ * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can
+ * ensure the customer exists before showing T&C screens independently of
+ * {@link initialize}.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email for the Iron customer.
+ */
+export type KycControllerCreateIronCustomerAction = {
+ type: `KycController:createIronCustomer`;
+ handler: KycController['createIronCustomer'];
+};
+
+/**
+ * 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.
+ * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were
+ * accepted (T&C2). Defaults to `true` when omitted.
+ * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted
+ * (T&C2). Defaults to `true` when omitted.
+ */
+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'];
+};
+
+/**
+ * Returns the vendor-scoped identity for the currently authenticated
+ * customer, or `null` when the flow has not yet captured a vendor customer
+ * id (before authentication or after {@link reset}).
+ *
+ * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor
+ * customer id to downstream calls without reading the full KYC state, which
+ * also holds session/access tokens. The id is session-scoped and never
+ * persisted.
+ *
+ * @returns The current {@link KycCustomerIdentity}, or `null`.
+ */
+export type KycControllerGetCustomerIdentityAction = {
+ type: `KycController:getCustomerIdentity`;
+ handler: KycController['getCustomerIdentity'];
+};
+
+/**
+ * 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'];
+};
+
+/**
+ * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`,
+ * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and
+ * schedules short-interval polling while the status is `pending`.
+ *
+ * @returns The latest status payload.
+ */
+export type KycControllerRefreshKycStatusAction = {
+ type: `KycController:refreshKycStatus`;
+ handler: KycController['refreshKycStatus'];
+};
+
+/**
+ * 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'];
+};
+
+/**
+ * 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
+ | KycControllerCreateIronCustomerAction
+ | KycControllerLoadDisclaimersAction
+ | KycControllerAcceptTermsAndStartSessionAction
+ | KycControllerClearSavedTermsAction
+ | KycControllerHandleFrameMessageAction
+ | KycControllerBuildCheckFrameUrlAction
+ | KycControllerBuildAuthFrameUrlAction
+ | KycControllerBuildResetFrameUrlAction
+ | KycControllerCheckKycRequiredAction
+ | KycControllerGetKycStatusAction
+ | KycControllerGetCustomerIdentityAction
+ | KycControllerStartSumSubAction
+ | KycControllerRefreshKycStatusAction
+ | KycControllerGetSessionStatusAction
+ | 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..c34d6a88eed
--- /dev/null
+++ b/packages/kyc-controller/src/KycController.test.ts
@@ -0,0 +1,2787 @@
+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';
+
+// `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('getCustomerIdentity', () => {
+ it('returns null before a vendor customer id is captured', async () => {
+ await withController(({ controller }) => {
+ expect(controller.getCustomerIdentity()).toBeNull();
+ });
+ });
+
+ it('returns the vendor-scoped identity once captured', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ ({ controller }) => {
+ expect(controller.getCustomerIdentity()).toStrictEqual({
+ vendor: 'moonpay',
+ id: 'cust-1',
+ });
+ },
+ );
+ });
+
+ it('returns null after reset clears the captured id', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ ({ controller }) => {
+ controller.reset();
+ expect(controller.getCustomerIdentity()).toBeNull();
+ },
+ );
+ });
+
+ it('drops a MoonPay id when initialize switches to another vendor', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ async ({ controller }) => {
+ await controller.initialize({ vendor: 'iron' });
+
+ expect(controller.state.moonpayCustomerId).toBeNull();
+ expect(controller.getCustomerIdentity()).toBeNull();
+ },
+ );
+ });
+
+ it('keeps a MoonPay id when initialize stays on MoonPay', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ async ({ controller }) => {
+ await controller.initialize({ vendor: 'moonpay' });
+
+ expect(controller.state.moonpayCustomerId).toBe('cust-1');
+ },
+ );
+ });
+
+ it('drops a MoonPay id when an Iron customer is created', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ async ({ controller }) => {
+ await controller.createIronCustomer({ email: 'a@b.co' });
+
+ expect(controller.state.moonpayCustomerId).toBeNull();
+ expect(controller.getCustomerIdentity()).toBeNull();
+ },
+ );
+ });
+ });
+
+ 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('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('iron vendor flow', () => {
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it('creates an Iron customer and loads Iron disclaimers on initialize', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('USA');
+ handlers.fetchIronDisclaimers.mockResolvedValue([
+ { id: 'd1', display_name: 'Iron T&C', url: 'https://t' },
+ ]);
+
+ await controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ product: 'money',
+ });
+
+ expect(handlers.createIronCustomer).toHaveBeenCalledWith({
+ email: 'a@b.co',
+ });
+ expect(handlers.fetchIronDisclaimers).toHaveBeenCalledWith({
+ country: 'USA',
+ });
+ expect(handlers.fetchDisclaimers).not.toHaveBeenCalled();
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(controller.state.activeVendor).toBe('iron');
+ expect(controller.state.activeProduct).toBe('money');
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.disclaimers).toHaveLength(1);
+ });
+ });
+
+ it('fails initialize when Iron customer creation fails', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.createIronCustomer.mockRejectedValue(new Error('iron down'));
+
+ await controller.initialize({ email: 'a@b.co', vendor: 'iron' });
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(
+ /Iron customer creation failed/u,
+ );
+ });
+ });
+
+ it('does not fail initialize when reset lands during Iron customer creation', async () => {
+ await withController(async ({ controller, handlers }) => {
+ let release: (value: {
+ id: string;
+ email: string;
+ status: string;
+ }) => void = () => {
+ // placeholder
+ };
+ handlers.createIronCustomer.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ });
+ controller.reset();
+ release({ id: '1', email: 'a@b.co', status: 'SigningsRequired' });
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ });
+ });
+
+ it('does not fail initialize when Iron customer creation rejects after reset', async () => {
+ await withController(async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.createIronCustomer.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ const pending = controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ });
+ controller.reset();
+ release(new Error('late'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ });
+ });
+
+ it('resumes an Iron session when terms and email are already present', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ termsAcceptedAt: 't',
+ acceptedDisclaimerIds: ['d1'],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' });
+
+ await controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ product: 'money',
+ });
+
+ expect(handlers.submitConsents).toHaveBeenCalled();
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(controller.state.phase).toBe('done');
+ controller.reset();
+ },
+ );
+ });
+
+ it('createIronCustomer sets the vendor and fails on API errors', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.createIronCustomer.mockRejectedValue(new Error('nope'));
+
+ await controller.createIronCustomer({ email: 'a@b.co' });
+
+ expect(controller.state.activeVendor).toBe('iron');
+ expect(controller.state.email).toBe('a@b.co');
+ expect(controller.state.phase).toBe('error');
+ });
+ });
+
+ it('createIronCustomer ignores API errors after reset', async () => {
+ await withController(async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.createIronCustomer.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ const pending = controller.createIronCustomer({ email: 'a@b.co' });
+ controller.reset();
+ release(new Error('late'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ });
+ });
+
+ it('posts consents and starts SumSub without MoonPay frames', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ handlers.submitConsents.mockResolvedValue(undefined);
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' });
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+
+ await controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ product: 'money',
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ });
+
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(handlers.submitConsents).toHaveBeenCalledWith({
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ });
+ expect(handlers.createUkycSession).toHaveBeenCalledWith(
+ expect.objectContaining({ vendorId: 'iron' }),
+ );
+ expect(launcher.launch).toHaveBeenCalled();
+ expect(controller.buildCheckFrameUrl()).toBeNull();
+ expect(controller.buildAuthFrameUrl()).toBeNull();
+ expect(controller.state.userStatus).toBe('pending');
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.sumsub.status).toBe('complete');
+ controller.reset();
+ },
+ );
+ });
+
+ it('fails the Iron session when email is missing', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', 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 the Iron session when disclaimer acceptance is missing', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ email: 'a@b.co',
+ disclaimers: [],
+ },
+ },
+ },
+ async ({ controller }) => {
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(/Missing Iron disclaimer/u);
+ },
+ );
+ });
+
+ it('returns to terms when SumSub fails during the Iron session', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(
+ new Error('sumsub down'),
+ );
+ handlers.fetchIronDisclaimers.mockResolvedValue([]);
+
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.termsAcceptedAt).toBeNull();
+ expect(controller.state.error).toMatch(/Iron session failed/u);
+ },
+ );
+ });
+
+ it('keeps done when status refresh fails after a successful SumSub', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ handlers.fetchKycStatus.mockRejectedValue(new Error('status down'));
+
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.sumsub.status).toBe('complete');
+ controller.reset();
+ },
+ );
+ });
+
+ it('ignores in-flight Iron consents after reset', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let release: () => void = () => {
+ // placeholder
+ };
+ handlers.submitConsents.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ });
+ controller.reset();
+ release();
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(handlers.createUkycSession).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('ignores SumSub completion after reset during the Iron session', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ let releaseLaunch: (value: { ok: boolean }) => void = () => {
+ // placeholder
+ };
+ launcher.launch.mockReturnValue(
+ new Promise((resolve) => {
+ releaseLaunch = resolve;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ });
+ // Consents + UKYC session run first; wait until launch is pending.
+ await Promise.resolve();
+ await Promise.resolve();
+ controller.reset();
+ releaseLaunch({ ok: true });
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(handlers.fetchKycStatus).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('ignores Iron session failures after reset', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.submitConsents.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ });
+ controller.reset();
+ release(new Error('late consent failure'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ },
+ );
+ });
+
+ it('refreshKycStatus stores status and emits statusChanged', async () => {
+ await withController(
+ { options: { userStatusPollIntervalMs: 60_000 } },
+ async ({ controller, handlers, rootMessenger }) => {
+ const listener = jest.fn();
+ rootMessenger.subscribe('KycController:statusChanged', listener);
+ handlers.fetchKycStatus.mockResolvedValue({
+ status: 'completed',
+ sumsubSessionId: 'ss-1',
+ });
+
+ const result = await controller.refreshKycStatus();
+
+ expect(result).toStrictEqual({
+ status: 'completed',
+ sumsubSessionId: 'ss-1',
+ errorCode: null,
+ });
+ expect(controller.state.userStatus).toBe('completed');
+ expect(listener).toHaveBeenCalledWith({
+ status: 'completed',
+ sumsubSessionId: 'ss-1',
+ errorCode: null,
+ });
+ },
+ );
+ });
+
+ it('polls user status while pending and stops on a terminal status', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'completed' });
+
+ await controller.refreshKycStatus();
+ expect(controller.state.userStatus).toBe('pending');
+
+ // First tick stays pending and reschedules; second tick completes.
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(controller.state.userStatus).toBe('pending');
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(controller.state.userStatus).toBe('completed');
+
+ // A second refresh while pending would no-op the timer start; then
+ // reset clears any leftover handles.
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' });
+ await controller.refreshKycStatus();
+ await controller.refreshKycStatus();
+ controller.reset();
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('drops superseded user-status poll ticks after reset', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ let release: (value: { status: string }) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ await controller.refreshKycStatus();
+ jest.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ controller.reset();
+ release({ status: 'completed' });
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(controller.state.userStatus).toBe('pending');
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('keeps polling when a user-status tick fails transiently', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockRejectedValueOnce(new Error('transient'))
+ .mockResolvedValueOnce({ status: 'completed' });
+
+ await controller.refreshKycStatus();
+ await jest.advanceTimersByTimeAsync(1000);
+ await jest.advanceTimersByTimeAsync(1000);
+
+ expect(controller.state.userStatus).toBe('completed');
+ controller.reset();
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('drops superseded user-status ticks that fail after reset', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockImplementationOnce(
+ async () =>
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ await controller.refreshKycStatus();
+ jest.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ controller.reset();
+ release(new Error('late'));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(controller.state.userStatus).toBe('pending');
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('returns cached user status when reset lands during refresh', async () => {
+ await withController(
+ {
+ options: {
+ state: { userStatus: 'pending' },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers }) => {
+ let release: (value: { status: string }) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.refreshKycStatus();
+ controller.reset();
+ release({ status: 'completed' });
+ const result = await pending;
+
+ expect(result.status).toBe('pending');
+ },
+ );
+ });
+
+ it('defaults superseded refresh status to not-started when unset', async () => {
+ await withController(
+ { options: { userStatusPollIntervalMs: 60_000 } },
+ async ({ controller, handlers }) => {
+ let release: (value: { status: string }) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.refreshKycStatus();
+ controller.reset();
+ release({ status: 'completed' });
+ const result = await pending;
+
+ expect(result.status).toBe('not-started');
+ },
+ );
+ });
+
+ it('maps session_not_in_valid_state to completed during SumSub', async () => {
+ await withController(
+ {
+ options: {
+ state: { activeVendor: 'iron', phase: 'submit' },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(
+ new Error(
+ "Fetching 'https://x' failed with status '409': session_not_in_valid_state",
+ ),
+ );
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({ alreadyCompleted: true });
+ expect(controller.state.userStatus).toBe('completed');
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.sumsub.status).toBe('complete');
+ },
+ );
+ });
+
+ it('leaves an already-reset controller idle when SumSub reports a stale session', async () => {
+ await withController(
+ {
+ options: {
+ state: { activeVendor: 'iron', phase: 'submit' },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let rejectSession: (error: Error) => void = () => undefined;
+ handlers.createUkycSession.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectSession = reject;
+ }),
+ );
+
+ const pending = controller.startSumSub();
+ controller.reset();
+ rejectSession(new Error('session_not_in_valid_state'));
+
+ expect(await pending).toStrictEqual({ alreadyCompleted: true });
+ expect(controller.state.userStatus).toBeNull();
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.sumsub.status).toBe('idle');
+ },
+ );
+ });
+
+ it('keeps phase done when Iron SumSub reports already completed', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(
+ new Error('session_not_in_valid_state'),
+ );
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' });
+
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.userStatus).toBe('completed');
+ controller.reset();
+ },
+ );
+ });
+ });
+
+ 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;
+ createIronCustomer: jest.Mock;
+ fetchIronDisclaimers: jest.Mock;
+ checkIronKycRequired: jest.Mock;
+ submitConsents: jest.Mock;
+ fetchKycStatus: jest.Mock;
+ getWrappingKey: jest.Mock;
+ fetchJwks: jest.Mock;
+ createUkycSession: jest.Mock;
+ createJourney: jest.Mock;
+ getSessionStatus: 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:createIronCustomer',
+ 'KycService:fetchIronDisclaimers',
+ 'KycService:checkIronKycRequired',
+ 'KycService:submitConsents',
+ 'KycService:fetchKycStatus',
+ 'KycService:getWrappingKey',
+ 'KycService:fetchJwks',
+ 'KycService:createUkycSession',
+ 'KycService:createJourney',
+ 'KycService:getSessionStatus',
+ '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 }),
+ createIronCustomer: jest.fn().mockResolvedValue({
+ id: 'iron-1',
+ email: 'a@b.co',
+ status: 'SigningsRequired',
+ }),
+ fetchIronDisclaimers: jest.fn().mockResolvedValue([]),
+ checkIronKycRequired: jest.fn().mockResolvedValue({ kycRequired: true }),
+ submitConsents: jest.fn().mockResolvedValue(undefined),
+ fetchKycStatus: jest.fn().mockResolvedValue({ status: 'pending' }),
+ 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')),
+ 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:createIronCustomer',
+ handlers.createIronCustomer,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:fetchIronDisclaimers',
+ handlers.fetchIronDisclaimers,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:checkIronKycRequired',
+ handlers.checkIronKycRequired,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:submitConsents',
+ handlers.submitConsents,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:fetchKycStatus',
+ handlers.fetchKycStatus,
+ );
+ 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(
+ '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..6a4d98520c3
--- /dev/null
+++ b/packages/kyc-controller/src/KycController.ts
@@ -0,0 +1,1924 @@
+import type {
+ ControllerGetStateAction,
+ ControllerStateChangeEvent,
+ StateMetadata,
+} from '@metamask/base-controller';
+import { BaseController } from '@metamask/base-controller';
+import type { Messenger } from '@metamask/messenger';
+import type {
+ UserStorageControllerPerformGetStorageAction,
+ UserStorageControllerPerformSetStorageAction,
+} from '@metamask/profile-sync-controller/user-storage';
+import type { 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 type {
+ KycCustomerIdentity,
+ KycDisclaimer,
+ KycPhase,
+ KycProduct,
+ KycSessionStatus,
+ KycSumSubLauncher,
+ KycSumSubStatus,
+ KycUserStatus,
+ KycVendor,
+} 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';
+
+// === 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.';
+
+// UKYC / relay error indicating the applicant already finished KYC. Mapped to
+// the simplified `completed` user status for the Money toast surface.
+const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state';
+
+// How often to refresh the user-keyed `GET /kyc/status` while the simplified
+// status is still `pending`. Overridable via the constructor.
+const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000;
+
+// === STATE ===
+
+/**
+ * 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 identity vendor driving the current flow. Captured at `initialize`.
+ * Defaults to `moonpay` when omitted so existing ramps/card callers keep
+ * the Check/Auth frame path. `iron` skips those frames.
+ */
+ activeVendor: KycVendor;
+
+ /**
+ * 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;
+
+ /**
+ * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the
+ * Money toast can render across cold starts). `null` until the first
+ * successful `refreshKycStatus`.
+ */
+ userStatus: KycUserStatus | null;
+ /** Optional SumSub session id for the retryable error path. */
+ userStatusSumsubSessionId: string | null;
+ /** Optional machine-readable error code for terminal / EDD UX. */
+ userStatusErrorCode: string | null;
+
+ /** 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,
+ },
+ activeVendor: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
+ 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,
+ },
+ userStatus: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: true,
+ },
+ userStatusSumsubSessionId: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: true,
+ usedInUi: true,
+ },
+ userStatusErrorCode: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: true,
+ },
+ 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,
+ activeVendor: 'moonpay',
+ activeProduct: null,
+ kycRequiredByProduct: {},
+ lastCheckedAt: null,
+ userStatus: null,
+ userStatusSumsubSessionId: null,
+ userStatusErrorCode: null,
+ sumsub: {
+ status: 'idle',
+ result: null,
+ sessionId: null,
+ applicantAccessToken: null,
+ sessionStatus: null,
+ },
+ };
+}
+
+// === MESSENGER ===
+
+const MESSENGER_EXPOSED_METHODS = [
+ 'initialize',
+ 'loadDisclaimers',
+ 'acceptTermsAndStartSession',
+ 'createIronCustomer',
+ 'clearSavedTerms',
+ 'handleFrameMessage',
+ 'buildCheckFrameUrl',
+ 'buildAuthFrameUrl',
+ 'buildResetFrameUrl',
+ 'checkKycRequired',
+ 'getKycStatus',
+ 'getCustomerIdentity',
+ 'refreshKycStatus',
+ 'startSumSub',
+ 'getSessionStatus',
+ 'reset',
+] as const;
+
+export type KycControllerGetStateAction = ControllerGetStateAction<
+ typeof controllerName,
+ KycControllerState
+>;
+
+export type KycControllerActions =
+ | KycControllerGetStateAction
+ | KycControllerMethodActions;
+
+type AllowedActions =
+ | KycServiceMethodActions
+ | UserStorageControllerPerformGetStorageAction
+ | UserStorageControllerPerformSetStorageAction;
+
+export type KycControllerStateChangeEvent = ControllerStateChangeEvent<
+ typeof controllerName,
+ KycControllerState
+>;
+
+/**
+ * Published when the user-keyed simplified KYC status changes (Money toast).
+ */
+export type KycControllerStatusChangedEvent = {
+ type: `${typeof controllerName}:statusChanged`;
+ payload: [
+ {
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ },
+ ];
+};
+
+export type KycControllerEvents =
+ | KycControllerStateChangeEvent
+ | KycControllerStatusChangedEvent;
+
+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;
+ /**
+ * How often, in milliseconds, to refresh `GET /kyc/status` while the
+ * simplified user status is `pending`. Defaults to
+ * {@link DEFAULT_USER_STATUS_POLL_INTERVAL_MS}.
+ */
+ userStatusPollIntervalMs?: number;
+};
+
+/**
+ * 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;
+
+ /** Interval, in milliseconds, between user-keyed status polls. */
+ readonly #userStatusPollIntervalMs: number;
+
+ /** Handle for the scheduled next user-status poll, or `null`. */
+ #userStatusPollTimer: ReturnType | null = null;
+
+ /** Monotonic token for the user-status poll loop (see `#pollToken`). */
+ #userStatusPollToken = 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.
+ * @param options.userStatusPollIntervalMs - How often to refresh the
+ * user-keyed KYC status while it is still `pending`.
+ */
+ constructor({
+ messenger,
+ state,
+ sumsubLauncher,
+ sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS,
+ userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS,
+ }: KycControllerOptions) {
+ super({
+ messenger,
+ metadata: kycControllerMetadata,
+ name: controllerName,
+ state: { ...getDefaultKycControllerState(), ...state },
+ });
+
+ this.#sumsubLauncher = sumsubLauncher;
+ this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs;
+ this.#userStatusPollIntervalMs = userStatusPollIntervalMs;
+ 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.
+ * @param params.vendor - Identity vendor for this flow. Pass `iron` for the
+ * Money/VBA path (no MoonPay Check/Auth frames). Defaults to `moonpay`.
+ */
+ async initialize(params?: {
+ email?: string;
+ product?: KycProduct;
+ vendor?: KycVendor;
+ }): 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;
+ }
+
+ const vendor = params?.vendor ?? 'moonpay';
+
+ // `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.activeVendor = vendor;
+ // `moonpayCustomerId` is only ever issued by the MoonPay Check / Auth
+ // frames. Leaving it set while the flow switches to another vendor would
+ // make `getCustomerIdentity` report a MoonPay id under the wrong vendor.
+ if (vendor !== 'moonpay') {
+ state.moonpayCustomerId = null;
+ }
+ 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.
+ }
+
+ // Iron: create the empty-shell customer before T&C (offsite decision).
+ if (vendor === 'iron' && this.state.email) {
+ try {
+ await this.messenger.call('KycService:createIronCustomer', {
+ email: this.state.email,
+ });
+ if (this.#generation !== generation) {
+ return;
+ }
+ } catch (error) {
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#fail(`Iron customer creation failed: ${String(error)}`);
+ return;
+ }
+ }
+
+ const hasTerms =
+ Boolean(this.state.termsAcceptedAt) &&
+ this.state.acceptedDisclaimerIds.length > 0;
+
+ if (hasTerms && this.state.email) {
+ if (vendor === 'iron') {
+ await this.#startIronSession({
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ });
+ } else {
+ await this.#createSession();
+ }
+ return;
+ }
+
+ this.#applyUpdate((state) => {
+ state.phase = 'terms';
+ });
+ await this.loadDisclaimers();
+ }
+
+ /**
+ * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can
+ * ensure the customer exists before showing T&C screens independently of
+ * {@link initialize}.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email for the Iron customer.
+ */
+ async createIronCustomer(params: { email: string }): Promise {
+ this.#applyUpdate((state) => {
+ state.email = params.email;
+ state.activeVendor = 'iron';
+ // See `initialize`: a MoonPay-issued customer id must not survive a
+ // switch to Iron, or `getCustomerIdentity` reports the wrong vendor.
+ state.moonpayCustomerId = null;
+ });
+ const generation = this.#generation;
+ try {
+ await this.messenger.call('KycService:createIronCustomer', {
+ email: params.email,
+ });
+ } catch (error) {
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#fail(`Iron customer creation failed: ${String(error)}`);
+ }
+ }
+
+ /**
+ * 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 =
+ this.state.activeVendor === 'iron'
+ ? await this.messenger.call('KycService:fetchIronDisclaimers', {
+ country,
+ })
+ : 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.
+ * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were
+ * accepted (T&C2). Defaults to `true` when omitted.
+ * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted
+ * (T&C2). Defaults to `true` when omitted.
+ */
+ async acceptTermsAndStartSession(params?: {
+ email?: string;
+ product?: KycProduct;
+ sumsubTncSigned?: boolean;
+ idosTncSigned?: boolean;
+ }): 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;
+ });
+ if (this.state.activeVendor === 'iron') {
+ await this.#startIronSession({
+ sumsubTncSigned: params?.sumsubTncSigned ?? true,
+ idosTncSigned: params?.idosTncSigned ?? true,
+ });
+ return;
+ }
+ await this.#createSession();
+ }
+
+ /**
+ * Iron-only path: post consents (Iron signings + Sumsub/idOS ack), then
+ * launch SumSub — skipping MoonPay Check/Auth frames.
+ *
+ * @param consents - T&C2 boolean flags.
+ * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted.
+ * @param consents.idosTncSigned - Whether idOS T&C were accepted.
+ */
+ async #startIronSession(consents: {
+ sumsubTncSigned: boolean;
+ idosTncSigned: boolean;
+ }): Promise {
+ const { email, acceptedDisclaimerIds } = this.state;
+ if (!email) {
+ this.#fail('Missing email for Iron session.');
+ return;
+ }
+ if (acceptedDisclaimerIds.length === 0) {
+ this.#fail('Missing Iron disclaimer acceptance.');
+ return;
+ }
+
+ const generation = this.#generation;
+ this.#applyUpdate((state) => {
+ state.error = null;
+ state.phase = 'session';
+ state.statusMessage = 'Submitting consents...';
+ // Iron has no MoonPay session/access tokens.
+ state.sessionToken = null;
+ state.accessToken = null;
+ });
+
+ try {
+ await this.messenger.call('KycService:submitConsents', {
+ ironDisclaimerIds: acceptedDisclaimerIds,
+ sumsubTncSigned: consents.sumsubTncSigned,
+ idosTncSigned: consents.idosTncSigned,
+ });
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#applyUpdate((state) => {
+ state.phase = 'submit';
+ state.statusMessage = 'Starting document verification...';
+ });
+ const sumsubResult = await this.startSumSub();
+ if (this.#generation !== generation) {
+ return;
+ }
+ const sumsubError = sumsubResult?.error;
+ if (typeof sumsubError === 'string') {
+ throw new Error(sumsubError);
+ }
+ // After SumSub, refresh user-keyed status for the Money toast and start
+ // polling while still pending. Soft-fail: toast refresh must not rewind
+ // the consent / SumSub outcome.
+ try {
+ await this.refreshKycStatus();
+ } catch (statusError) {
+ console.error('KYC status refresh failed:', statusError);
+ }
+ this.#updateIfCurrent(generation, (state) => {
+ if (state.phase !== 'error' && state.phase !== 'done') {
+ state.phase = 'done';
+ state.statusMessage = 'KYC submitted.';
+ }
+ });
+ } catch (error) {
+ console.error('Iron session failed:', error);
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#applyUpdate((state) => {
+ this.#clearAcceptedTerms(state);
+ state.activeProduct = null;
+ state.error = `Iron session failed: ${String(error)}`;
+ state.statusMessage =
+ 'Consent / verification failed — accept the terms to try again.';
+ state.phase = 'terms';
+ });
+ await this.loadDisclaimers();
+ }
+ }
+
+ /**
+ * 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];
+ }
+
+ /**
+ * Returns the vendor-scoped identity for the currently authenticated
+ * customer, or `null` when the flow has not yet captured a vendor customer
+ * id (before authentication or after {@link reset}).
+ *
+ * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor
+ * customer id to downstream calls without reading the full KYC state, which
+ * also holds session/access tokens. The id is session-scoped and never
+ * persisted.
+ *
+ * @returns The current {@link KycCustomerIdentity}, or `null`.
+ */
+ getCustomerIdentity(): KycCustomerIdentity | null {
+ const { moonpayCustomerId, activeVendor } = this.state;
+ if (!moonpayCustomerId) {
+ return null;
+ }
+ return { vendor: activeVendor, id: moonpayCustomerId };
+ }
+
+ /**
+ * 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 isIron = this.state.activeVendor === 'iron';
+ const { sessionId, kycStatus, finalStatus } = await this.messenger.call(
+ 'KycService:createUkycSession',
+ {
+ jwtToken,
+ vendorId: isIron ? 'iron' : 'moonpay',
+ ...(isIron
+ ? {}
+ : {
+ 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) {
+ // Applicant already finished KYC — treat as completed for Money toast.
+ if (String(error).includes(SESSION_NOT_IN_VALID_STATE)) {
+ // A reset() may have landed while `launch` was in flight; forcing
+ // `completed` (and publishing `statusChanged`) on an idle controller
+ // would resurrect a flow the consumer already tore down.
+ if (this.#generation !== generation) {
+ return { alreadyCompleted: true };
+ }
+ this.#applyUserStatus({
+ status: 'completed',
+ sumsubSessionId: null,
+ errorCode: null,
+ });
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'complete';
+ state.sumsub.result = { alreadyCompleted: true };
+ state.statusMessage = 'KYC already completed.';
+ state.phase = 'done';
+ state.error = null;
+ });
+ return { alreadyCompleted: true };
+ }
+ const result = { error: String(error) };
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'failed';
+ state.sumsub.result = result;
+ });
+ return result;
+ }
+ }
+
+ /**
+ * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`,
+ * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and
+ * schedules short-interval polling while the status is `pending`.
+ *
+ * @returns The latest status payload.
+ */
+ async refreshKycStatus(): Promise<{
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ }> {
+ const payload = await this.#fetchAndApplyUserStatus();
+ if (payload.status === 'pending') {
+ this.#ensureUserStatusPolling();
+ } else {
+ this.#stopUserStatusPolling();
+ }
+ return payload;
+ }
+
+ /**
+ * Fetches `GET /kyc/status` and applies it to state without managing the
+ * poll loop (used by both {@link refreshKycStatus} and the poll tick).
+ *
+ * @returns The latest status payload.
+ */
+ async #fetchAndApplyUserStatus(): Promise<{
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ }> {
+ const generation = this.#generation;
+ const response = await this.messenger.call('KycService:fetchKycStatus');
+ if (this.#generation !== generation) {
+ return {
+ status: this.state.userStatus ?? 'not-started',
+ sumsubSessionId: this.state.userStatusSumsubSessionId,
+ errorCode: this.state.userStatusErrorCode,
+ };
+ }
+ const payload = {
+ status: response.status,
+ sumsubSessionId: response.sumsubSessionId ?? null,
+ errorCode: response.errorCode ?? null,
+ };
+ this.#applyUserStatus(payload);
+ return payload;
+ }
+
+ /**
+ * Writes user-keyed status onto state and publishes `statusChanged` when the
+ * value actually changes.
+ *
+ * @param payload - The status payload to apply.
+ * @param payload.status - User-keyed KYC status from `GET /kyc/status`.
+ * @param payload.sumsubSessionId - Optional SumSub session id from status.
+ * @param payload.errorCode - Optional error code from status.
+ */
+ #applyUserStatus(payload: {
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ }): void {
+ const previous = this.state.userStatus;
+ this.#applyUpdate((state) => {
+ state.userStatus = payload.status;
+ state.userStatusSumsubSessionId = payload.sumsubSessionId;
+ state.userStatusErrorCode = payload.errorCode;
+ });
+ if (previous !== payload.status) {
+ this.messenger.publish(`${controllerName}:statusChanged`, payload);
+ }
+ }
+
+ /**
+ * Starts the user-status poll loop when not already running and status is
+ * still `pending`.
+ */
+ #ensureUserStatusPolling(): void {
+ if (this.#userStatusPollTimer !== null) {
+ return;
+ }
+ const token = this.#userStatusPollToken;
+ const tick = async (): Promise => {
+ try {
+ const payload = await this.#fetchAndApplyUserStatus();
+ // Race with `reset()` / `#stopUserStatusPolling` while the request was
+ // in flight — do not reschedule onto an idle controller.
+ /* istanbul ignore next */
+ if (this.#userStatusPollToken !== token) {
+ return;
+ }
+ if (payload.status !== 'pending') {
+ this.#stopUserStatusPolling();
+ return;
+ }
+ } catch {
+ // Keep polling on transient errors, unless the loop was superseded.
+ /* istanbul ignore next */
+ if (this.#userStatusPollToken !== token) {
+ return;
+ }
+ }
+ this.#userStatusPollTimer = setTimeout(() => {
+ this.#userStatusPollTimer = null;
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ tick();
+ }, this.#userStatusPollIntervalMs);
+ // Allow the process to exit while a pending-status poll is scheduled.
+ // React Native / browser timers are numbers with no `unref`, hence the
+ // optional call.
+ this.#userStatusPollTimer.unref?.();
+ };
+ this.#userStatusPollTimer = setTimeout(() => {
+ this.#userStatusPollTimer = null;
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ tick();
+ }, this.#userStatusPollIntervalMs);
+ this.#userStatusPollTimer.unref?.();
+ }
+
+ /**
+ * Stops the user-keyed status poll loop.
+ */
+ #stopUserStatusPolling(): void {
+ this.#userStatusPollToken += 1;
+ if (this.#userStatusPollTimer !== null) {
+ clearTimeout(this.#userStatusPollTimer);
+ this.#userStatusPollTimer = null;
+ }
+ }
+
+ /**
+ * 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;
+ }
+ }
+
+ /**
+ * 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();
+ this.#stopUserStatusPolling();
+ // 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.activeVendor = 'moonpay';
+ 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..7f38f86aa14
--- /dev/null
+++ b/packages/kyc-controller/src/KycService-method-action-types.ts
@@ -0,0 +1,205 @@
+/**
+ * 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'];
+};
+
+/**
+ * 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'];
+};
+
+/**
+ * Creates (or resumes) an Iron empty-shell customer for the authenticated
+ * canonical user. Must run before showing Iron T&C so the customer exists in
+ * `SigningsRequired` and resume logic can key off Iron status.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email associated with the Iron customer.
+ * @returns The Iron customer record (subset validated for controller use).
+ */
+export type KycServiceCreateIronCustomerAction = {
+ type: `KycService:createIronCustomer`;
+ handler: KycService['createIronCustomer'];
+};
+
+/**
+ * Fetches Iron disclaimers / terms the customer must accept before consents
+ * and the SumSub sub-flow.
+ *
+ * @param params - The parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code.
+ * @returns The disclaimers.
+ */
+export type KycServiceFetchIronDisclaimersAction = {
+ type: `KycService:fetchIronDisclaimers`;
+ handler: KycService['fetchIronDisclaimers'];
+};
+
+/**
+ * Checks whether Iron still requires KYC for the authenticated canonical
+ * user. Unlike the MoonPay variant, this does not take an access token.
+ *
+ * @returns Whether KYC is required.
+ */
+export type KycServiceCheckIronKycRequiredAction = {
+ type: `KycService:checkIronKycRequired`;
+ handler: KycService['checkIronKycRequired'];
+};
+
+/**
+ * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the
+ * authenticated user. The API responds with 204 No Content on success.
+ *
+ * @param params - The consent parameters.
+ */
+export type KycServiceSubmitConsentsAction = {
+ type: `KycService:submitConsents`;
+ handler: KycService['submitConsents'];
+};
+
+/**
+ * Fetches the user-keyed simplified KYC status used by Money toast / banner
+ * surfaces (`GET /kyc/status`).
+ *
+ * @returns The simplified status payload.
+ */
+export type KycServiceFetchKycStatusAction = {
+ type: `KycService:fetchKycStatus`;
+ handler: KycService['fetchKycStatus'];
+};
+
+/**
+ * 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
+ | KycServiceFetchDisclaimersAction
+ | KycServiceCreateSessionAction
+ | KycServiceCheckKycRequiredAction
+ | KycServiceCreateIronCustomerAction
+ | KycServiceFetchIronDisclaimersAction
+ | KycServiceCheckIronKycRequiredAction
+ | KycServiceSubmitConsentsAction
+ | KycServiceFetchKycStatusAction
+ | 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..c7d5d6c9db9
--- /dev/null
+++ b/packages/kyc-controller/src/KycService.test.ts
@@ -0,0 +1,810 @@
+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('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);
+ });
+
+ it('includes the API error message in HttpError when present', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { message: 'session_not_in_valid_state' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/session_not_in_valid_state/u);
+ });
+
+ it('includes the API error field in HttpError when message is absent', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { error: 'session_not_in_valid_state' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/session_not_in_valid_state/u);
+ });
+
+ it('prefers a string error field when message is not a string', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { message: 123, error: 'session_not_in_valid_state' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/session_not_in_valid_state/u);
+ });
+
+ it('falls back to status-only HttpError when the body has no useful fields', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { message: 1, error: 2 });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/failed with status '409'$/u);
+ });
+
+ it('falls back to status-only HttpError when the body is not an object', async () => {
+ nock(MOCK_API_URL).get('/sessions/sid/status').reply(409, null);
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/failed with status '409'$/u);
+ });
+ });
+
+ describe('createIronCustomer', () => {
+ it('creates an Iron customer and returns the validated subset', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/iron/customers', { email: 'a@b.co' })
+ .reply(200, {
+ id: 'iron-1',
+ email: 'a@b.co',
+ status: 'SigningsRequired',
+ customer_type: 'Person',
+ name: '',
+ partner_id: 'p',
+ identification_ids: [],
+ signing_ids: [],
+ created_at: '2026-01-01T00:00:00.000Z',
+ updated_at: '2026-01-01T00:00:00.000Z',
+ });
+ const { service } = getService();
+
+ expect(
+ await service.createIronCustomer({ email: 'a@b.co' }),
+ ).toMatchObject({
+ id: 'iron-1',
+ email: 'a@b.co',
+ status: 'SigningsRequired',
+ });
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/vendors/iron/customers').reply(200, {});
+ const { service } = getService();
+
+ await expect(
+ service.createIronCustomer({ email: 'a@b.co' }),
+ ).rejects.toThrow(/Malformed response received from iron customers API/u);
+ });
+ });
+
+ describe('fetchIronDisclaimers', () => {
+ it('returns Iron disclaimers for a country', async () => {
+ const disclaimers = [
+ { id: '1', display_name: 'Iron Terms', url: 'https://t' },
+ ];
+ nock(MOCK_API_URL)
+ .get('/vendors/iron/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, disclaimers);
+ const { service } = getService();
+
+ expect(
+ await service.fetchIronDisclaimers({ country: 'USA' }),
+ ).toStrictEqual(disclaimers);
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/iron/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, [{ id: 1 }]);
+ const { service } = getService();
+
+ await expect(
+ service.fetchIronDisclaimers({ country: 'USA' }),
+ ).rejects.toThrow(
+ /Malformed response received from iron disclaimers API/u,
+ );
+ });
+ });
+
+ describe('checkIronKycRequired', () => {
+ it('returns whether Iron KYC is required', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/iron/kyc-required')
+ .reply(200, { required: true });
+ const { service } = getService();
+
+ expect(await service.checkIronKycRequired()).toStrictEqual({
+ kycRequired: true,
+ });
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/vendors/iron/kyc-required').reply(200, {});
+ const { service } = getService();
+
+ await expect(service.checkIronKycRequired()).rejects.toThrow(
+ /Malformed response received from iron kyc-required API/u,
+ );
+ });
+ });
+
+ describe('submitConsents', () => {
+ it('posts consents and accepts a 204 response', async () => {
+ nock(MOCK_API_URL)
+ .post('/consents', {
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ kycLevel: 'standard',
+ })
+ .reply(204);
+ const { service } = getService();
+
+ expect(
+ await service.submitConsents({
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ }),
+ ).toBeUndefined();
+ });
+
+ it('throws an HttpError on a non-ok response', async () => {
+ nock(MOCK_API_URL).post('/consents').reply(500);
+ const { service } = getService();
+
+ await expect(
+ service.submitConsents({
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ }),
+ ).rejects.toThrow(/failed with status '500'/u);
+ });
+ });
+
+ describe('fetchKycStatus', () => {
+ it('returns the simplified user-keyed status', async () => {
+ nock(MOCK_API_URL).get('/kyc/status').reply(200, {
+ status: 'pending',
+ sumsubSessionId: 'ss-1',
+ });
+ const { service } = getService();
+
+ expect(await service.fetchKycStatus()).toStrictEqual({
+ status: 'pending',
+ sumsubSessionId: 'ss-1',
+ });
+ });
+
+ it('throws on an unknown status value', async () => {
+ nock(MOCK_API_URL).get('/kyc/status').reply(200, { status: 'weird' });
+ const { service } = getService();
+
+ await expect(service.fetchKycStatus()).rejects.toThrow(
+ /Malformed response received from kyc status API/u,
+ );
+ });
+ });
+
+ describe('createUkycSession vendorId', () => {
+ it('defaults vendorId to moonpay and forwards vendorMetadata', async () => {
+ const material = deriveClientMaterial(
+ new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1),
+ );
+ const ukycCapabilityToken = signStorageAccessToken({
+ material,
+ operations: ['read'],
+ expiresAt: new Date('2099-01-01T00:00:00.000Z'),
+ });
+ nock(MOCK_API_URL)
+ .post('/sessions', (body) => {
+ return (
+ body.vendorId === 'moonpay' &&
+ body.vendorMetadata?.moonPayAccessToken === 'tok'
+ );
+ })
+ .reply(200, { sessionId: 'sid' });
+ const { service } = getService();
+
+ expect(
+ await service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorMetadata: { moonPayAccessToken: 'tok' },
+ wrappedEncryptionKey: {
+ sessionId: 'wk',
+ encryptedKey: 'ek',
+ nonce: 'n',
+ },
+ ukycCapabilityToken,
+ }),
+ ).toStrictEqual({ sessionId: 'sid' });
+ });
+
+ it('sends vendorId iron with empty vendorMetadata when omitted', async () => {
+ const material = deriveClientMaterial(
+ new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1),
+ );
+ const ukycCapabilityToken = signStorageAccessToken({
+ material,
+ operations: ['read'],
+ expiresAt: new Date('2099-01-01T00:00:00.000Z'),
+ });
+ nock(MOCK_API_URL)
+ .post('/sessions', (body) => {
+ return (
+ body.vendorId === 'iron' &&
+ JSON.stringify(body.vendorMetadata) === '{}'
+ );
+ })
+ .reply(200, { sessionId: 'sid-iron' });
+ const { service } = getService();
+
+ expect(
+ await service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorId: 'iron',
+ wrappedEncryptionKey: {
+ sessionId: 'wk',
+ encryptedKey: 'ek',
+ nonce: 'n',
+ },
+ ukycCapabilityToken,
+ }),
+ ).toStrictEqual({ sessionId: 'sid-iron' });
+ });
+ });
+
+ 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..89201eb63a0
--- /dev/null
+++ b/packages/kyc-controller/src/KycService.ts
@@ -0,0 +1,925 @@
+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,
+ enums,
+ 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,
+ KycUserStatusResponse,
+ KycVendor,
+} from './types.js';
+import { UKYC_JWKS_PATH } from './ukyc/constants.js';
+import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js';
+import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.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',
+ 'createIronCustomer',
+ 'fetchIronDisclaimers',
+ 'checkIronKycRequired',
+ 'submitConsents',
+ 'fetchKycStatus',
+ 'getWrappingKey',
+ 'fetchJwks',
+ 'createUkycSession',
+ 'createJourney',
+ 'getSessionStatus',
+] 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(),
+});
+
+// Iron customer subset — `type` (not `object`) keeps extra Iron fields from
+// failing validation while still requiring the fields the controller needs.
+const IronCustomerResponseStruct = type({
+ id: string(),
+ email: string(),
+ status: string(),
+});
+export type IronCustomerResponse = Infer;
+
+const KYC_USER_STATUSES = [
+ 'not-started',
+ 'pending',
+ 'need-more-information',
+ 'terminal-failure',
+ 'completed',
+] as const;
+
+const KycUserStatusResponseStruct = type({
+ status: enums([...KYC_USER_STATUSES]),
+ sumsubSessionId: optional(string()),
+ errorCode: optional(string()),
+});
+
+// === PARAM TYPES ===
+
+export type CreateSessionParams = {
+ email: string;
+ termsAcceptedAt: string;
+ disclaimerIds: string[];
+};
+
+export type CheckKycRequiredParams = {
+ accessToken: string;
+ country: string;
+ capabilities?: { product: string }[];
+};
+
+export type CreateIronCustomerParams = {
+ email: string;
+};
+
+export type SubmitConsentsParams = {
+ ironDisclaimerIds: string[];
+ sumsubTncSigned: boolean;
+ idosTncSigned: boolean;
+ kycLevel?: 'standard';
+};
+
+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;
+ /**
+ * Identity vendor for the UKYC session. Defaults to `moonpay` for the
+ * existing Check/Auth flow. Pass `iron` for the Money/VBA path (no MoonPay
+ * metadata required).
+ */
+ vendorId?: KycVendor;
+ /**
+ * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` /
+ * `moonPayUserId`); optional / omitted for Iron.
+ */
+ 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;
+};
+
+// === 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;
+
+ /**
+ * 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.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;
+ }
+
+ /**
+ * 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 };
+ }
+
+ /**
+ * Creates (or resumes) an Iron empty-shell customer for the authenticated
+ * canonical user. Must run before showing Iron T&C so the customer exists in
+ * `SigningsRequired` and resume logic can key off Iron status.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email associated with the Iron customer.
+ * @returns The Iron customer record (subset validated for controller use).
+ */
+ async createIronCustomer(
+ params: CreateIronCustomerParams,
+ ): Promise {
+ const url = new URL('/vendors/iron/customers', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:createIronCustomer`, params.email],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({ email: params.email }),
+ }),
+ // Customer creation/resume must never serve a stale/cached result.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ IronCustomerResponseStruct,
+ 'iron customers',
+ );
+ }
+
+ /**
+ * Fetches Iron disclaimers / terms the customer must accept before consents
+ * and the SumSub sub-flow.
+ *
+ * @param params - The parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code.
+ * @returns The disclaimers.
+ */
+ async fetchIronDisclaimers({
+ country,
+ }: {
+ country: string;
+ }): Promise {
+ const url = new URL('/vendors/iron/disclaimers', this.#baseUrl);
+ url.searchParams.set('country', country);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchIronDisclaimers`, country],
+ queryFn: async () => this.#requestJson(url, { method: 'GET' }),
+ staleTime: inMilliseconds(5, Duration.Minute),
+ });
+ return this.#validateResponse(
+ data,
+ DisclaimersResponseStruct,
+ 'iron disclaimers',
+ ) as KycDisclaimer[];
+ }
+
+ /**
+ * Checks whether Iron still requires KYC for the authenticated canonical
+ * user. Unlike the MoonPay variant, this does not take an access token.
+ *
+ * @returns Whether KYC is required.
+ */
+ async checkIronKycRequired(): Promise<{ kycRequired: boolean }> {
+ const url = new URL('/vendors/iron/kyc-required', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:checkIronKycRequired`],
+ queryFn: async () =>
+ this.#requestJson(url, { method: 'POST', body: '{}' }),
+ // The requirement can change server-side, so always re-check.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ const { required } = this.#validateResponse(
+ data,
+ KycRequiredResponseStruct,
+ 'iron kyc-required',
+ );
+ return { kycRequired: required };
+ }
+
+ /**
+ * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the
+ * authenticated user. The API responds with 204 No Content on success.
+ *
+ * @param params - The consent parameters.
+ */
+ async submitConsents(params: SubmitConsentsParams): Promise {
+ const url = new URL('/consents', this.#baseUrl);
+ await this.fetchQuery({
+ queryKey: [
+ `${this.name}:submitConsents`,
+ params.ironDisclaimerIds,
+ params.sumsubTncSigned,
+ params.idosTncSigned,
+ params.kycLevel ?? 'standard',
+ ],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({
+ ironDisclaimerIds: params.ironDisclaimerIds,
+ sumsubTncSigned: params.sumsubTncSigned,
+ idosTncSigned: params.idosTncSigned,
+ kycLevel: params.kycLevel ?? 'standard',
+ }),
+ }),
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ }
+
+ /**
+ * Fetches the user-keyed simplified KYC status used by Money toast / banner
+ * surfaces (`GET /kyc/status`).
+ *
+ * @returns The simplified status payload.
+ */
+ async fetchKycStatus(): Promise {
+ const url = new URL('/kyc/status', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchKycStatus`],
+ queryFn: async () => this.#requestJson(url, { method: 'GET' }),
+ // Status is polled for toast flips, so it must always be fresh.
+ staleTime: 0,
+ cacheTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ KycUserStatusResponseStruct,
+ 'kyc status',
+ );
+ }
+
+ /**
+ * 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: params.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) {
+ let detail = '';
+ try {
+ const errorBody: unknown = await response.json();
+ if (errorBody && typeof errorBody === 'object') {
+ const record = errorBody as Record;
+ if (typeof record.message === 'string') {
+ detail = record.message;
+ } else if (typeof record.error === 'string') {
+ detail = record.error;
+ }
+ }
+ } catch {
+ // Ignore body parse failures; status alone is still useful.
+ }
+ throw new HttpError(
+ response.status,
+ `Fetching '${url.toString()}' failed with status '${response.status}'${
+ detail ? `: ${detail}` : ''
+ }`,
+ );
+ }
+
+ // Consent (and similar) endpoints return 204 No Content.
+ if (response.status === 204) {
+ return null;
+ }
+
+ 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..f986f8847a4 100644
--- a/packages/kyc-controller/src/index.test.ts
+++ b/packages/kyc-controller/src/index.test.ts
@@ -1,9 +1,19 @@
-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),
+ controllerName: 'KycController',
+ serviceName: 'KycService',
+ });
});
});
diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts
index 6972c117292..d6b24b3730a 100644
--- a/packages/kyc-controller/src/index.ts
+++ b/packages/kyc-controller/src/index.ts
@@ -1,9 +1,136 @@
-/**
- * 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,
+ KycControllerOptions,
+ KycControllerState,
+ KycControllerStateChangeEvent,
+ KycControllerStatusChangedEvent,
+} from './KycController.js';
+export type {
+ KycControllerAcceptTermsAndStartSessionAction,
+ KycControllerBuildAuthFrameUrlAction,
+ KycControllerBuildCheckFrameUrlAction,
+ KycControllerBuildResetFrameUrlAction,
+ KycControllerCheckKycRequiredAction,
+ KycControllerClearSavedTermsAction,
+ KycControllerCreateIronCustomerAction,
+ KycControllerGetCustomerIdentityAction,
+ KycControllerGetKycStatusAction,
+ KycControllerGetSessionStatusAction,
+ KycControllerHandleFrameMessageAction,
+ KycControllerInitializeAction,
+ KycControllerLoadDisclaimersAction,
+ KycControllerRefreshKycStatusAction,
+ KycControllerResetAction,
+ KycControllerStartSumSubAction,
+} from './KycController-method-action-types.js';
+
+export { KycService, serviceName } from './KycService.js';
+export type {
+ ApplicantAccessTokenResponse,
+ CheckKycRequiredParams,
+ CreateIronCustomerParams,
+ CreateSessionParams,
+ CreateUkycSessionParams,
+ GetSessionStatusParams,
+ GetWrappingKeyParams,
+ IronCustomerResponse,
+ JwksResponse,
+ KycServiceActions,
+ KycServiceCacheUpdatedEvent,
+ KycServiceEvents,
+ KycServiceGranularCacheUpdatedEvent,
+ KycServiceInvalidateQueriesAction,
+ KycServiceMessenger,
+ KycServiceOptions,
+ SubmitConsentsParams,
+ UkycSessionResponse,
+ WrappedEncryptionKey,
+ WrappingKeyResponse,
+} from './KycService.js';
+export type {
+ KycServiceCheckIronKycRequiredAction,
+ KycServiceCheckKycRequiredAction,
+ KycServiceCreateIronCustomerAction,
+ KycServiceCreateJourneyAction,
+ KycServiceCreateSessionAction,
+ KycServiceCreateUkycSessionAction,
+ KycServiceFetchDisclaimersAction,
+ KycServiceFetchIronDisclaimersAction,
+ KycServiceFetchJwksAction,
+ KycServiceFetchKycStatusAction,
+ KycServiceGetGeoCountryAction,
+ KycServiceGetSessionStatusAction,
+ KycServiceGetWrappingKeyAction,
+ KycServiceSubmitConsentsAction,
+} 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 {
+ KycCustomerIdentity,
+ KycDisclaimer,
+ KycPhase,
+ KycProduct,
+ KycSessionStatus,
+ KycSumSubLaunchParams,
+ KycSumSubLauncher,
+ KycSumSubStatus,
+ KycUserStatus,
+ KycUserStatusResponse,
+ 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';
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..9b1ee6ebfa8
--- /dev/null
+++ b/packages/kyc-controller/src/types.ts
@@ -0,0 +1,202 @@
+/**
+ * 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, card, and money can share one controller.
+ */
+export type KycProduct = 'ramps' | 'card' | 'money';
+
+/**
+ * Identity vendors supported behind the KYC surface.
+ *
+ * - `moonpay` — MoonPay Check/Auth frames + SumSub documents.
+ * - `iron` — Iron-only Money/VBA path: empty-shell customer → consents →
+ * SumSub, with no MoonPay Check/Auth frames.
+ */
+export type KycVendor = 'moonpay' | 'iron';
+
+/**
+ * Vendor-scoped identity for the currently authenticated KYC customer.
+ *
+ * Exposed to consumers (e.g. ramps) that must attach the vendor customer id to
+ * downstream provider calls without reading the full KYC state, which also
+ * holds session/access tokens. The identifier is session-scoped: it is only
+ * available once the customer has authenticated through the current flow and
+ * is cleared on `reset()`.
+ */
+export type KycCustomerIdentity = {
+ /** The identity vendor that issued {@link KycCustomerIdentity.id}. */
+ vendor: KycVendor;
+ /** The vendor customer id (e.g. MoonPay customer UUID). */
+ id: string;
+};
+
+/**
+ * User-keyed KYC status returned by `GET /kyc/status` and stored for Money
+ * toast / banner rendering. Collapses Iron + SumSub / relay state into the
+ * offsite contract.
+ */
+export type KycUserStatus =
+ | 'not-started'
+ | 'pending'
+ | 'need-more-information'
+ | 'terminal-failure'
+ | 'completed';
+
+/**
+ * Payload from `GET /kyc/status`, including optional fields that power the
+ * 3-state error contract (retryable SumSub vs terminal vs EDD).
+ */
+export type KycUserStatusResponse = {
+ status: KycUserStatus;
+ /** Present when the user can reopen a SumSub session (retryable path). */
+ sumsubSessionId?: string;
+ /** Machine-readable error code for terminal / EDD UX. */
+ errorCode?: string;
+};
+
+/**
+ * Phases of the end-to-end identity flow.
+ *
+ * - `idle` — nothing started.
+ * - `terms` — waiting for the customer to accept the vendor terms.
+ * - `session` — creating the vendor session (MoonPay) or posting consents
+ * (Iron).
+ * - `check` — running the invisible connection-check frame (MoonPay only).
+ * - `auth` — running the visible authentication (OTP) frame (MoonPay only).
+ * - `form` — authenticated. When the flow is scoped to a product, the
+ * KYC-required check runs automatically from here; otherwise the consumer
+ * drives it manually via `checkKycRequired`. Iron skips this phase.
+ * - `submit` — submitting the KYC-required check / launching SumSub.
+ * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` /
+ * `userStatus`. When KYC is required, the document-verification sub-flow is
+ * launched automatically.
+ * - `error` — flow halted; see `error`.
+ */
+export type KycPhase =
+ | 'idle'
+ | 'terms'
+ | 'session'
+ | 'check'
+ | 'auth'
+ | 'form'
+ | 'submit'
+ | 'done'
+ | 'error';
+
+/**
+ * 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..b9c8b8e5aa3
--- /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 { toBase64Url } from '../encoding.js';
+import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.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..f020337696f
--- /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 { toBase64Url } from '../encoding.js';
+import {
+ UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES,
+ UKYC_STORAGE_ACCESS_TOKEN_VERSION,
+} from './constants.js';
+import type { UkycClientMaterial } from './deriveClientMaterial.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..0098a6bbbc6
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/testToken.test.ts
@@ -0,0 +1,133 @@
+import { hexToBytes, stringToBytes } from '@metamask/utils';
+import { ed25519 } from '@noble/curves/ed25519';
+
+import { base64UrlToBytes } from '../encoding.js';
+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';
+
+// 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..2fe21e50845
--- /dev/null
+++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts
@@ -0,0 +1,65 @@
+import { toBase64Url } from '../encoding.js';
+import type { UkycClientMaterial } from './deriveClientMaterial.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/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/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md
index 9b27e131240..88d0c936d49 100644
--- a/packages/profile-sync-controller/CHANGELOG.md
+++ b/packages/profile-sync-controller/CHANGELOG.md
@@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
-- Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794))
-- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791))
+- Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9853](https://github.com/MetaMask/core/pull/9853))
## [29.0.0]
diff --git a/packages/profile-sync-controller/src/shared/storage-schema.ts b/packages/profile-sync-controller/src/shared/storage-schema.ts
index dc9f73fcfb6..e8e74f363e2 100644
--- a/packages/profile-sync-controller/src/shared/storage-schema.ts
+++ b/packages/profile-sync-controller/src/shared/storage-schema.ts
@@ -13,6 +13,7 @@ export const USER_STORAGE_FEATURE_NAMES = {
notifications: 'notifications',
accounts: 'accounts_v2',
addressBook: 'addressBook',
+ rampsAutoramps: 'rampsAutoramps',
};
export type UserStorageGenericFeatureName = string;
diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md
index c3573e3e5c8..97b6eb6e827 100644
--- a/packages/ramps-controller/CHANGELOG.md
+++ b/packages/ramps-controller/CHANGELOG.md
@@ -9,7 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679))
+- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from Profile Sync (`AuthenticationController:getSessionProfile`) via `NeoBankService:getCustomerByExternalId`, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when the wallet is not signed in or no MoonPay customer is mapped to the external id. ([#9853](https://github.com/MetaMask/core/pull/9853))
+- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation and Money Account wallet registration. ([#9853](https://github.com/MetaMask/core/pull/9853))
+- Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. ([#9853](https://github.com/MetaMask/core/pull/9853))
+- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id via Profile Sync → neobank-proxy external-id lookup, signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. ([#9853](https://github.com/MetaMask/core/pull/9853))
+- Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). ([#9853](https://github.com/MetaMask/core/pull/9853))
+
+### Changed
+
+- Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. ([#9859](https://github.com/MetaMask/core/pull/9859), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ([#9853](https://github.com/MetaMask/core/pull/9853))
+
+### Fixed
+
+- Keep the local `customerId` / `walletAddress` when a remote autoramp snapshot omits or blanks them. The proxy sends empty identity fields on partial status pushes, and `applyAutorampRemoteStatus` / `mapNeoBankAutorampToRemoteSnapshot` treated those as a clear, wiping valid local values during refresh-on-load and websocket pushes. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853))
## [20.0.0]
diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts
new file mode 100644
index 00000000000..956b7f2b6b4
--- /dev/null
+++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts
@@ -0,0 +1,147 @@
+/**
+ * This file is auto generated.
+ * Do not edit manually.
+ */
+
+import type { NeoBankService } from './NeoBankService.js';
+
+/**
+ * Fetches an autoramp account via neobank-proxy
+ * `GET /neobank/autoramps/{autoramp_id}` (MoonPay
+ * `GET /api/autoramps/{autoramp_id}`).
+ *
+ * @param autorampId - MoonPay / Ramp API autoramp id.
+ * @returns Remote snapshot for controller apply/refresh.
+ */
+export type NeoBankServiceGetAutorampAction = {
+ type: `NeoBankService:getAutoramp`;
+ handler: NeoBankService['getAutoramp'];
+};
+
+/**
+ * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`.
+ * Body is forwarded as opaque JSON (MoonPay address schema).
+ *
+ * @param body - Pix address registration payload.
+ * @param options - Optional idempotency key.
+ * @returns Parsed proxy JSON response.
+ */
+export type NeoBankServiceRegisterPixAddressAction = {
+ type: `NeoBankService:registerPixAddress`;
+ handler: NeoBankService['registerPixAddress'];
+};
+
+/**
+ * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`.
+ *
+ * @param query - Quote query params (forwarded as-is).
+ * @returns Parsed proxy JSON response.
+ */
+export type NeoBankServiceGetAutorampQuoteAction = {
+ type: `NeoBankService:getAutorampQuote`;
+ handler: NeoBankService['getAutorampQuote'];
+};
+
+/**
+ * Creates an autoramp from a signed quote via neobank-proxy
+ * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`).
+ *
+ * @param body - CreateAutoramp / signed-quote payload (forwarded as-is).
+ * @param options - Optional idempotency key.
+ * @returns Remote snapshot for controller apply/refresh.
+ */
+export type NeoBankServiceCreateAutorampAction = {
+ type: `NeoBankService:createAutoramp`;
+ handler: NeoBankService['createAutoramp'];
+};
+
+/**
+ * Fetches a quote for an existing autoramp via neobank-proxy
+ * `GET /neobank/autoramps/{autoramp_id}/quote`.
+ *
+ * @param autorampId - Autoramp id.
+ * @param query - Quote query params (forwarded as-is).
+ * @returns Parsed proxy JSON response.
+ */
+export type NeoBankServiceGetAutorampQuoteForAutorampAction = {
+ type: `NeoBankService:getAutorampQuoteForAutoramp`;
+ handler: NeoBankService['getAutorampQuoteForAutoramp'];
+};
+
+/**
+ * Attaches a signed quote to an autoramp via neobank-proxy
+ * `POST /neobank/autoramps/{autoramp_id}/quotes`.
+ *
+ * @param autorampId - Autoramp id.
+ * @param body - Quote attachment payload (forwarded as-is).
+ * @param options - Optional idempotency key.
+ * @returns Parsed proxy JSON response.
+ */
+export type NeoBankServiceAttachAutorampQuoteAction = {
+ type: `NeoBankService:attachAutorampQuote`;
+ handler: NeoBankService['attachAutorampQuote'];
+};
+
+/**
+ * Fetches a customer by partner external id via neobank-proxy
+ * `GET /neobank/customers/{external_id}/external`.
+ *
+ * @param externalId - Partner-assigned external customer id.
+ * @returns Parsed proxy JSON response.
+ */
+export type NeoBankServiceGetCustomerByExternalIdAction = {
+ type: `NeoBankService:getCustomerByExternalId`;
+ handler: NeoBankService['getCustomerByExternalId'];
+};
+
+/**
+ * Resolves Iron's internal customer id via neobank-proxy customer lookup,
+ * using the MetaMask canonical profile id as the partner `external_id`.
+ *
+ * @returns Iron's internal customer id.
+ */
+export type NeoBankServiceGetMoonpayCustomerIdAction = {
+ type: `NeoBankService:getMoonpayCustomerId`;
+ handler: NeoBankService['getMoonpayCustomerId'];
+};
+
+/**
+ * Checks whether a Monad Money Account address is already registered for the
+ * given Iron customer.
+ *
+ * @param params - Customer id and address to check.
+ * @param params.customerId - Iron / MoonPay customer UUID.
+ * @param params.address - Money Account address.
+ * @returns Active, disabled, or absent registration status.
+ */
+export type NeoBankServiceGetWalletRegistrationStatusAction = {
+ type: `NeoBankService:getWalletRegistrationStatus`;
+ handler: NeoBankService['getWalletRegistrationStatus'];
+};
+
+/**
+ * Submits a signed Monad Money Account ownership proof via neobank-proxy
+ * `POST /neobank/addresses/crypto/selfhosted`.
+ *
+ * @param params - Signed ownership proof.
+ * @returns Registered wallet record.
+ */
+export type NeoBankServiceRegisterSelfHostedWalletAction = {
+ type: `NeoBankService:registerSelfHostedWallet`;
+ handler: NeoBankService['registerSelfHostedWallet'];
+};
+
+/**
+ * Union of all NeoBankService action types.
+ */
+export type NeoBankServiceMethodActions =
+ | NeoBankServiceGetAutorampAction
+ | NeoBankServiceRegisterPixAddressAction
+ | NeoBankServiceGetAutorampQuoteAction
+ | NeoBankServiceCreateAutorampAction
+ | NeoBankServiceGetAutorampQuoteForAutorampAction
+ | NeoBankServiceAttachAutorampQuoteAction
+ | NeoBankServiceGetCustomerByExternalIdAction
+ | NeoBankServiceGetMoonpayCustomerIdAction
+ | NeoBankServiceGetWalletRegistrationStatusAction
+ | NeoBankServiceRegisterSelfHostedWalletAction;
diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts
new file mode 100644
index 00000000000..94cc82f2f05
--- /dev/null
+++ b/packages/ramps-controller/src/NeoBankService.test.ts
@@ -0,0 +1,588 @@
+import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
+import type { MockAnyNamespace } from '@metamask/messenger';
+import nock, { cleanAll } from 'nock';
+
+import {
+ mapNeoBankAutorampToRemoteSnapshot,
+ NeoBankService,
+} from './NeoBankService.js';
+import type { NeoBankServiceMessenger } from './NeoBankService.js';
+import { RampsEnvironment } from './RampsService.js';
+
+const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io';
+
+/**
+ * Builds a NeoBankService with AuthenticationController bearer auth stubbed.
+ *
+ * @param options - Optional constructor overrides.
+ * @param options.environment - Ramp environment for host selection.
+ * @param options.baseUrlOverride - Overrides the environment-derived host.
+ * @param options.omitDefaults - Pass `true` to exercise constructor defaulted
+ * parameters (`environment`, `policyOptions`).
+ * @param options.canonicalProfileId - Canonical profile id returned by the
+ * stubbed `AuthenticationController:getSessionProfile` (wallet registration).
+ * @returns Service instance for the test.
+ */
+function createService(options?: {
+ environment?: RampsEnvironment;
+ baseUrlOverride?: string;
+ omitDefaults?: boolean;
+ canonicalProfileId?: string;
+}): NeoBankService {
+ const rootMessenger = new Messenger({
+ namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace,
+ });
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getBearerToken',
+ async () => 'test-token',
+ );
+ const canonicalProfileId =
+ options?.canonicalProfileId ?? 'canonical-profile-1';
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ async () =>
+ ({
+ identifierId: 'id-1',
+ profileId: canonicalProfileId,
+ canonicalProfileId,
+ metaMetricsId: 'mm-1',
+ }) as never,
+ );
+
+ const messenger = new Messenger({
+ namespace: 'NeoBankService',
+ parent: rootMessenger,
+ }) as unknown as NeoBankServiceMessenger;
+ rootMessenger.delegate({
+ messenger,
+ actions: [
+ 'AuthenticationController:getBearerToken',
+ 'AuthenticationController:getSessionProfile',
+ ],
+ });
+
+ if (options?.omitDefaults) {
+ return new NeoBankService({
+ messenger,
+ context: 'test',
+ fetch: globalThis.fetch.bind(globalThis),
+ baseUrlOverride: options.baseUrlOverride,
+ });
+ }
+
+ return new NeoBankService({
+ messenger,
+ environment: options?.environment ?? RampsEnvironment.Staging,
+ context: 'test',
+ fetch: globalThis.fetch.bind(globalThis),
+ policyOptions: { maxRetries: 0 },
+ baseUrlOverride: options?.baseUrlOverride,
+ });
+}
+
+describe('NeoBankService', () => {
+ afterEach(() => {
+ cleanAll();
+ });
+
+ describe('mapNeoBankAutorampToRemoteSnapshot', () => {
+ it('maps MoonPay-shaped fields into a remote snapshot', () => {
+ expect(
+ mapNeoBankAutorampToRemoteSnapshot({
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Approved',
+ wallet_address: '0xabc',
+ deposit_rails: [{ type: 'Iban' }],
+ }),
+ ).toStrictEqual({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: 'Approved',
+ depositRailsSummary: { ready: true },
+ });
+ });
+
+ it('falls back to recipient_account.address when wallet_address is absent', () => {
+ expect(
+ mapNeoBankAutorampToRemoteSnapshot({
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Pending',
+ recipient_account: { address: '0xfrom-recipient' },
+ }),
+ ).toMatchObject({
+ walletAddress: '0xfrom-recipient',
+ depositRailsSummary: undefined,
+ });
+ });
+
+ it('marks deposit rails not ready when Approved without rails', () => {
+ expect(
+ mapNeoBankAutorampToRemoteSnapshot({
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Approved',
+ }),
+ ).toMatchObject({
+ depositRailsSummary: { ready: false },
+ });
+ });
+ });
+
+ describe('getAutoramp', () => {
+ it('gets /neobank/autoramps/{id} with bearer auth', async () => {
+ const scope = nock(STAGING_BASE)
+ .get(/\/neobank\/autoramps\/ar-1/u)
+ .matchHeader('Authorization', 'Bearer test-token')
+ .reply(200, {
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Authorized',
+ wallet_address: '0xabc',
+ });
+
+ const service = createService();
+ const snapshot = await service.getAutoramp('ar-1');
+
+ expect(scope.isDone()).toBe(true);
+ expect(snapshot).toMatchObject({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: 'Authorized',
+ walletAddress: '0xabc',
+ });
+ });
+
+ it('throws HttpError when the proxy returns a non-2xx status', async () => {
+ nock(STAGING_BASE)
+ .get(/\/neobank\/autoramps\/missing/u)
+ .reply(404);
+
+ const service = createService();
+ await expect(service.getAutoramp('missing')).rejects.toThrow(
+ /failed with status '404'/u,
+ );
+ });
+
+ it('throws when the response body is malformed', async () => {
+ nock(STAGING_BASE)
+ .get(/\/neobank\/autoramps\/ar-1/u)
+ .reply(200, { status: 'Authorized' });
+
+ const service = createService();
+ await expect(service.getAutoramp('ar-1')).rejects.toThrow(
+ 'Malformed response received from neo-bank autoramp API',
+ );
+ });
+ });
+
+ describe('registerPixAddress', () => {
+ it('posts /neobank/addresses/pix with JSON body and bearer auth', async () => {
+ const body = {
+ type: 'Pix',
+ pix_key: 'user@example.com',
+ customer_id: 'cust-1',
+ };
+
+ const scope = nock(STAGING_BASE)
+ .post('/neobank/addresses/pix', body)
+ .query(true)
+ .matchHeader('Authorization', 'Bearer test-token')
+ .matchHeader('Content-Type', 'application/json')
+ .reply(200, { id: 'addr-1', ...body });
+
+ const service = createService();
+ const result = await service.registerPixAddress(body);
+
+ expect(scope.isDone()).toBe(true);
+ expect(result).toMatchObject({ id: 'addr-1' });
+ });
+
+ it('forwards Idempotency-Key when provided', async () => {
+ const scope = nock(STAGING_BASE)
+ .post('/neobank/addresses/pix', { pix_key: 'k' })
+ .query(true)
+ .matchHeader('Idempotency-Key', 'idem-1')
+ .reply(200, { id: 'addr-1' });
+
+ const service = createService();
+ await service.registerPixAddress(
+ { pix_key: 'k' },
+ { idempotencyKey: 'idem-1' },
+ );
+
+ expect(scope.isDone()).toBe(true);
+ });
+ });
+
+ describe('getAutorampQuote', () => {
+ it('gets /neobank/autoramps/quote with query params', async () => {
+ const scope = nock(STAGING_BASE)
+ .get('/neobank/autoramps/quote')
+ .query((query) => {
+ return (
+ query.amount === '100' &&
+ query.currency === 'BRL' &&
+ typeof query.sdk === 'string' &&
+ typeof query.controller === 'string' &&
+ query.context === 'test'
+ );
+ })
+ .matchHeader('Authorization', 'Bearer test-token')
+ .reply(200, { quote_id: 'q-1', amount: '100' });
+
+ const service = createService();
+ const result = await service.getAutorampQuote({
+ amount: '100',
+ currency: 'BRL',
+ });
+
+ expect(scope.isDone()).toBe(true);
+ expect(result).toMatchObject({ quote_id: 'q-1' });
+ });
+ });
+
+ describe('createAutoramp', () => {
+ it('posts /neobank/autoramps and maps the Autoramp response', async () => {
+ const body = {
+ signed_quote: 'sig',
+ customer_id: 'cust-1',
+ };
+
+ const scope = nock(STAGING_BASE)
+ .post('/neobank/autoramps', body)
+ .query(true)
+ .matchHeader('Authorization', 'Bearer test-token')
+ .matchHeader('Content-Type', 'application/json')
+ .reply(201, {
+ id: 'ar-new',
+ customer_id: 'cust-1',
+ status: 'Pending',
+ wallet_address: '0xdef',
+ });
+
+ const service = createService();
+ const snapshot = await service.createAutoramp(body);
+
+ expect(scope.isDone()).toBe(true);
+ expect(snapshot).toMatchObject({
+ id: 'ar-new',
+ customerId: 'cust-1',
+ status: 'Pending',
+ walletAddress: '0xdef',
+ });
+ });
+
+ it('forwards Idempotency-Key when provided', async () => {
+ const scope = nock(STAGING_BASE)
+ .post('/neobank/autoramps', { signed_quote: 'sig' })
+ .query(true)
+ .matchHeader('Idempotency-Key', 'create-idem')
+ .reply(201, {
+ id: 'ar-2',
+ customer_id: 'cust-1',
+ status: 'Pending',
+ });
+
+ const service = createService();
+ await service.createAutoramp(
+ { signed_quote: 'sig' },
+ { idempotencyKey: 'create-idem' },
+ );
+
+ expect(scope.isDone()).toBe(true);
+ });
+
+ it('throws when the response body is malformed', async () => {
+ nock(STAGING_BASE)
+ .post('/neobank/autoramps')
+ .query(true)
+ .reply(201, { status: 'Pending' });
+
+ const service = createService();
+ await expect(
+ service.createAutoramp({ signed_quote: 'sig' }),
+ ).rejects.toThrow(
+ 'Malformed response received from neo-bank autoramp API',
+ );
+ });
+ });
+
+ describe('getAutorampQuoteForAutoramp', () => {
+ it('gets /neobank/autoramps/{id}/quote with query params', async () => {
+ const scope = nock(STAGING_BASE)
+ .get('/neobank/autoramps/ar-1/quote')
+ .query((query) => {
+ return query.amount === '50' && query.context === 'test';
+ })
+ .matchHeader('Authorization', 'Bearer test-token')
+ .reply(200, { quote_id: 'q-2' });
+
+ const service = createService();
+ const result = await service.getAutorampQuoteForAutoramp('ar-1', {
+ amount: '50',
+ });
+
+ expect(scope.isDone()).toBe(true);
+ expect(result).toMatchObject({ quote_id: 'q-2' });
+ });
+ });
+
+ describe('attachAutorampQuote', () => {
+ it('posts /neobank/autoramps/{id}/quotes with JSON body', async () => {
+ const body = { signed_quote: 'attach-sig' };
+
+ const scope = nock(STAGING_BASE)
+ .post('/neobank/autoramps/ar-1/quotes', body)
+ .query(true)
+ .matchHeader('Authorization', 'Bearer test-token')
+ .matchHeader('Content-Type', 'application/json')
+ .reply(200, { quote_id: 'q-attached' });
+
+ const service = createService();
+ const result = await service.attachAutorampQuote('ar-1', body);
+
+ expect(scope.isDone()).toBe(true);
+ expect(result).toMatchObject({ quote_id: 'q-attached' });
+ });
+ });
+
+ describe('getCustomerByExternalId', () => {
+ it('gets /neobank/customers/{external_id}/external', async () => {
+ const scope = nock(STAGING_BASE)
+ .get('/neobank/customers/ext-1/external')
+ .query(true)
+ .matchHeader('Authorization', 'Bearer test-token')
+ .reply(200, { id: 'cust-1', external_id: 'ext-1' });
+
+ const service = createService();
+ const result = await service.getCustomerByExternalId('ext-1');
+
+ expect(scope.isDone()).toBe(true);
+ expect(result).toMatchObject({ id: 'cust-1', external_id: 'ext-1' });
+ });
+ });
+
+ describe('Money Account wallet registration', () => {
+ it('resolves the Iron customer id via neobank customer lookup', async () => {
+ nock(STAGING_BASE)
+ .get('/neobank/customers/canonical-profile-1/external')
+ .matchHeader('authorization', 'Bearer test-token')
+ .reply(200, {
+ id: 'iron-customer-1',
+ external_id: 'canonical-profile-1',
+ });
+
+ const service = createService();
+
+ expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1');
+ });
+
+ it('checks Monad wallet registration status for a customer', async () => {
+ nock(STAGING_BASE)
+ .get('/neobank/addresses/crypto/iron-customer-1')
+ .query({ filter: 'SelfHosted' })
+ .reply(200, []);
+
+ const service = createService();
+
+ expect(
+ await service.getWalletRegistrationStatus({
+ customerId: 'iron-customer-1',
+ address: '0xabc',
+ }),
+ ).toStrictEqual({ type: 'absent' });
+ });
+
+ it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => {
+ nock(STAGING_BASE)
+ .post(
+ '/neobank/addresses/crypto/selfhosted',
+ {
+ customer_id: 'iron-customer-1',
+ address: '0xabc',
+ blockchain: 'Monad',
+ message: 'ownership message',
+ signature: '0xsig',
+ },
+ { reqheaders: { 'idempotency-key': 'idem-1' } },
+ )
+ .reply(200, {
+ id: 'wallet-1',
+ address: '0xabc',
+ disabled: false,
+ });
+
+ const service = createService();
+
+ expect(
+ await service.registerSelfHostedWallet({
+ customerId: 'iron-customer-1',
+ address: '0xabc',
+ message: 'ownership message',
+ signature: '0xsig',
+ idempotencyKey: 'idem-1',
+ }),
+ ).toMatchObject({
+ type: 'registered',
+ registration: { id: 'wallet-1', blockchain: 'Monad' },
+ });
+ });
+
+ it('uses the baseUrlOverride host for wallet routes', async () => {
+ const overrideUrl = 'https://on-ramp.dev-api.cx.metamask.io';
+ nock(overrideUrl)
+ .get('/neobank/customers/canonical-profile-1/external')
+ .reply(200, { id: 'iron-customer-1' });
+
+ const service = createService({ baseUrlOverride: overrideUrl });
+
+ expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1');
+ });
+
+ it('throws when the session profile has no usable external id', async () => {
+ const service = createService({ canonicalProfileId: '' });
+
+ await expect(service.getMoonpayCustomerId()).rejects.toThrow(
+ /Unable to resolve MetaMask canonical profile id/u,
+ );
+ });
+ });
+
+ describe('environments and policy hooks', () => {
+ it.each([
+ [RampsEnvironment.Production, 'https://on-ramp.api.cx.metamask.io'],
+ [RampsEnvironment.Development, 'https://on-ramp.dev-api.cx.metamask.io'],
+ [RampsEnvironment.Local, 'http://localhost:3000'],
+ ] as const)(
+ 'uses the %s host for getAutoramp',
+ async (environment, host) => {
+ const scope = nock(host)
+ .get(/\/neobank\/autoramps\/ar-1/u)
+ .reply(200, {
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Authorized',
+ });
+
+ const service = createService({ environment });
+ await service.getAutoramp('ar-1');
+
+ expect(scope.isDone()).toBe(true);
+ },
+ );
+
+ it('uses constructor defaults for environment and policyOptions', async () => {
+ const scope = nock(STAGING_BASE)
+ .get(/\/neobank\/autoramps\/ar-1/u)
+ .reply(200, {
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Authorized',
+ });
+
+ const service = createService({ omitDefaults: true });
+ await service.getAutoramp('ar-1');
+
+ expect(scope.isDone()).toBe(true);
+ });
+
+ it('calls getAutorampQuote and getAutorampQuoteForAutoramp without query', async () => {
+ const quoteScope = nock(STAGING_BASE)
+ .get('/neobank/autoramps/quote')
+ .query(true)
+ .reply(200, { quote_id: 'q-default' });
+ const forAutorampScope = nock(STAGING_BASE)
+ .get('/neobank/autoramps/ar-1/quote')
+ .query(true)
+ .reply(200, { quote_id: 'q-for-ar' });
+
+ const service = createService();
+ await service.getAutorampQuote();
+ await service.getAutorampQuoteForAutoramp('ar-1');
+
+ expect(quoteScope.isDone()).toBe(true);
+ expect(forAutorampScope.isDone()).toBe(true);
+ });
+
+ it('uses baseUrlOverride when provided', async () => {
+ const scope = nock('http://custom-neobank.test')
+ .get(/\/neobank\/autoramps\/ar-1/u)
+ .reply(200, {
+ id: 'ar-1',
+ customer_id: 'cust-1',
+ status: 'Authorized',
+ });
+
+ const service = createService({
+ baseUrlOverride: 'http://custom-neobank.test',
+ });
+ await service.getAutoramp('ar-1');
+
+ expect(scope.isDone()).toBe(true);
+ });
+
+ it('throws for an invalid environment', async () => {
+ await expect(
+ createService({
+ environment: 'bogus' as RampsEnvironment,
+ }).getAutoramp('ar-1'),
+ ).rejects.toThrow(/Invalid environment/u);
+ });
+
+ it('throws HttpError on non-2xx POST responses', async () => {
+ nock(STAGING_BASE)
+ .post('/neobank/addresses/pix')
+ .query(true)
+ .reply(422, { error: 'bad' });
+
+ const service = createService();
+ await expect(
+ service.registerPixAddress({ pix_key: 'k' }),
+ ).rejects.toThrow(/failed with status '422'/u);
+ });
+
+ it('omits nullish query values when building quote URLs', async () => {
+ const scope = nock(STAGING_BASE)
+ .get('/neobank/autoramps/quote')
+ .query((query) => {
+ return (
+ query.amount === '10' &&
+ query.currency === undefined &&
+ query.optional === undefined
+ );
+ })
+ .reply(200, { quote_id: 'q-nullish' });
+
+ const service = createService();
+ await service.getAutorampQuote({
+ amount: '10',
+ currency: undefined,
+ optional: null,
+ });
+
+ expect(scope.isDone()).toBe(true);
+ });
+
+ it('registers onRetry, onBreak, and onDegraded listeners', () => {
+ const service = createService();
+ const onRetry = jest.fn();
+ const onBreak = jest.fn();
+ const onDegraded = jest.fn();
+
+ const retrySub = service.onRetry(onRetry);
+ const breakSub = service.onBreak(onBreak);
+ const degradedSub = service.onDegraded(onDegraded);
+
+ expect(typeof retrySub.dispose).toBe('function');
+ expect(typeof breakSub.dispose).toBe('function');
+ expect(typeof degradedSub.dispose).toBe('function');
+
+ retrySub.dispose();
+ breakSub.dispose();
+ degradedSub.dispose();
+ });
+ });
+});
diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts
new file mode 100644
index 00000000000..bfefa07ac36
--- /dev/null
+++ b/packages/ramps-controller/src/NeoBankService.ts
@@ -0,0 +1,599 @@
+import type {
+ CreateServicePolicyOptions,
+ ServicePolicy,
+} from '@metamask/controller-utils';
+import {
+ createServicePolicy,
+ handleWhen,
+ HttpError,
+} from '@metamask/controller-utils';
+import type { Messenger } from '@metamask/messenger';
+import type { AuthenticationController } from '@metamask/profile-sync-controller';
+
+import packageJson from '../package.json';
+import type {
+ AutorampDepositRailsSummary,
+ AutorampRemoteSnapshot,
+} from './autorampAccount.js';
+import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js';
+import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js';
+import { WalletRegistrationService } from './wallet-registration-service.js';
+import type {
+ RegistrationOutcome,
+ RegistrationStatus,
+} from './wallet-registration-service.js';
+
+/**
+ * Name of the NeoBankService messenger namespace.
+ */
+export const serviceName = 'NeoBankService';
+
+/**
+ * Determines whether a failed neo-bank request is worth re-issuing.
+ *
+ * 4xx responses describe the request or the account's state (e.g. 403
+ * "Customer is not active", 422 validation), so repeating them only multiplies
+ * the same rejection. 429 stays retryable alongside 5xx and non-HTTP
+ * network/timeout errors.
+ *
+ * @param error - Error thrown while performing the request.
+ * @returns `true` when the error is worth retrying.
+ */
+function isRetryableError(error: unknown): boolean {
+ if (error instanceof HttpError) {
+ if (error.httpStatus === 429) {
+ return true;
+ }
+ return error.httpStatus < 400 || error.httpStatus >= 500;
+ }
+ return true;
+}
+
+/**
+ * Raw autoramp payload from the MetaMask Ramp API neo-bank proxy.
+ * Shape mirrors MoonPay Enterprise `GET /api/autoramps/{autoramp_id}`.
+ * The Ramp API handles partner auth / headers; the client only sends the
+ * MetaMask bearer token.
+ */
+export type NeoBankAutorampResponse = {
+ id: string;
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field
+ customer_id: string;
+ status: string;
+ /**
+ * Destination wallet when present on the proxy response.
+ * Field name may evolve with the Ramp API contract.
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field
+ wallet_address?: string;
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field
+ recipient_account?: {
+ address?: string;
+ };
+ // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field
+ deposit_rails?: unknown[];
+};
+
+/**
+ * Optional headers for neo-bank mutating requests.
+ */
+export type NeoBankRequestOptions = {
+ /**
+ * Forwarded as `Idempotency-Key` when set (MoonPay requires it on some POSTs;
+ * neobank-proxy generates one when omitted).
+ */
+ idempotencyKey?: string;
+};
+
+/**
+ * Query string values accepted by neo-bank GET helpers.
+ */
+export type NeoBankQueryParams = Record<
+ string,
+ string | number | boolean | undefined | null
+>;
+
+export type GetWalletRegistrationStatusParams = {
+ customerId: string;
+ address: string;
+};
+
+export type RegisterSelfHostedWalletParams = {
+ customerId: string;
+ address: string;
+ message: string;
+ signature: string;
+ /**
+ * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable
+ * key across retries of the same ownership body.
+ */
+ idempotencyKey?: string;
+};
+
+const MESSENGER_EXPOSED_METHODS = [
+ 'getAutoramp',
+ 'registerPixAddress',
+ 'getAutorampQuote',
+ 'createAutoramp',
+ 'getAutorampQuoteForAutoramp',
+ 'attachAutorampQuote',
+ 'getCustomerByExternalId',
+ 'getMoonpayCustomerId',
+ 'getWalletRegistrationStatus',
+ 'registerSelfHostedWallet',
+] as const;
+
+/**
+ * Actions that {@link NeoBankService} exposes to other consumers.
+ */
+export type NeoBankServiceActions = NeoBankServiceMethodActions;
+
+type AllowedActions =
+ | AuthenticationController.AuthenticationControllerGetBearerTokenAction
+ | AuthenticationController.AuthenticationControllerGetSessionProfileAction;
+
+export type NeoBankServiceEvents = never;
+
+type AllowedEvents = never;
+
+/**
+ * The messenger restricted to actions and events accessed by
+ * {@link NeoBankService}.
+ */
+export type NeoBankServiceMessenger = Messenger<
+ typeof serviceName,
+ NeoBankServiceActions | AllowedActions,
+ NeoBankServiceEvents | AllowedEvents
+>;
+
+/**
+ * Builds a path under the neobank-proxy global prefix.
+ *
+ * Live neobank-proxy (#1124) mounts routes at `/neobank` on the on-ramp.api
+ * host (ALB path routing, no rewrite). Prefer this over `/api/v2/...` so Core
+ * matches the proxy that ships.
+ *
+ * @param path - Path under `/neobank` (no leading slash).
+ * @returns Absolute path segment for URL join against the Ramp API host.
+ */
+function getNeoBankPath(path: string): string {
+ return `neobank/${path.replace(/^\//u, '')}`;
+}
+
+/**
+ * Resolves the Ramp API host for neo-bank calls (same hosts as {@link RampsService}).
+ *
+ * @param environment - Ramp environment.
+ * @returns Base URL.
+ */
+function getBaseUrl(environment: RampsEnvironment): string {
+ switch (environment) {
+ case RampsEnvironment.Production:
+ return 'https://on-ramp.api.cx.metamask.io';
+ case RampsEnvironment.Staging:
+ return 'https://on-ramp.uat-api.cx.metamask.io';
+ case RampsEnvironment.Development:
+ return 'https://on-ramp.dev-api.cx.metamask.io';
+ case RampsEnvironment.Local:
+ return 'http://localhost:3000';
+ default:
+ throw new Error(`Invalid environment: ${String(environment)}`);
+ }
+}
+
+/**
+ * Maps a Ramp API / MoonPay-shaped autoramp response into the local remote snapshot.
+ *
+ * @param response - Proxy response body.
+ * @returns Snapshot consumed by {@link applyAutorampRemoteStatus}.
+ */
+export function mapNeoBankAutorampToRemoteSnapshot(
+ response: NeoBankAutorampResponse,
+): AutorampRemoteSnapshot {
+ const depositRails = response.deposit_rails;
+ const hasDepositRails =
+ Array.isArray(depositRails) && depositRails.length > 0;
+ const depositRailsSummary: AutorampDepositRailsSummary | undefined =
+ hasDepositRails || response.status === 'Approved'
+ ? {
+ ready: response.status === 'Approved' && hasDepositRails,
+ }
+ : undefined;
+
+ return {
+ id: response.id,
+ customerId: response.customer_id,
+ walletAddress:
+ response.wallet_address !== undefined &&
+ response.wallet_address.length > 0
+ ? response.wallet_address
+ : response.recipient_account?.address,
+ status: response.status,
+ depositRailsSummary,
+ };
+}
+
+/**
+ * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy).
+ *
+ * Lives alongside {@link RampsService} and {@link TransakService}. Authentication
+ * and MoonPay partner headers are handled by the Ramp API; this service only
+ * attaches the MetaMask user bearer token.
+ *
+ * Paths use the neobank-proxy `/neobank` prefix on the on-ramp.api host.
+ */
+export class NeoBankService {
+ readonly name: typeof serviceName;
+
+ readonly #messenger: NeoBankServiceMessenger;
+
+ readonly #fetch: typeof fetch;
+
+ readonly #policy: ServicePolicy;
+
+ readonly #environment: RampsEnvironment;
+
+ readonly #context: string;
+
+ readonly #baseUrlOverride?: string;
+
+ #walletRegistrationService: WalletRegistrationService | undefined;
+
+ constructor({
+ messenger,
+ environment = RampsEnvironment.Staging,
+ context,
+ fetch: fetchFunction,
+ policyOptions = {},
+ baseUrlOverride,
+ }: {
+ messenger: NeoBankServiceMessenger;
+ environment?: RampsEnvironment;
+ context: string;
+ fetch: typeof fetch;
+ policyOptions?: CreateServicePolicyOptions;
+ baseUrlOverride?: string;
+ }) {
+ this.name = serviceName;
+ this.#messenger = messenger;
+ this.#fetch = fetchFunction;
+ this.#policy = createServicePolicy({
+ retryFilterPolicy: handleWhen(isRetryableError),
+ ...policyOptions,
+ });
+ this.#environment = environment;
+ this.#context = context;
+ this.#baseUrlOverride = baseUrlOverride;
+
+ this.#messenger.registerMethodActionHandlers(
+ this,
+ MESSENGER_EXPOSED_METHODS,
+ );
+ }
+
+ #getBaseUrl(): string {
+ if (this.#baseUrlOverride) {
+ return this.#baseUrlOverride;
+ }
+ return getBaseUrl(this.#environment);
+ }
+
+ /**
+ * Lazily builds the wallet registration client. Deferred so constructing the
+ * service never resolves the base URL eagerly (an invalid environment only
+ * throws when a request is made, matching the other neo-bank methods).
+ *
+ * @returns The wallet registration client.
+ */
+ #getWalletRegistrationService(): WalletRegistrationService {
+ this.#walletRegistrationService ??= new WalletRegistrationService({
+ fetch: this.#fetch,
+ baseUrl: this.#getBaseUrl(),
+ getAuthToken: async (): Promise =>
+ this.#messenger.call('AuthenticationController:getBearerToken'),
+ getExternalId: async (): Promise =>
+ this.#getCanonicalExternalId(),
+ });
+ return this.#walletRegistrationService;
+ }
+
+ async #getRequestHeaders(
+ options: NeoBankRequestOptions = {},
+ ): Promise> {
+ const bearerToken = await this.#messenger.call(
+ 'AuthenticationController:getBearerToken',
+ );
+ const headers: Record = {
+ Authorization: `Bearer ${bearerToken}`,
+ };
+ if (options.idempotencyKey) {
+ headers['Idempotency-Key'] = options.idempotencyKey;
+ }
+ return headers;
+ }
+
+ #buildUrl(path: string, query?: NeoBankQueryParams): URL {
+ const url = new URL(getNeoBankPath(path), this.#getBaseUrl());
+ url.searchParams.set('sdk', RAMPS_SDK_VERSION);
+ url.searchParams.set('controller', packageJson.version);
+ url.searchParams.set('context', this.#context);
+ if (query) {
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined && value !== null) {
+ url.searchParams.set(key, String(value));
+ }
+ }
+ }
+ return url;
+ }
+
+ /**
+ * Throws an {@link HttpError} that carries the upstream response body.
+ *
+ * The neobank-proxy mirrors MoonPay's status *and* body verbatim, so the
+ * body is usually the only place that explains a 4xx (e.g. which field or
+ * permission was rejected). Dropping it makes failures undiagnosable.
+ *
+ * @param url - Request URL, for context in the message.
+ * @param response - Non-OK fetch response.
+ */
+ async #throwHttpError(url: URL, response: Response): Promise {
+ let detail = '';
+ try {
+ const body = (await response.text()).trim();
+ if (body) {
+ detail = ` - ${body.slice(0, 500)}`;
+ }
+ } catch {
+ // Body already consumed or unreadable; the status alone still helps.
+ }
+ throw new HttpError(
+ response.status,
+ `Fetching '${url.toString()}' failed with status '${response.status}'${detail}`,
+ );
+ }
+
+ async #getJson(
+ path: string,
+ query?: NeoBankQueryParams,
+ ): Promise {
+ const url = this.#buildUrl(path, query);
+ return this.#policy.execute(async () => {
+ const headers = await this.#getRequestHeaders();
+ const fetchResponse = await this.#fetch(url, { headers });
+ if (!fetchResponse.ok) {
+ await this.#throwHttpError(url, fetchResponse);
+ }
+ return fetchResponse.json() as Promise;
+ });
+ }
+
+ async #postJson(
+ path: string,
+ body: Record,
+ options: NeoBankRequestOptions,
+ ): Promise {
+ const url = this.#buildUrl(path);
+ return this.#policy.execute(async () => {
+ const headers = await this.#getRequestHeaders(options);
+ headers['Content-Type'] = 'application/json';
+ const fetchResponse = await this.#fetch(url, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ });
+ if (!fetchResponse.ok) {
+ await this.#throwHttpError(url, fetchResponse);
+ }
+ return fetchResponse.json() as Promise;
+ });
+ }
+
+ #mapAutorampResponse(
+ response: NeoBankAutorampResponse,
+ ): AutorampRemoteSnapshot {
+ if (!response || typeof response !== 'object' || !response.id) {
+ throw new Error('Malformed response received from neo-bank autoramp API');
+ }
+ return mapNeoBankAutorampToRemoteSnapshot(response);
+ }
+
+ /**
+ * Fetches an autoramp account via neobank-proxy
+ * `GET /neobank/autoramps/{autoramp_id}` (MoonPay
+ * `GET /api/autoramps/{autoramp_id}`).
+ *
+ * @param autorampId - MoonPay / Ramp API autoramp id.
+ * @returns Remote snapshot for controller apply/refresh.
+ */
+ async getAutoramp(autorampId: string): Promise {
+ const response = await this.#getJson(
+ `autoramps/${encodeURIComponent(autorampId)}`,
+ );
+ return this.#mapAutorampResponse(response);
+ }
+
+ /**
+ * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`.
+ * Body is forwarded as opaque JSON (MoonPay address schema).
+ *
+ * @param body - Pix address registration payload.
+ * @param options - Optional idempotency key.
+ * @returns Parsed proxy JSON response.
+ */
+ async registerPixAddress(
+ body: Record,
+ options: NeoBankRequestOptions = {},
+ ): Promise {
+ return this.#postJson('addresses/pix', body, options);
+ }
+
+ /**
+ * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`.
+ *
+ * @param query - Quote query params (forwarded as-is).
+ * @returns Parsed proxy JSON response.
+ */
+ async getAutorampQuote(query: NeoBankQueryParams = {}): Promise {
+ return this.#getJson('autoramps/quote', query);
+ }
+
+ /**
+ * Creates an autoramp from a signed quote via neobank-proxy
+ * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`).
+ *
+ * @param body - CreateAutoramp / signed-quote payload (forwarded as-is).
+ * @param options - Optional idempotency key.
+ * @returns Remote snapshot for controller apply/refresh.
+ */
+ async createAutoramp(
+ body: Record,
+ options: NeoBankRequestOptions = {},
+ ): Promise {
+ const response = await this.#postJson(
+ 'autoramps',
+ body,
+ options,
+ );
+ return this.#mapAutorampResponse(response);
+ }
+
+ /**
+ * Fetches a quote for an existing autoramp via neobank-proxy
+ * `GET /neobank/autoramps/{autoramp_id}/quote`.
+ *
+ * @param autorampId - Autoramp id.
+ * @param query - Quote query params (forwarded as-is).
+ * @returns Parsed proxy JSON response.
+ */
+ async getAutorampQuoteForAutoramp(
+ autorampId: string,
+ query: NeoBankQueryParams = {},
+ ): Promise {
+ return this.#getJson(
+ `autoramps/${encodeURIComponent(autorampId)}/quote`,
+ query,
+ );
+ }
+
+ /**
+ * Attaches a signed quote to an autoramp via neobank-proxy
+ * `POST /neobank/autoramps/{autoramp_id}/quotes`.
+ *
+ * @param autorampId - Autoramp id.
+ * @param body - Quote attachment payload (forwarded as-is).
+ * @param options - Optional idempotency key.
+ * @returns Parsed proxy JSON response.
+ */
+ async attachAutorampQuote(
+ autorampId: string,
+ body: Record,
+ options: NeoBankRequestOptions = {},
+ ): Promise {
+ return this.#postJson(
+ `autoramps/${encodeURIComponent(autorampId)}/quotes`,
+ body,
+ options,
+ );
+ }
+
+ /**
+ * Fetches a customer by partner external id via neobank-proxy
+ * `GET /neobank/customers/{external_id}/external`.
+ *
+ * @param externalId - Partner-assigned external customer id.
+ * @returns Parsed proxy JSON response.
+ */
+ async getCustomerByExternalId(externalId: string): Promise {
+ return this.#getJson(
+ `customers/${encodeURIComponent(externalId)}/external`,
+ );
+ }
+
+ /**
+ * Resolves Iron's internal customer id via neobank-proxy customer lookup,
+ * using the MetaMask canonical profile id as the partner `external_id`.
+ *
+ * @returns Iron's internal customer id.
+ */
+ async getMoonpayCustomerId(): Promise {
+ return await this.#getWalletRegistrationService().getMoonpayCustomerId();
+ }
+
+ /**
+ * Checks whether a Monad Money Account address is already registered for the
+ * given Iron customer.
+ *
+ * @param params - Customer id and address to check.
+ * @param params.customerId - Iron / MoonPay customer UUID.
+ * @param params.address - Money Account address.
+ * @returns Active, disabled, or absent registration status.
+ */
+ async getWalletRegistrationStatus({
+ customerId,
+ address,
+ }: GetWalletRegistrationStatusParams): Promise {
+ return await this.#getWalletRegistrationService().getRegistrationStatus({
+ customerId,
+ address,
+ blockchain: 'Monad',
+ });
+ }
+
+ /**
+ * Submits a signed Monad Money Account ownership proof via neobank-proxy
+ * `POST /neobank/addresses/crypto/selfhosted`.
+ *
+ * @param params - Signed ownership proof.
+ * @returns Registered wallet record.
+ */
+ async registerSelfHostedWallet(
+ params: RegisterSelfHostedWalletParams,
+ ): Promise {
+ return await this.#getWalletRegistrationService().registerSelfHostedWallet({
+ ...params,
+ blockchain: 'Monad',
+ });
+ }
+
+ /**
+ * Resolves the MetaMask canonical profile id used as MoonPay's partner
+ * `external_id` for neobank customer lookup.
+ *
+ * @returns Canonical profile id.
+ */
+ async #getCanonicalExternalId(): Promise {
+ const profile = await this.#messenger.call(
+ 'AuthenticationController:getSessionProfile',
+ );
+ const canonical = profile?.canonicalProfileId;
+ const externalId =
+ typeof canonical === 'string' && canonical.length > 0
+ ? canonical
+ : profile?.profileId;
+ if (typeof externalId !== 'string' || externalId.length === 0) {
+ throw new Error(
+ 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup',
+ );
+ }
+ return externalId;
+ }
+
+ onRetry(
+ listener: Parameters[0],
+ ): ReturnType {
+ return this.#policy.onRetry(listener);
+ }
+
+ onBreak(
+ listener: Parameters[0],
+ ): ReturnType {
+ return this.#policy.onBreak(listener);
+ }
+
+ onDegraded(
+ listener: Parameters[0],
+ ): ReturnType {
+ return this.#policy.onDegraded(listener);
+ }
+}
diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts
index 3898e4fea14..b0ce940b265 100644
--- a/packages/ramps-controller/src/RampsController-method-action-types.ts
+++ b/packages/ramps-controller/src/RampsController-method-action-types.ts
@@ -280,6 +280,123 @@ export type RampsControllerRemoveOrderAction = {
handler: RampsController['removeOrder'];
};
+/**
+ * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`).
+ * When Backup & Sync is available, also pushes an incremental User Storage update
+ * unless a full sync is applying remote changes.
+ *
+ * @param accountOrInput - Full account or create fields.
+ * @returns The upserted {@link AutorampAccount}.
+ */
+export type RampsControllerAddAutorampAction = {
+ type: `RampsController:addAutoramp`;
+ handler: RampsController['addAutoramp'];
+};
+
+/**
+ * Creates an autoramp via the Ramp API neo-bank proxy and applies the
+ * returned snapshot locally.
+ *
+ * The MoonPay `customer_id` is not accepted from callers: it is resolved via
+ * {@link RampsController.resolveAutorampCustomerId} and injected into the
+ * request. This keeps the sensitive customer id owned by Profile Sync /
+ * the neo-bank proxy and avoids requiring the UI to know or plumb it.
+ *
+ * @param request - CreateAutoramp payload (any `customer_id` is overwritten).
+ * @param options - Optional idempotency key forwarded to the proxy.
+ * @param options.idempotencyKey - Value sent as `Idempotency-Key`.
+ * @returns The created/updated local {@link AutorampAccount}.
+ */
+export type RampsControllerCreateAutorampAction = {
+ type: `RampsController:createAutoramp`;
+ handler: RampsController['createAutoramp'];
+};
+
+/**
+ * Registers a Money Account wallet with MoonPay Iron via neobank-proxy.
+ *
+ * Consumers provide only the Monad address. The controller resolves the Iron
+ * customer id via {@link RampsController.resolveAutorampCustomerId}
+ * (Profile Sync → neobank-proxy external-id lookup) before the first
+ * list/lookup because list requires `customer_id` in the path. Message
+ * construction, EIP-191 signing, submission, and ambiguous-write
+ * reconciliation stay internal to this controller.
+ *
+ * @param params - Money Account wallet registration parameters.
+ * @param params.address - Monad Money Account address.
+ * @returns The successful registration state.
+ */
+export type RampsControllerRegisterMoneyAccountWalletAction = {
+ type: `RampsController:registerMoneyAccountWallet`;
+ handler: RampsController['registerMoneyAccountWallet'];
+};
+
+/**
+ * Removes a local autoramp account by id.
+ * Soft-deletes the remote User Storage entry when sync is available.
+ *
+ * @param autorampId - MoonPay autoramp id.
+ */
+export type RampsControllerRemoveAutorampAction = {
+ type: `RampsController:removeAutoramp`;
+ handler: RampsController['removeAutoramp'];
+};
+
+/**
+ * Marks that the UI has already notified for the autoramp's current status.
+ *
+ * @param autorampId - MoonPay autoramp id.
+ */
+export type RampsControllerMarkAutorampAsNotifiedAction = {
+ type: `RampsController:markAutorampAsNotified`;
+ handler: RampsController['markAutorampAsNotified'];
+};
+
+/**
+ * Applies a remote autoramp snapshot from a websocket / webhook push.
+ * Uses the same compare helper as refresh-on-load.
+ *
+ * @param remote - Remote autoramp snapshot.
+ * @returns The updated local account.
+ */
+export type RampsControllerApplyAutorampStatusFromPushAction = {
+ type: `RampsController:applyAutorampStatusFromPush`;
+ handler: RampsController['applyAutorampStatusFromPush'];
+};
+
+/**
+ * Fetches one autoramp from the Ramp API neo-bank proxy and applies it.
+ *
+ * @param autorampId - MoonPay autoramp id.
+ * @returns The updated local account.
+ */
+export type RampsControllerRefreshAutorampAction = {
+ type: `RampsController:refreshAutoramp`;
+ handler: RampsController['refreshAutoramp'];
+};
+
+/**
+ * Refreshes all known local autoramps from remote.
+ * Intended for app load / unlock catch-up when websockets were missed.
+ *
+ * @returns Updated autoramp accounts (failed fetches are skipped).
+ */
+export type RampsControllerRefreshAutorampsAction = {
+ type: `RampsController:refreshAutoramps`;
+ handler: RampsController['refreshAutoramps'];
+};
+
+/**
+ * Bidirectional sync of autoramp accounts with MetaMask User Storage
+ * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail.
+ *
+ * @param config - Optional error callbacks for Sentry / logging.
+ */
+export type RampsControllerSyncAutorampsWithUserStorageAction = {
+ type: `RampsController:syncAutorampsWithUserStorage`;
+ handler: RampsController['syncAutorampsWithUserStorage'];
+};
+
/**
* Starts polling all pending V2 orders at a fixed interval.
* Each poll cycle iterates orders with non-terminal statuses,
@@ -689,6 +806,15 @@ export type RampsControllerMethodActions =
| RampsControllerGetQuotesAction
| RampsControllerAddOrderAction
| RampsControllerRemoveOrderAction
+ | RampsControllerAddAutorampAction
+ | RampsControllerCreateAutorampAction
+ | RampsControllerRegisterMoneyAccountWalletAction
+ | RampsControllerRemoveAutorampAction
+ | RampsControllerMarkAutorampAsNotifiedAction
+ | RampsControllerApplyAutorampStatusFromPushAction
+ | RampsControllerRefreshAutorampAction
+ | RampsControllerRefreshAutorampsAction
+ | RampsControllerSyncAutorampsWithUserStorageAction
| RampsControllerStartOrderPollingAction
| RampsControllerStopOrderPollingAction
| RampsControllerGetBuyWidgetDataAction
diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts
index 54dca251d3c..44a5388eb9c 100644
--- a/packages/ramps-controller/src/RampsController.test.ts
+++ b/packages/ramps-controller/src/RampsController.test.ts
@@ -11,6 +11,7 @@ import type { Json } from '@metamask/utils';
import * as fs from 'fs';
import * as path from 'path';
+import { AutorampStatus } from './autorampAccount.js';
import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js';
import type {
RampsControllerMessenger,
@@ -22,6 +23,8 @@ import {
RampsController,
getDefaultRampsControllerState,
RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
+ RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS,
+ RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS,
} from './RampsController.js';
import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js';
import type {
@@ -63,6 +66,7 @@ import type {
TransakOrderPaymentMethod,
PatchUserRequestBody,
} from './TransakService.js';
+import { WalletRegistrationError } from './wallet-registration-service.js';
/**
* The default redirect ("fake callback") URL a staging `RampsService` returns.
@@ -77,12 +81,12 @@ describe('RampsController', () => {
'Execution prevented because the circuit breaker is open';
describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => {
- it('includes every RampsService action that RampsController calls', async () => {
+ it('includes every RampsService, TransakService, and NeoBankService action that RampsController calls', async () => {
expect.hasAssertions();
const controllerPath = path.join(__dirname, 'RampsController.ts');
const source = await fs.promises.readFile(controllerPath, 'utf-8');
const callPattern =
- /messenger\.call\s*\(\s*['"]((RampsService|TransakService):[^'"]+)['"]/gu;
+ /messenger\.call\s*\(\s*['"]((RampsService|TransakService|NeoBankService):[^'"]+)['"]/gu;
const calledActions = new Set();
let match: RegExpExecArray | null;
while ((match = callPattern.exec(source)) !== null) {
@@ -103,6 +107,7 @@ describe('RampsController', () => {
await withController(({ controller }) => {
expect(controller.state).toMatchInlineSnapshot(`
{
+ "autoramps": [],
"countries": {
"data": [],
"error": null,
@@ -179,6 +184,7 @@ describe('RampsController', () => {
await withController({ options: { state: {} } }, ({ controller }) => {
expect(controller.state).toMatchInlineSnapshot(`
{
+ "autoramps": [],
"countries": {
"data": [],
"error": null,
@@ -2198,6 +2204,7 @@ describe('RampsController', () => {
),
).toMatchInlineSnapshot(`
{
+ "autoramps": [],
"countries": {
"data": [],
"error": null,
@@ -2264,6 +2271,7 @@ describe('RampsController', () => {
),
).toMatchInlineSnapshot(`
{
+ "autoramps": [],
"countries": {
"data": [],
"error": null,
@@ -2306,6 +2314,7 @@ describe('RampsController', () => {
),
).toMatchInlineSnapshot(`
{
+ "autoramps": [],
"orders": [],
"providerAutoSelected": false,
"userRegion": null,
@@ -2324,6 +2333,7 @@ describe('RampsController', () => {
),
).toMatchInlineSnapshot(`
{
+ "autoramps": [],
"countries": {
"data": [],
"error": null,
@@ -8935,6 +8945,821 @@ describe('RampsController', () => {
});
});
+ describe('autoramps', () => {
+ it('adds and removes autoramp accounts', async () => {
+ await withController(({ controller }) => {
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ expect(controller.state.autoramps).toHaveLength(1);
+ expect(controller.state.autoramps[0]?.id).toBe('ar-1');
+ expect(controller.state.autoramps[0]?.status).toBe(
+ AutorampStatus.Authorized,
+ );
+
+ controller.removeAutoramp('ar-1');
+ expect(controller.state.autoramps).toHaveLength(0);
+ });
+ });
+
+ it('applies push snapshots and publishes notable transitions', async () => {
+ await withController(async ({ controller, messenger }) => {
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ const events: unknown[] = [];
+ messenger.subscribe(
+ 'RampsController:autorampStatusChanged',
+ (payload) => {
+ events.push(payload);
+ },
+ );
+
+ const updated = controller.applyAutorampStatusFromPush({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: AutorampStatus.Approved,
+ depositRailsSummary: { ready: true, currency: 'EUR' },
+ });
+
+ expect(updated.status).toBe(AutorampStatus.Approved);
+ expect(updated.depositRailsSummary).toStrictEqual({
+ ready: true,
+ currency: 'EUR',
+ });
+ expect(events).toHaveLength(1);
+ expect(events[0]).toMatchObject({
+ previousStatus: AutorampStatus.Authorized,
+ shouldNotify: true,
+ });
+ });
+ });
+
+ it('refreshes autoramps via NeoBankService', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const getAutoramp = jest.fn().mockResolvedValue({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ depositRailsSummary: { ready: true },
+ });
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getAutoramp',
+ getAutoramp,
+ );
+
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ const updated = await controller.refreshAutoramp('ar-1');
+ expect(getAutoramp).toHaveBeenCalledWith('ar-1');
+ expect(updated.status).toBe(AutorampStatus.Approved);
+
+ await controller.refreshAutoramps();
+ expect(getAutoramp).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('injects the Profile Sync customer id and applies the created autoramp', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ async () =>
+ ({
+ identifierId: 'id-1',
+ profileId: 'profile-1',
+ canonicalProfileId: 'canonical-1',
+ metaMetricsId: 'mm-1',
+ }) as never,
+ );
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getCustomerByExternalId',
+ async () => ({ id: 'cust-99' }),
+ );
+ const createAutoramp = jest.fn().mockResolvedValue({
+ id: 'ar-new',
+ customerId: 'cust-99',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Created,
+ });
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:createAutoramp',
+ createAutoramp,
+ );
+
+ const created = await controller.createAutoramp(
+ { customer_id: 'attacker-supplied', foo: 'bar' },
+ { idempotencyKey: 'idem-1' },
+ );
+
+ expect(createAutoramp).toHaveBeenCalledWith(
+ { foo: 'bar', customer_id: 'cust-99' },
+ { idempotencyKey: 'idem-1' },
+ );
+ expect(created.id).toBe('ar-new');
+ expect(
+ controller.state.autoramps.find((a) => a.id === 'ar-new')?.customerId,
+ ).toBe('cust-99');
+ });
+ });
+
+ it('prefers canonicalProfileId when resolving the external customer id', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ async () =>
+ ({
+ identifierId: 'id-1',
+ profileId: 'profile-1',
+ canonicalProfileId: 'canonical-1',
+ metaMetricsId: 'mm-1',
+ }) as never,
+ );
+ const getCustomerByExternalId = jest
+ .fn()
+ .mockResolvedValue({ id: 'cust-canonical' });
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getCustomerByExternalId',
+ getCustomerByExternalId,
+ );
+ const createAutoramp = jest.fn().mockResolvedValue({
+ id: 'ar-new',
+ customerId: 'cust-canonical',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Created,
+ });
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:createAutoramp',
+ createAutoramp,
+ );
+
+ await controller.createAutoramp({});
+
+ expect(getCustomerByExternalId).toHaveBeenCalledWith('canonical-1');
+ });
+ });
+
+ it('throws when no mapped external customer is available', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ async () =>
+ ({
+ identifierId: 'id-1',
+ profileId: 'profile-1',
+ metaMetricsId: 'mm-1',
+ }) as never,
+ );
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getCustomerByExternalId',
+ async () => null,
+ );
+ const createAutoramp = jest.fn();
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:createAutoramp',
+ createAutoramp,
+ );
+
+ await expect(controller.createAutoramp({})).rejects.toThrow(
+ /no MoonPay customer is mapped to external id "profile-1"/u,
+ );
+ expect(createAutoramp).not.toHaveBeenCalled();
+ });
+ });
+
+ it('throws when the wallet is not signed in to Profile Sync', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ async () =>
+ ({
+ identifierId: 'id-1',
+ profileId: '',
+ canonicalProfileId: '',
+ metaMetricsId: 'mm-1',
+ }) as never,
+ );
+ const getCustomerByExternalId = jest.fn();
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getCustomerByExternalId',
+ getCustomerByExternalId,
+ );
+ const createAutoramp = jest.fn();
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:createAutoramp',
+ createAutoramp,
+ );
+
+ await expect(controller.createAutoramp({})).rejects.toThrow(
+ /wallet is not signed in to Profile Sync/u,
+ );
+ expect(getCustomerByExternalId).not.toHaveBeenCalled();
+ expect(createAutoramp).not.toHaveBeenCalled();
+ });
+ });
+
+ it('falls back to profileId when canonicalProfileId is empty', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ async () =>
+ ({
+ identifierId: 'id-1',
+ profileId: 'profile-1',
+ canonicalProfileId: '',
+ metaMetricsId: 'mm-1',
+ }) as never,
+ );
+ const getCustomerByExternalId = jest
+ .fn()
+ .mockResolvedValue({ id: 'cust-profile' });
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getCustomerByExternalId',
+ getCustomerByExternalId,
+ );
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:createAutoramp',
+ async () => ({
+ id: 'ar-new',
+ customerId: 'cust-profile',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Created,
+ }),
+ );
+
+ await controller.createAutoramp({});
+
+ expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1');
+ });
+ });
+
+ it('skips failed refreshes when refreshing all autoramps', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getAutoramp',
+ async (id: string) => {
+ if (id === 'ar-bad') {
+ throw new Error('network');
+ }
+ return {
+ id,
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ };
+ },
+ );
+
+ controller.addAutoramp({
+ id: 'ar-bad',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+ controller.addAutoramp({
+ id: 'ar-good',
+ customerId: 'cust-1',
+ walletAddress: '0xdef',
+ status: AutorampStatus.Authorized,
+ });
+
+ const updated = await controller.refreshAutoramps();
+ expect(updated).toHaveLength(1);
+ expect(updated[0]?.id).toBe('ar-good');
+ expect(
+ controller.state.autoramps.find((a) => a.id === 'ar-bad')?.status,
+ ).toBe(AutorampStatus.Authorized);
+ });
+ });
+
+ it('syncs autoramps with user storage when gates pass', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const batchSet = jest.fn().mockResolvedValue(undefined);
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:getState',
+ () =>
+ ({
+ isBackupAndSyncEnabled: true,
+ }) as never,
+ );
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:isSignedIn',
+ () => true,
+ );
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:performGetStorageAllFeatureEntries',
+ async () => [],
+ );
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:performBatchSetStorage',
+ batchSet,
+ );
+
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ // Allow any incremental push from addAutoramp to settle, then full sync.
+ await Promise.resolve();
+ batchSet.mockClear();
+
+ await controller.syncAutorampsWithUserStorage();
+
+ expect(batchSet).toHaveBeenCalled();
+ const [, entries] = batchSet.mock.calls[0] as [
+ string,
+ [string, string][],
+ ];
+ expect(entries[0]?.[0]).toBe('ar-1');
+ expect(JSON.parse(entries[0]?.[1] ?? '{}').o.id).toBe('ar-1');
+ });
+ });
+
+ it('marks autoramp as notified', async () => {
+ await withController(({ controller }) => {
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ });
+ controller.markAutorampAsNotified('ar-1');
+ expect(controller.state.autoramps[0]?.notifiedForStatus).toBe(
+ AutorampStatus.Approved,
+ );
+ });
+ });
+
+ /**
+ * Registers the User Storage / auth handlers that let the incremental
+ * autoramp pushes run, so tests can drive the remote-write code paths.
+ *
+ * @param rootMessenger - Root messenger of the controller under test.
+ * @param batchSet - Handler for `performBatchSetStorage`.
+ */
+ function registerAutorampSyncHandlers(
+ rootMessenger: RootMessenger,
+ batchSet: jest.Mock,
+ ): void {
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:getState',
+ () => ({ isBackupAndSyncEnabled: true }) as never,
+ );
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:isSignedIn',
+ () => true,
+ );
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:performGetStorageAllFeatureEntries',
+ async () => [],
+ );
+ rootMessenger.registerActionHandler(
+ 'UserStorageController:performBatchSetStorage',
+ batchSet,
+ );
+ }
+
+ /**
+ * Lets floating remote-push promises settle.
+ */
+ async function flushPromises(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+
+ it('updates an existing autoramp when the id is already known', async () => {
+ await withController(({ controller }) => {
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ const updated = controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xdef',
+ status: AutorampStatus.Approved,
+ });
+
+ expect(controller.state.autoramps).toHaveLength(1);
+ expect(updated.walletAddress).toBe('0xdef');
+ expect(updated.status).toBe(AutorampStatus.Approved);
+ });
+ });
+
+ it('ignores removal and notification for unknown autoramp ids', async () => {
+ await withController(({ controller }) => {
+ controller.removeAutoramp('missing');
+ controller.markAutorampAsNotified('missing');
+
+ expect(controller.state.autoramps).toStrictEqual([]);
+ });
+ });
+
+ it('queues a remote delete when a full sync holds the semaphore', async () => {
+ await withController(({ controller }) => {
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ controller.setIsAutorampSyncingInProgress(true);
+ controller.removeAutoramp('ar-1');
+
+ const pending = controller.getPendingRemoteAutorampDeletes();
+ expect(pending.map((account) => account.id)).toStrictEqual(['ar-1']);
+
+ controller.acknowledgePendingRemoteAutorampDeletes([]);
+ expect(controller.getPendingRemoteAutorampDeletes()).toHaveLength(1);
+
+ controller.acknowledgePendingRemoteAutorampDeletes(pending);
+ expect(controller.getPendingRemoteAutorampDeletes()).toStrictEqual([]);
+
+ controller.setIsAutorampSyncingInProgress(false);
+ });
+ });
+
+ it('suppresses remote pushes while applying sync changes locally', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const batchSet = jest.fn().mockResolvedValue(undefined);
+ registerAutorampSyncHandlers(rootMessenger, batchSet);
+
+ controller.setIsApplyingAutorampSyncChanges(true);
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ });
+ controller.markAutorampAsNotified('ar-1');
+ controller.removeAutoramp('ar-1');
+ controller.setIsApplyingAutorampSyncChanges(false);
+
+ await flushPromises();
+
+ expect(batchSet).not.toHaveBeenCalled();
+ });
+ });
+
+ it('swallows remote storage failures raised by autoramp mutations', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const batchSet = jest.fn().mockRejectedValue(new Error('storage down'));
+ registerAutorampSyncHandlers(rootMessenger, batchSet);
+
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+ await flushPromises();
+
+ controller.markAutorampAsNotified('ar-1');
+ await flushPromises();
+
+ controller.applyAutorampStatusFromPush({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: AutorampStatus.Approved,
+ });
+ await flushPromises();
+
+ controller.removeAutoramp('ar-1');
+ await flushPromises();
+
+ expect(batchSet).toHaveBeenCalled();
+ expect(controller.state.autoramps).toStrictEqual([]);
+ });
+ });
+
+ it('creates an autoramp from a push that carries no wallet address', async () => {
+ await withController(({ controller }) => {
+ const created = controller.applyAutorampStatusFromPush({
+ id: 'ar-new',
+ customerId: 'cust-1',
+ status: AutorampStatus.Approved,
+ });
+
+ expect(created.walletAddress).toBe('');
+ expect(controller.state.autoramps).toHaveLength(1);
+ });
+ });
+
+ it('keeps local identity fields when a remote push omits or blanks them', async () => {
+ await withController(({ controller }) => {
+ controller.addAutoramp({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ });
+
+ const afterOmitted = controller.applyAutorampStatusFromPush({
+ id: 'ar-1',
+ customerId: '',
+ status: AutorampStatus.Approved,
+ });
+
+ expect(afterOmitted.customerId).toBe('cust-1');
+ expect(afterOmitted.walletAddress).toBe('0xabc');
+
+ const afterBlank = controller.applyAutorampStatusFromPush({
+ id: 'ar-1',
+ customerId: '',
+ walletAddress: '',
+ status: AutorampStatus.Approved,
+ });
+
+ expect(afterBlank.customerId).toBe('cust-1');
+ expect(afterBlank.walletAddress).toBe('0xabc');
+ });
+ });
+ });
+
+ describe('registerMoneyAccountWallet', () => {
+ const registration = {
+ id: 'wallet-1',
+ address: '0xabc',
+ blockchain: 'Monad' as const,
+ disabled: false,
+ isSelf: true,
+ };
+
+ type WalletRegistrationHandlers = {
+ getSessionProfile: jest.Mock;
+ getCustomerByExternalId: jest.Mock;
+ getWalletRegistrationStatus: jest.Mock;
+ registerSelfHostedWallet: jest.Mock;
+ signPersonalMessage: jest.Mock;
+ };
+
+ /**
+ * Registers default handlers for every messenger action the wallet
+ * registration flow calls, returning the mocks for per-test overrides.
+ *
+ * @param rootMessenger - The root messenger of the controller under test.
+ * @returns The registered handler mocks.
+ */
+ function registerWalletRegistrationHandlers(
+ rootMessenger: RootMessenger,
+ ): WalletRegistrationHandlers {
+ const handlers: WalletRegistrationHandlers = {
+ getSessionProfile: jest.fn().mockResolvedValue({
+ identifierId: 'id-1',
+ profileId: 'profile-1',
+ metaMetricsId: 'mm-1',
+ }),
+ getCustomerByExternalId: jest
+ .fn()
+ .mockResolvedValue({ id: 'iron-customer-1' }),
+ getWalletRegistrationStatus: jest
+ .fn()
+ .mockResolvedValue({ type: 'absent' }),
+ registerSelfHostedWallet: jest.fn().mockResolvedValue({
+ type: 'registered',
+ registration,
+ }),
+ signPersonalMessage: jest.fn().mockResolvedValue('0xsig'),
+ };
+ rootMessenger.registerActionHandler(
+ 'AuthenticationController:getSessionProfile',
+ handlers.getSessionProfile,
+ );
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getCustomerByExternalId',
+ handlers.getCustomerByExternalId,
+ );
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:getWalletRegistrationStatus',
+ handlers.getWalletRegistrationStatus,
+ );
+ rootMessenger.registerActionHandler(
+ 'NeoBankService:registerSelfHostedWallet',
+ handlers.registerSelfHostedWallet,
+ );
+ rootMessenger.registerActionHandler(
+ 'KeyringController:signPersonalMessage',
+ handlers.signPersonalMessage,
+ );
+ return handlers;
+ }
+
+ it('returns an existing active registration without signing', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ handlers.getWalletRegistrationStatus.mockResolvedValue({
+ type: 'active',
+ registration,
+ });
+
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toStrictEqual({
+ type: 'alreadyRegistered',
+ registration,
+ });
+ expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({
+ customerId: 'iron-customer-1',
+ address: '0xabc',
+ });
+ expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ it('returns an existing disabled registration without signing', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ handlers.getWalletRegistrationStatus.mockResolvedValue({
+ type: 'disabled',
+ registration: { ...registration, disabled: true },
+ });
+
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toMatchObject({ type: 'registeredDisabled' });
+ expect(handlers.signPersonalMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ it('signs and submits an ownership proof for an absent registration', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+
+ expect(
+ await controller.registerMoneyAccountWallet({ address: '0xabc' }),
+ ).toMatchObject({ type: 'registered' });
+
+ expect(handlers.signPersonalMessage).toHaveBeenCalledWith({
+ data: expect.stringContaining('as customer iron-customer-1.'),
+ from: '0xabc',
+ });
+ expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith(
+ expect.objectContaining({
+ address: '0xabc',
+ customerId: 'iron-customer-1',
+ signature: '0xsig',
+ idempotencyKey: expect.any(String),
+ }),
+ );
+ });
+ });
+
+ it('resolves the customer id via Profile Sync external-id lookup', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ handlers.getSessionProfile.mockResolvedValue({
+ identifierId: 'id-1',
+ profileId: 'profile-1',
+ canonicalProfileId: 'canonical-1',
+ metaMetricsId: 'mm-1',
+ });
+ handlers.getCustomerByExternalId.mockResolvedValue({
+ id: 'iron-customer-fallback',
+ });
+
+ await controller.registerMoneyAccountWallet({ address: '0xabc' });
+
+ expect(handlers.getCustomerByExternalId).toHaveBeenCalledWith(
+ 'canonical-1',
+ );
+ expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({
+ customerId: 'iron-customer-fallback',
+ address: '0xabc',
+ });
+ });
+ });
+
+ it('reconciles an ambiguous conflict as already registered', async () => {
+ await withController(async ({ controller, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ 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, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ 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, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ 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, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ 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, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ 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, rootMessenger }) => {
+ const handlers = registerWalletRegistrationHandlers(rootMessenger);
+ 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('addOrder', () => {
const mockOrder = {
id: '/providers/transak-staging/orders/abc-123',
@@ -11835,6 +12660,8 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger {
messenger,
actions: [
...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
+ ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS,
+ ...RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS,
'RemoteFeatureFlagController:getState',
],
});
diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts
index aac160d8825..fe7e0fbc5e1 100644
--- a/packages/ramps-controller/src/RampsController.ts
+++ b/packages/ramps-controller/src/RampsController.ts
@@ -6,19 +6,50 @@ import type {
import { BaseController } from '@metamask/base-controller';
import { BrokenCircuitError } from '@metamask/controller-utils';
import type { Messenger } from '@metamask/messenger';
+import type { AuthenticationController } from '@metamask/profile-sync-controller';
+import type { UserStorageController } from '@metamask/profile-sync-controller';
import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller';
import type { Json } from '@metamask/utils';
import type { Draft } from 'immer';
+import {
+ deleteAutorampInRemoteStorage,
+ syncAutorampsWithUserStorage as syncAutorampsWithUserStorageInternal,
+ updateAutorampInRemoteStorage,
+} from './autoramp-syncing/index.js';
+import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js';
+import type {
+ AutorampSyncingController,
+ AutorampSyncingOptions,
+} from './autoramp-syncing/types.js';
+import type {
+ AutorampAccount,
+ AutorampRemoteSnapshot,
+ CreateAutorampRequest,
+} from './autorampAccount.js';
+import {
+ applyAutorampRemoteStatus,
+ createAutorampAccount,
+ markAutorampNotified,
+} from './autorampAccount.js';
import {
getHeadlessProviderAllowlist,
isHeadlessAllProvidersEnabled,
normalizeHeadlessProviderId,
} from './featureFlags.js';
+import type {
+ NeoBankServiceCreateAutorampAction,
+ NeoBankServiceGetAutorampAction,
+ NeoBankServiceGetCustomerByExternalIdAction,
+ NeoBankServiceGetWalletRegistrationStatusAction,
+ NeoBankServiceRegisterSelfHostedWalletAction,
+} from './NeoBankService-method-action-types.js';
+import type { NeoBankServiceActions } from './NeoBankService.js';
import {
PENDING_ORDER_STATUSES,
TERMINAL_ORDER_STATUSES,
} from './orderStatus.js';
+import { buildOwnershipMessage } from './ownership-message.js';
import {
getProvidersServingAsset,
providerServesAsset,
@@ -116,6 +147,18 @@ import type {
TransakOrder,
} from './TransakService.js';
import type { TransakServiceActions } from './TransakService.js';
+import {
+ createInitialState as createInitialWalletRegistrationState,
+ transition as transitionWalletRegistration,
+} from './wallet-registration-machine.js';
+import {
+ createIdempotencyKey,
+ WalletRegistrationError,
+} from './wallet-registration-service.js';
+import type {
+ RegistrationStatus,
+ SelfHostedRegistration,
+} from './wallet-registration-service.js';
// === GENERAL ===
@@ -131,10 +174,7 @@ export const controllerName = 'RampsController';
* Any host (e.g. mobile) that creates a RampsController messenger must delegate
* these actions from the root messenger so the controller can function.
*/
-export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly (
- | RampsServiceActions['type']
- | TransakServiceActions['type']
-)[] = [
+export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [
'RampsService:getDefaultRedirectCallbackUrl',
'RampsService:getGeolocation',
'RampsService:getCountries',
@@ -170,7 +210,65 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly (
'TransakService:cancelOrder',
'TransakService:cancelAllActiveOrders',
'TransakService:getActiveOrders',
-];
+ 'NeoBankService:getAutoramp',
+ 'NeoBankService:createAutoramp',
+ 'NeoBankService:getCustomerByExternalId',
+ 'NeoBankService:getWalletRegistrationStatus',
+ 'NeoBankService:registerSelfHostedWallet',
+] as const satisfies readonly (
+ | RampsServiceActions['type']
+ | TransakServiceActions['type']
+ | NeoBankServiceActions['type']
+)[];
+
+/**
+ * Other controller actions RampsController calls via the messenger.
+ * Hosts that enable autoramp creation must delegate these from the root
+ * messenger so the controller can resolve the vendor customer identity via
+ * Profile Sync (`AuthenticationController:getSessionProfile`) and the
+ * neo-bank external-id lookup. `KeyringController:signPersonalMessage` is
+ * required for Money Account self-hosted wallet registration (EIP-191
+ * ownership proof).
+ */
+export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [
+ 'AuthenticationController:getSessionProfile',
+ 'KeyringController:signPersonalMessage',
+] as const;
+
+/**
+ * Structural type for the keyring controller's `signPersonalMessage` messenger
+ * action (EIP-191). Declared locally (mirroring
+ * `@metamask/keyring-controller`) to avoid a package dependency for a single
+ * type-only messenger action.
+ */
+export type KeyringControllerSignPersonalMessageAction = {
+ type: 'KeyringController:signPersonalMessage';
+ handler: (messageParams: { data: string; from: string }) => Promise;
+};
+
+/**
+ * Successful outcome of {@link RampsController.registerMoneyAccountWallet}.
+ */
+export type MoneyAccountWalletRegistrationResult =
+ | {
+ type: 'registered' | 'alreadyRegistered';
+ registration: SelfHostedRegistration;
+ }
+ | {
+ type: 'registeredDisabled';
+ registration: SelfHostedRegistration;
+ };
+
+/**
+ * User Storage / auth actions needed for autoramp Backup & Sync.
+ * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these.
+ */
+export const RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS = [
+ 'UserStorageController:getState',
+ 'UserStorageController:performGetStorageAllFeatureEntries',
+ 'UserStorageController:performBatchSetStorage',
+ 'AuthenticationController:isSignedIn',
+] as const;
/**
* Default TTL for quotes requests (15 seconds).
@@ -218,6 +316,22 @@ function hasHttpStatus(error: unknown): error is ErrorWithHttpStatus {
);
}
+/**
+ * Distinguishes an already-materialized {@link AutorampAccount} from the
+ * create-fields shape accepted by {@link RampsController.addAutoramp}.
+ *
+ * @param value - Full account or create fields.
+ * @returns Whether the value already carries the derived account fields.
+ */
+function isFullAutorampAccount(
+ value: AutorampAccount | { id: string; customerId: string },
+): value is AutorampAccount {
+ return (
+ typeof (value as AutorampAccount).updatedAt === 'number' &&
+ (value as AutorampAccount).lastSeenStatus !== undefined
+ );
+}
+
function getRampsErrorInfo(error: unknown): RampsErrorInfo {
if (error instanceof BrokenCircuitError && hasStringMessage(error)) {
return {
@@ -387,6 +501,12 @@ export type RampsControllerState = {
* and persists them.
*/
orders: RampsOrder[];
+ /**
+ * MoonPay Enterprise autoramp accounts (standing routes), separate from
+ * {@link RampsOrder} payment instances. Refreshed from remote on load /
+ * push; persisted for rediscovery and transition UX.
+ */
+ autoramps: AutorampAccount[];
/**
* Whether the currently selected provider was auto-selected by the system
* (no order history, no Transak) rather than chosen by the user or derived
@@ -448,6 +568,12 @@ const rampsControllerMetadata = {
includeInStateLogs: true,
usedInUi: true,
},
+ autoramps: {
+ persist: true,
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ usedInUi: true,
+ },
providerAutoSelected: {
persist: true,
includeInDebugSnapshot: true,
@@ -514,6 +640,7 @@ export function getDefaultRampsControllerState(): RampsControllerState {
},
},
orders: [],
+ autoramps: [],
providerAutoSelected: false,
};
}
@@ -638,7 +765,18 @@ type AllowedActions =
| TransakServiceGetIdProofStatusAction
| TransakServiceCancelOrderAction
| TransakServiceCancelAllActiveOrdersAction
- | TransakServiceGetActiveOrdersAction;
+ | TransakServiceGetActiveOrdersAction
+ | NeoBankServiceGetAutorampAction
+ | NeoBankServiceCreateAutorampAction
+ | NeoBankServiceGetCustomerByExternalIdAction
+ | NeoBankServiceGetWalletRegistrationStatusAction
+ | NeoBankServiceRegisterSelfHostedWalletAction
+ | KeyringControllerSignPersonalMessageAction
+ | UserStorageController.UserStorageControllerGetStateAction
+ | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction
+ | UserStorageController.UserStorageControllerPerformBatchSetStorageAction
+ | AuthenticationController.AuthenticationControllerIsSignedInAction
+ | AuthenticationController.AuthenticationControllerGetSessionProfileAction;
/**
* Published when the state of {@link RampsController} changes.
@@ -657,12 +795,28 @@ export type RampsControllerOrderStatusChangedEvent = {
payload: [{ order: RampsOrder; previousStatus: RampsOrderStatus }];
};
+/**
+ * Published when an autoramp account status transitions to a notable state
+ * that the UI has not yet notified for (e.g. Approved / Rejected).
+ */
+export type RampsControllerAutorampStatusChangedEvent = {
+ type: `${typeof controllerName}:autorampStatusChanged`;
+ payload: [
+ {
+ autoramp: AutorampAccount;
+ previousStatus: AutorampAccount['status'];
+ shouldNotify: boolean;
+ },
+ ];
+};
+
/**
* Events that {@link RampsControllerMessenger} exposes to other consumers.
*/
export type RampsControllerEvents =
| RampsControllerStateChangeEvent
- | RampsControllerOrderStatusChangedEvent;
+ | RampsControllerOrderStatusChangedEvent
+ | RampsControllerAutorampStatusChangedEvent;
/**
* Events from other messengers that {@link RampsController} subscribes to.
@@ -811,6 +965,15 @@ const MESSENGER_EXPOSED_METHODS = [
'getQuotes',
'addOrder',
'removeOrder',
+ 'addAutoramp',
+ 'createAutoramp',
+ 'removeAutoramp',
+ 'registerMoneyAccountWallet',
+ 'markAutorampAsNotified',
+ 'applyAutorampStatusFromPush',
+ 'refreshAutoramp',
+ 'refreshAutoramps',
+ 'syncAutorampsWithUserStorage',
'startOrderPolling',
'stopOrderPolling',
'getBuyWidgetData',
@@ -890,6 +1053,12 @@ export class RampsController extends BaseController<
#initPromise: Promise | null = null;
+ #isAutorampSyncingInProgress = false;
+
+ #isApplyingAutorampSyncChanges = false;
+
+ #pendingRemoteAutorampDeletes: AutorampAccount[] = [];
+
/**
* Clears the pending resource count map. Used only in tests to exercise the
* defensive path when get() returns undefined in the finally block.
@@ -2437,6 +2606,541 @@ export class RampsController extends BaseController<
this.#orderPollingMeta.delete(providerOrderId);
}
+ // === AUTORAMP ACCOUNT MANAGEMENT ===
+
+ /**
+ * Whether a full autoramp User Storage sync is currently running.
+ *
+ * @returns True when a full autoramp sync is in progress.
+ */
+ get isAutorampSyncingInProgress(): boolean {
+ return this.#isAutorampSyncingInProgress;
+ }
+
+ /**
+ * Sets the autoramp sync semaphore (used by autoramp-syncing module).
+ *
+ * @param value - Whether sync is in progress.
+ */
+ setIsAutorampSyncingInProgress(value: boolean): void {
+ this.#isAutorampSyncingInProgress = value;
+ }
+
+ /**
+ * Sets whether local mutations are applying remote sync results
+ * (suppresses incremental remote pushes).
+ *
+ * @param value - Whether sync changes are being applied locally.
+ */
+ setIsApplyingAutorampSyncChanges(value: boolean): void {
+ this.#isApplyingAutorampSyncChanges = value;
+ }
+
+ /**
+ * Returns autoramps deleted locally while a full sync held the semaphore.
+ *
+ * @returns Pending remote delete queue.
+ */
+ getPendingRemoteAutorampDeletes(): AutorampAccount[] {
+ return [...this.#pendingRemoteAutorampDeletes];
+ }
+
+ /**
+ * Clears acknowledged pending remote deletes after tombstones are written.
+ *
+ * @param accounts - Accounts whose remote tombstones were persisted.
+ */
+ acknowledgePendingRemoteAutorampDeletes(accounts: AutorampAccount[]): void {
+ if (accounts.length === 0) {
+ return;
+ }
+ const keys = new Set(accounts.map((account) => account.id));
+ this.#pendingRemoteAutorampDeletes =
+ this.#pendingRemoteAutorampDeletes.filter(
+ (account) => !keys.has(account.id),
+ );
+ }
+
+ #getAutorampSyncingOptions(): AutorampSyncingOptions {
+ return {
+ getRampsControllerInstance: (): AutorampSyncingController => this,
+ getMessenger: (): RampsControllerMessenger => this.messenger,
+ };
+ }
+
+ /**
+ * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`).
+ * When Backup & Sync is available, also pushes an incremental User Storage update
+ * unless a full sync is applying remote changes.
+ *
+ * @param accountOrInput - Full account or create fields.
+ * @returns The upserted {@link AutorampAccount}.
+ */
+ addAutoramp(
+ accountOrInput:
+ | AutorampAccount
+ | {
+ id: string;
+ customerId: string;
+ walletAddress: string;
+ status?: AutorampAccount['status'] | string;
+ },
+ ): AutorampAccount {
+ const account: AutorampAccount = isFullAutorampAccount(accountOrInput)
+ ? accountOrInput
+ : createAutorampAccount(accountOrInput);
+
+ this.update((state) => {
+ const idx = state.autoramps.findIndex(
+ (existing) => existing.id === account.id,
+ );
+ if (idx === -1) {
+ state.autoramps.push(account as Draft);
+ } else {
+ state.autoramps[idx] = {
+ ...state.autoramps[idx],
+ ...account,
+ } as Draft;
+ }
+ });
+
+ const upserted =
+ this.state.autoramps.find((existing) => existing.id === account.id) ??
+ account;
+
+ if (
+ !this.#isApplyingAutorampSyncChanges &&
+ !this.#isAutorampSyncingInProgress
+ ) {
+ updateAutorampInRemoteStorage(
+ upserted,
+ this.#getAutorampSyncingOptions(),
+ ).catch(() => undefined);
+ }
+
+ return upserted;
+ }
+
+ /**
+ * Creates an autoramp via the Ramp API neo-bank proxy and applies the
+ * returned snapshot locally.
+ *
+ * The MoonPay `customer_id` is not accepted from callers: it is resolved via
+ * {@link RampsController.resolveAutorampCustomerId} and injected into the
+ * request. This keeps the sensitive customer id owned by Profile Sync /
+ * the neo-bank proxy and avoids requiring the UI to know or plumb it.
+ *
+ * @param request - CreateAutoramp payload (any `customer_id` is overwritten).
+ * @param options - Optional idempotency key forwarded to the proxy.
+ * @param options.idempotencyKey - Value sent as `Idempotency-Key`.
+ * @returns The created/updated local {@link AutorampAccount}.
+ */
+ async createAutoramp(
+ request: CreateAutorampRequest,
+ options: { idempotencyKey?: string } = {},
+ ): Promise {
+ const customerId = await this.resolveAutorampCustomerId();
+
+ const body = { ...request, customer_id: customerId };
+ const remote = await this.messenger.call(
+ 'NeoBankService:createAutoramp',
+ body,
+ options,
+ );
+ return this.#applyAutorampRemoteSnapshot(remote);
+ }
+
+ /**
+ * Resolves the MoonPay `customer_id` for autoramp operations.
+ *
+ * Maps the wallet's Profile Sync id (the partner `external_id`) to the
+ * MoonPay customer via the neo-bank proxy's
+ * `GET /neobank/customers/{external_id}/external`. Prefers
+ * `canonicalProfileId` when present, otherwise `profileId`, matching
+ * {@link NeoBankService}'s canonical external-id resolution.
+ *
+ * @returns The MoonPay customer id.
+ */
+ async resolveAutorampCustomerId(): Promise {
+ const profile = await this.messenger.call(
+ 'AuthenticationController:getSessionProfile',
+ );
+ const canonical = profile?.canonicalProfileId;
+ const externalId =
+ typeof canonical === 'string' && canonical.length > 0
+ ? canonical
+ : profile?.profileId;
+ if (typeof externalId !== 'string' || externalId.length === 0) {
+ throw new Error(
+ 'Cannot create autoramp: wallet is not signed in to Profile Sync.',
+ );
+ }
+
+ const customer = await this.messenger.call(
+ 'NeoBankService:getCustomerByExternalId',
+ externalId,
+ );
+ const customerId =
+ customer &&
+ typeof customer === 'object' &&
+ typeof (customer as { id?: unknown }).id === 'string'
+ ? (customer as { id: string }).id
+ : null;
+ if (!customerId) {
+ throw new Error(
+ `Cannot create autoramp: no MoonPay customer is mapped to external id "${externalId}".`,
+ );
+ }
+ return customerId;
+ }
+
+ /**
+ * Registers a Money Account wallet with MoonPay Iron via neobank-proxy.
+ *
+ * Consumers provide only the Monad address. The controller resolves the Iron
+ * customer id via {@link RampsController.resolveAutorampCustomerId}
+ * (Profile Sync → neobank-proxy external-id lookup) before the first
+ * list/lookup because list requires `customer_id` in the path. Message
+ * construction, EIP-191 signing, submission, and ambiguous-write
+ * reconciliation stay internal to this controller.
+ *
+ * @param params - Money Account wallet registration parameters.
+ * @param params.address - Monad Money Account address.
+ * @returns The successful registration state.
+ */
+ async registerMoneyAccountWallet({
+ address,
+ }: {
+ address: string;
+ }): Promise {
+ let machine = transitionWalletRegistration(
+ createInitialWalletRegistrationState(),
+ { 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;
+ };
+
+ // List requires customer_id in the neobank path, so resolve Iron's id
+ // before the first lookup.
+ const customerId = await this.resolveAutorampCustomerId();
+
+ const lookup = async (): Promise => {
+ try {
+ return await this.messenger.call(
+ 'NeoBankService:getWalletRegistrationStatus',
+ { customerId, 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;
+ }
+
+ // Stable across transient retries of the same ownership proof; refreshed
+ // when the UTC-dated message must be rebuilt and re-signed.
+ let idempotencyKey = createIdempotencyKey();
+ let lastMessage: string | undefined;
+
+ while (true) {
+ const message = buildOwnershipMessage({
+ address,
+ customerId,
+ now: new Date(),
+ });
+ if (lastMessage !== undefined && message !== lastMessage) {
+ idempotencyKey = createIdempotencyKey();
+ }
+ lastMessage = message;
+
+ 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(
+ 'NeoBankService:registerSelfHostedWallet',
+ {
+ address,
+ customerId,
+ message,
+ signature,
+ idempotencyKey,
+ },
+ );
+ 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;
+ }
+ }
+ }
+ }
+
+ /**
+ * Removes a local autoramp account by id.
+ * Soft-deletes the remote User Storage entry when sync is available.
+ *
+ * @param autorampId - MoonPay autoramp id.
+ */
+ removeAutoramp(autorampId: string): void {
+ const existing = this.state.autoramps.find(
+ (autoramp) => autoramp.id === autorampId,
+ );
+
+ this.update((state) => {
+ state.autoramps = state.autoramps.filter(
+ (autoramp) => autoramp.id !== autorampId,
+ );
+ });
+
+ if (!existing || this.#isApplyingAutorampSyncChanges) {
+ return;
+ }
+
+ if (this.#isAutorampSyncingInProgress) {
+ this.#pendingRemoteAutorampDeletes.push(existing);
+ return;
+ }
+
+ deleteAutorampInRemoteStorage(
+ existing,
+ this.#getAutorampSyncingOptions(),
+ ).catch(() => undefined);
+ }
+
+ /**
+ * Marks that the UI has already notified for the autoramp's current status.
+ *
+ * @param autorampId - MoonPay autoramp id.
+ */
+ markAutorampAsNotified(autorampId: string): void {
+ const existing = this.state.autoramps.find(
+ (autoramp) => autoramp.id === autorampId,
+ );
+ if (!existing) {
+ return;
+ }
+ const notified = markAutorampNotified(existing);
+ this.update((state) => {
+ const idx = state.autoramps.findIndex(
+ (autoramp) => autoramp.id === autorampId,
+ );
+ if (idx !== -1) {
+ state.autoramps[idx] = notified as Draft;
+ }
+ });
+
+ if (
+ !this.#isApplyingAutorampSyncChanges &&
+ !this.#isAutorampSyncingInProgress
+ ) {
+ updateAutorampInRemoteStorage(
+ notified,
+ this.#getAutorampSyncingOptions(),
+ ).catch(() => undefined);
+ }
+ }
+
+ /**
+ * Applies a remote autoramp snapshot from a websocket / webhook push.
+ * Uses the same compare helper as refresh-on-load.
+ *
+ * @param remote - Remote autoramp snapshot.
+ * @returns The updated local account.
+ */
+ applyAutorampStatusFromPush(remote: AutorampRemoteSnapshot): AutorampAccount {
+ return this.#applyAutorampRemoteSnapshot(remote);
+ }
+
+ /**
+ * Fetches one autoramp from the Ramp API neo-bank proxy and applies it.
+ *
+ * @param autorampId - MoonPay autoramp id.
+ * @returns The updated local account.
+ */
+ async refreshAutoramp(autorampId: string): Promise {
+ const remote = await this.messenger.call(
+ 'NeoBankService:getAutoramp',
+ autorampId,
+ );
+ return this.#applyAutorampRemoteSnapshot(remote);
+ }
+
+ /**
+ * Refreshes all known local autoramps from remote.
+ * Intended for app load / unlock catch-up when websockets were missed.
+ *
+ * @returns Updated autoramp accounts (failed fetches are skipped).
+ */
+ async refreshAutoramps(): Promise {
+ const ids = this.state.autoramps.map((autoramp) => autoramp.id);
+ const updated: AutorampAccount[] = [];
+
+ for (const id of ids) {
+ try {
+ updated.push(await this.refreshAutoramp(id));
+ } catch {
+ // Keep local state for this id; continue remaining refreshes.
+ }
+ }
+
+ return updated;
+ }
+
+ /**
+ * Bidirectional sync of autoramp accounts with MetaMask User Storage
+ * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail.
+ *
+ * @param config - Optional error callbacks for Sentry / logging.
+ */
+ async syncAutorampsWithUserStorage(
+ config: SyncAutorampsWithUserStorageConfig = {},
+ ): Promise {
+ await syncAutorampsWithUserStorageInternal(
+ config,
+ this.#getAutorampSyncingOptions(),
+ );
+ }
+
+ #applyAutorampRemoteSnapshot(
+ remote: AutorampRemoteSnapshot,
+ ): AutorampAccount {
+ const local =
+ this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ??
+ null;
+ const result = applyAutorampRemoteStatus(local, remote);
+
+ this.update((state) => {
+ const idx = state.autoramps.findIndex(
+ (autoramp) => autoramp.id === result.account.id,
+ );
+ if (idx === -1) {
+ state.autoramps.push(result.account as Draft);
+ } else {
+ state.autoramps[idx] = result.account as Draft;
+ }
+ });
+
+ if (result.statusChanged) {
+ this.messenger.publish('RampsController:autorampStatusChanged', {
+ autoramp: result.account,
+ previousStatus: result.previousStatus,
+ shouldNotify: result.shouldNotify,
+ });
+ }
+
+ const upserted =
+ this.state.autoramps.find(
+ (autoramp) => autoramp.id === result.account.id,
+ ) ?? result.account;
+
+ if (
+ !this.#isApplyingAutorampSyncChanges &&
+ !this.#isAutorampSyncingInProgress
+ ) {
+ updateAutorampInRemoteStorage(
+ upserted,
+ this.#getAutorampSyncingOptions(),
+ ).catch(() => undefined);
+ }
+
+ return upserted;
+ }
+
/**
* Refreshes a single order via the V2 API and updates it in state.
* Publishes orderStatusChanged if the status transitioned.
diff --git a/packages/ramps-controller/src/autoramp-syncing/constants.ts b/packages/ramps-controller/src/autoramp-syncing/constants.ts
new file mode 100644
index 00000000000..c69c57ea629
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/constants.ts
@@ -0,0 +1,25 @@
+/**
+ * User Storage feature key for MoonPay Enterprise autoramp accounts.
+ * Each autoramp is stored as a separate entry under this feature.
+ */
+export const USER_STORAGE_RAMPS_AUTORAMPS_FEATURE = 'rampsAutoramps';
+
+/**
+ * Key for version in User Storage schema.
+ */
+export const USER_STORAGE_VERSION_KEY = 'v';
+
+/**
+ * Current version of the autoramp User Storage schema.
+ */
+export const USER_STORAGE_VERSION = '1';
+
+/**
+ * Trace names for autoramp syncing operations.
+ */
+export const TraceName = {
+ AutorampSyncFull: 'Ramps Autoramp Sync Full',
+ AutorampSyncSaveBatch: 'Ramps Autoramp Sync Save Batch',
+ AutorampSyncUpdateRemote: 'Ramps Autoramp Sync Update Remote',
+ AutorampSyncDeleteRemote: 'Ramps Autoramp Sync Delete Remote',
+} as const;
diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts
new file mode 100644
index 00000000000..0d8195cf10c
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts
@@ -0,0 +1,690 @@
+import type { AutorampAccount } from '../autorampAccount.js';
+import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js';
+import {
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ USER_STORAGE_VERSION,
+ USER_STORAGE_VERSION_KEY,
+} from './constants.js';
+import {
+ computeAutorampMergePlan,
+ deleteAutorampInRemoteStorage,
+ syncAutorampsWithUserStorage,
+ updateAutorampInRemoteStorage,
+} from './controller-integration.js';
+import { mapAutorampToUserStorageEntry } from './format-utils.js';
+import type {
+ AutorampSyncingController,
+ AutorampSyncingOptions,
+ SyncAutorampAccount,
+} from './types.js';
+
+/**
+ * Builds an autoramp account with sync-relevant defaults.
+ *
+ * @param overrides - Fields to override on the generated account.
+ * @returns A sync-aware autoramp account.
+ */
+function buildAccount(
+ overrides: Partial & { id: string },
+): SyncAutorampAccount {
+ return {
+ ...createAutorampAccount({
+ customerId: 'customer-1',
+ walletAddress: '0xwallet',
+ status: AutorampStatus.Authorized,
+ updatedAt: 1_000,
+ ...overrides,
+ }),
+ ...(overrides.deletedAt === undefined
+ ? {}
+ : { deletedAt: overrides.deletedAt }),
+ };
+}
+
+/**
+ * Serializes an account the way User Storage would return it.
+ *
+ * @param account - Account to serialize.
+ * @returns JSON string of the remote entry.
+ */
+function toRemoteEntryJson(account: SyncAutorampAccount): string {
+ return JSON.stringify(mapAutorampToUserStorageEntry(account));
+}
+
+type Harness = {
+ options: AutorampSyncingOptions;
+ controller: jest.Mocked & {
+ state: { autoramps: AutorampAccount[] };
+ };
+ call: jest.Mock;
+ onAutorampSyncErroneousSituation: jest.Mock;
+ batchSetCalls: () => [string, string][][];
+};
+
+/**
+ * Builds a sync test harness with a stubbed controller and messenger.
+ *
+ * @param args - Harness configuration.
+ * @param args.localAccounts - Accounts present in controller state.
+ * @param args.remoteEntries - Raw JSON entries returned by User Storage.
+ * @param args.pendingDeletes - Accounts queued for remote soft-delete.
+ * @param args.canSync - Whether the Backup & Sync gates should pass.
+ * @param args.trace - Optional trace callback.
+ * @returns The harness.
+ */
+function buildHarness({
+ localAccounts = [],
+ remoteEntries = [],
+ pendingDeletes = [],
+ canSync = true,
+ trace,
+}: {
+ localAccounts?: AutorampAccount[];
+ remoteEntries?: (string | null)[];
+ pendingDeletes?: AutorampAccount[];
+ canSync?: boolean;
+ trace?: AutorampSyncingOptions['trace'];
+} = {}): Harness {
+ const batchSetCalls: [string, string][][] = [];
+
+ const call = jest.fn((action: string, ...args: unknown[]) => {
+ switch (action) {
+ case 'UserStorageController:getState':
+ return { isBackupAndSyncEnabled: canSync };
+ case 'AuthenticationController:isSignedIn':
+ return canSync;
+ case 'UserStorageController:performGetStorageAllFeatureEntries':
+ return remoteEntries;
+ case 'UserStorageController:performBatchSetStorage':
+ batchSetCalls.push(args[1] as [string, string][]);
+ return undefined;
+ default:
+ throw new Error(`unexpected action ${action}`);
+ }
+ });
+
+ const state = { autoramps: [...localAccounts] };
+
+ const controller = {
+ state,
+ isAutorampSyncingInProgress: false,
+ setIsAutorampSyncingInProgress: jest.fn(),
+ setIsApplyingAutorampSyncChanges: jest.fn(),
+ addAutoramp: jest.fn((account: AutorampAccount) => {
+ const index = state.autoramps.findIndex(
+ (entry) => entry.id === account.id,
+ );
+ if (index === -1) {
+ state.autoramps.push(account);
+ } else {
+ state.autoramps[index] = account;
+ }
+ return account;
+ }),
+ removeAutoramp: jest.fn((autorampId: string) => {
+ state.autoramps = state.autoramps.filter(
+ (entry) => entry.id !== autorampId,
+ );
+ controller.state.autoramps = state.autoramps;
+ }),
+ getPendingRemoteAutorampDeletes: jest.fn(() => pendingDeletes),
+ acknowledgePendingRemoteAutorampDeletes: jest.fn(),
+ } as unknown as Harness['controller'];
+
+ const onAutorampSyncErroneousSituation = jest.fn();
+
+ return {
+ options: {
+ getRampsControllerInstance: () => controller,
+ getMessenger: () => ({ call }) as never,
+ ...(trace ? { trace } : {}),
+ },
+ controller,
+ call,
+ onAutorampSyncErroneousSituation,
+ batchSetCalls: () => batchSetCalls,
+ };
+}
+
+describe('computeAutorampMergePlan', () => {
+ it('ignores remote tombstones for accounts that are absent locally', () => {
+ const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 });
+
+ const plan = computeAutorampMergePlan([], [remote]);
+
+ expect(plan.accountsToDeleteLocally).toStrictEqual([]);
+ expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]);
+ expect(plan.accountsToUpdateRemotely).toStrictEqual([]);
+ });
+
+ it('re-uploads a local account that is newer than a remote tombstone', () => {
+ const local = buildAccount({ id: 'ar-1', updatedAt: 9_000 });
+ const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 });
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+
+ expect(
+ plan.accountsToUpdateRemotely.map((account) => account.id),
+ ).toStrictEqual(['ar-1']);
+ expect(plan.accountsToDeleteLocally).toStrictEqual([]);
+ });
+
+ it('treats a local account with no timestamp as older than a tombstone', () => {
+ const local = {
+ ...buildAccount({ id: 'ar-1' }),
+ updatedAt: undefined,
+ } as unknown as SyncAutorampAccount;
+ const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 });
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+
+ expect(
+ plan.accountsToDeleteLocally.map((account) => account.id),
+ ).toStrictEqual(['ar-1']);
+ });
+
+ it('imports the remote account when it is newer than the local copy', () => {
+ const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 });
+ const remote = buildAccount({
+ id: 'ar-1',
+ status: AutorampStatus.Approved,
+ updatedAt: 2_000,
+ });
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+
+ expect(
+ plan.accountsToAddOrUpdateLocally.map((a) => a.status),
+ ).toStrictEqual([AutorampStatus.Approved]);
+ expect(plan.accountsToUpdateRemotely).toStrictEqual([]);
+ });
+
+ it('plans no work when local and remote accounts match', () => {
+ const local = buildAccount({ id: 'ar-1' });
+ const remote = buildAccount({ id: 'ar-1' });
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+
+ expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]);
+ expect(plan.accountsToDeleteLocally).toStrictEqual([]);
+ expect(plan.accountsToUpdateRemotely).toStrictEqual([]);
+ expect([...plan.remoteAccountsMap.keys()]).toStrictEqual(['ar-1']);
+ });
+});
+
+describe('syncAutorampsWithUserStorage', () => {
+ it('does nothing when syncing is not permitted', async () => {
+ const harness = buildHarness({ canSync: false });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(
+ harness.controller.setIsAutorampSyncingInProgress,
+ ).not.toHaveBeenCalled();
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ });
+
+ it('returns early when User Storage holds no entries', async () => {
+ const harness = buildHarness({ remoteEntries: [] });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ expect(
+ harness.controller.setIsAutorampSyncingInProgress,
+ ).toHaveBeenCalledWith(false);
+ });
+
+ it('treats a null feature-entries response as empty', async () => {
+ const harness = buildHarness();
+ harness.call.mockImplementation((action: string) => {
+ if (action === 'UserStorageController:getState') {
+ return { isBackupAndSyncEnabled: true };
+ }
+ if (action === 'AuthenticationController:isSignedIn') {
+ return true;
+ }
+ if (
+ action === 'UserStorageController:performGetStorageAllFeatureEntries'
+ ) {
+ return null;
+ }
+ throw new Error(`unexpected action ${action}`);
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.controller.addAutoramp).not.toHaveBeenCalled();
+ });
+
+ it('imports remote-only accounts into controller state', async () => {
+ const remote = buildAccount({ id: 'ar-remote', updatedAt: 2_000 });
+ const harness = buildHarness({
+ remoteEntries: [toRemoteEntryJson(remote)],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.controller.addAutoramp).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'ar-remote' }),
+ );
+ expect(
+ harness.controller.setIsApplyingAutorampSyncChanges.mock.calls,
+ ).toStrictEqual([[true], [false]]);
+ });
+
+ it('uploads local-only accounts to User Storage', async () => {
+ const local = buildAccount({ id: 'ar-local', updatedAt: 3_000 });
+ const other = buildAccount({ id: 'ar-other', updatedAt: 4_000 });
+ const harness = buildHarness({
+ localAccounts: [local, other],
+ remoteEntries: [toRemoteEntryJson(other)],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ const [entries] = harness.batchSetCalls();
+ expect(entries.map(([key]) => key)).toStrictEqual(['ar-local']);
+ });
+
+ it('stamps an upload that has no local timestamp', async () => {
+ const local = {
+ ...buildAccount({ id: 'ar-local' }),
+ updatedAt: 0,
+ } as unknown as AutorampAccount;
+ const harness = buildHarness({
+ localAccounts: [local],
+ remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-untouched' }))],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.call).toHaveBeenCalledWith(
+ 'UserStorageController:performBatchSetStorage',
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ expect.any(Array),
+ );
+ const [entries] = harness.batchSetCalls();
+ const uploaded = entries.find(([key]) => key === 'ar-local');
+ expect(uploaded).toBeDefined();
+ expect(JSON.parse((uploaded as [string, string])[1]).lu).toBeGreaterThan(0);
+ });
+
+ it('deletes local accounts that were tombstoned remotely', async () => {
+ const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 });
+ const tombstone = buildAccount({
+ id: 'ar-1',
+ updatedAt: 5_000,
+ deletedAt: 5_000,
+ });
+ const harness = buildHarness({
+ localAccounts: [local],
+ remoteEntries: [toRemoteEntryJson(tombstone)],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.controller.removeAutoramp).toHaveBeenCalledWith('ar-1');
+ });
+
+ it('does not re-import a remote account that is queued for local deletion', async () => {
+ const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 });
+ const harness = buildHarness({
+ remoteEntries: [toRemoteEntryJson(pending)],
+ pendingDeletes: [pending],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.controller.addAutoramp).not.toHaveBeenCalled();
+ });
+
+ it('uploads tombstones for pending remote deletes and acknowledges them', async () => {
+ const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 });
+ const harness = buildHarness({
+ remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))],
+ pendingDeletes: [pending],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ const [entries] = harness.batchSetCalls();
+ const tombstone = entries.find(([key]) => key === 'ar-pending');
+ expect(tombstone).toBeDefined();
+ expect(JSON.parse((tombstone as [string, string])[1]).dt).toBeGreaterThan(
+ 0,
+ );
+ expect(
+ harness.controller.acknowledgePendingRemoteAutorampDeletes,
+ ).toHaveBeenCalledWith([pending]);
+ });
+
+ it('ignores pending deletes that have no storage key', async () => {
+ const harness = buildHarness({
+ remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))],
+ pendingDeletes: [
+ { ...buildAccount({ id: 'ar-pending' }), id: '' } as AutorampAccount,
+ ],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(
+ harness.controller.acknowledgePendingRemoteAutorampDeletes,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('re-uploads a local account whose newer remote copy was not imported', async () => {
+ // The account is queued for deletion, so the newer remote copy is not
+ // applied locally; the surviving local copy still has to reach the remote.
+ const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 });
+ const remote = buildAccount({
+ id: 'ar-1',
+ status: AutorampStatus.Approved,
+ updatedAt: 5_000,
+ });
+ const harness = buildHarness({
+ localAccounts: [local],
+ remoteEntries: [toRemoteEntryJson(remote)],
+ pendingDeletes: [local],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(harness.controller.addAutoramp).not.toHaveBeenCalled();
+ const [entries] = harness.batchSetCalls();
+ expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']);
+ expect(JSON.parse(entries[0][1]).o.status).toBe(AutorampStatus.Authorized);
+ });
+
+ it('stamps a re-uploaded local account that has no timestamp', async () => {
+ const local = {
+ ...buildAccount({ id: 'ar-1' }),
+ updatedAt: 0,
+ } as unknown as AutorampAccount;
+ const remote = buildAccount({
+ id: 'ar-1',
+ status: AutorampStatus.Approved,
+ updatedAt: 5_000,
+ });
+ const harness = buildHarness({
+ localAccounts: [local],
+ remoteEntries: [toRemoteEntryJson(remote)],
+ pendingDeletes: [local],
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ const [entries] = harness.batchSetCalls();
+ expect(JSON.parse(entries[0][1]).lu).toBeGreaterThan(0);
+ });
+
+ it('reports an unsupported storage version and skips the entry', async () => {
+ const harness = buildHarness({
+ remoteEntries: [
+ JSON.stringify({
+ [USER_STORAGE_VERSION_KEY]: '999',
+ o: { id: 'ar-1' },
+ }),
+ ],
+ });
+
+ await syncAutorampsWithUserStorage(
+ {
+ onAutorampSyncErroneousSituation:
+ harness.onAutorampSyncErroneousSituation,
+ },
+ harness.options,
+ );
+
+ expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith(
+ 'Unsupported autoramp storage version',
+ { version: '999', expectedVersion: USER_STORAGE_VERSION },
+ );
+ expect(harness.controller.addAutoramp).not.toHaveBeenCalled();
+ });
+
+ it('reports a remote entry that is missing its payload', async () => {
+ const harness = buildHarness({
+ remoteEntries: [
+ JSON.stringify({ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION }),
+ ],
+ });
+
+ await syncAutorampsWithUserStorage(
+ {
+ onAutorampSyncErroneousSituation:
+ harness.onAutorampSyncErroneousSituation,
+ },
+ harness.options,
+ );
+
+ expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith(
+ 'Remote autoramp entry missing payload',
+ {},
+ );
+ });
+
+ it('reports a remote entry that cannot be parsed', async () => {
+ const harness = buildHarness({ remoteEntries: ['not json'] });
+
+ await syncAutorampsWithUserStorage(
+ {
+ onAutorampSyncErroneousSituation:
+ harness.onAutorampSyncErroneousSituation,
+ },
+ harness.options,
+ );
+
+ expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith(
+ 'Failed to parse remote autoramp entry',
+ expect.objectContaining({ entryLength: 'not json'.length }),
+ );
+ });
+
+ it('skips a remote entry whose payload has no id', async () => {
+ const harness = buildHarness({
+ remoteEntries: [
+ JSON.stringify({
+ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION,
+ o: {
+ id: '',
+ customerId: 'c',
+ walletAddress: '0x1',
+ status: AutorampStatus.Authorized,
+ lastSeenStatus: AutorampStatus.Authorized,
+ },
+ lu: 1_000,
+ }),
+ ],
+ });
+
+ await syncAutorampsWithUserStorage(
+ {
+ onAutorampSyncErroneousSituation:
+ harness.onAutorampSyncErroneousSituation,
+ },
+ harness.options,
+ );
+
+ expect(harness.controller.addAutoramp).not.toHaveBeenCalled();
+ expect(harness.onAutorampSyncErroneousSituation).not.toHaveBeenCalled();
+ });
+
+ it('skips a remote write whose account has an empty storage key', async () => {
+ const harness = buildHarness({
+ localAccounts: [
+ { ...buildAccount({ id: 'ar-local' }), id: '' } as AutorampAccount,
+ ],
+ remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))],
+ });
+ harness.controller.getPendingRemoteAutorampDeletes.mockReturnValue([]);
+
+ await syncAutorampsWithUserStorage(
+ {
+ onAutorampSyncErroneousSituation:
+ harness.onAutorampSyncErroneousSituation,
+ },
+ harness.options,
+ );
+
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ });
+
+ it('reports and rethrows when the sync fails', async () => {
+ const harness = buildHarness();
+ const failure = new Error('storage down');
+ harness.call.mockImplementation((action: string) => {
+ if (action === 'UserStorageController:getState') {
+ return { isBackupAndSyncEnabled: true };
+ }
+ if (action === 'AuthenticationController:isSignedIn') {
+ return true;
+ }
+ throw failure;
+ });
+
+ await expect(
+ syncAutorampsWithUserStorage(
+ {
+ onAutorampSyncErroneousSituation:
+ harness.onAutorampSyncErroneousSituation,
+ },
+ harness.options,
+ ),
+ ).rejects.toThrow('storage down');
+
+ expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith(
+ 'Error synchronizing autoramps',
+ { error: failure },
+ );
+ expect(
+ harness.controller.setIsAutorampSyncingInProgress,
+ ).toHaveBeenLastCalledWith(false);
+ });
+
+ it('wraps the sync and the batch save in traces when a callback is given', async () => {
+ const traceNames: string[] = [];
+ const trace = jest.fn(
+ async (request: { name: string }, fn?: () => unknown) => {
+ traceNames.push(request.name);
+ return await (fn as () => Promise)();
+ },
+ ) as unknown as AutorampSyncingOptions['trace'];
+
+ const harness = buildHarness({
+ localAccounts: [buildAccount({ id: 'ar-local' })],
+ remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))],
+ trace,
+ });
+
+ await syncAutorampsWithUserStorage({}, harness.options);
+
+ expect(traceNames).toStrictEqual([
+ 'Ramps Autoramp Sync Full',
+ 'Ramps Autoramp Sync Save Batch',
+ ]);
+ });
+});
+
+describe('updateAutorampInRemoteStorage', () => {
+ it('writes the account with a refreshed timestamp', async () => {
+ const harness = buildHarness();
+
+ await updateAutorampInRemoteStorage(
+ buildAccount({ id: 'ar-1' }),
+ harness.options,
+ );
+
+ const [entries] = harness.batchSetCalls();
+ expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']);
+ });
+
+ it('does nothing when syncing is not permitted', async () => {
+ const harness = buildHarness({ canSync: false });
+
+ await updateAutorampInRemoteStorage(
+ buildAccount({ id: 'ar-1' }),
+ harness.options,
+ );
+
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ });
+
+ it('does nothing for an account that is not syncable', async () => {
+ const harness = buildHarness();
+
+ await updateAutorampInRemoteStorage(
+ { ...buildAccount({ id: 'ar-1' }), id: '' },
+ harness.options,
+ );
+
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ });
+
+ it('wraps the write in a trace when a callback is given', async () => {
+ const trace = jest.fn(async (_request: unknown, fn?: () => unknown) =>
+ (fn as () => Promise)(),
+ ) as unknown as AutorampSyncingOptions['trace'];
+ const harness = buildHarness({ trace });
+
+ await updateAutorampInRemoteStorage(
+ buildAccount({ id: 'ar-1' }),
+ harness.options,
+ );
+
+ expect(trace).toHaveBeenCalled();
+ expect(harness.batchSetCalls()).toHaveLength(1);
+ });
+});
+
+describe('deleteAutorampInRemoteStorage', () => {
+ it('writes a tombstone for the account', async () => {
+ const harness = buildHarness();
+
+ await deleteAutorampInRemoteStorage(
+ buildAccount({ id: 'ar-1' }),
+ harness.options,
+ );
+
+ const [entries] = harness.batchSetCalls();
+ expect(JSON.parse(entries[0][1]).dt).toBeGreaterThan(0);
+ });
+
+ it('does nothing when syncing is not permitted', async () => {
+ const harness = buildHarness({ canSync: false });
+
+ await deleteAutorampInRemoteStorage(
+ buildAccount({ id: 'ar-1' }),
+ harness.options,
+ );
+
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ });
+
+ it('does nothing for an account with no id', async () => {
+ const harness = buildHarness();
+
+ await deleteAutorampInRemoteStorage(
+ { ...buildAccount({ id: 'ar-1' }), id: '' },
+ harness.options,
+ );
+
+ expect(harness.batchSetCalls()).toStrictEqual([]);
+ });
+
+ it('wraps the tombstone write in a trace when a callback is given', async () => {
+ const trace = jest.fn(async (_request: unknown, fn?: () => unknown) =>
+ (fn as () => Promise)(),
+ ) as unknown as AutorampSyncingOptions['trace'];
+ const harness = buildHarness({ trace });
+
+ await deleteAutorampInRemoteStorage(
+ buildAccount({ id: 'ar-1' }),
+ harness.options,
+ );
+
+ expect(trace).toHaveBeenCalled();
+ expect(harness.batchSetCalls()).toHaveLength(1);
+ });
+});
diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts
new file mode 100644
index 00000000000..e6c03c52be4
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts
@@ -0,0 +1,437 @@
+import type { AutorampAccount } from '../autorampAccount.js';
+import {
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ USER_STORAGE_VERSION,
+ USER_STORAGE_VERSION_KEY,
+ TraceName,
+} from './constants.js';
+import {
+ areAutorampsEqual,
+ createAutorampStorageKey,
+ isSyncableAutoramp,
+ mapAutorampToUserStorageEntry,
+ mapUserStorageEntryToAutoramp,
+ stripAutorampSyncMetadata,
+} from './format-utils.js';
+import { canPerformAutorampSyncing } from './sync-utils.js';
+import type {
+ AutorampSyncingOptions,
+ SyncAutorampAccount,
+ SyncAutorampsWithUserStorageConfig,
+ UserStorageAutorampEntry,
+} from './types.js';
+
+function getAutorampTimestamp(account: SyncAutorampAccount): number {
+ return account.updatedAt ?? 0;
+}
+
+/**
+ * Builds the local/remote merge plan for autoramp sync.
+ *
+ * @param localAccounts - Syncable local accounts.
+ * @param validRemoteAccounts - Syncable remote accounts.
+ * @returns Local mutations and remote uploads to apply.
+ */
+export function computeAutorampMergePlan(
+ localAccounts: SyncAutorampAccount[],
+ validRemoteAccounts: SyncAutorampAccount[],
+): {
+ remoteAccountsMap: Map;
+ accountsToAddOrUpdateLocally: SyncAutorampAccount[];
+ accountsToDeleteLocally: SyncAutorampAccount[];
+ accountsToUpdateRemotely: SyncAutorampAccount[];
+} {
+ const localAccountsMap = new Map();
+ const remoteAccountsMap = new Map();
+
+ localAccounts.forEach((account) => {
+ localAccountsMap.set(createAutorampStorageKey(account), account);
+ });
+ validRemoteAccounts.forEach((account) => {
+ remoteAccountsMap.set(createAutorampStorageKey(account), account);
+ });
+
+ const accountsToAddOrUpdateLocally: SyncAutorampAccount[] = [];
+ const accountsToDeleteLocally: SyncAutorampAccount[] = [];
+ const accountsToUpdateRemotely: SyncAutorampAccount[] = [];
+
+ for (const remoteAccount of validRemoteAccounts) {
+ const key = createAutorampStorageKey(remoteAccount);
+ const localAccount = localAccountsMap.get(key);
+
+ if (remoteAccount.deletedAt) {
+ if (localAccount) {
+ const localTimestamp = getAutorampTimestamp(localAccount);
+ if (localTimestamp > remoteAccount.deletedAt) {
+ accountsToUpdateRemotely.push(localAccount);
+ } else {
+ accountsToDeleteLocally.push(remoteAccount);
+ }
+ }
+ } else if (!localAccount) {
+ accountsToAddOrUpdateLocally.push(remoteAccount);
+ } else if (!areAutorampsEqual(localAccount, remoteAccount)) {
+ const localTimestamp = getAutorampTimestamp(localAccount);
+ const remoteTimestamp = getAutorampTimestamp(remoteAccount);
+ if (localTimestamp >= remoteTimestamp) {
+ accountsToUpdateRemotely.push(localAccount);
+ } else {
+ accountsToAddOrUpdateLocally.push(remoteAccount);
+ }
+ }
+ }
+
+ for (const localAccount of localAccounts) {
+ const key = createAutorampStorageKey(localAccount);
+ if (!remoteAccountsMap.has(key)) {
+ accountsToUpdateRemotely.push(localAccount);
+ }
+ }
+
+ return {
+ remoteAccountsMap,
+ accountsToAddOrUpdateLocally,
+ accountsToDeleteLocally,
+ accountsToUpdateRemotely,
+ };
+}
+
+async function getRemoteAutoramps(
+ options: AutorampSyncingOptions,
+ config: SyncAutorampsWithUserStorageConfig,
+): Promise {
+ const { getMessenger } = options;
+ const { onAutorampSyncErroneousSituation } = config;
+
+ const remoteJsonArray =
+ (await getMessenger().call(
+ 'UserStorageController:performGetStorageAllFeatureEntries',
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ )) ?? [];
+
+ if (remoteJsonArray.length === 0) {
+ return [];
+ }
+
+ const remoteAccounts: SyncAutorampAccount[] = [];
+ for (const entryJson of remoteJsonArray) {
+ try {
+ const entry = JSON.parse(entryJson) as UserStorageAutorampEntry;
+ if (entry[USER_STORAGE_VERSION_KEY] !== USER_STORAGE_VERSION) {
+ onAutorampSyncErroneousSituation?.(
+ 'Unsupported autoramp storage version',
+ {
+ version: entry[USER_STORAGE_VERSION_KEY],
+ expectedVersion: USER_STORAGE_VERSION,
+ },
+ );
+ continue;
+ }
+ if (!entry.o || typeof entry.o !== 'object') {
+ onAutorampSyncErroneousSituation?.(
+ 'Remote autoramp entry missing payload',
+ {},
+ );
+ continue;
+ }
+ const mapped = mapUserStorageEntryToAutoramp(entry);
+ if (!createAutorampStorageKey(mapped)) {
+ continue;
+ }
+ remoteAccounts.push(mapped);
+ } catch (error) {
+ onAutorampSyncErroneousSituation?.(
+ 'Failed to parse remote autoramp entry',
+ { error, entryLength: entryJson.length },
+ );
+ }
+ }
+
+ return remoteAccounts;
+}
+
+async function saveAutorampsToUserStorage(
+ accounts: SyncAutorampAccount[],
+ options: AutorampSyncingOptions,
+ config: SyncAutorampsWithUserStorageConfig,
+): Promise {
+ const { getMessenger, trace } = options;
+ const { onAutorampSyncErroneousSituation } = config;
+
+ const save = async (): Promise => {
+ const storageEntries: [string, string][] = [];
+ for (const account of accounts) {
+ const key = createAutorampStorageKey(account);
+ // Defensive: every caller filters on `isSyncableAutoramp` or a non-empty
+ // key before reaching here, so an id-less account is unreachable today.
+ /* istanbul ignore next */
+ if (!key) {
+ onAutorampSyncErroneousSituation?.(
+ 'Skipping autoramp remote write with empty storage key',
+ { hasId: Boolean(account.id) },
+ );
+ continue;
+ }
+ storageEntries.push([
+ key,
+ JSON.stringify(mapAutorampToUserStorageEntry(account)),
+ ]);
+ }
+ // Defensive: only reachable if every account was skipped above, which the
+ // callers' filtering already rules out.
+ /* istanbul ignore next */
+ if (storageEntries.length === 0) {
+ return;
+ }
+ await getMessenger().call(
+ 'UserStorageController:performBatchSetStorage',
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ storageEntries,
+ );
+ };
+
+ if (trace) {
+ await trace(
+ {
+ name: TraceName.AutorampSyncSaveBatch,
+ data: { autorampCount: accounts.length },
+ },
+ save,
+ );
+ return;
+ }
+ await save();
+}
+
+/**
+ * Syncs autoramp accounts between local controller state and User Storage.
+ *
+ * @param config - Optional error callbacks.
+ * @param options - Sync options (controller + messenger).
+ */
+export async function syncAutorampsWithUserStorage(
+ config: SyncAutorampsWithUserStorageConfig,
+ options: AutorampSyncingOptions,
+): Promise {
+ const { getRampsControllerInstance, trace } = options;
+ const { onAutorampSyncErroneousSituation } = config;
+
+ if (!canPerformAutorampSyncing(options)) {
+ return;
+ }
+
+ const controller = getRampsControllerInstance();
+ controller.setIsAutorampSyncingInProgress(true);
+
+ try {
+ const validRemoteAccounts = (
+ await getRemoteAutoramps(options, config)
+ ).filter(
+ (account: SyncAutorampAccount) =>
+ Boolean(account.deletedAt) || isSyncableAutoramp(account),
+ );
+
+ const performSync = async (): Promise => {
+ const getLocalAccounts = (): AutorampAccount[] =>
+ controller.state.autoramps.filter(isSyncableAutoramp);
+
+ const pendingDeleteKeysBeforeApply = new Set(
+ controller
+ .getPendingRemoteAutorampDeletes()
+ .map((account) => createAutorampStorageKey(account))
+ .filter((key) => key.length > 0),
+ );
+
+ const {
+ remoteAccountsMap,
+ accountsToAddOrUpdateLocally,
+ accountsToDeleteLocally,
+ accountsToUpdateRemotely,
+ } = computeAutorampMergePlan(getLocalAccounts(), validRemoteAccounts);
+
+ controller.setIsApplyingAutorampSyncChanges(true);
+ try {
+ for (const account of accountsToDeleteLocally) {
+ controller.removeAutoramp(createAutorampStorageKey(account));
+ }
+ for (const account of accountsToAddOrUpdateLocally) {
+ if (
+ !account.deletedAt &&
+ !pendingDeleteKeysBeforeApply.has(createAutorampStorageKey(account))
+ ) {
+ controller.addAutoramp(stripAutorampSyncMetadata(account));
+ }
+ }
+ } finally {
+ controller.setIsApplyingAutorampSyncChanges(false);
+ }
+
+ const localKeys = new Set(
+ getLocalAccounts().map((account) => createAutorampStorageKey(account)),
+ );
+ const pendingDeletes = controller
+ .getPendingRemoteAutorampDeletes()
+ .filter((account) => {
+ const key = createAutorampStorageKey(account);
+ return key.length > 0 && !localKeys.has(key);
+ });
+ const pendingDeleteKeys = new Set(
+ pendingDeletes.map((account) => createAutorampStorageKey(account)),
+ );
+
+ const now = Date.now();
+ const uploads: SyncAutorampAccount[] = [
+ ...accountsToUpdateRemotely
+ .filter(
+ (account) =>
+ !pendingDeleteKeys.has(createAutorampStorageKey(account)),
+ )
+ .map((account) => ({
+ ...account,
+ updatedAt: account.updatedAt || now,
+ })),
+ // Local-only accounts already included via merge plan; also upload
+ // accounts present locally that differ after apply.
+ ...getLocalAccounts()
+ .filter((account) => {
+ const key = createAutorampStorageKey(account);
+ // Defensive: `pendingDeletes` already excludes anything still
+ // present locally, so this cannot match a local account.
+ /* istanbul ignore next */
+ if (pendingDeleteKeys.has(key)) {
+ return false;
+ }
+ const remote = remoteAccountsMap.get(key);
+ return !remote || !areAutorampsEqual(account, remote);
+ })
+ .filter(
+ (account) =>
+ !accountsToUpdateRemotely.some(
+ (planned) =>
+ createAutorampStorageKey(planned) ===
+ createAutorampStorageKey(account),
+ ),
+ )
+ .map((account) => ({
+ ...account,
+ updatedAt: account.updatedAt || now,
+ })),
+ ...pendingDeletes.map((account) => ({
+ ...account,
+ deletedAt: now,
+ updatedAt: now,
+ })),
+ ];
+
+ // Dedupe by key, prefer later entries
+ const uploadMap = new Map();
+ for (const account of uploads) {
+ uploadMap.set(createAutorampStorageKey(account), account);
+ }
+
+ if (uploadMap.size > 0) {
+ await saveAutorampsToUserStorage(
+ [...uploadMap.values()],
+ options,
+ config,
+ );
+ controller.acknowledgePendingRemoteAutorampDeletes(pendingDeletes);
+ }
+ };
+
+ if (trace) {
+ await trace(
+ {
+ name: TraceName.AutorampSyncFull,
+ data: {
+ localAutorampCount:
+ controller.state.autoramps.filter(isSyncableAutoramp).length,
+ remoteAutorampCount: validRemoteAccounts.length,
+ },
+ },
+ performSync,
+ );
+ return;
+ }
+
+ await performSync();
+ } catch (error) {
+ onAutorampSyncErroneousSituation?.('Error synchronizing autoramps', {
+ error,
+ });
+ throw error;
+ } finally {
+ controller.setIsAutorampSyncingInProgress(false);
+ }
+}
+
+/**
+ * Updates a single autoramp in remote storage without a full sync.
+ *
+ * @param account - Local autoramp that changed.
+ * @param options - Sync options.
+ * @param config - Optional error callbacks.
+ */
+export async function updateAutorampInRemoteStorage(
+ account: SyncAutorampAccount,
+ options: AutorampSyncingOptions,
+ config: SyncAutorampsWithUserStorageConfig = {},
+): Promise {
+ const { trace } = options;
+
+ const update = async (): Promise => {
+ if (!canPerformAutorampSyncing(options) || !isSyncableAutoramp(account)) {
+ return;
+ }
+ await saveAutorampsToUserStorage(
+ [{ ...account, updatedAt: Date.now() }],
+ options,
+ config,
+ );
+ };
+
+ if (trace) {
+ await trace({ name: TraceName.AutorampSyncUpdateRemote }, update);
+ return;
+ }
+ await update();
+}
+
+/**
+ * Soft-deletes an autoramp in remote storage.
+ *
+ * @param account - Autoramp to tombstone remotely.
+ * @param options - Sync options.
+ * @param config - Optional error callbacks.
+ */
+export async function deleteAutorampInRemoteStorage(
+ account: SyncAutorampAccount,
+ options: AutorampSyncingOptions,
+ config: SyncAutorampsWithUserStorageConfig = {},
+): Promise {
+ const { trace } = options;
+
+ const remove = async (): Promise => {
+ if (!canPerformAutorampSyncing(options) || !account.id) {
+ return;
+ }
+ const now = Date.now();
+ await saveAutorampsToUserStorage(
+ [
+ {
+ ...account,
+ deletedAt: now,
+ updatedAt: now,
+ },
+ ],
+ options,
+ config,
+ );
+ };
+
+ if (trace) {
+ await trace({ name: TraceName.AutorampSyncDeleteRemote }, remove);
+ return;
+ }
+ await remove();
+}
diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts
new file mode 100644
index 00000000000..a70dfa16a17
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts
@@ -0,0 +1,102 @@
+import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js';
+import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js';
+import {
+ areAutorampsEqual,
+ createAutorampStorageKey,
+ isSyncableAutoramp,
+ mapAutorampToUserStorageEntry,
+ mapUserStorageEntryToAutoramp,
+ stripAutorampSyncMetadata,
+} from './format-utils.js';
+
+describe('autoramp-syncing/format-utils', () => {
+ const account = createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ updatedAt: 1000,
+ depositRailsSummary: { ready: true, currency: 'EUR' },
+ });
+
+ it('creates storage keys from id', () => {
+ expect(createAutorampStorageKey(account)).toBe('ar-1');
+ expect(createAutorampStorageKey('ar-2')).toBe('ar-2');
+ });
+
+ it('detects syncable autoramps', () => {
+ expect(isSyncableAutoramp(account)).toBe(true);
+ expect(isSyncableAutoramp({ id: '' })).toBe(false);
+ expect(isSyncableAutoramp(null)).toBe(false);
+ });
+
+ it('maps to user storage without deposit rails', () => {
+ const entry = mapAutorampToUserStorageEntry({
+ ...account,
+ notifiedForStatus: AutorampStatus.Approved,
+ });
+
+ expect(entry).toStrictEqual({
+ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION,
+ o: {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ lastSeenStatus: AutorampStatus.Approved,
+ notifiedForStatus: AutorampStatus.Approved,
+ },
+ lu: 1000,
+ });
+ expect(entry.o).not.toHaveProperty('depositRailsSummary');
+ });
+
+ it('round-trips storage entries and strips deletedAt', () => {
+ const entry = mapAutorampToUserStorageEntry({
+ ...account,
+ deletedAt: 2000,
+ });
+ const mapped = mapUserStorageEntryToAutoramp(entry);
+ expect(mapped.deletedAt).toBe(2000);
+ expect(stripAutorampSyncMetadata(mapped)).not.toHaveProperty('deletedAt');
+ });
+
+ it('stamps the current time when the account has no update timestamp', () => {
+ const entry = mapAutorampToUserStorageEntry({
+ ...account,
+ updatedAt: 0,
+ });
+
+ expect(entry.lu).toBeGreaterThan(0);
+ expect(entry.o).not.toHaveProperty('notifiedForStatus');
+ expect(entry).not.toHaveProperty('dt');
+ });
+
+ it('normalizes a notified status and defaults a missing timestamp', () => {
+ const mapped = mapUserStorageEntryToAutoramp({
+ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION,
+ o: {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ lastSeenStatus: AutorampStatus.Approved,
+ notifiedForStatus: AutorampStatus.Approved,
+ },
+ });
+
+ expect(mapped.notifiedForStatus).toBe(AutorampStatus.Approved);
+ expect(mapped.updatedAt).toBeGreaterThan(0);
+ expect(mapped).not.toHaveProperty('deletedAt');
+ });
+
+ it('compares sync-relevant fields', () => {
+ expect(areAutorampsEqual(account, { ...account })).toBe(true);
+ expect(
+ areAutorampsEqual(account, {
+ ...account,
+ status: AutorampStatus.Authorized,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts
new file mode 100644
index 00000000000..13af41c9249
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts
@@ -0,0 +1,121 @@
+import type { AutorampAccount } from '../autorampAccount.js';
+import { normalizeAutorampStatus } from '../autorampAccount.js';
+import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js';
+import type { SyncAutorampAccount, UserStorageAutorampEntry } from './types.js';
+
+/**
+ * Storage key for an autoramp entry (MoonPay autoramp id).
+ *
+ * @param account - Autoramp account or id-bearing object.
+ * @returns Storage key string.
+ */
+export function createAutorampStorageKey(
+ account: Pick | string,
+): string {
+ return typeof account === 'string' ? account : account.id;
+}
+
+/**
+ * Whether an autoramp has the minimum fields required to sync.
+ *
+ * @param account - Candidate autoramp.
+ * @returns True when syncable.
+ */
+export function isSyncableAutoramp(
+ account: Partial | null | undefined,
+): account is AutorampAccount {
+ return Boolean(
+ account &&
+ typeof account.id === 'string' &&
+ account.id.length > 0 &&
+ typeof account.customerId === 'string' &&
+ typeof account.walletAddress === 'string' &&
+ account.status,
+ );
+}
+
+/**
+ * Map a local autoramp to a User Storage entry (strips depositRailsSummary).
+ *
+ * @param account - Local or sync-aware autoramp.
+ * @returns Compact storage entry.
+ */
+export function mapAutorampToUserStorageEntry(
+ account: SyncAutorampAccount,
+): UserStorageAutorampEntry {
+ const now = Date.now();
+ return {
+ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION,
+ o: {
+ id: account.id,
+ customerId: account.customerId,
+ walletAddress: account.walletAddress,
+ status: account.status,
+ lastSeenStatus: account.lastSeenStatus,
+ ...(account.notifiedForStatus
+ ? { notifiedForStatus: account.notifiedForStatus }
+ : {}),
+ },
+ lu: account.updatedAt || now,
+ ...(account.deletedAt ? { dt: account.deletedAt } : {}),
+ };
+}
+
+/**
+ * Map a User Storage entry back to a sync-aware autoramp account.
+ *
+ * @param entry - Remote storage entry.
+ * @returns Sync autoramp (no depositRailsSummary).
+ */
+export function mapUserStorageEntryToAutoramp(
+ entry: UserStorageAutorampEntry,
+): SyncAutorampAccount {
+ return {
+ id: entry.o.id,
+ customerId: entry.o.customerId,
+ walletAddress: entry.o.walletAddress,
+ status: normalizeAutorampStatus(entry.o.status),
+ lastSeenStatus: normalizeAutorampStatus(entry.o.lastSeenStatus),
+ ...(entry.o.notifiedForStatus
+ ? {
+ notifiedForStatus: normalizeAutorampStatus(entry.o.notifiedForStatus),
+ }
+ : {}),
+ updatedAt: entry.lu ?? Date.now(),
+ ...(entry.dt ? { deletedAt: entry.dt } : {}),
+ };
+}
+
+/**
+ * Strip sync-only metadata before writing into controller state.
+ *
+ * @param account - Sync-aware autoramp.
+ * @returns Plain {@link AutorampAccount}.
+ */
+export function stripAutorampSyncMetadata(
+ account: SyncAutorampAccount,
+): AutorampAccount {
+ const { deletedAt: _deletedAt, ...rest } = account;
+ return rest;
+}
+
+/**
+ * Compare syncable fields for equality (ignores depositRailsSummary).
+ *
+ * @param left - First account.
+ * @param right - Second account.
+ * @returns True when sync-relevant fields match.
+ */
+export function areAutorampsEqual(
+ left: SyncAutorampAccount,
+ right: SyncAutorampAccount,
+): boolean {
+ return (
+ left.id === right.id &&
+ left.customerId === right.customerId &&
+ left.walletAddress === right.walletAddress &&
+ left.status === right.status &&
+ left.lastSeenStatus === right.lastSeenStatus &&
+ left.notifiedForStatus === right.notifiedForStatus
+ );
+}
diff --git a/packages/ramps-controller/src/autoramp-syncing/index.ts b/packages/ramps-controller/src/autoramp-syncing/index.ts
new file mode 100644
index 00000000000..f8dd8064634
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/index.ts
@@ -0,0 +1,28 @@
+export {
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ USER_STORAGE_VERSION,
+ USER_STORAGE_VERSION_KEY,
+ TraceName,
+} from './constants.js';
+export type {
+ UserStorageAutorampEntry,
+ SyncAutorampAccount,
+ AutorampSyncingController,
+ AutorampSyncingOptions,
+ SyncAutorampsWithUserStorageConfig,
+} from './types.js';
+export {
+ createAutorampStorageKey,
+ isSyncableAutoramp,
+ mapAutorampToUserStorageEntry,
+ mapUserStorageEntryToAutoramp,
+ stripAutorampSyncMetadata,
+ areAutorampsEqual,
+} from './format-utils.js';
+export { canPerformAutorampSyncing } from './sync-utils.js';
+export {
+ computeAutorampMergePlan,
+ syncAutorampsWithUserStorage,
+ updateAutorampInRemoteStorage,
+ deleteAutorampInRemoteStorage,
+} from './controller-integration.js';
diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts
new file mode 100644
index 00000000000..1b7c83dc45f
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts
@@ -0,0 +1,128 @@
+import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js';
+import { computeAutorampMergePlan } from './controller-integration.js';
+import { canPerformAutorampSyncing } from './sync-utils.js';
+import type { AutorampSyncingOptions } from './types.js';
+
+describe('autoramp-syncing/sync-utils', () => {
+ it('returns false when messenger actions are unavailable', () => {
+ const options: AutorampSyncingOptions = {
+ getMessenger: () =>
+ ({
+ call: () => {
+ throw new Error('not delegated');
+ },
+ }) as AutorampSyncingOptions['getMessenger'] extends () => infer R
+ ? R
+ : never,
+ getRampsControllerInstance: () => ({
+ state: { autoramps: [] },
+ isAutorampSyncingInProgress: false,
+ setIsAutorampSyncingInProgress: jest.fn(),
+ setIsApplyingAutorampSyncChanges: jest.fn(),
+ addAutoramp: jest.fn(),
+ removeAutoramp: jest.fn(),
+ getPendingRemoteAutorampDeletes: (): [] => [],
+ acknowledgePendingRemoteAutorampDeletes: jest.fn(),
+ }),
+ };
+
+ expect(canPerformAutorampSyncing(options)).toBe(false);
+ });
+
+ it('returns true when B&S and auth gates pass', () => {
+ const call = jest.fn((action: string) => {
+ if (action === 'UserStorageController:getState') {
+ return { isBackupAndSyncEnabled: true };
+ }
+ if (action === 'AuthenticationController:isSignedIn') {
+ return true;
+ }
+ throw new Error(`unexpected ${action}`);
+ });
+
+ const options = {
+ getMessenger: () => ({ call }) as never,
+ getRampsControllerInstance: () => ({
+ state: { autoramps: [] },
+ isAutorampSyncingInProgress: false,
+ setIsAutorampSyncingInProgress: jest.fn(),
+ setIsApplyingAutorampSyncChanges: jest.fn(),
+ addAutoramp: jest.fn(),
+ removeAutoramp: jest.fn(),
+ getPendingRemoteAutorampDeletes: (): [] => [],
+ acknowledgePendingRemoteAutorampDeletes: jest.fn(),
+ }),
+ } as AutorampSyncingOptions;
+
+ expect(canPerformAutorampSyncing(options)).toBe(true);
+ });
+});
+
+describe('autoramp-syncing/computeAutorampMergePlan', () => {
+ it('imports remote-only accounts and uploads local-only accounts', () => {
+ const local = createAutorampAccount({
+ id: 'local-1',
+ customerId: 'c',
+ walletAddress: '0x1',
+ status: AutorampStatus.Authorized,
+ updatedAt: 10,
+ });
+ const remote = createAutorampAccount({
+ id: 'remote-1',
+ customerId: 'c',
+ walletAddress: '0x2',
+ status: AutorampStatus.Approved,
+ updatedAt: 20,
+ });
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+
+ expect(plan.accountsToAddOrUpdateLocally.map((a) => a.id)).toStrictEqual([
+ 'remote-1',
+ ]);
+ expect(plan.accountsToUpdateRemotely.map((a) => a.id)).toStrictEqual([
+ 'local-1',
+ ]);
+ });
+
+ it('prefers newer timestamp on conflicts', () => {
+ const local = createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'c',
+ walletAddress: '0x1',
+ status: AutorampStatus.Authorized,
+ updatedAt: 50,
+ });
+ const remote = {
+ ...createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'c',
+ walletAddress: '0x1',
+ status: AutorampStatus.Approved,
+ updatedAt: 10,
+ }),
+ };
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+ expect(plan.accountsToUpdateRemotely).toHaveLength(1);
+ expect(plan.accountsToAddOrUpdateLocally).toHaveLength(0);
+ });
+
+ it('applies remote tombstones when local is older', () => {
+ const local = createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'c',
+ walletAddress: '0x1',
+ status: AutorampStatus.Authorized,
+ updatedAt: 10,
+ });
+ const remote = {
+ ...local,
+ deletedAt: 20,
+ updatedAt: 20,
+ };
+
+ const plan = computeAutorampMergePlan([local], [remote]);
+ expect(plan.accountsToDeleteLocally).toHaveLength(1);
+ });
+});
diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts
new file mode 100644
index 00000000000..b95a51015f9
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts
@@ -0,0 +1,49 @@
+import type { AutorampSyncingOptions } from './types.js';
+
+/**
+ * Check if we can perform autoramp User Storage syncing.
+ *
+ * Requires Backup & Sync enabled, signed-in auth, and no in-progress sync.
+ * Optional `isRampsSyncingEnabled` on User Storage state defaults to true when absent.
+ *
+ * @param options - Sync options.
+ * @returns Whether sync can run.
+ */
+export function canPerformAutorampSyncing(
+ options: AutorampSyncingOptions,
+): boolean {
+ const { getMessenger, getRampsControllerInstance } = options;
+
+ try {
+ const userStorageState = getMessenger().call(
+ 'UserStorageController:getState',
+ ) as {
+ isBackupAndSyncEnabled?: boolean;
+ isRampsSyncingEnabled?: boolean;
+ };
+
+ const isBackupAndSyncEnabled = Boolean(
+ userStorageState.isBackupAndSyncEnabled,
+ );
+ const isRampsSyncingEnabled =
+ userStorageState.isRampsSyncingEnabled ?? true;
+ const isAuthEnabled = getMessenger().call(
+ 'AuthenticationController:isSignedIn',
+ );
+ const { isAutorampSyncingInProgress } = getRampsControllerInstance();
+
+ if (
+ !isBackupAndSyncEnabled ||
+ !isRampsSyncingEnabled ||
+ isAutorampSyncingInProgress ||
+ !isAuthEnabled
+ ) {
+ return false;
+ }
+
+ return true;
+ } catch {
+ // Host has not delegated User Storage / auth actions yet.
+ return false;
+ }
+}
diff --git a/packages/ramps-controller/src/autoramp-syncing/types.ts b/packages/ramps-controller/src/autoramp-syncing/types.ts
new file mode 100644
index 00000000000..b3b732e6192
--- /dev/null
+++ b/packages/ramps-controller/src/autoramp-syncing/types.ts
@@ -0,0 +1,70 @@
+import type { TraceCallback } from '@metamask/controller-utils';
+
+import type { AutorampAccount } from '../autorampAccount.js';
+import type { RampsControllerMessenger } from '../RampsController.js';
+import type {
+ USER_STORAGE_VERSION,
+ USER_STORAGE_VERSION_KEY,
+} from './constants.js';
+
+/**
+ * Compact User Storage entry for an autoramp account.
+ * Omits deposit rail details — those are re-fetched from the Ramp API / MoonPay.
+ */
+export type UserStorageAutorampEntry = {
+ [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION;
+ o: {
+ id: string;
+ customerId: string;
+ walletAddress: string;
+ status: string;
+ lastSeenStatus: string;
+ notifiedForStatus?: string;
+ };
+ lu?: number;
+ dt?: number;
+};
+
+/**
+ * {@link AutorampAccount} plus optional soft-delete metadata for sync merge.
+ */
+export type SyncAutorampAccount = AutorampAccount & {
+ deletedAt?: number;
+};
+
+/**
+ * Minimal controller surface required by autoramp syncing.
+ */
+export type AutorampSyncingController = {
+ state: {
+ autoramps: AutorampAccount[];
+ };
+ readonly isAutorampSyncingInProgress: boolean;
+ setIsAutorampSyncingInProgress: (value: boolean) => void;
+ setIsApplyingAutorampSyncChanges: (value: boolean) => void;
+ addAutoramp: (account: AutorampAccount) => AutorampAccount;
+ removeAutoramp: (autorampId: string) => void;
+ getPendingRemoteAutorampDeletes: () => AutorampAccount[];
+ acknowledgePendingRemoteAutorampDeletes: (
+ accounts: AutorampAccount[],
+ ) => void;
+};
+
+/**
+ * Options for autoramp syncing operations.
+ */
+export type AutorampSyncingOptions = {
+ getRampsControllerInstance: () => AutorampSyncingController;
+ getMessenger: () => RampsControllerMessenger;
+ trace?: TraceCallback;
+};
+
+/**
+ * Optional callbacks for sync error reporting.
+ */
+export type SyncAutorampsWithUserStorageConfig = {
+ onAutorampSyncErroneousSituation?: (
+ errorMessage: string,
+ sentryContext?: Record,
+ ) => void;
+};
diff --git a/packages/ramps-controller/src/autorampAccount.test.ts b/packages/ramps-controller/src/autorampAccount.test.ts
new file mode 100644
index 00000000000..d5518956a98
--- /dev/null
+++ b/packages/ramps-controller/src/autorampAccount.test.ts
@@ -0,0 +1,163 @@
+import type {
+ ApplyAutorampRemoteStatusResult,
+ AutorampAccount,
+ AutorampRemoteSnapshot,
+} from './autorampAccount.js';
+import {
+ AutorampStatus,
+ applyAutorampRemoteStatus,
+ createAutorampAccount,
+ isTerminalAutorampStatus,
+ markAutorampNotified,
+ normalizeAutorampStatus,
+} from './autorampAccount.js';
+
+describe('autorampAccount', () => {
+ describe('normalizeAutorampStatus', () => {
+ it('returns known statuses as-is', () => {
+ expect(normalizeAutorampStatus(AutorampStatus.Approved)).toBe(
+ AutorampStatus.Approved,
+ );
+ expect(normalizeAutorampStatus('DepositAccountAdded')).toBe(
+ AutorampStatus.DepositAccountAdded,
+ );
+ });
+
+ it('falls back to Created for unknown values', () => {
+ expect(normalizeAutorampStatus('Nope')).toBe(AutorampStatus.Created);
+ });
+ });
+
+ describe('isTerminalAutorampStatus', () => {
+ it('identifies terminal statuses', () => {
+ expect(isTerminalAutorampStatus(AutorampStatus.Rejected)).toBe(true);
+ expect(isTerminalAutorampStatus(AutorampStatus.Cancelled)).toBe(true);
+ expect(isTerminalAutorampStatus(AutorampStatus.Approved)).toBe(false);
+ expect(isTerminalAutorampStatus(AutorampStatus.Authorized)).toBe(false);
+ });
+ });
+
+ describe('createAutorampAccount', () => {
+ it('defaults status to Authorized and mirrors lastSeenStatus', () => {
+ const account = createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ updatedAt: 1000,
+ });
+
+ expect(account).toStrictEqual({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ lastSeenStatus: AutorampStatus.Authorized,
+ updatedAt: 1000,
+ depositRailsSummary: undefined,
+ });
+ });
+ });
+
+ describe('applyAutorampRemoteStatus', () => {
+ const baseLocal: AutorampAccount = createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Authorized,
+ updatedAt: 1,
+ });
+
+ it('creates a local account without notify when local is null', () => {
+ const remote: AutorampRemoteSnapshot = {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ depositRailsSummary: { ready: true, currency: 'EUR' },
+ };
+
+ const result = applyAutorampRemoteStatus(null, remote);
+
+ expect(result.statusChanged).toBe(false);
+ expect(result.shouldNotify).toBe(false);
+ expect(result.account.status).toBe(AutorampStatus.Approved);
+ expect(result.account.depositRailsSummary).toStrictEqual({
+ ready: true,
+ currency: 'EUR',
+ });
+ });
+
+ it('detects Approved transition and requests notify once', () => {
+ const remote: AutorampRemoteSnapshot = {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: AutorampStatus.Approved,
+ depositRailsSummary: { ready: true },
+ };
+
+ const result = applyAutorampRemoteStatus(baseLocal, remote);
+
+ expect(result).toMatchObject({
+ previousStatus: AutorampStatus.Authorized,
+ statusChanged: true,
+ shouldNotify: true,
+ } satisfies Partial);
+ expect(result.account.status).toBe(AutorampStatus.Approved);
+ expect(result.account.lastSeenStatus).toBe(AutorampStatus.Authorized);
+ });
+
+ it('does not notify again when already notified for that status', () => {
+ const local = markAutorampNotified({
+ ...baseLocal,
+ status: AutorampStatus.Approved,
+ lastSeenStatus: AutorampStatus.Authorized,
+ notifiedForStatus: AutorampStatus.Approved,
+ });
+
+ const result = applyAutorampRemoteStatus(local, {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: AutorampStatus.Approved,
+ });
+
+ expect(result.statusChanged).toBe(false);
+ expect(result.shouldNotify).toBe(false);
+ });
+
+ it('does not notify for non-notable transitions', () => {
+ const result = applyAutorampRemoteStatus(baseLocal, {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: AutorampStatus.DepositAccountAdded,
+ });
+
+ expect(result.statusChanged).toBe(true);
+ expect(result.shouldNotify).toBe(false);
+ });
+
+ it('notifies for Rejected', () => {
+ const result = applyAutorampRemoteStatus(baseLocal, {
+ id: 'ar-1',
+ customerId: 'cust-1',
+ status: AutorampStatus.Rejected,
+ });
+
+ expect(result.shouldNotify).toBe(true);
+ });
+ });
+
+ describe('markAutorampNotified', () => {
+ it('sets notifiedForStatus to current status', () => {
+ const account = createAutorampAccount({
+ id: 'ar-1',
+ customerId: 'cust-1',
+ walletAddress: '0xabc',
+ status: AutorampStatus.Approved,
+ });
+
+ expect(markAutorampNotified(account).notifiedForStatus).toBe(
+ AutorampStatus.Approved,
+ );
+ });
+ });
+});
diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts
new file mode 100644
index 00000000000..eca7f7fa382
--- /dev/null
+++ b/packages/ramps-controller/src/autorampAccount.ts
@@ -0,0 +1,247 @@
+/**
+ * Local + remote models for MoonPay Enterprise autoramp accounts.
+ * Separate from {@link RampsOrder}: autoramps are standing routes; orders are payments.
+ */
+
+/**
+ * Autoramp lifecycle statuses from MoonPay Enterprise.
+ *
+ * @see https://dev.enterprise.moonpay.com/autoramp-status
+ */
+export enum AutorampStatus {
+ Created = 'Created',
+ Authorized = 'Authorized',
+ EditPending = 'EditPending',
+ DepositAccountAdded = 'DepositAccountAdded',
+ Approved = 'Approved',
+ Rejected = 'Rejected',
+ Cancelled = 'Cancelled',
+}
+
+/**
+ * Non-PII deposit readiness summary cached after a remote refresh.
+ * Full deposit rail details (IBAN, etc.) should be re-fetched when needed — not synced.
+ */
+export type AutorampDepositRailsSummary = {
+ /** Source currency code when known (e.g. EUR). */
+ currency?: string;
+ /** True when the autoramp is approved and deposit details may be shared. */
+ ready: boolean;
+};
+
+/**
+ * Local controller representation of an autoramp account.
+ */
+export type AutorampAccount = {
+ /** MoonPay autoramp id. */
+ id: string;
+ /** MoonPay customer id. */
+ customerId: string;
+ /** Destination wallet address associated with this autoramp. */
+ walletAddress: string;
+ /** Latest status from MoonPay (source of truth after refresh). */
+ status: AutorampStatus;
+ /**
+ * Status observed before the most recent remote apply.
+ * Used for transition UX / analytics (e.g. Authorized → Approved).
+ */
+ lastSeenStatus: AutorampStatus;
+ /**
+ * Last status for which the UI already showed a notification.
+ * Prevents duplicate toasts across refresh and push.
+ */
+ notifiedForStatus?: AutorampStatus;
+ /** Epoch ms of the last local update from remote or push. */
+ updatedAt: number;
+ /** Optional non-PII deposit readiness cache. */
+ depositRailsSummary?: AutorampDepositRailsSummary;
+};
+
+/**
+ * Controller-facing payload for creating an autoramp.
+ *
+ * Mirrors the MoonPay `POST /api/autoramps` body that
+ * {@link NeoBankService.createAutoramp} forwards opaquely, minus `customer_id`:
+ * `RampsController.createAutoramp` injects the vendor customer id resolved from
+ * the KYC controller, so callers never supply (or need to know) it.
+ */
+export type CreateAutorampRequest = Record;
+
+/**
+ * Minimal remote snapshot from `GET /api/autoramps/{id}` (or a push payload).
+ * Host apps / BFF map MoonPay responses into this shape.
+ */
+export type AutorampRemoteSnapshot = {
+ id: string;
+ customerId: string;
+ walletAddress?: string;
+ status: AutorampStatus | string;
+ depositRailsSummary?: AutorampDepositRailsSummary;
+};
+
+/**
+ * Result of applying a remote autoramp snapshot onto local state.
+ */
+export type ApplyAutorampRemoteStatusResult = {
+ account: AutorampAccount;
+ previousStatus: AutorampStatus;
+ statusChanged: boolean;
+ /** True when status changed and UI has not yet notified for the new status. */
+ shouldNotify: boolean;
+};
+
+/**
+ * Terminal autoramp statuses — no further lifecycle progress expected.
+ */
+export const TERMINAL_AUTORAMP_STATUSES: ReadonlySet = new Set([
+ AutorampStatus.Rejected,
+ AutorampStatus.Cancelled,
+]);
+
+/**
+ * Statuses that commonly warrant user-visible transition UX (toast / banner).
+ */
+export const NOTABLE_AUTORAMP_STATUSES: ReadonlySet = new Set([
+ AutorampStatus.Approved,
+ AutorampStatus.Rejected,
+ AutorampStatus.Cancelled,
+]);
+
+/**
+ * Whether an autoramp status is terminal.
+ *
+ * @param status - Status to test.
+ * @returns Whether the status is terminal.
+ */
+export function isTerminalAutorampStatus(status: AutorampStatus): boolean {
+ return TERMINAL_AUTORAMP_STATUSES.has(status);
+}
+
+/**
+ * Normalize a remote status string into {@link AutorampStatus}.
+ * Unknown values fall back to {@link AutorampStatus.Created}.
+ *
+ * @param status - Remote status string.
+ * @returns A known {@link AutorampStatus}.
+ */
+export function normalizeAutorampStatus(
+ status: AutorampStatus | string,
+): AutorampStatus {
+ if (Object.values(AutorampStatus).includes(status as AutorampStatus)) {
+ return status as AutorampStatus;
+ }
+ return AutorampStatus.Created;
+}
+
+/**
+ * Build a new local autoramp account from create/response fields.
+ *
+ * @param input - Required identity + status fields.
+ * @param input.id - MoonPay autoramp id.
+ * @param input.customerId - MoonPay customer id.
+ * @param input.walletAddress - Destination wallet address.
+ * @param input.status - Optional remote status (defaults to Authorized).
+ * @param input.depositRailsSummary - Optional non-PII deposit readiness cache.
+ * @param input.updatedAt - Optional epoch ms timestamp (defaults to now).
+ * @returns A new {@link AutorampAccount}.
+ */
+export function createAutorampAccount(input: {
+ id: string;
+ customerId: string;
+ walletAddress: string;
+ status?: AutorampStatus | string;
+ depositRailsSummary?: AutorampDepositRailsSummary;
+ updatedAt?: number;
+}): AutorampAccount {
+ const status = normalizeAutorampStatus(
+ input.status ?? AutorampStatus.Authorized,
+ );
+ return {
+ id: input.id,
+ customerId: input.customerId,
+ walletAddress: input.walletAddress,
+ status,
+ lastSeenStatus: status,
+ updatedAt: input.updatedAt ?? Date.now(),
+ depositRailsSummary: input.depositRailsSummary,
+ };
+}
+
+/**
+ * Apply a remote autoramp snapshot onto a local account for transition detection.
+ * Pure helper — shared by refresh-on-load and websocket push paths.
+ *
+ * @param local - Current local account (or null when first upserting from remote).
+ * @param remote - Remote snapshot (MoonPay GET or push).
+ * @returns Updated account plus change / notify flags.
+ */
+export function applyAutorampRemoteStatus(
+ local: AutorampAccount | null,
+ remote: AutorampRemoteSnapshot,
+): ApplyAutorampRemoteStatusResult {
+ const remoteStatus = normalizeAutorampStatus(remote.status);
+
+ if (!local) {
+ const account = createAutorampAccount({
+ id: remote.id,
+ customerId: remote.customerId,
+ walletAddress: remote.walletAddress ?? '',
+ status: remoteStatus,
+ depositRailsSummary: remote.depositRailsSummary,
+ });
+ return {
+ account,
+ previousStatus: remoteStatus,
+ statusChanged: false,
+ shouldNotify: false,
+ };
+ }
+
+ const previousStatus = local.status;
+ const statusChanged = previousStatus !== remoteStatus;
+ const shouldNotify =
+ statusChanged &&
+ local.notifiedForStatus !== remoteStatus &&
+ NOTABLE_AUTORAMP_STATUSES.has(remoteStatus);
+
+ const account: AutorampAccount = {
+ ...local,
+ id: remote.id,
+ // A blank remote identity field means "not supplied", not "cleared": the
+ // proxy omits or empties these on partial status pushes, so keep the local
+ // value rather than wiping it.
+ customerId:
+ remote.customerId.length > 0 ? remote.customerId : local.customerId,
+ walletAddress:
+ remote.walletAddress !== undefined && remote.walletAddress.length > 0
+ ? remote.walletAddress
+ : local.walletAddress,
+ status: remoteStatus,
+ lastSeenStatus: previousStatus,
+ updatedAt: Date.now(),
+ depositRailsSummary:
+ remote.depositRailsSummary ?? local.depositRailsSummary,
+ };
+
+ return {
+ account,
+ previousStatus,
+ statusChanged,
+ shouldNotify,
+ };
+}
+
+/**
+ * Mark that the UI has notified for the account's current status.
+ *
+ * @param account - Account to update.
+ * @returns Account with `notifiedForStatus` set to current status.
+ */
+export function markAutorampNotified(
+ account: AutorampAccount,
+): AutorampAccount {
+ return {
+ ...account,
+ notifiedForStatus: account.status,
+ };
+}
diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts
index f1d1dcdbe6d..9b4642c524d 100644
--- a/packages/ramps-controller/src/index.ts
+++ b/packages/ramps-controller/src/index.ts
@@ -6,11 +6,14 @@ export type {
RampsControllerState,
RampsControllerStateChangeEvent,
RampsControllerOrderStatusChangedEvent,
+ RampsControllerAutorampStatusChangedEvent,
RampsControllerOptions,
UserRegion,
ResourceState,
TransakState,
NativeProvidersState,
+ MoneyAccountWalletRegistrationResult,
+ KeyringControllerSignPersonalMessageAction,
} from './RampsController.js';
export type {
RampsControllerExecuteRequestAction,
@@ -29,6 +32,15 @@ export type {
RampsControllerGetQuotesAction,
RampsControllerAddOrderAction,
RampsControllerRemoveOrderAction,
+ RampsControllerAddAutorampAction,
+ RampsControllerCreateAutorampAction,
+ RampsControllerRemoveAutorampAction,
+ RampsControllerRegisterMoneyAccountWalletAction,
+ RampsControllerMarkAutorampAsNotifiedAction,
+ RampsControllerApplyAutorampStatusFromPushAction,
+ RampsControllerRefreshAutorampAction,
+ RampsControllerRefreshAutorampsAction,
+ RampsControllerSyncAutorampsWithUserStorageAction,
RampsControllerStartOrderPollingAction,
RampsControllerStopOrderPollingAction,
RampsControllerGetBuyWidgetDataAction,
@@ -67,6 +79,8 @@ export {
getDefaultRampsControllerState,
getInternalOrderCode,
RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS,
+ RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS,
+ RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS,
} from './RampsController.js';
export type {
RampsServiceActions,
@@ -165,6 +179,66 @@ export {
TERMINAL_ORDER_STATUSES,
isTerminalOrderStatus,
} from './orderStatus.js';
+export type {
+ AutorampAccount,
+ AutorampDepositRailsSummary,
+ AutorampRemoteSnapshot,
+ ApplyAutorampRemoteStatusResult,
+ CreateAutorampRequest,
+} from './autorampAccount.js';
+export {
+ AutorampStatus,
+ TERMINAL_AUTORAMP_STATUSES,
+ NOTABLE_AUTORAMP_STATUSES,
+ isTerminalAutorampStatus,
+ normalizeAutorampStatus,
+ createAutorampAccount,
+ applyAutorampRemoteStatus,
+ markAutorampNotified,
+} from './autorampAccount.js';
+export type {
+ UserStorageAutorampEntry,
+ SyncAutorampAccount,
+ AutorampSyncingOptions,
+ SyncAutorampsWithUserStorageConfig,
+} from './autoramp-syncing/index.js';
+export {
+ USER_STORAGE_RAMPS_AUTORAMPS_FEATURE,
+ syncAutorampsWithUserStorage,
+ updateAutorampInRemoteStorage,
+ deleteAutorampInRemoteStorage,
+ canPerformAutorampSyncing,
+ computeAutorampMergePlan,
+ mapAutorampToUserStorageEntry,
+ mapUserStorageEntryToAutoramp,
+} from './autoramp-syncing/index.js';
+export type {
+ NeoBankServiceActions,
+ NeoBankServiceEvents,
+ NeoBankServiceMessenger,
+ NeoBankAutorampResponse,
+ NeoBankRequestOptions,
+ NeoBankQueryParams,
+ GetWalletRegistrationStatusParams,
+ RegisterSelfHostedWalletParams,
+} from './NeoBankService.js';
+export type {
+ NeoBankServiceGetAutorampAction,
+ NeoBankServiceRegisterPixAddressAction,
+ NeoBankServiceGetAutorampQuoteAction,
+ NeoBankServiceCreateAutorampAction,
+ NeoBankServiceGetAutorampQuoteForAutorampAction,
+ NeoBankServiceAttachAutorampQuoteAction,
+ NeoBankServiceGetCustomerByExternalIdAction,
+ NeoBankServiceGetMoonpayCustomerIdAction,
+ NeoBankServiceGetWalletRegistrationStatusAction,
+ NeoBankServiceRegisterSelfHostedWalletAction,
+} from './NeoBankService-method-action-types.js';
+export {
+ NeoBankService,
+ serviceName as neoBankServiceName,
+ mapNeoBankAutorampToRemoteSnapshot,
+} from './NeoBankService.js';
export type { TypedError } from './errorNormalization.js';
export {
getErrorMessage,
@@ -220,3 +294,13 @@ export type {
TransakServiceGeneratePaymentWidgetUrlAction,
TransakServiceCreateWidgetUrlAction,
} from './TransakService-method-action-types.js';
+
+export type {
+ Blockchain,
+ RegistrationOutcome,
+ RegistrationStatus,
+ SelfHostedRegistration,
+ WalletRegistrationErrorKind,
+} from './wallet-registration-service.js';
+export { WalletRegistrationError } from './wallet-registration-service.js';
+export { buildOwnershipMessage } from './ownership-message.js';
diff --git a/packages/ramps-controller/src/ownership-message.test.ts b/packages/ramps-controller/src/ownership-message.test.ts
new file mode 100644
index 00000000000..071144a4642
--- /dev/null
+++ b/packages/ramps-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/ramps-controller/src/ownership-message.ts b/packages/ramps-controller/src/ownership-message.ts
new file mode 100644
index 00000000000..539d5e3aac3
--- /dev/null
+++ b/packages/ramps-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/ramps-controller/src/wallet-registration-machine.test.ts b/packages/ramps-controller/src/wallet-registration-machine.test.ts
new file mode 100644
index 00000000000..de58a81ef12
--- /dev/null
+++ b/packages/ramps-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/ramps-controller/src/wallet-registration-machine.ts b/packages/ramps-controller/src/wallet-registration-machine.ts
new file mode 100644
index 00000000000..3251c56d02e
--- /dev/null
+++ b/packages/ramps-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/ramps-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts
new file mode 100644
index 00000000000..d76327eeba0
--- /dev/null
+++ b/packages/ramps-controller/src/wallet-registration-service.test.ts
@@ -0,0 +1,709 @@
+import {
+ createIdempotencyKey,
+ extractErrorBody,
+ WalletRegistrationError,
+ WalletRegistrationService,
+} from './wallet-registration-service.js';
+
+const BASE_URL = 'https://on-ramp.dev-api.cx.metamask.io';
+const AUTH_TOKEN = 'session-jwt-abc';
+const EXTERNAL_ID = 'canonical-profile-1';
+const CUSTOMER_ID = '019ff69c-3039-77b0-9d5d-e4a3baefd7b7';
+
+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 =>
+ typeof body === 'string' ? body : 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,
+ getExternalId: async (): Promise => EXTERNAL_ID,
+ });
+
+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('createIdempotencyKey', () => {
+ it('returns a non-empty string', () => {
+ expect(createIdempotencyKey().length).toBeGreaterThan(0);
+ });
+
+ // `globalThis.crypto.randomUUID` is absent under Node 18, so the preferred
+ // path has to be exercised against an installed stub rather than the ambient
+ // runtime.
+ it('prefers randomUUID when the runtime provides it', () => {
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'crypto',
+ );
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: { randomUUID: () => 'uuid-1' },
+ });
+ try {
+ expect(createIdempotencyKey()).toBe('uuid-1');
+ } finally {
+ if (originalDescriptor) {
+ Object.defineProperty(globalThis, 'crypto', originalDescriptor);
+ } else {
+ // Node 18 exposes no own `crypto` descriptor, so the stub has to be
+ // removed rather than restored, or it leaks into later tests.
+ Reflect.deleteProperty(globalThis, 'crypto');
+ }
+ }
+ });
+
+ it('falls back when randomUUID is unavailable', () => {
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ globalThis,
+ 'crypto',
+ );
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: { randomUUID: undefined },
+ });
+ try {
+ expect(createIdempotencyKey()).toMatch(/^wallet-reg-/u);
+ } finally {
+ if (originalDescriptor) {
+ Object.defineProperty(globalThis, 'crypto', originalDescriptor);
+ }
+ }
+ });
+});
+
+describe('extractErrorBody', () => {
+ it('returns whitespace-only bodies unchanged', () => {
+ expect(extractErrorBody(' ')).toBe(' ');
+ expect(extractErrorBody('')).toBe('');
+ });
+
+ it('unwraps a JSON-encoded string', () => {
+ expect(extractErrorBody(JSON.stringify('already exists'))).toBe(
+ 'already exists',
+ );
+ });
+
+ it('prefers message on a JSON object', () => {
+ expect(extractErrorBody(JSON.stringify({ message: 'forbidden' }))).toBe(
+ 'forbidden',
+ );
+ });
+
+ it('keeps a JSON object without message as raw text', () => {
+ const raw = JSON.stringify({ code: 'x', detail: 'nope' });
+ expect(extractErrorBody(raw)).toBe(raw);
+ });
+
+ it('returns plain text that is not JSON', () => {
+ expect(extractErrorBody('not json at all')).toBe('not json at all');
+ });
+
+ it('returns non-object JSON values as the raw trimmed text', () => {
+ expect(extractErrorBody('null')).toBe('null');
+ expect(extractErrorBody('42')).toBe('42');
+ expect(extractErrorBody('true')).toBe('true');
+ });
+});
+
+describe('WalletRegistrationService.getMoonpayCustomerId', () => {
+ it('returns Iron customer id from GET /neobank/customers/{external_id}/external', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, {
+ id: 'iron-customer-1',
+ external_id: EXTERNAL_ID,
+ status: 'Active',
+ }),
+ );
+
+ expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe(
+ 'iron-customer-1',
+ );
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${BASE_URL}/neobank/customers/${EXTERNAL_ID}/external`,
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ authorization: `Bearer ${AUTH_TOKEN}`,
+ }),
+ }),
+ );
+ });
+
+ it('maps a failed customer lookup to a typed HTTP error with transparent body', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => textResponse(404, 'not found'),
+ );
+
+ await expect(
+ buildService(fetchMock).getMoonpayCustomerId(),
+ ).rejects.toMatchObject({
+ kind: 'notFound',
+ httpStatus: 404,
+ body: 'not found',
+ });
+ });
+
+ 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' });
+ });
+
+ it('rejects an empty external id before calling the network', async () => {
+ const fetchMock = jest.fn();
+ const service = new WalletRegistrationService({
+ fetch: fetchMock,
+ baseUrl: BASE_URL,
+ getAuthToken: async (): Promise => AUTH_TOKEN,
+ getExternalId: async (): Promise => '',
+ });
+
+ await expect(service.getMoonpayCustomerId()).rejects.toMatchObject({
+ kind: 'malformedResponse',
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+describe('WalletRegistrationService.getRegistrationStatus', () => {
+ it('lists via /neobank/addresses/crypto/{customer_id}?filter=SelfHosted', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => jsonResponse(200, []),
+ );
+ const service = buildService(fetchMock);
+
+ await service.getRegistrationStatus({
+ customerId: CUSTOMER_ID,
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit];
+ expect(url).toBe(
+ `${BASE_URL}/neobank/addresses/crypto/${CUSTOMER_ID}?filter=SelfHosted`,
+ );
+ expect(url).not.toContain('iron.xyz');
+ expect(url).not.toContain('/vendors/moonpay/');
+ expect(init.method).toBe('GET');
+ expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`);
+ });
+
+ it('returns an active match parsed from wallet_address (Monad filter client-side)', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, [
+ verifiedAddress({ blockchain: 'Ethereum' }),
+ verifiedAddress(),
+ ]),
+ );
+ const service = buildService(fetchMock);
+
+ const status = await service.getRegistrationStatus({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toMatchObject({ kind: 'lookupUnavailable', body: 'boom' });
+ });
+
+ 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({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ 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({
+ customerId: CUSTOMER_ID,
+ address: EVM_ADDRESS,
+ blockchain: 'Monad',
+ }),
+ ).rejects.toMatchObject({ kind: 'lookupUnavailable' });
+ });
+});
+
+const registerRequest = {
+ customerId: CUSTOMER_ID,
+ 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: CUSTOMER_ID,
+ disabled: false,
+ signature: '0xdeadbeef',
+ created_at: '2026-08-12T10:00:00Z',
+ ...overrides,
+});
+
+describe('WalletRegistrationService.registerSelfHostedWallet', () => {
+ it('posts to /neobank/addresses/crypto/selfhosted with an idempotency key', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(200, selfHostedResponse()),
+ );
+ const service = buildService(fetchMock);
+
+ const outcome = await service.registerSelfHostedWallet({
+ ...registerRequest,
+ idempotencyKey: 'idem-wallet-1',
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit];
+ expect(url).toBe(`${BASE_URL}/neobank/addresses/crypto/selfhosted`);
+ expect(url).not.toContain('iron.xyz');
+ expect(init.method).toBe('POST');
+ expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`);
+ expect(init.headers['Idempotency-Key']).toBe('idem-wallet-1');
+ 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('generates an Idempotency-Key when the caller omits one', 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];
+ expect(init.headers['Idempotency-Key']?.length).toBeGreaterThan(0);
+ });
+
+ it('maps a plain-string 409 body to an ambiguous conflict error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ textResponse(
+ 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 => textResponse(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 => textResponse(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 => textResponse(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 => textResponse(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 => textResponse(403, 'suspended'),
+ );
+ const notFoundFetch = jest.fn(
+ async (): Promise => textResponse(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 a JSON error object with message when present', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(403, { message: 'forbidden' }),
+ );
+ const service = buildService(fetchMock);
+
+ await expect(
+ service.registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'forbidden', body: 'forbidden' });
+ });
+
+ it('maps a JSON-encoded string error body', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ textResponse(409, JSON.stringify('already exists')),
+ );
+
+ await expect(
+ buildService(fetchMock).registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'conflict', body: 'already exists' });
+ });
+
+ it('keeps a JSON object without message as the raw body', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise =>
+ jsonResponse(400, { code: 'x', detail: 'nope' }),
+ );
+
+ await expect(
+ buildService(fetchMock).registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({
+ kind: 'validation',
+ body: JSON.stringify({ code: 'x', detail: 'nope' }),
+ });
+ });
+
+ it('keeps a whitespace-only error body as-is', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => textResponse(400, ' '),
+ );
+
+ await expect(
+ buildService(fetchMock).registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'validation', body: ' ' });
+ });
+
+ it('omits Error.message when the upstream body is empty', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => textResponse(400, ''),
+ );
+
+ await expect(
+ buildService(fetchMock).registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({
+ kind: 'validation',
+ body: '',
+ message: 'wallet registration failed: validation',
+ });
+ });
+
+ it('maps an unreadable error body to malformedResponse', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => ({
+ ok: false,
+ status: 500,
+ json: async (): Promise => {
+ throw new Error('no json');
+ },
+ text: async (): Promise => {
+ throw new Error('no text');
+ },
+ }),
+ );
+
+ await expect(
+ buildService(fetchMock).registerSelfHostedWallet(registerRequest),
+ ).rejects.toMatchObject({ kind: 'malformedResponse', httpStatus: 500 });
+ });
+
+ it('maps 429 to a rateLimited error', async () => {
+ const fetchMock = jest.fn(
+ async (): Promise => textResponse(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' });
+ });
+});
diff --git a/packages/ramps-controller/src/wallet-registration-service.ts b/packages/ramps-controller/src/wallet-registration-service.ts
new file mode 100644
index 00000000000..240f6720474
--- /dev/null
+++ b/packages/ramps-controller/src/wallet-registration-service.ts
@@ -0,0 +1,457 @@
+/** 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;
+ /**
+ * Base URL of the Money Movement neobank-proxy host
+ * (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are under `/neobank`.
+ */
+ baseUrl: string;
+ getAuthToken: () => Promise;
+ /**
+ * MetaMask profile / partner external id used as MoonPay `external_id`
+ * (typically `AuthenticationController:getSessionProfile().canonicalProfileId`).
+ */
+ getExternalId: () => Promise;
+};
+
+export type GetRegistrationStatusRequest = {
+ customerId: string;
+ address: string;
+ blockchain: Blockchain;
+};
+
+export type RegisterSelfHostedWalletRequest = {
+ customerId: string;
+ address: string;
+ blockchain: Blockchain;
+ message: string;
+ signature: string;
+ /**
+ * Stable key reused across retries of the same ownership proof. Generated
+ * when omitted.
+ */
+ idempotencyKey?: string;
+};
+
+/** Successful registration outcome. */
+export type RegistrationOutcome = {
+ type: 'registered';
+ registration: SelfHostedRegistration;
+};
+
+/**
+ * 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';
+ }
+}
+
+/**
+ * Builds a client-side Idempotency-Key for MoonPay POSTs. Prefer a stable
+ * caller-supplied key across retries of the same proof.
+ *
+ * @returns A random UUID when available, otherwise a timestamped fallback.
+ */
+export function createIdempotencyKey(): string {
+ const cryptoObj = globalThis.crypto as
+ | { randomUUID?: () => string }
+ | undefined;
+ if (typeof cryptoObj?.randomUUID === 'function') {
+ return cryptoObj.randomUUID();
+ }
+ return `wallet-reg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+/**
+ * Extracts a human-readable error body from a transparent neobank-proxy
+ * response. Upstream may return a plain string or a JSON value; both are
+ * mirrored 1:1 (no `{ code: 'iron_error' }` envelope).
+ *
+ * @param raw - Raw response text.
+ * @returns Normalized body string for {@link WalletRegistrationError}.
+ */
+export function extractErrorBody(raw: string): string {
+ const trimmed = raw.trim();
+ if (!trimmed) {
+ return raw;
+ }
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ if (typeof parsed === 'string') {
+ return parsed;
+ }
+ if (parsed && typeof parsed === 'object') {
+ const { message } = parsed as { message?: unknown };
+ if (typeof message === 'string') {
+ return message;
+ }
+ }
+ return trimmed;
+ } catch {
+ return trimmed;
+ }
+}
+
+/**
+ * Data service that talks to the Money Movement neobank-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;
+
+ readonly #getExternalId: () => Promise;
+
+ constructor(options: WalletRegistrationServiceOptions) {
+ this.#fetch = options.fetch;
+ this.#baseUrl = options.baseUrl.replace(/\/$/u, '');
+ this.#getAuthToken = options.getAuthToken;
+ this.#getExternalId = options.getExternalId;
+ }
+
+ /**
+ * Resolves Iron's internal customer id via
+ * `GET /neobank/customers/{external_id}/external`, using the MetaMask
+ * profile/canonical id as `external_id`. 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, externalId] = await Promise.all([
+ this.#getAuthToken(),
+ this.#getExternalId(),
+ ]);
+ if (!externalId) {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'MetaMask external id (canonical profile id) is empty',
+ });
+ }
+
+ const response = await this.#fetch(
+ `${this.#baseUrl}/neobank/customers/${encodeURIComponent(externalId)}/external`,
+ {
+ 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 { id } = payload as { id?: unknown };
+ if (typeof id !== 'string' || id.length === 0) {
+ throw new WalletRegistrationError('malformedResponse', {
+ message: 'MoonPay customer body missing id',
+ });
+ }
+ return id;
+ }
+
+ /**
+ * Reconciles a wallet against the customer's registered self-hosted addresses
+ * via `GET /neobank/addresses/crypto/{customer_id}?filter=SelfHosted`.
+ * Upstream returns all self-hosted chains; Monad filtering stays client-side
+ * for the POC. A failed or malformed lookup is reported as
+ * `lookupUnavailable` and never downgraded to `absent`.
+ *
+ * @param request - Customer id and Monad address to reconcile.
+ * @returns The active / disabled / absent status for the address.
+ */
+ async getRegistrationStatus(
+ request: GetRegistrationStatusRequest,
+ ): Promise {
+ const { customerId, address, blockchain } = request;
+
+ let response: HttpResponse;
+ try {
+ const token = await this.#getAuthToken();
+ const url = new URL(
+ `${this.#baseUrl}/neobank/addresses/crypto/${encodeURIComponent(customerId)}`,
+ );
+ url.searchParams.set('filter', 'SelfHosted');
+ response = await this.#fetch(url.toString(), {
+ 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: extractErrorBody(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 neobank-proxy
+ * `POST /neobank/addresses/crypto/selfhosted`. The client supplies
+ * `customer_id` and an `Idempotency-Key` (generated when omitted). 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 {
+ const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey();
+ let response: HttpResponse;
+ try {
+ const token = await this.#getAuthToken();
+ response = await this.#fetch(
+ `${this.#baseUrl}/neobank/addresses/crypto/selfhosted`,
+ {
+ method: 'POST',
+ headers: {
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ 'Idempotency-Key': idempotencyKey,
+ },
+ 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 raw = '';
+ try {
+ raw = await response.text();
+ } catch {
+ return new WalletRegistrationError('malformedResponse', {
+ httpStatus: response.status,
+ message: 'error body could not be read',
+ });
+ }
+
+ const { status } = response;
+ const kind = mapStatusToKind(status);
+ const body = extractErrorBody(raw);
+ return new WalletRegistrationError(kind, {
+ httpStatus: status,
+ body,
+ message: body || undefined,
+ });
+ }
+
+ #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/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md
index 28b5678e75a..1841e2ff895 100644
--- a/packages/transaction-pay-controller/CHANGELOG.md
+++ b/packages/transaction-pay-controller/CHANGELOG.md
@@ -7,9 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Add `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853))
+
### Changed
-- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823))
+- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823), [#9853](https://github.com/MetaMask/core/pull/9853))
+
+### Fixed
+
+- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval. Skipped results (vaulting disabled) are not retained, so a later enablement can retry the same payout hash. ([#9849](https://github.com/MetaMask/core/pull/9849), [#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853))
+- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853))
## [26.3.0]
diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json
index 3ac7dd8ee89..eec01336006 100644
--- a/packages/transaction-pay-controller/package.json
+++ b/packages/transaction-pay-controller/package.json
@@ -67,6 +67,7 @@
"@metamask/keyring-controller": "^27.1.1",
"@metamask/messenger": "^2.0.0",
"@metamask/metamask-eth-abis": "^3.1.1",
+ "@metamask/money-account-utils": "^1.1.0",
"@metamask/network-controller": "^35.0.1",
"@metamask/ramps-controller": "^20.0.0",
"@metamask/remote-feature-flag-controller": "^5.0.0",
diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts
index 14a91436fa2..09e0ae9eb78 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts
@@ -49,6 +49,38 @@ export type TransactionPayControllerUpdateFiatPaymentAction = {
handler: TransactionPayController['updateFiatPayment'];
};
+/**
+ * Vaults mUSD received in a completed Iron payout transaction.
+ *
+ * Concurrent calls for the same payout hash share one in-flight submission.
+ * Successful results are retained for the controller lifetime so retries
+ * return the prior hash without submitting again. Skipped results (vaulting
+ * disabled) are not retained, so a later enablement can retry the same hash.
+ *
+ * @param request - Completed Iron payout details.
+ * @returns Hash of the confirmed vault transaction, or `{ skipped: true }`
+ * when vaulting is disabled.
+ */
+export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = {
+ type: `TransactionPayController:submitMoneyAccountVaultDeposit`;
+ handler: TransactionPayController['submitMoneyAccountVaultDeposit'];
+};
+
+/**
+ * Creates a user-confirmed exact-out vmUSD withdrawal to Iron.
+ *
+ * Concurrent calls with the same request ID share one in-flight batch setup.
+ * Successful batch results are retained so a later call returns the same
+ * `batchId` without creating another approval.
+ *
+ * @param request - Backend-bound exact-out Iron intent.
+ * @returns Pending transaction batch ID.
+ */
+export type TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction = {
+ type: `TransactionPayController:submitMoneyAccountVaultWithdraw`;
+ handler: TransactionPayController['submitMoneyAccountVaultWithdraw'];
+};
+
/**
* Gets the delegation transaction for a given transaction.
*
@@ -144,6 +176,8 @@ export type TransactionPayControllerMethodActions =
| TransactionPayControllerSetTransactionConfigAction
| TransactionPayControllerUpdatePaymentTokenAction
| TransactionPayControllerUpdateFiatPaymentAction
+ | TransactionPayControllerSubmitMoneyAccountVaultDepositAction
+ | TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction
| TransactionPayControllerGetDelegationTransactionAction
| TransactionPayControllerGetAmountDataAction
| TransactionPayControllerGetFiatOptionsAction
diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts
index 467f6406ab2..e173155c555 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts
@@ -16,6 +16,8 @@ import type {
UpdateTransactionDataCallback,
} from './types.js';
import { getStrategyOrder } from './utils/feature-flags.js';
+import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js';
+import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js';
import { updateQuotes } from './utils/quotes.js';
import { updateSourceAmounts } from './utils/source-amounts.js';
import {
@@ -31,6 +33,8 @@ jest.mock('./utils/source-amounts');
jest.mock('./utils/quotes');
jest.mock('./utils/transaction');
jest.mock('./utils/feature-flags');
+jest.mock('./utils/ma-vault-payout');
+jest.mock('./utils/ma-vault-withdraw');
const TRANSACTION_ID_MOCK = '123-456';
const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta;
@@ -50,6 +54,12 @@ describe('TransactionPayController', () => {
);
const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges);
const getStrategyOrderMock = jest.mocked(getStrategyOrder);
+ const submitMoneyAccountVaultDepositFromPayoutMock = jest.mocked(
+ submitMoneyAccountVaultDepositFromPayout,
+ );
+ const submitMoneyAccountVaultWithdrawUtilMock = jest.mocked(
+ submitMoneyAccountVaultWithdrawUtil,
+ );
let messenger: TransactionPayControllerMessenger;
let getKeyringControllerStateMock: jest.Mock;
@@ -106,6 +116,205 @@ describe('TransactionPayController', () => {
});
});
+ describe('Money Account vault actions', () => {
+ const moneyAccountAddress =
+ '0x1111111111111111111111111111111111111111' as Hex;
+ const transactionHash =
+ '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex;
+ const recipient = '0x2222222222222222222222222222222222222222' as Hex;
+
+ it('exposes the payout deposit action through the messenger', async () => {
+ submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({
+ transactionHash,
+ });
+ createController();
+
+ const result = await messenger.call(
+ 'TransactionPayController:submitMoneyAccountVaultDeposit',
+ {
+ moneyAccountAddress,
+ transactionHash,
+ },
+ );
+
+ expect(submitMoneyAccountVaultDepositFromPayoutMock).toHaveBeenCalledWith(
+ { moneyAccountAddress, transactionHash },
+ messenger,
+ );
+ expect(result).toStrictEqual({ transactionHash });
+ });
+
+ it('deduplicates concurrent payout deposit actions by transaction hash', async () => {
+ let resolveSubmit:
+ | ((value: { transactionHash?: Hex }) => void)
+ | undefined;
+ submitMoneyAccountVaultDepositFromPayoutMock.mockImplementation(
+ async () =>
+ await new Promise((resolve) => {
+ resolveSubmit = resolve;
+ }),
+ );
+ const controller = createController();
+ const request = { moneyAccountAddress, transactionHash };
+
+ const first = controller.submitMoneyAccountVaultDeposit(request);
+ const second = controller.submitMoneyAccountVaultDeposit(request);
+ resolveSubmit?.({ transactionHash });
+
+ expect(await first).toStrictEqual({ transactionHash });
+ expect(await second).toStrictEqual({ transactionHash });
+ expect(
+ submitMoneyAccountVaultDepositFromPayoutMock,
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns the prior result on retry after a successful deposit without resubmitting', async () => {
+ submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({
+ transactionHash,
+ });
+ const controller = createController();
+ const request = { moneyAccountAddress, transactionHash };
+
+ const first = await controller.submitMoneyAccountVaultDeposit(request);
+ const second = await controller.submitMoneyAccountVaultDeposit(request);
+
+ expect(first).toStrictEqual({ transactionHash });
+ expect(second).toStrictEqual({ transactionHash });
+ expect(
+ submitMoneyAccountVaultDepositFromPayoutMock,
+ ).toHaveBeenCalledTimes(1);
+ });
+
+ it('retries after a failed deposit', async () => {
+ submitMoneyAccountVaultDepositFromPayoutMock
+ .mockRejectedValueOnce(new Error('vault failed'))
+ .mockResolvedValueOnce({ transactionHash });
+ const controller = createController();
+ const request = { moneyAccountAddress, transactionHash };
+
+ await expect(
+ controller.submitMoneyAccountVaultDeposit(request),
+ ).rejects.toThrow('vault failed');
+
+ expect(
+ await controller.submitMoneyAccountVaultDeposit(request),
+ ).toStrictEqual({ transactionHash });
+ expect(
+ submitMoneyAccountVaultDepositFromPayoutMock,
+ ).toHaveBeenCalledTimes(2);
+ });
+
+ it('retries after a skipped deposit once vaulting is enabled', async () => {
+ submitMoneyAccountVaultDepositFromPayoutMock
+ .mockResolvedValueOnce({ skipped: true })
+ .mockResolvedValueOnce({ transactionHash });
+ const controller = createController();
+ const request = { moneyAccountAddress, transactionHash };
+
+ expect(
+ await controller.submitMoneyAccountVaultDeposit(request),
+ ).toStrictEqual({ skipped: true });
+
+ expect(
+ await controller.submitMoneyAccountVaultDeposit(request),
+ ).toStrictEqual({ transactionHash });
+ expect(
+ submitMoneyAccountVaultDepositFromPayoutMock,
+ ).toHaveBeenCalledTimes(2);
+ });
+
+ it('exposes the exact-out withdraw action through the messenger', async () => {
+ submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({
+ batchId: '0x123' as Hex,
+ });
+ createController();
+ const request = {
+ amountInRaw: '5000000',
+ moneyAccountAddress,
+ recipient,
+ requestId: 'request-id',
+ };
+
+ const result = await messenger.call(
+ 'TransactionPayController:submitMoneyAccountVaultWithdraw',
+ request,
+ );
+
+ expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledWith(
+ request,
+ messenger,
+ );
+ expect(result).toStrictEqual({ batchId: '0x123' });
+ });
+
+ it('deduplicates concurrent withdraw actions by request ID', async () => {
+ let resolveSubmit: ((value: { batchId: Hex }) => void) | undefined;
+ submitMoneyAccountVaultWithdrawUtilMock.mockImplementation(
+ async () =>
+ await new Promise((resolve) => {
+ resolveSubmit = resolve;
+ }),
+ );
+ const controller = createController();
+ const request = {
+ amountInRaw: '5000000',
+ moneyAccountAddress,
+ recipient,
+ requestId: 'request-id',
+ };
+
+ const first = controller.submitMoneyAccountVaultWithdraw(request);
+ const second = controller.submitMoneyAccountVaultWithdraw(request);
+ resolveSubmit?.({ batchId: '0x123' });
+
+ expect(await first).toStrictEqual({ batchId: '0x123' });
+ expect(await second).toStrictEqual({ batchId: '0x123' });
+ expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns the same batchId on retry after approval is created without resubmitting', async () => {
+ submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({
+ batchId: '0x123' as Hex,
+ });
+ const controller = createController();
+ const request = {
+ amountInRaw: '5000000',
+ moneyAccountAddress,
+ recipient,
+ requestId: 'request-id',
+ };
+
+ const first = await controller.submitMoneyAccountVaultWithdraw(request);
+ const second = await controller.submitMoneyAccountVaultWithdraw(request);
+
+ expect(first).toStrictEqual({ batchId: '0x123' });
+ expect(second).toStrictEqual({ batchId: '0x123' });
+ expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('retries withdraw after a failed batch setup', async () => {
+ submitMoneyAccountVaultWithdrawUtilMock
+ .mockRejectedValueOnce(new Error('batch failed'))
+ .mockResolvedValueOnce({ batchId: '0x123' as Hex });
+ const controller = createController();
+ const request = {
+ amountInRaw: '5000000',
+ moneyAccountAddress,
+ recipient,
+ requestId: 'request-id',
+ };
+
+ await expect(
+ controller.submitMoneyAccountVaultWithdraw(request),
+ ).rejects.toThrow('batch failed');
+
+ expect(
+ await controller.submitMoneyAccountVaultWithdraw(request),
+ ).toStrictEqual({ batchId: '0x123' });
+ expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(2);
+ });
+ });
+
describe('updatePaymentToken', () => {
it('calls util', () => {
createController().updatePaymentToken({
diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts
index 0f35951e683..aa6c777a860 100644
--- a/packages/transaction-pay-controller/src/TransactionPayController.ts
+++ b/packages/transaction-pay-controller/src/TransactionPayController.ts
@@ -29,6 +29,11 @@ import type {
UpdatePaymentTokenRequest,
} from './types.js';
import { getStrategyOrder } from './utils/feature-flags.js';
+import type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js';
+import type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js';
+import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js';
+import type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js';
+import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js';
import { updateQuotes } from './utils/quotes.js';
import { updateSourceAmounts } from './utils/source-amounts.js';
import {
@@ -45,6 +50,8 @@ const MESSENGER_EXPOSED_METHODS = [
'polymarketGetDepositWalletAddress',
'polymarketSubmitDepositWalletBatch',
'setTransactionConfig',
+ 'submitMoneyAccountVaultDeposit',
+ 'submitMoneyAccountVaultWithdraw',
'updateFiatPayment',
'updatePaymentToken',
] as const;
@@ -87,6 +94,27 @@ export class TransactionPayController extends BaseController<
readonly #resolveSourceAmount?: ResolveSourceAmountCallback;
+ /**
+ * In-flight and completed payout vault deposits, keyed by payout tx hash.
+ * Completed successes stay cached for the controller lifetime so webhook
+ * replays / retries do not re-submit. Preferable to persisted state here
+ * because vaulting is idempotent per process and avoids a state migration.
+ */
+ readonly #vaultDepositRequests = new Map<
+ string,
+ Promise
+ >();
+
+ /**
+ * In-flight and completed withdraw batch setups, keyed by requestId.
+ * Successful `addTransactionBatch` results stay cached for the controller
+ * lifetime so a second call cannot open another approval for the same id.
+ */
+ readonly #vaultWithdrawRequests = new Map<
+ string,
+ Promise<{ batchId: `0x${string}` }>
+ >();
+
constructor({
fiatOptions,
getAmountData,
@@ -215,6 +243,75 @@ export class TransactionPayController extends BaseController<
});
}
+ /**
+ * Vaults mUSD received in a completed Iron payout transaction.
+ *
+ * Concurrent calls for the same payout hash share one in-flight submission.
+ * Successful results are retained for the controller lifetime so retries
+ * return the prior hash without submitting again. Skipped results (vaulting
+ * disabled) are not retained, so a later enablement can retry the same hash.
+ *
+ * @param request - Completed Iron payout details.
+ * @returns Hash of the confirmed vault transaction, or `{ skipped: true }`
+ * when vaulting is disabled.
+ */
+ submitMoneyAccountVaultDeposit(
+ request: SubmitMoneyAccountVaultDepositRequest,
+ ): Promise {
+ const key = request.transactionHash.toLowerCase();
+ const current = this.#vaultDepositRequests.get(key);
+ if (current) {
+ return current;
+ }
+
+ const pending = submitMoneyAccountVaultDepositFromPayout(
+ request,
+ this.messenger,
+ )
+ .then((result) => {
+ if (result.skipped) {
+ this.#vaultDepositRequests.delete(key);
+ }
+ return result;
+ })
+ .catch((error: unknown) => {
+ this.#vaultDepositRequests.delete(key);
+ throw error;
+ });
+ this.#vaultDepositRequests.set(key, pending);
+ return pending;
+ }
+
+ /**
+ * Creates a user-confirmed exact-out vmUSD withdrawal to Iron.
+ *
+ * Concurrent calls with the same request ID share one in-flight batch setup.
+ * Successful batch results are retained so a later call returns the same
+ * `batchId` without creating another approval.
+ *
+ * @param request - Backend-bound exact-out Iron intent.
+ * @returns Pending transaction batch ID.
+ */
+ submitMoneyAccountVaultWithdraw(
+ request: SubmitMoneyAccountVaultWithdrawRequest,
+ ): Promise<{ batchId: `0x${string}` }> {
+ const key = request.requestId;
+ const current = this.#vaultWithdrawRequests.get(key);
+ if (current) {
+ return current;
+ }
+
+ const pending = submitMoneyAccountVaultWithdrawUtil(
+ request,
+ this.messenger,
+ ).catch((error: unknown) => {
+ this.#vaultWithdrawRequests.delete(key);
+ throw error;
+ });
+ this.#vaultWithdrawRequests.set(key, pending);
+ return pending;
+ }
+
/**
* Gets the delegation transaction for a given transaction.
*
diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts
index 1d52593f72e..0c430b4f470 100644
--- a/packages/transaction-pay-controller/src/index.ts
+++ b/packages/transaction-pay-controller/src/index.ts
@@ -39,9 +39,14 @@ export type {
TransactionPayControllerPolymarketGetDepositWalletAddressAction,
TransactionPayControllerPolymarketSubmitDepositWalletBatchAction,
TransactionPayControllerSetTransactionConfigAction,
+ TransactionPayControllerSubmitMoneyAccountVaultDepositAction,
+ TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction,
TransactionPayControllerUpdatePaymentTokenAction,
TransactionPayControllerUpdateFiatPaymentAction,
} from './TransactionPayController-method-action-types.js';
+export type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js';
+export type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js';
+export type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js';
export { PaymentOverride, TransactionPayStrategy } from './constants.js';
export { TransactionPayController } from './TransactionPayController.js';
export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook.js';
diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts
index 9bf58a51114..74e47de23b2 100644
--- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts
+++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts
@@ -89,6 +89,19 @@ describe('FiatStrategy', () => {
).rejects.toThrow('Fiat: Missing transaction hash');
});
+ it('returns skipped when vault deposit is disabled', async () => {
+ submitFiatQuotesMock.mockResolvedValue({ skipped: true });
+
+ const result = await new FiatStrategy().execute({
+ isSmartTransaction: () => false,
+ quotes: [QUOTE_MOCK],
+ messenger: {} as TransactionPayControllerMessenger,
+ transaction: { txParams: { from: '0x1' } } as TransactionMeta,
+ });
+
+ expect(result).toStrictEqual({ skipped: true });
+ });
+
it('preserves nested Post-Ramp and Vault prefixes', async () => {
submitFiatQuotesMock.mockRejectedValue(
new Error('Post-Ramp: Direct mUSD: Vault: Missing transaction hash'),
diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts
index b6444f3c286..989c87145b7 100644
--- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts
+++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts
@@ -24,6 +24,10 @@ export class FiatStrategy implements PayStrategy {
try {
const result = await submitFiatQuotes(request);
+ if (result.skipped) {
+ return result;
+ }
+
if (result.transactionHash === undefined) {
throw new Error('Missing transaction hash');
}
diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts
index e1efce2806e..68ca081c666 100644
--- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts
+++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts
@@ -18,6 +18,7 @@ import type {
import { prefixError } from '../../utils/error-prefix.js';
import { getFiatVaultDisabled } from '../../utils/feature-flags.js';
import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js';
+import type { SubmitMoneyAccountVaultDepositResult } from '../../utils/ma-vault-deposit.js';
import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js';
import { MUSD_MONAD_FIAT_ASSET } from './constants.js';
import type { FiatQuote } from './types.js';
@@ -130,7 +131,7 @@ export async function submitDirectMusdAfterFiatCompletion({
}: {
order: RampsOrder;
request: PayStrategyExecuteRequest;
-}): Promise<{ transactionHash?: Hex }> {
+}): Promise {
const { messenger, transaction } = request;
try {
diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts
index 2c8310931c0..cbabadb992a 100644
--- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts
+++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts
@@ -1326,7 +1326,7 @@ describe('submitFiatQuotes', () => {
);
});
- it('skips the vault batch and returns an empty hash when vaultDisabled is enabled', async () => {
+ it('skips the vault batch and returns skipped when vaultDisabled is enabled', async () => {
const { callMock, request } = getRequest({
quotes: [
getFiatQuoteMock({
@@ -1378,7 +1378,7 @@ describe('submitFiatQuotes', () => {
const result = await submitFiatQuotes(request);
- expect(result).toStrictEqual({ transactionHash: '0x' });
+ expect(result).toStrictEqual({ skipped: true });
expect(callMock).not.toHaveBeenCalledWith(
'TransactionPayController:getAmountData',
expect.anything(),
diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts
index 19042c2a3ee..e546e2b85a4 100644
--- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts
+++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts
@@ -133,6 +133,10 @@ export async function submitFiatQuotes(
request,
});
+ if (result.skipped) {
+ return result;
+ }
+
if (result.transactionHash === undefined) {
throw new Error('Missing transaction hash');
}
@@ -239,7 +243,7 @@ async function submitRelayAfterFiatCompletion({
}: {
order: RampsOrder;
request: PayStrategyExecuteRequest;
-}): Promise<{ transactionHash?: Hex }> {
+}): Promise<{ skipped?: true; transactionHash?: Hex }> {
const { messenger, quotes, transaction } = request;
const transactionId = transaction.id;
diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts
index f81bbf7516d..b9f63a01d41 100644
--- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts
+++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts
@@ -70,6 +70,8 @@ export function getMessengerMock({
TransactionControllerAddTransactionBatchAction['handler']
> = jest.fn();
+ const getMoneyAccountBalanceMock = jest.fn();
+
const findNetworkClientIdByChainIdMock: jest.MockedFn<
NetworkControllerFindNetworkClientIdByChainIdAction['handler']
> = jest.fn();
@@ -191,6 +193,11 @@ export function getMessengerMock({
addTransactionBatchMock,
);
+ messenger.registerActionHandler(
+ 'MoneyAccountBalanceService:getMoneyAccountBalance',
+ getMoneyAccountBalanceMock,
+ );
+
messenger.registerActionHandler(
'NetworkController:findNetworkClientIdByChainId',
findNetworkClientIdByChainIdMock,
@@ -320,6 +327,7 @@ export function getMessengerMock({
getGasFeeControllerStateMock,
getGasFeeTokensMock,
getKeyringControllerStateMock,
+ getMoneyAccountBalanceMock,
getNetworkClientByIdMock,
getNetworkConfigurationByChainIdMock,
getRemoteFeatureFlagControllerStateMock,
diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts
index b8ee97a3990..47adc7a8ba5 100644
--- a/packages/transaction-pay-controller/src/types.ts
+++ b/packages/transaction-pay-controller/src/types.ts
@@ -61,6 +61,15 @@ import type {
} from './constants.js';
import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types.js';
+type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = {
+ type: 'MoneyAccountBalanceService:getMoneyAccountBalance';
+ handler: (accountAddress: Hex) => Promise<{
+ musdBalance: string;
+ totalBalance: string;
+ vmusdValueInMusd: string;
+ }>;
+};
+
export type AllowedActions =
| AccountTrackerControllerGetStateAction
| AssetsControllerGetStateForTransactionPayAction
@@ -68,6 +77,7 @@ export type AllowedActions =
| GetGasFeeState
| KeyringControllerGetStateAction
| KeyringControllerSignTypedMessageAction
+ | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction
| NetworkControllerFindNetworkClientIdByChainIdAction
| NetworkControllerGetNetworkClientByIdAction
| NetworkControllerGetNetworkConfigurationByChainIdAction
@@ -824,6 +834,7 @@ export type PayStrategy = {
/** Execute or submit the quotes to obtain required tokens. */
execute: (request: PayStrategyExecuteRequest) => Promise<{
+ skipped?: true;
transactionHash?: Hex;
}>;
};
diff --git a/packages/transaction-pay-controller/src/utils/chomp.test.ts b/packages/transaction-pay-controller/src/utils/chomp.test.ts
index acbffb5e2f8..af4639ce278 100644
--- a/packages/transaction-pay-controller/src/utils/chomp.test.ts
+++ b/packages/transaction-pay-controller/src/utils/chomp.test.ts
@@ -9,13 +9,19 @@ jest.mock('./provider');
const MONEY_ACCOUNT_ADDRESS =
'0x1111111111111111111111111111111111111111' as Hex;
+const BORING_VAULT_ADDRESS =
+ '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex;
+const OTHER_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex;
const CHOMP_TX_HASH =
'0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex;
const FROM_BLOCK = '0x100' as Hex;
const SOURCE_AMOUNT_RAW = '5000000'; // 5 mUSD (6 decimals)
-// uint256 hex for 5000000 (>= source amount)
-const TRANSFER_DATA_SUFFICIENT =
+// uint256 hex for 5000000 (exact source amount)
+const TRANSFER_DATA_EXACT =
'0x00000000000000000000000000000000000000000000000000000000004c4b40';
+// uint256 hex for 5000001 (above source amount)
+const TRANSFER_DATA_ABOVE =
+ '0x00000000000000000000000000000000000000000000000000000000004c4b41';
// uint256 hex for 4999999 (< source amount)
const TRANSFER_DATA_INSUFFICIENT =
'0x00000000000000000000000000000000000000000000000000000000004c4b3f';
@@ -28,11 +34,17 @@ function padAddress(address: string): string {
}
const MONEY_ACCOUNT_PADDED = padAddress(MONEY_ACCOUNT_ADDRESS);
-
-function buildMusdTransferLog(
- txHash: Hex = CHOMP_TX_HASH,
- data: string = TRANSFER_DATA_SUFFICIENT,
-): {
+const BORING_VAULT_PADDED = padAddress(BORING_VAULT_ADDRESS);
+
+function buildMusdTransferLog({
+ txHash = CHOMP_TX_HASH,
+ data = TRANSFER_DATA_EXACT,
+ to = BORING_VAULT_ADDRESS,
+}: {
+ txHash?: Hex;
+ data?: string;
+ to?: Hex;
+} = {}): {
address: string;
topics: string[];
data: string;
@@ -41,11 +53,7 @@ function buildMusdTransferLog(
return {
address: MUSD_MONAD_ADDRESS,
data,
- topics: [
- ERC20_TRANSFER_TOPIC,
- MONEY_ACCOUNT_PADDED,
- padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
- ],
+ topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, padAddress(to)],
transactionHash: txHash,
};
}
@@ -62,7 +70,7 @@ describe('chomp', () => {
});
describe('findRecentChompVaultDeposit', () => {
- it('returns the CHOMP tx hash when a Transfer log with sufficient amount is found', async () => {
+ it('returns the CHOMP tx hash when Transfer is to the vault with exact amount', async () => {
rpcRequestMock.mockResolvedValueOnce([buildMusdTransferLog()]);
const result = await findRecentChompVaultDeposit({
@@ -70,16 +78,50 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
expect(result).toBe(CHOMP_TX_HASH);
- // Only eth_getLogs should have been called.
+ expect(rpcRequestMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns undefined when Transfer is not to the vault', async () => {
+ rpcRequestMock.mockResolvedValueOnce([
+ buildMusdTransferLog({ to: OTHER_RECIPIENT }),
+ ]);
+
+ const result = await findRecentChompVaultDeposit({
+ fromBlock: FROM_BLOCK,
+ messenger: buildMessenger(),
+ moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
+ sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
+ });
+
+ expect(result).toBeUndefined();
+ expect(rpcRequestMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns undefined when the transfer amount does not exactly match', async () => {
+ rpcRequestMock.mockResolvedValueOnce([
+ buildMusdTransferLog({ data: TRANSFER_DATA_ABOVE }),
+ ]);
+
+ const result = await findRecentChompVaultDeposit({
+ fromBlock: FROM_BLOCK,
+ messenger: buildMessenger(),
+ moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
+ sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
+ });
+
+ expect(result).toBeUndefined();
expect(rpcRequestMock).toHaveBeenCalledTimes(1);
});
it('returns undefined when the mUSD transfer amount is below the required amount', async () => {
rpcRequestMock.mockResolvedValueOnce([
- buildMusdTransferLog(CHOMP_TX_HASH, TRANSFER_DATA_INSUFFICIENT),
+ buildMusdTransferLog({ data: TRANSFER_DATA_INSUFFICIENT }),
]);
const result = await findRecentChompVaultDeposit({
@@ -87,6 +129,7 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
expect(result).toBeUndefined();
@@ -101,13 +144,14 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
expect(result).toBeUndefined();
expect(rpcRequestMock).toHaveBeenCalledTimes(1);
});
- it('queries eth_getLogs with the correct filter', async () => {
+ it('queries eth_getLogs filtered to transfers from the Money Account to the vault', async () => {
rpcRequestMock.mockResolvedValueOnce([]);
await findRecentChompVaultDeposit({
@@ -115,6 +159,7 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
expect(rpcRequestMock).toHaveBeenCalledWith(
@@ -126,22 +171,26 @@ describe('chomp', () => {
address: MUSD_MONAD_ADDRESS,
fromBlock: FROM_BLOCK,
toBlock: 'latest',
- topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, null],
+ topics: [
+ ERC20_TRANSFER_TOPIC,
+ MONEY_ACCOUNT_PADDED,
+ BORING_VAULT_PADDED,
+ ],
}),
],
}),
);
});
- it('processes logs newest-first and returns the most recent match', async () => {
+ it('processes logs newest-first and returns the most recent exact vault match', async () => {
const olderHash =
'0x0000000000000000000000000000000000000000000000000000000000000001' as Hex;
const newerHash =
'0x0000000000000000000000000000000000000000000000000000000000000002' as Hex;
rpcRequestMock.mockResolvedValueOnce([
- buildMusdTransferLog(olderHash),
- buildMusdTransferLog(newerHash),
+ buildMusdTransferLog({ txHash: olderHash }),
+ buildMusdTransferLog({ txHash: newerHash }),
]);
const result = await findRecentChompVaultDeposit({
@@ -149,19 +198,23 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
expect(result).toBe(newerHash);
expect(rpcRequestMock).toHaveBeenCalledTimes(1);
});
- it('skips logs with insufficient amount and returns the first sufficient one', async () => {
- const insufficientHash =
+ it('skips amount mismatches and returns the first exact vault match', async () => {
+ const mismatchedHash =
'0x0000000000000000000000000000000000000000000000000000000000000001' as Hex;
rpcRequestMock.mockResolvedValueOnce([
- buildMusdTransferLog(insufficientHash, TRANSFER_DATA_INSUFFICIENT),
- buildMusdTransferLog(CHOMP_TX_HASH),
+ buildMusdTransferLog({
+ txHash: mismatchedHash,
+ data: TRANSFER_DATA_INSUFFICIENT,
+ }),
+ buildMusdTransferLog(),
]);
const result = await findRecentChompVaultDeposit({
@@ -169,16 +222,16 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
- // Logs reversed: CHOMP_TX_HASH checked first (newer), passes amount check.
expect(result).toBe(CHOMP_TX_HASH);
expect(rpcRequestMock).toHaveBeenCalledTimes(1);
});
it('treats a log with data "0x" as zero amount and skips it', async () => {
rpcRequestMock.mockResolvedValueOnce([
- buildMusdTransferLog(CHOMP_TX_HASH, '0x'),
+ buildMusdTransferLog({ data: '0x' }),
]);
const result = await findRecentChompVaultDeposit({
@@ -186,6 +239,7 @@ describe('chomp', () => {
messenger: buildMessenger(),
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
sourceAmountRaw: SOURCE_AMOUNT_RAW,
+ vaultAddress: BORING_VAULT_ADDRESS,
});
expect(result).toBeUndefined();
diff --git a/packages/transaction-pay-controller/src/utils/chomp.ts b/packages/transaction-pay-controller/src/utils/chomp.ts
index 2c2cc466276..073ea4b3492 100644
--- a/packages/transaction-pay-controller/src/utils/chomp.ts
+++ b/packages/transaction-pay-controller/src/utils/chomp.ts
@@ -19,18 +19,34 @@ type RpcLog = {
transactionHash: Hex;
};
+/**
+ * Finds a recent mUSD Transfer from the Money Account into the boring vault
+ * whose amount exactly matches `sourceAmountRaw`. Exact amount + vault `to`
+ * avoid treating Pix/other outbound transfers as CHOMP vault success.
+ *
+ * @param options - Scan options.
+ * @param options.messenger - Controller messenger for RPC.
+ * @param options.moneyAccountAddress - Money Account that sent the transfer.
+ * @param options.sourceAmountRaw - Exact raw mUSD amount expected.
+ * @param options.fromBlock - Inclusive block to start the log scan.
+ * @param options.vaultAddress - Boring vault address that must be the Transfer `to`.
+ * @returns Matching transaction hash, if any.
+ */
export async function findRecentChompVaultDeposit({
messenger,
moneyAccountAddress,
sourceAmountRaw,
fromBlock,
+ vaultAddress,
}: {
messenger: TransactionPayControllerMessenger;
moneyAccountAddress: Hex;
sourceAmountRaw: string;
fromBlock: Hex;
+ vaultAddress: Hex;
}): Promise