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,