SELF-1812: integrate sumsub into mobile app (#1650)

* sumsub initial pass

* add sumsub tee url

* agent feedback and fixes

* update lock

* agent feedback

* fix types

* agnet feedback

* fix mock

* agent feedback

* lazy load sumsub screen

* white button color

* fix lint

* add debug url link

* allow us to see recordings

* debug maestro run

* disable e2e screen recording for now. don't load sumsub logic when running e2e test

* remove lazy loading

* skip installing sumsub plugin

* retest ios e2e

* get e2e tests passing

* clean up
This commit is contained in:
Justin Hernandez
2026-01-26 14:06:36 -08:00
committed by GitHub
parent d708d85982
commit ba856226d8
25 changed files with 1422 additions and 42 deletions

View File

@@ -0,0 +1,14 @@
// 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.
export type {
AccessTokenResponse,
SumsubApplicantInfo,
SumsubResult,
} from '@/integrations/sumsub/types';
export {
type SumsubConfig,
fetchAccessToken,
launchSumsub,
} from '@/integrations/sumsub/sumsubService';

View File

@@ -0,0 +1,102 @@
// 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 { SUMSUB_TEE_URL } from '@env';
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-module';
import type {
AccessTokenResponse,
SumsubResult,
} from '@/integrations/sumsub/types';
export interface SumsubConfig {
accessToken: string;
locale?: string;
debug?: boolean;
onStatusChanged?: (prevStatus: string, newStatus: string) => void;
onEvent?: (eventType: string, payload: unknown) => void;
}
const FETCH_TIMEOUT_MS = 30000; // 30 seconds
export const fetchAccessToken = async (
phoneNumber: string,
): Promise<AccessTokenResponse> => {
const apiUrl = SUMSUB_TEE_URL;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(`${apiUrl}/access-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone: phoneNumber }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to get Sumsub access token (HTTP ${response.status})`,
);
}
const body = await response.json();
// Handle both string and object responses
if (typeof body === 'string') {
return JSON.parse(body) as AccessTokenResponse;
}
return body as AccessTokenResponse;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error) {
if (err.name === 'AbortError') {
throw new Error(
`Request to Sumsub TEE timed out after ${FETCH_TIMEOUT_MS / 1000}s`,
);
}
throw new Error(`Failed to get Sumsub access token: ${err.message}`);
}
throw new Error('Failed to get Sumsub access token: Unknown error');
}
};
export const launchSumsub = async (
config: SumsubConfig,
): Promise<SumsubResult> => {
const sdk = SNSMobileSDK.init(config.accessToken, async () => {
// Token refresh not implemented for test flow
throw new Error(
'Sumsub token expired - refresh not implemented for test flow',
);
})
.withHandlers({
onStatusChanged: event => {
console.log(`Sumsub status: ${event.prevStatus} => ${event.newStatus}`);
config.onStatusChanged?.(event.prevStatus, event.newStatus);
},
onLog: _event => {
// Log event received but don't log message (may contain PII)
console.log('[Sumsub] Log event received');
},
onEvent: event => {
// Only log event type, not full payload (may contain PII)
console.log(`Sumsub event: ${event.eventType}`);
config.onEvent?.(event.eventType, event.payload);
},
})
.withDebug(config.debug ?? __DEV__)
.withLocale(config.locale ?? 'en')
.withAnalyticsEnabled(true) // Device Intelligence requires this
.build();
return sdk.launch();
};

View File

@@ -0,0 +1,40 @@
// 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.
export interface AccessTokenResponse {
token: string;
userId: string;
}
export interface SumsubApplicantInfo {
id: string;
createdAt: string;
key: string;
clientId: string;
inspectionId: string;
externalUserId: string;
info?: {
firstName?: string;
lastName?: string;
dob?: string;
country?: string;
phone?: string;
};
email?: string;
phone?: string;
review: {
reviewAnswer: string;
reviewResult: {
reviewAnswer: string;
};
};
type: string;
}
export interface SumsubResult {
success: boolean;
status: string;
errorType?: string;
errorMsg?: string;
}

