[SELF-1891] feat(kyc): Other IDs button (#1660)

* feat(kyc): Other IDs button

* trigger sumsub flow directly from event listener

* formatting

* formatting

* add todo

* add feature flag

---------

Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
This commit is contained in:
Leszek Stachowski
2026-01-27 23:03:36 +01:00
committed by GitHub
parent e0c0c37372
commit 80d9e2d625
12 changed files with 245 additions and 56 deletions

View File

@@ -21,19 +21,25 @@ export interface SumsubConfig {
const FETCH_TIMEOUT_MS = 30000; // 30 seconds
export const fetchAccessToken = async (
phoneNumber: string,
phoneNumber?: string,
): Promise<AccessTokenResponse> => {
const apiUrl = SUMSUB_TEE_URL;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const requestBody: Record<string, string> = {};
if (phoneNumber) {
requestBody.phone = phoneNumber;
}
const response = await fetch(`${apiUrl}/access-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone: phoneNumber }),
body: JSON.stringify(requestBody),
signal: controller.signal,
});

View File

@@ -21,6 +21,7 @@ import {
} from '@selfxyz/mobile-sdk-alpha';
import { logNFCEvent, logProofEvent } from '@/config/sentry';
import { fetchAccessToken, launchSumsub } from '@/integrations/sumsub';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import {
@@ -298,6 +299,16 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
navigationRef.navigate('AadhaarUpload', { countryCode });
}
break;
case 'kyc':
fetchAccessToken()
.then(accessToken => {
launchSumsub({ accessToken: accessToken.token });
})
// TODO: show sumsub error screen
.catch(error => {
console.error('Error launching Sumsub:', error);
});
break;
default:
if (countryCode) {
navigationRef.navigate('ComingSoon', { countryCode });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,17 @@ import { wasm as wasmTester } from 'circom_tester';
import path from 'path';
import { packBytesAndPoseidon } from '@selfxyz/common/utils/hash';
import { poseidon2 } from 'poseidon-lite';
import { generateKycRegisterInput, generateMockKycRegisterInput } from '@selfxyz/common/utils/kyc/generateInputs.js';
import {
generateKycRegisterInput,
generateMockKycRegisterInput,
} from '@selfxyz/common/utils/kyc/generateInputs.js';
import { KycRegisterInput } from '@selfxyz/common/utils/kyc/types';
import { KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_LENGTH, KYC_ID_TYPE_INDEX, KYC_ID_TYPE_LENGTH } from '@selfxyz/common/utils/kyc/constants';
import {
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_LENGTH,
KYC_ID_TYPE_INDEX,
KYC_ID_TYPE_LENGTH,
} from '@selfxyz/common/utils/kyc/constants';
describe('REGISTER KYC Circuit Tests', () => {
let circuit: any;
@@ -42,7 +50,11 @@ describe('REGISTER KYC Circuit Tests', () => {
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH
);
const nullifierInputs = [...'sumsub'.split('').map((x) => x.charCodeAt(0)), ...idnumber, ...input.data_padded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH)];
const nullifierInputs = [
...'sumsub'.split('').map((x) => x.charCodeAt(0)),
...idnumber,
...input.data_padded.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH),
];
const nullifier = packBytesAndPoseidon(nullifierInputs);
const commitment = poseidon2([
input.secret,

View File

@@ -1,28 +1,78 @@
//Helper function to destructure the kyc data from the api response
import { Point } from "@zk-kit/baby-jubjub";
import { KYC_ADDRESS_INDEX, KYC_ADDRESS_LENGTH, KYC_COUNTRY_INDEX, KYC_COUNTRY_LENGTH, KYC_DOB_INDEX, KYC_DOB_LENGTH, KYC_EXPIRY_DATE_INDEX, KYC_EXPIRY_DATE_LENGTH, KYC_FULL_NAME_INDEX, KYC_FULL_NAME_LENGTH, KYC_GENDER_INDEX, KYC_GENDER_LENGTH, KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_LENGTH, KYC_ID_TYPE_INDEX, KYC_ID_TYPE_LENGTH, KYC_ISSUANCE_DATE_INDEX, KYC_ISSUANCE_DATE_LENGTH, KYC_PHONE_NUMBER_INDEX, KYC_PHONE_NUMBER_LENGTH, KYC_PHOTO_HASH_INDEX, KYC_PHOTO_HASH_LENGTH } from "./constants.js";
import { KycData } from "./types.js";
import { Point } from '@zk-kit/baby-jubjub';
import {
KYC_ADDRESS_INDEX,
KYC_ADDRESS_LENGTH,
KYC_COUNTRY_INDEX,
KYC_COUNTRY_LENGTH,
KYC_DOB_INDEX,
KYC_DOB_LENGTH,
KYC_EXPIRY_DATE_INDEX,
KYC_EXPIRY_DATE_LENGTH,
KYC_FULL_NAME_INDEX,
KYC_FULL_NAME_LENGTH,
KYC_GENDER_INDEX,
KYC_GENDER_LENGTH,
KYC_ID_NUMBER_INDEX,
KYC_ID_NUMBER_LENGTH,
KYC_ID_TYPE_INDEX,
KYC_ID_TYPE_LENGTH,
KYC_ISSUANCE_DATE_INDEX,
KYC_ISSUANCE_DATE_LENGTH,
KYC_PHONE_NUMBER_INDEX,
KYC_PHONE_NUMBER_LENGTH,
KYC_PHOTO_HASH_INDEX,
KYC_PHOTO_HASH_LENGTH,
} from './constants.js';
import { KycData } from './types.js';
//accepts a base64 signature and returns a signature object
export function deserializeSignature(signature: string): { R: Point<bigint>; s: bigint} {
export function deserializeSignature(signature: string): { R: Point<bigint>; s: bigint } {
const [Rx, Ry, s] = Buffer.from(signature, 'base64').toString('utf-8').split(',').map(BigInt);
return { R: [Rx, Ry] as Point<bigint>, s };
}
//accepts a base64 applicant info and returns a kyc data object
export function deserializeApplicantInfo(applicantInfoBase64: string): Omit<KycData, 'user_identifier' | 'current_date' | 'majority_age_ASCII' | 'selector_older_than'> {
export function deserializeApplicantInfo(
applicantInfoBase64: string
): Omit<
KycData,
'user_identifier' | 'current_date' | 'majority_age_ASCII' | 'selector_older_than'
> {
const applicantInfo = Buffer.from(applicantInfoBase64, 'base64').toString('utf-8');
const country = applicantInfo.slice(KYC_COUNTRY_INDEX, KYC_COUNTRY_INDEX + KYC_COUNTRY_LENGTH).replace(/\x00/g, '');
const idType = applicantInfo.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH).replace(/\x00/g, '');
const idNumber = applicantInfo.slice(KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH).replace(/\x00/g, '');
const issuanceDate = applicantInfo.slice(KYC_ISSUANCE_DATE_INDEX, KYC_ISSUANCE_DATE_INDEX + KYC_ISSUANCE_DATE_LENGTH).replace(/\x00/g, '');
const expiryDate = applicantInfo.slice(KYC_EXPIRY_DATE_INDEX, KYC_EXPIRY_DATE_INDEX + KYC_EXPIRY_DATE_LENGTH).replace(/\x00/g, '');
const fullName = applicantInfo.slice(KYC_FULL_NAME_INDEX, KYC_FULL_NAME_INDEX + KYC_FULL_NAME_LENGTH).replace(/\x00/g, '');
const dob = applicantInfo.slice(KYC_DOB_INDEX, KYC_DOB_INDEX + KYC_DOB_LENGTH).replace(/\x00/g, '');
const photoHash = applicantInfo.slice(KYC_PHOTO_HASH_INDEX, KYC_PHOTO_HASH_INDEX + KYC_PHOTO_HASH_LENGTH).replace(/\x00/g, '');
const phoneNumber = applicantInfo.slice(KYC_PHONE_NUMBER_INDEX, KYC_PHONE_NUMBER_INDEX + KYC_PHONE_NUMBER_LENGTH).replace(/\x00/g, '');
const gender = applicantInfo.slice(KYC_GENDER_INDEX, KYC_GENDER_INDEX + KYC_GENDER_LENGTH).replace(/\x00/g, '');
const address = applicantInfo.slice(KYC_ADDRESS_INDEX, KYC_ADDRESS_INDEX + KYC_ADDRESS_LENGTH).replace(/\x00/g, '');
const country = applicantInfo
.slice(KYC_COUNTRY_INDEX, KYC_COUNTRY_INDEX + KYC_COUNTRY_LENGTH)
.replace(/\x00/g, '');
const idType = applicantInfo
.slice(KYC_ID_TYPE_INDEX, KYC_ID_TYPE_INDEX + KYC_ID_TYPE_LENGTH)
.replace(/\x00/g, '');
const idNumber = applicantInfo
.slice(KYC_ID_NUMBER_INDEX, KYC_ID_NUMBER_INDEX + KYC_ID_NUMBER_LENGTH)
.replace(/\x00/g, '');
const issuanceDate = applicantInfo
.slice(KYC_ISSUANCE_DATE_INDEX, KYC_ISSUANCE_DATE_INDEX + KYC_ISSUANCE_DATE_LENGTH)
.replace(/\x00/g, '');
const expiryDate = applicantInfo
.slice(KYC_EXPIRY_DATE_INDEX, KYC_EXPIRY_DATE_INDEX + KYC_EXPIRY_DATE_LENGTH)
.replace(/\x00/g, '');
const fullName = applicantInfo
.slice(KYC_FULL_NAME_INDEX, KYC_FULL_NAME_INDEX + KYC_FULL_NAME_LENGTH)
.replace(/\x00/g, '');
const dob = applicantInfo
.slice(KYC_DOB_INDEX, KYC_DOB_INDEX + KYC_DOB_LENGTH)
.replace(/\x00/g, '');
const photoHash = applicantInfo
.slice(KYC_PHOTO_HASH_INDEX, KYC_PHOTO_HASH_INDEX + KYC_PHOTO_HASH_LENGTH)
.replace(/\x00/g, '');
const phoneNumber = applicantInfo
.slice(KYC_PHONE_NUMBER_INDEX, KYC_PHONE_NUMBER_INDEX + KYC_PHONE_NUMBER_LENGTH)
.replace(/\x00/g, '');
const gender = applicantInfo
.slice(KYC_GENDER_INDEX, KYC_GENDER_INDEX + KYC_GENDER_LENGTH)
.replace(/\x00/g, '');
const address = applicantInfo
.slice(KYC_ADDRESS_INDEX, KYC_ADDRESS_INDEX + KYC_ADDRESS_LENGTH)
.replace(/\x00/g, '');
return {
country,
@@ -35,6 +85,6 @@ export function deserializeApplicantInfo(applicantInfoBase64: string): Omit<KycD
photoHash,
phoneNumber,
gender,
address
address,
};
}

View File

@@ -87,7 +87,12 @@ export const generateMockKycRegisterInput = async (
return kycRegisterInput;
};
export const generateKycRegisterInput = async (applicantInfoBase64: string, signatureBase64: string, pubkeyStr: [string, string], secret: string) => {
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];
@@ -105,7 +110,7 @@ export const generateKycRegisterInput = async (applicantInfoBase64: string, sign
};
return kycRegisterInput;
}
};
export const generateCircuitInputsOfac = (data: KycData, smt: SMT, proofLevel: number) => {
const name = data.fullName;

View File

@@ -19,7 +19,12 @@ export type KycData = {
selector_older_than: string;
};
export const serializeKycData = (kycData: Omit<KycData, 'user_identifier' | 'current_date' | 'majority_age_ASCII' | 'selector_older_than'>) => {
export const serializeKycData = (
kycData: Omit<
KycData,
'user_identifier' | 'current_date' | 'majority_age_ASCII' | 'selector_older_than'
>
) => {
//ensure max length of each field
let serializedData = '';
serializedData += kycData.country.padEnd(constants.KYC_COUNTRY_LENGTH, '\0');

View File

@@ -782,7 +782,12 @@ export const getNameDobLeafKyc = (name: string, dob: string) => {
return generateSmallKey(poseidon2([dobHash, nameHash]));
};
const processNameKyc = (firstName: string, lastName: string, i: number, reverse: boolean): bigint => {
const processNameKyc = (
firstName: string,
lastName: string,
i: number,
reverse: boolean
): bigint => {
const namePaddingLength = 64;
firstName = firstName.replace(/'/g, '');
@@ -792,7 +797,7 @@ const processNameKyc = (firstName: string, lastName: string, i: number, reverse:
lastName = lastName.replace(/[- ]/g, '<');
lastName = lastName.replace(/\./g, '');
let nameStr = reverse ? (lastName + ' ' + firstName) : (firstName + ' ' + lastName);
let nameStr = reverse ? lastName + ' ' + firstName : firstName + ' ' + lastName;
const nameArr = nameStr
.padEnd(namePaddingLength, '\0')
.split('')

View File

@@ -491,7 +491,10 @@ contract IdentityRegistrySelfricaImplV1 is IdentityRegistrySelfricaStorageV1, II
/// @dev Callable only by the owner for testing or administration.
/// @param nullifier The nullifier associated with the identity commitment.
/// @param commitment The identity commitment to add.
function devAddIdentityCommitment(uint256 nullifier, uint256 commitment) external onlyProxy onlyRole(SECURITY_ROLE) {
function devAddIdentityCommitment(
uint256 nullifier,
uint256 commitment
) external onlyProxy onlyRole(SECURITY_ROLE) {
_nullifiers[nullifier] = true;
uint256 imt_root = _identityCommitmentIMT._insert(commitment);
_rootTimestamps[imt_root] = block.timestamp;
@@ -504,7 +507,11 @@ contract IdentityRegistrySelfricaImplV1 is IdentityRegistrySelfricaStorageV1, II
/// @param oldLeaf The current identity commitment to update.
/// @param newLeaf The new identity commitment.
/// @param siblingNodes An array of sibling nodes for Merkle proof generation.
function devUpdateCommitment(uint256 oldLeaf, uint256 newLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyRole(SECURITY_ROLE) {
function devUpdateCommitment(
uint256 oldLeaf,
uint256 newLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _identityCommitmentIMT._update(oldLeaf, newLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentUpdated(oldLeaf, newLeaf, imt_root, block.timestamp);
@@ -514,7 +521,10 @@ contract IdentityRegistrySelfricaImplV1 is IdentityRegistrySelfricaStorageV1, II
/// @dev Caller must be the owner. Provides sibling nodes for proof of position.
/// @param oldLeaf The identity commitment to remove.
/// @param siblingNodes An array of sibling nodes for Merkle proof generation.
function devRemoveCommitment(uint256 oldLeaf, uint256[] calldata siblingNodes) external onlyProxy onlyRole(SECURITY_ROLE) {
function devRemoveCommitment(
uint256 oldLeaf,
uint256[] calldata siblingNodes
) external onlyProxy onlyRole(SECURITY_ROLE) {
uint256 imt_root = _identityCommitmentIMT._remove(oldLeaf, siblingNodes);
_rootTimestamps[imt_root] = block.timestamp;
emit DevCommitmentRemoved(oldLeaf, imt_root, block.timestamp);

View File

@@ -0,0 +1,16 @@
// 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.
/**
* Static feature flags for the SDK.
* These are compile-time constants that control feature availability.
* Set to true when ready to launch the feature.
*/
export const FeatureFlags = {
/**
* Enable Sumsub/KYC "Other IDs" option in the ID selection screen.
* When false, the KYC button will be hidden from users.
*/
KYC_ENABLED: false,
} as const;

View File

@@ -7,10 +7,12 @@ import { StyleSheet } from 'react-native';
import AadhaarLogo from '../../../svgs/icons/aadhaar.svg';
import EPassportLogoRounded from '../../../svgs/icons/epassport_rounded.svg';
import PassportCameraScanIcon from '../../../svgs/icons/passport_camera_scan.svg';
import PlusIcon from '../../../svgs/icons/plus.svg';
import SelfLogo from '../../../svgs/logo.svg';
import { BodyText, RoundFlag, View, XStack, YStack } from '../../components';
import { black, slate100, slate300, slate400, white } from '../../constants/colors';
import { FeatureFlags } from '../../config/features';
import { black, blue100, blue600, slate100, slate300, slate400, white } from '../../constants/colors';
import { advercase, dinot } from '../../constants/fonts';
import { useSelfClient } from '../../context';
import { buttonTap } from '../../haptic';
@@ -24,6 +26,8 @@ const getDocumentName = (docType: string): string => {
return 'ID card';
case 'a':
return 'Aadhaar';
case 'kyc':
return 'Other IDs';
default:
return 'Unknown Document';
}
@@ -37,12 +41,14 @@ const getDocumentNameForEvent = (docType: string): string => {
return 'id_card';
case 'a':
return 'aadhaar';
case 'kyc':
return 'kyc';
default:
return 'unknown_document';
}
};
const getDocumentDescription = (docType: string): string => {
const getDocumentDescription = (docType: string): string | null => {
switch (docType) {
case 'p':
return 'Verified Biometric Passport';
@@ -50,6 +56,8 @@ const getDocumentDescription = (docType: string): string => {
return 'Verified Biometric ID card';
case 'a':
return 'Verified mAadhaar QR code';
case 'kyc':
return "National ID, Driver's License etc.";
default:
return 'Unknown Document';
}
@@ -63,11 +71,61 @@ const getDocumentLogo = (docType: string): React.ReactNode => {
return <EPassportLogoRounded />;
case 'a':
return <AadhaarLogo />;
case 'kyc':
// same color as epassport_rounded.svg
return <PassportCameraScanIcon color={'#075985'} />;
default:
return null;
}
};
const getDocumentSecurityBadge = (docType: string): string | null => {
switch (docType) {
case 'p':
case 'i':
case 'a':
return 'Best security';
default:
return null;
}
};
type DocumentItemProps = {
docType: string;
onPress: () => void;
};
const DocumentItem: React.FC<DocumentItemProps> = ({ docType, onPress }) => {
const securityBadge = getDocumentSecurityBadge(docType);
const description = getDocumentDescription(docType);
return (
<XStack
style={styles.documentItem}
backgroundColor={white}
borderWidth={1}
borderColor={slate300}
elevation={4}
borderRadius={'$5'}
padding={'$3'}
pressStyle={{
transform: [{ scale: 0.97 }],
backgroundColor: slate100,
}}
onPress={onPress}
>
<XStack alignItems="center" gap={'$3'} flex={1}>
{securityBadge && <BodyText style={styles.securityBadgeText}>{securityBadge}</BodyText>}
<View style={styles.documentLogoContainer}>{getDocumentLogo(docType)}</View>
<YStack gap={'$1'}>
<BodyText style={styles.documentNameText}>{getDocumentName(docType)}</BodyText>
{description && <BodyText style={styles.documentDescriptionText}>{description}</BodyText>}
</YStack>
</XStack>
</XStack>
);
};
type IDSelectionScreenProps = {
countryCode: string;
documentTypes: string[];
@@ -112,30 +170,14 @@ const IDSelectionScreen: React.FC<IDSelectionScreenProps> = props => {
</YStack>
<YStack gap="$3">
{documentTypes.map((docType: string) => (
<XStack
key={docType}
backgroundColor={white}
borderWidth={1}
borderColor={slate300}
elevation={4}
borderRadius={'$5'}
padding={'$3'}
pressStyle={{
transform: [{ scale: 0.97 }],
backgroundColor: slate100,
}}
onPress={() => onSelectDocumentType(docType)}
>
<XStack alignItems="center" gap={'$3'} flex={1}>
{getDocumentLogo(docType)}
<YStack gap={'$1'}>
<BodyText style={styles.documentNameText}>{getDocumentName(docType)}</BodyText>
<BodyText style={styles.documentDescriptionText}>{getDocumentDescription(docType)}</BodyText>
</YStack>
</XStack>
</XStack>
<DocumentItem key={docType} docType={docType} onPress={() => onSelectDocumentType(docType)} />
))}
<BodyText style={styles.footerText}>Be sure your document is ready to scan</BodyText>
{FeatureFlags.KYC_ENABLED && (
<View style={styles.kycContainer}>
<DocumentItem docType="kyc" onPress={() => onSelectDocumentType('kyc')} />
</View>
)}
</YStack>
</YStack>
);
@@ -149,6 +191,33 @@ const styles = StyleSheet.create({
textAlign: 'center',
color: black,
},
documentLogoContainer: {
width: 48,
height: 48,
alignItems: 'center',
justifyContent: 'center',
},
documentItem: {
position: 'relative',
borderWidth: 1,
},
securityBadgeText: {
fontSize: 12,
fontFamily: dinot,
color: blue600,
backgroundColor: blue100,
borderRadius: 12,
borderWidth: 1,
borderColor: blue600,
paddingHorizontal: 8,
paddingVertical: 4,
position: 'absolute',
top: -20,
right: -20,
},
kycContainer: {
marginTop: 36,
},
documentNameText: {
fontSize: 24,
fontFamily: dinot,