From a40fa2fc3fed565aa8e9d5a961628accc60565df Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 12 Aug 2026 15:17:45 -0600 Subject: [PATCH 1/3] feat(kyc-controller): add Iron Money KYC path and status polling Introduce vendor:'iron' flow (no MoonPay Check/Auth frames) with KycService clients for Iron customers/disclaimers/consents and user-keyed GET /kyc/status, so Money can drive toast state against the planned Milestone 1 API contract. --- packages/kyc-controller/CHANGELOG.md | 1 + .../src/KycController-method-action-types.ts | 33 + .../kyc-controller/src/KycController.test.ts | 696 ++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 424 ++++++++++- .../src/KycService-method-action-types.ts | 65 ++ .../kyc-controller/src/KycService.test.ts | 270 +++++++ packages/kyc-controller/src/KycService.ts | 220 +++++- packages/kyc-controller/src/index.ts | 13 + packages/kyc-controller/src/types.ts | 50 +- 9 files changed, 1747 insertions(+), 25 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 71dc41e7c9..51617299f3 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 - 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)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 2586ea9c63..a6b747d88c 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`; @@ -154,6 +173,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 +212,7 @@ export type KycControllerResetAction = { */ export type KycControllerMethodActions = | KycControllerInitializeAction + | KycControllerCreateIronCustomerAction | KycControllerLoadDisclaimersAction | KycControllerAcceptTermsAndStartSessionAction | KycControllerClearSavedTermsAction @@ -191,5 +223,6 @@ export type KycControllerMethodActions = | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction | KycControllerStartSumSubAction + | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3c3078c27e..c74b32b7f6 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1769,6 +1769,663 @@ 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('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 +2448,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 +2483,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 +2553,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 +2596,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 3abb1eb9dc..242072501a 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -23,6 +23,8 @@ import type { KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, + KycUserStatus, + KycVendor, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { toBase64Url } from './encoding.js'; @@ -110,6 +112,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 +156,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 +177,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 +275,12 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, + activeVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, activeProduct: { includeInDebugSnapshot: true, includeInStateLogs: true, @@ -265,6 +299,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 +344,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 +367,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'initialize', 'loadDisclaimers', 'acceptTermsAndStartSession', + 'createIronCustomer', 'clearSavedTerms', 'handleFrameMessage', 'buildCheckFrameUrl', @@ -318,6 +375,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'buildResetFrameUrl', 'checkKycRequired', 'getKycStatus', + 'refreshKycStatus', 'startSumSub', 'getSessionStatus', 'reset', @@ -342,7 +400,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 +443,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 +514,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 +532,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 +551,7 @@ export class KycController extends BaseController< this.#sumsubLauncher = sumsubLauncher; this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; + this.#userStatusPollIntervalMs = userStatusPollIntervalMs; this.#keypair = generateKeyPair(); this.messenger.registerMethodActionHandlers( @@ -510,10 +603,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 +620,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 +630,7 @@ export class KycController extends BaseController< if (params?.email) { state.email = params.email; } + state.activeVendor = vendor; state.activeProduct = params?.product ?? null; }); @@ -550,12 +649,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 +689,32 @@ 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'; + }); + 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 +736,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 +764,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 +789,101 @@ 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. + */ + 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; + } + if ( + sumsubResult && + 'error' in sumsubResult && + typeof sumsubResult.error === 'string' + ) { + throw new Error(sumsubResult.error); + } + // 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. */ @@ -1142,14 +1394,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 +1514,22 @@ 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)) { + 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 +1539,134 @@ 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. + */ + #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. + 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 +1799,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 +1812,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 2b3e57e86a..80caa8e8cd 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -424,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(); + + await expect( + service.createIronCustomer({ email: 'a@b.co' }), + ).resolves.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(); + + await expect( + service.submitConsents({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }), + ).resolves.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', () => { diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 460105d4d4..9ab5f59247 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', @@ -199,6 +210,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 = { @@ -213,6 +247,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; }; @@ -230,7 +275,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 @@ -445,6 +500,140 @@ 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, + cacheTime: 0, + }); + return this.#validateResponse( + data, + IronCustomerResponseStruct, + 'iron customers', + ); + } + + /** + * Fetches Iron disclaimers / terms the customer must accept before consents + * and the SumSub sub-flow. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchIronDisclaimers({ + country, + }: { + country: string; + }): Promise { + const url = new URL('/vendors/iron/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchIronDisclaimers`, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: inMilliseconds(5, Duration.Minute), + }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'iron disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Checks whether Iron still requires KYC for the authenticated canonical + * user. Unlike the MoonPay variant, this does not take an access token. + * + * @returns Whether KYC is required. + */ + async checkIronKycRequired(): Promise<{ kycRequired: boolean }> { + const url = new URL('/vendors/iron/kyc-required', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:checkIronKycRequired`], + queryFn: async () => this.#requestJson(url, { method: 'POST', body: '{}' }), + // The requirement can change server-side, so always re-check. + staleTime: 0, + cacheTime: 0, + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'iron kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the + * authenticated user. The API responds with 204 No Content on success. + * + * @param params - The consent parameters. + */ + async submitConsents(params: SubmitConsentsParams): Promise { + const url = new URL('/consents', this.#baseUrl); + await this.fetchQuery({ + queryKey: [ + `${this.name}:submitConsents`, + params.ironDisclaimerIds, + params.sumsubTncSigned, + params.idosTncSigned, + params.kycLevel ?? 'standard', + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + ironDisclaimerIds: params.ironDisclaimerIds, + sumsubTncSigned: params.sumsubTncSigned, + idosTncSigned: params.idosTncSigned, + kycLevel: params.kycLevel ?? 'standard', + }), + }), + staleTime: 0, + cacheTime: 0, + }); + } + + /** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ + async fetchKycStatus(): Promise { + const url = new URL('/kyc/status', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchKycStatus`], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for toast flips, so it must always be fresh. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + KycUserStatusResponseStruct, + 'kyc status', + ); + } + /** * Requests a per-session wrapping key from the UKYC backend. * @@ -530,10 +719,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, @@ -693,12 +882,33 @@ export class KycService extends BaseDataService< 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..a3f79c15aa 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,13 @@ export type { KycControllerBuildResetFrameUrlAction, KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, + KycControllerCreateIronCustomerAction, KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, KycControllerInitializeAction, KycControllerLoadDisclaimersAction, + KycControllerRefreshKycStatusAction, KycControllerResetAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -32,10 +35,12 @@ export { KycService, serviceName } from './KycService.js'; export type { ApplicantAccessTokenResponse, CheckKycRequiredParams, + CreateIronCustomerParams, CreateSessionParams, CreateUkycSessionParams, GetSessionStatusParams, GetWrappingKeyParams, + IronCustomerResponse, JwksResponse, KycServiceActions, KycServiceCacheUpdatedEvent, @@ -44,20 +49,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 { @@ -83,6 +94,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..62b75370c4 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -8,29 +8,59 @@ /** * 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'; + +/** + * 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 = From 633f53f5921cf0cd7e8f2a0323ad4c80ab3a3476 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 12 Aug 2026 15:37:15 -0600 Subject: [PATCH 2/3] fix(kyc-controller): resolve CI lint and changelog failures Add PR link and formatting for changelog, fix JSDoc/restricted-syntax/jest matcher lint errors, and apply Prettier to the package. Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 4 ++-- packages/kyc-controller/README.md | 2 +- .../kyc-controller/scripts/mint-ukyc-test-token.ts | 4 +++- packages/kyc-controller/src/KycController.test.ts | 8 ++++++-- packages/kyc-controller/src/KycController.ts | 14 ++++++++------ packages/kyc-controller/src/KycService.test.ts | 12 ++++++------ packages/kyc-controller/src/KycService.ts | 8 +++++--- packages/kyc-controller/src/ukyc/testToken.test.ts | 6 +++--- 8 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 51617299f3..eabca887b9 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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 +- 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)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `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. diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 32ee194a13..7aaf642375 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -20,4 +20,4 @@ This watches `src/**/*.ts` and re-runs the build on each change (it also perform ## Contributing -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). \ No newline at end of file +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts index 82b51801ac..10b47b1ea4 100644 --- a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts +++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts @@ -98,7 +98,9 @@ if (flags['expires-at']) { } else if (flags['expires-in']) { const issuedAt = params.issuedAt ?? new Date(); params.issuedAt = issuedAt; - params.expiresAt = new Date(issuedAt.getTime() + parseDurationMs(flags['expires-in'])); + params.expiresAt = new Date( + issuedAt.getTime() + parseDurationMs(flags['expires-in']), + ); } const result = mintUkycTestToken(params); diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index c74b32b7f6..98ab1e7c07 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1810,7 +1810,9 @@ describe('KycController', () => { 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); + expect(controller.state.error).toMatch( + /Iron customer creation failed/u, + ); }); }); @@ -2027,7 +2029,9 @@ describe('KycController', () => { }, }, async ({ controller, handlers }) => { - handlers.createUkycSession.mockRejectedValue(new Error('sumsub down')); + handlers.createUkycSession.mockRejectedValue( + new Error('sumsub down'), + ); handlers.fetchIronDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 242072501a..50af784f9f 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -804,6 +804,8 @@ export class KycController extends BaseController< * 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; @@ -846,12 +848,9 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - if ( - sumsubResult && - 'error' in sumsubResult && - typeof sumsubResult.error === 'string' - ) { - throw new Error(sumsubResult.error); + 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 @@ -1594,6 +1593,9 @@ export class KycController extends BaseController< * 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; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 80caa8e8cd..c7d5d6c9db 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -497,9 +497,9 @@ describe('KycService', () => { }); const { service } = getService(); - await expect( - service.createIronCustomer({ email: 'a@b.co' }), - ).resolves.toMatchObject({ + expect( + await service.createIronCustomer({ email: 'a@b.co' }), + ).toMatchObject({ id: 'iron-1', email: 'a@b.co', status: 'SigningsRequired', @@ -581,13 +581,13 @@ describe('KycService', () => { .reply(204); const { service } = getService(); - await expect( - service.submitConsents({ + expect( + await service.submitConsents({ ironDisclaimerIds: ['d1'], sumsubTncSigned: true, idosTncSigned: true, }), - ).resolves.toBeUndefined(); + ).toBeUndefined(); }); it('throws an HttpError on a non-ok response', async () => { diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 9ab5f59247..e155ca8f38 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -85,8 +85,9 @@ type AllowedActions = /** * Published when {@link KycService}'s cache is updated. */ -export type KycServiceCacheUpdatedEvent = - DataServiceCacheUpdatedEvent; +export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; /** * Published when a single key within {@link KycService}'s cache is updated. @@ -568,7 +569,8 @@ export class KycService extends BaseDataService< 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: '{}' }), + queryFn: async () => + this.#requestJson(url, { method: 'POST', body: '{}' }), // The requirement can change server-side, so always re-check. staleTime: 0, cacheTime: 0, diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts index 2784d87ba2..36a86f4580 100644 --- a/packages/kyc-controller/src/ukyc/testToken.test.ts +++ b/packages/kyc-controller/src/ukyc/testToken.test.ts @@ -101,9 +101,9 @@ describe('UKYC mintUkycTestToken', () => { // 32 bytes hex-encoded. expect(result.localUserSecret).toMatch(/^[0-9a-f]{64}$/u); expect(result.token.payload.operations).toStrictEqual(['read']); - expect(result.authorizationHeader.startsWith( - `${UKYC_CAPABILITY_AUTH_SCHEME} `, - )).toBe(true); + expect( + result.authorizationHeader.startsWith(`${UKYC_CAPABILITY_AUTH_SCHEME} `), + ).toBe(true); }); it('binds session_id for a Relay-presented token', () => { From 689dde03d579335b952ce00dff2a5381d5e4333f Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 12 Aug 2026 15:56:35 -0600 Subject: [PATCH 3/3] fix(kyc-controller): apply oxfmt for lint:misc:check Use oxfmt (not Prettier) so import order and markdown alignment match the monorepo misc formatter. Co-authored-by: Cursor --- packages/kyc-controller/ARCHITECTURE.md | 2 +- packages/kyc-controller/src/KycController.ts | 2 +- packages/kyc-controller/src/ukyc/deriveClientMaterial.ts | 2 +- packages/kyc-controller/src/ukyc/storageAccessToken.ts | 2 +- packages/kyc-controller/src/ukyc/testToken.test.ts | 4 ++-- packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 265a538fbc..27ceff651a 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -658,6 +658,6 @@ Reference client (metamask-mobile): | `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | | `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | | `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | -| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | | `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | | `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 50af784f9f..a004f81859 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -14,6 +14,7 @@ import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; +import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { @@ -27,7 +28,6 @@ import type { KycVendor, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; -import { toBase64Url } from './encoding.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts index 59ffa68d02..b9c8b8e5aa 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -3,8 +3,8 @@ import { ed25519 } from '@noble/curves/ed25519'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; -import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; import { toBase64Url } from '../encoding.js'; +import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; /** * Derives UKYC client material from the root `local_user_secret` using diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index abc9455a1e..f020337696 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -1,12 +1,12 @@ import { stringToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; +import { toBase64Url } from '../encoding.js'; import { UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, UKYC_STORAGE_ACCESS_TOKEN_VERSION, } from './constants.js'; import type { UkycClientMaterial } from './deriveClientMaterial.js'; -import { toBase64Url } from '../encoding.js'; /** * Mints `storage_access_token` capabilities — the client-signed, scoped, diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts index 36a86f4580..0098a6bbbc 100644 --- a/packages/kyc-controller/src/ukyc/testToken.test.ts +++ b/packages/kyc-controller/src/ukyc/testToken.test.ts @@ -1,6 +1,7 @@ -import { ed25519 } from '@noble/curves/ed25519'; import { hexToBytes, stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { base64UrlToBytes } from '../encoding.js'; import { UKYC_CAPABILITY_AUTH_SCHEME, UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, @@ -8,7 +9,6 @@ import { } from './constants.js'; import { canonicalizeJson } from './storageAccessToken.js'; import { mintUkycTestToken } from './testToken.js'; -import { base64UrlToBytes } from '../encoding.js'; // A fixed 32-byte secret (all 0x42), as hex, so storage_id and keys are stable. const SECRET_HEX = '42'.repeat(32); diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts index ae28ec4ec2..2fe21e5084 100644 --- a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -1,5 +1,5 @@ -import type { UkycClientMaterial } from './deriveClientMaterial.js'; import { toBase64Url } from '../encoding.js'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; import type { UkycStorageAccessToken } from './storageAccessToken.js'; /**