View File

@@ -13,6 +13,7 @@ import DevHapticFeedbackScreen from '@/screens/dev/DevHapticFeedbackScreen';
import DevLoadingScreen from '@/screens/dev/DevLoadingScreen';
import DevPrivateKeyScreen from '@/screens/dev/DevPrivateKeyScreen';
import DevSettingsScreen from '@/screens/dev/DevSettingsScreen';
import SumsubTestScreen from '@/screens/dev/SumsubTestScreen';
const devHeaderOptions: NativeStackNavigationOptions = {
headerStyle: {
@@ -21,6 +22,7 @@ const devHeaderOptions: NativeStackNavigationOptions = {
headerTitleStyle: {
color: white,
},
headerTintColor: white,
headerBackTitle: 'close',
};
@@ -80,6 +82,13 @@ const devScreens = {
title: 'Dev Loading Screen',
} as NativeStackNavigationOptions,
},
SumsubTest: {
screen: SumsubTestScreen,
options: {
...devHeaderOptions,
title: 'Sumsub Test',
} as NativeStackNavigationOptions,
},
};
export default devScreens;

View File

@@ -681,6 +681,29 @@ const DevSettingsScreen: React.FC<DevSettingsScreenProps> = ({}) => {
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
<Button
style={{ backgroundColor: 'white' }}
borderColor={slate200}
borderRadius="$2"
height="$5"
padding={0}
onPress={() => {
navigation.navigate('SumsubTest');
}}
>
<XStack
width="100%"
justifyContent="space-between"
paddingVertical="$3"
paddingLeft="$4"
paddingRight="$1.5"
>
<Text fontSize="$5" color={slate500} fontFamily={dinot}>
Sumsub Test Flow
</Text>
<ChevronRight color={slate500} strokeWidth={2.5} />
</XStack>
</Button>
{IS_DEV_MODE && (
<Button
style={{ backgroundColor: 'white' }}

View File

@@ -0,0 +1,686 @@
// 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, { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, ScrollView, TextInput } from 'react-native';
import { io, type Socket } from 'socket.io-client';
import { Button, Text, XStack, YStack } from 'tamagui';
import { SUMSUB_TEE_URL } from '@env';
import { useNavigation } from '@react-navigation/native';
import { ChevronLeft } from '@tamagui/lucide-icons';
import {
green500,
red500,
slate200,
slate400,
slate500,
slate600,
slate800,
white,
yellow500,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import {
fetchAccessToken,
launchSumsub,
type SumsubApplicantInfo,
type SumsubResult,
} from '@/integrations/sumsub';
const SumsubTestScreen: React.FC = () => {
const navigation = useNavigation();
const [phoneNumber, setPhoneNumber] = useState('+11234567890');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [sdkLaunching, setSdkLaunching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SumsubResult | null>(null);
const [applicantInfo, setApplicantInfo] =
useState<SumsubApplicantInfo | null>(null);
const socketRef = useRef<Socket | null>(null);
const hasSubscribedRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const paddingBottom = useSafeBottomPadding(20);
const handleFetchToken = useCallback(async () => {
setLoading(true);
setError(null);
setAccessToken(null);
setUserId(null);
setResult(null);
try {
const response = await fetchAccessToken(phoneNumber);
if (!isMountedRef.current) return;
setAccessToken(response.token);
setUserId(response.userId);
Alert.alert('Success', 'Access token generated successfully', [
{ text: 'OK' },
]);
} catch (err) {
if (!isMountedRef.current) return;
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
Alert.alert('Error', `Failed to fetch access token: ${message}`, [
{ text: 'OK' },
]);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, [phoneNumber]);
const subscribeToWebSocket = useCallback(() => {
if (!userId || hasSubscribedRef.current) {
return;
}
console.log('Connecting to WebSocket:', SUMSUB_TEE_URL);
const socket = io(SUMSUB_TEE_URL, {
transports: ['websocket', 'polling'],
});
socketRef.current = socket;
socket.on('connect', () => {
console.log('Socket connected, subscribing to user');
hasSubscribedRef.current = true;
socket.emit('subscribe', userId);
});
socket.on('success', (data: SumsubApplicantInfo) => {
console.log('Received applicant info');
if (!isMountedRef.current) return;
setApplicantInfo(data);
Alert.alert(
'Verification Complete',
'Your verification was successful!',
[{ text: 'OK' }],
);
});
socket.on('verification_failed', (reason: string) => {
console.log('Verification failed:', reason);
if (!isMountedRef.current) return;
setError(`Verification failed: ${reason}`);
Alert.alert('Verification Failed', reason, [{ text: 'OK' }]);
});
socket.on('error', (errorMessage: string) => {
console.error('Socket error:', errorMessage);
if (!isMountedRef.current) return;
setError(errorMessage);
hasSubscribedRef.current = false;
});
socket.on('disconnect', () => {
console.log('Socket disconnected');
hasSubscribedRef.current = false;
});
}, [userId]);
const handleLaunchSumsub = useCallback(async () => {
if (!accessToken) {
Alert.alert(
'Error',
'No access token available. Please generate one first.',
[{ text: 'OK' }],
);
return;
}
setSdkLaunching(true);
setResult(null);
setError(null);
try {
const sdkResult = await launchSumsub({
accessToken,
debug: true,
locale: 'en',
onEvent: (eventType, _payload) => {
console.log('SDK Event:', eventType);
// Subscribe to WebSocket when verification is completed
if (eventType === 'idCheck.onApplicantVerificationCompleted') {
subscribeToWebSocket();
}
},
});
if (!isMountedRef.current) return;
setResult(sdkResult);
if (sdkResult.success) {
Alert.alert(
'SDK Closed',
`Sumsub SDK closed with status: ${sdkResult.status}`,
[{ text: 'OK' }],
);
} else {
Alert.alert(
'Error',
`Sumsub failed: ${sdkResult.errorMsg || sdkResult.errorType || 'Unknown error'}`,
[{ text: 'OK' }],
);
}
} catch (err) {
console.error('Sumsub launch error:', err);
if (!isMountedRef.current) return;
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
Alert.alert('Error', `Failed to launch Sumsub SDK: ${message}`, [
{ text: 'OK' },
]);
} finally {
if (isMountedRef.current) {
setSdkLaunching(false);
}
}
}, [accessToken, subscribeToWebSocket]);
const handleReset = useCallback(() => {
setApplicantInfo(null);
setAccessToken(null);
setUserId(null);
setResult(null);
setError(null);
hasSubscribedRef.current = false;
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
}
}, []);
useEffect(() => {
return () => {
isMountedRef.current = false;
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
hasSubscribedRef.current = false;
}
};
}, []);
// If we have applicant info, show that
if (applicantInfo) {
return (
<ScrollView showsVerticalScrollIndicator={false}>
<YStack
gap="$4"
alignItems="center"
backgroundColor="white"
flex={1}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom={paddingBottom}
>
{/* Back Button */}
<XStack width="100%" justifyContent="flex-start">
<Button
backgroundColor="transparent"
borderRadius="$2"
paddingHorizontal="$0"
onPress={() => navigation.goBack()}
icon={<ChevronLeft size={24} color={slate600} />}
>
<Text
color={slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
Back
</Text>
</Button>
</XStack>
{/* Success Header */}
<YStack
width="100%"
backgroundColor={green500}
borderRadius="$4"
padding="$4"
alignItems="center"
>
<Text
fontSize="$7"
color={white}
fontFamily={dinot}
fontWeight="600"
>
Verification Complete
</Text>
<Text fontSize="$4" color={white} fontFamily={dinot} marginTop="$2">
Your verification was successful
</Text>
</YStack>
{/* Applicant Info */}
<YStack
width="100%"
backgroundColor={slate200}
borderRadius="$4"
padding="$4"
gap="$3"
>
<Text
fontSize="$6"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
Applicant Information
</Text>
<YStack gap="$2">
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Name:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.firstName || 'N/A'}{' '}
{applicantInfo.info?.lastName || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Date of Birth:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.dob || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Country:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.country || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Phone:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.info?.phone || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Email:
</Text>
<Text
fontSize="$4"
color={slate800}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.email || 'N/A'}
</Text>
</XStack>
<XStack justifyContent="space-between">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Review Result:
</Text>
<Text
fontSize="$4"
color={green500}
fontFamily={dinot}
fontWeight="600"
>
{applicantInfo.review.reviewAnswer}
</Text>
</XStack>
</YStack>
{/* Raw JSON */}
<YStack
marginTop="$2"
backgroundColor={white}
borderRadius="$2"
padding="$3"
>
<Text
fontSize="$3"
color={slate400}
fontFamily={dinot}
fontWeight="600"
marginBottom="$2"
>
Raw Data:
</Text>
<Text fontSize="$2" color={slate500} fontFamily={dinot}>
{JSON.stringify(applicantInfo, null, 2)}
</Text>
</YStack>
</YStack>
<Button
backgroundColor={slate600}
borderRadius="$2"
height="$6"
width="100%"
onPress={handleReset}
>
<Text
color={white}
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
>
Start New Verification
</Text>
</Button>
</YStack>
</ScrollView>
);
}
return (
<ScrollView showsVerticalScrollIndicator={false}>
<YStack
gap="$4"
alignItems="center"
backgroundColor="white"
flex={1}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom={paddingBottom}
>
{/* Back Button */}
<XStack width="100%" justifyContent="flex-start">
<Button
backgroundColor="transparent"
borderRadius="$2"
paddingHorizontal="$0"
onPress={() => navigation.goBack()}
icon={<ChevronLeft size={24} color={slate600} />}
>
<Text
color={slate600}
fontSize="$5"
fontFamily={dinot}
fontWeight="600"
>
Back
</Text>
</Button>
</XStack>
{/* TEE Service Status */}
<YStack
width="100%"
backgroundColor={slate200}
borderRadius="$4"
padding="$4"
>
<Text
fontSize="$5"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
TEE Service
</Text>
<Text
fontSize="$3"
color={slate500}
fontFamily={dinot}
marginTop="$2"
>
{SUMSUB_TEE_URL}
</Text>
</YStack>
{/* Phone Number Input */}
<YStack width="100%" gap="$2">
<Text
fontSize="$4"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
Phone Number
</Text>
<TextInput
value={phoneNumber}
onChangeText={setPhoneNumber}
placeholder="+11234567890"
keyboardType="phone-pad"
style={{
backgroundColor: white,
borderWidth: 1,
borderColor: slate200,
borderRadius: 8,
padding: 12,
fontSize: 16,
fontFamily: dinot,
color: slate800,
}}
/>
</YStack>
{/* Generate Token Button */}
<Button
backgroundColor={slate600}
borderRadius="$2"
height="$6"
width="100%"
onPress={handleFetchToken}
disabled={loading || !phoneNumber}
opacity={loading || !phoneNumber ? 0.5 : 1}
>
<Text color={white} fontSize="$6" fontFamily={dinot} fontWeight="600">
{loading ? 'Requesting token…' : 'Generate Access Token'}
</Text>
</Button>
{/* Token Status */}
{accessToken && (
<YStack
width="100%"
backgroundColor={green500}
borderRadius="$4"
padding="$4"
>
<Text
fontSize="$5"
color={white}
fontFamily={dinot}
fontWeight="600"
>
Access Token Generated
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot} marginTop="$2">
User ID: {userId}
</Text>
<Text
fontSize="$2"
color={white}
fontFamily={dinot}
marginTop="$2"
opacity={0.8}
>
Token: {accessToken.substring(0, 30)}...
</Text>
</YStack>
)}
{/* Launch SDK Button */}
{accessToken && (
<Button
backgroundColor={green500}
borderRadius="$2"
height="$6"
width="100%"
onPress={handleLaunchSumsub}
disabled={sdkLaunching}
opacity={sdkLaunching ? 0.5 : 1}
>
<Text
color={white}
fontSize="$6"
fontFamily={dinot}
fontWeight="600"
>
{sdkLaunching ? 'Launching…' : 'Launch Sumsub SDK'}
</Text>
</Button>
)}
{/* Error Display */}
{error && (
<YStack
width="100%"
backgroundColor={red500}
borderRadius="$4"
padding="$4"
>
<Text
fontSize="$5"
color={white}
fontFamily={dinot}
fontWeight="600"
>
Error
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot} marginTop="$2">
{error}
</Text>
</YStack>
)}
{/* SDK Result Display */}
{result && (
<YStack
width="100%"
backgroundColor={slate200}
borderRadius="$4"
padding="$4"
gap="$2"
>
<Text
fontSize="$6"
color={slate600}
fontFamily={dinot}
fontWeight="600"
>
SDK Result
</Text>
<YStack gap="$1">
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Success:{' '}
<Text
fontWeight="600"
color={result.success ? green500 : red500}
>
{result.success ? 'Yes' : 'No'}
</Text>
</Text>
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Status:{' '}
<Text fontWeight="600" color={slate600}>
{result.status}
</Text>
</Text>
{result.errorType && (
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Error Type:{' '}
<Text fontWeight="600" color={red500}>
{result.errorType}
</Text>
</Text>
)}
{result.errorMsg && (
<Text fontSize="$4" color={slate500} fontFamily={dinot}>
Error Message:{' '}
<Text fontWeight="600" color={red500}>
{result.errorMsg}
</Text>
</Text>
)}
</YStack>
<Text
fontSize="$3"
color={slate500}
fontFamily={dinot}
marginTop="$2"
>
Waiting for verification results from WebSocket...
</Text>
</YStack>
)}
{/* Instructions */}
<YStack
width="100%"
backgroundColor={yellow500}
borderRadius="$4"
padding="$4"
gap="$2"
>
<Text fontSize="$5" color={white} fontFamily={dinot} fontWeight="600">
Instructions
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
1. Make sure the TEE service is running at {SUMSUB_TEE_URL}
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
2. Enter a phone number and tap "Generate Access Token"
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
3. Tap "Launch Sumsub SDK" to start verification
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
4. Complete the verification flow
</Text>
<Text fontSize="$3" color={white} fontFamily={dinot}>
5. Results will appear automatically via WebSocket
</Text>
</YStack>
</YStack>
</ScrollView>
);
};
export default SumsubTestScreen;

56
app/src/types/sumsub.d.ts vendored Normal file
View File

@@ -0,0 +1,56 @@
// 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.
declare module '@sumsub/react-native-mobilesdk-module' {
export interface SumsubEvent {
eventType: string;
payload: Record<string, unknown>;
}
export interface SumsubHandlers {
onStatusChanged?: (event: SumsubStatusChangedEvent) => void;
onLog?: (event: SumsubLogEvent) => void;
onEvent?: (event: SumsubEvent) => void;
}
export interface SumsubLogEvent {
message: string;
}
export interface SumsubResult {
success: boolean;
status: string;
errorType?: string;
errorMsg?: string;
}
export interface SumsubSDK {
withHandlers(handlers: SumsubHandlers): SumsubSDK;
withDebug(debug: boolean): SumsubSDK;
withLocale(locale: string): SumsubSDK;
withAnalyticsEnabled(enabled: boolean): SumsubSDK;
withAutoCloseOnApprove(seconds: number): SumsubSDK;
withApplicantConf(config: { email?: string; phone?: string }): SumsubSDK;
withPreferredDocumentDefinitions(
definitions: Record<string, { idDocType: string; country: string }>,
): SumsubSDK;
withDisableMLKit(disable: boolean): SumsubSDK;
withStrings(strings: Record<string, string>): SumsubSDK;
build(): SumsubSDK;
launch(): Promise<SumsubResult>;
dismiss(): void;
}
export interface SumsubStatusChangedEvent {
prevStatus: string;
newStatus: string;
}
export default class SNSMobileSDK {
static init(
accessToken: string,
tokenExpirationHandler: () => Promise<string>,
): SumsubSDK;
}
}