From bce36ce2473bf12314e15a0a3aee3c283d59dd4e Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Fri, 11 Sep 2026 11:25:29 -0700 Subject: [PATCH 1/3] throw after logging error to state --- packages/kyc-controller/CHANGELOG.md | 5 + .../src/KycController-method-action-types.ts | 6 + .../kyc-controller/src/KycController.test.ts | 164 +++++++++++------- packages/kyc-controller/src/KycController.ts | 18 +- 4 files changed, 122 insertions(+), 71 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 5f843a907d4..6a37685975b 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** `KycController.checkKycRequired` and the consents-path session (`acceptTermsAndStartSession` / a resumed `initialize`) now rethrow the underlying error after recording it on controller state. + - A product-scoped MoonPay auto-run therefore rejects `handleFrameMessage` / `onAuthenticated` when the KYC-required check fails, instead of only setting `phase: 'error'`. + ## [0.3.0] ### Added diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index c03a617de6b..e60d3ec0e66 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -18,6 +18,8 @@ import type { KycController } from './KycController.js'; * call `checkKycRequired` manually. * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors * skip Check/Auth frames and use the consents path. Defaults to `moonpay`. + * @throws If a resumed consents-path session records a failure on state. + * The original error is rethrown after the controller rewinds to `terms`. */ export type KycControllerInitializeAction = { type: `KycController:initialize`; @@ -94,6 +96,8 @@ export type KycControllerFetchSessionDisclaimersAction = { * @param params.credentialReusabilityConsentGiven - Whether the customer * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. + * @throws If the consents-path session records a failure on state. The + * original error is rethrown after the controller rewinds to `terms`. */ export type KycControllerAcceptTermsAndStartSessionAction = { type: `KycController:acceptTermsAndStartSession`; @@ -160,6 +164,8 @@ export type KycControllerBuildResetFrameUrlAction = { * @param params.product - The consuming feature. * @param params.country - Optional alpha-3 country override. * @returns Whether KYC is required. + * @throws If the KYC-required service call fails after the error is recorded + * on controller state (`phase: 'error'`). */ export type KycControllerCheckKycRequiredAction = { type: `KycController:checkKycRequired`; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index e67e970bcf3..5281b6e089a 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -943,11 +943,13 @@ describe('KycController', () => { async ({ controller, handlers }) => { handlers.submitVendorDisclaimers.mockRejectedValue(new Error('stop')); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow('stop'); expect(handlers.submitVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', @@ -970,11 +972,13 @@ describe('KycController', () => { async ({ controller, handlers }) => { handlers.submitVendorDisclaimers.mockRejectedValue(new Error('down')); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow('down'); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); }, @@ -1162,7 +1166,9 @@ describe('KycController', () => { async ({ controller, handlers, launcher, moonPayFrames }) => { handlers.checkKycRequired.mockRejectedValue(new Error('down')); - await moonPayFrames.options.onAuthenticated(); + await expect( + moonPayFrames.options.onAuthenticated(), + ).rejects.toThrow('down'); expect(controller.state.phase).toBe('error'); expect(launcher.launch).not.toHaveBeenCalled(); @@ -1293,9 +1299,9 @@ describe('KycController', () => { async ({ controller, handlers }) => { handlers.checkKycRequired.mockRejectedValue(new Error('down')); - expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( - false, - ); + await expect( + controller.checkKycRequired({ product: 'ramps' }), + ).rejects.toThrow('down'); expect(controller.state.error).toMatch(/KYC check failed/u); }, ); @@ -3406,13 +3412,15 @@ describe('KycController', () => { ); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - product: 'money', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - credentialReusabilityConsentGiven: true, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + credentialReusabilityConsentGiven: true, + }), + ).rejects.toThrow("Fetching 'disclaimers' failed with status '409'"); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/Consents session failed/u); @@ -3443,12 +3451,14 @@ describe('KycController', () => { ); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - product: 'money', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow("Fetching 'disclaimers' failed with status '409'"); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/Consents session failed/u); @@ -3710,11 +3720,13 @@ describe('KycController', () => { ); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow('sumsub down'); expect(controller.state.phase).toBe('terms'); expect(controller.state.vendorDisclaimersAccepted.iron).toBeNull(); @@ -3737,11 +3749,13 @@ describe('KycController', () => { handlers.createJourney.mockRejectedValue(new Error('journey down')); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow('journey down'); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/journey down/u); @@ -3810,11 +3824,13 @@ describe('KycController', () => { }); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow('SumSub verification could not run.'); expect(controller.state.phase).toBe('terms'); expect(controller.state.sumsub.status).toBe('idle'); @@ -4213,11 +4229,13 @@ describe('KycController', () => { ); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow('iron signings down'); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/iron signings down/u); @@ -4275,11 +4293,13 @@ describe('KycController', () => { ); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow("Fetching 'disclaimers' failed with status '500'"); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/Consents session failed/u); @@ -4308,11 +4328,15 @@ describe('KycController', () => { }); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow( + 'Session disclaimer catalog is missing documents for an accepted category.', + ); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch( @@ -4343,11 +4367,15 @@ describe('KycController', () => { }); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow( + 'Session disclaimer catalog is missing documents for an accepted category.', + ); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch( @@ -4388,11 +4416,13 @@ describe('KycController', () => { ); handlers.fetchVendorDisclaimers.mockResolvedValue([]); - await controller.acceptTermsAndStartSession({ - email: 'a@b.co', - providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, - idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, - }); + await expect( + controller.acceptTermsAndStartSession({ + email: 'a@b.co', + providerDisclaimersAccepted: MOCK_SUMSUB_DISCLAIMERS_ACCEPTED, + idosDisclaimersAccepted: MOCK_IDOS_DISCLAIMERS_ACCEPTED, + }), + ).rejects.toThrow("Fetching 'disclaimers' failed with status '409'"); expect(controller.state.phase).toBe('terms'); expect(controller.state.error).toMatch(/Consents session failed/u); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 1553a8e41be..f2086f39f24 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -873,6 +873,8 @@ export class KycController extends BaseController< * call `checkKycRequired` manually. * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors * skip Check/Auth frames and use the consents path. Defaults to `moonpay`. + * @throws If a resumed consents-path session records a failure on state. + * The original error is rethrown after the controller rewinds to `terms`. */ async initialize(params?: { email?: string; @@ -1167,6 +1169,8 @@ export class KycController extends BaseController< * @param params.credentialReusabilityConsentGiven - Whether the customer * consented to reuse existing idOS credentials. Used when recording * session-scoped disclaimers on the consents path. Defaults to `false`. + * @throws If the consents-path session records a failure on state. The + * original error is rethrown after the controller rewinds to `terms`. */ async acceptTermsAndStartSession(params?: { email?: string; @@ -1232,6 +1236,8 @@ export class KycController extends BaseController< * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records. * @param consents.credentialReusabilityConsentGiven - Whether credential * reuse was accepted. + * @throws If a step after recording state fails. The original error is + * rethrown after the controller rewinds to `terms`. */ async #startConsentsSession(consents: { providerDisclaimersAccepted: KycConsentRecord[]; @@ -1377,6 +1383,7 @@ export class KycController extends BaseController< statusMessage: 'Consent / verification failed — accept the terms to try again.', }); + throw error; } } @@ -1666,9 +1673,10 @@ export class KycController extends BaseController< * document-verification sub-flow is launched. When no product is set, this is * a no-op and the flow stays at `form` for the consumer to drive manually. * - * Errors are already recorded on state by `checkKycRequired` (`error` - * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows - * them so it can be awaited safely from the frame-message handler. + * `startSumSub` records `sumsub.status = 'failed'` and this method swallows + * that rejection so it can be awaited safely from the frame-message handler. + * `checkKycRequired` still records `phase: 'error'` and rethrows, so a + * failed auto-run check surfaces to the caller. */ async #continueAfterAuthentication(): Promise { const product = this.state.activeProduct; @@ -1745,6 +1753,8 @@ export class KycController extends BaseController< * @param params.product - The consuming feature. * @param params.country - Optional alpha-3 country override. * @returns Whether KYC is required. + * @throws If the KYC-required service call fails after the error is recorded + * on controller state (`phase: 'error'`). */ async checkKycRequired(params: { product: KycProduct; @@ -1798,7 +1808,7 @@ export class KycController extends BaseController< return false; } this.#fail(`KYC check failed: ${String(error)}`); - return false; + throw error; } } From e9fccf5750a5614316f617f3c411f5e1d841310e Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Fri, 11 Sep 2026 11:28:02 -0700 Subject: [PATCH 2/3] changelog --- packages/kyc-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 6a37685975b..6dff5556577 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** `KycController.checkKycRequired` and the consents-path session (`acceptTermsAndStartSession` / a resumed `initialize`) now rethrow the underlying error after recording it on controller state. +- **BREAKING:** `KycController.checkKycRequired` and the consents-path session (`acceptTermsAndStartSession` / a resumed `initialize`) now rethrow the underlying error after recording it on controller state. ([#10214](https://github.com/MetaMask/core/pull/10214)) - A product-scoped MoonPay auto-run therefore rejects `handleFrameMessage` / `onAuthenticated` when the KYC-required check fails, instead of only setting `phase: 'error'`. ## [0.3.0] From 740420dae006435cd7c9f1ce7913baa257f36439 Mon Sep 17 00:00:00 2001 From: Jiexi Luan-Huang Date: Fri, 11 Sep 2026 11:39:08 -0700 Subject: [PATCH 3/3] fix(kyc-controller): format test expect() for oxfmt Co-authored-by: Cursor --- packages/kyc-controller/src/KycController.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 5281b6e089a..e428ad3f65d 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1166,9 +1166,9 @@ describe('KycController', () => { async ({ controller, handlers, launcher, moonPayFrames }) => { handlers.checkKycRequired.mockRejectedValue(new Error('down')); - await expect( - moonPayFrames.options.onAuthenticated(), - ).rejects.toThrow('down'); + await expect(moonPayFrames.options.onAuthenticated()).rejects.toThrow( + 'down', + ); expect(controller.state.phase).toBe('error'); expect(launcher.launch).not.toHaveBeenCalled();