diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 27ceff651a..7ccd5084bc 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -22,13 +22,13 @@ This document explains: 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. | +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card' \| 'money'`) and a phase machine. Identity vendor is a parameterized `KycVendor` (`initialize({ vendor })`), not vendor-branded public methods. | +| **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. | --- @@ -100,8 +100,9 @@ graph TB Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): `initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, -`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`, -`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`, +`createVendorCustomer`, `clearSavedTerms`, `handleFrameMessage`, +`buildCheckFrameUrl`, `buildAuthFrameUrl`, `buildResetFrameUrl`, +`checkKycRequired`, `getKycStatus`, `getCustomerIdentity`, `refreshKycStatus`, `startSumSub`, `reset`. #### 2.2 `KycService` @@ -121,18 +122,22 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): `getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, -`createUkycSession`, `createJourney`. +`createVendorCustomer`, `submitConsents`, `fetchKycStatus`, `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 | +| Method | HTTP | Endpoint | Purpose | +| ---------------------- | ------ | ---------------------------------------- | ----------------------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/{vendor}/disclaimers?country=` | Terms to accept (`vendor` defaults to `moonpay`) | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create MoonPay vendor session | +| `checkKycRequired` | `POST` | `/vendors/{vendor}/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createVendorCustomer` | `POST` | `/vendors/{vendor}/customers` | Create or resume an empty-shell vendor customer | +| `submitConsents` | `POST` | `/consents` | Post T&C1 + T&C2 consents (204 No Content) | +| `fetchKycStatus` | `GET` | `/kyc/status` | User-keyed simplified KYC status | +| `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` @@ -167,6 +172,7 @@ classDiagram +string email +string termsAcceptedAt [persisted] +string[] acceptedDisclaimerIds [persisted] + +KycVendor termsAcceptedVendor [persisted] +KycDisclaimer[] disclaimers +string disclaimersError +string geoCountry @@ -194,8 +200,11 @@ classDiagram 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. + `termsAcceptedVendor`, `kycRequiredByProduct`, `lastCheckedAt`. These survive + restarts so the flow can skip already-accepted terms and reuse cached results. + Acceptance is vendor-scoped: `initialize` (and `createVendorCustomer`) drops + the stored acceptance when it belongs to a different vendor, so one vendor's + disclaimer ids are never submitted to another. - **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 @@ -215,7 +224,7 @@ stateDiagram-v2 idle --> terms : initialize() (no saved terms) idle --> session : initialize() (saved terms + email) - terms --> session : acceptTermsAndStartSession() + terms --> session : acceptTermsAndStartSession({ sumsubTncSigned, idosTncSigned }) session --> check : createSession() ok session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) @@ -244,6 +253,14 @@ stateDiagram-v2 > 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. +> **Non-MoonPay vendors use a consents path.** `initialize({ vendor: 'iron' })` +> creates an empty-shell customer, loads vendor disclaimers, and — after terms +> are accepted — posts consents and launches SumSub. MoonPay Check/Auth frames +> are skipped; `phase` moves `terms → session → submit → done`. +> `acceptTermsAndStartSession` requires `sumsubTncSigned` and `idosTncSigned` +> (T&C2) for every vendor; omitted flags fail the flow instead of defaulting to +> `true`. + > **`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 @@ -299,7 +316,7 @@ sequenceDiagram Svc->>API: GET /disclaimers Ctrl-->>UI: phase = terms (+ disclaimers) - User->>Ctrl: acceptTermsAndStartSession({ email }) + User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosTncSigned }) Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) Svc->>API: POST /sessions Ctrl-->>UI: phase = check (+ sessionToken) @@ -549,8 +566,9 @@ graph TB - **`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-service-init.ts`** constructs `KycService` with an `env` derived from + `isProduction()` and (currently) a dev `baseUrl` override. It does not inject + a `fetch`; `KycService` defaults to the runtime's native `fetch`. - **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to the controller's messenger. - **`kyc-service-messenger.ts`** delegates diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 34e465f3d5..2a1169909c 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,10 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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)) +- Parameterize Universal KYC vendor HTTP on `KycService` so identity vendors share one client surface instead of vendor-branded methods ([#9908](https://github.com/MetaMask/core/pull/9908)): + - `fetchDisclaimers({ vendor, country })` and `checkKycRequired({ vendor, ... })` call `/vendors/{vendor}/disclaimers` and `/vendors/{vendor}/kyc-required` (`vendor` defaults to `moonpay`) + - `createVendorCustomer({ vendor, email })` calls `POST /vendors/{vendor}/customers` + - `submitConsents({ disclaimerIds, ... })` posts `POST /consents` (wire body still uses `ironDisclaimerIds`) + - `fetchKycStatus()` reads `GET /kyc/status` +- Add a consents-path KYC flow on `KycController` for non-MoonPay vendors (currently `iron`): empty-shell customer → disclaimers → consents → SumSub, skipping MoonPay Check/Auth frames. `initialize({ vendor })` and `createVendorCustomer({ vendor, email })` drive the path; `acceptTermsAndStartSession` requires `sumsubTncSigned` / `idosTncSigned`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycController.refreshKycStatus()` and the `KycController:statusChanged` event so consumers can poll user-keyed KYC status for toast / banner surfaces. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycController.getCustomerIdentity()` (and `KycCustomerIdentity`) returning the vendor-scoped `{ vendor, id }` for the current session, or `null` before authentication and after `reset()`. ([#9908](https://github.com/MetaMask/core/pull/9908), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Extend `KycProduct` with `'money'` and `KycVendor` with `'iron'`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add `KycUserStatus` / `KycUserStatusResponse` types for the simplified `GET /kyc/status` payload. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add persisted `termsAcceptedVendor` state recording which vendor's disclaimers `acceptedDisclaimerIds` belong to, so stored acceptance is only reused for that vendor. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Add persisted `sumsubTncAccepted` and `idosTncAccepted` state so T&C2 flags can be validated when resuming a consents-path session. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- 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), [#9615](https://github.com/MetaMask/core/pull/9615), [#9712](https://github.com/MetaMask/core/pull/9712), [#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), [#9712](https://github.com/MetaMask/core/pull/9712), [#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. @@ -21,14 +31,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add UKYC session-status polling to `KycController` - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor +### Changed + +- Make the `fetch` option on the `KycService` constructor optional; it now defaults to the runtime's native `fetch` (browser, React Native, Node 18+), so consumers no longer need to inject one. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- **BREAKING:** Invalidate terms acceptance when `termsAcceptedVendor` is `null` (pre-migration state), forcing reacceptance after the multi-vendor upgrade to ensure users review current vendor terms. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- **BREAKING:** Require `sumsubTncSigned` and `idosTncSigned` on `acceptTermsAndStartSession` for every vendor, so callers explicitly declare T&C2 acceptance. Zero-argument calls and omitted flags fail instead of defaulting to `true`. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Rename `CreateUkycSessionParams.vendorId` to `vendor` for consistency with other service methods. ([#9908](https://github.com/MetaMask/core/pull/9908)) + ### 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)) +- Clear `moonpayCustomerId` when the active vendor is not MoonPay, so `getCustomerIdentity()` cannot report a MoonPay customer id under another vendor. ([#9908](https://github.com/MetaMask/core/pull/9908), [#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 an unconditional `unref()` threw when status polling started outside Node. ([#9908](https://github.com/MetaMask/core/pull/9908), [#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. ([#9908](https://github.com/MetaMask/core/pull/9908), [#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Validate that `accessToken` and `country` are provided when calling `checkKycRequired` with vendor `moonpay`, failing fast with a clear error instead of posting `undefined` values to the API. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Check for global `fetch` availability before binding in `KycService` constructor, throwing a descriptive error if `fetch` is neither provided nor globally available (e.g. older Node environments). ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Check for null bearer token before calling `assert()` in `#requestJson`, ensuring the custom "wallet signed in" error message is shown instead of a generic superstruct error. ([#9908](https://github.com/MetaMask/core/pull/9908)) +- Require reacceptance of consents-path terms when `sumsubTncAccepted` or `idosTncAccepted` are `null` (pre-migration state), preventing invalid T&C2 flag submission on session resume. ([#9908](https://github.com/MetaMask/core/pull/9908)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index b2aa85cca4..93614bab45 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -16,8 +16,8 @@ import type { KycController } from './KycController.js'; * 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`. + * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors + * skip Check/Auth frames and use the consents path. Defaults to `moonpay`. */ export type KycControllerInitializeAction = { type: `KycController:initialize`; @@ -25,16 +25,17 @@ export type KycControllerInitializeAction = { }; /** - * 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}. + * Creates (or resumes) an empty-shell customer for the given identity + * vendor. Exposed so a consumer 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. + * @param params.vendor - Identity vendor for the customer. + * @param params.email - Email for the vendor customer. */ -export type KycControllerCreateIronCustomerAction = { - type: `KycController:createIronCustomer`; - handler: KycController['createIronCustomer']; +export type KycControllerCreateVendorCustomerAction = { + type: `KycController:createVendorCustomer`; + handler: KycController['createVendorCustomer']; }; /** @@ -52,15 +53,15 @@ export type KycControllerLoadDisclaimersAction = { * Captures terms acceptance for the currently loaded disclaimers and creates * a session. * - * @param params - Optional parameters. + * @param params - The 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. + * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. + * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. */ export type KycControllerAcceptTermsAndStartSessionAction = { type: `KycController:acceptTermsAndStartSession`; @@ -229,7 +230,7 @@ export type KycControllerResetAction = { */ export type KycControllerMethodActions = | KycControllerInitializeAction - | KycControllerCreateIronCustomerAction + | KycControllerCreateVendorCustomerAction | KycControllerLoadDisclaimersAction | KycControllerAcceptTermsAndStartSessionAction | KycControllerClearSavedTermsAction diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index c34d6a88ee..0dc9cded7c 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -107,7 +107,11 @@ describe('KycController', () => { await withController( { options: { - state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + termsAcceptedVendor: 'moonpay', + }, }, }, async ({ controller, handlers }) => { @@ -261,6 +265,7 @@ describe('KycController', () => { expect(handlers.getGeoCountry).not.toHaveBeenCalled(); expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'moonpay', country: 'USA', }); }, @@ -276,6 +281,7 @@ describe('KycController', () => { expect(controller.state.geoCountry).toBe('FRA'); expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'moonpay', country: 'FRA', }); }); @@ -306,6 +312,8 @@ describe('KycController', () => { await controller.acceptTermsAndStartSession({ email: 'a@b.co', product: 'ramps', + sumsubTncSigned: true, + idosTncSigned: true, }); expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); @@ -316,6 +324,55 @@ describe('KycController', () => { ); }); + it('fails when T&C2 flags are omitted', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + // @ts-expect-error T&C2 flags are required + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(handlers.createSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('persists required T&C2 flags on a MoonPay session', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); + expect(controller.state.termsAcceptedVendor).toBe('moonpay'); + expect(controller.state.sumsubTncAccepted).toBe(true); + expect(controller.state.idosTncAccepted).toBe(true); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + it('clears stale auth tokens when a new session is created', async () => { await withController( { @@ -350,7 +407,10 @@ describe('KycController', () => { ); // Creating a new session must invalidate the carried-over auth. - await controller.acceptTermsAndStartSession(); + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.accessToken).toBeNull(); expect(controller.buildAuthFrameUrl()).toBeNull(); @@ -382,7 +442,10 @@ describe('KycController', () => { }), ); - const pending = controller.acceptTermsAndStartSession(); + const pending = controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); // 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. @@ -416,7 +479,10 @@ describe('KycController', () => { handlers.createSession.mockRejectedValue(new Error('nope')); handlers.fetchDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession(); + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('terms'); expect(controller.state.termsAcceptedAt).toBeNull(); @@ -450,7 +516,10 @@ describe('KycController', () => { }), ); - const pending = controller.acceptTermsAndStartSession(); + const pending = controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); // Reset while the create request is in flight, then let it fail. The // superseded flow must not force the now-idle controller back to @@ -481,7 +550,11 @@ describe('KycController', () => { handlers.createSession.mockRejectedValue(new Error('nope')); handlers.fetchDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ product: 'ramps' }); + await controller.acceptTermsAndStartSession({ + product: 'ramps', + sumsubTncSigned: true, + idosTncSigned: true, + }); // The failed flow must not leave a lingering product behind that a // later product-less `acceptTermsAndStartSession` would auto-run. @@ -499,7 +572,10 @@ describe('KycController', () => { }, }, async ({ controller }) => { - await controller.acceptTermsAndStartSession(); + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing email/u); @@ -509,7 +585,11 @@ describe('KycController', () => { it('fails when no disclaimers were accepted', async () => { await withController(async ({ controller }) => { - await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing terms acceptance/u); @@ -965,6 +1045,7 @@ describe('KycController', () => { // session (reaching phase `check`) for the second completion. termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'], + termsAcceptedVendor: 'moonpay', }, }, }, @@ -1283,7 +1364,7 @@ describe('KycController', () => { ); }); - it('drops a MoonPay id when an Iron customer is created', async () => { + it('drops a MoonPay id when a non-MoonPay customer is created', async () => { await withController( { options: { @@ -1291,13 +1372,40 @@ describe('KycController', () => { }, }, async ({ controller }) => { - await controller.createIronCustomer({ email: 'a@b.co' }); + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); expect(controller.state.moonpayCustomerId).toBeNull(); expect(controller.getCustomerIdentity()).toBeNull(); }, ); }); + + it('keeps a MoonPay id when createVendorCustomer stays on MoonPay', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller, handlers }) => { + handlers.createVendorCustomer.mockResolvedValue({ + id: 'mp-1', + email: 'a@b.co', + status: 'active', + }); + + await controller.createVendorCustomer({ + vendor: 'moonpay', + email: 'a@b.co', + }); + + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); }); describe('startSumSub', () => { @@ -1863,7 +1971,7 @@ describe('KycController', () => { it('creates an Iron customer and loads Iron disclaimers on initialize', async () => { await withController(async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); - handlers.fetchIronDisclaimers.mockResolvedValue([ + handlers.fetchDisclaimers.mockResolvedValue([ { id: 'd1', display_name: 'Iron T&C', url: 'https://t' }, ]); @@ -1873,13 +1981,14 @@ describe('KycController', () => { product: 'money', }); - expect(handlers.createIronCustomer).toHaveBeenCalledWith({ + expect(handlers.createVendorCustomer).toHaveBeenCalledWith({ + vendor: 'iron', email: 'a@b.co', }); - expect(handlers.fetchIronDisclaimers).toHaveBeenCalledWith({ + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', country: 'USA', }); - expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); expect(handlers.createSession).not.toHaveBeenCalled(); expect(controller.state.activeVendor).toBe('iron'); expect(controller.state.activeProduct).toBe('money'); @@ -1890,13 +1999,13 @@ describe('KycController', () => { it('fails initialize when Iron customer creation fails', async () => { await withController(async ({ controller, handlers }) => { - handlers.createIronCustomer.mockRejectedValue(new Error('iron down')); + handlers.createVendorCustomer.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, + /Vendor customer creation failed/u, ); }); }); @@ -1910,7 +2019,7 @@ describe('KycController', () => { }) => void = () => { // placeholder }; - handlers.createIronCustomer.mockReturnValue( + handlers.createVendorCustomer.mockReturnValue( new Promise((resolve) => { release = resolve; }), @@ -1934,7 +2043,7 @@ describe('KycController', () => { let release: (error: Error) => void = () => { // placeholder }; - handlers.createIronCustomer.mockReturnValue( + handlers.createVendorCustomer.mockReturnValue( new Promise((_resolve, reject) => { release = reject; }), @@ -1960,6 +2069,9 @@ describe('KycController', () => { state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['d1'], + termsAcceptedVendor: 'iron', + sumsubTncAccepted: true, + idosTncAccepted: true, }, userStatusPollIntervalMs: 60_000, }, @@ -1985,11 +2097,177 @@ describe('KycController', () => { ); }); - it('createIronCustomer sets the vendor and fails on API errors', async () => { + it('does not reuse MoonPay terms acceptance for a consents-path vendor', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([ + { id: 'iron-d1', display_name: 'T', url: 'u' }, + ]); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(handlers.submitConsents).not.toHaveBeenCalled(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.termsAcceptedVendor).toBeNull(); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + vendor: 'iron', + country: 'USA', + }); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('requires reacceptance when T&C2 flags are null (pre-migration state)', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['d1'], + termsAcceptedVendor: 'iron', + // T&C2 flags are null, simulating pre-migration state + sumsubTncAccepted: null, + idosTncAccepted: null, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'T', url: 'u' }, + ]); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + // T&C2 flags were null; reacceptance required. + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.sumsubTncAccepted).toBeNull(); + expect(controller.state.idosTncAccepted).toBeNull(); + }, + ); + }); + + it('does not reuse consents-path terms acceptance for MoonPay', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['iron-d1'], + termsAcceptedVendor: 'iron', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ email: 'a@b.co', vendor: 'moonpay' }); + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('drops another vendor terms acceptance when createVendorCustomer switches vendor', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['moonpay-d1'], + termsAcceptedVendor: 'moonpay', + }, + }, + }, + async ({ controller }) => { + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + expect(controller.state.termsAcceptedVendor).toBeNull(); + }, + ); + }); + + it('keeps terms acceptance when createVendorCustomer stays on the same vendor', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['iron-d1'], + termsAcceptedVendor: 'iron', + }, + }, + }, + async ({ controller }) => { + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([ + 'iron-d1', + ]); + expect(controller.state.termsAcceptedVendor).toBe('iron'); + }, + ); + }); + + it('stamps the active vendor onto the terms acceptance', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(controller.state.termsAcceptedVendor).toBe('iron'); + expect(controller.state.sumsubTncAccepted).toBe(true); + expect(controller.state.idosTncAccepted).toBe(true); + controller.reset(); + }, + ); + }); + + it('createVendorCustomer sets the vendor and fails on API errors', async () => { await withController(async ({ controller, handlers }) => { - handlers.createIronCustomer.mockRejectedValue(new Error('nope')); + handlers.createVendorCustomer.mockRejectedValue(new Error('nope')); - await controller.createIronCustomer({ email: 'a@b.co' }); + await controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); expect(controller.state.activeVendor).toBe('iron'); expect(controller.state.email).toBe('a@b.co'); @@ -1997,18 +2275,21 @@ describe('KycController', () => { }); }); - it('createIronCustomer ignores API errors after reset', async () => { + it('createVendorCustomer ignores API errors after reset', async () => { await withController(async ({ controller, handlers }) => { let release: (error: Error) => void = () => { // placeholder }; - handlers.createIronCustomer.mockReturnValue( + handlers.createVendorCustomer.mockReturnValue( new Promise((_resolve, reject) => { release = reject; }), ); - const pending = controller.createIronCustomer({ email: 'a@b.co' }); + const pending = controller.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }); controller.reset(); release(new Error('late')); await pending; @@ -2046,12 +2327,12 @@ describe('KycController', () => { expect(handlers.createSession).not.toHaveBeenCalled(); expect(handlers.submitConsents).toHaveBeenCalledWith({ - ironDisclaimerIds: ['d1'], + disclaimerIds: ['d1'], sumsubTncSigned: true, idosTncSigned: true, }); expect(handlers.createUkycSession).toHaveBeenCalledWith( - expect.objectContaining({ vendorId: 'iron' }), + expect.objectContaining({ vendor: 'iron' }), ); expect(launcher.launch).toHaveBeenCalled(); expect(controller.buildCheckFrameUrl()).toBeNull(); @@ -2064,6 +2345,91 @@ describe('KycController', () => { ); }); + it('fails the consents path when T&C2 flags are omitted', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + // @ts-expect-error T&C2 flags are required + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(handlers.submitConsents).not.toHaveBeenCalled(); + }, + ); + }); + + it('fails the consents path when only one T&C2 flag is provided', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + // @ts-expect-error both T&C2 flags are required + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing T&C2 acceptance/u); + expect(handlers.submitConsents).not.toHaveBeenCalled(); + }, + ); + }); + + it('submits explicit T&C2 false flags on the consents path', 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 }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: false, + idosTncSigned: false, + }); + + expect(handlers.submitConsents).toHaveBeenCalledWith({ + disclaimerIds: ['d1'], + sumsubTncSigned: false, + idosTncSigned: false, + }); + expect(controller.state.sumsubTncAccepted).toBe(false); + expect(controller.state.idosTncAccepted).toBe(false); + controller.reset(); + }, + ); + }); + it('fails the Iron session when email is missing', async () => { await withController( { @@ -2075,7 +2441,10 @@ describe('KycController', () => { }, }, async ({ controller }) => { - await controller.acceptTermsAndStartSession(); + await controller.acceptTermsAndStartSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('error'); expect(controller.state.error).toMatch(/Missing email/u); @@ -2095,10 +2464,16 @@ describe('KycController', () => { }, }, async ({ controller }) => { - await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('error'); - expect(controller.state.error).toMatch(/Missing Iron disclaimer/u); + expect(controller.state.error).toMatch( + /Missing disclaimer acceptance/u, + ); }, ); }); @@ -2117,13 +2492,17 @@ describe('KycController', () => { handlers.createUkycSession.mockRejectedValue( new Error('sumsub down'), ); - handlers.fetchIronDisclaimers.mockResolvedValue([]); + handlers.fetchDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('terms'); expect(controller.state.termsAcceptedAt).toBeNull(); - expect(controller.state.error).toMatch(/Iron session failed/u); + expect(controller.state.error).toMatch(/Consents session failed/u); }, ); }); @@ -2146,7 +2525,11 @@ describe('KycController', () => { }); handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); - await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('done'); expect(controller.state.sumsub.status).toBe('complete'); @@ -2177,6 +2560,8 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, }); controller.reset(); release(); @@ -2211,6 +2596,8 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, }); // Consents + UKYC session run first; wait until launch is pending. await Promise.resolve(); @@ -2247,6 +2634,8 @@ describe('KycController', () => { const pending = controller.acceptTermsAndStartSession({ email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, }); controller.reset(); release(new Error('late consent failure')); @@ -2319,6 +2708,60 @@ describe('KycController', () => { } }); + it('does not start a second poll loop when refreshed during an in-flight tick', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let releaseTick: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + // Initial refresh starts the loop. + .mockResolvedValueOnce({ status: 'pending' }) + // The first scheduled tick hangs, so the timer handle is null + // while the request is in flight. + .mockImplementationOnce( + async () => + new Promise((resolve) => { + releaseTick = resolve; + }), + ) + // Any later poll stays pending so the loop keeps scheduling. + .mockResolvedValue({ status: 'pending' }); + + await controller.refreshKycStatus(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(1); + + // Fire the scheduled tick; it clears the timer handle then awaits. + jest.advanceTimersByTime(1000); + await Promise.resolve(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(2); + + // A concurrent refresh while the tick is in flight (timer handle + // null) must not spin up a second loop on the same token. + await controller.refreshKycStatus(); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(3); + + // Let the in-flight tick resolve and reschedule. + releaseTick({ status: 'pending' }); + await Promise.resolve(); + await Promise.resolve(); + + // A single loop means exactly one fetch per interval; a duplicated + // loop would fire twice here. + await jest.advanceTimersByTimeAsync(1000); + expect(handlers.fetchKycStatus).toHaveBeenCalledTimes(4); + + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + it('drops superseded user-status poll ticks after reset', async () => { jest.useFakeTimers(); try { @@ -2441,6 +2884,44 @@ describe('KycController', () => { ); }); + it('does not restart polling when reset lands during refresh', async () => { + jest.useFakeTimers(); + try { + await withController( + { + options: { + state: { userStatus: 'pending' }, + userStatusPollIntervalMs: 1000, + }, + }, + async ({ controller, handlers, rootMessenger }) => { + const listener = jest.fn(); + rootMessenger.subscribe('KycController:statusChanged', listener); + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'pending' }); + await pending; + handlers.fetchKycStatus.mockClear(); + await jest.advanceTimersByTimeAsync(3000); + + expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + it('defaults superseded refresh status to not-started when unset', async () => { await withController( { options: { userStatusPollIntervalMs: 60_000 } }, @@ -2532,7 +3013,11 @@ describe('KycController', () => { ); handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); - await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + sumsubTncSigned: true, + idosTncSigned: true, + }); expect(controller.state.phase).toBe('done'); expect(controller.state.userStatus).toBe('completed'); @@ -2564,9 +3049,7 @@ type ServiceHandlers = { fetchDisclaimers: jest.Mock; createSession: jest.Mock; checkKycRequired: jest.Mock; - createIronCustomer: jest.Mock; - fetchIronDisclaimers: jest.Mock; - checkIronKycRequired: jest.Mock; + createVendorCustomer: jest.Mock; submitConsents: jest.Mock; fetchKycStatus: jest.Mock; getWrappingKey: jest.Mock; @@ -2599,9 +3082,7 @@ const SERVICE_ACTIONS = [ 'KycService:fetchDisclaimers', 'KycService:createSession', 'KycService:checkKycRequired', - 'KycService:createIronCustomer', - 'KycService:fetchIronDisclaimers', - 'KycService:checkIronKycRequired', + 'KycService:createVendorCustomer', 'KycService:submitConsents', 'KycService:fetchKycStatus', 'KycService:getWrappingKey', @@ -2669,13 +3150,11 @@ function withController( fetchDisclaimers: jest.fn().mockResolvedValue([]), createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), - createIronCustomer: jest.fn().mockResolvedValue({ + createVendorCustomer: 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({ @@ -2713,16 +3192,8 @@ function withController( handlers.checkKycRequired, ); rootMessenger.registerActionHandler( - 'KycService:createIronCustomer', - handlers.createIronCustomer, - ); - rootMessenger.registerActionHandler( - 'KycService:fetchIronDisclaimers', - handlers.fetchIronDisclaimers, - ); - rootMessenger.registerActionHandler( - 'KycService:checkIronKycRequired', - handlers.checkIronKycRequired, + 'KycService:createVendorCustomer', + handlers.createVendorCustomer, ); rootMessenger.registerActionHandler( 'KycService:submitConsents', diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 6a4d98520c..a07948bf8e 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -17,6 +17,7 @@ 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 { CreateUkycSessionParams } from './KycService.js'; import type { KycCustomerIdentity, KycDisclaimer, @@ -141,6 +142,26 @@ export type KycControllerState = { termsAcceptedAt: string | null; /** IDs of the disclaimers the customer accepted (persisted). */ acceptedDisclaimerIds: string[]; + /** + * The vendor whose disclaimers `acceptedDisclaimerIds` belong to (persisted). + * Each vendor serves its own disclaimer set, so acceptance recorded for one + * vendor must not be reused for another. `null` when nothing is accepted. + */ + termsAcceptedVendor: KycVendor | null; + /** + * Whether the customer accepted the SumSub T&C (T&C2) during the last + * terms acceptance (persisted). Consents-path vendors require this flag + * when resuming a session. `null` for acceptance recorded before this + * field existed (treated as requiring reacceptance). + */ + sumsubTncAccepted: boolean | null; + /** + * Whether the customer accepted the idOS T&C (T&C2) during the last + * terms acceptance (persisted). Consents-path vendors require this flag + * when resuming a session. `null` for acceptance recorded before this + * field existed (treated as requiring reacceptance). + */ + idosTncAccepted: boolean | null; /** Disclaimers fetched for the current country. */ disclaimers: KycDisclaimer[]; @@ -160,7 +181,7 @@ export type KycControllerState = { /** * 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. + * the Check/Auth frame path. Non-MoonPay vendors skip those frames. */ activeVendor: KycVendor; @@ -240,6 +261,24 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, + termsAcceptedVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + sumsubTncAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + idosTncAccepted: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, disclaimers: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -339,6 +378,9 @@ export function getDefaultKycControllerState(): KycControllerState { email: null, termsAcceptedAt: null, acceptedDisclaimerIds: [], + termsAcceptedVendor: null, + sumsubTncAccepted: null, + idosTncAccepted: null, disclaimers: [], disclaimersError: null, geoCountry: null, @@ -362,13 +404,37 @@ export function getDefaultKycControllerState(): KycControllerState { }; } +/** + * Whether an error indicates the applicant already finished KYC — the UKYC / + * relay `session_not_in_valid_state` signal — which the controller maps to the + * simplified `completed` user status. + * + * @param error - The caught error. + * @returns `true` when the error carries the `session_not_in_valid_state` + * marker. + */ +function isSessionAlreadyCompletedError(error: unknown): boolean { + return String(error).includes(SESSION_NOT_IN_VALID_STATE); +} + +/** + * Vendors other than MoonPay skip Check/Auth frames and use the empty-shell + * customer + consents path instead. + * + * @param vendor - The identity vendor for the current flow. + * @returns `true` when the vendor uses the consents session path. + */ +function usesConsentsFlow(vendor: KycVendor): boolean { + return vendor !== 'moonpay'; +} + // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ 'initialize', 'loadDisclaimers', 'acceptTermsAndStartSession', - 'createIronCustomer', + 'createVendorCustomer', 'clearSavedTerms', 'handleFrameMessage', 'buildCheckFrameUrl', @@ -522,6 +588,15 @@ export class KycController extends BaseController< /** Handle for the scheduled next user-status poll, or `null`. */ #userStatusPollTimer: ReturnType | null = null; + /** + * Whether a user-status poll loop is currently active. Tracked separately + * from {@link #userStatusPollTimer} because a scheduled tick clears the timer + * handle before awaiting `fetchKycStatus`; relying on the handle alone would + * let a concurrent {@link refreshKycStatus} start a second loop on the same + * token during that in-flight window. + */ + #userStatusPolling = false; + /** Monotonic token for the user-status poll loop (see `#pollToken`). */ #userStatusPollToken = 0; @@ -605,8 +680,8 @@ export class KycController extends BaseController< * 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`. + * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors + * skip Check/Auth frames and use the consents path. Defaults to `moonpay`. */ async initialize(params?: { email?: string; @@ -632,6 +707,12 @@ export class KycController extends BaseController< if (params?.email) { state.email = params.email; } + // Terms acceptance is vendor-scoped: reusing another vendor's saved + // acceptance would skip loading this vendor's disclaimers and submit its + // ids to the wrong vendor. + if (!this.#hasTermsForVendor(vendor)) { + this.#clearAcceptedTerms(state); + } 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 @@ -657,10 +738,10 @@ export class KycController extends BaseController< // 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) { + if (usesConsentsFlow(vendor) && this.state.email) { try { - await this.messenger.call('KycService:createIronCustomer', { + await this.messenger.call('KycService:createVendorCustomer', { + vendor, email: this.state.email, }); if (this.#generation !== generation) { @@ -670,7 +751,7 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - this.#fail(`Iron customer creation failed: ${String(error)}`); + this.#fail(`Vendor customer creation failed: ${String(error)}`); return; } } @@ -680,10 +761,22 @@ export class KycController extends BaseController< this.state.acceptedDisclaimerIds.length > 0; if (hasTerms && this.state.email) { - if (vendor === 'iron') { - await this.#startIronSession({ - sumsubTncSigned: true, - idosTncSigned: true, + if (usesConsentsFlow(vendor)) { + // Consents-path vendors require T&C2 flags; if they weren't persisted + // (i.e. null from pre-migration state), require reacceptance. + const sumsubTncSigned = this.state.sumsubTncAccepted; + const idosTncSigned = this.state.idosTncAccepted; + if (sumsubTncSigned === null || idosTncSigned === null) { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + return; + } + await this.#startConsentsSession({ + sumsubTncSigned, + idosTncSigned, }); } else { await this.#createSession(); @@ -698,31 +791,42 @@ export class KycController extends BaseController< } /** - * 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}. + * Creates (or resumes) an empty-shell customer for the given identity + * vendor. Exposed so a consumer 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. + * @param params.vendor - Identity vendor for the customer. + * @param params.email - Email for the vendor customer. */ - async createIronCustomer(params: { email: string }): Promise { + async createVendorCustomer(params: { + vendor: KycVendor; + 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; + // See `initialize`: acceptance recorded for another vendor cannot carry + // over, and a MoonPay-issued customer id must not survive a switch to + // another vendor or `getCustomerIdentity` reports the wrong vendor. + if (!this.#hasTermsForVendor(params.vendor)) { + this.#clearAcceptedTerms(state); + } + state.activeVendor = params.vendor; + if (params.vendor !== 'moonpay') { + state.moonpayCustomerId = null; + } }); const generation = this.#generation; try { - await this.messenger.call('KycService:createIronCustomer', { + await this.messenger.call('KycService:createVendorCustomer', { + vendor: params.vendor, email: params.email, }); } catch (error) { if (this.#generation !== generation) { return; } - this.#fail(`Iron customer creation failed: ${String(error)}`); + this.#fail(`Vendor customer creation failed: ${String(error)}`); } } @@ -747,14 +851,13 @@ export class KycController extends BaseController< state.geoCountry = country; }); } - const disclaimers = - this.state.activeVendor === 'iron' - ? await this.messenger.call('KycService:fetchIronDisclaimers', { - country, - }) - : await this.messenger.call('KycService:fetchDisclaimers', { - country, - }); + const disclaimers = await this.messenger.call( + 'KycService:fetchDisclaimers', + { + vendor: this.state.activeVendor, + country, + }, + ); this.#updateIfCurrent(generation, (state) => { state.disclaimers = disclaimers; state.disclaimersError = null; @@ -770,65 +873,75 @@ export class KycController extends BaseController< * Captures terms acceptance for the currently loaded disclaimers and creates * a session. * - * @param params - Optional parameters. + * @param params - The 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. + * @param params.sumsubTncSigned - Whether Sumsub T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. + * @param params.idosTncSigned - Whether idOS T&C were accepted (T&C2). + * Required for every vendor so callers explicitly declare acceptance. */ - async acceptTermsAndStartSession(params?: { + async acceptTermsAndStartSession(params: { email?: string; product?: KycProduct; - sumsubTncSigned?: boolean; - idosTncSigned?: boolean; + sumsubTncSigned: boolean; + idosTncSigned: boolean; }): Promise { + const sumsubTncSigned = params?.sumsubTncSigned; + const idosTncSigned = params?.idosTncSigned; + if ( + typeof sumsubTncSigned !== 'boolean' || + typeof idosTncSigned !== 'boolean' + ) { + this.#fail('Missing T&C2 acceptance flags.'); + return; + } + const termsAcceptedAt = new Date().toISOString(); const disclaimerIds = this.state.disclaimers.map( (disclaimer) => disclaimer.id, ); this.#applyUpdate((state) => { - if (params?.email) { + if (params.email) { state.email = params.email; } - if (params?.product) { + if (params.product) { state.activeProduct = params.product; } state.termsAcceptedAt = termsAcceptedAt; state.acceptedDisclaimerIds = disclaimerIds; + state.termsAcceptedVendor = state.activeVendor; + state.sumsubTncAccepted = sumsubTncSigned; + state.idosTncAccepted = idosTncSigned; }); - if (this.state.activeVendor === 'iron') { - await this.#startIronSession({ - sumsubTncSigned: params?.sumsubTncSigned ?? true, - idosTncSigned: params?.idosTncSigned ?? true, - }); + if (usesConsentsFlow(this.state.activeVendor)) { + await this.#startConsentsSession({ sumsubTncSigned, idosTncSigned }); return; } await this.#createSession(); } /** - * Iron-only path: post consents (Iron signings + Sumsub/idOS ack), then + * Consents-path vendors: post vendor 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: { + async #startConsentsSession(consents: { sumsubTncSigned: boolean; idosTncSigned: boolean; }): Promise { const { email, acceptedDisclaimerIds } = this.state; if (!email) { - this.#fail('Missing email for Iron session.'); + this.#fail('Missing email for consents session.'); return; } if (acceptedDisclaimerIds.length === 0) { - this.#fail('Missing Iron disclaimer acceptance.'); + this.#fail('Missing disclaimer acceptance.'); return; } @@ -837,14 +950,14 @@ export class KycController extends BaseController< state.error = null; state.phase = 'session'; state.statusMessage = 'Submitting consents...'; - // Iron has no MoonPay session/access tokens. + // Consents-path vendors have no MoonPay session/access tokens. state.sessionToken = null; state.accessToken = null; }); try { await this.messenger.call('KycService:submitConsents', { - ironDisclaimerIds: acceptedDisclaimerIds, + disclaimerIds: acceptedDisclaimerIds, sumsubTncSigned: consents.sumsubTncSigned, idosTncSigned: consents.idosTncSigned, }); @@ -878,14 +991,14 @@ export class KycController extends BaseController< } }); } catch (error) { - console.error('Iron session failed:', error); + console.error('Consents 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.error = `Consents session failed: ${String(error)}`; state.statusMessage = 'Consent / verification failed — accept the terms to try again.'; state.phase = 'terms'; @@ -985,6 +1098,25 @@ export class KycController extends BaseController< #clearAcceptedTerms(state: KycControllerState): void { state.termsAcceptedAt = null; state.acceptedDisclaimerIds = []; + state.termsAcceptedVendor = null; + state.sumsubTncAccepted = null; + state.idosTncAccepted = null; + } + + /** + * Determines whether the stored terms acceptance belongs to the given + * vendor. Acceptance persisted before `termsAcceptedVendor` existed + * (indicated by `null`) is invalidated to force reacceptance, ensuring users + * re-review vendor terms after the multi-vendor upgrade. + * + * @param vendor - The vendor about to drive the flow. + * @returns `true` when the stored acceptance can be reused for `vendor`. + */ + #hasTermsForVendor(vendor: KycVendor): boolean { + if (this.state.termsAcceptedVendor === null) { + return false; + } + return this.state.termsAcceptedVendor === vendor; } /** @@ -1319,6 +1451,33 @@ export class KycController extends BaseController< return { vendor: activeVendor, id: moonpayCustomerId }; } + /** + * Builds the vendor-specific fields spread into a + * `KycService:createUkycSession` call, derived from the active vendor and the + * currently captured auth state. + * + * MoonPay sessions must carry the access token and customer id in + * `vendorMetadata`; other vendors carry no vendor metadata. + * + * @returns The vendor-specific subset of the `createUkycSession` params. + */ + #buildUkycSessionVendorFields(): Pick< + CreateUkycSessionParams, + 'vendor' | 'vendorMetadata' + > { + if (this.state.activeVendor === 'moonpay') { + return { + vendor: 'moonpay', + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + }; + } + + return { vendor: this.state.activeVendor }; + } + /** * Runs the SumSub document-verification sub-flow end to end: * @@ -1424,20 +1583,11 @@ export class KycController extends BaseController< 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, - }, - }), + ...this.#buildUkycSessionVendorFields(), wrappedEncryptionKey, ukycCapabilityToken, }, @@ -1545,7 +1695,7 @@ export class KycController extends BaseController< return result; } catch (error) { // Applicant already finished KYC — treat as completed for Money toast. - if (String(error).includes(SESSION_NOT_IN_VALID_STATE)) { + if (isSessionAlreadyCompletedError(error)) { // 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. @@ -1587,7 +1737,15 @@ export class KycController extends BaseController< sumsubSessionId: string | null; errorCode: string | null; }> { + const generation = this.#generation; const payload = await this.#fetchAndApplyUserStatus(); + // A `reset()` landing while the request was in flight already stopped + // polling and left the flow idle, and the payload above is the pre-reset + // cached status. Starting a loop from it would poll — and publish + // `statusChanged` — on a torn-down flow. + if (this.#generation !== generation) { + return payload; + } if (payload.status === 'pending') { this.#ensureUserStatusPolling(); } else { @@ -1655,9 +1813,10 @@ export class KycController extends BaseController< * still `pending`. */ #ensureUserStatusPolling(): void { - if (this.#userStatusPollTimer !== null) { + if (this.#userStatusPolling) { return; } + this.#userStatusPolling = true; const token = this.#userStatusPollToken; const tick = async (): Promise => { try { @@ -1702,6 +1861,7 @@ export class KycController extends BaseController< */ #stopUserStatusPolling(): void { this.#userStatusPollToken += 1; + this.#userStatusPolling = false; if (this.#userStatusPollTimer !== null) { clearTimeout(this.#userStatusPollTimer); this.#userStatusPollTimer = null; diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 7f38f86aa1..08f641805c 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -22,6 +22,7 @@ export type KycServiceGetGeoCountryAction = { * created. * * @param params - The parameters. + * @param params.vendor - Identity vendor. Defaults to `moonpay`. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ @@ -42,7 +43,7 @@ export type KycServiceCreateSessionAction = { }; /** - * Checks whether KYC is required for the given access token, country, and + * Checks whether KYC is required for the given vendor, country, and * capabilities. * * @param params - The check parameters. @@ -54,45 +55,23 @@ export type KycServiceCheckKycRequiredAction = { }; /** - * 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. + * Creates (or resumes) an empty-shell customer for the authenticated + * canonical user on the given identity vendor. Must run before showing + * vendor T&C so the customer exists and resume logic can key off vendor + * status. * * @param params - The parameters. - * @param params.email - Email associated with the Iron customer. - * @returns The Iron customer record (subset validated for controller use). + * @param params.vendor - Identity vendor (e.g. `iron` for Money/VBA). + * @param params.email - Email associated with the customer. + * @returns The vendor customer record (subset validated for controller use). */ -export type KycServiceCreateIronCustomerAction = { - type: `KycService:createIronCustomer`; - handler: KycService['createIronCustomer']; +export type KycServiceCreateVendorCustomerAction = { + type: `KycService:createVendorCustomer`; + handler: KycService['createVendorCustomer']; }; /** - * 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 + * Posts T&C1 (vendor 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. @@ -193,9 +172,7 @@ export type KycServiceMethodActions = | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction - | KycServiceCreateIronCustomerAction - | KycServiceFetchIronDisclaimersAction - | KycServiceCheckIronKycRequiredAction + | KycServiceCreateVendorCustomerAction | KycServiceSubmitConsentsAction | KycServiceFetchKycStatusAction | KycServiceGetWrappingKeyAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index c7d5d6c9db..96dc002fb9 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -23,6 +23,52 @@ describe('KycService', () => { cleanAll(); }); + describe('constructor', () => { + it('falls back to the native fetch when no fetch is injected', 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({ omitFetch: true }); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws when fetch is not globally available and not provided', () => { + const savedFetch = globalThis.fetch; + try { + // @ts-expect-error - deliberately removing fetch for test + delete globalThis.fetch; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: KycServiceMessenger = new Messenger({ + namespace: 'KycService', + parent: rootMessenger, + }); + + expect( + () => + new KycService({ + messenger: + messenger as unknown as MockAnyNamespace, + baseUrl: MOCK_API_URL, + }), + ).toThrow( + 'fetch is not available globally and was not provided in options', + ); + } finally { + globalThis.fetch = savedFetch; + } + }); + }); + describe('getGeoCountry', () => { it('maps the geolocation to an ISO alpha-3 country code', async () => { const { service } = getService({ geolocation: 'US-NY' }); @@ -479,7 +525,7 @@ describe('KycService', () => { }); }); - describe('createIronCustomer', () => { + describe('createVendorCustomer', () => { it('creates an Iron customer and returns the validated subset', async () => { nock(MOCK_API_URL) .post('/vendors/iron/customers', { email: 'a@b.co' }) @@ -498,7 +544,7 @@ describe('KycService', () => { const { service } = getService(); expect( - await service.createIronCustomer({ email: 'a@b.co' }), + await service.createVendorCustomer({ vendor: 'iron', email: 'a@b.co' }), ).toMatchObject({ id: 'iron-1', email: 'a@b.co', @@ -511,12 +557,17 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.createIronCustomer({ email: 'a@b.co' }), - ).rejects.toThrow(/Malformed response received from iron customers API/u); + service.createVendorCustomer({ + vendor: 'iron', + email: 'a@b.co', + }), + ).rejects.toThrow( + /Malformed response received from vendor customers API/u, + ); }); }); - describe('fetchIronDisclaimers', () => { + describe('fetchDisclaimers for a non-MoonPay vendor', () => { it('returns Iron disclaimers for a country', async () => { const disclaimers = [ { id: '1', display_name: 'Iron Terms', url: 'https://t' }, @@ -528,7 +579,7 @@ describe('KycService', () => { const { service } = getService(); expect( - await service.fetchIronDisclaimers({ country: 'USA' }), + await service.fetchDisclaimers({ vendor: 'iron', country: 'USA' }), ).toStrictEqual(disclaimers); }); @@ -540,32 +591,46 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchIronDisclaimers({ country: 'USA' }), - ).rejects.toThrow( - /Malformed response received from iron disclaimers API/u, - ); + service.fetchDisclaimers({ vendor: 'iron', country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); }); }); - describe('checkIronKycRequired', () => { + describe('checkKycRequired for a non-MoonPay vendor', () => { 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({ + expect(await service.checkKycRequired({ vendor: 'iron' })).toStrictEqual({ kycRequired: true, }); }); + it('throws when accessToken is missing for MoonPay vendor', async () => { + const { service } = getService(); + + await expect( + service.checkKycRequired({ vendor: 'moonpay', country: 'USA' }), + ).rejects.toThrow('accessToken is required for vendor "moonpay"'); + }); + + it('throws when country is missing for MoonPay vendor', async () => { + const { service } = getService(); + + await expect( + service.checkKycRequired({ vendor: 'moonpay', accessToken: 'tok' }), + ).rejects.toThrow('country is required for vendor "moonpay"'); + }); + 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, - ); + await expect( + service.checkKycRequired({ vendor: 'iron' }), + ).rejects.toThrow(/Malformed response received from kyc-required API/u); }); }); @@ -583,7 +648,7 @@ describe('KycService', () => { expect( await service.submitConsents({ - ironDisclaimerIds: ['d1'], + disclaimerIds: ['d1'], sumsubTncSigned: true, idosTncSigned: true, }), @@ -596,7 +661,7 @@ describe('KycService', () => { await expect( service.submitConsents({ - ironDisclaimerIds: ['d1'], + disclaimerIds: ['d1'], sumsubTncSigned: true, idosTncSigned: true, }), @@ -662,7 +727,7 @@ describe('KycService', () => { ).toStrictEqual({ sessionId: 'sid' }); }); - it('sends vendorId iron with empty vendorMetadata when omitted', async () => { + it('sends vendor iron with empty vendorMetadata when omitted', async () => { const material = deriveClientMaterial( new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1), ); @@ -684,7 +749,7 @@ describe('KycService', () => { expect( await service.createUkycSession({ jwtToken: 'jwt', - vendorId: 'iron', + vendor: 'iron', wrappedEncryptionKey: { sessionId: 'wk', encryptedKey: 'ek', @@ -753,6 +818,8 @@ type RootMessenger = Messenger< * @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. + * @param args.omitFetch - When true, omit the `fetch` option so the service + * falls back to the runtime's native `fetch`. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -763,12 +830,16 @@ function getService({ // `null` means "omit the option entirely" (exercises the constructor's // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, + // When true, omit the `fetch` option so the service falls back to the + // runtime's native `fetch` (which nock intercepts). + omitFetch = false, }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; fractalEncryptionBaseUrl?: string | null; + omitFetch?: boolean; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -799,7 +870,7 @@ function getService({ ); const service = new KycService({ - fetch, + ...(omitFetch ? {} : { fetch }), messenger, baseUrl, ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 89201eb63a..7c8e38f141 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -50,9 +50,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchDisclaimers', 'createSession', 'checkKycRequired', - 'createIronCustomer', - 'fetchIronDisclaimers', - 'checkIronKycRequired', + 'createVendorCustomer', 'submitConsents', 'fetchKycStatus', 'getWrappingKey', @@ -122,7 +120,12 @@ export type KycServiceMessenger = Messenger< */ export type KycServiceOptions = { messenger: KycServiceMessenger; - fetch: typeof fetch; + /** + * A function used to make HTTP requests. Defaults to the runtime's native + * `fetch`, so consumers do not need to inject one on platforms where `fetch` + * is available globally (browser, React Native, Node 18+). + */ + fetch?: typeof fetch; /** * Mandatory value that sets the base url to KYC api */ @@ -211,14 +214,14 @@ const SessionStatusResponseStruct = type({ vendorStatus: string(), }); -// Iron customer subset — `type` (not `object`) keeps extra Iron fields from +// Vendor customer subset — `type` (not `object`) keeps extra vendor fields from // failing validation while still requiring the fields the controller needs. -const IronCustomerResponseStruct = type({ +const VendorCustomerResponseStruct = type({ id: string(), email: string(), status: string(), }); -export type IronCustomerResponse = Infer; +export type VendorCustomerResponse = Infer; const KYC_USER_STATUSES = [ 'not-started', @@ -243,17 +246,33 @@ export type CreateSessionParams = { }; export type CheckKycRequiredParams = { - accessToken: string; - country: string; + /** + * Identity vendor to check. Defaults to `moonpay` for the existing + * Check/Auth path. + */ + vendor?: KycVendor; + /** + * MoonPay access token. Required when `vendor` is `moonpay` (or omitted). + */ + accessToken?: string; + /** + * ISO 3166-1 alpha-3 country code. Required when `vendor` is `moonpay`. + */ + country?: string; capabilities?: { product: string }[]; }; -export type CreateIronCustomerParams = { +export type CreateVendorCustomerParams = { + vendor: KycVendor; email: string; }; export type SubmitConsentsParams = { - ironDisclaimerIds: string[]; + /** + * Vendor disclaimer ids the customer accepted (T&C1). Mapped to the UKYC + * wire field `ironDisclaimerIds` for the Money/VBA consents contract. + */ + disclaimerIds: string[]; sumsubTncSigned: boolean; idosTncSigned: boolean; kycLevel?: 'standard'; @@ -278,13 +297,13 @@ 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). + * existing Check/Auth flow. Pass a non-MoonPay vendor (e.g. `iron`) for + * the consents path (no MoonPay metadata required). */ - vendorId?: KycVendor; + vendor?: KycVendor; /** * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` / - * `moonPayUserId`); optional / omitted for Iron. + * `moonPayUserId`); optional / omitted for other vendors. */ vendorMetadata?: Record; wrappedEncryptionKey: WrappedEncryptionKey; @@ -308,8 +327,9 @@ export type GetSessionStatusParams = { /** * `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. + * HTTP is performed through the runtime's native `fetch` (or an injected + * `fetch` when provided), 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 @@ -333,7 +353,8 @@ export class KycService extends BaseDataService< * * @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.fetch - A function used to make HTTP requests. Defaults to + * the runtime's native `fetch`. * @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 @@ -356,7 +377,18 @@ export class KycService extends BaseDataService< queryClientConfig, policyOptions, }); - this.#fetch = fetchFunction; + // Fall back to the runtime's native `fetch`, bound to `globalThis` so it + // can be invoked as a method of this instance without an illegal-invocation + // error on platforms that check the receiver. + if (fetchFunction) { + this.#fetch = fetchFunction; + } else if (typeof globalThis.fetch === 'function') { + this.#fetch = globalThis.fetch.bind(globalThis); + } else { + throw new Error( + 'KycService: fetch is not available globally and was not provided in options. Please inject a fetch implementation.', + ); + } if (!baseUrl) { throw new Error('KycService: baseUrl is required'); } @@ -406,18 +438,21 @@ export class KycService extends BaseDataService< * created. * * @param params - The parameters. + * @param params.vendor - Identity vendor. Defaults to `moonpay`. * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ async fetchDisclaimers({ + vendor = 'moonpay', country, }: { + vendor?: KycVendor; country: string; }): Promise { - const url = new URL('/vendors/moonpay/disclaimers', this.#baseUrl); + const url = new URL(`/vendors/${vendor}/disclaimers`, this.#baseUrl); url.searchParams.set('country', country); const data = await this.fetchQuery({ - queryKey: [`${this.name}:fetchDisclaimers`, country], + queryKey: [`${this.name}:fetchDisclaimers`, vendor, country], queryFn: async () => this.#requestJson(url, { method: 'GET' }), staleTime: inMilliseconds(5, Duration.Minute), }); @@ -462,7 +497,7 @@ export class KycService extends BaseDataService< } /** - * Checks whether KYC is required for the given access token, country, and + * Checks whether KYC is required for the given vendor, country, and * capabilities. * * @param params - The check parameters. @@ -471,23 +506,44 @@ export class KycService extends BaseDataService< async checkKycRequired( params: CheckKycRequiredParams, ): Promise<{ kycRequired: boolean }> { - const url = new URL('/vendors/moonpay/kyc-required', this.#baseUrl); + const vendor = params.vendor ?? 'moonpay'; + const url = new URL(`/vendors/${vendor}/kyc-required`, this.#baseUrl); const capabilities = params.capabilities ?? [{ product: 'ramps' }]; + const body = + vendor === 'moonpay' + ? { + accessToken: params.accessToken, + country: params.country, + capabilities, + } + : {}; + + // MoonPay requires accessToken and country; validate before making the request. + if (vendor === 'moonpay') { + if (!params.accessToken) { + throw new Error( + 'checkKycRequired: accessToken is required for vendor "moonpay".', + ); + } + if (!params.country) { + throw new Error( + 'checkKycRequired: country is required for vendor "moonpay".', + ); + } + } + const data = await this.fetchQuery({ queryKey: [ `${this.name}:checkKycRequired`, - params.accessToken, - params.country, + vendor, + params.accessToken ?? null, + params.country ?? null, capabilities, ], queryFn: async () => this.#requestJson(url, { method: 'POST', - body: JSON.stringify({ - accessToken: params.accessToken, - country: params.country, - capabilities, - }), + body: JSON.stringify(body), }), // The requirement can change server-side, so always re-check. staleTime: 0, @@ -502,20 +558,26 @@ export class KycService extends BaseDataService< } /** - * 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. + * Creates (or resumes) an empty-shell customer for the authenticated + * canonical user on the given identity vendor. Must run before showing + * vendor T&C so the customer exists and resume logic can key off vendor + * status. * * @param params - The parameters. - * @param params.email - Email associated with the Iron customer. - * @returns The Iron customer record (subset validated for controller use). + * @param params.vendor - Identity vendor (e.g. `iron` for Money/VBA). + * @param params.email - Email associated with the customer. + * @returns The vendor customer record (subset validated for controller use). */ - async createIronCustomer( - params: CreateIronCustomerParams, - ): Promise { - const url = new URL('/vendors/iron/customers', this.#baseUrl); + async createVendorCustomer( + params: CreateVendorCustomerParams, + ): Promise { + const url = new URL(`/vendors/${params.vendor}/customers`, this.#baseUrl); const data = await this.fetchQuery({ - queryKey: [`${this.name}:createIronCustomer`, params.email], + queryKey: [ + `${this.name}:createVendorCustomer`, + params.vendor, + params.email, + ], queryFn: async () => this.#requestJson(url, { method: 'POST', @@ -527,64 +589,13 @@ export class KycService extends BaseDataService< }); 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', + VendorCustomerResponseStruct, + 'vendor customers', ); - return { kycRequired: required }; } /** - * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the + * Posts T&C1 (vendor 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. @@ -594,7 +605,7 @@ export class KycService extends BaseDataService< await this.fetchQuery({ queryKey: [ `${this.name}:submitConsents`, - params.ironDisclaimerIds, + params.disclaimerIds, params.sumsubTncSigned, params.idosTncSigned, params.kycLevel ?? 'standard', @@ -602,8 +613,9 @@ export class KycService extends BaseDataService< queryFn: async () => this.#requestJson(url, { method: 'POST', + // UKYC Money/VBA consents contract still uses `ironDisclaimerIds`. body: JSON.stringify({ - ironDisclaimerIds: params.ironDisclaimerIds, + ironDisclaimerIds: params.disclaimerIds, sumsubTncSigned: params.sumsubTncSigned, idosTncSigned: params.idosTncSigned, kycLevel: params.kycLevel ?? 'standard', @@ -721,7 +733,7 @@ export class KycService extends BaseDataService< this.#requestJson(url, { method: 'POST', body: JSON.stringify({ - vendorId: params.vendorId ?? 'moonpay', + vendorId: params.vendor ?? 'moonpay', vendorUserId: 'mockedId', jwtToken: params.jwtToken, vendorMetadata: params.vendorMetadata ?? {}, @@ -835,24 +847,6 @@ export class KycService extends BaseDataService< } } - /** - * 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. * @@ -885,7 +879,16 @@ export class KycService extends BaseDataService< } if (authenticated) { - headers.Authorization = `Bearer ${await this.#getBearerToken()}`; + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token — is the wallet signed in?', + ); + } + assert(bearerToken, string()); + headers.Authorization = `Bearer ${bearerToken}`; } const response = await this.#fetch(url.toString(), { diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index d6b24b3730..b710b46352 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -20,7 +20,7 @@ export type { KycControllerBuildResetFrameUrlAction, KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, - KycControllerCreateIronCustomerAction, + KycControllerCreateVendorCustomerAction, KycControllerGetCustomerIdentityAction, KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, @@ -36,12 +36,12 @@ export { KycService, serviceName } from './KycService.js'; export type { ApplicantAccessTokenResponse, CheckKycRequiredParams, - CreateIronCustomerParams, + CreateVendorCustomerParams, CreateSessionParams, CreateUkycSessionParams, GetSessionStatusParams, GetWrappingKeyParams, - IronCustomerResponse, + VendorCustomerResponse, JwksResponse, KycServiceActions, KycServiceCacheUpdatedEvent, @@ -56,14 +56,12 @@ export type { WrappingKeyResponse, } from './KycService.js'; export type { - KycServiceCheckIronKycRequiredAction, KycServiceCheckKycRequiredAction, - KycServiceCreateIronCustomerAction, + KycServiceCreateVendorCustomerAction, KycServiceCreateJourneyAction, KycServiceCreateSessionAction, KycServiceCreateUkycSessionAction, KycServiceFetchDisclaimersAction, - KycServiceFetchIronDisclaimersAction, KycServiceFetchJwksAction, KycServiceFetchKycStatusAction, KycServiceGetGeoCountryAction, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 9b1ee6ebfa..c25f3e0223 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -38,9 +38,9 @@ export type KycCustomerIdentity = { }; /** - * 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. + * User-keyed KYC status returned by `GET /kyc/status` and stored for toast / + * banner rendering. Collapses vendor + SumSub / relay state into the offsite + * contract. */ export type KycUserStatus = | 'not-started' @@ -67,12 +67,13 @@ export type KycUserStatusResponse = { * - `idle` — nothing started. * - `terms` — waiting for the customer to accept the vendor terms. * - `session` — creating the vendor session (MoonPay) or posting consents - * (Iron). + * (non-MoonPay vendors). * - `check` — running the invisible connection-check frame (MoonPay only). * - `auth` — running the visible authentication (OTP) frame (MoonPay only). * - `form` — authenticated. When the flow is scoped to a product, the * KYC-required check runs automatically from here; otherwise the consumer - * drives it manually via `checkKycRequired`. Iron skips this phase. + * drives it manually via `checkKycRequired`. Consents-path vendors skip + * this phase. * - `submit` — submitting the KYC-required check / launching SumSub. * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` / * `userStatus`. When KYC is required, the document-verification sub-flow is diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 97b6eb6e82..71461667bc 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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 the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`KycController:getCustomerIdentity`, `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), [#9908](https://github.com/MetaMask/core/pull/9908)) +- Export `KycControllerGetCustomerIdentityAction` so hosts can type the KYC messenger action they delegate onto `RampsController` without depending on `@metamask/kyc-controller`. ([#9908](https://github.com/MetaMask/core/pull/9908)) - 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)) @@ -19,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- Resolve autoramp / Money Account wallet-registration customer id from `KycController:getCustomerIdentity` when a session identity is present (vendor-scoped `{ vendor, id }`), falling back to Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Hosts that enable autoramp creation / wallet registration must delegate `KycController:getCustomerIdentity` on the `RampsController` messenger (`RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`). ([#9908](https://github.com/MetaMask/core/pull/9908), [#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 diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index 94cc82f2f0..118407c45a 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -1,3 +1,5 @@ +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { ConstantBackoff } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; import nock, { cleanAll } from 'nock'; @@ -19,6 +21,8 @@ const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; * @param options.baseUrlOverride - Overrides the environment-derived host. * @param options.omitDefaults - Pass `true` to exercise constructor defaulted * parameters (`environment`, `policyOptions`). + * @param options.policyOptions - Retry/circuit policy overrides. Defaults to + * `{ maxRetries: 0 }` so tests fail fast unless they opt into retries. * @param options.canonicalProfileId - Canonical profile id returned by the * stubbed `AuthenticationController:getSessionProfile` (wallet registration). * @returns Service instance for the test. @@ -27,6 +31,7 @@ function createService(options?: { environment?: RampsEnvironment; baseUrlOverride?: string; omitDefaults?: boolean; + policyOptions?: CreateServicePolicyOptions; canonicalProfileId?: string; }): NeoBankService { const rootMessenger = new Messenger({ @@ -75,7 +80,7 @@ function createService(options?: { environment: options?.environment ?? RampsEnvironment.Staging, context: 'test', fetch: globalThis.fetch.bind(globalThis), - policyOptions: { maxRetries: 0 }, + policyOptions: options?.policyOptions ?? { maxRetries: 0 }, baseUrlOverride: options?.baseUrlOverride, }); } @@ -166,6 +171,52 @@ describe('NeoBankService', () => { ); }); + it('retries a 429 then succeeds', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(429) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ + policyOptions: { + maxRetries: 1, + backoff: new ConstantBackoff(0), + }, + }); + const snapshot = await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshot.id).toBe('ar-1'); + }); + + it('retries a network error then succeeds', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .replyWithError('ECONNRESET') + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ + policyOptions: { + maxRetries: 1, + backoff: new ConstantBackoff(0), + }, + }); + const snapshot = await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshot.id).toBe('ar-1'); + }); + it('throws when the response body is malformed', async () => { nock(STAGING_BASE) .get(/\/neobank\/autoramps\/ar-1/u) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index b0ce940b26..7bba207751 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -297,10 +297,10 @@ export type RampsControllerAddAutorampAction = { * 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 + * The vendor `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. + * request. This keeps the sensitive customer id owned by KYC / 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. @@ -315,12 +315,12 @@ export type RampsControllerCreateAutorampAction = { /** * 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. + * Consumers provide only the Monad address. The controller resolves the + * vendor customer id via {@link RampsController.resolveAutorampCustomerId} + * (KYC session identity, else 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. diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 44a5388eb9..ddd2e691b9 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9206,6 +9206,83 @@ describe('RampsController', () => { }); }); + it('prefers the KYC session identity over Profile Sync lookup', async () => { + await withController(async ({ controller, rootMessenger }) => { + spyOnGetCustomerIdentity(rootMessenger, { + vendor: 'iron', + id: 'kyc-cust-1', + }); + const getSessionProfile = jest.fn(); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + getSessionProfile, + ); + const getCustomerByExternalId = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'kyc-cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + const created = await controller.createAutoramp({ + customer_id: 'attacker-supplied', + }); + + expect(createAutoramp).toHaveBeenCalledWith( + { customer_id: 'kyc-cust-1' }, + {}, + ); + expect(created.customerId).toBe('kyc-cust-1'); + expect(getSessionProfile).not.toHaveBeenCalled(); + expect(getCustomerByExternalId).not.toHaveBeenCalled(); + }); + }); + + it('falls back to Profile Sync when KYC identity has an empty id', async () => { + await withController(async ({ controller, rootMessenger }) => { + spyOnGetCustomerIdentity(rootMessenger, { vendor: 'iron', id: '' }); + 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-fallback' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + async () => ({ + id: 'ar-new', + customerId: 'cust-fallback', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }), + ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('canonical-1'); + }); + }); + it('skips failed refreshes when refreshing all autoramps', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( @@ -9505,6 +9582,7 @@ describe('RampsController', () => { }; type WalletRegistrationHandlers = { + getCustomerIdentity: jest.Mock; getSessionProfile: jest.Mock; getCustomerByExternalId: jest.Mock; getWalletRegistrationStatus: jest.Mock; @@ -9523,6 +9601,7 @@ describe('RampsController', () => { rootMessenger: RootMessenger, ): WalletRegistrationHandlers { const handlers: WalletRegistrationHandlers = { + getCustomerIdentity: spyOnGetCustomerIdentity(rootMessenger, null), getSessionProfile: jest.fn().mockResolvedValue({ identifierId: 'id-1', profileId: 'profile-1', @@ -9638,13 +9717,29 @@ describe('RampsController', () => { await controller.registerMoneyAccountWallet({ address: '0xabc' }); + expect(handlers.getCustomerIdentity).toHaveBeenCalled(); expect(handlers.getCustomerByExternalId).toHaveBeenCalledWith( 'canonical-1', ); + }); + }); + + it('prefers the KYC session identity over Profile Sync lookup', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getCustomerIdentity.mockReturnValue({ + vendor: 'iron', + id: 'kyc-cust-1', + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ - customerId: 'iron-customer-fallback', + customerId: 'kyc-cust-1', address: '0xabc', }); + expect(handlers.getSessionProfile).not.toHaveBeenCalled(); + expect(handlers.getCustomerByExternalId).not.toHaveBeenCalled(); }); }); @@ -12617,6 +12712,13 @@ function getRootMessenger(): RootMessenger { 'RampsService:getDefaultRedirectCallbackUrl', () => STAGING_REDIRECT_CALLBACK_URL, ); + // Default: no KYC session, so autoramp / wallet registration fall back to + // Profile Sync → neo-bank external-id lookup. Tests that need a session + // identity call `spyOnGetCustomerIdentity`. + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + () => null, + ); return rootMessenger; } @@ -12644,6 +12746,28 @@ function spyOnDefaultRedirectCallbackUrl( return handler; } +/** + * Replaces the default `KycController:getCustomerIdentity` handler with a spy. + * + * @param rootMessenger - The root messenger to re-register the handler on. + * @param identity - Session identity to return, or `null` when none is captured. + * @returns The spy standing in for the KYC controller method. + */ +function spyOnGetCustomerIdentity( + rootMessenger: RootMessenger, + identity: { vendor: string; id: string } | null, +): jest.Mock<{ vendor: string; id: string } | null, []> { + const handler = jest.fn<{ vendor: string; id: string } | null, []>( + () => identity, + ); + rootMessenger.unregisterActionHandler('KycController:getCustomerIdentity'); + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + handler, + ); + return handler; +} + /** * Constructs the messenger for the controller under test. * diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index fe7e0fbc5e..d1158fdc07 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -224,17 +224,31 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ /** * 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 + * messenger so the controller can resolve the vendor customer identity: + * `KycController:getCustomerIdentity` (session-scoped, preferred) then + * Profile Sync (`AuthenticationController:getSessionProfile`) plus 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 = [ + 'KycController:getCustomerIdentity', 'AuthenticationController:getSessionProfile', 'KeyringController:signPersonalMessage', ] as const; +/** + * Structural type for the KYC controller's `getCustomerIdentity` messenger + * action. Declared locally (mirroring `@metamask/kyc-controller`) so this + * package does not need a dependency on the KYC package; the messenger only + * matches on the action `type` string, so the shapes stay compatible with + * the vendor-scoped `{ vendor, id }` session identity. + */ +export type KycControllerGetCustomerIdentityAction = { + type: 'KycController:getCustomerIdentity'; + handler: () => { vendor: string; id: string } | null; +}; + /** * Structural type for the keyring controller's `signPersonalMessage` messenger * action (EIP-191). Declared locally (mirroring @@ -771,6 +785,7 @@ type AllowedActions = | NeoBankServiceGetCustomerByExternalIdAction | NeoBankServiceGetWalletRegistrationStatusAction | NeoBankServiceRegisterSelfHostedWalletAction + | KycControllerGetCustomerIdentityAction | KeyringControllerSignPersonalMessageAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction @@ -2725,10 +2740,10 @@ export class RampsController extends BaseController< * 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 + * The vendor `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. + * request. This keeps the sensitive customer id owned by KYC / 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. @@ -2751,17 +2766,25 @@ export class RampsController extends BaseController< } /** - * Resolves the MoonPay `customer_id` for autoramp operations. + * Resolves the vendor `customer_id` for autoramp / Money Account 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 + * Prefers the session-scoped identity from + * `KycController:getCustomerIdentity` (captured during the current KYC + * flow, vendor-neutral `{ vendor, id }`). When that is `null` (before + * authentication or after `reset()`), maps the wallet's Profile Sync id + * (the partner `external_id`) to the vendor 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. + * @returns The vendor customer id. */ async resolveAutorampCustomerId(): Promise { + const identity = this.messenger.call('KycController:getCustomerIdentity'); + if (identity?.id) { + return identity.id; + } + const profile = await this.messenger.call( 'AuthenticationController:getSessionProfile', ); @@ -2797,12 +2820,12 @@ export class RampsController extends BaseController< /** * 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. + * Consumers provide only the Monad address. The controller resolves the + * vendor customer id via {@link RampsController.resolveAutorampCustomerId} + * (KYC session identity, else 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. @@ -2833,8 +2856,8 @@ export class RampsController extends BaseController< return undefined; }; - // List requires customer_id in the neobank path, so resolve Iron's id - // before the first lookup. + // List requires customer_id in the neobank path, so resolve the vendor + // customer id before the first lookup. const customerId = await this.resolveAutorampCustomerId(); const lookup = async (): Promise => { diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 9b4642c524..4072af3fec 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -14,6 +14,7 @@ export type { NativeProvidersState, MoneyAccountWalletRegistrationResult, KeyringControllerSignPersonalMessageAction, + KycControllerGetCustomerIdentityAction, } from './RampsController.js'; export type { RampsControllerExecuteRequestAction,