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 { const fromPadded = padAddress(moneyAccountAddress); + const toPadded = padAddress(vaultAddress); const logs = await rpcRequest({ messenger, @@ -41,7 +57,7 @@ export async function findRecentChompVaultDeposit({ address: MUSD_MONAD_ADDRESS, fromBlock, toBlock: 'latest', - topics: [ERC20_TRANSFER_TOPIC, fromPadded, null], + topics: [ERC20_TRANSFER_TOPIC, fromPadded, toPadded], }, ], }); @@ -50,16 +66,30 @@ export async function findRecentChompVaultDeposit({ count: logs.length, fromBlock, moneyAccountAddress, + vaultAddress, }); const requiredAmount = BigInt(sourceAmountRaw); + const vaultTopic = toPadded.toLowerCase(); // Examine newest logs first so we return the most recent CHOMP match. for (const txLog of [...logs].reverse()) { + const logTo = txLog.topics[2]?.toLowerCase(); + if (logTo !== vaultTopic) { + log('CHOMP scan: skipping log - transfer is not to the vault', { + expectedTo: vaultAddress, + logTo, + txHash: txLog.transactionHash, + }); + continue; + } + const transferAmount = BigInt(txLog.data === '0x' ? '0x0' : txLog.data); - if (transferAmount < requiredAmount) { - log('CHOMP scan: skipping log — transfer amount below required', { + // Exact amount only: >= would falsely treat larger outbound transfers + // (e.g. Pix) as vault deposits when `to` filtering alone is insufficient. + if (transferAmount !== requiredAmount) { + log('CHOMP scan: skipping log - transfer amount is not an exact match', { requiredAmount: requiredAmount.toString(), transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, @@ -72,12 +102,17 @@ export async function findRecentChompVaultDeposit({ sourceAmountRaw, transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, + vaultAddress, }); return txLog.transactionHash; } - log('CHOMP scan: no match found', { fromBlock, moneyAccountAddress }); + log('CHOMP scan: no match found', { + fromBlock, + moneyAccountAddress, + vaultAddress, + }); return undefined; } diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 1aca3ef304c..d167538513e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -7,7 +7,11 @@ import type { Hex } from '@metamask/utils'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; -import { submitMoneyAccountVaultDeposit } from './ma-vault-deposit.js'; +import { + submitMoneyAccountVaultDeposit, + submitMoneyAccountVaultDepositBatch, +} from './ma-vault-deposit.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -17,12 +21,15 @@ import { } from './transaction.js'; jest.mock('./chomp'); +jest.mock('./money-account-vault-config'); jest.mock('./provider'); jest.mock('./transaction'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS_MOCK = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; const NETWORK_CLIENT_ID_MOCK = 'network-client-id-mock'; const TRANSACTION_MOCK = { @@ -72,6 +79,9 @@ function callSubmit({ describe('submitMoneyAccountVaultDeposit', () => { const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); @@ -82,6 +92,13 @@ describe('submitMoneyAccountVaultDeposit', () => { beforeEach(() => { jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue({ + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: BORING_VAULT_ADDRESS_MOCK, + chainId: '0x8f' as Hex, + lensAddress: '0x3333333333333333333333333333333333333333' as Hex, + tellerAddress: '0x4444444444444444444444444444444444444444' as Hex, + }); getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); collectTransactionIdsMock.mockImplementation( (_chainId, _from, _messenger, onTransaction) => { @@ -270,7 +287,7 @@ describe('submitMoneyAccountVaultDeposit', () => { const result = await callSubmit({ callMock, vaultDisabled: true }); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalled(); expect(updateTransactionMock).not.toHaveBeenCalled(); expect(collectTransactionIdsMock).not.toHaveBeenCalled(); @@ -512,4 +529,46 @@ describe('submitMoneyAccountVaultDeposit', () => { expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); }); }); + + describe('parentless vault batches', () => { + const depositCalls: BatchTransactionParams[] = [ + { data: '0xapprove' as Hex, to: '0xapprove' as Hex }, + { data: '0xdeposit' as Hex, to: '0xdeposit' as Hex }, + ]; + + it('submits without updating a parent transaction', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0xvault' }); + }); + + it('returns before submission when disabled', async () => { + const callMock = jest.fn(); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: true, + }); + + expect(callMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ skipped: true }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index 8f3facf82f5..7f46689106e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -13,6 +13,7 @@ import { MUSD_MONAD_FIAT_ASSET } from '../strategy/fiat/constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; import { prefixError } from './error-prefix.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -25,6 +26,11 @@ const log = createModuleLogger(projectLogger, 'ma-vault-deposit'); export const VAULT_ERROR_PREFIX = 'Vault: '; +export type SubmitMoneyAccountVaultDepositResult = { + skipped?: true; + transactionHash?: Hex; +}; + /** * Submits a Money Account mUSD vault deposit batch on Monad once the source * mUSD has settled in the Money Account (fiat on-ramp, Relay bridge, or any @@ -47,7 +53,8 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; * @param options.transaction - Original Money Account transaction meta. * @param options.vaultDisabled - When `true`, skip the vault batch and leave * the settled mUSD in the Money Account. Caller-evaluated kill-switch. - * @returns Hash of the final submitted child transaction, if available. + * @returns Hash of the final submitted child transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export async function submitMoneyAccountVaultDeposit({ fromBlock, @@ -65,7 +72,7 @@ export async function submitMoneyAccountVaultDeposit({ sourceAmountRaw: string; transaction: TransactionMeta; vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const transactionId = transaction.id; const moneyAccountAddress = (moneyAccountAddressOverride ?? transaction.txParams.from) as Hex | undefined; @@ -81,7 +88,7 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); - return { transactionHash: '0x' }; + return { skipped: true }; } const nestedTransactions = await resolveVaultDepositBatch({ @@ -92,6 +99,60 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: nestedTransactions, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled: false, + }); +} + +/** + * Submits pre-built Money Account vault calls without requiring a parent + * transaction. When `transactionId` is supplied, submitted child IDs are also + * linked to that parent for the existing Fiat and Relay flows. + * + * @param options - Submission options. + * @param options.depositCalls - Pre-built approve and deposit calls. + * @param options.fromBlock - Block at which to begin the CHOMP race check. + * @param options.messenger - Transaction Pay controller messenger. + * @param options.moneyAccountAddress - Money Account that owns the mUSD. + * @param options.sourceAmountRaw - Raw mUSD amount to deposit. + * @param options.transactionId - Optional parent transaction to link children. + * @param options.vaultDisabled - Whether vault submission is disabled. + * @returns Hash of the final confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. + */ +export async function submitMoneyAccountVaultDepositBatch({ + depositCalls, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled, +}: { + depositCalls: NestedTransactionMetadata[]; + fromBlock?: Hex; + messenger: TransactionPayControllerMessenger; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + transactionId?: string; + vaultDisabled: boolean; +}): Promise { + if (vaultDisabled) { + log('Skipping vault deposit because vaultDisabled is true', { + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { skipped: true }; + } + // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. const preChompHash = await tryFindChompDeposit({ @@ -117,23 +178,25 @@ export async function submitMoneyAccountVaultDeposit({ messenger, (id) => { transactionIds.push(id); - updateTransaction( - { - transactionId, - messenger, - note: 'Add required transaction ID from Money Account vault submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(id); - }, - ); + if (transactionId) { + updateTransaction( + { + transactionId, + messenger, + note: 'Add required transaction ID from Money Account vault submission', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(id); + }, + ); + } }, ); log('Submitting Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -151,7 +214,7 @@ export async function submitMoneyAccountVaultDeposit({ origin: ORIGIN_METAMASK, requireApproval: false, skipInitialGasEstimate: true, - transactions: nestedTransactions.map((nestedTransaction, index) => ({ + transactions: depositCalls.map((nestedTransaction, index) => ({ params: { data: nestedTransaction.data, to: nestedTransaction.to, @@ -185,7 +248,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Submitted Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -209,7 +272,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Confirmed Money Account vault deposit', { hash, moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -316,18 +379,20 @@ async function tryFindChompDeposit({ messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; - transactionId: string; + transactionId?: string; }): Promise { if (!fromBlock) { return undefined; } try { + const { boringVault } = getMoneyAccountVaultConfig(messenger); return await findRecentChompVaultDeposit({ fromBlock, messenger, moneyAccountAddress, sourceAmountRaw, + vaultAddress: boringVault, }); } catch (chompError) { log('CHOMP check failed', { chompError, transactionId }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts new file mode 100644 index 00000000000..ca8098195ef --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts @@ -0,0 +1,198 @@ +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './ma-vault-payout.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./ma-vault-deposit'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); +jest.mock('./transaction'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const PAYOUT_HASH = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const VAULT_HASH = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: '0x3333333333333333333333333333333333333333' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444' as Hex, + tellerAddress: '0x5555555555555555555555555555555555555555' as Hex, +}; + +function getMessenger(): TransactionPayControllerMessenger { + return { + call: jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + throw new Error(`Unexpected action: ${action}`); + }), + } as unknown as TransactionPayControllerMessenger; +} + +describe('submitMoneyAccountVaultDepositFromPayout', () => { + const buildMoneyAccountDepositBatchMock = jest.mocked( + buildMoneyAccountDepositBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); + const submitMoneyAccountVaultDepositBatchMock = jest.mocked( + submitMoneyAccountVaultDepositBatch, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: '5000000', + blockNumber: '0x123', + }); + buildMoneyAccountDepositBatchMock.mockResolvedValue({ + approveTx: { + params: { + data: '0xapprove', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + }, + depositTx: { + params: { + data: '0xdeposit', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + }, + } as never); + submitMoneyAccountVaultDepositBatchMock.mockResolvedValue({ + transactionHash: VAULT_HASH, + }); + }); + + it('resolves the Iron payout and submits a parentless vault batch', async () => { + const messenger = getMessenger(); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: PAYOUT_HASH, + walletAddress: MONEY_ACCOUNT_ADDRESS, + }); + expect(buildMoneyAccountDepositBatchMock).toHaveBeenCalledWith({ + amount: 5000000n, + provider: expect.anything(), + ...VAULT_CONFIG, + }); + expect(submitMoneyAccountVaultDepositBatchMock).toHaveBeenCalledWith({ + depositCalls: [ + expect.objectContaining({ data: '0xapprove' }), + expect.objectContaining({ data: '0xdeposit' }), + ], + fromBlock: '0x123', + messenger, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + expect(result).toStrictEqual({ transactionHash: VAULT_HASH }); + }); + + it('defaults vaultDisabled to false', async () => { + const messenger = getMessenger(); + + await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a payout without an mUSD transfer to the Money Account', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: '0x123', + }); + + await expect( + submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ), + ).rejects.toThrow('Payout transaction has no mUSD transfer'); + + expect(buildMoneyAccountDepositBatchMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositBatchMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when vaulting is disabled', async () => { + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: true, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ skipped: true }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when deposits are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ skipped: true }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts new file mode 100644 index 00000000000..1c55b0948d2 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts @@ -0,0 +1,83 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './ma-vault-deposit.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +export type SubmitMoneyAccountVaultDepositRequest = { + moneyAccountAddress: Hex; + transactionHash: Hex; + vaultDisabled?: boolean; +}; + +/** + * Resolves an Iron payout transaction and vaults the received mUSD. + * + * @param request - Iron payout details. + * @param messenger - Transaction Pay controller messenger. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` when + * vaulting is disabled. + */ +export async function submitMoneyAccountVaultDepositFromPayout( + request: SubmitMoneyAccountVaultDepositRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const { + moneyAccountAddress, + transactionHash, + vaultDisabled = false, + } = request; + + if ( + vaultDisabled || + !isMoneyAccountVaultActionEnabled(messenger, 'deposit') + ) { + return { skipped: true }; + } + + const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: transactionHash, + walletAddress: moneyAccountAddress, + }); + + if (!amountRaw || BigInt(amountRaw) <= 0n) { + throw new Error('Payout transaction has no mUSD transfer'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount: BigInt(amountRaw), + provider, + ...vaultConfig, + }); + + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: [ + { ...approveTx.params, type: approveTx.type }, + { ...depositTx.params, type: depositTx.type }, + ], + fromBlock: blockNumber, + messenger, + moneyAccountAddress, + sourceAmountRaw: amountRaw, + vaultDisabled: false, + }); +} diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts new file mode 100644 index 00000000000..fb732dcc2eb --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts @@ -0,0 +1,195 @@ +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw } from './ma-vault-withdraw.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const IRON_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x3333333333333333333333333333333333333333' as Hex, + boringVault: '0x4444444444444444444444444444444444444444' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x5555555555555555555555555555555555555555' as Hex, + tellerAddress: '0x6666666666666666666666666666666666666666' as Hex, +}; + +function getRequest( + overrides: Partial = {}, +): SubmitMoneyAccountVaultWithdrawRequest { + return { + amountInRaw: '5000000', + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + recipient: IRON_ADDRESS, + requestId: 'request-id', + ...overrides, + }; +} + +function getMessenger({ + balance = '5000000', +}: { + balance?: string; +} = {}): { + callMock: jest.Mock; + messenger: TransactionPayControllerMessenger; +} { + const callMock = jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + if (action === 'MoneyAccountBalanceService:getMoneyAccountBalance') { + return Promise.resolve({ + musdBalance: '0', + totalBalance: balance, + vmusdValueInMusd: balance, + }); + } + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: '0xbatch' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + messenger: { + call: callMock, + } as unknown as TransactionPayControllerMessenger, + }; +} + +describe('submitMoneyAccountVaultWithdraw', () => { + const buildMoneyAccountWithdrawBatchMock = jest.mocked( + buildMoneyAccountWithdrawBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + buildMoneyAccountWithdrawBatchMock.mockResolvedValue({ + transferTx: { + params: { + data: '0xtransfer', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + type: 'tokenMethodTransfer', + }, + withdrawTx: { + params: { + data: '0xwithdraw', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + type: 'moneyAccountWithdraw', + }, + } as never); + }); + + it('creates one user-confirmed atomic batch to the Iron address', async () => { + const { callMock, messenger } = getMessenger(); + const request = getRequest(); + + const result = await submitMoneyAccountVaultWithdraw(request, messenger); + + expect(buildMoneyAccountWithdrawBatchMock).toHaveBeenCalledWith({ + accountantAddress: VAULT_CONFIG.accountantAddress, + amount: 5000000n, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + provider: expect.anything(), + recipient: IRON_ADDRESS, + tellerAddress: VAULT_CONFIG.tellerAddress, + }); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: MONEY_ACCOUNT_ADDRESS, + isGasFeeSponsored: true, + isInternal: true, + networkClientId: NETWORK_CLIENT_ID, + origin: 'metamask', + requestId: 'request-id', + requireApproval: true, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ data: '0xwithdraw' }), + }), + expect.objectContaining({ + params: expect.objectContaining({ data: '0xtransfer' }), + }), + ], + }), + ); + expect(result).toStrictEqual({ batchId: '0xbatch' }); + }); + + it('rejects an amount above the withdrawable vmUSD value', async () => { + const { messenger } = getMessenger({ balance: '4999999' }); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), messenger), + ).rejects.toThrow('Insufficient withdrawable vmUSD balance'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it('rejects when Money Account withdrawals are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), getMessenger().messenger), + ).rejects.toThrow('Money Account vault withdrawal is disabled'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [{ amountInRaw: '0' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: '-1' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: 'invalid' }, 'Withdrawal amount must be greater than zero'], + [{ recipient: '0x1234' }, 'Iron recipient is invalid'], + [ + { recipient: MONEY_ACCOUNT_ADDRESS }, + 'Iron recipient must differ from the Money Account', + ], + [{ requestId: '' }, 'Missing withdraw request id'], + ])('rejects invalid withdraw input %#', async (overrides, message) => { + await expect( + submitMoneyAccountVaultWithdraw( + getRequest(overrides), + getMessenger().messenger, + ), + ).rejects.toThrow(message); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts new file mode 100644 index 00000000000..7beb1bb9f6f --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts @@ -0,0 +1,115 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { TransactionBatchResult } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +/** + * On-chain withdraw intent. Quote / Pix / Iron identifiers stay outside Core; + * Monad and mUSD are fixed by the Money Account vault config constants. + */ +export type SubmitMoneyAccountVaultWithdrawRequest = { + amountInRaw: string; + moneyAccountAddress: Hex; + recipient: Hex; + requestId: string; +}; + +/** + * Creates a user-confirmed atomic vmUSD withdrawal and mUSD transfer to Iron. + * + * @param request - Exact-out withdraw intent. + * @param messenger - Transaction Pay controller messenger. + * @returns The pending transaction batch ID. + */ +export async function submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + validateRequest(request); + + if (!isMoneyAccountVaultActionEnabled(messenger, 'withdraw')) { + throw new Error('Money Account vault withdrawal is disabled'); + } + + const amount = BigInt(request.amountInRaw); + const balance = await messenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + request.moneyAccountAddress, + ); + + if (amount > BigInt(balance.vmusdValueInMusd)) { + throw new Error('Insufficient withdrawable vmUSD balance'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { withdrawTx, transferTx } = await buildMoneyAccountWithdrawBatch({ + accountantAddress: vaultConfig.accountantAddress, + amount, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: request.moneyAccountAddress, + provider, + recipient: request.recipient, + tellerAddress: vaultConfig.tellerAddress, + }); + + return await messenger.call('TransactionController:addTransactionBatch', { + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: request.moneyAccountAddress, + isGasFeeSponsored: true, + isInternal: true, + networkClientId, + origin: ORIGIN_METAMASK, + requestId: request.requestId, + requireApproval: true, + skipInitialGasEstimate: true, + transactions: [withdrawTx, transferTx], + }); +} + +function validateRequest( + request: SubmitMoneyAccountVaultWithdrawRequest, +): void { + if (!request.requestId) { + throw new Error('Missing withdraw request id'); + } + + let amount: bigint; + try { + amount = BigInt(request.amountInRaw); + } catch { + throw new Error('Withdrawal amount must be greater than zero'); + } + + if (amount <= 0n) { + throw new Error('Withdrawal amount must be greater than zero'); + } + + if (!isValidHexAddress(request.recipient)) { + throw new Error('Iron recipient is invalid'); + } + if ( + request.recipient.toLowerCase() === + request.moneyAccountAddress.toLowerCase() + ) { + throw new Error('Iron recipient must differ from the Money Account'); + } +} diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts new file mode 100644 index 00000000000..82ff8e0a9ca --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts @@ -0,0 +1,90 @@ +import type { Hex, Json } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; + +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222', + boringVault: '0x3333333333333333333333333333333333333333', + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444', + tellerAddress: '0x5555555555555555555555555555555555555555', +}; + +function getMessenger( + flag: unknown, + moneyAccount: unknown = undefined, +): TransactionPayControllerMessenger { + return { + call: jest.fn(() => ({ + remoteFeatureFlags: { + moneyAccount: moneyAccount as Json, + moneyAccountVaultConfig: flag as Json, + }, + })), + } as unknown as TransactionPayControllerMessenger; +} + +describe('getMoneyAccountVaultConfig', () => { + it('returns a valid Monad vault config', () => { + expect( + getMoneyAccountVaultConfig(getMessenger(VAULT_CONFIG)), + ).toStrictEqual(VAULT_CONFIG as Record); + }); + + it.each([ + ['deposit', { moneyAccountDepositEnabled: true }], + ['withdraw', { moneyAccountWithdrawEnabled: true }], + ] as const)('returns true when %s is enabled', (action, flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + action, + ), + ).toBe(true); + }); + + it.each(['deposit', 'withdraw'] as const)( + 'defaults %s to disabled', + (action) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, {}), + action, + ), + ).toBe(false); + }, + ); + + it.each([undefined, [], 'enabled'])( + 'treats non-object Money Account flags as disabled', + (flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + 'deposit', + ), + ).toBe(false); + }, + ); + + it('throws when vault config is missing', () => { + expect(() => getMoneyAccountVaultConfig(getMessenger(undefined))).toThrow( + 'Money Account vault config is unavailable', + ); + }); + + it.each([ + { ...VAULT_CONFIG, chainId: '0x1' }, + { ...VAULT_CONFIG, tellerAddress: '0x1234' }, + { ...VAULT_CONFIG, lensAddress: undefined }, + ])('throws when vault config is invalid', (config) => { + expect(() => getMoneyAccountVaultConfig(getMessenger(config))).toThrow( + 'Money Account vault config is invalid', + ); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts new file mode 100644 index 00000000000..5785eb2cd59 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts @@ -0,0 +1,86 @@ +import type { Hex, Json } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; + +const VAULT_CONFIG_FLAG = 'moneyAccountVaultConfig'; +const REQUIRED_ADDRESS_KEYS = [ + 'boringVault', + 'tellerAddress', + 'accountantAddress', + 'lensAddress', +] as const; + +type MoneyAccountVaultAction = 'deposit' | 'withdraw'; + +export type MoneyAccountVaultConfig = { + accountantAddress: Hex; + boringVault: Hex; + chainId: Hex; + lensAddress: Hex; + tellerAddress: Hex; +}; + +/** + * Reads and validates the Money Account vault configuration. + * + * @param messenger - Transaction Pay controller messenger. + * @returns Validated Monad vault configuration. + */ +export function getMoneyAccountVaultConfig( + messenger: TransactionPayControllerMessenger, +): MoneyAccountVaultConfig { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.[VAULT_CONFIG_FLAG]; + + if (value === undefined) { + throw new Error('Money Account vault config is unavailable'); + } + + if (!isVaultConfig(value)) { + throw new Error('Money Account vault config is invalid'); + } + + return value; +} + +/** + * Returns whether the requested Money Account vault action is enabled. + * + * @param messenger - Transaction Pay controller messenger. + * @param action - Vault action to inspect. + * @returns Whether the remote feature flag explicitly enables the action. + */ +export function isMoneyAccountVaultActionEnabled( + messenger: TransactionPayControllerMessenger, + action: MoneyAccountVaultAction, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.moneyAccount; + if (!value || Array.isArray(value) || typeof value !== 'object') { + return false; + } + + const key = + action === 'deposit' + ? 'moneyAccountDepositEnabled' + : 'moneyAccountWithdrawEnabled'; + return value[key] === true; +} + +function isVaultConfig(value: Json): value is Json & MoneyAccountVaultConfig { + if ( + !value || + Array.isArray(value) || + typeof value !== 'object' || + value.chainId !== CHAIN_ID_MONAD + ) { + return false; + } + + return REQUIRED_ADDRESS_KEYS.every((key) => { + const address = value[key]; + return typeof address === 'string' && isValidHexAddress(address as Hex); + }); +} diff --git a/packages/transaction-pay-controller/tsconfig.build.json b/packages/transaction-pay-controller/tsconfig.build.json index 4865a1c8327..ad329d91746 100644 --- a/packages/transaction-pay-controller/tsconfig.build.json +++ b/packages/transaction-pay-controller/tsconfig.build.json @@ -39,6 +39,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../money-account-utils/tsconfig.build.json" + }, { "path": "../sentinel-api-service/tsconfig.build.json" } diff --git a/packages/transaction-pay-controller/tsconfig.json b/packages/transaction-pay-controller/tsconfig.json index 67ae32f3465..fbae571a6cc 100644 --- a/packages/transaction-pay-controller/tsconfig.json +++ b/packages/transaction-pay-controller/tsconfig.json @@ -37,6 +37,9 @@ { "path": "../messenger" }, + { + "path": "../money-account-utils" + }, { "path": "../sentinel-api-service" } diff --git a/yarn.lock b/yarn.lock index 0f653bf45d8..467ee181753 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7684,12 +7684,29 @@ __metadata: resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/geolocation-controller": "npm:^1.0.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^29.0.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" + chokidar-cli: "npm:^3.0.0" deepmerge: "npm:^4.2.2" jest: "npm:^30.4.2" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" + tweetnacl: "npm:^1.0.3" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -7943,7 +7960,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-utils@workspace:packages/money-account-utils": +"@metamask/money-account-utils@npm:^1.1.0, @metamask/money-account-utils@workspace:packages/money-account-utils": version: 0.0.0-use.local resolution: "@metamask/money-account-utils@workspace:packages/money-account-utils" dependencies: @@ -9384,6 +9401,7 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/money-account-utils": "npm:^1.1.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/ramps-controller": "npm:^20.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" @@ -11014,7 +11032,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -13739,6 +13757,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -13760,6 +13785,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -14727,7 +14761,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -14881,7 +14915,21 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar-cli@npm:^3.0.0": + version: 3.0.0 + resolution: "chokidar-cli@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.2" + lodash.debounce: "npm:^4.0.8" + lodash.throttle: "npm:^4.1.1" + yargs: "npm:^13.3.0" + bin: + chokidar: index.js + checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9 + languageName: node + linkType: hard + +"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -15069,6 +15117,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^5.0.0": + version: 5.0.0 + resolution: "cliui@npm:5.0.0" + dependencies: + string-width: "npm:^3.1.0" + strip-ansi: "npm:^5.2.0" + wrap-ansi: "npm:^5.1.0" + checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -15165,6 +15224,15 @@ __metadata: languageName: node linkType: hard +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -15174,6 +15242,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -15931,6 +16006,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" @@ -16367,6 +16449,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^7.0.1": + version: 7.0.3 + resolution: "emoji-regex@npm:7.0.3" + checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -17726,6 +17815,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -17996,7 +18094,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -19303,6 +19401,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -20568,6 +20673,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -20644,6 +20759,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -22793,7 +22915,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -22820,6 +22942,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -23066,6 +23197,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -25023,6 +25161,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -25523,6 +25668,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -26119,6 +26271,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^3.0.0, string-width@npm:^3.1.0": + version: 3.1.0 + resolution: "string-width@npm:3.1.0" + dependencies: + emoji-regex: "npm:^7.0.1" + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^5.1.0" + checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2 + languageName: node + linkType: hard + "string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" @@ -26178,6 +26341,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46 + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" @@ -27865,6 +28037,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which@npm:^1.2.10": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -27959,6 +28138,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^5.1.0": + version: 5.1.0 + resolution: "wrap-ansi@npm:5.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + string-width: "npm:^3.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914 + languageName: node + linkType: hard + "wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" @@ -28132,6 +28322,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -28169,6 +28366,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^13.1.2": + version: 13.1.2 + resolution: "yargs-parser@npm:13.1.2" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -28191,6 +28398,24 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^13.3.0": + version: 13.3.2 + resolution: "yargs@npm:13.3.2" + dependencies: + cliui: "npm:^5.0.0" + find-up: "npm:^3.0.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^3.0.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^13.1.2" + checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0"