diff --git a/packages/webview-app/src/App.tsx b/packages/webview-app/src/App.tsx
index 032d38c8e..c86f5097d 100644
--- a/packages/webview-app/src/App.tsx
+++ b/packages/webview-app/src/App.tsx
@@ -32,9 +32,11 @@ import { SocialSignOnMethodPickerScreen } from './screens/onboarding/SocialSignO
import { SocialSignOnPickerScreen } from './screens/onboarding/SocialSignOnPickerScreen';
import { TourScreen } from './screens/onboarding/TourScreen';
import { DialogueWithCtaScreen } from './screens/proving/DialogueWithCtaScreen';
+import { DiscloseResultScreen } from './screens/proving/DiscloseResultScreen';
import { KycPendingScreen } from './screens/proving/KycPendingScreen';
import { KycSuccessScreen } from './screens/proving/KycSuccessScreen';
import { ProofGenerationDialogueScreen } from './screens/proving/ProofGenerationDialogueScreen';
+import { ProofGenerationRouteScreen } from './screens/proving/ProofGenerationRouteScreen';
import { ProofGenerationSuccessScreen } from './screens/proving/ProofGenerationSuccessScreen';
import { ProofHistoryScreen } from './screens/proving/ProofHistoryScreen';
import { ProofRequestReceiptScreen } from './screens/proving/ProofRequestReceiptScreen';
@@ -72,7 +74,8 @@ export const App: React.FC = () => (
} />
} />
} />
- } />
+ } />
+ } />
} />
} />
} />
diff --git a/packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx b/packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx
new file mode 100644
index 000000000..033137c32
--- /dev/null
+++ b/packages/webview-app/src/screens/proving/DiscloseResultScreen.tsx
@@ -0,0 +1,111 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import type React from 'react';
+import { useCallback, useMemo } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+
+import { colors, StatusState, WarningOctagonIcon } from '@selfxyz/euclid';
+import type { BridgeError, VerificationResult } from '@selfxyz/webview-bridge';
+
+import { useSelfClient } from '../../providers/SelfClientProvider';
+import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
+import { WEB_SAFE_AREA } from '../../utils/insets';
+import { normalizeError } from '../../utils/provingUtils';
+
+interface DiscloseResultLocationState {
+ success?: boolean;
+ error?: BridgeError | string;
+ resultSent?: boolean;
+}
+
+export const DiscloseResultScreen: React.FC = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { analytics, haptic, lifecycle } = useSelfClient();
+ const { request, verificationId } = useVerificationRequest();
+
+ const { success = true, error, resultSent = false } = (location.state as DiscloseResultLocationState | null) ?? {};
+ const normalizedError = normalizeError(error);
+ const result = useMemo(
+ () =>
+ success
+ ? {
+ success: true,
+ userId: request.userId,
+ verificationId,
+ claims: {
+ resultType: 'proofRequested',
+ },
+ }
+ : {
+ success: false,
+ userId: request.userId,
+ verificationId,
+ claims: {
+ resultType: 'proofRequested',
+ },
+ error: normalizedError ?? {
+ code: 'proof_generation_failed',
+ message: 'The proof request could not be completed.',
+ },
+ },
+ [normalizedError, request.userId, success, verificationId],
+ );
+
+ const onContinue = useCallback(async () => {
+ haptic.trigger('selection');
+ let hasDeliveredResult = resultSent;
+
+ if (!resultSent && result) {
+ try {
+ await lifecycle.setResult(result);
+ hasDeliveredResult = true;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to deliver result';
+ analytics.trackEvent('verification_result_callback_failed', {
+ error: message,
+ });
+ }
+ }
+
+ if (success) {
+ if (hasDeliveredResult) {
+ await lifecycle.dismiss();
+ }
+ navigate('/', { replace: true });
+ return;
+ }
+
+ navigate('/proving', { replace: true });
+ }, [analytics, haptic, lifecycle, navigate, result, resultSent, success]);
+
+ return (
+
+ }
+ />
+
+ );
+};
diff --git a/packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx b/packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx
new file mode 100644
index 000000000..d49fbaf4a
--- /dev/null
+++ b/packages/webview-app/src/screens/proving/ProofGenerationRouteScreen.tsx
@@ -0,0 +1,126 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import type React from 'react';
+import { useEffect, useMemo, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { ProofGenerationScreen as EuclidProofGenerationScreen } from '@selfxyz/euclid';
+import { useProvingStore } from '@selfxyz/mobile-sdk-alpha/browser';
+
+import { useSelfClient } from '../../providers/SelfClientProvider';
+import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
+import { WEB_SAFE_AREA } from '../../utils/insets';
+import { getFailureState, getGenerationStep, getIdCardProps } from '../../utils/provingUtils';
+import { hasDiscloseRequestContext } from '../../utils/verificationRequest';
+
+export const ProofGenerationRouteScreen: React.FC = () => {
+ const navigate = useNavigate();
+ const { client, analytics } = useSelfClient();
+ const { request, displayLabels } = useVerificationRequest();
+ const init = useProvingStore(state => state.init);
+ const setUserConfirmed = useProvingStore(state => state.setUserConfirmed);
+ const currentState = useProvingStore(state => state.currentState);
+ const passportData = useProvingStore(state => state.passportData);
+ const errorCode = useProvingStore(state => state.error_code);
+ const reason = useProvingStore(state => state.reason);
+
+ const hasValidRequestContext = hasDiscloseRequestContext({ request, displayLabels });
+ const hasInitializedRef = useRef(false);
+ const hasAutoConfirmedRef = useRef(false);
+ const hasNavigatedRef = useRef(false);
+
+ useEffect(() => {
+ if (!hasValidRequestContext) {
+ navigate('/', { replace: true });
+ return;
+ }
+
+ if (hasInitializedRef.current) {
+ return;
+ }
+
+ hasInitializedRef.current = true;
+ void init(client, 'disclose').catch(error => {
+ if (hasNavigatedRef.current) {
+ return;
+ }
+
+ hasNavigatedRef.current = true;
+ const message = error instanceof Error ? error.message : 'The proof request could not be completed.';
+ analytics.trackEvent('prove_generation_init_failed', { error: message });
+ navigate('/proving/result', {
+ replace: true,
+ state: {
+ success: false,
+ error: {
+ code: 'proof_generation_init_failed',
+ message,
+ },
+ },
+ });
+ });
+ }, [analytics, client, hasValidRequestContext, init, navigate]);
+
+ useEffect(() => {
+ if (hasNavigatedRef.current) {
+ return;
+ }
+
+ if (currentState === 'passport_data_not_found') {
+ hasNavigatedRef.current = true;
+ navigate('/proving/result', {
+ replace: true,
+ state: {
+ success: false,
+ error: {
+ code: 'passport_data_not_found',
+ message: 'No document found. Please register a document first.',
+ },
+ },
+ });
+ return;
+ }
+
+ if (currentState === 'ready_to_prove' && !hasAutoConfirmedRef.current) {
+ hasAutoConfirmedRef.current = true;
+ setUserConfirmed(client);
+ return;
+ }
+
+ if (currentState === 'completed') {
+ hasNavigatedRef.current = true;
+ navigate('/proving/result', {
+ replace: true,
+ state: { success: true },
+ });
+ return;
+ }
+
+ if (currentState === 'error' || currentState === 'failure' || currentState === 'passport_not_supported') {
+ hasNavigatedRef.current = true;
+ navigate('/proving/result', {
+ replace: true,
+ state: {
+ success: false,
+ error: getFailureState(currentState, errorCode, reason),
+ },
+ });
+ }
+ }, [client, currentState, errorCode, navigate, reason, setUserConfirmed]);
+
+ const idCardProps = useMemo(() => getIdCardProps(passportData?.documentCategory), [passportData?.documentCategory]);
+
+ if (!hasValidRequestContext) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/webview-app/src/screens/proving/ProvingScreen.tsx b/packages/webview-app/src/screens/proving/ProvingScreen.tsx
index 490b8b043..5a6546411 100644
--- a/packages/webview-app/src/screens/proving/ProvingScreen.tsx
+++ b/packages/webview-app/src/screens/proving/ProvingScreen.tsx
@@ -3,30 +3,28 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type React from 'react';
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProofRequestScreen, SelfLogo } from '@selfxyz/euclid';
-import type { VerificationResult } from '@selfxyz/webview-bridge';
import { useSelfClient } from '../../providers/SelfClientProvider';
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
import { WEB_SAFE_AREA } from '../../utils/insets';
-
-function titleCaseDisclosure(disclosure: string): string {
- return disclosure
- .replace(/[_-]+/g, ' ')
- .replace(/\s+/g, ' ')
- .trim()
- .replace(/\b\w/g, match => match.toUpperCase());
-}
+import { titleCaseDisclosure } from '../../utils/provingUtils';
+import { hasDiscloseRequestContext } from '../../utils/verificationRequest';
export const ProvingScreen: React.FC = () => {
const navigate = useNavigate();
const { analytics, haptic, lifecycle } = useSelfClient();
- const { request, displayLabels, requestType, appName, appEndpoint, timestamp, verificationId } =
- useVerificationRequest();
- const [proving, setProving] = useState(false);
+ const { request, displayLabels, appName, appEndpoint, timestamp } = useVerificationRequest();
+ const hasValidRequestContext = hasDiscloseRequestContext({ request, displayLabels });
+
+ useEffect(() => {
+ if (!hasValidRequestContext) {
+ navigate('/', { replace: true });
+ }
+ }, [hasValidRequestContext, navigate]);
const proofItems = useMemo(() => {
if (displayLabels && displayLabels.length > 0) {
@@ -37,48 +35,30 @@ export const ProvingScreen: React.FC = () => {
}));
}, [displayLabels, request.disclosures]);
- const onVerify = useCallback(async () => {
- const result: VerificationResult = {
- success: true,
- userId: request.userId,
- verificationId,
- claims: {
- resultType: requestType,
- },
- };
-
+ const onVerify = useCallback(() => {
haptic.trigger('selection');
analytics.trackEvent('prove_verify_pressed');
- setProving(true);
+ navigate('/proving/generating', { replace: true });
+ }, [analytics, haptic, navigate]);
- try {
- await lifecycle.setResult(result);
-
- navigate('/proving/result', {
- state: { success: true, result, resultSent: true },
- });
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Proving failed';
- analytics.trackEvent('prove_verify_failed', { error: message });
- navigate('/proving/result', {
- state: { success: false, error: message, result, resultSent: false },
- });
- } finally {
- setProving(false);
- }
- }, [analytics, haptic, lifecycle, navigate, request.userId, requestType, verificationId]);
-
- const onCancel = useCallback(() => {
+ const onCancel = useCallback(async () => {
haptic.trigger('selection');
analytics.trackEvent('prove_verify_cancelled');
- lifecycle.dismiss({ reason: 'user_cancel' });
- navigate('/');
+ try {
+ await lifecycle.dismiss({ reason: 'user_cancel' });
+ } finally {
+ navigate('/', { replace: true });
+ }
}, [analytics, haptic, lifecycle, navigate]);
+ if (!hasValidRequestContext) {
+ return null;
+ }
+
return (
}
diff --git a/packages/webview-app/src/utils/provingUtils.test.ts b/packages/webview-app/src/utils/provingUtils.test.ts
new file mode 100644
index 000000000..1dec9bbab
--- /dev/null
+++ b/packages/webview-app/src/utils/provingUtils.test.ts
@@ -0,0 +1,159 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import { describe, expect, it } from 'vitest';
+
+import {
+ getFailureState,
+ getGenerationStep,
+ getIdCardProps,
+ normalizeError,
+ titleCaseDisclosure,
+} from './provingUtils';
+
+describe('provingUtils', () => {
+ describe('getGenerationStep', () => {
+ it('should map early states to readingRegistry', () => {
+ expect(getGenerationStep('parsing_id_document')).toBe('readingRegistry');
+ expect(getGenerationStep('fetching_data')).toBe('readingRegistry');
+ });
+
+ it('should map proving-phase states to generatingProof', () => {
+ expect(getGenerationStep('validating_document')).toBe('generatingProof');
+ expect(getGenerationStep('init_tee_connexion')).toBe('generatingProof');
+ expect(getGenerationStep('ready_to_prove')).toBe('generatingProof');
+ expect(getGenerationStep('proving')).toBe('generatingProof');
+ });
+
+ it('should map listening_for_status to awaitingVerification', () => {
+ expect(getGenerationStep('listening_for_status')).toBe('awaitingVerification');
+ });
+
+ it('should map post_proving to finishingUp', () => {
+ expect(getGenerationStep('post_proving')).toBe('finishingUp');
+ });
+
+ it('should default unknown states to readingRegistry', () => {
+ expect(getGenerationStep('idle')).toBe('readingRegistry');
+ expect(getGenerationStep('completed')).toBe('readingRegistry');
+ expect(getGenerationStep('')).toBe('readingRegistry');
+ });
+ });
+
+ describe('getFailureState', () => {
+ it('should use provided code and reason', () => {
+ expect(getFailureState('error', 'custom_code', 'Something broke')).toEqual({
+ code: 'custom_code',
+ message: 'Something broke',
+ });
+ });
+
+ it('should fall back to currentState when code is null', () => {
+ expect(getFailureState('failure', null, 'Reason')).toEqual({
+ code: 'failure',
+ message: 'Reason',
+ });
+ });
+
+ it('should fall back to default message when reason is null', () => {
+ expect(getFailureState('error', 'some_code', null)).toEqual({
+ code: 'some_code',
+ message: 'The proof request could not be completed.',
+ });
+ });
+
+ it('should fall back to both defaults when code and reason are null', () => {
+ expect(getFailureState('passport_not_supported', null, null)).toEqual({
+ code: 'passport_not_supported',
+ message: 'The proof request could not be completed.',
+ });
+ });
+ });
+
+ describe('getIdCardProps', () => {
+ it('should return passport props by default', () => {
+ expect(getIdCardProps()).toEqual({ variant: 'passport', title: 'Passport', subtitle: 'Verified Passport' });
+ expect(getIdCardProps('passport')).toEqual({
+ variant: 'passport',
+ title: 'Passport',
+ subtitle: 'Verified Passport',
+ });
+ });
+
+ it('should return id-card props', () => {
+ expect(getIdCardProps('id_card')).toEqual({ variant: 'id-card', title: 'ID Card', subtitle: 'Verified ID' });
+ });
+
+ it('should return aadhaar props', () => {
+ expect(getIdCardProps('aadhaar')).toEqual({
+ variant: 'aadhaar',
+ title: 'Aadhaar',
+ subtitle: 'Verified IN Aadhaar ID',
+ });
+ });
+
+ it('should return kyc props', () => {
+ expect(getIdCardProps('kyc')).toEqual({
+ variant: 'pending',
+ title: 'KYC Record',
+ subtitle: 'Verification document loaded',
+ });
+ });
+
+ it('should default to passport for unknown categories', () => {
+ expect(getIdCardProps('drivers_license')).toEqual({
+ variant: 'passport',
+ title: 'Passport',
+ subtitle: 'Verified Passport',
+ });
+ });
+ });
+
+ describe('titleCaseDisclosure', () => {
+ it('should convert underscored keys to title case', () => {
+ expect(titleCaseDisclosure('full_name')).toBe('Full Name');
+ });
+
+ it('should convert hyphenated keys to title case', () => {
+ expect(titleCaseDisclosure('date-of-birth')).toBe('Date Of Birth');
+ });
+
+ it('should handle mixed separators', () => {
+ expect(titleCaseDisclosure('some_mixed-key')).toBe('Some Mixed Key');
+ });
+
+ it('should collapse multiple separators and whitespace', () => {
+ expect(titleCaseDisclosure('full__name')).toBe('Full Name');
+ expect(titleCaseDisclosure(' full_name ')).toBe('Full Name');
+ });
+
+ it('should handle single word', () => {
+ expect(titleCaseDisclosure('nationality')).toBe('Nationality');
+ });
+ });
+
+ describe('normalizeError', () => {
+ it('should return undefined for falsy input', () => {
+ expect(normalizeError(undefined)).toBeUndefined();
+ expect(normalizeError('')).toBeUndefined();
+ });
+
+ it('should wrap a string into a BridgeError', () => {
+ expect(normalizeError('Something failed')).toEqual({
+ code: 'proof_generation_failed',
+ message: 'Something failed',
+ });
+ });
+
+ it('should pass through a BridgeError object', () => {
+ const err = { code: 'custom', message: 'msg' };
+ expect(normalizeError(err)).toBe(err);
+ });
+
+ it('should preserve details on a BridgeError object', () => {
+ const err = { code: 'custom', message: 'msg', details: { extra: true } };
+ expect(normalizeError(err)).toBe(err);
+ });
+ });
+});
diff --git a/packages/webview-app/src/utils/provingUtils.ts b/packages/webview-app/src/utils/provingUtils.ts
new file mode 100644
index 000000000..325638508
--- /dev/null
+++ b/packages/webview-app/src/utils/provingUtils.ts
@@ -0,0 +1,73 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import type { IDCardProps } from '@selfxyz/euclid';
+import type { BridgeError } from '@selfxyz/webview-bridge';
+
+export type GenerationStep = 'readingRegistry' | 'generatingProof' | 'awaitingVerification' | 'finishingUp';
+
+export function getFailureState(
+ currentState: string,
+ code: string | null,
+ reason: string | null,
+): { code: string; message: string } {
+ return {
+ code: code ?? currentState ?? 'proof_generation_failed',
+ message: reason ?? 'The proof request could not be completed.',
+ };
+}
+
+export function getGenerationStep(currentState: string): GenerationStep {
+ switch (currentState) {
+ case 'parsing_id_document':
+ case 'fetching_data':
+ return 'readingRegistry';
+ case 'validating_document':
+ case 'init_tee_connexion':
+ case 'ready_to_prove':
+ case 'proving':
+ return 'generatingProof';
+ case 'listening_for_status':
+ return 'awaitingVerification';
+ case 'post_proving':
+ return 'finishingUp';
+ default:
+ return 'readingRegistry';
+ }
+}
+
+export function getIdCardProps(documentCategory?: string): IDCardProps {
+ switch (documentCategory) {
+ case 'id_card':
+ return { variant: 'id-card', title: 'ID Card', subtitle: 'Verified ID' };
+ case 'aadhaar':
+ return { variant: 'aadhaar', title: 'Aadhaar', subtitle: 'Verified IN Aadhaar ID' };
+ case 'kyc':
+ return { variant: 'pending', title: 'KYC Record', subtitle: 'Verification document loaded' };
+ case 'passport':
+ default:
+ return { variant: 'passport', title: 'Passport', subtitle: 'Verified Passport' };
+ }
+}
+
+export function normalizeError(error: BridgeError | string | undefined): BridgeError | undefined {
+ if (!error) {
+ return undefined;
+ }
+ if (typeof error === 'string') {
+ return {
+ code: 'proof_generation_failed',
+ message: error,
+ };
+ }
+ return error;
+}
+
+export function titleCaseDisclosure(disclosure: string): string {
+ return disclosure
+ .replace(/[_-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\b\w/g, match => match.toUpperCase());
+}
diff --git a/packages/webview-app/src/utils/verificationRequest.test.ts b/packages/webview-app/src/utils/verificationRequest.test.ts
index 739be3c1a..5520226ce 100644
--- a/packages/webview-app/src/utils/verificationRequest.test.ts
+++ b/packages/webview-app/src/utils/verificationRequest.test.ts
@@ -5,7 +5,11 @@
import { describe, expect, it } from 'vitest';
import { getPromptMockFromSearch, getPromptMockSearch } from './mockOnboardingFlow';
-import { parseBrowserHostTargetOrigin, parseVerificationRequestContext } from './verificationRequest';
+import {
+ hasDiscloseRequestContext,
+ parseBrowserHostTargetOrigin,
+ parseVerificationRequestContext,
+} from './verificationRequest';
describe('verificationRequest utils', () => {
describe('parseBrowserHostTargetOrigin', () => {
@@ -90,6 +94,17 @@ describe('verificationRequest utils', () => {
expect(context.appEndpoint).toBe('');
expect(context.verificationId).toBeUndefined();
});
+
+ it('should require disclose items for the disclose route', () => {
+ const withDisclosures = parseVerificationRequestContext('?disclosures=full_name');
+ expect(hasDiscloseRequestContext(withDisclosures)).toBe(true);
+
+ const withLabels = parseVerificationRequestContext('?proofItems=Full%20Name');
+ expect(hasDiscloseRequestContext(withLabels)).toBe(true);
+
+ const empty = parseVerificationRequestContext('?userId=user-1');
+ expect(hasDiscloseRequestContext(empty)).toBe(false);
+ });
});
describe('prompt mock utils', () => {
diff --git a/packages/webview-app/src/utils/verificationRequest.ts b/packages/webview-app/src/utils/verificationRequest.ts
index d2ef8dada..0edcdec8d 100644
--- a/packages/webview-app/src/utils/verificationRequest.ts
+++ b/packages/webview-app/src/utils/verificationRequest.ts
@@ -23,6 +23,12 @@ interface TargetOriginOptions {
allowWildcard?: boolean;
}
+export function hasDiscloseRequestContext(
+ context: Pick,
+) {
+ return Boolean((context.displayLabels && context.displayLabels.length > 0) || context.request.disclosures?.length);
+}
+
export function parseBrowserHostTargetOrigin(search: string, options: TargetOriginOptions = {}): string | undefined {
const params = new URLSearchParams(search);
return normalizeTargetOrigin(params.get('targetOrigin'), options);