diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md
index 2c1f42780c..27ceff651a 100644
--- a/packages/kyc-controller/ARCHITECTURE.md
+++ b/packages/kyc-controller/ARCHITECTURE.md
@@ -507,7 +507,7 @@ graph TB
direction TB
subgraph engine["Engine wiring"]
CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"]
- SInit["kyc-service-init.ts
new KycService({ env, messenger, baseUrl })"]
+ SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"]
CMsgr["kyc-controller-messenger.ts
delegates KycService:*"]
SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"]
Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"]
diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md
index e4c43a53ae..af12b8492d 100644
--- a/packages/kyc-controller/CHANGELOG.md
+++ b/packages/kyc-controller/CHANGELOG.md
@@ -9,8 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Initial release of the `@metamask/kyc-controller` package: a platform-agnostic controller and data service for orchestrating KYC / identity verification across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615), [#9712](https://github.com/MetaMask/core/pull/9712))
- - `KycController` owns the end-to-end flow: the KYC state machine, the MoonPay Check/Auth hosted-frame message protocol, X25519 credential decryption, and SumSub orchestration through an injected `KycSumSubLauncher` adapter (keeping the controller SDK-free). It performs the authenticated Universal KYC (UKYC) HTTP calls (disclaimers, sessions, kyc-required, wrapping-key, JWKS, UKYC session/journey/status polling) with `superstruct` response validation and `createServicePolicy` resilience, sourcing the bearer token and geolocation through the messenger.
- - Passing an optional `product` (`ramps` or `card`) makes the controller automatically run the KYC-required check and chain into the SumSub sub-flow after authentication, with generation/phase guards so `reset()` and stale frame messages cannot corrupt state.
+- 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))
+- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781))
+- Add `KycController` 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))
+ - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter.
+ - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger.
+ - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors.
+ - Add automatic post-authentication continuation to `KycController`
+ - Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API
+ - Add UKYC session-status polling to `KycController`
+ - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor
+
+### 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))
+- 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))
+- 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))
[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 2586ea9c63..b2aa85cca4 100644
--- a/packages/kyc-controller/src/KycController-method-action-types.ts
+++ b/packages/kyc-controller/src/KycController-method-action-types.ts
@@ -16,12 +16,27 @@ 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`.
*/
export type KycControllerInitializeAction = {
type: `KycController:initialize`;
handler: KycController['initialize'];
};
+/**
+ * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can
+ * ensure the customer exists before showing T&C screens independently of
+ * {@link initialize}.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email for the Iron customer.
+ */
+export type KycControllerCreateIronCustomerAction = {
+ type: `KycController:createIronCustomer`;
+ handler: KycController['createIronCustomer'];
+};
+
/**
* Loads the disclaimers for the resolved (or provided) country.
*
@@ -42,6 +57,10 @@ export type KycControllerLoadDisclaimersAction = {
* @param params.product - The consuming feature the flow runs for. See
* {@link initialize} for how the product drives the automatic post
* authentication continuation.
+ * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were
+ * accepted (T&C2). Defaults to `true` when omitted.
+ * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted
+ * (T&C2). Defaults to `true` when omitted.
*/
export type KycControllerAcceptTermsAndStartSessionAction = {
type: `KycController:acceptTermsAndStartSession`;
@@ -126,6 +145,23 @@ export type KycControllerGetKycStatusAction = {
handler: KycController['getKycStatus'];
};
+/**
+ * Returns the vendor-scoped identity for the currently authenticated
+ * customer, or `null` when the flow has not yet captured a vendor customer
+ * id (before authentication or after {@link reset}).
+ *
+ * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor
+ * customer id to downstream calls without reading the full KYC state, which
+ * also holds session/access tokens. The id is session-scoped and never
+ * persisted.
+ *
+ * @returns The current {@link KycCustomerIdentity}, or `null`.
+ */
+export type KycControllerGetCustomerIdentityAction = {
+ type: `KycController:getCustomerIdentity`;
+ handler: KycController['getCustomerIdentity'];
+};
+
/**
* Runs the SumSub document-verification sub-flow end to end:
*
@@ -154,6 +190,18 @@ export type KycControllerStartSumSubAction = {
handler: KycController['startSumSub'];
};
+/**
+ * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`,
+ * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and
+ * schedules short-interval polling while the status is `pending`.
+ *
+ * @returns The latest status payload.
+ */
+export type KycControllerRefreshKycStatusAction = {
+ type: `KycController:refreshKycStatus`;
+ handler: KycController['refreshKycStatus'];
+};
+
/**
* Fetches the current UKYC session status for the active sub-flow and records
* it on state. Useful for a one-off refresh outside the automatic polling
@@ -181,6 +229,7 @@ export type KycControllerResetAction = {
*/
export type KycControllerMethodActions =
| KycControllerInitializeAction
+ | KycControllerCreateIronCustomerAction
| KycControllerLoadDisclaimersAction
| KycControllerAcceptTermsAndStartSessionAction
| KycControllerClearSavedTermsAction
@@ -190,6 +239,8 @@ export type KycControllerMethodActions =
| KycControllerBuildResetFrameUrlAction
| KycControllerCheckKycRequiredAction
| KycControllerGetKycStatusAction
+ | KycControllerGetCustomerIdentityAction
| KycControllerStartSumSubAction
+ | KycControllerRefreshKycStatusAction
| KycControllerGetSessionStatusAction
| KycControllerResetAction;
diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts
index 3c3078c27e..c34d6a88ee 100644
--- a/packages/kyc-controller/src/KycController.test.ts
+++ b/packages/kyc-controller/src/KycController.test.ts
@@ -1215,6 +1215,91 @@ describe('KycController', () => {
});
});
+ describe('getCustomerIdentity', () => {
+ it('returns null before a vendor customer id is captured', async () => {
+ await withController(({ controller }) => {
+ expect(controller.getCustomerIdentity()).toBeNull();
+ });
+ });
+
+ it('returns the vendor-scoped identity once captured', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ ({ controller }) => {
+ expect(controller.getCustomerIdentity()).toStrictEqual({
+ vendor: 'moonpay',
+ id: 'cust-1',
+ });
+ },
+ );
+ });
+
+ it('returns null after reset clears the captured id', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ ({ controller }) => {
+ controller.reset();
+ expect(controller.getCustomerIdentity()).toBeNull();
+ },
+ );
+ });
+
+ it('drops a MoonPay id when initialize switches to another vendor', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ async ({ controller }) => {
+ await controller.initialize({ vendor: 'iron' });
+
+ expect(controller.state.moonpayCustomerId).toBeNull();
+ expect(controller.getCustomerIdentity()).toBeNull();
+ },
+ );
+ });
+
+ it('keeps a MoonPay id when initialize stays on MoonPay', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ async ({ controller }) => {
+ await controller.initialize({ vendor: 'moonpay' });
+
+ expect(controller.state.moonpayCustomerId).toBe('cust-1');
+ },
+ );
+ });
+
+ it('drops a MoonPay id when an Iron customer is created', async () => {
+ await withController(
+ {
+ options: {
+ state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' },
+ },
+ },
+ async ({ controller }) => {
+ await controller.createIronCustomer({ email: 'a@b.co' });
+
+ expect(controller.state.moonpayCustomerId).toBeNull();
+ expect(controller.getCustomerIdentity()).toBeNull();
+ },
+ );
+ });
+ });
+
describe('startSumSub', () => {
it('throws and marks failed when the SDK is unavailable', async () => {
await withController(async ({ controller, launcher }) => {
@@ -1769,6 +1854,694 @@ describe('KycController', () => {
});
});
+ describe('iron vendor flow', () => {
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it('creates an Iron customer and loads Iron disclaimers on initialize', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.getGeoCountry.mockResolvedValue('USA');
+ handlers.fetchIronDisclaimers.mockResolvedValue([
+ { id: 'd1', display_name: 'Iron T&C', url: 'https://t' },
+ ]);
+
+ await controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ product: 'money',
+ });
+
+ expect(handlers.createIronCustomer).toHaveBeenCalledWith({
+ email: 'a@b.co',
+ });
+ expect(handlers.fetchIronDisclaimers).toHaveBeenCalledWith({
+ country: 'USA',
+ });
+ expect(handlers.fetchDisclaimers).not.toHaveBeenCalled();
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(controller.state.activeVendor).toBe('iron');
+ expect(controller.state.activeProduct).toBe('money');
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.disclaimers).toHaveLength(1);
+ });
+ });
+
+ it('fails initialize when Iron customer creation fails', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.createIronCustomer.mockRejectedValue(new Error('iron down'));
+
+ await controller.initialize({ email: 'a@b.co', vendor: 'iron' });
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(
+ /Iron customer creation failed/u,
+ );
+ });
+ });
+
+ it('does not fail initialize when reset lands during Iron customer creation', async () => {
+ await withController(async ({ controller, handlers }) => {
+ let release: (value: {
+ id: string;
+ email: string;
+ status: string;
+ }) => void = () => {
+ // placeholder
+ };
+ handlers.createIronCustomer.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ });
+ controller.reset();
+ release({ id: '1', email: 'a@b.co', status: 'SigningsRequired' });
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ });
+ });
+
+ it('does not fail initialize when Iron customer creation rejects after reset', async () => {
+ await withController(async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.createIronCustomer.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ const pending = controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ });
+ controller.reset();
+ release(new Error('late'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ });
+ });
+
+ it('resumes an Iron session when terms and email are already present', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ termsAcceptedAt: 't',
+ acceptedDisclaimerIds: ['d1'],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' });
+
+ await controller.initialize({
+ email: 'a@b.co',
+ vendor: 'iron',
+ product: 'money',
+ });
+
+ expect(handlers.submitConsents).toHaveBeenCalled();
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(controller.state.phase).toBe('done');
+ controller.reset();
+ },
+ );
+ });
+
+ it('createIronCustomer sets the vendor and fails on API errors', async () => {
+ await withController(async ({ controller, handlers }) => {
+ handlers.createIronCustomer.mockRejectedValue(new Error('nope'));
+
+ await controller.createIronCustomer({ email: 'a@b.co' });
+
+ expect(controller.state.activeVendor).toBe('iron');
+ expect(controller.state.email).toBe('a@b.co');
+ expect(controller.state.phase).toBe('error');
+ });
+ });
+
+ it('createIronCustomer ignores API errors after reset', async () => {
+ await withController(async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.createIronCustomer.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ const pending = controller.createIronCustomer({ email: 'a@b.co' });
+ controller.reset();
+ release(new Error('late'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ });
+ });
+
+ it('posts consents and starts SumSub without MoonPay frames', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ handlers.submitConsents.mockResolvedValue(undefined);
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' });
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+
+ await controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ product: 'money',
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ });
+
+ expect(handlers.createSession).not.toHaveBeenCalled();
+ expect(handlers.submitConsents).toHaveBeenCalledWith({
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ });
+ expect(handlers.createUkycSession).toHaveBeenCalledWith(
+ expect.objectContaining({ vendorId: 'iron' }),
+ );
+ expect(launcher.launch).toHaveBeenCalled();
+ expect(controller.buildCheckFrameUrl()).toBeNull();
+ expect(controller.buildAuthFrameUrl()).toBeNull();
+ expect(controller.state.userStatus).toBe('pending');
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.sumsub.status).toBe('complete');
+ controller.reset();
+ },
+ );
+ });
+
+ it('fails the Iron session when email is missing', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller }) => {
+ await controller.acceptTermsAndStartSession();
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(/Missing email/u);
+ },
+ );
+ });
+
+ it('fails the Iron session when disclaimer acceptance is missing', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ email: 'a@b.co',
+ disclaimers: [],
+ },
+ },
+ },
+ async ({ controller }) => {
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('error');
+ expect(controller.state.error).toMatch(/Missing Iron disclaimer/u);
+ },
+ );
+ });
+
+ it('returns to terms when SumSub fails during the Iron session', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(
+ new Error('sumsub down'),
+ );
+ handlers.fetchIronDisclaimers.mockResolvedValue([]);
+
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('terms');
+ expect(controller.state.termsAcceptedAt).toBeNull();
+ expect(controller.state.error).toMatch(/Iron session failed/u);
+ },
+ );
+ });
+
+ it('keeps done when status refresh fails after a successful SumSub', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ launcher.launch.mockImplementation(async ({ onStatusChange }) => {
+ onStatusChange?.('InProgress', 'Completed');
+ return { ok: true };
+ });
+ handlers.fetchKycStatus.mockRejectedValue(new Error('status down'));
+
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.sumsub.status).toBe('complete');
+ controller.reset();
+ },
+ );
+ });
+
+ it('ignores in-flight Iron consents after reset', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let release: () => void = () => {
+ // placeholder
+ };
+ handlers.submitConsents.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ });
+ controller.reset();
+ release();
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(handlers.createUkycSession).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('ignores SumSub completion after reset during the Iron session', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers, launcher }) => {
+ let releaseLaunch: (value: { ok: boolean }) => void = () => {
+ // placeholder
+ };
+ launcher.launch.mockReturnValue(
+ new Promise((resolve) => {
+ releaseLaunch = resolve;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ });
+ // Consents + UKYC session run first; wait until launch is pending.
+ await Promise.resolve();
+ await Promise.resolve();
+ controller.reset();
+ releaseLaunch({ ok: true });
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(handlers.fetchKycStatus).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ it('ignores Iron session failures after reset', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.submitConsents.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ const pending = controller.acceptTermsAndStartSession({
+ email: 'a@b.co',
+ });
+ controller.reset();
+ release(new Error('late consent failure'));
+ await pending;
+
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.error).toBeNull();
+ },
+ );
+ });
+
+ it('refreshKycStatus stores status and emits statusChanged', async () => {
+ await withController(
+ { options: { userStatusPollIntervalMs: 60_000 } },
+ async ({ controller, handlers, rootMessenger }) => {
+ const listener = jest.fn();
+ rootMessenger.subscribe('KycController:statusChanged', listener);
+ handlers.fetchKycStatus.mockResolvedValue({
+ status: 'completed',
+ sumsubSessionId: 'ss-1',
+ });
+
+ const result = await controller.refreshKycStatus();
+
+ expect(result).toStrictEqual({
+ status: 'completed',
+ sumsubSessionId: 'ss-1',
+ errorCode: null,
+ });
+ expect(controller.state.userStatus).toBe('completed');
+ expect(listener).toHaveBeenCalledWith({
+ status: 'completed',
+ sumsubSessionId: 'ss-1',
+ errorCode: null,
+ });
+ },
+ );
+ });
+
+ it('polls user status while pending and stops on a terminal status', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockResolvedValueOnce({ status: 'completed' });
+
+ await controller.refreshKycStatus();
+ expect(controller.state.userStatus).toBe('pending');
+
+ // First tick stays pending and reschedules; second tick completes.
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(controller.state.userStatus).toBe('pending');
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(controller.state.userStatus).toBe('completed');
+
+ // A second refresh while pending would no-op the timer start; then
+ // reset clears any leftover handles.
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' });
+ await controller.refreshKycStatus();
+ await controller.refreshKycStatus();
+ controller.reset();
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('drops superseded user-status poll ticks after reset', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ let release: (value: { status: string }) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ await controller.refreshKycStatus();
+ jest.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ controller.reset();
+ release({ status: 'completed' });
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(controller.state.userStatus).toBe('pending');
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('keeps polling when a user-status tick fails transiently', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockRejectedValueOnce(new Error('transient'))
+ .mockResolvedValueOnce({ status: 'completed' });
+
+ await controller.refreshKycStatus();
+ await jest.advanceTimersByTimeAsync(1000);
+ await jest.advanceTimersByTimeAsync(1000);
+
+ expect(controller.state.userStatus).toBe('completed');
+ controller.reset();
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('drops superseded user-status ticks that fail after reset', async () => {
+ jest.useFakeTimers();
+ try {
+ await withController(
+ { options: { userStatusPollIntervalMs: 1000 } },
+ async ({ controller, handlers }) => {
+ let release: (error: Error) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus
+ .mockResolvedValueOnce({ status: 'pending' })
+ .mockImplementationOnce(
+ async () =>
+ new Promise((_resolve, reject) => {
+ release = reject;
+ }),
+ );
+
+ await controller.refreshKycStatus();
+ jest.advanceTimersByTime(1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ controller.reset();
+ release(new Error('late'));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(controller.state.userStatus).toBe('pending');
+ },
+ );
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('returns cached user status when reset lands during refresh', async () => {
+ await withController(
+ {
+ options: {
+ state: { userStatus: 'pending' },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers }) => {
+ let release: (value: { status: string }) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.refreshKycStatus();
+ controller.reset();
+ release({ status: 'completed' });
+ const result = await pending;
+
+ expect(result.status).toBe('pending');
+ },
+ );
+ });
+
+ it('defaults superseded refresh status to not-started when unset', async () => {
+ await withController(
+ { options: { userStatusPollIntervalMs: 60_000 } },
+ async ({ controller, handlers }) => {
+ let release: (value: { status: string }) => void = () => {
+ // placeholder
+ };
+ handlers.fetchKycStatus.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const pending = controller.refreshKycStatus();
+ controller.reset();
+ release({ status: 'completed' });
+ const result = await pending;
+
+ expect(result.status).toBe('not-started');
+ },
+ );
+ });
+
+ it('maps session_not_in_valid_state to completed during SumSub', async () => {
+ await withController(
+ {
+ options: {
+ state: { activeVendor: 'iron', phase: 'submit' },
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(
+ new Error(
+ "Fetching 'https://x' failed with status '409': session_not_in_valid_state",
+ ),
+ );
+
+ const result = await controller.startSumSub();
+
+ expect(result).toStrictEqual({ alreadyCompleted: true });
+ expect(controller.state.userStatus).toBe('completed');
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.sumsub.status).toBe('complete');
+ },
+ );
+ });
+
+ it('leaves an already-reset controller idle when SumSub reports a stale session', async () => {
+ await withController(
+ {
+ options: {
+ state: { activeVendor: 'iron', phase: 'submit' },
+ },
+ },
+ async ({ controller, handlers }) => {
+ let rejectSession: (error: Error) => void = () => undefined;
+ handlers.createUkycSession.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectSession = reject;
+ }),
+ );
+
+ const pending = controller.startSumSub();
+ controller.reset();
+ rejectSession(new Error('session_not_in_valid_state'));
+
+ expect(await pending).toStrictEqual({ alreadyCompleted: true });
+ expect(controller.state.userStatus).toBeNull();
+ expect(controller.state.phase).toBe('idle');
+ expect(controller.state.sumsub.status).toBe('idle');
+ },
+ );
+ });
+
+ it('keeps phase done when Iron SumSub reports already completed', async () => {
+ await withController(
+ {
+ options: {
+ state: {
+ activeVendor: 'iron',
+ disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }],
+ },
+ userStatusPollIntervalMs: 60_000,
+ },
+ },
+ async ({ controller, handlers }) => {
+ handlers.createUkycSession.mockRejectedValue(
+ new Error('session_not_in_valid_state'),
+ );
+ handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' });
+
+ await controller.acceptTermsAndStartSession({ email: 'a@b.co' });
+
+ expect(controller.state.phase).toBe('done');
+ expect(controller.state.userStatus).toBe('completed');
+ controller.reset();
+ },
+ );
+ });
+ });
+
describe('messenger actions', () => {
it('exposes methods as messenger actions', async () => {
await withController(({ rootMessenger }) => {
@@ -1791,6 +2564,11 @@ type ServiceHandlers = {
fetchDisclaimers: jest.Mock;
createSession: jest.Mock;
checkKycRequired: jest.Mock;
+ createIronCustomer: jest.Mock;
+ fetchIronDisclaimers: jest.Mock;
+ checkIronKycRequired: jest.Mock;
+ submitConsents: jest.Mock;
+ fetchKycStatus: jest.Mock;
getWrappingKey: jest.Mock;
fetchJwks: jest.Mock;
createUkycSession: jest.Mock;
@@ -1821,6 +2599,11 @@ const SERVICE_ACTIONS = [
'KycService:fetchDisclaimers',
'KycService:createSession',
'KycService:checkKycRequired',
+ 'KycService:createIronCustomer',
+ 'KycService:fetchIronDisclaimers',
+ 'KycService:checkIronKycRequired',
+ 'KycService:submitConsents',
+ 'KycService:fetchKycStatus',
'KycService:getWrappingKey',
'KycService:fetchJwks',
'KycService:createUkycSession',
@@ -1886,6 +2669,15 @@ function withController(
fetchDisclaimers: jest.fn().mockResolvedValue([]),
createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }),
checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }),
+ createIronCustomer: jest.fn().mockResolvedValue({
+ id: 'iron-1',
+ email: 'a@b.co',
+ status: 'SigningsRequired',
+ }),
+ fetchIronDisclaimers: jest.fn().mockResolvedValue([]),
+ checkIronKycRequired: jest.fn().mockResolvedValue({ kycRequired: true }),
+ submitConsents: jest.fn().mockResolvedValue(undefined),
+ fetchKycStatus: jest.fn().mockResolvedValue({ status: 'pending' }),
getWrappingKey: jest.fn().mockResolvedValue({
id: 'wk',
jwtChain: 'jwt.chain.sig',
@@ -1920,6 +2712,26 @@ function withController(
'KycService:checkKycRequired',
handlers.checkKycRequired,
);
+ rootMessenger.registerActionHandler(
+ 'KycService:createIronCustomer',
+ handlers.createIronCustomer,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:fetchIronDisclaimers',
+ handlers.fetchIronDisclaimers,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:checkIronKycRequired',
+ handlers.checkIronKycRequired,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:submitConsents',
+ handlers.submitConsents,
+ );
+ rootMessenger.registerActionHandler(
+ 'KycService:fetchKycStatus',
+ handlers.fetchKycStatus,
+ );
rootMessenger.registerActionHandler(
'KycService:getWrappingKey',
handlers.getWrappingKey,
diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts
index 54bb2240ea..6a4d98520c 100644
--- a/packages/kyc-controller/src/KycController.ts
+++ b/packages/kyc-controller/src/KycController.ts
@@ -18,12 +18,15 @@ import { toBase64Url } from './encoding.js';
import type { KycControllerMethodActions } from './KycController-method-action-types.js';
import type { KycServiceMethodActions } from './KycService-method-action-types.js';
import type {
+ KycCustomerIdentity,
KycDisclaimer,
KycPhase,
KycProduct,
KycSessionStatus,
KycSumSubLauncher,
KycSumSubStatus,
+ KycUserStatus,
+ KycVendor,
} from './types.js';
import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js';
import { verifyJwtChain } from './ukyc/jwtChain.js';
@@ -110,6 +113,14 @@ const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([
const VENDOR_PROCESSING_MESSAGE =
'Your KYC has been submitted and is being processed by the vendor.';
+// UKYC / relay error indicating the applicant already finished KYC. Mapped to
+// the simplified `completed` user status for the Money toast surface.
+const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state';
+
+// How often to refresh the user-keyed `GET /kyc/status` while the simplified
+// status is still `pending`. Overridable via the constructor.
+const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000;
+
// === STATE ===
/**
@@ -146,6 +157,13 @@ export type KycControllerState = {
/** Vendor customer id, used for the SumSub hand-off. */
moonpayCustomerId: string | null;
+ /**
+ * The identity vendor driving the current flow. Captured at `initialize`.
+ * Defaults to `moonpay` when omitted so existing ramps/card callers keep
+ * the Check/Auth frame path. `iron` skips those frames.
+ */
+ activeVendor: KycVendor;
+
/**
* The product the current flow is running for. Captured at `initialize`
* (or `acceptTermsAndStartSession`) and used to automatically run the
@@ -160,6 +178,17 @@ export type KycControllerState = {
/** ISO-8601 timestamp of the last KYC-required check (persisted). */
lastCheckedAt: string | null;
+ /**
+ * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the
+ * Money toast can render across cold starts). `null` until the first
+ * successful `refreshKycStatus`.
+ */
+ userStatus: KycUserStatus | null;
+ /** Optional SumSub session id for the retryable error path. */
+ userStatusSumsubSessionId: string | null;
+ /** Optional machine-readable error code for terminal / EDD UX. */
+ userStatusErrorCode: string | null;
+
/** SumSub document-verification sub-flow state. */
sumsub: {
status: KycSumSubStatus;
@@ -247,6 +276,12 @@ const kycControllerMetadata = {
persist: false,
usedInUi: false,
},
+ activeVendor: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: false,
+ usedInUi: true,
+ },
activeProduct: {
includeInDebugSnapshot: true,
includeInStateLogs: true,
@@ -265,6 +300,24 @@ const kycControllerMetadata = {
persist: true,
usedInUi: false,
},
+ userStatus: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: true,
+ },
+ userStatusSumsubSessionId: {
+ includeInDebugSnapshot: false,
+ includeInStateLogs: false,
+ persist: true,
+ usedInUi: true,
+ },
+ userStatusErrorCode: {
+ includeInDebugSnapshot: true,
+ includeInStateLogs: true,
+ persist: true,
+ usedInUi: true,
+ },
sumsub: {
includeInDebugSnapshot: false,
includeInStateLogs: false,
@@ -292,9 +345,13 @@ export function getDefaultKycControllerState(): KycControllerState {
sessionToken: null,
accessToken: null,
moonpayCustomerId: null,
+ activeVendor: 'moonpay',
activeProduct: null,
kycRequiredByProduct: {},
lastCheckedAt: null,
+ userStatus: null,
+ userStatusSumsubSessionId: null,
+ userStatusErrorCode: null,
sumsub: {
status: 'idle',
result: null,
@@ -311,6 +368,7 @@ const MESSENGER_EXPOSED_METHODS = [
'initialize',
'loadDisclaimers',
'acceptTermsAndStartSession',
+ 'createIronCustomer',
'clearSavedTerms',
'handleFrameMessage',
'buildCheckFrameUrl',
@@ -318,6 +376,8 @@ const MESSENGER_EXPOSED_METHODS = [
'buildResetFrameUrl',
'checkKycRequired',
'getKycStatus',
+ 'getCustomerIdentity',
+ 'refreshKycStatus',
'startSumSub',
'getSessionStatus',
'reset',
@@ -342,7 +402,23 @@ export type KycControllerStateChangeEvent = ControllerStateChangeEvent<
KycControllerState
>;
-export type KycControllerEvents = KycControllerStateChangeEvent;
+/**
+ * Published when the user-keyed simplified KYC status changes (Money toast).
+ */
+export type KycControllerStatusChangedEvent = {
+ type: `${typeof controllerName}:statusChanged`;
+ payload: [
+ {
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ },
+ ];
+};
+
+export type KycControllerEvents =
+ | KycControllerStateChangeEvent
+ | KycControllerStatusChangedEvent;
type AllowedEvents = never;
@@ -369,6 +445,12 @@ export type KycControllerOptions = {
* {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}.
*/
sessionStatusPollIntervalMs?: number;
+ /**
+ * How often, in milliseconds, to refresh `GET /kyc/status` while the
+ * simplified user status is `pending`. Defaults to
+ * {@link DEFAULT_USER_STATUS_POLL_INTERVAL_MS}.
+ */
+ userStatusPollIntervalMs?: number;
};
/**
@@ -434,6 +516,15 @@ export class KycController extends BaseController<
*/
#pollToken = 0;
+ /** Interval, in milliseconds, between user-keyed status polls. */
+ readonly #userStatusPollIntervalMs: number;
+
+ /** Handle for the scheduled next user-status poll, or `null`. */
+ #userStatusPollTimer: ReturnType | null = null;
+
+ /** Monotonic token for the user-status poll loop (see `#pollToken`). */
+ #userStatusPollToken = 0;
+
/**
* Constructs a new {@link KycController}.
*
@@ -443,12 +534,15 @@ export class KycController extends BaseController<
* @param options.sumsubLauncher - The platform SumSub launcher adapter.
* @param options.sessionStatusPollIntervalMs - How often to poll the UKYC
* session status after the SumSub SDK completes.
+ * @param options.userStatusPollIntervalMs - How often to refresh the
+ * user-keyed KYC status while it is still `pending`.
*/
constructor({
messenger,
state,
sumsubLauncher,
sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS,
+ userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS,
}: KycControllerOptions) {
super({
messenger,
@@ -459,6 +553,7 @@ export class KycController extends BaseController<
this.#sumsubLauncher = sumsubLauncher;
this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs;
+ this.#userStatusPollIntervalMs = userStatusPollIntervalMs;
this.#keypair = generateKeyPair();
this.messenger.registerMethodActionHandlers(
@@ -510,10 +605,13 @@ 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`.
*/
async initialize(params?: {
email?: string;
product?: KycProduct;
+ vendor?: KycVendor;
}): Promise {
// A repeat `initialize` while a session flow is already in progress must
// not tear it down: creating a new vendor session clears the tokens and
@@ -524,6 +622,8 @@ export class KycController extends BaseController<
return;
}
+ const vendor = params?.vendor ?? 'moonpay';
+
// `initialize` starts a fresh flow, so `activeProduct` is always reset to
// this call's product (or `null`). Otherwise a prior run's product could
// linger and cause `#continueAfterAuthentication` to auto-run the check /
@@ -532,6 +632,13 @@ export class KycController extends BaseController<
if (params?.email) {
state.email = params.email;
}
+ state.activeVendor = vendor;
+ // `moonpayCustomerId` is only ever issued by the MoonPay Check / Auth
+ // frames. Leaving it set while the flow switches to another vendor would
+ // make `getCustomerIdentity` report a MoonPay id under the wrong vendor.
+ if (vendor !== 'moonpay') {
+ state.moonpayCustomerId = null;
+ }
state.activeProduct = params?.product ?? null;
});
@@ -550,12 +657,37 @@ 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) {
+ try {
+ await this.messenger.call('KycService:createIronCustomer', {
+ email: this.state.email,
+ });
+ if (this.#generation !== generation) {
+ return;
+ }
+ } catch (error) {
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#fail(`Iron customer creation failed: ${String(error)}`);
+ return;
+ }
+ }
+
const hasTerms =
Boolean(this.state.termsAcceptedAt) &&
this.state.acceptedDisclaimerIds.length > 0;
if (hasTerms && this.state.email) {
- await this.#createSession();
+ if (vendor === 'iron') {
+ await this.#startIronSession({
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ });
+ } else {
+ await this.#createSession();
+ }
return;
}
@@ -565,6 +697,35 @@ export class KycController extends BaseController<
await this.loadDisclaimers();
}
+ /**
+ * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can
+ * ensure the customer exists before showing T&C screens independently of
+ * {@link initialize}.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email for the Iron customer.
+ */
+ async createIronCustomer(params: { email: string }): Promise {
+ this.#applyUpdate((state) => {
+ state.email = params.email;
+ state.activeVendor = 'iron';
+ // See `initialize`: a MoonPay-issued customer id must not survive a
+ // switch to Iron, or `getCustomerIdentity` reports the wrong vendor.
+ state.moonpayCustomerId = null;
+ });
+ const generation = this.#generation;
+ try {
+ await this.messenger.call('KycService:createIronCustomer', {
+ email: params.email,
+ });
+ } catch (error) {
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#fail(`Iron customer creation failed: ${String(error)}`);
+ }
+ }
+
/**
* Loads the disclaimers for the resolved (or provided) country.
*
@@ -586,10 +747,14 @@ export class KycController extends BaseController<
state.geoCountry = country;
});
}
- const disclaimers = await this.messenger.call(
- 'KycService:fetchDisclaimers',
- { country },
- );
+ const disclaimers =
+ this.state.activeVendor === 'iron'
+ ? await this.messenger.call('KycService:fetchIronDisclaimers', {
+ country,
+ })
+ : await this.messenger.call('KycService:fetchDisclaimers', {
+ country,
+ });
this.#updateIfCurrent(generation, (state) => {
state.disclaimers = disclaimers;
state.disclaimersError = null;
@@ -610,10 +775,16 @@ export class KycController extends BaseController<
* @param params.product - The consuming feature the flow runs for. See
* {@link initialize} for how the product drives the automatic post
* authentication continuation.
+ * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were
+ * accepted (T&C2). Defaults to `true` when omitted.
+ * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted
+ * (T&C2). Defaults to `true` when omitted.
*/
async acceptTermsAndStartSession(params?: {
email?: string;
product?: KycProduct;
+ sumsubTncSigned?: boolean;
+ idosTncSigned?: boolean;
}): Promise {
const termsAcceptedAt = new Date().toISOString();
const disclaimerIds = this.state.disclaimers.map(
@@ -629,9 +800,100 @@ export class KycController extends BaseController<
state.termsAcceptedAt = termsAcceptedAt;
state.acceptedDisclaimerIds = disclaimerIds;
});
+ if (this.state.activeVendor === 'iron') {
+ await this.#startIronSession({
+ sumsubTncSigned: params?.sumsubTncSigned ?? true,
+ idosTncSigned: params?.idosTncSigned ?? true,
+ });
+ return;
+ }
await this.#createSession();
}
+ /**
+ * Iron-only path: post consents (Iron signings + Sumsub/idOS ack), then
+ * launch SumSub — skipping MoonPay Check/Auth frames.
+ *
+ * @param consents - T&C2 boolean flags.
+ * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted.
+ * @param consents.idosTncSigned - Whether idOS T&C were accepted.
+ */
+ async #startIronSession(consents: {
+ sumsubTncSigned: boolean;
+ idosTncSigned: boolean;
+ }): Promise {
+ const { email, acceptedDisclaimerIds } = this.state;
+ if (!email) {
+ this.#fail('Missing email for Iron session.');
+ return;
+ }
+ if (acceptedDisclaimerIds.length === 0) {
+ this.#fail('Missing Iron disclaimer acceptance.');
+ return;
+ }
+
+ const generation = this.#generation;
+ this.#applyUpdate((state) => {
+ state.error = null;
+ state.phase = 'session';
+ state.statusMessage = 'Submitting consents...';
+ // Iron has no MoonPay session/access tokens.
+ state.sessionToken = null;
+ state.accessToken = null;
+ });
+
+ try {
+ await this.messenger.call('KycService:submitConsents', {
+ ironDisclaimerIds: acceptedDisclaimerIds,
+ sumsubTncSigned: consents.sumsubTncSigned,
+ idosTncSigned: consents.idosTncSigned,
+ });
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#applyUpdate((state) => {
+ state.phase = 'submit';
+ state.statusMessage = 'Starting document verification...';
+ });
+ const sumsubResult = await this.startSumSub();
+ if (this.#generation !== generation) {
+ return;
+ }
+ const sumsubError = sumsubResult?.error;
+ if (typeof sumsubError === 'string') {
+ throw new Error(sumsubError);
+ }
+ // After SumSub, refresh user-keyed status for the Money toast and start
+ // polling while still pending. Soft-fail: toast refresh must not rewind
+ // the consent / SumSub outcome.
+ try {
+ await this.refreshKycStatus();
+ } catch (statusError) {
+ console.error('KYC status refresh failed:', statusError);
+ }
+ this.#updateIfCurrent(generation, (state) => {
+ if (state.phase !== 'error' && state.phase !== 'done') {
+ state.phase = 'done';
+ state.statusMessage = 'KYC submitted.';
+ }
+ });
+ } catch (error) {
+ console.error('Iron session failed:', error);
+ if (this.#generation !== generation) {
+ return;
+ }
+ this.#applyUpdate((state) => {
+ this.#clearAcceptedTerms(state);
+ state.activeProduct = null;
+ state.error = `Iron session failed: ${String(error)}`;
+ state.statusMessage =
+ 'Consent / verification failed — accept the terms to try again.';
+ state.phase = 'terms';
+ });
+ await this.loadDisclaimers();
+ }
+ }
+
/**
* Creates a vendor session from the currently stored terms + email.
*/
@@ -1037,6 +1299,26 @@ export class KycController extends BaseController<
return this.state.kycRequiredByProduct[params.product];
}
+ /**
+ * Returns the vendor-scoped identity for the currently authenticated
+ * customer, or `null` when the flow has not yet captured a vendor customer
+ * id (before authentication or after {@link reset}).
+ *
+ * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor
+ * customer id to downstream calls without reading the full KYC state, which
+ * also holds session/access tokens. The id is session-scoped and never
+ * persisted.
+ *
+ * @returns The current {@link KycCustomerIdentity}, or `null`.
+ */
+ getCustomerIdentity(): KycCustomerIdentity | null {
+ const { moonpayCustomerId, activeVendor } = this.state;
+ if (!moonpayCustomerId) {
+ return null;
+ }
+ return { vendor: activeVendor, id: moonpayCustomerId };
+ }
+
/**
* Runs the SumSub document-verification sub-flow end to end:
*
@@ -1142,14 +1424,20 @@ 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,
- vendorMetadata: {
- moonPayAccessToken: this.state.accessToken,
- moonPayUserId: this.state.moonpayCustomerId,
- },
+ vendorId: isIron ? 'iron' : 'moonpay',
+ ...(isIron
+ ? {}
+ : {
+ vendorMetadata: {
+ moonPayAccessToken: this.state.accessToken,
+ moonPayUserId: this.state.moonpayCustomerId,
+ },
+ }),
wrappedEncryptionKey,
ukycCapabilityToken,
},
@@ -1256,6 +1544,28 @@ 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)) {
+ // A reset() may have landed while `launch` was in flight; forcing
+ // `completed` (and publishing `statusChanged`) on an idle controller
+ // would resurrect a flow the consumer already tore down.
+ if (this.#generation !== generation) {
+ return { alreadyCompleted: true };
+ }
+ this.#applyUserStatus({
+ status: 'completed',
+ sumsubSessionId: null,
+ errorCode: null,
+ });
+ this.#updateIfCurrent(generation, (state) => {
+ state.sumsub.status = 'complete';
+ state.sumsub.result = { alreadyCompleted: true };
+ state.statusMessage = 'KYC already completed.';
+ state.phase = 'done';
+ state.error = null;
+ });
+ return { alreadyCompleted: true };
+ }
const result = { error: String(error) };
this.#updateIfCurrent(generation, (state) => {
state.sumsub.status = 'failed';
@@ -1265,6 +1575,139 @@ export class KycController extends BaseController<
}
}
+ /**
+ * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`,
+ * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and
+ * schedules short-interval polling while the status is `pending`.
+ *
+ * @returns The latest status payload.
+ */
+ async refreshKycStatus(): Promise<{
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ }> {
+ const payload = await this.#fetchAndApplyUserStatus();
+ if (payload.status === 'pending') {
+ this.#ensureUserStatusPolling();
+ } else {
+ this.#stopUserStatusPolling();
+ }
+ return payload;
+ }
+
+ /**
+ * Fetches `GET /kyc/status` and applies it to state without managing the
+ * poll loop (used by both {@link refreshKycStatus} and the poll tick).
+ *
+ * @returns The latest status payload.
+ */
+ async #fetchAndApplyUserStatus(): Promise<{
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ }> {
+ const generation = this.#generation;
+ const response = await this.messenger.call('KycService:fetchKycStatus');
+ if (this.#generation !== generation) {
+ return {
+ status: this.state.userStatus ?? 'not-started',
+ sumsubSessionId: this.state.userStatusSumsubSessionId,
+ errorCode: this.state.userStatusErrorCode,
+ };
+ }
+ const payload = {
+ status: response.status,
+ sumsubSessionId: response.sumsubSessionId ?? null,
+ errorCode: response.errorCode ?? null,
+ };
+ this.#applyUserStatus(payload);
+ return payload;
+ }
+
+ /**
+ * Writes user-keyed status onto state and publishes `statusChanged` when the
+ * value actually changes.
+ *
+ * @param payload - The status payload to apply.
+ * @param payload.status - User-keyed KYC status from `GET /kyc/status`.
+ * @param payload.sumsubSessionId - Optional SumSub session id from status.
+ * @param payload.errorCode - Optional error code from status.
+ */
+ #applyUserStatus(payload: {
+ status: KycUserStatus;
+ sumsubSessionId: string | null;
+ errorCode: string | null;
+ }): void {
+ const previous = this.state.userStatus;
+ this.#applyUpdate((state) => {
+ state.userStatus = payload.status;
+ state.userStatusSumsubSessionId = payload.sumsubSessionId;
+ state.userStatusErrorCode = payload.errorCode;
+ });
+ if (previous !== payload.status) {
+ this.messenger.publish(`${controllerName}:statusChanged`, payload);
+ }
+ }
+
+ /**
+ * Starts the user-status poll loop when not already running and status is
+ * still `pending`.
+ */
+ #ensureUserStatusPolling(): void {
+ if (this.#userStatusPollTimer !== null) {
+ return;
+ }
+ const token = this.#userStatusPollToken;
+ const tick = async (): Promise => {
+ try {
+ const payload = await this.#fetchAndApplyUserStatus();
+ // Race with `reset()` / `#stopUserStatusPolling` while the request was
+ // in flight — do not reschedule onto an idle controller.
+ /* istanbul ignore next */
+ if (this.#userStatusPollToken !== token) {
+ return;
+ }
+ if (payload.status !== 'pending') {
+ this.#stopUserStatusPolling();
+ return;
+ }
+ } catch {
+ // Keep polling on transient errors, unless the loop was superseded.
+ /* istanbul ignore next */
+ if (this.#userStatusPollToken !== token) {
+ return;
+ }
+ }
+ this.#userStatusPollTimer = setTimeout(() => {
+ this.#userStatusPollTimer = null;
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ tick();
+ }, this.#userStatusPollIntervalMs);
+ // Allow the process to exit while a pending-status poll is scheduled.
+ // React Native / browser timers are numbers with no `unref`, hence the
+ // optional call.
+ this.#userStatusPollTimer.unref?.();
+ };
+ this.#userStatusPollTimer = setTimeout(() => {
+ this.#userStatusPollTimer = null;
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ tick();
+ }, this.#userStatusPollIntervalMs);
+ this.#userStatusPollTimer.unref?.();
+ }
+
+ /**
+ * Stops the user-keyed status poll loop.
+ */
+ #stopUserStatusPolling(): void {
+ this.#userStatusPollToken += 1;
+ if (this.#userStatusPollTimer !== null) {
+ clearTimeout(this.#userStatusPollTimer);
+ this.#userStatusPollTimer = null;
+ }
+ }
+
/**
* Fetches the current UKYC session status for the active sub-flow and records
* it on state. Useful for a one-off refresh outside the automatic polling
@@ -1397,6 +1840,7 @@ export class KycController extends BaseController<
// Stop any session-status polling so a late poll cannot write onto the
// now-idle controller.
this.#stopPolling();
+ this.#stopUserStatusPolling();
// Invalidate any in-flight async work started before this reset so its
// results are discarded rather than written onto the now-idle controller.
this.#generation += 1;
@@ -1409,6 +1853,7 @@ export class KycController extends BaseController<
state.sessionToken = null;
state.accessToken = null;
state.moonpayCustomerId = null;
+ state.activeVendor = 'moonpay';
state.activeProduct = null;
state.sumsub = {
status: 'idle',
diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts
index 2d642d9ab2..7f38f86aa1 100644
--- a/packages/kyc-controller/src/KycService-method-action-types.ts
+++ b/packages/kyc-controller/src/KycService-method-action-types.ts
@@ -53,6 +53,66 @@ export type KycServiceCheckKycRequiredAction = {
handler: KycService['checkKycRequired'];
};
+/**
+ * Creates (or resumes) an Iron empty-shell customer for the authenticated
+ * canonical user. Must run before showing Iron T&C so the customer exists in
+ * `SigningsRequired` and resume logic can key off Iron status.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email associated with the Iron customer.
+ * @returns The Iron customer record (subset validated for controller use).
+ */
+export type KycServiceCreateIronCustomerAction = {
+ type: `KycService:createIronCustomer`;
+ handler: KycService['createIronCustomer'];
+};
+
+/**
+ * Fetches Iron disclaimers / terms the customer must accept before consents
+ * and the SumSub sub-flow.
+ *
+ * @param params - The parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code.
+ * @returns The disclaimers.
+ */
+export type KycServiceFetchIronDisclaimersAction = {
+ type: `KycService:fetchIronDisclaimers`;
+ handler: KycService['fetchIronDisclaimers'];
+};
+
+/**
+ * Checks whether Iron still requires KYC for the authenticated canonical
+ * user. Unlike the MoonPay variant, this does not take an access token.
+ *
+ * @returns Whether KYC is required.
+ */
+export type KycServiceCheckIronKycRequiredAction = {
+ type: `KycService:checkIronKycRequired`;
+ handler: KycService['checkIronKycRequired'];
+};
+
+/**
+ * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the
+ * authenticated user. The API responds with 204 No Content on success.
+ *
+ * @param params - The consent parameters.
+ */
+export type KycServiceSubmitConsentsAction = {
+ type: `KycService:submitConsents`;
+ handler: KycService['submitConsents'];
+};
+
+/**
+ * Fetches the user-keyed simplified KYC status used by Money toast / banner
+ * surfaces (`GET /kyc/status`).
+ *
+ * @returns The simplified status payload.
+ */
+export type KycServiceFetchKycStatusAction = {
+ type: `KycService:fetchKycStatus`;
+ handler: KycService['fetchKycStatus'];
+};
+
/**
* Requests a per-session wrapping key from the UKYC backend.
*
@@ -133,6 +193,11 @@ export type KycServiceMethodActions =
| KycServiceFetchDisclaimersAction
| KycServiceCreateSessionAction
| KycServiceCheckKycRequiredAction
+ | KycServiceCreateIronCustomerAction
+ | KycServiceFetchIronDisclaimersAction
+ | KycServiceCheckIronKycRequiredAction
+ | KycServiceSubmitConsentsAction
+ | KycServiceFetchKycStatusAction
| KycServiceGetWrappingKeyAction
| KycServiceFetchJwksAction
| KycServiceCreateUkycSessionAction
diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts
index 59bf709002..c7d5d6c9db 100644
--- a/packages/kyc-controller/src/KycService.test.ts
+++ b/packages/kyc-controller/src/KycService.test.ts
@@ -87,6 +87,13 @@ describe('KycService', () => {
).rejects.toThrow(/Malformed response received from disclaimers API/u);
});
+ it('throws when no bearer token is available', async () => {
+ const { service } = getService({ bearerToken: '' });
+ await expect(
+ service.fetchDisclaimers({ country: 'USA' }),
+ ).rejects.toThrow(/Unable to obtain an authentication bearer token/u);
+ });
+
it('throws an HttpError on a non-ok response', async () => {
nock(MOCK_API_URL)
.get('/vendors/moonpay/disclaimers')
@@ -230,8 +237,8 @@ describe('KycService', () => {
});
it('throws when no Fractal base URL is configured', async () => {
- // An empty base URL exercises the constructor's "not configured" guard.
- const { service } = getService({ fractalEncryptionBaseUrl: '' });
+ // Omit the option entirely so the constructor falls back to ''.
+ const { service } = getService({ fractalEncryptionBaseUrl: null });
await expect(service.fetchJwks()).rejects.toThrow(
/fractalEncryptionBaseUrl is not configured/u,
@@ -417,6 +424,276 @@ describe('KycService', () => {
service.getSessionStatus({ sessionId: 'sid' }),
).rejects.toThrow(/failed with status '404'/u);
});
+
+ it('includes the API error message in HttpError when present', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { message: 'session_not_in_valid_state' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/session_not_in_valid_state/u);
+ });
+
+ it('includes the API error field in HttpError when message is absent', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { error: 'session_not_in_valid_state' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/session_not_in_valid_state/u);
+ });
+
+ it('prefers a string error field when message is not a string', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { message: 123, error: 'session_not_in_valid_state' });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/session_not_in_valid_state/u);
+ });
+
+ it('falls back to status-only HttpError when the body has no useful fields', async () => {
+ nock(MOCK_API_URL)
+ .get('/sessions/sid/status')
+ .reply(409, { message: 1, error: 2 });
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/failed with status '409'$/u);
+ });
+
+ it('falls back to status-only HttpError when the body is not an object', async () => {
+ nock(MOCK_API_URL).get('/sessions/sid/status').reply(409, null);
+ const { service } = getService();
+
+ await expect(
+ service.getSessionStatus({ sessionId: 'sid' }),
+ ).rejects.toThrow(/failed with status '409'$/u);
+ });
+ });
+
+ describe('createIronCustomer', () => {
+ it('creates an Iron customer and returns the validated subset', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/iron/customers', { email: 'a@b.co' })
+ .reply(200, {
+ id: 'iron-1',
+ email: 'a@b.co',
+ status: 'SigningsRequired',
+ customer_type: 'Person',
+ name: '',
+ partner_id: 'p',
+ identification_ids: [],
+ signing_ids: [],
+ created_at: '2026-01-01T00:00:00.000Z',
+ updated_at: '2026-01-01T00:00:00.000Z',
+ });
+ const { service } = getService();
+
+ expect(
+ await service.createIronCustomer({ email: 'a@b.co' }),
+ ).toMatchObject({
+ id: 'iron-1',
+ email: 'a@b.co',
+ status: 'SigningsRequired',
+ });
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/vendors/iron/customers').reply(200, {});
+ const { service } = getService();
+
+ await expect(
+ service.createIronCustomer({ email: 'a@b.co' }),
+ ).rejects.toThrow(/Malformed response received from iron customers API/u);
+ });
+ });
+
+ describe('fetchIronDisclaimers', () => {
+ it('returns Iron disclaimers for a country', async () => {
+ const disclaimers = [
+ { id: '1', display_name: 'Iron Terms', url: 'https://t' },
+ ];
+ nock(MOCK_API_URL)
+ .get('/vendors/iron/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, disclaimers);
+ const { service } = getService();
+
+ expect(
+ await service.fetchIronDisclaimers({ country: 'USA' }),
+ ).toStrictEqual(disclaimers);
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL)
+ .get('/vendors/iron/disclaimers')
+ .query({ country: 'USA' })
+ .reply(200, [{ id: 1 }]);
+ const { service } = getService();
+
+ await expect(
+ service.fetchIronDisclaimers({ country: 'USA' }),
+ ).rejects.toThrow(
+ /Malformed response received from iron disclaimers API/u,
+ );
+ });
+ });
+
+ describe('checkIronKycRequired', () => {
+ it('returns whether Iron KYC is required', async () => {
+ nock(MOCK_API_URL)
+ .post('/vendors/iron/kyc-required')
+ .reply(200, { required: true });
+ const { service } = getService();
+
+ expect(await service.checkIronKycRequired()).toStrictEqual({
+ kycRequired: true,
+ });
+ });
+
+ it('throws on a malformed response', async () => {
+ nock(MOCK_API_URL).post('/vendors/iron/kyc-required').reply(200, {});
+ const { service } = getService();
+
+ await expect(service.checkIronKycRequired()).rejects.toThrow(
+ /Malformed response received from iron kyc-required API/u,
+ );
+ });
+ });
+
+ describe('submitConsents', () => {
+ it('posts consents and accepts a 204 response', async () => {
+ nock(MOCK_API_URL)
+ .post('/consents', {
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ kycLevel: 'standard',
+ })
+ .reply(204);
+ const { service } = getService();
+
+ expect(
+ await service.submitConsents({
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ }),
+ ).toBeUndefined();
+ });
+
+ it('throws an HttpError on a non-ok response', async () => {
+ nock(MOCK_API_URL).post('/consents').reply(500);
+ const { service } = getService();
+
+ await expect(
+ service.submitConsents({
+ ironDisclaimerIds: ['d1'],
+ sumsubTncSigned: true,
+ idosTncSigned: true,
+ }),
+ ).rejects.toThrow(/failed with status '500'/u);
+ });
+ });
+
+ describe('fetchKycStatus', () => {
+ it('returns the simplified user-keyed status', async () => {
+ nock(MOCK_API_URL).get('/kyc/status').reply(200, {
+ status: 'pending',
+ sumsubSessionId: 'ss-1',
+ });
+ const { service } = getService();
+
+ expect(await service.fetchKycStatus()).toStrictEqual({
+ status: 'pending',
+ sumsubSessionId: 'ss-1',
+ });
+ });
+
+ it('throws on an unknown status value', async () => {
+ nock(MOCK_API_URL).get('/kyc/status').reply(200, { status: 'weird' });
+ const { service } = getService();
+
+ await expect(service.fetchKycStatus()).rejects.toThrow(
+ /Malformed response received from kyc status API/u,
+ );
+ });
+ });
+
+ describe('createUkycSession vendorId', () => {
+ it('defaults vendorId to moonpay and forwards vendorMetadata', async () => {
+ const material = deriveClientMaterial(
+ new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1),
+ );
+ const ukycCapabilityToken = signStorageAccessToken({
+ material,
+ operations: ['read'],
+ expiresAt: new Date('2099-01-01T00:00:00.000Z'),
+ });
+ nock(MOCK_API_URL)
+ .post('/sessions', (body) => {
+ return (
+ body.vendorId === 'moonpay' &&
+ body.vendorMetadata?.moonPayAccessToken === 'tok'
+ );
+ })
+ .reply(200, { sessionId: 'sid' });
+ const { service } = getService();
+
+ expect(
+ await service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorMetadata: { moonPayAccessToken: 'tok' },
+ wrappedEncryptionKey: {
+ sessionId: 'wk',
+ encryptedKey: 'ek',
+ nonce: 'n',
+ },
+ ukycCapabilityToken,
+ }),
+ ).toStrictEqual({ sessionId: 'sid' });
+ });
+
+ it('sends vendorId iron with empty vendorMetadata when omitted', async () => {
+ const material = deriveClientMaterial(
+ new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1),
+ );
+ const ukycCapabilityToken = signStorageAccessToken({
+ material,
+ operations: ['read'],
+ expiresAt: new Date('2099-01-01T00:00:00.000Z'),
+ });
+ nock(MOCK_API_URL)
+ .post('/sessions', (body) => {
+ return (
+ body.vendorId === 'iron' &&
+ JSON.stringify(body.vendorMetadata) === '{}'
+ );
+ })
+ .reply(200, { sessionId: 'sid-iron' });
+ const { service } = getService();
+
+ expect(
+ await service.createUkycSession({
+ jwtToken: 'jwt',
+ vendorId: 'iron',
+ wrappedEncryptionKey: {
+ sessionId: 'wk',
+ encryptedKey: 'ek',
+ nonce: 'n',
+ },
+ ukycCapabilityToken,
+ }),
+ ).toStrictEqual({ sessionId: 'sid-iron' });
+ });
});
describe('baseUrl', () => {
@@ -435,6 +712,12 @@ describe('KycService', () => {
disclaimers,
);
});
+
+ it('throws when baseUrl is empty', () => {
+ expect(() => getService({ baseUrl: '' })).toThrow(
+ 'KycService: baseUrl is required',
+ );
+ });
});
describe('messenger actions', () => {
@@ -468,8 +751,8 @@ type RootMessenger = Messenger<
* @param args.geolocation - The location the geolocation handler returns.
* @param args.defaultPolicy - When true, omit `policyOptions` to use defaults.
* @param args.baseUrl - Base URL of the KYC API.
- * @param args.fractalEncryptionBaseUrl - Fractal base URL; pass `''` to
- * exercise the service's "not configured" guard.
+ * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the
+ * option so the service falls back to an empty string.
* @returns The service, root messenger, and service messenger.
*/
function getService({
@@ -477,13 +760,15 @@ function getService({
geolocation = 'US-NY',
defaultPolicy = false,
baseUrl = MOCK_API_URL,
+ // `null` means "omit the option entirely" (exercises the constructor's
+ // `?? ''` fallback); omitting the field defaults to the mock Fractal URL.
fractalEncryptionBaseUrl = MOCK_FRACTAL_URL,
}: {
bearerToken?: string;
geolocation?: string | null;
defaultPolicy?: boolean;
baseUrl?: string;
- fractalEncryptionBaseUrl?: string;
+ fractalEncryptionBaseUrl?: string | null;
} = {}): {
service: KycService;
rootMessenger: RootMessenger;
@@ -514,9 +799,10 @@ function getService({
);
const service = new KycService({
+ fetch,
messenger,
baseUrl,
- fractalEncryptionBaseUrl,
+ ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }),
...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }),
});
diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts
index a6d92536de..35677e1d8f 100644
--- a/packages/kyc-controller/src/KycService.ts
+++ b/packages/kyc-controller/src/KycService.ts
@@ -14,6 +14,7 @@ import {
array,
assert,
boolean,
+ enums,
optional,
string,
StructError,
@@ -25,7 +26,12 @@ import type { QueryClientConfig } from '@tanstack/query-core';
import { alpha2ToAlpha3 } from './countryCodes.js';
import type { KycServiceMethodActions } from './KycService-method-action-types.js';
-import type { KycDisclaimer, KycSessionStatus } from './types.js';
+import type {
+ KycDisclaimer,
+ KycSessionStatus,
+ KycUserStatusResponse,
+ KycVendor,
+} from './types.js';
import { UKYC_JWKS_PATH } from './ukyc/constants.js';
import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js';
import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js';
@@ -44,6 +50,11 @@ const MESSENGER_EXPOSED_METHODS = [
'fetchDisclaimers',
'createSession',
'checkKycRequired',
+ 'createIronCustomer',
+ 'fetchIronDisclaimers',
+ 'checkIronKycRequired',
+ 'submitConsents',
+ 'fetchKycStatus',
'getWrappingKey',
'fetchJwks',
'createUkycSession',
@@ -111,14 +122,18 @@ export type KycServiceMessenger = Messenger<
*/
export type KycServiceOptions = {
messenger: KycServiceMessenger;
+ fetch: typeof fetch;
/**
- * Base url of the KYC api
+ * Mandatory value that sets the base url to KYC api
*/
baseUrl: string;
/**
- * Base URL of the Fractal encryption api
+ * Base URL of the Fractal encryption service, from which the JWKS used to
+ * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is
+ * fetched. Required to run the wrapping-key exchange in
+ * {@link KycService.fetchJwks}.
*/
- fractalEncryptionBaseUrl: string;
+ fractalEncryptionBaseUrl?: string;
/**
* Shared configuration applied to all queries exposed by the service (e.g. a
* default `staleTime`/`gcTime`). Each data service gets its own
@@ -196,6 +211,29 @@ const SessionStatusResponseStruct = type({
vendorStatus: string(),
});
+// Iron customer subset — `type` (not `object`) keeps extra Iron fields from
+// failing validation while still requiring the fields the controller needs.
+const IronCustomerResponseStruct = type({
+ id: string(),
+ email: string(),
+ status: string(),
+});
+export type IronCustomerResponse = Infer;
+
+const KYC_USER_STATUSES = [
+ 'not-started',
+ 'pending',
+ 'need-more-information',
+ 'terminal-failure',
+ 'completed',
+] as const;
+
+const KycUserStatusResponseStruct = type({
+ status: enums([...KYC_USER_STATUSES]),
+ sumsubSessionId: optional(string()),
+ errorCode: optional(string()),
+});
+
// === PARAM TYPES ===
export type CreateSessionParams = {
@@ -210,6 +248,17 @@ export type CheckKycRequiredParams = {
capabilities?: { product: string }[];
};
+export type CreateIronCustomerParams = {
+ email: string;
+};
+
+export type SubmitConsentsParams = {
+ ironDisclaimerIds: string[];
+ sumsubTncSigned: boolean;
+ idosTncSigned: boolean;
+ kycLevel?: 'standard';
+};
+
export type GetWrappingKeyParams = {
sessionClientPublicKey: string;
};
@@ -227,7 +276,17 @@ export type WrappedEncryptionKey = {
export type CreateUkycSessionParams = {
jwtToken: string;
- vendorMetadata: Record;
+ /**
+ * Identity vendor for the UKYC session. Defaults to `moonpay` for the
+ * existing Check/Auth flow. Pass `iron` for the Money/VBA path (no MoonPay
+ * metadata required).
+ */
+ vendorId?: KycVendor;
+ /**
+ * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` /
+ * `moonPayUserId`); optional / omitted for Iron.
+ */
+ vendorMetadata?: Record;
wrappedEncryptionKey: WrappedEncryptionKey;
/**
* The client-signed `ukyc_capability_token` (envelope: payload + Ed25519
@@ -249,7 +308,7 @@ 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 the global `fetch`, and the auth bearer token and
+ * HTTP is performed through an injected `fetch`, and the auth bearer token and
* geolocation come from other controllers via the messenger.
*
* It extends {@link BaseDataService}, so every request is routed through
@@ -263,6 +322,8 @@ export class KycService extends BaseDataService<
typeof serviceName,
KycServiceMessenger
> {
+ readonly #fetch: typeof fetch;
+
readonly #baseUrl: string;
readonly #fractalEncryptionBaseUrl: string;
@@ -272,6 +333,7 @@ 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.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
@@ -282,6 +344,7 @@ export class KycService extends BaseDataService<
*/
constructor({
messenger,
+ fetch: fetchFunction,
baseUrl,
fractalEncryptionBaseUrl,
queryClientConfig = {},
@@ -293,8 +356,12 @@ export class KycService extends BaseDataService<
queryClientConfig,
policyOptions,
});
+ this.#fetch = fetchFunction;
+ if (!baseUrl) {
+ throw new Error('KycService: baseUrl is required');
+ }
this.#baseUrl = baseUrl;
- this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl;
+ this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? '';
this.messenger.registerMethodActionHandlers(
this,
MESSENGER_EXPOSED_METHODS,
@@ -434,6 +501,141 @@ export class KycService extends BaseDataService<
return { kycRequired: required };
}
+ /**
+ * Creates (or resumes) an Iron empty-shell customer for the authenticated
+ * canonical user. Must run before showing Iron T&C so the customer exists in
+ * `SigningsRequired` and resume logic can key off Iron status.
+ *
+ * @param params - The parameters.
+ * @param params.email - Email associated with the Iron customer.
+ * @returns The Iron customer record (subset validated for controller use).
+ */
+ async createIronCustomer(
+ params: CreateIronCustomerParams,
+ ): Promise {
+ const url = new URL('/vendors/iron/customers', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:createIronCustomer`, params.email],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({ email: params.email }),
+ }),
+ // Customer creation/resume must never serve a stale/cached result.
+ staleTime: 0,
+ gcTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ IronCustomerResponseStruct,
+ 'iron customers',
+ );
+ }
+
+ /**
+ * Fetches Iron disclaimers / terms the customer must accept before consents
+ * and the SumSub sub-flow.
+ *
+ * @param params - The parameters.
+ * @param params.country - ISO 3166-1 alpha-3 country code.
+ * @returns The disclaimers.
+ */
+ async fetchIronDisclaimers({
+ country,
+ }: {
+ country: string;
+ }): Promise {
+ const url = new URL('/vendors/iron/disclaimers', this.#baseUrl);
+ url.searchParams.set('country', country);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchIronDisclaimers`, country],
+ queryFn: async () => this.#requestJson(url, { method: 'GET' }),
+ staleTime: inMilliseconds(5, Duration.Minute),
+ });
+ return this.#validateResponse(
+ data,
+ DisclaimersResponseStruct,
+ 'iron disclaimers',
+ ) as KycDisclaimer[];
+ }
+
+ /**
+ * Checks whether Iron still requires KYC for the authenticated canonical
+ * user. Unlike the MoonPay variant, this does not take an access token.
+ *
+ * @returns Whether KYC is required.
+ */
+ async checkIronKycRequired(): Promise<{ kycRequired: boolean }> {
+ const url = new URL('/vendors/iron/kyc-required', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:checkIronKycRequired`],
+ queryFn: async () =>
+ this.#requestJson(url, { method: 'POST', body: '{}' }),
+ // The requirement can change server-side, so always re-check.
+ staleTime: 0,
+ gcTime: 0,
+ });
+ const { required } = this.#validateResponse(
+ data,
+ KycRequiredResponseStruct,
+ 'iron kyc-required',
+ );
+ return { kycRequired: required };
+ }
+
+ /**
+ * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the
+ * authenticated user. The API responds with 204 No Content on success.
+ *
+ * @param params - The consent parameters.
+ */
+ async submitConsents(params: SubmitConsentsParams): Promise {
+ const url = new URL('/consents', this.#baseUrl);
+ await this.fetchQuery({
+ queryKey: [
+ `${this.name}:submitConsents`,
+ params.ironDisclaimerIds,
+ params.sumsubTncSigned,
+ params.idosTncSigned,
+ params.kycLevel ?? 'standard',
+ ],
+ queryFn: async () =>
+ this.#requestJson(url, {
+ method: 'POST',
+ body: JSON.stringify({
+ ironDisclaimerIds: params.ironDisclaimerIds,
+ sumsubTncSigned: params.sumsubTncSigned,
+ idosTncSigned: params.idosTncSigned,
+ kycLevel: params.kycLevel ?? 'standard',
+ }),
+ }),
+ staleTime: 0,
+ gcTime: 0,
+ });
+ }
+
+ /**
+ * Fetches the user-keyed simplified KYC status used by Money toast / banner
+ * surfaces (`GET /kyc/status`).
+ *
+ * @returns The simplified status payload.
+ */
+ async fetchKycStatus(): Promise {
+ const url = new URL('/kyc/status', this.#baseUrl);
+ const data = await this.fetchQuery({
+ queryKey: [`${this.name}:fetchKycStatus`],
+ queryFn: async () => this.#requestJson(url, { method: 'GET' }),
+ // Status is polled for toast flips, so it must always be fresh.
+ staleTime: 0,
+ gcTime: 0,
+ });
+ return this.#validateResponse(
+ data,
+ KycUserStatusResponseStruct,
+ 'kyc status',
+ );
+ }
+
/**
* Requests a per-session wrapping key from the UKYC backend.
*
@@ -519,10 +721,10 @@ export class KycService extends BaseDataService<
this.#requestJson(url, {
method: 'POST',
body: JSON.stringify({
- vendorId: 'moonpay',
+ vendorId: params.vendorId ?? 'moonpay',
vendorUserId: 'mockedId',
jwtToken: params.jwtToken,
- vendorMetadata: params.vendorMetadata,
+ vendorMetadata: params.vendorMetadata ?? {},
wrappedEncryptionKey: params.wrappedEncryptionKey,
ukycCapabilityToken: encodeStorageAccessTokenForHeader(
params.ukycCapabilityToken,
@@ -669,20 +871,46 @@ export class KycService extends BaseDataService<
'AuthenticationController:getBearerToken',
);
assert(bearerToken, string());
+ if (!bearerToken) {
+ throw new Error(
+ 'Unable to obtain an authentication bearer token — is the wallet signed in?',
+ );
+ }
headers.Authorization = `Bearer ${bearerToken}`;
}
- const response = await fetch(url.toString(), {
+ const response = await this.#fetch(url.toString(), {
...init,
headers,
});
if (!response.ok) {
+ let detail = '';
+ try {
+ const errorBody: unknown = await response.json();
+ if (errorBody && typeof errorBody === 'object') {
+ const record = errorBody as Record;
+ if (typeof record.message === 'string') {
+ detail = record.message;
+ } else if (typeof record.error === 'string') {
+ detail = record.error;
+ }
+ }
+ } catch {
+ // Ignore body parse failures; status alone is still useful.
+ }
throw new HttpError(
response.status,
- `Fetching '${url.toString()}' failed with status '${response.status}'`,
+ `Fetching '${url.toString()}' failed with status '${response.status}'${
+ detail ? `: ${detail}` : ''
+ }`,
);
}
+ // Consent (and similar) endpoints return 204 No Content.
+ if (response.status === 204) {
+ return null;
+ }
+
return (await response.json()) as Json;
}
}
diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts
index 20830da2c5..d6b24b3730 100644
--- a/packages/kyc-controller/src/index.ts
+++ b/packages/kyc-controller/src/index.ts
@@ -11,6 +11,7 @@ export type {
KycControllerOptions,
KycControllerState,
KycControllerStateChangeEvent,
+ KycControllerStatusChangedEvent,
} from './KycController.js';
export type {
KycControllerAcceptTermsAndStartSessionAction,
@@ -19,11 +20,14 @@ export type {
KycControllerBuildResetFrameUrlAction,
KycControllerCheckKycRequiredAction,
KycControllerClearSavedTermsAction,
+ KycControllerCreateIronCustomerAction,
+ KycControllerGetCustomerIdentityAction,
KycControllerGetKycStatusAction,
KycControllerGetSessionStatusAction,
KycControllerHandleFrameMessageAction,
KycControllerInitializeAction,
KycControllerLoadDisclaimersAction,
+ KycControllerRefreshKycStatusAction,
KycControllerResetAction,
KycControllerStartSumSubAction,
} from './KycController-method-action-types.js';
@@ -32,10 +36,12 @@ export { KycService, serviceName } from './KycService.js';
export type {
ApplicantAccessTokenResponse,
CheckKycRequiredParams,
+ CreateIronCustomerParams,
CreateSessionParams,
CreateUkycSessionParams,
GetSessionStatusParams,
GetWrappingKeyParams,
+ IronCustomerResponse,
JwksResponse,
KycServiceActions,
KycServiceCacheUpdatedEvent,
@@ -44,20 +50,26 @@ export type {
KycServiceInvalidateQueriesAction,
KycServiceMessenger,
KycServiceOptions,
+ SubmitConsentsParams,
UkycSessionResponse,
WrappedEncryptionKey,
WrappingKeyResponse,
} from './KycService.js';
export type {
+ KycServiceCheckIronKycRequiredAction,
KycServiceCheckKycRequiredAction,
+ KycServiceCreateIronCustomerAction,
KycServiceCreateJourneyAction,
KycServiceCreateSessionAction,
KycServiceCreateUkycSessionAction,
KycServiceFetchDisclaimersAction,
+ KycServiceFetchIronDisclaimersAction,
KycServiceFetchJwksAction,
+ KycServiceFetchKycStatusAction,
KycServiceGetGeoCountryAction,
KycServiceGetSessionStatusAction,
KycServiceGetWrappingKeyAction,
+ KycServiceSubmitConsentsAction,
} from './KycService-method-action-types.js';
export {
@@ -76,6 +88,7 @@ export type {
} from './crypto.js';
export type {
+ KycCustomerIdentity,
KycDisclaimer,
KycPhase,
KycProduct,
@@ -83,6 +96,8 @@ export type {
KycSumSubLaunchParams,
KycSumSubLauncher,
KycSumSubStatus,
+ KycUserStatus,
+ KycUserStatusResponse,
KycVendor,
} from './types.js';
diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts
index 1323aea0c1..9b1ee6ebfa 100644
--- a/packages/kyc-controller/src/types.ts
+++ b/packages/kyc-controller/src/types.ts
@@ -8,29 +8,75 @@
/**
* A MetaMask feature that consumes KYC. Used to key the per-product
- * "is KYC required" cache so ramps and card can share one controller.
+ * "is KYC required" cache so ramps, card, and money can share one controller.
*/
-export type KycProduct = 'ramps' | 'card';
+export type KycProduct = 'ramps' | 'card' | 'money';
/**
* Identity vendors supported behind the KYC surface.
+ *
+ * - `moonpay` — MoonPay Check/Auth frames + SumSub documents.
+ * - `iron` — Iron-only Money/VBA path: empty-shell customer → consents →
+ * SumSub, with no MoonPay Check/Auth frames.
+ */
+export type KycVendor = 'moonpay' | 'iron';
+
+/**
+ * Vendor-scoped identity for the currently authenticated KYC customer.
+ *
+ * Exposed to consumers (e.g. ramps) that must attach the vendor customer id to
+ * downstream provider calls without reading the full KYC state, which also
+ * holds session/access tokens. The identifier is session-scoped: it is only
+ * available once the customer has authenticated through the current flow and
+ * is cleared on `reset()`.
+ */
+export type KycCustomerIdentity = {
+ /** The identity vendor that issued {@link KycCustomerIdentity.id}. */
+ vendor: KycVendor;
+ /** The vendor customer id (e.g. MoonPay customer UUID). */
+ id: string;
+};
+
+/**
+ * User-keyed KYC status returned by `GET /kyc/status` and stored for Money
+ * toast / banner rendering. Collapses Iron + SumSub / relay state into the
+ * offsite contract.
*/
-export type KycVendor = 'moonpay';
+export type KycUserStatus =
+ | 'not-started'
+ | 'pending'
+ | 'need-more-information'
+ | 'terminal-failure'
+ | 'completed';
+
+/**
+ * Payload from `GET /kyc/status`, including optional fields that power the
+ * 3-state error contract (retryable SumSub vs terminal vs EDD).
+ */
+export type KycUserStatusResponse = {
+ status: KycUserStatus;
+ /** Present when the user can reopen a SumSub session (retryable path). */
+ sumsubSessionId?: string;
+ /** Machine-readable error code for terminal / EDD UX. */
+ errorCode?: string;
+};
/**
* Phases of the end-to-end identity flow.
*
* - `idle` — nothing started.
* - `terms` — waiting for the customer to accept the vendor terms.
- * - `session` — creating the vendor session.
- * - `check` — running the invisible connection-check frame.
- * - `auth` — running the visible authentication (OTP) frame.
+ * - `session` — creating the vendor session (MoonPay) or posting consents
+ * (Iron).
+ * - `check` — running the invisible connection-check frame (MoonPay only).
+ * - `auth` — running the visible authentication (OTP) frame (MoonPay only).
* - `form` — authenticated. When the flow is scoped to a product, the
* KYC-required check runs automatically from here; otherwise the consumer
- * drives it manually via `checkKycRequired`.
- * - `submit` — submitting the KYC-required check.
- * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. When KYC is
- * required, the document-verification sub-flow is launched automatically.
+ * drives it manually via `checkKycRequired`. Iron skips this phase.
+ * - `submit` — submitting the KYC-required check / launching SumSub.
+ * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` /
+ * `userStatus`. When KYC is required, the document-verification sub-flow is
+ * launched automatically.
* - `error` — flow halted; see `error`.
*/
export type KycPhase =