{
it('handles malformed iOS response', () => {
// Platform.OS is already mocked as 'ios' by default
- const parseScanResponse = getFreshParseScanResponse();
const response = '{"invalid": "json"';
expect(() => parseScanResponse(response)).toThrow();
@@ -205,7 +185,6 @@ describe('parseScanResponse', () => {
it('handles malformed Android response', () => {
// Set Platform.OS to android for this test
global.mockPlatformOS = 'android';
- const parseScanResponse = getFreshParseScanResponse();
const response = {
mrz: 'valid_mrz',
@@ -218,7 +197,6 @@ describe('parseScanResponse', () => {
it('handles missing required fields', () => {
// Platform.OS is already mocked as 'ios' by default
- const parseScanResponse = getFreshParseScanResponse();
const response = JSON.stringify({
// Providing minimal data but missing critical passportMRZ field
dataGroupHashes: JSON.stringify({
@@ -238,7 +216,6 @@ describe('parseScanResponse', () => {
it('handles invalid hex data in dataGroupHashes', () => {
// Platform.OS is already mocked as 'ios' by default
- const parseScanResponse = getFreshParseScanResponse();
const response = JSON.stringify({
dataGroupHashes: JSON.stringify({
DG1: { sodHash: 'invalid_hex' },
diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx
index f08f5995d..520b1a3f3 100644
--- a/app/tests/src/navigation.test.tsx
+++ b/app/tests/src/navigation.test.tsx
@@ -113,7 +113,6 @@ describe('navigation', () => {
'ShowRecoveryPhrase',
'Splash',
'StarfallPushCode',
- 'SumsubTest',
'WebView',
]);
});
diff --git a/app/tests/src/screens/dev/DevSettingsScreen.test.tsx b/app/tests/src/screens/dev/DevSettingsScreen.test.tsx
new file mode 100644
index 000000000..034b67a93
--- /dev/null
+++ b/app/tests/src/screens/dev/DevSettingsScreen.test.tsx
@@ -0,0 +1,316 @@
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import React from 'react';
+import { Alert } from 'react-native';
+import { render, waitFor } from '@testing-library/react-native';
+
+import DevSettingsScreen from '@/screens/dev/DevSettingsScreen';
+
+// Mock Alert
+jest.spyOn(Alert, 'alert');
+
+// Mock react-native
+jest.mock('react-native', () => ({
+ __esModule: true,
+ Alert: {
+ alert: jest.fn(),
+ },
+ ScrollView: ({ children, ...props }: any) => {children}
,
+ Platform: { OS: 'ios', select: jest.fn() },
+ StyleSheet: {
+ create: (styles: any) => styles,
+ flatten: (style: any) => style,
+ },
+}));
+
+jest.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: jest.fn(() => ({ bottom: 0 })),
+}));
+
+jest.mock('@react-navigation/native', () => ({
+ useNavigation: jest.fn(() => ({ navigate: jest.fn() })),
+}));
+
+// Mock Tamagui
+jest.mock('tamagui', () => ({
+ YStack: ({ children, ...props }: any) => {children}
,
+}));
+
+// Mock hooks and stores
+jest.mock('@/stores/settingStore', () => ({
+ useSettingStore: jest.fn(selector => {
+ const state = {
+ loggingSeverity: 'info',
+ setLoggingSeverity: jest.fn(),
+ useStrongBox: false,
+ setUseStrongBox: jest.fn(),
+ };
+ return selector ? selector(state) : state;
+ }),
+}));
+
+jest.mock('@/providers/passportDataProvider', () => ({
+ loadDocumentCatalogDirectlyFromKeychain: jest.fn(),
+ saveDocumentCatalogDirectlyToKeychain: jest.fn(),
+}));
+
+jest.mock('@/screens/dev/hooks/useDangerZoneActions', () => ({
+ useDangerZoneActions: jest.fn(() => ({
+ handleClearSecretsPress: jest.fn(),
+ handleClearDocumentCatalogPress: jest.fn(),
+ handleClearPointEventsPress: jest.fn(),
+ handleResetBackupStatePress: jest.fn(),
+ handleClearBackupEventsPress: jest.fn(),
+ handleClearPendingVerificationsPress: jest.fn(),
+ })),
+}));
+
+jest.mock('@/screens/dev/hooks/useNotificationHandlers', () => ({
+ useNotificationHandlers: jest.fn(() => ({
+ hasNotificationPermission: false,
+ subscribedTopics: [],
+ handleTopicToggle: jest.fn(),
+ })),
+}));
+
+// Mock sections
+jest.mock('@/screens/dev/sections', () => ({
+ DangerZoneSection: ({ onRemoveExpirationDateFlag, ...props }: any) => (
+
+
+
+ ),
+ DebugShortcutsSection: () => DebugShortcuts
,
+ DevTogglesSection: () => DevToggles
,
+ PushNotificationsSection: () => PushNotifications
,
+}));
+
+jest.mock('@/screens/dev/components/ParameterSection', () => ({
+ ParameterSection: ({ children }: any) => {children}
,
+}));
+
+jest.mock('@/screens/dev/components/LogLevelSelector', () => ({
+ LogLevelSelector: () => LogLevelSelector
,
+}));
+
+jest.mock('@/screens/dev/components/ErrorInjectionSelector', () => ({
+ ErrorInjectionSelector: () => ErrorInjectionSelector
,
+}));
+
+jest.mock('@/components/ErrorBoundary', () => ({
+ __esModule: true,
+ default: ({ children }: any) => {children}
,
+}));
+
+// Mock icons
+jest.mock('@/assets/icons/bug_icon.svg', () => 'BugIcon');
+
+describe('DevSettingsScreen - handleRemoveExpirationDateFlagPress', () => {
+ let mockLoadDocumentCatalog: jest.Mock;
+ let mockSaveDocumentCatalog: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ const passportProvider = jest.requireMock(
+ '@/providers/passportDataProvider',
+ );
+ mockLoadDocumentCatalog =
+ passportProvider.loadDocumentCatalogDirectlyFromKeychain;
+ mockSaveDocumentCatalog =
+ passportProvider.saveDocumentCatalogDirectlyToKeychain;
+ });
+
+ it('should show confirmation alert when Remove Expiration Date Flag is pressed', () => {
+ const { root } = render();
+
+ const button = root.findByType('button');
+ expect(button).toBeTruthy();
+
+ button.props.onClick();
+
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Remove Expiration Date Flag',
+ 'Are you sure you want to remove the expiration date flag for the current (selected) document?.',
+ expect.arrayContaining([
+ expect.objectContaining({ text: 'Cancel', style: 'cancel' }),
+ expect.objectContaining({ text: 'Remove', style: 'destructive' }),
+ ]),
+ );
+ });
+
+ it('should successfully remove expiration date flag when document is selected', async () => {
+ const mockCatalog = {
+ selectedDocumentId: 'doc-123',
+ documents: [
+ {
+ id: 'doc-123',
+ hasExpirationDate: true,
+ },
+ ],
+ };
+
+ mockLoadDocumentCatalog.mockResolvedValue(mockCatalog);
+ mockSaveDocumentCatalog.mockResolvedValue(undefined);
+
+ const { root } = render();
+
+ const button = root.findByType('button');
+ button.props.onClick();
+
+ // Get the onPress callback from the alert
+ const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
+ const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
+
+ // Execute the remove action
+ await removeButton.onPress();
+
+ await waitFor(() => {
+ expect(mockLoadDocumentCatalog).toHaveBeenCalled();
+ expect(mockSaveDocumentCatalog).toHaveBeenCalledWith({
+ selectedDocumentId: 'doc-123',
+ documents: [
+ {
+ id: 'doc-123',
+ // hasExpirationDate should be deleted
+ },
+ ],
+ });
+ });
+
+ // Success alert should be shown
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Success',
+ 'Expiration date flag removed successfully.',
+ [{ text: 'OK' }],
+ );
+ });
+ });
+
+ it('should show error alert when no document is selected', async () => {
+ const mockCatalog = {
+ selectedDocumentId: 'non-existent-doc',
+ documents: [
+ {
+ id: 'doc-123',
+ hasExpirationDate: true,
+ },
+ ],
+ };
+
+ mockLoadDocumentCatalog.mockResolvedValue(mockCatalog);
+
+ const { root } = render();
+
+ const button = root.findByType('button');
+ button.props.onClick();
+
+ const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
+ const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
+
+ await removeButton.onPress();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'No Document Selected',
+ 'Please select a document before removing the expiration date flag.',
+ [{ text: 'OK' }],
+ );
+ });
+
+ // Should not attempt to save
+ expect(mockSaveDocumentCatalog).not.toHaveBeenCalled();
+ });
+
+ it('should show error alert when loadDocumentCatalog fails', async () => {
+ const mockError = new Error('Failed to load catalog');
+ mockLoadDocumentCatalog.mockRejectedValue(mockError);
+
+ // Mock console.error to avoid test output clutter
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ const { root } = render();
+
+ const button = root.findByType('button');
+ button.props.onClick();
+
+ const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
+ const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
+
+ await removeButton.onPress();
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to remove expiration date flag:',
+ 'Failed to load catalog',
+ );
+ });
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Error',
+ 'Failed to remove expiration date flag. Please try again.',
+ [{ text: 'OK' }],
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should show error alert when saveDocumentCatalog fails', async () => {
+ const mockCatalog = {
+ selectedDocumentId: 'doc-123',
+ documents: [
+ {
+ id: 'doc-123',
+ hasExpirationDate: true,
+ },
+ ],
+ };
+
+ mockLoadDocumentCatalog.mockResolvedValue(mockCatalog);
+ mockSaveDocumentCatalog.mockRejectedValue(new Error('Failed to save'));
+
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ const { root } = render();
+
+ const button = root.findByType('button');
+ button.props.onClick();
+
+ const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
+ const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
+
+ await removeButton.onPress();
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Error',
+ 'Failed to remove expiration date flag. Please try again.',
+ [{ text: 'OK' }],
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should not call saveDocumentCatalog when user cancels', async () => {
+ const { root } = render();
+
+ const button = root.findByType('button');
+ button.props.onClick();
+
+ // User cancels - should not load or save anything
+ expect(mockLoadDocumentCatalog).not.toHaveBeenCalled();
+ expect(mockSaveDocumentCatalog).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/tests/src/screens/home/PointsInfoScreen.test.tsx b/app/tests/src/screens/home/PointsInfoScreen.test.tsx
new file mode 100644
index 000000000..698170f2d
--- /dev/null
+++ b/app/tests/src/screens/home/PointsInfoScreen.test.tsx
@@ -0,0 +1,339 @@
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import React from 'react';
+import { act, render } from '@testing-library/react-native';
+
+import PointsInfoScreen from '@/screens/home/PointsInfoScreen';
+import { unregisterModalCallbacks } from '@/utils/modalCallbackRegistry';
+
+jest.mock('react-native', () => {
+ const MockView = ({ children, ...props }: any) => (
+ {children}
+ );
+ const MockText = ({ children, ...props }: any) => (
+ {children}
+ );
+ const MockImage = ({ ...props }: any) => ;
+
+ return {
+ __esModule: true,
+ Image: MockImage,
+ Platform: { OS: 'ios', select: jest.fn() },
+ StyleSheet: {
+ create: (styles: any) => styles,
+ flatten: (style: any) => style,
+ },
+ Text: MockText,
+ View: MockView,
+ };
+});
+
+jest.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: jest.fn(() => ({
+ top: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ })),
+}));
+
+// Mock Tamagui components
+jest.mock('tamagui', () => {
+ const View: any = 'View';
+ const Text: any = 'Text';
+ const createViewComponent = (displayName: string) => {
+ const MockComponent = ({ children, ...props }: any) => (
+
+ {children}
+
+ );
+ MockComponent.displayName = displayName;
+ return MockComponent;
+ };
+
+ const MockYStack = createViewComponent('YStack');
+ const MockXStack = createViewComponent('XStack');
+ const MockView = createViewComponent('View');
+ const MockScrollView = createViewComponent('ScrollView');
+
+ const MockText = ({ children, ...props }: any) => (
+ {children}
+ );
+ MockText.displayName = 'Text';
+
+ return {
+ __esModule: true,
+ YStack: MockYStack,
+ XStack: MockXStack,
+ View: MockView,
+ Text: MockText,
+ ScrollView: MockScrollView,
+ };
+});
+
+// Mock mobile SDK components
+jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
+ PrimaryButton: ({ children, onPress, ...props }: any) => (
+
+ {children}
+
+ ),
+ Title: ({ children }: any) => {children}
,
+}));
+
+// Mock SVG icons
+jest.mock('@/assets/icons/checkmark_square.svg', () => 'CheckmarkSquareIcon');
+jest.mock('@/assets/icons/cloud_backup.svg', () => 'CloudBackupIcon');
+jest.mock(
+ '@/assets/icons/push_notifications.svg',
+ () => 'PushNotificationsIcon',
+);
+jest.mock('@/assets/icons/star.svg', () => 'StarIcon');
+
+// Mock images
+jest.mock('@/assets/images/referral.png', () => 'ReferralImage');
+
+jest.mock('@/utils/modalCallbackRegistry', () => ({
+ getModalCallbacks: jest.fn(),
+ registerModalCallbacks: jest.fn(),
+ unregisterModalCallbacks: jest.fn(),
+}));
+
+const mockUnregisterModalCallbacks =
+ unregisterModalCallbacks as jest.MockedFunction<
+ typeof unregisterModalCallbacks
+ >;
+
+// Mock getModalCallbacks at module level
+const { getModalCallbacks } = jest.requireMock('@/utils/modalCallbackRegistry');
+
+describe('PointsInfoScreen', () => {
+ const mockOnButtonPress = jest.fn();
+ const mockOnModalDismiss = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup getModalCallbacks to return our mock callbacks
+ getModalCallbacks.mockImplementation((id: number) => {
+ if (id === 1) {
+ return {
+ onButtonPress: mockOnButtonPress,
+ onModalDismiss: mockOnModalDismiss,
+ };
+ }
+ return undefined;
+ });
+ });
+
+ it('should render without crashing', () => {
+ expect(() => {
+ render();
+ }).not.toThrow();
+ });
+
+ it('should not show Next button when showNextButton is false', () => {
+ const { queryByTestId } = render(
+ ,
+ );
+
+ // Verify button is not rendered
+ const nextButton = queryByTestId('primary-button');
+ expect(nextButton).toBeNull();
+ });
+
+ it('should show Next button when showNextButton is true', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ // Verify button is rendered
+ const nextButton = getByTestId('primary-button');
+ expect(nextButton).toBeTruthy();
+ });
+
+ describe('Callback handling', () => {
+ it('should call onModalDismiss and unregister callbacks when component unmounts without button press', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ // Initially, no callbacks should be called
+ expect(mockOnModalDismiss).not.toHaveBeenCalled();
+ expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
+ expect(mockOnButtonPress).not.toHaveBeenCalled();
+
+ // Unmount the component (simulating user navigating back)
+ act(() => {
+ unmount();
+ });
+
+ // onModalDismiss should be called to clear referrer
+ expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
+ // Callbacks should be unregistered to prevent memory leak
+ expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
+ // onButtonPress should not be called (user didn't press the button)
+ expect(mockOnButtonPress).not.toHaveBeenCalled();
+ });
+
+ it('should call onModalDismiss on unmount even when showNextButton is false', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ act(() => {
+ unmount();
+ });
+
+ // Callbacks should be called even if button is not shown (callbackId is present)
+ expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
+ expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle missing callbacks gracefully', () => {
+ // Mock getModalCallbacks to return undefined
+ getModalCallbacks.mockReturnValue(undefined);
+
+ const { unmount } = render(
+ ,
+ );
+
+ // Should not throw when unmounting with missing callbacks
+ expect(() => {
+ act(() => {
+ unmount();
+ });
+ }).not.toThrow();
+
+ // Should still attempt to unregister
+ expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(999);
+ });
+
+ it('should handle missing callbackId gracefully', () => {
+ const { unmount } = render(
+ ,
+ );
+
+ // Should not throw when unmounting without callbackId
+ expect(() => {
+ act(() => {
+ unmount();
+ });
+ }).not.toThrow();
+
+ // Should not attempt to unregister if no callbackId
+ expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Button press handling', () => {
+ it('should call onButtonPress and unregister callbacks when Next button is pressed, then not call onModalDismiss on unmount', () => {
+ const { getByTestId, unmount } = render(
+ ,
+ );
+
+ const primaryButton = getByTestId('primary-button');
+
+ // Press the button
+ act(() => {
+ primaryButton.props.onPress();
+ });
+
+ // onButtonPress should be called
+ expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
+ // Callbacks should NOT be unregistered yet (component still mounted)
+ expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
+ // onModalDismiss should NOT be called (button was pressed)
+ expect(mockOnModalDismiss).not.toHaveBeenCalled();
+
+ // Clear mock calls from button press
+ jest.clearAllMocks();
+
+ // Unmount the component
+ act(() => {
+ unmount();
+ });
+
+ // onModalDismiss should NOT be called (button was pressed, not navigated back)
+ expect(mockOnModalDismiss).not.toHaveBeenCalled();
+ // Callbacks should be unregistered to prevent memory leak
+ expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
+ });
+
+ it('should allow multiple button presses without unregistering callbacks (regression test)', () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ const primaryButton = getByTestId('primary-button');
+
+ // Press the button first time
+ act(() => {
+ primaryButton.props.onPress();
+ });
+
+ expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
+ expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
+
+ // Press the button again (simulating returning to this screen after modal dismissal)
+ act(() => {
+ primaryButton.props.onPress();
+ });
+
+ // onButtonPress should be called again
+ expect(mockOnButtonPress).toHaveBeenCalledTimes(2);
+ // Callbacks should still NOT be unregistered (component still mounted)
+ expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Referrer cleanup integration', () => {
+ it('should ensure cleanup is called in correct order for referrer clearing', () => {
+ const callOrder: string[] = [];
+
+ const onModalDismissWithTracking = jest.fn(() => {
+ callOrder.push('onModalDismiss');
+ });
+
+ const unregisterWithTracking = jest.fn(() => {
+ callOrder.push('unregister');
+ });
+
+ getModalCallbacks.mockReturnValue({
+ onButtonPress: mockOnButtonPress,
+ onModalDismiss: onModalDismissWithTracking,
+ });
+
+ mockUnregisterModalCallbacks.mockImplementation(unregisterWithTracking);
+
+ const { unmount } = render(
+ ,
+ );
+
+ act(() => {
+ unmount();
+ });
+
+ // Verify onModalDismiss is called before unregister
+ expect(callOrder).toEqual(['onModalDismiss', 'unregister']);
+ });
+ });
+});
diff --git a/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx b/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx
index e28b85888..7fa700abe 100644
--- a/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx
+++ b/app/tests/src/screens/kyc/KYCVerifiedScreen.test.tsx
@@ -3,8 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
-import { useNavigation } from '@react-navigation/native';
-import { fireEvent, render } from '@testing-library/react-native';
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
import * as haptics from '@/integrations/haptics';
import KYCVerifiedScreen from '@/screens/kyc/KYCVerifiedScreen';
@@ -35,6 +34,9 @@ jest.mock('react-native-safe-area-context', () => ({
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
+ useRoute: jest.fn(() => ({
+ params: { documentId: 'test-document-id' },
+ })),
}));
// Mock Tamagui components
@@ -81,19 +83,33 @@ jest.mock('@/config/sentry', () => ({
captureException: jest.fn(),
}));
-const mockUseNavigation = useNavigation as jest.MockedFunction<
- typeof useNavigation
->;
+const mockEmit = jest.fn();
+const mockSelfClient = { emit: mockEmit };
+
+jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
+ useSelfClient: jest.fn(() => mockSelfClient),
+ loadSelectedDocument: jest.fn(() =>
+ Promise.resolve({ documentCategory: 'kyc' }),
+ ),
+ SdkEvents: {
+ DOCUMENT_OWNERSHIP_CONFIRMED: 'DOCUMENT_OWNERSHIP_CONFIRMED',
+ },
+}));
+
+jest.mock('@/stores/pendingKycStore', () => ({
+ usePendingKycStore: jest.fn(() => ({
+ pendingVerifications: [],
+ removePendingVerification: jest.fn(),
+ })),
+}));
+
+jest.mock('@/providers/passportDataProvider', () => ({
+ setSelectedDocument: jest.fn(() => Promise.resolve()),
+}));
describe('KYCVerifiedScreen', () => {
- const mockNavigate = jest.fn();
-
beforeEach(() => {
jest.clearAllMocks();
-
- mockUseNavigation.mockReturnValue({
- navigate: mockNavigate,
- } as any);
});
it('should render the screen without errors', () => {
@@ -140,17 +156,98 @@ describe('KYCVerifiedScreen', () => {
expect(haptics.buttonTap).toHaveBeenCalledTimes(1);
});
- it('should navigate to ProvingScreenRouter when "Generate proof" is pressed', () => {
+ it('should emit DOCUMENT_OWNERSHIP_CONFIRMED when "Generate proof" is pressed', async () => {
const { root } = render();
const button = root.findAllByType('button')[0];
fireEvent.press(button);
- expect(mockNavigate).toHaveBeenCalledWith('ProvingScreenRouter');
+ await waitFor(() => {
+ expect(mockEmit).toHaveBeenCalledWith(
+ 'DOCUMENT_OWNERSHIP_CONFIRMED',
+ expect.objectContaining({ documentCategory: 'kyc' }),
+ );
+ });
});
- it('should have navigation available', () => {
- render();
- expect(mockUseNavigation).toHaveBeenCalled();
+ it('should use the documentId from route params', () => {
+ const { root } = render();
+ // Component should render without errors when documentId is provided
+ expect(root).toBeTruthy();
+ });
+
+ describe('Loading state', () => {
+ it('should show "Generating..." text while loading', async () => {
+ const { root } = render();
+ const button = root.findAllByType('button')[0];
+
+ // Initially shows "Generate proof"
+ expect(button.props.children).toBe('Generate proof');
+ expect(button.props.disabled).toBeFalsy();
+
+ // Press the button
+ fireEvent.press(button);
+
+ // Should show "Generating..." while loading
+ await waitFor(() => {
+ const updatedButton = root.findAllByType('button')[0];
+ expect(updatedButton.props.children).toBe('Generating...');
+ expect(updatedButton.props.disabled).toBe(true);
+ });
+ });
+
+ it('should prevent multiple concurrent proof generations', async () => {
+ const { root } = render();
+ const button = root.findAllByType('button')[0];
+
+ // Press the button multiple times rapidly
+ fireEvent.press(button);
+ fireEvent.press(button);
+ fireEvent.press(button);
+
+ await waitFor(() => {
+ // Emit should only be called once
+ expect(mockEmit).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('should re-enable button after proof generation completes', async () => {
+ const { root } = render();
+ const button = root.findAllByType('button')[0];
+
+ fireEvent.press(button);
+
+ // Wait for async operations to complete
+ await waitFor(() => {
+ expect(mockEmit).toHaveBeenCalled();
+ });
+
+ // Button should be re-enabled after completion
+ await waitFor(() => {
+ const updatedButton = root.findAllByType('button')[0];
+ expect(updatedButton.props.disabled).toBeFalsy();
+ expect(updatedButton.props.children).toBe('Generate proof');
+ });
+ });
+
+ it('should re-enable button after error', async () => {
+ // Mock an error in setSelectedDocument
+ const { setSelectedDocument } = jest.requireMock(
+ '@/providers/passportDataProvider',
+ );
+ setSelectedDocument.mockRejectedValueOnce(new Error('Test error'));
+
+ const { root } = render();
+ const button = root.findAllByType('button')[0];
+
+ fireEvent.press(button);
+
+ // Wait for error handling
+ await waitFor(() => {
+ const updatedButton = root.findAllByType('button')[0];
+ expect(updatedButton.props.disabled).toBeFalsy();
+ expect(updatedButton.props.children).toBe('Generate proof');
+ });
+ });
});
});
diff --git a/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx b/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx
index 14baedc09..fc241a41d 100644
--- a/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx
+++ b/app/tests/src/screens/kyc/KycSuccessScreen.test.tsx
@@ -25,12 +25,33 @@ jest.mock('react-native', () => ({
},
View: ({ children, ...props }: any) => {children}
,
Text: ({ children, ...props }: any) => {children},
+ AppState: {
+ addEventListener: jest.fn(() => ({ remove: jest.fn() })),
+ currentState: 'active',
+ },
+ NativeModules: {
+ NativeLoggerBridge: {},
+ RNPassportReader: {},
+ },
+ NativeEventEmitter: jest.fn(() => ({
+ addListener: jest.fn(() => ({ remove: jest.fn() })),
+ removeAllListeners: jest.fn(),
+ })),
+ requireNativeComponent: jest.fn(() => 'NativeComponent'),
}));
jest.mock('react-native-edge-to-edge', () => ({
SystemBars: () => null,
}));
+jest.mock('@/hooks/useSumsubWebSocket', () => ({
+ useSumsubWebSocket: jest.fn(() => ({
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+ unsubscribeAll: jest.fn(),
+ })),
+}));
+
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0 })),
}));
@@ -45,6 +66,7 @@ jest.mock('tamagui', () => ({
YStack: ({ children, ...props }: any) => {children}
,
View: ({ children, ...props }: any) => {children}
,
Text: ({ children, ...props }: any) => {children},
+ styled: (Component: any) => (props: any) => ,
}));
jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
@@ -108,7 +130,10 @@ jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
}));
jest.mock('@/stores/settingStore', () => ({
- useSettingStore: jest.fn(),
+ useSettingStore: Object.assign(jest.fn(), {
+ getState: jest.fn(() => ({ loggingSeverity: 'info' })),
+ subscribe: jest.fn(() => jest.fn()),
+ }),
}));
const mockUseNavigation = useNavigation as jest.MockedFunction<
diff --git a/app/tests/src/utils/cardBackgroundSelector.test.ts b/app/tests/src/utils/cardBackgroundSelector.test.ts
new file mode 100644
index 000000000..80d8fc18e
--- /dev/null
+++ b/app/tests/src/utils/cardBackgroundSelector.test.ts
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import type { IDDocument } from '@selfxyz/common';
+import { serializeKycData } from '@selfxyz/common';
+
+import { getBackgroundIndex } from '@/utils/cardBackgroundSelector';
+
+const BACKGROUND_COUNT = 6;
+
+function createKycDocument(serializedApplicantInfo: string): IDDocument {
+ return {
+ documentCategory: 'kyc',
+ documentType: 'drivers_licence',
+ mock: false,
+ serializedApplicantInfo,
+ signature: '',
+ pubkey: [],
+ };
+}
+
+describe('getBackgroundIndex', () => {
+ it('returns a deterministic index for a valid KYC payload', () => {
+ const serializedData = serializeKycData({
+ country: 'USA',
+ idType: 'passport',
+ idNumber: 'P1234567',
+ issuanceDate: '2020-01-01',
+ expiryDate: '2030-01-01',
+ fullName: 'Jane Doe',
+ dob: '1990-01-01',
+ photoHash: 'photohash',
+ phoneNumber: '+1234567890',
+ gender: 'F',
+ address: '123 Main St',
+ });
+ const serializedApplicantInfo = Buffer.from(
+ serializedData,
+ 'utf-8',
+ ).toString('base64');
+
+ const document = createKycDocument(serializedApplicantInfo);
+
+ const firstIndex = getBackgroundIndex(document);
+ const secondIndex = getBackgroundIndex(document);
+
+ expect(firstIndex).toBe(secondIndex);
+ expect(firstIndex).toBeGreaterThanOrEqual(1);
+ expect(firstIndex).toBeLessThanOrEqual(BACKGROUND_COUNT);
+ });
+
+ it('does not throw for malformed KYC payload and still returns a valid index', () => {
+ const document = createKycDocument(undefined as unknown as string);
+
+ expect(() => getBackgroundIndex(document)).not.toThrow();
+
+ const index = getBackgroundIndex(document);
+ expect(index).toBeGreaterThanOrEqual(1);
+ expect(index).toBeLessThanOrEqual(BACKGROUND_COUNT);
+ });
+});
diff --git a/app/tests/src/utils/documents.test.ts b/app/tests/src/utils/documents.test.ts
new file mode 100644
index 000000000..e5ceac204
--- /dev/null
+++ b/app/tests/src/utils/documents.test.ts
@@ -0,0 +1,132 @@
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import type { DocumentMetadata } from '@selfxyz/common';
+
+import { isDocumentInactive } from '@/utils/documents';
+
+const createMockMetadata = (
+ overrides: Partial = {},
+): DocumentMetadata =>
+ ({
+ id: 'test-doc-id',
+ documentType: 'aadhaar',
+ documentCategory: 'aadhaar',
+ data: 'test-data',
+ mock: false,
+ isRegistered: true,
+ registeredAt: Date.now(),
+ ...overrides,
+ }) as DocumentMetadata;
+
+describe('isDocumentInactive', () => {
+ describe('registered pre-document expiration', () => {
+ describe('when hasExpirationDate is undefined', () => {
+ it('returns true for aadhaar document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'aadhaar',
+ hasExpirationDate: undefined,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false for passport document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'passport',
+ hasExpirationDate: undefined,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for id_card document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'id_card',
+ hasExpirationDate: undefined,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+ });
+ });
+
+ describe('registered post-document expiration', () => {
+ describe('when hasExpirationDate is true', () => {
+ it('returns false for aadhaar document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'aadhaar',
+ hasExpirationDate: true,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for passport document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'passport',
+ hasExpirationDate: true,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for id_card document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'id_card',
+ hasExpirationDate: true,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('when hasExpirationDate is false', () => {
+ it('returns false for aadhaar document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'aadhaar',
+ hasExpirationDate: false,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for passport document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'passport',
+ hasExpirationDate: false,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false for id_card document', () => {
+ const metadata = createMockMetadata({
+ documentCategory: 'id_card',
+ hasExpirationDate: false,
+ });
+
+ const result = isDocumentInactive(metadata);
+
+ expect(result).toBe(false);
+ });
+ });
+ });
+});
diff --git a/app/tests/src/utils/points/api.test.ts b/app/tests/src/utils/points/api.test.ts
index 2d1c51891..36b7291cf 100644
--- a/app/tests/src/utils/points/api.test.ts
+++ b/app/tests/src/utils/points/api.test.ts
@@ -77,6 +77,7 @@ describe('Points API - Signature Logic', () => {
let mockWallet: any;
let consoleErrorSpy: jest.SpyInstance;
+ let originalBufferFrom: typeof Buffer.from;
beforeEach(() => {
jest.clearAllMocks();
@@ -101,6 +102,11 @@ describe('Points API - Signature Logic', () => {
// Mock ethers.getBytes
(ethers.getBytes as jest.Mock).mockReturnValue(mockSignatureBytes);
+ // Save original Buffer.from before mocking (global.Buffer is shared across
+ // all test files in the same worker, so we must restore it to avoid
+ // poisoning other test files like nfcScanner.test.ts)
+ originalBufferFrom = global.Buffer.from;
+
// Mock Buffer.from for base64 conversion
global.Buffer.from = jest.fn().mockReturnValue({
toString: jest.fn().mockReturnValue(mockSignatureBase64),
@@ -113,6 +119,7 @@ describe('Points API - Signature Logic', () => {
});
afterEach(() => {
+ global.Buffer.from = originalBufferFrom;
consoleErrorSpy.mockRestore();
});
diff --git a/app/version.json b/app/version.json
index e62830802..e8600bc29 100644
--- a/app/version.json
+++ b/app/version.json
@@ -1,10 +1,10 @@
{
"ios": {
- "build": 212,
- "lastDeployed": "2026-02-06T23:20:10.343Z"
+ "build": 213,
+ "lastDeployed": "2026-02-09T22:47:48.603Z"
},
"android": {
- "build": 140,
- "lastDeployed": "2026-02-05T00:58:22Z"
+ "build": 142,
+ "lastDeployed": "2026-02-10T00:22:34Z"
}
}
diff --git a/common/index.ts b/common/index.ts
index bac966259..915bc4f16 100644
--- a/common/index.ts
+++ b/common/index.ts
@@ -26,6 +26,7 @@ export type { Environment } from './src/utils/types.js';
// Utils exports
export {
+ AADHAAR_ATTESTATION_ID,
API_URL,
API_URL_STAGING,
CSCA_TREE_URL,
@@ -42,9 +43,8 @@ export {
IDENTITY_TREE_URL_STAGING,
IDENTITY_TREE_URL_STAGING_ID_CARD,
ID_CARD_ATTESTATION_ID,
- PASSPORT_ATTESTATION_ID,
- AADHAAR_ATTESTATION_ID,
KYC_ATTESTATION_ID,
+ PASSPORT_ATTESTATION_ID,
PCR0_MANAGER_ADDRESS,
REDIRECT_URL,
RPC_URL,
@@ -102,6 +102,23 @@ export {
stringToBigInt,
} from './src/utils/index.js';
+export {
+ KYC_ID_NUMBER_INDEX,
+ KYC_ID_NUMBER_LENGTH,
+ KYC_MAX_LENGTH,
+} from './src/utils/kyc/constants.js';
+
+export type { KycData } from './src/utils/kyc/types.js';
+export { serializeKycData } from './src/utils/kyc/types.js';
+
+export {
+ NON_OFAC_DUMMY_INPUT,
+ OFAC_DUMMY_INPUT,
+ generateKycDiscloseInput,
+ generateKycRegisterInput,
+ generateMockKycRegisterInput,
+} from './src/utils/kyc/generateInputs.js';
+
// Crypto polyfill for cross-platform compatibility
export {
createHash,
@@ -121,10 +138,11 @@ export {
hash,
packBytesAndPoseidon,
} from './src/utils/hash.js';
+export { deserializeApplicantInfo } from './src/utils/kyc/api.js';
export { generateTestData, testCustomData } from './src/utils/aadhaar/utils.js';
-export { isAadhaarDocument, isMRZDocument } from './src/utils/index.js';
+export { isAadhaarDocument, isKycDocument, isMRZDocument } from './src/utils/index.js';
export {
prepareAadhaarDiscloseData,
@@ -132,19 +150,3 @@ export {
prepareAadhaarRegisterData,
prepareAadhaarRegisterTestData,
} from './src/utils/aadhaar/mockData.js';
-
-export {
- generateKycDiscloseInput,
- generateMockKycRegisterInput,
- NON_OFAC_DUMMY_INPUT,
- OFAC_DUMMY_INPUT,
- generateKycRegisterInput,
-} from './src/utils/kyc/generateInputs.js';
-
-export {
- KYC_MAX_LENGTH,
- KYC_ID_NUMBER_INDEX,
- KYC_ID_NUMBER_LENGTH,
-} from './src/utils/kyc/constants.js';
-
-export { serializeKycData, KycData } from './src/utils/kyc/types.js';
diff --git a/common/src/utils/aadhaar/mockData.ts b/common/src/utils/aadhaar/mockData.ts
index 4357478bd..12c5d7c38 100644
--- a/common/src/utils/aadhaar/mockData.ts
+++ b/common/src/utils/aadhaar/mockData.ts
@@ -574,13 +574,13 @@ export function processQRDataSimple(qrData: string) {
const extractedFields = extractQRDataFields(qrDataBytes);
// Calculate qrHash exclude timestamp (positions 9-25, 17 bytes)
- // const qrDataWithoutTimestamp = [
- // ...Array.from(qrDataPadded.slice(0, 9)),
- // ...Array.from(qrDataPadded.slice(9, 26)).map((x) => 0),
- // ...Array.from(qrDataPadded.slice(26)),
- // ];
- // const qrHash = packBytesAndPoseidon(qrDataWithoutTimestamp);
- const qrHash = packBytesAndPoseidon(Array.from(qrDataPadded));
+ const qrDataWithoutTimestamp = [
+ ...Array.from(qrDataPadded.slice(0, 9)),
+ ...Array.from(qrDataPadded.slice(9, 26)).map((x) => 0),
+ ...Array.from(qrDataPadded.slice(26)),
+ ];
+ const qrHash = packBytesAndPoseidon(qrDataWithoutTimestamp);
+ // const qrHash = packBytesAndPoseidon(Array.from(qrDataPadded));
const photo = extractPhoto(Array.from(qrDataPadded), photoEOI + 1);
const photoHash = packBytesAndPoseidon(photo.bytes.map(Number));
diff --git a/common/src/utils/circuits/circuitsName.ts b/common/src/utils/circuits/circuitsName.ts
index bb83c3925..64fc5075d 100644
--- a/common/src/utils/circuits/circuitsName.ts
+++ b/common/src/utils/circuits/circuitsName.ts
@@ -1,4 +1,4 @@
-import type { IDDocument, PassportData } from '../types.js';
+import { type IDDocument, isKycDocument, type PassportData } from '../types.js';
export function getCircuitNameFromPassportData(
passportData: IDDocument,
@@ -14,6 +14,10 @@ export function getCircuitNameFromPassportData(
function getDSCircuitNameFromPassportData(passportData: IDDocument) {
console.log('Getting DSC circuit name from passport data...');
+ if (isKycDocument(passportData)) {
+ throw new Error('KYC documents do not have a DSC circuit');
+ }
+
if (passportData.documentCategory === 'aadhaar') {
throw new Error('Aadhaar does not have a DSC circuit');
}
@@ -87,6 +91,10 @@ function getRegisterNameFromPassportData(passportData: IDDocument) {
return 'register_aadhaar';
}
+ if (isKycDocument(passportData)) {
+ return 'register_kyc';
+ }
+
if (!passportData.passportMetadata) {
console.error('Passport metadata is missing');
throw new Error('Passport data are not parsed');
diff --git a/common/src/utils/circuits/registerInputs.ts b/common/src/utils/circuits/registerInputs.ts
index 0f510b6b2..8fb079ee7 100644
--- a/common/src/utils/circuits/registerInputs.ts
+++ b/common/src/utils/circuits/registerInputs.ts
@@ -18,77 +18,24 @@ import {
getCircuitNameFromPassportData,
hashEndpointWithScope,
} from '../../utils/index.js';
-import type { AadhaarData, Environment, IDDocument, OfacTree } from '../../utils/types.js';
+import type {
+ AadhaarData,
+ Environment,
+ IDDocument,
+ KycData as KycIDData,
+ OfacTree,
+} from '../../utils/types.js';
+import { KycField } from '../kyc/constants.js';
+import {
+ generateKycDiscloseInputFromData,
+ generateKycRegisterInput,
+} from '../kyc/generateInputs.js';
import { LeanIMT } from '@openpassport/zk-kit-lean-imt';
import { SMT } from '@openpassport/zk-kit-smt';
-import { KycField } from '../kyc/constants.js';
export { generateCircuitInputsRegister } from './generateInputs.js';
-// export function generateTEEInputsKycDisclose( secret: string,
-// kycData: KycData,
-// selfApp: SelfApp,
-// getTree: (
-// doc: DocumentCategory,
-// tree: T
-// ) => T extends 'ofac' ? OfacTree : any
-
-// ) {
-
-// const {generateKycInputWithOutSig} = require('../kyc/generateInputs.js');
-
-// const { scope, disclosures, userId, userDefinedData, chainID } = selfApp;
-// const userIdentifierHash = calculateUserIdentifierHash(chainID, userId, userDefinedData);
-
-// // Map SelfAppDisclosureConfig to KycField array
-// const mapDisclosuresToKycFields = (config: SelfAppDisclosureConfig): KycField[] => {
-// const mapping: [keyof SelfAppDisclosureConfig, KycField][] = [
-// ['issuing_state', 'ADDRESS'],
-// ['nationality', 'COUNTRY'],
-// ['name', 'FULL_NAME'],
-// ['passport_number', 'ID_NUMBER'],
-// ['date_of_birth', 'DOB'],
-// ['gender', 'GENDER'],
-// ['expiry_date', 'EXPIRY_DATE'],
-// ];
-// return mapping.filter(([key]) => config[key]).map(([_, field]) => field);
-// };
-
-// const ofac_trees = getTree('kyc', 'ofac');
-// if (!ofac_trees) {
-// throw new Error('OFAC trees not loaded');
-// }
-
-// if (!ofac_trees.nameAndDob || !ofac_trees.nameAndYob) {
-// throw new Error('Invalid OFAC tree structure: missing required fields');
-// }
-
-// const nameAndDobSMT = new SMT(poseidon2, true);
-// const nameAndYobSMT = new SMT(poseidon2, true);
-// nameAndDobSMT.import(ofac_trees.nameAndDob);
-// nameAndYobSMT.import(ofac_trees.nameAndYob);
-
-// const inputs = generateKycInputWithOutSig(
-// kycData.serializedRealData,
-// nameAndDobSMT,
-// nameAndYobSMT,
-// disclosures.ofac,
-// scope,
-// userIdentifierHash.toString(),
-// mapDisclosuresToKycFields(disclosures),
-// disclosures.excludedCountries,
-// disclosures.minimumAge
-// );
-
-// return {
-// inputs,
-// circuitName: 'vc_and_disclose_kyc',
-// endpointType: selfApp.endpointType,
-// endpoint: selfApp.endpoint,
-// };
-// }
-
export function generateTEEInputsAadhaarDisclose(
secret: string,
aadhaarData: AadhaarData,
@@ -182,45 +129,6 @@ export function generateTEEInputsDSC(
return { inputs, circuitName, endpointType, endpoint };
}
-/*** DISCLOSURE ***/
-
-function getSelectorDg1(document: DocumentCategory, disclosures: SelfAppDisclosureConfig) {
- switch (document) {
- case 'passport':
- return getSelectorDg1Passport(disclosures);
- case 'id_card':
- return getSelectorDg1IdCard(disclosures);
- }
-}
-
-function getSelectorDg1Passport(disclosures: SelfAppDisclosureConfig) {
- const selector_dg1 = Array(88).fill('0');
- Object.entries(disclosures).forEach(([attribute, reveal]) => {
- if (['ofac', 'excludedCountries', 'minimumAge'].includes(attribute)) {
- return;
- }
- if (reveal) {
- const [start, end] = attributeToPosition[attribute as keyof typeof attributeToPosition];
- selector_dg1.fill('1', start, end + 1);
- }
- });
- return selector_dg1;
-}
-
-function getSelectorDg1IdCard(disclosures: SelfAppDisclosureConfig) {
- const selector_dg1 = Array(90).fill('0');
- Object.entries(disclosures).forEach(([attribute, reveal]) => {
- if (['ofac', 'excludedCountries', 'minimumAge'].includes(attribute)) {
- return;
- }
- if (reveal) {
- const [start, end] = attributeToPosition_ID[attribute as keyof typeof attributeToPosition_ID];
- selector_dg1.fill('1', start, end + 1);
- }
- });
- return selector_dg1;
-}
-
export function generateTEEInputsDiscloseStateless(
secret: string,
passportData: IDDocument,
@@ -239,15 +147,15 @@ export function generateTEEInputsDiscloseStateless(
);
return { inputs, circuitName, endpointType, endpoint };
}
- // if (passportData.documentCategory === 'kyc') {
- // const { inputs, circuitName, endpointType, endpoint } = generateTEEInputsKycDisclose(
- // secret,
- // passportData,
- // selfApp,
- // getTree
- // );
- // return { inputs, circuitName, endpointType, endpoint };
- // }
+ if (passportData.documentCategory === 'kyc') {
+ const { inputs, circuitName, endpointType, endpoint } = generateTEEInputsKycDisclose(
+ secret,
+ passportData,
+ selfApp,
+ getTree
+ );
+ return { inputs, circuitName, endpointType, endpoint };
+ }
const { scope, disclosures, endpoint, userId, userDefinedData, chainID } = selfApp;
const userIdentifierHash = calculateUserIdentifierHash(chainID, userId, userDefinedData);
const scope_hash = hashEndpointWithScope(endpoint, scope);
@@ -310,6 +218,111 @@ export function generateTEEInputsDiscloseStateless(
};
}
+/*** DISCLOSURE ***/
+
+function getSelectorDg1(document: DocumentCategory, disclosures: SelfAppDisclosureConfig) {
+ switch (document) {
+ case 'passport':
+ return getSelectorDg1Passport(disclosures);
+ case 'id_card':
+ return getSelectorDg1IdCard(disclosures);
+ }
+}
+
+function getSelectorDg1Passport(disclosures: SelfAppDisclosureConfig) {
+ const selector_dg1 = Array(88).fill('0');
+ Object.entries(disclosures).forEach(([attribute, reveal]) => {
+ if (['ofac', 'excludedCountries', 'minimumAge'].includes(attribute)) {
+ return;
+ }
+ if (reveal) {
+ const [start, end] = attributeToPosition[attribute as keyof typeof attributeToPosition];
+ selector_dg1.fill('1', start, end + 1);
+ }
+ });
+ return selector_dg1;
+}
+
+function getSelectorDg1IdCard(disclosures: SelfAppDisclosureConfig) {
+ const selector_dg1 = Array(90).fill('0');
+ Object.entries(disclosures).forEach(([attribute, reveal]) => {
+ if (['ofac', 'excludedCountries', 'minimumAge'].includes(attribute)) {
+ return;
+ }
+ if (reveal) {
+ const [start, end] = attributeToPosition_ID[attribute as keyof typeof attributeToPosition_ID];
+ selector_dg1.fill('1', start, end + 1);
+ }
+ });
+ return selector_dg1;
+}
+
+export function generateTEEInputsKycDisclose(
+ secret: string,
+ kycData: KycIDData,
+ selfApp: SelfApp,
+ getTree: (
+ doc: DocumentCategory,
+ tree: T
+ ) => T extends 'ofac' ? OfacTree : any
+) {
+ const { scope, disclosures, endpoint, userId, userDefinedData, chainID } = selfApp;
+ const userIdentifierHash = calculateUserIdentifierHash(chainID, userId, userDefinedData);
+ const scope_hash = hashEndpointWithScope(endpoint, scope);
+
+ // Map SelfAppDisclosureConfig to KycField array
+ const mapDisclosuresToKycFields = (config: SelfAppDisclosureConfig): KycField[] => {
+ const mapping: [keyof SelfAppDisclosureConfig, KycField][] = [
+ ['issuing_state', 'ADDRESS'],
+ ['nationality', 'COUNTRY'],
+ ['name', 'FULL_NAME'],
+ ['passport_number', 'ID_NUMBER'],
+ ['date_of_birth', 'DOB'],
+ ['gender', 'GENDER'],
+ ['expiry_date', 'EXPIRY_DATE'],
+ ];
+ return mapping.filter(([key]) => config[key]).map(([_, field]) => field);
+ };
+
+ const ofac_trees = getTree('kyc', 'ofac');
+ if (!ofac_trees) {
+ throw new Error('OFAC trees not loaded');
+ }
+
+ if (!ofac_trees.nameAndDob || !ofac_trees.nameAndYob) {
+ throw new Error('Invalid OFAC tree structure: missing required fields');
+ }
+
+ const nameAndDobSMT = new SMT(poseidon2, true);
+ const nameAndYobSMT = new SMT(poseidon2, true);
+ nameAndDobSMT.import(ofac_trees.nameAndDob);
+ nameAndYobSMT.import(ofac_trees.nameAndYob);
+
+ const serialized_tree = getTree('kyc', 'commitment');
+ const tree = LeanIMT.import((a, b) => poseidon2([a, b]), serialized_tree);
+
+ const inputs = generateKycDiscloseInputFromData(
+ kycData.serializedApplicantInfo,
+ secret,
+ nameAndDobSMT,
+ nameAndYobSMT,
+ tree,
+ disclosures.ofac ?? false,
+ scope_hash,
+ userIdentifierHash.toString(),
+ mapDisclosuresToKycFields(disclosures),
+ disclosures.excludedCountries,
+ disclosures.minimumAge
+ );
+
+ return {
+ inputs,
+ circuitName: 'vc_and_disclose_kyc',
+ endpointType: selfApp.endpointType,
+ endpoint: selfApp.endpoint,
+ };
+}
+
export async function generateTEEInputsRegister(
secret: string,
passportData: IDDocument,
@@ -326,11 +339,26 @@ export async function generateTEEInputsRegister(
return { inputs, circuitName, endpointType, endpoint };
}
- // if (passportData.documentCategory === 'kyc') {
- // throw new Error('Kyc does not support registration');
- // }
+ if (passportData.documentCategory === 'kyc') {
+ const inputs = await generateKycRegisterInput(
+ passportData.serializedApplicantInfo,
+ passportData.signature,
+ [passportData.pubkey[0].toString(), passportData.pubkey[1].toString()],
+ secret
+ );
+ return {
+ inputs,
+ circuitName: getCircuitNameFromPassportData(passportData, 'register'),
+ endpointType: env === 'stg' ? 'staging_celo' : 'celo',
+ endpoint: 'https://self.xyz',
+ };
+ }
- const inputs = generateCircuitInputsRegister(secret, passportData, dscTree as string);
+ const inputs = generateCircuitInputsRegister(
+ secret,
+ passportData as PassportData,
+ dscTree as string
+ );
const circuitName = getCircuitNameFromPassportData(passportData, 'register');
const endpointType = env === 'stg' ? 'staging_celo' : 'celo';
const endpoint = 'https://self.xyz';
diff --git a/common/src/utils/index.ts b/common/src/utils/index.ts
index 5f899a58d..b0924fd97 100644
--- a/common/src/utils/index.ts
+++ b/common/src/utils/index.ts
@@ -5,6 +5,7 @@ export type {
DocumentCategory,
DocumentMetadata,
IDDocument,
+ KycData,
OfacTree,
PassportData,
} from './types.js';
@@ -70,6 +71,6 @@ export {
export { getCircuitNameFromPassportData } from './circuits/circuitsName.js';
export { getSKIPEM } from './csca.js';
export { initElliptic } from './certificate_parsing/elliptic.js';
-export { isAadhaarDocument, isMRZDocument } from './types.js';
+export { isAadhaarDocument, isKycDocument, isMRZDocument } from './types.js';
export { parseCertificateSimple } from './certificate_parsing/parseCertificateSimple.js';
export { parseDscCertificateData } from './passports/passport_parsing/parseDscCertificateData.js';
diff --git a/common/src/utils/kyc/api.ts b/common/src/utils/kyc/api.ts
index d9ecd4afb..967bf0e11 100644
--- a/common/src/utils/kyc/api.ts
+++ b/common/src/utils/kyc/api.ts
@@ -1,5 +1,7 @@
-//Helper function to destructure the kyc data from the api response
-import { Point } from '@zk-kit/baby-jubjub';
+// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
import {
KYC_ADDRESS_INDEX,
KYC_ADDRESS_LENGTH,
@@ -26,11 +28,7 @@ import {
} from './constants.js';
import { KycData } from './types.js';
-//accepts a base64 signature and returns a signature object
-export function deserializeSignature(signature: string): { R: Point; s: bigint } {
- const [Rx, Ry, s] = Buffer.from(signature, 'base64').toString('utf-8').split(',').map(BigInt);
- return { R: [Rx, Ry] as Point, s };
-}
+import { Point } from '@zk-kit/baby-jubjub';
//accepts a base64 applicant info and returns a kyc data object
export function deserializeApplicantInfo(
@@ -88,3 +86,9 @@ export function deserializeApplicantInfo(
address,
};
}
+
+//accepts a base64 signature and returns a signature object
+export function deserializeSignature(signature: string): { R: Point; s: bigint } {
+ const [Rx, Ry, s] = Buffer.from(signature, 'base64').toString('utf-8').split(',').map(BigInt);
+ return { R: [Rx, Ry] as Point, s };
+}
diff --git a/common/src/utils/kyc/generateInputs.ts b/common/src/utils/kyc/generateInputs.ts
index 92c74319d..d33874787 100644
--- a/common/src/utils/kyc/generateInputs.ts
+++ b/common/src/utils/kyc/generateInputs.ts
@@ -1,38 +1,23 @@
-import { SMT } from '@openpassport/zk-kit-smt';
+import { poseidon2 } from 'poseidon-lite';
+
+import { COMMITMENT_TREE_DEPTH } from '../../constants/constants.js';
+import { formatCountriesList } from '../circuits/formatInputs.js';
+import { findIndexInTree, formatInput } from '../circuits/generateInputs.js';
+import { packBytesAndPoseidon } from '../hash.js';
import {
generateMerkleProof,
generateSMTProof,
getNameDobLeafKyc,
getNameYobLeafKyc,
} from '../trees.js';
-import { KycDiscloseInput, KycRegisterInput, serializeKycData, KycData } from './types.js';
-import { findIndexInTree, formatInput } from '../circuits/generateInputs.js';
-import { createKycSelector, KYC_MAX_LENGTH, KycField } from './constants.js';
-import { poseidon2 } from 'poseidon-lite';
-import { Base8, inCurve, mulPointEscalar, subOrder } from '@zk-kit/baby-jubjub';
-import { signEdDSA } from './ecdsa/ecdsa.js';
-import { LeanIMT } from '@openpassport/zk-kit-lean-imt';
-import { packBytesAndPoseidon } from '../hash.js';
-import { COMMITMENT_TREE_DEPTH } from '../../constants/constants.js';
import { deserializeApplicantInfo, deserializeSignature } from './api.js';
+import { createKycSelector, KYC_MAX_LENGTH, KycField } from './constants.js';
+import { signEdDSA } from './ecdsa/ecdsa.js';
+import { KycData, KycDiscloseInput, KycRegisterInput, serializeKycData } from './types.js';
-export const OFAC_DUMMY_INPUT: KycData = {
- country: 'KEN',
- idType: 'NATIONAL ID',
- idNumber: '12345678901234567890123456789012', //32 digits
- issuanceDate: '20200101',
- expiryDate: '20290101',
- fullName: 'ABBAS ABU',
- dob: '19481210',
- photoHash: '1234567890',
- phoneNumber: '1234567890',
- gender: 'M',
- address: '1234567890',
- user_identifier: '1234567890',
- current_date: '20250101',
- majority_age_ASCII: '20',
- selector_older_than: '1',
-};
+import { LeanIMT } from '@openpassport/zk-kit-lean-imt';
+import { SMT } from '@openpassport/zk-kit-smt';
+import { Base8, inCurve, mulPointEscalar, subOrder } from '@zk-kit/baby-jubjub';
export const NON_OFAC_DUMMY_INPUT: KycData = {
country: 'KEN',
@@ -52,66 +37,29 @@ export const NON_OFAC_DUMMY_INPUT: KycData = {
selector_older_than: '1',
};
+export const OFAC_DUMMY_INPUT: KycData = {
+ country: 'KEN',
+ idType: 'NATIONAL ID',
+ idNumber: '12345678901234567890123456789012', //32 digits
+ issuanceDate: '20200101',
+ expiryDate: '20290101',
+ fullName: 'ABBAS ABU',
+ dob: '19481210',
+ photoHash: '1234567890',
+ phoneNumber: '1234567890',
+ gender: 'M',
+ address: '1234567890',
+ user_identifier: '1234567890',
+ current_date: '20250101',
+ majority_age_ASCII: '20',
+ selector_older_than: '1',
+};
+
export const createKycDiscloseSelFromFields = (fieldsToReveal: KycField[]): string[] => {
const [lowResult, highResult] = createKycSelector(fieldsToReveal);
return [lowResult.toString(), highResult.toString()];
};
-export const generateMockKycRegisterInput = async (
- secretKey?: bigint,
- ofac?: boolean,
- secret?: string
-) => {
- const kycData = ofac ? OFAC_DUMMY_INPUT : NON_OFAC_DUMMY_INPUT;
- const serializedData = serializeKycData(kycData).padEnd(KYC_MAX_LENGTH, '\0');
-
- const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
-
- const sk = secretKey ? secretKey : BigInt(Math.floor(Math.random() * Number(subOrder - 2n))) + 1n;
-
- const pk = mulPointEscalar(Base8, sk);
- console.assert(inCurve(pk), 'Point pk not on curve');
- console.assert(pk[0] != 0n && pk[1] != 0n, 'pk is zero');
-
- const [sig, pubKey] = signEdDSA(sk, msgPadded);
- console.assert(BigInt(sig.S) < subOrder, ' s is greater than scalar field');
-
- const kycRegisterInput: KycRegisterInput = {
- data_padded: msgPadded.map((x) => Number(x)),
- s: BigInt(sig.S),
- R: sig.R8 as [bigint, bigint],
- pubKey,
- secret: secret || '1234',
- };
-
- return kycRegisterInput;
-};
-
-export const generateKycRegisterInput = async (
- applicantInfoBase64: string,
- signatureBase64: string,
- pubkeyStr: [string, string],
- secret: string
-) => {
- const applicantInfo = deserializeApplicantInfo(applicantInfoBase64);
- const signature = deserializeSignature(signatureBase64);
- const pubkey = [BigInt(pubkeyStr[0]), BigInt(pubkeyStr[1])] as [bigint, bigint];
-
- const serializedData = serializeKycData(applicantInfo);
-
- const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
-
- const kycRegisterInput: KycRegisterInput = {
- data_padded: msgPadded.map((x) => Number(x)),
- s: signature.s,
- R: signature.R,
- pubKey: pubkey,
- secret,
- };
-
- return kycRegisterInput;
-};
-
export const generateCircuitInputsOfac = (data: KycData, smt: SMT, proofLevel: number) => {
const name = data.fullName;
const dob = data.dob;
@@ -195,7 +143,9 @@ export const generateKycDiscloseInput = (
leaf_depth: formatInput(leaf_depth),
path: formatInput(merkle_path),
siblings: formatInput(siblings),
- forbidden_countries_list: forbiddenCountriesList || [...Array(120)].map((x) => '0'),
+ forbidden_countries_list: forbiddenCountriesList
+ ? formatInput(formatCountriesList(forbiddenCountriesList))
+ : [...Array(120)].map((x) => '0'),
ofac_name_dob_smt_leaf_key: nameDobInputs.smt_leaf_key,
ofac_name_dob_smt_root: nameDobInputs.smt_root,
ofac_name_dob_smt_siblings: nameDobInputs.smt_siblings,
@@ -211,3 +161,141 @@ export const generateKycDiscloseInput = (
return circuitInput;
};
+
+export const generateKycDiscloseInputFromData = (
+ serializedApplicantInfo: string,
+ secret: string,
+ nameDobSmt: SMT,
+ nameYobSmt: SMT,
+ identityTree: LeanIMT,
+ ofac: boolean,
+ scope: string,
+ userIdentifier: string,
+ fieldsToReveal?: KycField[],
+ forbiddenCountriesList?: string[],
+ minimumAge?: number
+): KycDiscloseInput => {
+ // Decode base64 applicant info to get raw padded bytes for the circuit
+ const rawData = Buffer.from(serializedApplicantInfo, 'base64').toString('utf-8');
+ const serializedData = rawData.padEnd(KYC_MAX_LENGTH, '\0');
+ const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
+
+ // Compute commitment
+ const commitment = poseidon2([secret, packBytesAndPoseidon(msgPadded)]);
+
+ // Find in tree and generate merkle proof
+ const index = findIndexInTree(identityTree, commitment);
+ const {
+ siblings,
+ path: merkle_path,
+ leaf_depth,
+ } = generateMerkleProof(identityTree, index, COMMITMENT_TREE_DEPTH);
+
+ // Deserialize to get individual fields for OFAC lookups
+ const applicantData = deserializeApplicantInfo(serializedApplicantInfo);
+ const ofacData = {
+ ...applicantData,
+ user_identifier: '',
+ current_date: '',
+ majority_age_ASCII: '',
+ selector_older_than: '',
+ } as KycData;
+ const nameDobInputs = generateCircuitInputsOfac(ofacData, nameDobSmt, 2);
+ const nameYobInputs = generateCircuitInputsOfac(ofacData, nameYobSmt, 1);
+
+ // Build disclosure selector
+ const fieldsToRevealFinal = fieldsToReveal || [];
+ const compressed_disclose_sel = createKycDiscloseSelFromFields(fieldsToRevealFinal);
+
+ // Age and date
+ const majorityAgeASCII = minimumAge
+ ? minimumAge
+ .toString()
+ .padStart(3, '0')
+ .split('')
+ .map((x) => x.charCodeAt(0))
+ : ['0', '0', '0'].map((x) => x.charCodeAt(0));
+
+ const currentDate = new Date().toISOString().split('T')[0].replace(/-/g, '').split('');
+
+ const circuitInput: KycDiscloseInput = {
+ data_padded: formatInput(msgPadded),
+ compressed_disclose_sel: compressed_disclose_sel,
+ scope: scope,
+ merkle_root: formatInput(BigInt(identityTree.root)),
+ leaf_depth: formatInput(leaf_depth),
+ path: formatInput(merkle_path),
+ siblings: formatInput(siblings),
+ forbidden_countries_list: forbiddenCountriesList
+ ? formatInput(formatCountriesList(forbiddenCountriesList))
+ : [...Array(120)].map(() => '0'),
+ ofac_name_dob_smt_leaf_key: nameDobInputs.smt_leaf_key,
+ ofac_name_dob_smt_root: nameDobInputs.smt_root,
+ ofac_name_dob_smt_siblings: nameDobInputs.smt_siblings,
+ ofac_name_yob_smt_leaf_key: nameYobInputs.smt_leaf_key,
+ ofac_name_yob_smt_root: nameYobInputs.smt_root,
+ ofac_name_yob_smt_siblings: nameYobInputs.smt_siblings,
+ selector_ofac: ofac ? ['1'] : ['0'],
+ user_identifier: userIdentifier,
+ current_date: currentDate,
+ majority_age_ASCII: majorityAgeASCII,
+ secret: secret,
+ };
+
+ return circuitInput;
+};
+
+export const generateKycRegisterInput = async (
+ applicantInfoBase64: string,
+ signatureBase64: string,
+ pubkeyStr: [string, string],
+ secret: string
+) => {
+ const applicantInfo = deserializeApplicantInfo(applicantInfoBase64);
+ const signature = deserializeSignature(signatureBase64);
+ const pubkey = [BigInt(pubkeyStr[0]), BigInt(pubkeyStr[1])] as [bigint, bigint];
+
+ const serializedData = serializeKycData(applicantInfo).padEnd(KYC_MAX_LENGTH, '\0');
+
+ const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
+
+ const kycRegisterInput: KycRegisterInput = {
+ data_padded: msgPadded,
+ s: signature.s,
+ R: signature.R,
+ pubKey: pubkey,
+ secret,
+ };
+
+ return kycRegisterInput;
+};
+
+export const generateMockKycRegisterInput = async (
+ secretKey?: bigint,
+ ofac?: boolean,
+ secret?: string
+) => {
+ const kycData = ofac ? OFAC_DUMMY_INPUT : NON_OFAC_DUMMY_INPUT;
+ const serializedData = serializeKycData(kycData).padEnd(KYC_MAX_LENGTH, '\0');
+
+ const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
+
+ const sk = secretKey ? secretKey : BigInt(Math.floor(Math.random() * Number(subOrder - 2n))) + 1n;
+
+ const pk = mulPointEscalar(Base8, sk);
+ console.assert(inCurve(pk), 'Point pk not on curve');
+ console.assert(pk[0] != 0n && pk[1] != 0n, 'pk is zero');
+
+ const [sig, pubKey] = signEdDSA(sk, msgPadded);
+ console.assert(BigInt(sig.S) < subOrder, ' s is greater than scalar field');
+
+ const kycRegisterInput: KycRegisterInput = {
+ data_padded: msgPadded.map((x) => Number(x)),
+ s: BigInt(sig.S),
+ R: sig.R8 as [bigint, bigint],
+ pubKey,
+ secret: secret || '1234',
+ };
+
+ return kycRegisterInput;
+};
diff --git a/common/src/utils/kyc/utils.ts b/common/src/utils/kyc/utils.ts
new file mode 100644
index 000000000..2fd152afd
--- /dev/null
+++ b/common/src/utils/kyc/utils.ts
@@ -0,0 +1,43 @@
+import { poseidon2 } from 'poseidon-lite';
+
+import { packBytesAndPoseidon } from '../hash.js';
+import { IDDocument, isKycDocument } from '../types.js';
+import { deserializeApplicantInfo } from './api.js';
+import {
+ KYC_ID_NUMBER_INDEX,
+ KYC_ID_NUMBER_LENGTH,
+ KYC_ID_TYPE_INDEX,
+ KYC_ID_TYPE_LENGTH,
+} from './constants.js';
+import { serializeKycData } from './types.js';
+
+export const generateKycCommitment = (passportData: IDDocument, secret: string) => {
+ if (isKycDocument(passportData)) {
+ const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
+ const serializedData = serializeKycData(applicantInfo);
+ const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
+ const dataPadded = msgPadded.map((x) => Number(x));
+ const commitment = poseidon2([secret, packBytesAndPoseidon(dataPadded)]);
+ return commitment.toString();
+ }
+};
+
+export const generateKycNullifier = (passportData: IDDocument) => {
+ if (isKycDocument(passportData)) {
+ const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
+ const serializedData = serializeKycData(applicantInfo);
+ const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
+ const dataPadded = msgPadded.map((x) => Number(x));
+ const idNumber = dataPadded.slice(
+ KYC_ID_NUMBER_INDEX,
+ KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
+ );
+ const nullifierInputs = [
+ ...'sumsub'.split('').map((x) => x.charCodeAt(0)),
+ ...idNumber,
+ ...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
+ ];
+ const nullifier = packBytesAndPoseidon(nullifierInputs);
+ return nullifier;
+ }
+};
diff --git a/common/src/utils/passports/passport.ts b/common/src/utils/passports/passport.ts
index d4daf0a51..43af8c8e9 100644
--- a/common/src/utils/passports/passport.ts
+++ b/common/src/utils/passports/passport.ts
@@ -29,14 +29,22 @@ import {
import { formatInput } from '../circuits/generateInputs.js';
import { findStartIndex, findStartIndexEC } from '../csca.js';
import { hash, packBytesAndPoseidon } from '../hash.js';
+import { deserializeApplicantInfo } from '../kyc/api.js';
+import {
+ KYC_ID_NUMBER_INDEX,
+ KYC_ID_NUMBER_LENGTH,
+ KYC_ID_TYPE_INDEX,
+ KYC_ID_TYPE_LENGTH,
+} from '../kyc/constants.js';
+import { serializeKycData } from '../kyc/types.js';
import { sha384_512Pad, shaPad } from '../shaPad.js';
import { getLeafDscTree } from '../trees.js';
import type { DocumentCategory, IDDocument, PassportData, SignatureAlgorithm } from '../types.js';
-import { AadhaarData, isAadhaarDocument, isMRZDocument } from '../types.js';
+import { AadhaarData, isAadhaarDocument, isKycDocument, isMRZDocument } from '../types.js';
import { formatMrz } from './format.js';
import { parsePassportData } from './passport_parsing/parsePassportData.js';
-export function calculateContentHash(passportData: PassportData | AadhaarData): string {
+export function calculateContentHash(passportData: IDDocument): string {
if (isMRZDocument(passportData) && passportData.eContent) {
// eContent is likely a buffer or array, convert to string properly
const eContentStr =
@@ -47,6 +55,13 @@ export function calculateContentHash(passportData: PassportData | AadhaarData):
return sha256(eContentStr);
}
+ if (isKycDocument(passportData)) {
+ const serializedData = passportData.serializedApplicantInfo;
+ const parsedApplicantInfo = deserializeApplicantInfo(serializedData);
+ const stableFields = `${parsedApplicantInfo.fullName}${parsedApplicantInfo.dob}${parsedApplicantInfo.country}${parsedApplicantInfo.idType}`;
+ return sha256(stableFields);
+ }
+
// For MRZ documents without eContent, hash core stable fields
const stableData = {
documentType: passportData.documentType,
@@ -193,6 +208,23 @@ export function generateNullifier(passportData: IDDocument) {
if (isAadhaarDocument(passportData)) {
return nullifierHash(passportData.extractedFields);
}
+ if (isKycDocument(passportData)) {
+ const applicantInfo = deserializeApplicantInfo(passportData.serializedApplicantInfo);
+ const serializedData = serializeKycData(applicantInfo);
+ const msgPadded = Array.from(serializedData, (x) => x.charCodeAt(0));
+ const dataPadded = msgPadded.map((x) => Number(x));
+ const idNumber = dataPadded.slice(
+ KYC_ID_NUMBER_INDEX,
+ KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
+ );
+ const nullifierInputs = [
+ ...'sumsub'.split('').map((x) => x.charCodeAt(0)),
+ ...idNumber,
+ ...dataPadded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
+ ];
+ const nullifier = packBytesAndPoseidon(nullifierInputs);
+ return nullifier;
+ }
const signedAttr_shaBytes = hash(
passportData.passportMetadata.signedAttrHashFunction,
@@ -318,6 +350,8 @@ export function getSignatureAlgorithmFullName(
export function inferDocumentCategory(documentType: string): DocumentCategory {
if (documentType.includes('passport')) {
return 'passport' as DocumentCategory;
+ } else if (documentType.includes('kyc')) {
+ return 'kyc' as DocumentCategory;
} else if (documentType.includes('id')) {
return 'id_card' as DocumentCategory;
} else if (documentType.includes('aadhaar')) {
diff --git a/common/src/utils/passports/validate.ts b/common/src/utils/passports/validate.ts
index a2be39ae4..5250c6a0a 100644
--- a/common/src/utils/passports/validate.ts
+++ b/common/src/utils/passports/validate.ts
@@ -22,12 +22,15 @@ import {
nullifierHash,
processQRDataSimple,
} from '../aadhaar/mockData.js';
+import { generateKycCommitment, generateKycNullifier } from '../kyc/utils.js';
import {
AadhaarData,
AttestationIdHex,
type DeployedCircuits,
type DocumentCategory,
IDDocument,
+ isKycDocument,
+ KycData,
type PassportData,
} from '../types.js';
import { generateCommitment, generateNullifier } from './passport.js';
@@ -49,7 +52,8 @@ function validateRegistrationCircuit(
circuitNameRegister &&
(deployedCircuits.REGISTER.includes(circuitNameRegister) ||
deployedCircuits.REGISTER_ID.includes(circuitNameRegister) ||
- deployedCircuits.REGISTER_AADHAAR.includes(circuitNameRegister));
+ deployedCircuits.REGISTER_AADHAAR.includes(circuitNameRegister) ||
+ deployedCircuits.REGISTER_KYC.includes(circuitNameRegister));
return { isValid: !!isValid, circuitName: circuitNameRegister };
}
@@ -82,7 +86,7 @@ export async function checkDocumentSupported(
details: string;
}> {
const deployedCircuits = opts.getDeployedCircuits(passportData.documentCategory);
- if (passportData.documentCategory === 'aadhaar') {
+ if (passportData.documentCategory === 'aadhaar' || passportData.documentCategory === 'kyc') {
const { isValid, circuitName } = validateRegistrationCircuit(passportData, deployedCircuits);
if (!isValid) {
@@ -241,7 +245,9 @@ export async function isDocumentNullified(passportData: IDDocument) {
? AttestationIdHex.passport
: passportData.documentCategory === 'aadhaar'
? AttestationIdHex.aadhaar
- : AttestationIdHex.id_card;
+ : passportData.documentCategory === 'kyc'
+ ? AttestationIdHex.kyc
+ : AttestationIdHex.id_card;
console.log('checking for nullifier', nullifierHex, attestationId);
const baseUrl = passportData.mock === false ? API_URL : API_URL_STAGING;
const controller = new AbortController();
@@ -270,7 +276,7 @@ export async function isDocumentNullified(passportData: IDDocument) {
}
export async function isUserRegistered(
- documentData: PassportData | AadhaarData,
+ documentData: IDDocument,
secret: string,
getCommitmentTree: (docCategory: DocumentCategory) => string
) {
@@ -281,7 +287,9 @@ export async function isUserRegistered(
const document: DocumentCategory = documentData.documentCategory;
let commitment: string;
- if (document === 'aadhaar') {
+ if (isKycDocument(documentData)) {
+ commitment = generateKycCommitment(documentData, secret);
+ } else if (document === 'aadhaar') {
const aadhaarData = documentData as AadhaarData;
const nullifier = nullifierHash(aadhaarData.extractedFields);
const packedCommitment = computePackedCommitment(aadhaarData.extractedFields);
@@ -327,6 +335,11 @@ export async function isUserRegisteredWithAlternativeCSCA(
let commitment_list: string[];
let csca_list: string[];
+ if (document === 'kyc') {
+ const isRegistered = await isUserRegistered(passportData, secret, getCommitmentTree);
+ return { isRegistered, csca: null };
+ }
+
if (document === 'aadhaar') {
// For Aadhaar, use public keys from protocol store instead of CSCA
const publicKeys = getAltCSCA(document);
diff --git a/common/src/utils/proving.ts b/common/src/utils/proving.ts
index e85ead857..0fb0a2fe9 100644
--- a/common/src/utils/proving.ts
+++ b/common/src/utils/proving.ts
@@ -1,5 +1,5 @@
-import forge from 'node-forge';
import { Buffer } from 'buffer';
+import forge from 'node-forge';
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
import { initElliptic } from '../utils/certificate_parsing/elliptic.js';
@@ -34,9 +34,9 @@ export const ec = new EC('p256');
// eslint-disable-next-line -- clientKey is created from ec so must be second
export const clientKey = ec.genKeyPair();
-type RegisterSuffixes = '' | '_id' | '_aadhaar';
+type RegisterSuffixes = '' | '_id' | '_aadhaar' | '_kyc';
type DscSuffixes = '' | '_id';
-type DiscloseSuffixes = '' | '_id' | '_aadhaar';
+type DiscloseSuffixes = '' | '_id' | '_aadhaar' | '_kyc';
type ProofTypes = 'register' | 'dsc' | 'disclose';
type RegisterProofType = `${Extract}${RegisterSuffixes}`;
type DscProofType = `${Extract}${DscSuffixes}`;
@@ -59,6 +59,10 @@ export function encryptAES256GCM(plaintext: string, key: forge.util.ByteStringBu
};
}
+function bigIntReplacer(_key: string, value: unknown): unknown {
+ return typeof value === 'bigint' ? value.toString() : value;
+}
+
export function getPayload(
inputs: any,
circuitType: RegisterProofType | DscProofType | DiscloseProofType,
@@ -75,7 +79,9 @@ export function getPayload(
? 'disclose'
: circuitName === 'vc_and_disclose_aadhaar'
? 'disclose_aadhaar'
- : 'disclose_id';
+ : circuitName === 'vc_and_disclose_kyc'
+ ? 'disclose_kyc'
+ : 'disclose_id';
const payload: TEEPayloadDisclose = {
type,
endpointType: endpointType,
@@ -83,7 +89,7 @@ export function getPayload(
onchain: endpointType === 'celo' ? true : false,
circuit: {
name: circuitName,
- inputs: JSON.stringify(inputs),
+ inputs: JSON.stringify(inputs, bigIntReplacer),
},
version,
userDefinedData,
@@ -91,14 +97,19 @@ export function getPayload(
};
return payload;
} else {
- const type = circuitName === 'register_aadhaar' ? 'register_aadhaar' : circuitType;
+ const type =
+ circuitName === 'register_aadhaar'
+ ? 'register_aadhaar'
+ : circuitName === 'register_kyc'
+ ? 'register_kyc'
+ : circuitType;
const payload: TEEPayload = {
type: type as RegisterProofType | DscProofType,
onchain: true,
endpointType: endpointType,
circuit: {
name: circuitName,
- inputs: JSON.stringify(inputs),
+ inputs: JSON.stringify(inputs, bigIntReplacer),
},
};
return payload;
diff --git a/common/src/utils/types.ts b/common/src/utils/types.ts
index b158f43dd..c269c3bb4 100644
--- a/common/src/utils/types.ts
+++ b/common/src/utils/types.ts
@@ -1,7 +1,7 @@
import type { ExtractedQRData } from './aadhaar/utils.js';
import type { CertificateData } from './certificate_parsing/dataStructure.js';
+import type { KycField } from './kyc/constants.js';
import type { PassportMetadata } from './passports/passport_parsing/parsePassportData.js';
-import { KycField } from './kyc/constants.js';
// Base interface for common fields
interface BaseIDData {
@@ -22,16 +22,11 @@ export interface AadhaarData extends BaseIDData {
photoHash?: string;
}
-// export interface KycData extends BaseIDData {
-// documentCategory: 'kyc';
-// serializedRealData: string;
-// kycFields: KycField[];
-// }
-
export type DeployedCircuits = {
REGISTER: string[];
REGISTER_ID: string[];
REGISTER_AADHAAR: string[];
+ REGISTER_KYC: string[];
DSC: string[];
DSC_ID: string[];
};
@@ -51,19 +46,29 @@ export interface DocumentMetadata {
mock: boolean; // whether this is a mock document
isRegistered?: boolean; // whether the document is registered onChain
registeredAt?: number; // timestamp (epoch ms) when document was registered
+ hasExpirationDate?: boolean; // whether the document has an expiration date
+ idType?: string; // for KYC documents: the ID type used (e.g. "passport", "drivers_licence")
}
export type DocumentType =
| 'passport'
| 'id_card'
| 'aadhaar'
+ | 'drivers_licence'
| 'mock_passport'
| 'mock_id_card'
| 'mock_aadhaar';
export type Environment = 'prod' | 'stg';
-export type IDDocument = AadhaarData | PassportData;
+export type IDDocument = AadhaarData | KycData | PassportData;
+
+export interface KycData extends BaseIDData {
+ documentCategory: 'kyc';
+ serializedApplicantInfo: string;
+ signature: string;
+ pubkey: string[];
+}
export type OfacTree = {
passportNoAndNationality: any;
@@ -85,6 +90,20 @@ export interface PassportData extends BaseIDData {
passportMetadata?: PassportMetadata;
}
+// pending - pending sumsub verification
+// processing - sumsub verification completed and pending onchain confirmation
+// failed - sumsub verification failed
+export type PendingKycStatus = 'pending' | 'processing' | 'failed';
+
+export interface PendingKycVerification {
+ userId: string; // Correlation key from fetchAccessToken()
+ createdAt: number; // Timestamp when verification started
+ status: PendingKycStatus; // Current status
+ errorMessage?: string; // Error message if failed
+ timeoutAt: number; // When to consider timed out
+ documentId?: string; // Content hash of stored KYC document
+}
+
export type Proof = {
proof: {
a: [string, string];
@@ -156,6 +175,7 @@ export enum AttestationIdHex {
passport = '0x0000000000000000000000000000000000000000000000000000000000000001',
id_card = '0x0000000000000000000000000000000000000000000000000000000000000002',
aadhaar = '0x0000000000000000000000000000000000000000000000000000000000000003',
+ kyc = '0x0000000000000000000000000000000000000000000000000000000000000004',
}
export function castCSCAProof(proof: any): Proof {
@@ -169,15 +189,15 @@ export function castCSCAProof(proof: any): Proof {
};
}
-export function isAadhaarDocument(
- passportData: PassportData | AadhaarData
-): passportData is AadhaarData {
+export function isAadhaarDocument(passportData: IDDocument): passportData is AadhaarData {
return passportData.documentCategory === 'aadhaar';
}
-export function isMRZDocument(
- passportData: PassportData | AadhaarData
-): passportData is PassportData {
+export function isKycDocument(passportData: IDDocument): passportData is KycData {
+ return passportData.documentCategory === 'kyc';
+}
+
+export function isMRZDocument(passportData: IDDocument): passportData is PassportData {
return (
passportData.documentCategory === 'passport' || passportData.documentCategory === 'id_card'
);
diff --git a/contracts/contracts/IdentityVerificationHubImplV2.sol b/contracts/contracts/IdentityVerificationHubImplV2.sol
index f2fdc9e79..41e2fb608 100644
--- a/contracts/contracts/IdentityVerificationHubImplV2.sol
+++ b/contracts/contracts/IdentityVerificationHubImplV2.sol
@@ -29,7 +29,7 @@ import {console} from "hardhat/console.sol";
* @dev This contract orchestrates multi-step verification processes including document attestation,
* zero-knowledge proofs, OFAC compliance, and attribute disclosure control.
*
- * @custom:version 2.12.0
+ * @custom:version 2.13.0
*/
contract IdentityVerificationHubImplV2 is ImplRoot {
/// @custom:storage-location erc7201:self.storage.IdentityVerificationHub
diff --git a/contracts/deployments/registry.json b/contracts/deployments/registry.json
index 133ef0e65..8a4d20995 100644
--- a/contracts/deployments/registry.json
+++ b/contracts/deployments/registry.json
@@ -1,6 +1,6 @@
{
"$schema": "./registry.schema.json",
- "lastUpdated": "2025-12-10T06:17:50.863Z",
+ "lastUpdated": "2026-02-09T11:26:31.105Z",
"contracts": {
"IdentityVerificationHub": {
"source": "IdentityVerificationHubImplV2",
@@ -22,6 +22,11 @@
"type": "uups-proxy",
"description": "Aadhaar identity registry"
},
+ "IdentityRegistryKyc": {
+ "source": "IdentityRegistryKycImplV1",
+ "type": "uups-proxy",
+ "description": "KYC identity registry"
+ },
"PCR0Manager": {
"source": "PCR0Manager",
"type": "non-upgradeable",
@@ -45,8 +50,8 @@
"deployments": {
"IdentityVerificationHub": {
"proxy": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
- "currentVersion": "2.12.0",
- "currentImpl": "0x05FB9D7830889cc389E88198f6A224eA87F01151"
+ "currentVersion": "2.13.0",
+ "currentImpl": "0x0D911083b2F2236D79EF20bb58AAf6009a1220B5"
},
"IdentityRegistry": {
"proxy": "0x37F5CB8cB1f6B00aa768D8aA99F1A9289802A968",
@@ -63,6 +68,11 @@
"currentVersion": "1.2.0",
"currentImpl": "0xbD861A9cecf7B0A9631029d55A8CE1155e50697c"
},
+ "IdentityRegistryKyc": {
+ "proxy": "0x9cABdeBC3aF136efD69EB881e02118AC612c63b9",
+ "currentVersion": "1.0.0",
+ "currentImpl": "0x82FA9D41939229B6189cf326e855c6d6db2aAa57"
+ },
"PCR0Manager": {
"address": "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
"currentVersion": "1.2.0"
@@ -73,6 +83,22 @@
}
}
},
+ "celo-sepolia": {
+ "chainId": 11142220,
+ "governance": {
+ "securityMultisig": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
+ "operationsMultisig": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
+ "securityThreshold": "1/1",
+ "operationsThreshold": "1/1"
+ },
+ "deployments": {
+ "IdentityVerificationHub": {
+ "proxy": "0x16ECBA51e18a4a7e61fdC417f0d47AFEeDfbed74",
+ "currentVersion": "2.13.0",
+ "currentImpl": "0x244c93516Abd58E1952452d3D8C4Ce7D454776B8"
+ }
+ }
+ },
"localhost": {
"chainId": 31337,
"governance": {
@@ -97,6 +123,12 @@
"deployedAt": "2025-12-10T05:43:58.258Z",
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
"gitCommit": ""
+ },
+ "celo-sepolia": {
+ "impl": "0x92d637c5e6EFa17320B663f97cc4d44176984dAd",
+ "deployedAt": "2026-02-02T13:39:44.500Z",
+ "deployedBy": "0x846F1cF04ec494303e4B90440b130bb01913E703",
+ "gitCommit": "61a41950"
}
}
},
@@ -111,6 +143,40 @@
"deployedAt": "",
"deployedBy": "",
"gitCommit": ""
+ },
+ "celo-sepolia": {
+ "impl": "0x48985ec4f71cBC8f387c5C77143110018560c7eD",
+ "deployedAt": "",
+ "deployedBy": "0x846f1cf04ec494303e4b90440b130bb01913e703",
+ "gitCommit": ""
+ }
+ }
+ },
+ "2.13.0": {
+ "initializerVersion": 12,
+ "initializerFunction": "",
+ "changelog": "Upgrade to v2.13.0",
+ "gitTag": "identityverificationhub-v2.13.0",
+ "deployments": {
+ "celo-sepolia": {
+ "impl": "0x244c93516Abd58E1952452d3D8C4Ce7D454776B8",
+ "deployedAt": "2026-02-02T14:47:21.882Z",
+ "deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
+ "gitCommit": "33bca485"
+ }
+ }
+ },
+ "2.13.0": {
+ "initializerVersion": 12,
+ "initializerFunction": "",
+ "changelog": "Upgrade to v2.13.0",
+ "gitTag": "identityverificationhub-v2.13.0",
+ "deployments": {
+ "celo": {
+ "impl": "0x0D911083b2F2236D79EF20bb58AAf6009a1220B5",
+ "deployedAt": "2026-02-09T11:26:30.941Z",
+ "deployedBy": "0xC1C860804EFdA544fe79194d1a37e60b846CEdeb",
+ "gitCommit": "88ae00b1"
}
}
}
@@ -220,6 +286,22 @@
}
}
}
+ },
+ "IdentityRegistryKyc": {
+ "1.0.0": {
+ "initializerVersion": 1,
+ "initializerFunction": "initialize",
+ "changelog": "Initial KYC registry deployment",
+ "gitTag": "",
+ "deployments": {
+ "celo": {
+ "impl": "0x82FA9D41939229B6189cf326e855c6d6db2aAa57",
+ "deployedAt": "2026-02-09T00:00:00.000Z",
+ "deployedBy": "",
+ "gitCommit": "03876a86284b0ed794fbff7aae142e62a3212624"
+ }
+ }
+ }
}
}
}
diff --git a/contracts/ignition/deployments/chain-42220/deployed_addresses.json b/contracts/ignition/deployments/chain-42220/deployed_addresses.json
index 5792b7545..4b4aceaac 100644
--- a/contracts/ignition/deployments/chain-42220/deployed_addresses.json
+++ b/contracts/ignition/deployments/chain-42220/deployed_addresses.json
@@ -86,10 +86,16 @@
"DeployAadhaarRegistryModule#PoseidonT3": "0xC9B4a92d98dbFC76D440233b8598910cA2da353f",
"DeployAadhaarRegistryModule#IdentityRegistryAadhaarImplV1": "0x70D543432782D460C96753b52c2aC2797f26924B",
"DeployAadhaarRegistryModule#IdentityRegistry": "0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4",
- "UpdateAllRegistries#a3": "0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4",
"DeployHubV2#IdentityVerificationHub": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
"UpdateHubRegistries#IdentityVerificationHubImplV2": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
"DeployNewHubAndUpgradee#IdentityVerificationHubV2": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
"DeployNewHubAndUpgradee#CustomVerifier": "0x026696925F7DA40EE8B372442750A70BA9C006fA",
- "DeployNewHubAndUpgradee#IdentityVerificationHubImplV2": "0xa267e58B2d6BA9fc07Af06471423AFb56e4e82B3"
+ "DeployNewHubAndUpgradee#IdentityVerificationHubImplV2": "0xa267e58B2d6BA9fc07Af06471423AFb56e4e82B3",
+ "DeployKycRegistryModule#PoseidonT3": "0x3a74EeCfF282539905F4a43c5EF4f5F155D1579F",
+ "DeployKycRegistryModule#Verifier_gcp_jwt": "0x87785cC7E9Bc70f87E6F454235214bDEc853C044",
+ "DeployKycRegistryModule#IdentityRegistryKycImplV1": "0x82FA9D41939229B6189cf326e855c6d6db2aAa57",
+ "DeployKycRegistryModule#IdentityRegistry": "0x9cABdeBC3aF136efD69EB881e02118AC612c63b9",
+ "UpdateAllRegistries#a3": "0x9cABdeBC3aF136efD69EB881e02118AC612c63b9",
+ "DeployAllVerifiers#Verifier_register_kyc": "0xbc15010D9748A5e7c0B947D0c0aCb31bD57a0626",
+ "DeployAllVerifiers#Verifier_vc_and_disclose_kyc": "0xdB0454156bBa5e5b9CA97be350eCc178ddE20b0f"
}
diff --git a/contracts/ignition/modules/registry/deployKycRegistry.ts b/contracts/ignition/modules/registry/deployKycRegistry.ts
index 4c98e0b47..4980acbf1 100644
--- a/contracts/ignition/modules/registry/deployKycRegistry.ts
+++ b/contracts/ignition/modules/registry/deployKycRegistry.ts
@@ -26,7 +26,8 @@ export default buildModule("DeployKycRegistryModule", (m) => {
const gcpKycVerifier = m.contract("Verifier_gcp_jwt", []);
- const pcr0Manager = m.contract("PCR0Manager", []);
+ // PCR0Manager not deployed - using existing mainnet PCR0Manager at 0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717
+ // const pcr0Manager = m.contract("PCR0Manager", []);
console.log("✅ Registry deployment module setup complete!");
console.log(" 📋 Summary:");
@@ -34,14 +35,12 @@ export default buildModule("DeployKycRegistryModule", (m) => {
console.log(" - IdentityRegistryKycImplV1: Implementation contract");
console.log(" - IdentityRegistry: Proxy contract");
console.log(" - Verifier_gcp_jwt: GCP JWT verifier contract");
- console.log(" - PCR0Manager: PCR0Manager contract");
return {
poseidonT3,
identityRegistryKycImpl,
registry,
gcpKycVerifier,
- pcr0Manager,
};
});
diff --git a/contracts/ignition/modules/registry/updateRegistries.ts b/contracts/ignition/modules/registry/updateRegistries.ts
index c6de52a80..ef1cc54df 100644
--- a/contracts/ignition/modules/registry/updateRegistries.ts
+++ b/contracts/ignition/modules/registry/updateRegistries.ts
@@ -34,13 +34,12 @@ const registries = {
// },
"DeployKycRegistryModule#IdentityRegistry": {
shouldChange: true,
- hub: "0x16ECBA51e18a4a7e61fdC417f0d47AFEeDfbed74",
+ hub: "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
nameAndDobOfac: "12056959379782485690824392224737824782985009863971097094085968061978428696483",
nameAndYobOfac: "14482015433179009576094845155298164108788397224633034095648782513909282765564",
onlyTEEAddress: "0xe6b2856a51a17bd4edeb88b3f74370d64475b0fc",
- gcpJWTVerifier: "0x13ee8CEa15a262D81a245b37889F7b4bEd015f4c",
- pcr0Manager: "0xf2810D5E9938816D42F0Ae69D33F013a23C0aED2",
- imageDigest: "0x67368d91dc708dee7be8fd9d85eff1fce3181e6e5b9fdfa37fc2d99034ea88e6",
+ gcpJWTVerifier: "0x87785cC7E9Bc70f87E6F454235214bDEc853C044",
+ pcr0Manager: "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
gcpRootCAPubkeyHash: "14165687497759817957828709957846495993787741657460065475757428560999622217191",
},
};
diff --git a/contracts/tasks/upgrade/upgrade.ts b/contracts/tasks/upgrade/upgrade.ts
index 41741079d..376b7ddaf 100644
--- a/contracts/tasks/upgrade/upgrade.ts
+++ b/contracts/tasks/upgrade/upgrade.ts
@@ -37,7 +37,7 @@ import {
getLatestVersionInfo,
getVersionInfo,
getGovernanceConfig,
- validateReinitializerVersion,
+ readReinitializerVersion,
} from "./utils";
import { execSync } from "child_process";
import * as readline from "readline";
@@ -65,7 +65,7 @@ async function promptYesNo(question: string): Promise {
*/
const CHAIN_CONFIG: Record = {
celo: { chainId: 42220, safePrefix: "celo" },
- "celo-sepolia": { chainId: 44787, safePrefix: "celo" },
+ "celo-sepolia": { chainId: 11142220, safePrefix: "celo" },
sepolia: { chainId: 11155111, safePrefix: "sep" },
localhost: { chainId: 31337, safePrefix: "eth" },
};
@@ -314,39 +314,54 @@ task("upgrade", "Deploy new implementation and create Safe proposal for upgrade"
// ========================================================================
log.step("Checking reinitializer version...");
- // Check if target version already exists in registry
const targetVersionInfo = getVersionInfo(contractId, newVersion);
const latestVersionInfo = getLatestVersionInfo(contractId);
+ const latestInitVersion = latestVersionInfo?.info.initializerVersion || 0;
- // If target version exists, use its initializerVersion; otherwise increment latest
- const expectedInitializerVersion = targetVersionInfo
- ? targetVersionInfo.initializerVersion
- : (latestVersionInfo?.info.initializerVersion || 0) + 1;
+ let actualReinitVersion: number | null = null;
+ let noNewInitializer = false;
if (contractFilePath) {
- const reinitValidation = validateReinitializerVersion(contractFilePath, expectedInitializerVersion);
+ actualReinitVersion = readReinitializerVersion(contractFilePath);
- if (!reinitValidation.valid) {
- log.error(reinitValidation.error!);
+ if (actualReinitVersion === null) {
+ log.error("Could not find reinitializer in contract file");
+ return;
+ }
+
+ // If target version already exists in registry, validate against its expected version
+ if (targetVersionInfo) {
+ const expected = targetVersionInfo.initializerVersion;
+ if (actualReinitVersion !== expected) {
+ log.error(`Reinitializer mismatch: expected ${expected}, found ${actualReinitVersion}`);
+ return;
+ }
+ }
+
+ if (actualReinitVersion === latestInitVersion) {
+ // No new reinitializer — code-only upgrade
+ noNewInitializer = true;
+ log.success(`No new initialization needed (reinitializer stays at ${actualReinitVersion})`);
+ } else if (actualReinitVersion === latestInitVersion + 1) {
+ // Standard upgrade with new reinitializer
+ log.success(`Reinitializer version correct: reinitializer(${actualReinitVersion})`);
+ } else {
+ log.error(
+ `Unexpected reinitializer(${actualReinitVersion}). Expected ${latestInitVersion} (no-init) or ${latestInitVersion + 1} (with init)`,
+ );
log.box([
"REINITIALIZER VERSION MISMATCH",
"═".repeat(50),
"",
- `Expected: reinitializer(${expectedInitializerVersion})`,
- reinitValidation.actual !== null ? `Found: reinitializer(${reinitValidation.actual})` : "Found: none",
+ `Latest registry version has reinitializer: ${latestInitVersion}`,
+ `Contract file has reinitializer: ${actualReinitVersion}`,
"",
- "The initialize function must use the correct reinitializer version.",
- "Each upgrade should increment the version by 1.",
- "",
- "Example pattern:",
- ` function initialize(...) external reinitializer(${expectedInitializerVersion}) {`,
- " // initialization logic",
- " }",
+ "Valid options:",
+ ` ${latestInitVersion} — code-only upgrade (no new initialization)`,
+ ` ${latestInitVersion + 1} — upgrade with new initializer`,
]);
return;
}
-
- log.success(`Reinitializer version correct: reinitializer(${reinitValidation.actual})`);
} else {
log.warning("Could not locate contract file - skipping reinitializer check");
}
@@ -389,14 +404,25 @@ task("upgrade", "Deploy new implementation and create Safe proposal for upgrade"
try {
if (contractName === "IdentityVerificationHubImplV2") {
- const CustomVerifier = await hre.ethers.getContractFactory("CustomVerifier");
- const customVerifier = await CustomVerifier.deploy();
- await customVerifier.waitForDeployment();
+ const libraryNames = [
+ "CustomVerifier",
+ "OutputFormatterLib",
+ "ProofVerifierLib",
+ "RegisterProofVerifierLib",
+ "DscProofVerifierLib",
+ "RootCheckLib",
+ "OfacCheckLib",
+ ];
+ const libraries: Record = {};
+ for (const libName of libraryNames) {
+ const LibFactory = await hre.ethers.getContractFactory(libName);
+ const lib = await LibFactory.deploy();
+ await lib.waitForDeployment();
+ libraries[libName] = await lib.getAddress();
+ log.info(`Deployed library: ${libName} → ${libraries[libName]}`);
+ }
- ContractFactory = await hre.ethers.getContractFactory(contractName, {
- libraries: { CustomVerifier: await customVerifier.getAddress() },
- });
- log.info("Deployed CustomVerifier library for linking");
+ ContractFactory = await hre.ethers.getContractFactory(contractName, { libraries });
} else if (
contractName === "IdentityRegistryImplV1" ||
contractName === "IdentityRegistryIdCardImplV1" ||
@@ -593,7 +619,7 @@ task("upgrade", "Deploy new implementation and create Safe proposal for upgrade"
log.step("Updating deployment registry...");
const latestVersion = getLatestVersionInfo(contractId);
- const newInitializerVersion = (latestVersion?.info.initializerVersion || 0) + 1;
+ const newInitializerVersion = actualReinitVersion ?? (latestVersion?.info.initializerVersion || 0) + 1;
const deployerAddress = (await hre.ethers.provider.getSigner()).address;
addVersion(
@@ -602,7 +628,7 @@ task("upgrade", "Deploy new implementation and create Safe proposal for upgrade"
newVersion,
{
initializerVersion: newInitializerVersion,
- initializerFunction: "initialize", // Always "initialize" - version tracked via reinitializer(N) modifier
+ initializerFunction: noNewInitializer ? "" : "initialize",
changelog: changelog || `Upgrade to v${newVersion}`,
gitTag: `${contractId.toLowerCase()}-v${newVersion}`,
},
@@ -680,18 +706,22 @@ task("upgrade", "Deploy new implementation and create Safe proposal for upgrade"
// Encode initializer function call
let initData = "0x";
- const targetVersionInfoForInit = getVersionInfo(contractId, newVersion);
- const initializerName = targetVersionInfoForInit?.initializerFunction || `initializeV${newInitializerVersion}`;
+ if (!noNewInitializer) {
+ const targetVersionInfoForInit = getVersionInfo(contractId, newVersion);
+ const initializerName = targetVersionInfoForInit?.initializerFunction || `initializeV${newInitializerVersion}`;
- try {
- const iface = proxyContract.interface;
- const initFragment = iface.getFunction(initializerName);
- if (initFragment && initFragment.inputs.length === 0) {
- initData = iface.encodeFunctionData(initializerName, []);
- log.detail("Initializer", initializerName);
+ try {
+ const iface = proxyContract.interface;
+ const initFragment = iface.getFunction(initializerName);
+ if (initFragment && initFragment.inputs.length === 0) {
+ initData = iface.encodeFunctionData(initializerName, []);
+ log.detail("Initializer", initializerName);
+ }
+ } catch {
+ log.detail("Initializer", "None");
}
- } catch {
- log.detail("Initializer", "None");
+ } else {
+ log.detail("Initializer", "None (code-only upgrade)");
}
// Build upgrade transaction data
diff --git a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx
index 352997407..a9f6c697f 100644
--- a/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx
+++ b/packages/mobile-sdk-alpha/src/components/buttons/HeldPrimaryButtonProveScreen.tsx
@@ -21,6 +21,7 @@ interface HeldPrimaryButtonProveScreenProps {
isScrollable: boolean;
isReadyToProve: boolean;
isDocumentExpired: boolean;
+ hasCheckedForInactiveDocument: boolean;
}
interface ButtonContext {
@@ -29,6 +30,7 @@ interface ButtonContext {
isReadyToProve: boolean;
onVerify: () => void;
isDocumentExpired: boolean;
+ hasCheckedForInactiveDocument: boolean;
}
type ButtonEvent =
@@ -38,6 +40,7 @@ type ButtonEvent =
hasScrolledToBottom: boolean;
isReadyToProve: boolean;
isDocumentExpired: boolean;
+ hasCheckedForInactiveDocument: boolean;
}
| { type: 'VERIFY' };
@@ -56,6 +59,7 @@ const buttonMachine = createMachine(
isReadyToProve: false,
onVerify: input.onVerify,
isDocumentExpired: false,
+ hasCheckedForInactiveDocument: false,
}),
on: {
PROPS_UPDATED: {
@@ -177,13 +181,15 @@ const buttonMachine = createMachine(
context.selectedAppSessionId !== event.selectedAppSessionId ||
context.hasScrolledToBottom !== event.hasScrolledToBottom ||
context.isReadyToProve !== event.isReadyToProve ||
- context.isDocumentExpired !== event.isDocumentExpired
+ context.isDocumentExpired !== event.isDocumentExpired ||
+ context.hasCheckedForInactiveDocument !== event.hasCheckedForInactiveDocument
) {
return {
selectedAppSessionId: event.selectedAppSessionId,
hasScrolledToBottom: event.hasScrolledToBottom,
isReadyToProve: event.isReadyToProve,
isDocumentExpired: event.isDocumentExpired,
+ hasCheckedForInactiveDocument: event.hasCheckedForInactiveDocument,
};
}
}
@@ -203,6 +209,7 @@ export const HeldPrimaryButtonProveScreen: React.FC {
const [state, send] = useMachine(buttonMachine, {
input: { onVerify },
@@ -215,10 +222,18 @@ export const HeldPrimaryButtonProveScreen: React.FC = ({ text }) => (
diff --git a/packages/mobile-sdk-alpha/src/constants/colors.ts b/packages/mobile-sdk-alpha/src/constants/colors.ts
index 1381b085f..1c6328baa 100644
--- a/packages/mobile-sdk-alpha/src/constants/colors.ts
+++ b/packages/mobile-sdk-alpha/src/constants/colors.ts
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+export const amber200 = '#FDE68A';
+
/// NEW
export const amber50 = '#FFFBEB';
-export const amber500 = '#F2E3C8';
+export const amber500 = '#F59E0B';
+export const amber700 = '#B45309';
export const black = '#000000';
export const blue100 = '#DBEAFE';
export const blue600 = '#2563EB';
export const blue700 = '#1D4ED8';
+
// OLD
export const borderColor = '#343434';
@@ -18,6 +22,8 @@ export const cyan300 = '#67E8F9';
export const emerald500 = '#10B981';
+export const gray400 = '#9CA3AF';
+
export const green500 = '#22C55E';
export const green600 = '#16A34A';
@@ -28,6 +34,7 @@ export const neutral400 = '#A3A3A3';
export const neutral700 = '#404040';
export const red500 = '#EF4444';
+export const red600 = '#DC2626';
export const separatorColor = '#E0E0E0';
@@ -59,8 +66,11 @@ export const teal500 = '#5EEAD4';
export const textBlack = '#333333';
+export const warmCream = '#F2E3C8';
+
export const white = '#ffffff';
+export const yellow50 = '#FEFCE8';
export const yellow500 = '#FDE047';
export const zinc400 = '#A1A1AA';
diff --git a/packages/mobile-sdk-alpha/src/constants/index.ts b/packages/mobile-sdk-alpha/src/constants/index.ts
index eba2b56b7..caba4c715 100644
--- a/packages/mobile-sdk-alpha/src/constants/index.ts
+++ b/packages/mobile-sdk-alpha/src/constants/index.ts
@@ -19,8 +19,10 @@ export { NFC_IMAGE } from './images';
export { advercase, dinot, dinotBold, plexMono } from './fonts';
export {
+ amber200,
amber50,
amber500,
+ amber700,
black,
blue100,
blue600,
@@ -29,12 +31,14 @@ export {
charcoal,
cyan300,
emerald500,
+ gray400,
green500,
green600,
iosSeparator,
neutral400,
neutral700,
red500,
+ red600,
separatorColor,
sky500,
slate100,
@@ -50,7 +54,9 @@ export {
teal300,
teal500,
textBlack,
+ warmCream,
white,
+ yellow50,
yellow500,
zinc400,
zinc500,
diff --git a/packages/mobile-sdk-alpha/src/documents/utils.ts b/packages/mobile-sdk-alpha/src/documents/utils.ts
index c5a34cabe..66ecdc1f2 100644
--- a/packages/mobile-sdk-alpha/src/documents/utils.ts
+++ b/packages/mobile-sdk-alpha/src/documents/utils.ts
@@ -178,7 +178,7 @@ export async function markCurrentDocumentAsRegistered(selfClient: SelfClient): P
}
export async function reStorePassportDataWithRightCSCA(selfClient: SelfClient, passportData: IDDocument, csca: string) {
- if (passportData.documentCategory === 'aadhaar') {
+ if (passportData.documentCategory === 'aadhaar' || passportData.documentCategory === 'kyc') {
return;
}
const cscaInCurrentPassporData = passportData.passportMetadata?.csca;
@@ -236,13 +236,15 @@ export async function storeDocumentWithDeduplication(
// Add to catalog
const docType = passportData.documentType;
+ const documentCategory = passportData.documentCategory || inferDocumentCategory(docType);
const metadata: DocumentMetadata = {
id: contentHash,
documentType: docType,
- documentCategory: passportData.documentCategory || inferDocumentCategory(docType),
+ documentCategory,
data: isMRZDocument(passportData) ? passportData.mrz : (passportData as AadhaarData).qrData || '',
mock: passportData.mock || false,
isRegistered: false,
+ hasExpirationDate: documentCategory === 'id_card' || documentCategory === 'passport',
};
catalog.documents.push(metadata);
diff --git a/packages/mobile-sdk-alpha/src/documents/validation.ts b/packages/mobile-sdk-alpha/src/documents/validation.ts
index fb0f1bdd9..39455857a 100644
--- a/packages/mobile-sdk-alpha/src/documents/validation.ts
+++ b/packages/mobile-sdk-alpha/src/documents/validation.ts
@@ -2,11 +2,11 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
-import type { AadhaarData, DocumentMetadata, IDDocument } from '@selfxyz/common';
+import { type AadhaarData, deserializeApplicantInfo, type DocumentMetadata, type IDDocument } from '@selfxyz/common';
import { attributeToPosition, attributeToPosition_ID } from '@selfxyz/common/constants';
import type { PassportData } from '@selfxyz/common/types/passport';
-import type { DocumentCatalog } from '@selfxyz/common/utils/types';
-import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
+import type { DocumentCatalog, KycData } from '@selfxyz/common/utils/types';
+import { isAadhaarDocument, isKycDocument, isMRZDocument } from '@selfxyz/common/utils/types';
export interface DocumentAttributes {
nameSlice: string;
@@ -106,17 +106,101 @@ function getPassportAttributes(mrz: string, documentCategory: string): DocumentA
};
}
+function getKycAttributes(document: KycData): DocumentAttributes {
+ try {
+ const data = deserializeApplicantInfo(document.serializedApplicantInfo);
+
+ // Format name like MRZ: surname< currentYear) {
+ fullYear -= 100;
+ }
+ yobSlice = fullYear.toString();
+ dobFormatted = data.dob;
+ }
+ }
+
+ // Format expiry date to YYMMDD if provided
+ let expiryDateFormatted = '';
+ if (data.expiryDate) {
+ const expiryMatch = data.expiryDate.match(/(\d{4})-(\d{2})-(\d{2})/); // YYYY-MM-DD
+ if (expiryMatch) {
+ const [, year, month, day] = expiryMatch;
+ expiryDateFormatted = `${year.slice(-2)}${month}${day}`;
+ } else if (data.expiryDate.length === 8 && /^\d{8}$/.test(data.expiryDate)) {
+ // Already in YYYYMMDD format
+ expiryDateFormatted = `${data.expiryDate.slice(2, 4)}${data.expiryDate.slice(4, 6)}${data.expiryDate.slice(6, 8)}`;
+ } else if (data.expiryDate.length === 6 && /^\d{6}$/.test(data.expiryDate)) {
+ // Already in YYMMDD format
+ expiryDateFormatted = data.expiryDate;
+ }
+ }
+
+ return {
+ nameSlice: nameSliceFormatted,
+ dobSlice: dobFormatted,
+ yobSlice,
+ issuingStateSlice: data.country || '',
+ nationalitySlice: data.country || '',
+ passNoSlice: data.idNumber || '',
+ sexSlice: data.gender || '',
+ expiryDateSlice: expiryDateFormatted,
+ isPassportType: false,
+ };
+ } catch {
+ // Return safe defaults if deserialization or processing fails
+ return {
+ nameSlice: '',
+ dobSlice: '',
+ yobSlice: '',
+ issuingStateSlice: '',
+ nationalitySlice: '',
+ passNoSlice: '',
+ sexSlice: '',
+ expiryDateSlice: '',
+ isPassportType: false,
+ };
+ }
+}
+
/**
* Extracts document attributes from passport, ID card, or Aadhaar data.
*
* @param document - Document data (PassportData, AadhaarData, or IDDocument)
* @returns Document attributes including name, DOB, expiry date, etc.
*/
-export function getDocumentAttributes(document: PassportData | AadhaarData): DocumentAttributes {
+export function getDocumentAttributes(document: PassportData | AadhaarData | KycData): DocumentAttributes {
if (isAadhaarDocument(document)) {
return getAadhaarAttributes(document);
} else if (isMRZDocument(document)) {
return getPassportAttributes(document.mrz, document.documentCategory);
+ } else if (isKycDocument(document)) {
+ return getKycAttributes(document);
} else {
// Fallback for unknown document types
return {
diff --git a/packages/mobile-sdk-alpha/src/flows/onboarding/confirm-identification.tsx b/packages/mobile-sdk-alpha/src/flows/onboarding/confirm-identification.tsx
index ca181efad..2cb584433 100644
--- a/packages/mobile-sdk-alpha/src/flows/onboarding/confirm-identification.tsx
+++ b/packages/mobile-sdk-alpha/src/flows/onboarding/confirm-identification.tsx
@@ -80,6 +80,10 @@ const getDocumentMetadata = async (selfClient: SelfClient) => {
signatureAlgorithm: 'rsa',
curveOrExponent: '65537',
} as const;
+ } else if (selectedDocument?.data?.documentCategory === 'kyc') {
+ metadata = {
+ documentCategory: selectedDocument?.data?.documentCategory,
+ } as const;
} else {
const passportData = selectedDocument?.data;
metadata = {
diff --git a/packages/mobile-sdk-alpha/src/flows/onboarding/id-selection-screen.tsx b/packages/mobile-sdk-alpha/src/flows/onboarding/id-selection-screen.tsx
index 4803fff46..9fa2674be 100644
--- a/packages/mobile-sdk-alpha/src/flows/onboarding/id-selection-screen.tsx
+++ b/packages/mobile-sdk-alpha/src/flows/onboarding/id-selection-screen.tsx
@@ -128,11 +128,10 @@ const DocumentItem: React.FC = ({ docType, onPress }) => {
type IDSelectionScreenProps = {
countryCode: string;
documentTypes: string[];
- showKyc?: boolean;
};
const IDSelectionScreen: React.FC = props => {
- const { countryCode = '', documentTypes = [], showKyc = false } = props;
+ const { countryCode = '', documentTypes = [] } = props;
const selfClient = useSelfClient();
const onSelectDocumentType = (docType: string) => {
@@ -173,11 +172,9 @@ const IDSelectionScreen: React.FC = props => {
onSelectDocumentType(docType)} />
))}
Be sure your document is ready to scan
- {showKyc && (
-
- onSelectDocumentType('kyc')} />
-
- )}
+
+ onSelectDocumentType('kyc')} />
+
);
diff --git a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts
index bcf7ec396..54acb7305 100644
--- a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts
+++ b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts
@@ -63,12 +63,14 @@ const getMappingKey = (circuitType: 'disclose' | 'register' | 'dsc', documentCat
if (documentCategory === 'passport') return 'DISCLOSE';
if (documentCategory === 'id_card') return 'DISCLOSE_ID';
if (documentCategory === 'aadhaar') return 'DISCLOSE_AADHAAR';
+ if (documentCategory === 'kyc') return 'DISCLOSE_KYC';
throw new Error(`Unsupported document category for disclose: ${documentCategory}`);
}
if (circuitType === 'register') {
if (documentCategory === 'passport') return 'REGISTER';
if (documentCategory === 'id_card') return 'REGISTER_ID';
if (documentCategory === 'aadhaar') return 'REGISTER_AADHAAR';
+ if (documentCategory === 'kyc') return 'REGISTER_KYC';
throw new Error(`Unsupported document category for register: ${documentCategory}`);
}
// circuitType === 'dsc'
@@ -108,7 +110,9 @@ const _generateCircuitInputs = async (
({ inputs, circuitName, endpointType, endpoint } = await generateTEEInputsRegister(
secret as string,
passportData,
- document === 'aadhaar' ? protocolStore[document].public_keys : protocolStore[document].dsc_tree,
+ document === 'aadhaar' || document === 'kyc'
+ ? protocolStore[document].public_keys
+ : protocolStore[document].dsc_tree,
env,
));
circuitTypeWithDocumentExtension = `${circuitType}${document === 'passport' ? '' : '_id'}`;
@@ -117,6 +121,9 @@ const _generateCircuitInputs = async (
if (document === 'aadhaar') {
throw new Error('DSC circuit type is not supported for Aadhaar documents');
}
+ if (document === 'kyc') {
+ throw new Error('DSC circuit type is not supported for KYC documents');
+ }
({ inputs, circuitName, endpointType, endpoint } = generateTEEInputsDSC(
passportData as PassportData,
protocolStore[document].csca_tree as string[][],
@@ -138,7 +145,9 @@ const _generateCircuitInputs = async (
? protocolStore.passport
: doc === 'aadhaar'
? protocolStore.aadhaar
- : protocolStore.id_card;
+ : doc === 'kyc'
+ ? protocolStore.kyc
+ : protocolStore.id_card;
switch (tree) {
case 'ofac':
return docStore.ofac_trees;
@@ -899,7 +908,9 @@ export const useProvingStore = create((set, get) => {
typedCircuitType === 'disclose'
? passportData.documentCategory === 'aadhaar'
? 'disclose_aadhaar'
- : 'disclose'
+ : passportData.documentCategory === 'kyc'
+ ? 'disclose_kyc'
+ : 'disclose'
: getCircuitNameFromPassportData(passportData, typedCircuitType as 'register' | 'dsc');
const wsRpcUrl = resolveWebSocketUrl(selfClient, typedCircuitType, passportData as PassportData, circuitName);
@@ -1143,6 +1154,13 @@ export const useProvingStore = create((set, get) => {
});
await selfClient.getProtocolState().aadhaar.fetch_all(env!);
break;
+ case 'kyc':
+ selfClient.logProofEvent('info', 'Protocol store fetch', context, {
+ step: 'protocol_store_fetch',
+ document,
+ });
+ await selfClient.getProtocolState().kyc.fetch_all(env!);
+ break;
}
selfClient.logProofEvent('info', 'Data fetch succeeded', context, {
duration_ms: Date.now() - startTime,
@@ -1233,12 +1251,8 @@ export const useProvingStore = create((set, get) => {
const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA(passportData, secret as string, {
getCommitmentTree: (docCategory: DocumentCategory) => getCommitmentTree(selfClient, docCategory),
getAltCSCA: (docType: DocumentCategory) => {
- if (docType === 'kyc') {
- //TODO
- throw new Error('KYC is not supported yet');
- }
- if (docType === 'aadhaar') {
- const publicKeys = selfClient.getProtocolState().aadhaar.public_keys;
+ if (docType === 'aadhaar' || docType === 'kyc') {
+ const publicKeys = selfClient.getProtocolState()[docType].public_keys;
// Convert string[] to Record format expected by AlternativeCSCA
return publicKeys ? Object.fromEntries(publicKeys.map(key => [key, key])) : {};
}
@@ -1332,7 +1346,12 @@ export const useProvingStore = create((set, get) => {
let circuitName;
if (circuitType === 'disclose') {
- circuitName = passportData.documentCategory === 'aadhaar' ? 'disclose_aadhaar' : 'disclose';
+ circuitName =
+ passportData.documentCategory === 'aadhaar'
+ ? 'disclose_aadhaar'
+ : passportData.documentCategory === 'kyc'
+ ? 'disclose_kyc'
+ : 'disclose';
} else {
circuitName = getCircuitNameFromPassportData(passportData, circuitType as 'register' | 'dsc');
}
diff --git a/packages/mobile-sdk-alpha/src/stores/protocolStore.ts b/packages/mobile-sdk-alpha/src/stores/protocolStore.ts
index 87a42a589..007247c4c 100644
--- a/packages/mobile-sdk-alpha/src/stores/protocolStore.ts
+++ b/packages/mobile-sdk-alpha/src/stores/protocolStore.ts
@@ -147,11 +147,7 @@ export async function fetchAllTreesAndCircuits(
* public key list instead.
*/
export function getAltCSCAPublicKeys(selfClient: SelfClient, docCategory: DocumentCategory) {
- if (docCategory === 'kyc') {
- //TODO
- throw new Error('KYC is not supported yet');
- }
- if (docCategory === 'aadhaar') {
+ if (docCategory === 'aadhaar' || docCategory === 'kyc') {
return selfClient.getProtocolState()[docCategory].public_keys;
}
@@ -549,11 +545,99 @@ export const useProtocolStore = create((set, get) => ({
deployed_circuits: null,
circuits_dns_mapping: null,
ofac_trees: null,
- fetch_all: async (_environment: 'prod' | 'stg') => {},
- fetch_deployed_circuits: async (_environment: 'prod' | 'stg') => {},
- fetch_circuits_dns_mapping: async (_environment: 'prod' | 'stg') => {},
- fetch_public_keys: async (_environment: 'prod' | 'stg') => {},
- fetch_identity_tree: async (_environment: 'prod' | 'stg') => {},
- fetch_ofac_trees: async (_environment: 'prod' | 'stg') => {},
+ fetch_all: async (environment: 'prod' | 'stg') => {
+ try {
+ await Promise.all([
+ get().kyc.fetch_deployed_circuits(environment),
+ get().kyc.fetch_circuits_dns_mapping(environment),
+ get().kyc.fetch_public_keys(environment),
+ get().kyc.fetch_identity_tree(environment),
+ get().kyc.fetch_ofac_trees(environment),
+ ]);
+ } catch (error) {
+ console.error(`Failed fetching kyc data for ${environment}:`, error);
+ throw error; // Re-throw to let proving machine handle it
+ }
+ },
+ fetch_deployed_circuits: async (environment: 'prod' | 'stg') => {
+ const url = `${environment === 'prod' ? API_URL : API_URL_STAGING}/deployed-circuits`;
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP error fetching ${url}! status: ${response.status}`);
+ }
+ const responseText = await response.text();
+ const data = JSON.parse(responseText);
+ set({ kyc: { ...get().kyc, deployed_circuits: data.data } });
+ },
+ fetch_circuits_dns_mapping: async (environment: 'prod' | 'stg') => {
+ const url = `${environment === 'prod' ? API_URL : API_URL_STAGING}/circuit-dns-mapping-gcp`;
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP error fetching ${url}! status: ${response.status}`);
+ }
+ const responseText = await response.text();
+ const data = JSON.parse(responseText);
+ set({
+ kyc: { ...get().kyc, circuits_dns_mapping: data.data },
+ });
+ },
+ fetch_public_keys: async (_environment: 'prod' | 'stg') => {
+ set({ kyc: { ...get().kyc, public_keys: null } });
+ },
+ fetch_identity_tree: async (environment: 'prod' | 'stg') => {
+ const url = `${environment === 'prod' ? TREE_URL : TREE_URL_STAGING}/identity-kyc`;
+ try {
+ const response = await fetchWithTimeout(url);
+ if (!response.ok) {
+ throw new Error(`HTTP error fetching ${url}! status: ${response.status}`);
+ }
+ const responseText = await response.text();
+ const data = JSON.parse(responseText);
+ set({ kyc: { ...get().kyc, commitment_tree: data.data } });
+ } catch (error) {
+ console.error(`Failed fetching kyc identity tree from ${url}:`, error);
+ set({ kyc: { ...get().kyc, commitment_tree: null } });
+ }
+ },
+ fetch_ofac_trees: async (environment: 'prod' | 'stg') => {
+ const baseUrl = environment === 'prod' ? TREE_URL : TREE_URL_STAGING;
+ const nameDobUrl = `${baseUrl}/ofac/name-dob-kyc`;
+ const nameYobUrl = `${baseUrl}/ofac/name-yob-kyc`;
+
+ try {
+ const fetchTree = async (url: string): Promise => {
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`HTTP error fetching ${url}! status: ${res.status}`);
+ }
+ const responseData = await res.json();
+
+ if (responseData && typeof responseData === 'object' && 'status' in responseData) {
+ if (responseData.status !== 'success' || !responseData.data) {
+ throw new Error(`Failed to fetch tree from ${url}: ${responseData.message || 'Invalid response format'}`);
+ }
+ return responseData.data;
+ }
+
+ return responseData;
+ };
+
+ const [nameDobData, nameYobData] = await Promise.all([fetchTree(nameDobUrl), fetchTree(nameYobUrl)]);
+
+ set({
+ kyc: {
+ ...get().kyc,
+ ofac_trees: {
+ passportNoAndNationality: null,
+ nameAndDob: nameDobData,
+ nameAndYob: nameYobData,
+ },
+ },
+ });
+ } catch (error) {
+ console.error('Failed fetching kyc OFAC trees:', error);
+ set({ kyc: { ...get().kyc, ofac_trees: null } });
+ }
+ },
},
}));
diff --git a/packages/mobile-sdk-alpha/src/types/ui.ts b/packages/mobile-sdk-alpha/src/types/ui.ts
index 70724d00a..628f07dc3 100644
--- a/packages/mobile-sdk-alpha/src/types/ui.ts
+++ b/packages/mobile-sdk-alpha/src/types/ui.ts
@@ -20,6 +20,7 @@ export interface DocumentMetadata {
mock: boolean;
isRegistered?: boolean;
registeredAt?: number; // timestamp (epoch ms) when document was registered
+ hasExpirationDate?: boolean; // whether the document has an expiration date
}
/**
diff --git a/packages/mobile-sdk-alpha/tests/data/country-data-sync.integration.test.ts b/packages/mobile-sdk-alpha/tests/data/country-data-sync.integration.test.ts
index 76be2be58..b7ae26629 100644
--- a/packages/mobile-sdk-alpha/tests/data/country-data-sync.integration.test.ts
+++ b/packages/mobile-sdk-alpha/tests/data/country-data-sync.integration.test.ts
@@ -35,6 +35,7 @@ function isNetworkError(error: unknown): boolean {
'fetch failed', // Generic fetch failure
'network', // Generic network error
'AbortError', // Request aborted (timeout)
+ 'AbortSignal', // AbortSignal compatibility issue in test environments
];
const errorMessage = error.message.toLowerCase();
diff --git a/packages/mobile-sdk-alpha/tests/documents/utils.test.ts b/packages/mobile-sdk-alpha/tests/documents/utils.test.ts
index 5bb68e197..3d678e2a4 100644
--- a/packages/mobile-sdk-alpha/tests/documents/utils.test.ts
+++ b/packages/mobile-sdk-alpha/tests/documents/utils.test.ts
@@ -4,11 +4,12 @@
import { describe, expect, it } from 'vitest';
-import type { DocumentCatalog } from '@selfxyz/common/types';
+import type { AadhaarData, DocumentCatalog } from '@selfxyz/common';
import type { PassportData } from '@selfxyz/common/types/passport';
import type { DocumentsAdapter, SelfClient } from '../../src';
import { createSelfClient, defaultConfig, loadSelectedDocument } from '../../src';
+import { storeDocumentWithDeduplication } from '../../src/documents/utils';
const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAdapter): SelfClient => {
return createSelfClient({
@@ -160,3 +161,171 @@ describe('loadSelectedDocument', () => {
expect(saveDocumentCatalogSpy).not.toHaveBeenCalled();
});
});
+
+describe('storeDocumentWithDeduplication', () => {
+ const passportDocument = {
+ mrz: 'P {
+ const emptyCatalog: DocumentCatalog = { documents: [] };
+ const loadDocumentCatalogSpy = vi.fn().mockResolvedValue(emptyCatalog);
+ const saveDocumentCatalogSpy = vi.fn();
+ const saveDocumentSpy = vi.fn();
+
+ const client = createMockSelfClientWithDocumentsAdapter({
+ loadDocumentCatalog: loadDocumentCatalogSpy,
+ loadDocumentById: vi.fn(),
+ saveDocumentCatalog: saveDocumentCatalogSpy,
+ saveDocument: saveDocumentSpy,
+ deleteDocument: vi.fn(),
+ });
+
+ await storeDocumentWithDeduplication(client, passportDocument);
+
+ expect(saveDocumentCatalogSpy).toHaveBeenCalledTimes(1);
+ const savedCatalog = saveDocumentCatalogSpy.mock.calls[0][0] as DocumentCatalog;
+
+ expect(savedCatalog.documents).toHaveLength(1);
+ expect(savedCatalog.documents[0].documentCategory).toBe('passport');
+ expect(savedCatalog.documents[0].hasExpirationDate).toBe(true);
+ });
+
+ it('sets hasExpirationDate to true for ID card documents', async () => {
+ const emptyCatalog: DocumentCatalog = { documents: [] };
+ const loadDocumentCatalogSpy = vi.fn().mockResolvedValue(emptyCatalog);
+ const saveDocumentCatalogSpy = vi.fn();
+ const saveDocumentSpy = vi.fn();
+
+ const client = createMockSelfClientWithDocumentsAdapter({
+ loadDocumentCatalog: loadDocumentCatalogSpy,
+ loadDocumentById: vi.fn(),
+ saveDocumentCatalog: saveDocumentCatalogSpy,
+ saveDocument: saveDocumentSpy,
+ deleteDocument: vi.fn(),
+ });
+
+ await storeDocumentWithDeduplication(client, idCardDocument);
+
+ expect(saveDocumentCatalogSpy).toHaveBeenCalledTimes(1);
+ const savedCatalog = saveDocumentCatalogSpy.mock.calls[0][0] as DocumentCatalog;
+
+ expect(savedCatalog.documents).toHaveLength(1);
+ expect(savedCatalog.documents[0].documentCategory).toBe('id_card');
+ expect(savedCatalog.documents[0].hasExpirationDate).toBe(true);
+ });
+
+ it('sets hasExpirationDate to false for Aadhaar documents', async () => {
+ const emptyCatalog: DocumentCatalog = { documents: [] };
+ const loadDocumentCatalogSpy = vi.fn().mockResolvedValue(emptyCatalog);
+ const saveDocumentCatalogSpy = vi.fn();
+ const saveDocumentSpy = vi.fn();
+
+ const client = createMockSelfClientWithDocumentsAdapter({
+ loadDocumentCatalog: loadDocumentCatalogSpy,
+ loadDocumentById: vi.fn(),
+ saveDocumentCatalog: saveDocumentCatalogSpy,
+ saveDocument: saveDocumentSpy,
+ deleteDocument: vi.fn(),
+ });
+
+ await storeDocumentWithDeduplication(client, aadhaarDocument);
+
+ expect(saveDocumentCatalogSpy).toHaveBeenCalledTimes(1);
+ const savedCatalog = saveDocumentCatalogSpy.mock.calls[0][0] as DocumentCatalog;
+
+ expect(savedCatalog.documents).toHaveLength(1);
+ expect(savedCatalog.documents[0].documentCategory).toBe('aadhaar');
+ expect(savedCatalog.documents[0].hasExpirationDate).toBe(false);
+ });
+
+ it('infers passport category and sets hasExpirationDate when documentCategory is missing', async () => {
+ const docWithoutCategory = {
+ mrz: 'P {
+ const docWithoutCategory = {
+ mrz: 'P {
+ const docWithoutCategory = {
+ qrData: 'test-qr-data',
+ documentType: 'aadhaar',
+ } as AadhaarData;
+
+ const emptyCatalog: DocumentCatalog = { documents: [] };
+ const saveDocumentCatalogSpy = vi.fn();
+
+ const client = createMockSelfClientWithDocumentsAdapter({
+ loadDocumentCatalog: vi.fn().mockResolvedValue(emptyCatalog),
+ loadDocumentById: vi.fn(),
+ saveDocumentCatalog: saveDocumentCatalogSpy,
+ saveDocument: vi.fn(),
+ deleteDocument: vi.fn(),
+ });
+
+ await storeDocumentWithDeduplication(client, docWithoutCategory);
+
+ const savedCatalog = saveDocumentCatalogSpy.mock.calls[0][0] as DocumentCatalog;
+ expect(savedCatalog.documents[0].documentCategory).toBe('aadhaar');
+ expect(savedCatalog.documents[0].hasExpirationDate).toBe(false);
+ });
+});
diff --git a/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts b/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts
index 429040ce7..8a83ce71a 100644
--- a/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts
+++ b/packages/mobile-sdk-alpha/tests/proving/provingMachine.documentProcessor.test.ts
@@ -655,6 +655,7 @@ describe('validatingDocument', () => {
REGISTER: [],
REGISTER_ID: [],
REGISTER_AADHAAR: [],
+ REGISTER_KYC: [],
DSC: [],
DSC_ID: [],
};
diff --git a/patches/@sumsub+react-native-mobilesdk-module+1.40.2.patch b/patches/@sumsub+react-native-mobilesdk-module+1.40.2.patch
index e04a1916a..094f374c5 100644
--- a/patches/@sumsub+react-native-mobilesdk-module+1.40.2.patch
+++ b/patches/@sumsub+react-native-mobilesdk-module+1.40.2.patch
@@ -1,24 +1,13 @@
diff --git a/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle b/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle
-index 0000000..0000001 100644
+index 8796953..b00f0d4 100644
--- a/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle
+++ b/node_modules/@sumsub/react-native-mobilesdk-module/android/build.gradle
-@@ -77,11 +77,11 @@ dependencies {
+@@ -77,7 +77,7 @@ dependencies {
implementation "com.sumsub.sns:idensic-mobile-sdk:1.40.2"
- // Enable Device Intelligence (Fisherman) for fraud detection
- // Privacy: Declare device fingerprinting/identifiers in Google Play Data Safety form
// remove comment to enable Device Intelligence
- // implementation "com.sumsub.sns:idensic-mobile-sdk-fisherman:1.40.2"
+ implementation "com.sumsub.sns:idensic-mobile-sdk-fisherman:1.40.2"
- // VideoIdent disabled on both iOS and Android for current release
- // Reason: Avoids microphone permission requirements (FOREGROUND_SERVICE_MICROPHONE on Android)
- // Feature: Provides liveness checks via live video calls with human agents
- // TODO: Re-enable on both platforms for future release when liveness checks are needed
// remove comment if you need VideoIdent support
-- // implementation "com.sumsub.sns:idensic-mobile-sdk-videoident:1.40.2"
-+ // implementation "com.sumsub.sns:idensic-mobile-sdk-videoident:1.40.2"
+ // implementation "com.sumsub.sns:idensic-mobile-sdk-videoident:1.40.2"
// remove comment if you need EID support
- // implementation "com.sumsub.sns:idensic-mobile-sdk-eid:1.40.2"
- // remove comment if you need NFC support
- // implementation "com.sumsub.sns:idensic-mobile-sdk-nfc:1.40.2"
- }