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 =