SELF-1938 sumsub integration (#1661)

* Sumsub: Update keychain and types

* sumsub: ProvingMachine changes - WIP

* fix: remove duplicate identifier

* update proving machine

* Refactor && Continue onchain registration if user left the app

* fix register flow

* Add hooks to KycSuccessScreen

* Integrate KycVerifiedScreen (#1686)

* Integrate KycVerifiedScreen & Fix race conditions

* yarn lint

* lint

* lint

* add mock kyc

* fix disclose flow

* yarn lint

* Feat/add kyc home screen card design (#1708)

* feat: add new designs to the kycIdCard

* refactor: Update KycIdCard design to match IdCard styling

* feat: update document cards + dev document

* feat: update empty id card for new design

* feat: update pending document card design

* feat: update expired doc + unregistered doc cards from new design

* fix: unregisted id card button links to continue registration screen

* fix: logo design on document cards

* feat: add 6 different backgrounds for ids

deterministically shows 1 of 6 backgrounds for each document | fix: fixed document designs not displaying correctly.

* chore: trigger CI rebuild

* feat: Integrate PendingIdCard to Homescreen

* fix KycIdCard.tsx

---------

Co-authored-by: seshanthS <seshanth@protonmail.com>

* lint

* fix tests

* fix: cleanup only on unmount

* coderabbit comments

* fix: cleanup unused code

* fix: edge case for German Passports with D<< nationality code

* fix tests

* review comments

* review comments

* lint

* Hide duplicated cards in Homescreen

* remove console.log

* fix patch

* remove unused vars

* agent updates

* agent feedback

* abstract colors and formatting

* agent feedback

* Regenerate Sumsub patch-package patch

* fix: handle malformed kyc payload in card background selector

* re-add for clean up

---------

Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
Co-authored-by: Evi Nova <tranquil_flow@protonmail.com>
Co-authored-by: Justin Hernandez <justin.hernandez@self.xyz>
This commit is contained in:
Seshanth.S
2026-02-12 03:21:10 +05:30
committed by GitHub
parent 0bece5edd0
commit 886e02f53d
74 changed files with 3647 additions and 1606 deletions

View File

@@ -133,7 +133,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.232.0)
fastlane (2.232.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

View File

@@ -0,0 +1,14 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_4)">
<path d="M9.0025 6.9075H9C7.84434 6.9075 6.9075 7.84434 6.9075 9V9.0025C6.9075 10.1582 7.84434 11.095 9 11.095H9.0025C10.1582 11.095 11.095 10.1582 11.095 9.0025V9C11.095 7.84434 10.1582 6.9075 9.0025 6.9075Z" fill="white"/>
<g>
<path d="M4.895 7.0625C4.895 5.82 5.9025 4.8125 7.145 4.8125H11.49L16.3025 0H4.305L0 4.305V11.3875H4.895V7.06V7.0625Z" fill="white"/>
<path d="M13.105 6.595V10.7725C13.105 12.015 12.0975 13.0225 10.855 13.0225H6.6775L1.6975 18.0025H13.695L18 13.6975V6.5975H13.105V6.595Z" fill="white"/>
</g>
</g>
<defs>
<clipPath id="clip0_0_4">
<rect width="18" height="18" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 808 B

View File

@@ -0,0 +1,56 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 491 264.194" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" d="M0.5 132.07C0.5 307.642 245.5 307.496 245.5 132.07C245.5 -43.3565 490.5 -43.3565 490.5 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_2" d="M4.90808 132.07C4.90808 304.484 245.506 304.343 245.506 132.07C245.506 -40.2042 486.103 -40.2042 486.103 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_3" d="M9.46289 132.07C9.46289 301.326 245.653 301.191 245.653 132.07C245.653 -37.0518 481.843 -37.0518 481.843 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_4" d="M13.5996 132.07C13.5996 298.174 245.387 298.039 245.387 132.07C245.387 -33.8995 477.175 -33.8995 477.175 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_5" d="M18.1142 132.07C18.1142 295.016 245.5 294.886 245.5 132.07C245.5 -30.7472 472.885 -30.7472 472.885 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_6" d="M22.5166 132.07C22.5166 291.863 245.5 291.734 245.5 132.07C245.5 -27.595 468.483 -27.595 468.483 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_7" d="M27.0439 132.069C27.0439 288.705 245.619 288.575 245.619 132.069C245.619 -24.4371 464.195 -24.4371 464.195 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_8" d="M31.1807 132.069C31.1807 285.552 245.354 285.423 245.354 132.069C245.354 -21.2848 459.527 -21.2848 459.527 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_9" d="M35.7315 132.069C35.7315 282.394 245.502 282.271 245.502 132.069C245.502 -18.1324 455.273 -18.1324 455.273 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_10" d="M40.1348 132.069C40.1348 279.236 245.503 279.119 245.503 132.069C245.503 -14.9801 450.872 -14.9801 450.872 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_11" d="M44.54 132.069C44.54 276.084 245.501 275.966 245.501 132.069C245.501 -11.8278 446.461 -11.8278 446.461 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_12" d="M49.1064 132.069C49.1064 272.926 245.665 272.814 245.665 132.069C245.665 -8.67544 442.223 -8.67544 442.223 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_13" d="M53.2432 132.069C53.2432 269.773 245.399 269.662 245.399 132.069C245.399 -5.52311 437.555 -5.52311 437.555 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_14" d="M57.7471 132.07C57.7471 266.616 245.501 266.504 245.501 132.07C245.501 -2.36442 433.255 -2.36442 433.255 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_15" d="M62.1533 132.07C62.1533 263.463 245.505 263.352 245.505 132.07C245.505 0.787918 428.856 0.787918 428.856 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_16" d="M66.6875 132.07C66.6875 260.305 245.631 260.199 245.631 132.07C245.631 3.94025 424.575 3.94025 424.575 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_17" d="M70.8242 132.07C70.8242 257.147 245.366 257.047 245.366 132.07C245.366 7.09259 419.907 7.09259 419.907 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_18" d="M75.3604 132.07C75.3604 253.995 245.5 253.895 245.5 132.07C245.5 10.2449 415.639 10.2449 415.639 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_19" d="M79.7666 132.07C79.7666 250.836 245.503 250.743 245.503 132.07C245.503 13.3973 411.24 13.3973 411.24 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_20" d="M84.2686 132.07C84.2686 247.684 245.598 247.59 245.598 132.07C245.598 16.5496 406.927 16.5496 406.927 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_21" d="M88.4053 132.07C88.4053 244.526 245.332 244.438 245.332 132.07C245.332 19.7019 402.259 19.7019 402.259 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_22" d="M92.8867 132.069C92.8867 241.373 245.411 241.279 245.411 132.069C245.411 22.8597 397.936 22.8597 397.936 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_23" d="M97.376 132.069C97.376 238.215 245.498 238.127 245.498 132.069C245.498 26.012 393.62 26.012 393.62 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_24" d="M101.786 132.07C101.786 235.057 245.501 234.975 245.501 132.07C245.501 29.1643 389.215 29.1643 389.215 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_25" d="M106.331 132.07C106.331 231.905 245.643 231.822 245.643 132.07C245.643 32.3167 384.956 32.3167 384.956 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_26" d="M110.468 132.07C110.468 228.746 245.378 228.67 245.378 132.07C245.378 35.469 380.288 35.469 380.288 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_27" d="M114.993 132.069C114.993 225.594 245.501 225.518 245.501 132.069C245.501 38.6212 376.008 38.6212 376.008 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_28" d="M119.399 132.069C119.399 222.436 245.499 222.365 245.499 132.069C245.499 41.7735 371.599 41.7735 371.599 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_29" d="M123.912 132.069C123.912 219.283 245.61 219.207 245.61 132.069C245.61 44.9314 367.307 44.9314 367.307 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_30" d="M128.049 132.069C128.049 216.125 245.344 216.055 245.344 132.069C245.344 48.0837 362.639 48.0837 362.639 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_31" d="M132.606 132.069C132.606 212.967 245.499 212.902 245.499 132.069C245.499 51.2361 358.392 51.2361 358.392 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_32" d="M137.013 132.069C137.013 209.815 245.503 209.75 245.503 132.069C245.503 54.3884 353.994 54.3884 353.994 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_33" d="M141.423 132.069C141.423 206.656 245.506 206.598 245.506 132.069C245.506 57.5407 349.589 57.5407 349.589 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_34" d="M145.975 132.069C145.975 203.504 245.655 203.445 245.655 132.069C245.655 60.6931 345.336 60.6931 345.336 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_35" d="M150.111 132.069C150.111 200.346 245.39 200.293 245.39 132.069C245.39 63.8454 340.668 63.8454 340.668 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_36" d="M154.626 132.07C154.626 197.194 245.502 197.135 245.502 132.07C245.502 67.0041 336.378 67.0041 336.378 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_37" d="M159.033 132.07C159.033 194.036 245.501 193.983 245.501 132.07C245.501 70.1564 331.97 70.1564 331.97 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_38" d="M163.556 132.07C163.556 190.878 245.622 190.831 245.622 132.07C245.622 73.3088 327.688 73.3088 327.688 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_39" d="M167.692 132.07C167.692 187.726 245.356 187.679 245.356 132.07C245.356 76.4611 323.02 76.4611 323.02 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_40" d="M172.243 132.07C172.243 184.567 245.505 184.526 245.505 132.07C245.505 79.6134 318.766 79.6134 318.766 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_41" d="M176.646 132.07C176.646 181.415 245.5 181.374 245.5 132.07C245.5 82.7658 314.354 82.7658 314.354 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_42" d="M181.052 132.07C181.052 178.257 245.503 178.222 245.503 132.07C245.503 85.9181 309.955 85.9181 309.955 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_43" d="M185.618 132.07C185.618 175.105 245.667 175.069 245.667 132.07C245.667 89.0704 305.716 89.0704 305.716 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_44" d="M189.755 132.069C189.755 171.946 245.402 171.911 245.402 132.069C245.402 92.2282 301.049 92.2282 301.049 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_45" d="M194.263 132.069C194.263 168.788 245.503 168.758 245.503 132.069C245.503 95.3805 296.742 95.3805 296.742 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_46" d="M198.661 132.07C198.661 165.636 245.498 165.606 245.498 132.07C245.498 98.5329 292.335 98.5329 292.335 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_47" d="M203.199 132.069C203.199 162.477 245.634 162.454 245.634 132.069C245.634 101.685 288.068 101.685 288.068 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_48" d="M207.336 132.07C207.336 159.325 245.368 159.302 245.368 132.07C245.368 104.838 283.4 104.838 283.4 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_49" d="M211.872 132.07C211.872 156.167 245.502 156.149 245.502 132.07C245.502 107.99 279.132 107.99 279.132 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_50" d="M216.282 132.07C216.282 153.015 245.504 152.997 245.504 132.07C245.504 111.142 274.727 111.142 274.727 132.07" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_51" d="M220.78 132.069C220.78 149.856 245.6 149.838 245.6 132.069C245.6 114.3 270.42 114.3 270.42 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
<path id="Vector_52" d="M224.917 132.069C224.917 146.698 245.335 146.686 245.335 132.069C245.335 117.452 265.752 117.452 265.752 132.069" stroke="#6366F1" stroke-miterlimit="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_inactive)">
<path d="M16.0044 12.28H16C13.9455 12.28 12.28 13.9455 12.28 16V16.0044C12.28 18.0589 13.9455 19.7244 16 19.7244H16.0044C18.0589 19.7244 19.7244 18.0589 19.7244 16.0044V16C19.7244 13.9455 18.0589 12.28 16.0044 12.28Z" fill="#DC2626"/>
<path d="M8.70222 12.5556C8.70222 10.3467 10.4933 8.55556 12.7022 8.55556H20.4267L28.9822 0H7.65333L0 7.65333V20.2444H8.70222V12.5511V12.5556Z" fill="#DC2626"/>
<path d="M23.2978 11.7244V19.1511C23.2978 21.36 21.5067 23.1511 19.2978 23.1511H11.8711L3.01778 32.0044H24.3467L32 24.3511V11.7289H23.2978V11.7244Z" fill="#DC2626"/>
</g>
<defs>
<clipPath id="clip0_inactive">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -0,0 +1,12 @@
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_pending)">
<path d="M16.0044 12.28H16C13.9455 12.28 12.28 13.9455 12.28 16V16.0044C12.28 18.0589 13.9455 19.7244 16 19.7244H16.0044C18.0589 19.7244 19.7244 18.0589 19.7244 16.0044V16C19.7244 13.9455 18.0589 12.28 16.0044 12.28Z" fill="white"/>
<path d="M8.70222 12.5556C8.70222 10.3467 10.4933 8.55556 12.7022 8.55556H20.4267L28.9822 0H7.65333L0 7.65333V20.2444H8.70222V12.5511V12.5556Z" fill="white"/>
<path d="M23.2978 11.7244V19.1511C23.2978 21.36 21.5067 23.1511 19.2978 23.1511H11.8711L3.01778 32.0044H24.3467L32 24.3511V11.7289H23.2978V11.7244Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_pending">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 801 B

View File

@@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_0_742)">
<path d="M16.0044 12.28H16C13.9455 12.28 12.28 13.9455 12.28 16V16.0044C12.28 18.0589 13.9455 19.7244 16 19.7244H16.0044C18.0589 19.7244 19.7244 18.0589 19.7244 16.0044V16C19.7244 13.9455 18.0589 12.28 16.0044 12.28Z" fill="#D1D5DB"/>
<path d="M8.70222 12.5556C8.70222 10.3467 10.4933 8.55556 12.7022 8.55556H20.4267L28.9822 0H7.65333L0 7.65333V20.2444H8.70222V12.5511V12.5556Z" fill="#D1D5DB"/>
<path d="M23.2978 11.7244V19.1511C23.2978 21.36 21.5067 23.1511 19.2978 23.1511H11.8711L3.01778 32.0044H24.3467L32 24.3511V11.7289H23.2978V11.7244Z" fill="#D1D5DB"/>
</g>
<defs>
<clipPath id="clip0_0_742">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -0,0 +1,179 @@
// 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 { FC } from 'react';
import React from 'react';
import { Dimensions, Image, StyleSheet } from 'react-native';
import { Text, XStack, YStack } from 'tamagui';
import {
black,
gray400,
slate200,
slate300,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import SelfLogoUnverified from '@/assets/images/self_logo_unverified.svg';
import WavePatternBody from '@/assets/images/wave_pattern_body.png';
interface EmptyIdCardProps {
onRegisterPress: () => void;
}
/**
* Empty state card shown when user has no registered documents.
* Matches Figma design exactly:
* - White header with gray Self logo and "NO IDENTITY FOUND" text
* - Solid gray divider line
* - White body with gray wave pattern (from original unverified_human.png)
* - Pill-shaped white button with gray border
*/
const EmptyIdCard: FC<EmptyIdCardProps> = ({ onRegisterPress }) => {
const { width: screenWidth } = Dimensions.get('window');
// Card dimensions (matching IdCardLayout)
const cardWidth = screenWidth * 0.95 - 16;
const cardHeight = cardWidth * 0.635;
const borderRadius = 12;
// Figma exact dimensions (scaled from 353px reference width)
const scale = cardWidth / 353;
const headerHeight = 67 * scale;
const figmaPadding = 14 * scale;
const logoSize = 32 * scale;
const headerGap = 12 * scale;
// Font sizes from Figma
const fontSize = {
header: 20 * scale, // 20px in Figma
subtitle: 7 * scale, // 7px in Figma
button: 16 * scale, // 16px in Figma
};
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack
width={cardWidth}
height={cardHeight}
borderRadius={borderRadius}
overflow="hidden"
borderWidth={1}
borderColor="#E5E7EB"
backgroundColor={white}
marginBottom={8}
>
{/* Header Section - White background with bottom border */}
<YStack
height={headerHeight}
padding={figmaPadding}
backgroundColor={white}
justifyContent="center"
borderBottomWidth={2}
borderBottomColor={slate300}
>
{/* Content row */}
<XStack flex={1} alignItems="center">
{/* Logo + Text */}
<XStack alignItems="center" gap={headerGap} flex={1}>
{/* Self logo (gray) - exact Figma asset */}
<YStack
width={logoSize}
height={logoSize}
alignItems="center"
justifyContent="center"
>
<SelfLogoUnverified width={logoSize} height={logoSize} />
</YStack>
{/* Text container */}
<YStack gap={2}>
<Text
fontFamily={dinot}
fontSize={fontSize.header}
fontWeight="500"
color={black}
textTransform="uppercase"
lineHeight={fontSize.header * 1.1}
>
NO IDENTITY FOUND
</Text>
<Text
fontFamily={dinot}
fontSize={fontSize.subtitle}
color={gray400}
letterSpacing={0.7}
textTransform="uppercase"
>
NO IDENTITY FOUND
</Text>
</YStack>
</XStack>
</XStack>
</YStack>
{/* Body Section - White background with wave pattern */}
<YStack style={styles.body}>
{/* Wave pattern background - exact same as unverified_human.png */}
<Image
source={WavePatternBody}
style={styles.wavePattern}
resizeMode="cover"
/>
{/* Register button - pill-shaped with gray border */}
<YStack
position="absolute"
bottom={figmaPadding}
left={figmaPadding}
right={figmaPadding}
>
<YStack
backgroundColor={white}
borderWidth={1}
borderColor={slate200}
borderRadius={9999}
paddingVertical={8 * scale}
paddingHorizontal={20 * scale}
alignItems="center"
justifyContent="center"
onPress={onRegisterPress}
pressStyle={{ opacity: 0.7 }}
>
<Text
fontFamily={dinot}
fontSize={fontSize.button}
fontWeight="500"
color={black}
textAlign="center"
>
Register a new ID
</Text>
</YStack>
</YStack>
</YStack>
</YStack>
</YStack>
);
};
const styles = StyleSheet.create({
body: {
flex: 1,
position: 'relative',
overflow: 'hidden',
backgroundColor: 'white',
},
wavePattern: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
},
});
export default EmptyIdCard;

View File

@@ -0,0 +1,163 @@
// 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 { FC } from 'react';
import React from 'react';
import { Dimensions, Image, StyleSheet } from 'react-native';
import { Text, XStack, YStack } from 'tamagui';
import {
black,
gray400,
red600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import SelfLogoInactive from '@/assets/images/self_logo_inactive.svg';
import WavePatternBody from '@/assets/images/wave_pattern_body.png';
/**
* Expired state card shown when user's identity document has expired.
* Matches Figma design exactly:
* - White header with red Self logo and "EXPIRED ID" text
* - Red divider line
* - White body with gray wave pattern
* - Black "EXPIRED ID" badge in bottom right
*/
const ExpiredIdCard: FC = () => {
const { width: screenWidth } = Dimensions.get('window');
// Card dimensions (matching IdCardLayout)
const cardWidth = screenWidth * 0.95 - 16;
const cardHeight = cardWidth * 0.635;
const borderRadius = 12;
// Figma exact dimensions (scaled from 353px reference width)
const scale = cardWidth / 353;
const headerHeight = 67 * scale;
const figmaPadding = 14 * scale;
const logoSize = 32 * scale;
const headerGap = 12 * scale;
// Font sizes from Figma
const fontSize = {
header: 20 * scale, // 20px in Figma
subtitle: 7 * scale, // 7px in Figma
};
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack
width={cardWidth}
height={cardHeight}
borderRadius={borderRadius}
overflow="hidden"
borderWidth={1}
borderColor="#E5E7EB"
backgroundColor={white}
marginBottom={8}
shadowColor="#000"
shadowOffset={{ width: 0, height: 44 }}
shadowOpacity={0.25}
shadowRadius={68}
elevation={12}
>
{/* Header Section - White background with red divider */}
<YStack
height={headerHeight}
padding={figmaPadding}
backgroundColor={white}
justifyContent="center"
borderBottomWidth={2}
borderBottomColor={red600}
>
{/* Content row */}
<XStack flex={1} alignItems="center">
{/* Logo + Text */}
<XStack alignItems="center" gap={headerGap} flex={1}>
{/* Red Self logo (reuses inactive logo) */}
<YStack
width={logoSize}
height={logoSize}
alignItems="center"
justifyContent="center"
>
<SelfLogoInactive width={logoSize} height={logoSize} />
</YStack>
{/* Text container */}
<YStack gap={2}>
<Text
fontFamily={dinot}
fontSize={fontSize.header}
fontWeight="500"
color={red600}
textTransform="uppercase"
lineHeight={fontSize.header * 1.1}
>
EXPIRED ID
</Text>
<Text
fontFamily={dinot}
fontSize={fontSize.subtitle}
color={gray400}
letterSpacing={0.7}
textTransform="uppercase"
>
TIME TO REGISTER A VALID COPY
</Text>
</YStack>
</XStack>
</XStack>
</YStack>
{/* Body Section - White background with wave pattern */}
<YStack flex={1} position="relative" overflow="hidden">
{/* Wave pattern background */}
<Image
source={WavePatternBody}
style={styles.wavePattern}
resizeMode="cover"
/>
{/* Expired badge - bottom right (black background) */}
<YStack
position="absolute"
bottom={figmaPadding}
right={figmaPadding}
backgroundColor={black}
borderRadius={30}
paddingHorizontal={8 * scale}
paddingVertical={4 * scale}
>
<Text
fontFamily={dinot}
fontSize={10 * scale}
fontWeight="500"
color={white}
letterSpacing={0.6}
textTransform="uppercase"
>
EXPIRED ID
</Text>
</YStack>
</YStack>
</YStack>
</YStack>
);
};
const styles = StyleSheet.create({
wavePattern: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
},
});
export default ExpiredIdCard;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
// 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 { FC } from 'react';
import React from 'react';
import { Dimensions, Image, StyleSheet, View } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import { Text, XStack, YStack } from 'tamagui';
import { deserializeApplicantInfo } from '@selfxyz/common';
import { commonNames } from '@selfxyz/common/constants/countries';
import type { KycData } from '@selfxyz/common/utils/types';
import { RoundFlag } from '@selfxyz/mobile-sdk-alpha/components';
import { white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot, plexMono } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import CardBackgroundId1 from '@/assets/images/card_background_id1.png';
import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
interface KycIdCardProps {
idDocument: KycData;
selected: boolean;
hidden: boolean;
}
/**
* Maps KYC idType to display title.
* idType values from Sumsub: "drivers_licence", "passport", "NATIONAL ID", etc.
*/
function getKycDocTitle(idType: string): string {
const normalized = idType
.toLowerCase()
.replace(/[_\s]+/g, ' ')
.trim();
if (normalized.includes('driver')) return 'DRIVERS LICENSE';
if (normalized.includes('passport')) return 'PASSPORT';
if (normalized.includes('national')) return 'NATIONAL ID';
if (normalized.includes('residence')) return 'RESIDENCE PERMIT';
return 'ID CARD';
}
/**
* Derives a demonym-like adjective from the country code.
* Falls back to the country code if no mapping found.
*/
function getCountryAdjective(countryCode: string): string {
const name = commonNames[countryCode as keyof typeof commonNames];
if (!name) return countryCode;
const demonyms: Record<string, string> = {
USA: 'US',
GBR: 'UK',
CAN: 'CANADIAN',
AUS: 'AUSTRALIAN',
IND: 'INDIAN',
DEU: 'GERMAN',
FRA: 'FRENCH',
JPN: 'JAPANESE',
KOR: 'KOREAN',
BRA: 'BRAZILIAN',
MEX: 'MEXICAN',
ITA: 'ITALIAN',
ESP: 'SPANISH',
NLD: 'DUTCH',
PRT: 'PORTUGUESE',
CHN: 'CHINESE',
RUS: 'RUSSIAN',
KEN: 'KENYAN',
NGA: 'NIGERIAN',
ZAF: 'SOUTH AFRICAN',
SGP: 'SINGAPOREAN',
MYS: 'MALAYSIAN',
PHL: 'PHILIPPINE',
IDN: 'INDONESIAN',
THA: 'THAI',
VNM: 'VIETNAMESE',
ARE: 'UAE',
SAU: 'SAUDI',
EGY: 'EGYPTIAN',
TUR: 'TURKISH',
POL: 'POLISH',
SWE: 'SWEDISH',
NOR: 'NORWEGIAN',
DNK: 'DANISH',
FIN: 'FINNISH',
CHE: 'SWISS',
AUT: 'AUSTRIAN',
BEL: 'BELGIAN',
IRL: 'IRISH',
NZL: 'NEW ZEALAND',
ARG: 'ARGENTINE',
COL: 'COLOMBIAN',
PER: 'PERUVIAN',
CHL: 'CHILEAN',
};
return demonyms[countryCode] || name.toUpperCase();
}
/**
* KYC document card - matches IdCard design exactly but shows "STANDARD" badge.
* Used for documents verified through Sumsub KYC flow (drivers license, etc.).
*/
const KycIdCard: FC<KycIdCardProps> = ({
idDocument,
selected,
hidden: _hidden,
}) => {
// Extract KYC fields from serialized applicant info
const applicantInfo = deserializeApplicantInfo(
idDocument.serializedApplicantInfo,
);
const country = applicantInfo.country || '';
const idType = applicantInfo.idType || '';
const idNumber = applicantInfo.idNumber || '';
const docTitle = getKycDocTitle(idType);
const countryAdj = getCountryAdjective(country);
const { width: screenWidth } = Dimensions.get('window');
// Card dimensions (matching IdCard: 353x224 for expanded, 353x67 for header only)
const cardWidth = screenWidth * 0.95 - 16;
const cardHeight = selected ? cardWidth * 0.635 : cardWidth * 0.19;
const borderRadius = 12;
const padding = cardWidth * 0.04;
// Figma exact dimensions (scaled from 353px reference width)
const scale = cardWidth / 353;
const headerHeight = 67 * scale;
const figmaPadding = 14 * scale;
const logoCircleSize = 32 * scale;
const headerGap = 12 * scale;
// Get truncated ID for display (e.g., "0xD123..345")
const getTruncatedId = (): string => {
if (idNumber && idNumber.length > 10) {
return `0x${idNumber.slice(0, 4)}..${idNumber.slice(-3)}`;
}
return idNumber ? `0x${idNumber}` : '';
};
const truncatedId = getTruncatedId();
// Header title (e.g., "DRIVERS LICENSE")
const headerTitle = docTitle;
// Subtitle text (e.g., "VERIFIED US DRIVERS LICENSE")
const subtitleText = `VERIFIED ${countryAdj} ${docTitle}`;
// Bottom label (e.g., "US DRIVERS LICENSE")
const bottomLabel = `${countryAdj} ${docTitle}`;
// Font sizes (matching IdCard exactly)
const fontSize = {
header: cardWidth * 0.057, // 20px at 353px width
subtitle: cardWidth * 0.02, // 7px at 353px width
badge: cardWidth * 0.028, // 10px at 353px width
bottomLabel: cardWidth * 0.043, // 15px at 353px width
bottomId: cardWidth * 0.028, // 10px at 353px width
};
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack
width={cardWidth}
height={cardHeight}
borderRadius={borderRadius}
overflow="hidden"
backgroundColor="#000000"
shadowColor="#000"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.25}
shadowRadius={14}
elevation={8}
marginBottom={8}
alignItems="stretch"
>
{/* Header Section - Dark gradient (same as IdCard) */}
<View style={{ width: cardWidth * 1.05, height: headerHeight }}>
<LinearGradient
colors={['#000000', '#343434']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={{
flex: 1,
paddingHorizontal: figmaPadding,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
{/* Logo + Text */}
<XStack alignItems="center" gap={headerGap}>
{/* Country flag */}
<RoundFlag countryCode={country} size={logoCircleSize} />
{/* Text container */}
<YStack gap={2}>
<Text
fontFamily={dinot}
fontSize={fontSize.header}
fontWeight="500"
color={white}
textTransform="uppercase"
lineHeight={fontSize.header * 1.1}
>
{headerTitle}
</Text>
<Text
fontFamily={dinot}
fontSize={fontSize.subtitle}
color="#9193A2"
letterSpacing={0.7}
textTransform="uppercase"
>
{subtitleText}
</Text>
</YStack>
</XStack>
{/* Self logo on right */}
<SelfLogoPending
width={logoCircleSize * 0.56 * 5}
height={logoCircleSize}
/>
</LinearGradient>
</View>
{/* Body Section - Colorful wave pattern (same as IdCard real documents) */}
{selected && (
<YStack flex={1} position="relative" overflow="hidden">
{/* Pre-composited background image (colorful gradient + chrome wave) */}
<Image
source={CardBackgroundId1}
style={styles.backgroundImage}
resizeMode="cover"
/>
{/* Bottom content: Left text + Right badge */}
<XStack
position="absolute"
bottom={padding}
left={padding}
right={padding}
justifyContent="space-between"
alignItems="flex-end"
>
{/* Bottom Left: ID + Document Label */}
<YStack gap={4}>
{truncatedId ? (
<Text
fontFamily={plexMono}
fontSize={fontSize.bottomId}
color={white}
>
{truncatedId}
</Text>
) : null}
<Text
fontFamily={dinot}
fontSize={fontSize.bottomLabel}
fontWeight="500"
color={white}
textTransform="uppercase"
letterSpacing={0.6}
>
{bottomLabel}
</Text>
</YStack>
{/* STANDARD Badge - KYC documents always show STANDARD */}
<YStack
backgroundColor="rgba(0, 0, 0, 0.5)"
borderRadius={30}
paddingHorizontal={padding * 0.6}
paddingVertical={padding * 0.3}
>
<Text
fontFamily={dinot}
fontSize={fontSize.badge}
fontWeight="500"
color={white}
textTransform="uppercase"
letterSpacing={0.6}
>
STANDARD
</Text>
</YStack>
</XStack>
</YStack>
)}
</YStack>
</YStack>
);
};
const styles = StyleSheet.create({
backgroundImage: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
},
});
export default KycIdCard;

View File

@@ -0,0 +1,179 @@
// 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 { FC } from 'react';
import React from 'react';
import { Dimensions, Image, StyleSheet } from 'react-native';
import { Text, XStack, YStack } from 'tamagui';
import {
amber50,
amber200,
amber500,
amber700,
black,
gray400,
yellow50,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import SelfLogoPending from '@/assets/images/self_logo_pending.svg';
import WavePatternPending from '@/assets/images/wave_pattern_pending.png';
interface PendingIdCardProps {
onClick?: () => void;
}
/**
* Pending state card shown when user has submitted identity for KYC verification.
* Matches Figma design exactly:
* - Amber-50 tinted header and body
* - Orange divider line
* - Orange logo circle with white Self logo
* - "IDENTITY UNDER REVIEW" title
* - Yellow "Pending" badge in bottom right
*/
const PendingIdCard: FC<PendingIdCardProps> = ({ onClick }) => {
const { width: screenWidth } = Dimensions.get('window');
// Card dimensions (matching IdCardLayout)
const cardWidth = screenWidth * 0.95 - 16;
const cardHeight = cardWidth * 0.635;
const borderRadius = 12;
// Figma exact dimensions (scaled from 353px reference width)
const scale = cardWidth / 353;
const headerHeight = 67 * scale;
const figmaPadding = 14 * scale;
const logoSize = 32 * scale;
const headerGap = 12 * scale;
// Font sizes from Figma
const fontSize = {
header: 20 * scale, // 20px in Figma
subtitle: 7 * scale, // 7px in Figma
};
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack
width={cardWidth}
height={cardHeight}
borderRadius={borderRadius}
overflow="hidden"
borderWidth={1}
borderColor="#E5E7EB"
backgroundColor={yellow50}
marginBottom={8}
shadowColor={amber500}
shadowOffset={{ width: 0, height: 14 }}
shadowOpacity={0.25}
shadowRadius={28}
elevation={12}
onPress={onClick}
pressStyle={onClick ? { opacity: 0.7 } : undefined}
>
{/* Header Section */}
<YStack
height={headerHeight}
padding={figmaPadding}
backgroundColor={amber50}
justifyContent="center"
borderBottomWidth={2}
borderBottomColor={amber500}
>
{/* Content row */}
<XStack flex={1} alignItems="center">
{/* Logo + Text */}
<XStack alignItems="center" gap={headerGap} flex={1}>
{/* Orange circle with white Self logo */}
<YStack
width={logoSize}
height={logoSize}
borderRadius={logoSize / 2}
backgroundColor={amber500}
alignItems="center"
justifyContent="center"
overflow="hidden"
>
<SelfLogoPending
width={logoSize * 0.56}
height={logoSize * 0.56}
/>
</YStack>
{/* Text container */}
<YStack gap={2}>
<Text
fontFamily={dinot}
fontSize={fontSize.header}
fontWeight="500"
color={black}
textTransform="uppercase"
lineHeight={fontSize.header * 1.1}
>
IDENTITY UNDER REVIEW
</Text>
<Text
fontFamily={dinot}
fontSize={fontSize.subtitle}
color={gray400}
letterSpacing={0.7}
textTransform="uppercase"
>
NO IDENTITY FOUND
</Text>
</YStack>
</XStack>
</XStack>
</YStack>
{/* Body Section */}
<YStack flex={1} position="relative" overflow="hidden">
{/* Wave pattern background */}
<Image
source={WavePatternPending}
style={styles.wavePattern}
resizeMode="cover"
/>
{/* Pending badge - bottom right */}
<YStack
position="absolute"
bottom={figmaPadding}
right={figmaPadding}
backgroundColor={amber200}
borderRadius={30}
paddingHorizontal={8 * scale}
paddingVertical={4 * scale}
>
<Text
fontFamily={dinot}
fontSize={10 * scale}
fontWeight="500"
color={amber700}
letterSpacing={0.6}
textTransform="uppercase"
>
Pending
</Text>
</YStack>
</YStack>
</YStack>
</YStack>
);
};
const styles = StyleSheet.create({
wavePattern: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
},
});
export default PendingIdCard;

View File

@@ -0,0 +1,183 @@
// 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 { FC } from 'react';
import React from 'react';
import { Dimensions, Image, StyleSheet } from 'react-native';
import { Text, XStack, YStack } from 'tamagui';
import {
gray400,
red600,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import SelfLogoInactive from '@/assets/images/self_logo_inactive.svg';
import WavePatternBody from '@/assets/images/wave_pattern_body.png';
interface UnregisteredIdCardProps {
onRegisterPress: () => void;
}
/**
* Unregistered state card shown when user has a scanned document that
* hasn't been registered on-chain yet.
* Matches design pattern:
* - White header with red Self logo and "UNREGISTERED ID" text
* - Red divider line
* - White body with gray wave pattern
* - Full-width red pill button "Complete Registration"
*/
const UnregisteredIdCard: FC<UnregisteredIdCardProps> = ({
onRegisterPress,
}) => {
const { width: screenWidth } = Dimensions.get('window');
// Card dimensions (matching IdCardLayout)
const cardWidth = screenWidth * 0.95 - 16;
const cardHeight = cardWidth * 0.635;
const borderRadius = 12;
// Figma exact dimensions (scaled from 353px reference width)
const scale = cardWidth / 353;
const headerHeight = 67 * scale;
const figmaPadding = 14 * scale;
const logoSize = 32 * scale;
const headerGap = 12 * scale;
// Font sizes from Figma
const fontSize = {
header: 20 * scale, // 20px in Figma
subtitle: 7 * scale, // 7px in Figma
button: 16 * scale, // 16px in Figma
};
return (
<YStack width="100%" alignItems="center" justifyContent="center">
<YStack
width={cardWidth}
height={cardHeight}
borderRadius={borderRadius}
overflow="hidden"
borderWidth={1}
borderColor="#E5E7EB"
backgroundColor={white}
marginBottom={8}
shadowColor="#000"
shadowOffset={{ width: 0, height: 44 }}
shadowOpacity={0.25}
shadowRadius={68}
elevation={12}
>
{/* Header Section - White background with red divider */}
<YStack
height={headerHeight}
padding={figmaPadding}
backgroundColor={white}
justifyContent="center"
borderBottomWidth={2}
borderBottomColor={red600}
>
{/* Content row */}
<XStack flex={1} alignItems="center">
{/* Logo + Text */}
<XStack alignItems="center" gap={headerGap} flex={1}>
{/* Red Self logo */}
<YStack
width={logoSize}
height={logoSize}
alignItems="center"
justifyContent="center"
>
<SelfLogoInactive width={logoSize} height={logoSize} />
</YStack>
{/* Text container */}
<YStack gap={2}>
<Text
fontFamily={dinot}
fontSize={fontSize.header}
fontWeight="500"
color={red600}
textTransform="uppercase"
lineHeight={fontSize.header * 1.1}
>
UNREGISTERED ID
</Text>
<Text
fontFamily={dinot}
fontSize={fontSize.subtitle}
color={gray400}
letterSpacing={0.7}
textTransform="uppercase"
>
DOCUMENT NEEDS TO FINISH REGISTRATION
</Text>
</YStack>
</XStack>
</XStack>
</YStack>
{/* Body Section - White background with wave pattern */}
<YStack style={styles.body}>
{/* Wave pattern background */}
<Image
source={WavePatternBody}
style={styles.wavePattern}
resizeMode="cover"
/>
{/* Register button - full-width red pill */}
<YStack
position="absolute"
bottom={figmaPadding}
left={figmaPadding}
right={figmaPadding}
>
<YStack
backgroundColor={red600}
borderRadius={9999}
paddingVertical={8 * scale}
paddingHorizontal={20 * scale}
alignItems="center"
justifyContent="center"
onPress={onRegisterPress}
pressStyle={{ opacity: 0.7 }}
>
<Text
fontFamily={dinot}
fontSize={fontSize.button}
fontWeight="500"
color={white}
textAlign="center"
>
Complete Registration
</Text>
</YStack>
</YStack>
</YStack>
</YStack>
</YStack>
);
};
const styles = StyleSheet.create({
body: {
flex: 1,
position: 'relative',
overflow: 'hidden',
backgroundColor: 'white',
},
wavePattern: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
},
});
export default UnregisteredIdCard;

View File

@@ -0,0 +1,39 @@
// 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 { AadhaarData } from '@selfxyz/common';
import type { PassportData } from '@selfxyz/common/types/passport';
import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
export type SecurityLevel = 'HI-SECURITY' | 'LOW-SECURITY' | 'STANDARD';
/**
* Determines security badge based on document type and NFC presence.
* - KYC documents -> STANDARD (always)
* - Aadhaar -> LOW-SECURITY (always, no NFC)
* - MRZ documents (passport, ID card) -> HI-SECURITY if NFC, LOW-SECURITY otherwise
*
* NFC presence is determined by checking if dg2Hash exists and is not empty.
* dg2Hash contains the facial image data which is only available via NFC read.
*/
export function getSecurityLevel(
document: PassportData | AadhaarData,
): SecurityLevel {
if (isAadhaarDocument(document)) {
return 'LOW-SECURITY'; // Aadhaar never has NFC
}
if (isMRZDocument(document)) {
// Check if document has NFC data (dg2Hash presence indicates NFC read)
// dg2Hash contains facial image data which requires NFC to extract
const hasNfc = Boolean(
document.dg2Hash &&
Array.isArray(document.dg2Hash) &&
document.dg2Hash.length > 0,
);
return hasNfc ? 'HI-SECURITY' : 'LOW-SECURITY';
}
return 'LOW-SECURITY'; // Fallback
}

View File

@@ -0,0 +1,101 @@
// 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 { useCallback, useEffect, useRef } from 'react';
import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
import { navigationRef } from '@/navigation';
import { usePendingKycStore } from '@/stores/pendingKycStore';
/**
* Hook to recover pending KYC verifications on app restart.
*
* This hook runs on app startup and:
* 1. Checks for any pending verifications in the store
* 2. For each non-expired pending/processing verification, reconnects to websocket
* 3. Subscribes to the userId to receive any cached results
* 4. Updates verification status based on server response
* 5. Initiates proving machine after document storage (handled in useSumsubWebSocket)
*
* NOTE: This requires the TEE server to cache completed verification results
* so they can be retrieved when the app reopens.
*/
export function usePendingKycRecovery() {
const { pendingVerifications, removeExpiredVerifications } =
usePendingKycStore();
const hasAttemptedRecoveryRef = useRef<Set<string>>(new Set());
const handleSuccess = useCallback(() => {
console.log('[PendingKycRecovery] Successfully recovered verification');
}, []);
const handleError = useCallback((error: string) => {
console.error('[PendingKycRecovery] Error:', error);
}, []);
const handleVerificationFailed = useCallback((reason: string) => {
console.log('[PendingKycRecovery] Verification failed:', reason);
}, []);
const { subscribe, unsubscribeAll } = useSumsubWebSocket({
skipAddPending: true,
onSuccess: handleSuccess,
onError: handleError,
onVerificationFailed: handleVerificationFailed,
});
// Clean up expired verifications once on mount
useEffect(() => {
removeExpiredVerifications();
return () => unsubscribeAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run once on mount
useEffect(() => {
console.log(
'[PendingKycRecovery] Already attempted userIds:',
Array.from(hasAttemptedRecoveryRef.current),
);
const processingWithDocument = pendingVerifications.find(
v =>
v.status === 'processing' &&
v.documentId &&
v.timeoutAt > Date.now() &&
!hasAttemptedRecoveryRef.current.has(v.userId),
);
if (processingWithDocument) {
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
console.log(
'[PendingKycRecovery] Resuming processing verification, navigating to KYCVerified:',
processingWithDocument.userId,
);
if (navigationRef.isReady()) {
navigationRef.navigate('KYCVerified', {
documentId: processingWithDocument.documentId,
});
}
return;
}
const firstPending = pendingVerifications.find(
v =>
v.status === 'pending' &&
v.timeoutAt > Date.now() &&
!hasAttemptedRecoveryRef.current.has(v.userId),
);
if (firstPending) {
hasAttemptedRecoveryRef.current.add(firstPending.userId);
console.log(
'[PendingKycRecovery] Recovering pending verification:',
firstPending.userId,
);
subscribe(firstPending.userId);
}
}, [pendingVerifications, subscribe, unsubscribeAll]);
}

View File

@@ -0,0 +1,201 @@
// 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 { useCallback, useRef } from 'react';
import { io, type Socket } from 'socket.io-client';
import { SUMSUB_TEE_URL } from '@env';
import { deserializeApplicantInfo } from '@selfxyz/common';
import type { DocumentType, KycData } from '@selfxyz/common/utils/types';
import type { SumsubApplicantInfoSerialized } from '@/integrations/sumsub/types';
import { navigationRef } from '@/navigation';
import { storeDocumentWithDeduplication } from '@/providers/passportDataProvider';
import { usePendingKycStore } from '@/stores/pendingKycStore';
interface UseSumsubWebSocketOptions {
onSuccess?: () => void;
onError?: (error: string) => void;
onVerificationFailed?: (reason: string) => void;
skipAddPending?: boolean;
}
/**
* Shared hook for Sumsub websocket subscription logic.
* Handles connecting to the TEE service, subscribing to a userId,
* and processing verification results.
*/
export function useSumsubWebSocket(options: UseSumsubWebSocketOptions = {}) {
const {
onSuccess,
onError,
onVerificationFailed,
skipAddPending = false,
} = options;
const addPendingVerification = usePendingKycStore(
state => state.addPendingVerification,
);
const updateVerificationStatus = usePendingKycStore(
state => state.updateVerificationStatus,
);
const getPendingVerification = usePendingKycStore(
state => state.getPendingVerification,
);
const socketsRef = useRef<Map<string, Socket>>(new Map());
const subscribedUserIdsRef = useRef<Set<string>>(new Set());
const subscribe = useCallback(
(userId: string) => {
if (subscribedUserIdsRef.current.has(userId)) {
console.log('[SumsubWebSocket] Already subscribed to userId:', userId);
return;
}
const existingVerification = getPendingVerification(userId);
const isProcessing = existingVerification?.status === 'processing';
// Don't retry 'processing' verifications as the proving machine is reading to be triggered.
if (isProcessing) {
console.log(
'[SumsubWebSocket] Verification in processing state, skipping for userId:',
userId,
);
return;
}
if (!skipAddPending) {
console.log(
'[SumsubWebSocket] Adding pending verification for userId:',
userId,
);
addPendingVerification(userId);
}
subscribedUserIdsRef.current.add(userId);
console.log('[SumsubWebSocket] Connecting to WebSocket:', SUMSUB_TEE_URL);
const socket = io(SUMSUB_TEE_URL, {
transports: ['websocket', 'polling'],
});
socketsRef.current.set(userId, socket);
socket.on('connect', () => {
console.log(
'[SumsubWebSocket] Connected, subscribing to user:',
userId,
);
socket.emit('subscribe', userId);
});
socket.on('success', async (data: SumsubApplicantInfoSerialized) => {
console.log(
'[SumsubWebSocket] Received applicant info for userId:',
userId,
);
try {
const applicantInfoDeserialized = deserializeApplicantInfo(
data.applicantInfo,
);
const kycData: KycData = {
documentType: applicantInfoDeserialized.idType as DocumentType,
documentCategory: 'kyc',
mock: applicantInfoDeserialized.idNumber.startsWith('Mock'),
signature: data.signature,
pubkey: data.pubkey,
serializedApplicantInfo: data.applicantInfo,
};
const documentId = await storeDocumentWithDeduplication(kycData);
console.log(
'[SumsubWebSocket] KYC data stored successfully, documentId:',
documentId,
);
updateVerificationStatus(userId, 'processing', undefined, documentId);
if (navigationRef.isReady()) {
navigationRef.navigate('KYCVerified', { documentId });
}
onSuccess?.();
} catch (err) {
console.error('[SumsubWebSocket] Failed to store KYC data:', err);
updateVerificationStatus(
userId,
'failed',
'Failed to store KYC data',
);
onError?.('Failed to store KYC data');
}
socket.disconnect();
socketsRef.current.delete(userId);
subscribedUserIdsRef.current.delete(userId);
});
socket.on('verification_failed', (reason: string) => {
console.log('[SumsubWebSocket] Verification failed:', reason);
updateVerificationStatus(userId, 'failed', reason);
onVerificationFailed?.(reason);
socket.disconnect();
socketsRef.current.delete(userId);
subscribedUserIdsRef.current.delete(userId);
});
socket.on('error', (errorMessage: string) => {
console.error('[SumsubWebSocket] Socket error:', errorMessage);
updateVerificationStatus(userId, 'failed', errorMessage);
onError?.(errorMessage);
socket.disconnect();
socketsRef.current.delete(userId);
subscribedUserIdsRef.current.delete(userId);
});
socket.on('disconnect', () => {
console.log('[SumsubWebSocket] Disconnected for userId:', userId);
});
},
[
addPendingVerification,
updateVerificationStatus,
getPendingVerification,
onSuccess,
onError,
onVerificationFailed,
skipAddPending,
],
);
const unsubscribe = useCallback((userId: string) => {
const socket = socketsRef.current.get(userId);
if (socket) {
socket.disconnect();
socketsRef.current.delete(userId);
}
subscribedUserIdsRef.current.delete(userId);
}, []);
const unsubscribeAll = useCallback(() => {
socketsRef.current.forEach(socket => {
socket.disconnect();
});
socketsRef.current.clear();
subscribedUserIdsRef.current.clear();
}, []);
const isSubscribed = useCallback((userId: string) => {
return subscribedUserIdsRef.current.has(userId);
}, []);
return {
subscribe,
unsubscribe,
unsubscribeAll,
isSubscribed,
};
}

View File

@@ -35,26 +35,17 @@ export interface SumsubConfig {
const FETCH_TIMEOUT_MS = 30000; // 30 seconds
export const fetchAccessToken = async (
phoneNumber?: string,
): Promise<AccessTokenResponse> => {
export const fetchAccessToken = async (): 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(requestBody),
signal: controller.signal,
});

View File

@@ -32,6 +32,12 @@ export interface SumsubApplicantInfo {
type: string;
}
export interface SumsubApplicantInfoSerialized {
signature: string;
applicantInfo: string;
pubkey: Array<string>;
}
export interface SumsubResult {
success: boolean;
status: string;

View File

@@ -13,7 +13,6 @@ 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: {
@@ -82,13 +81,6 @@ const devScreens = {
title: 'Dev Loading Screen',
} as NativeStackNavigationOptions,
},
SumsubTest: {
screen: SumsubTestScreen,
options: {
...devHeaderOptions,
title: 'Sumsub Test',
} as NativeStackNavigationOptions,
},
};
export default devScreens;

View File

@@ -16,6 +16,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { DefaultNavBar } from '@/components/navbar';
import { usePendingKycRecovery } from '@/hooks/usePendingKycRecovery';
import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import AppLayout from '@/layouts/AppLayout';
import accountScreens from '@/navigation/account';
@@ -82,6 +83,7 @@ const Navigation = createStaticNavigation(AppNavigation);
const NavigationWithTracking = () => {
useRecoveryPrompts();
usePendingKycRecovery();
const selfClient = useSelfClient();
const trackScreen = () => {
const currentRoute = navigationRef.getCurrentRoute();

View File

@@ -159,6 +159,7 @@ export type OnboardingRoutesParamList = {
| {
status?: string;
userId?: string;
documentId?: string;
}
| undefined;
KycFailure: {

View File

@@ -44,6 +44,7 @@ import type { PropsWithChildren } from 'react';
import React, { createContext, useCallback, useContext, useMemo } from 'react';
import Keychain from 'react-native-keychain';
import { deserializeApplicantInfo } from '@selfxyz/common';
import type {
PublicKeyDetailsECDSA,
PublicKeyDetailsRSA,
@@ -51,7 +52,6 @@ import type {
import {
brutforceSignatureAlgorithmDsc,
calculateContentHash,
inferDocumentCategory,
} from '@selfxyz/common/utils';
import { parseCertificateSimple } from '@selfxyz/common/utils/certificate_parsing/parseCertificateSimple';
import type {
@@ -61,7 +61,7 @@ import type {
IDDocument,
PassportData,
} from '@selfxyz/common/utils/types';
import { isMRZDocument } from '@selfxyz/common/utils/types';
import { isKycDocument, isMRZDocument } from '@selfxyz/common/utils/types';
import type { DocumentsAdapter, SelfClient } from '@selfxyz/mobile-sdk-alpha';
import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
@@ -835,7 +835,7 @@ export async function setSelectedDocument(documentId: string): Promise<void> {
async function storeDocumentDirectlyToKeychain(
contentHash: string,
passportData: PassportData | AadhaarData,
passportData: IDDocument,
): Promise<void> {
const { setOptions } = await createKeychainOptions({ requireAuth: false });
await Keychain.setGenericPassword(contentHash, JSON.stringify(passportData), {
@@ -847,11 +847,10 @@ async function storeDocumentDirectlyToKeychain(
// Duplicate funciton. prefer one on mobile sdk
export async function storeDocumentWithDeduplication(
passportData: PassportData | AadhaarData,
passportData: IDDocument,
): Promise<string> {
const contentHash = calculateContentHash(passportData);
const catalog = await loadDocumentCatalogDirectlyFromKeychain();
// Check for existing document with same content
const existing = catalog.documents.find(d => d.id === contentHash);
if (existing) {
@@ -861,7 +860,6 @@ export async function storeDocumentWithDeduplication(
// Update the stored document with potentially new metadata
await storeDocumentDirectlyToKeychain(contentHash, passportData);
// Update selected document to this one
catalog.selectedDocumentId = contentHash;
await saveDocumentCatalogDirectlyToKeychain(catalog);
@@ -872,19 +870,36 @@ export async function storeDocumentWithDeduplication(
await storeDocumentDirectlyToKeychain(contentHash, passportData);
// Add to catalog
let dataField: string;
if (isMRZDocument(passportData)) {
dataField = passportData.mrz;
} else if (isKycDocument(passportData)) {
dataField = passportData.serializedApplicantInfo;
} else {
dataField = (passportData as AadhaarData).qrData || '';
}
const metadata: DocumentMetadata = {
id: contentHash,
documentType: passportData.documentType,
documentCategory:
passportData.documentCategory ||
inferDocumentCategory(
(passportData as PassportData | AadhaarData).documentType,
),
data: isMRZDocument(passportData)
? (passportData as PassportData).mrz
: (passportData as AadhaarData).qrData || '', // Store MRZ for passports/IDs, relevant data for aadhaar
documentCategory: passportData.documentCategory,
data: dataField,
mock: passportData.mock || false,
isRegistered: false,
...(isKycDocument(passportData)
? (() => {
try {
const parsedApplicantInfo = deserializeApplicantInfo(
passportData.serializedApplicantInfo,
);
return parsedApplicantInfo.idType
? { idType: parsedApplicantInfo.idType }
: {};
} catch {
return {};
}
})()
: {}),
};
catalog.documents.push(metadata);
@@ -894,9 +909,7 @@ export async function storeDocumentWithDeduplication(
return contentHash;
}
// Duplicate function. prefer one in mobile sdk
export async function storePassportData(
passportData: PassportData | AadhaarData,
) {
export async function storePassportData(passportData: IDDocument) {
await storeDocumentWithDeduplication(passportData);
}

View File

@@ -202,12 +202,8 @@ export function getAlternativeCSCA(
useProtocolStore: SelfClient['useProtocolStore'],
docCategory: DocumentCategory,
): AlternativeCSCA {
if (docCategory === 'kyc') {
//TODO
throw new Error('KYC is not supported yet');
}
if (docCategory === 'aadhaar') {
const publicKeys = useProtocolStore.getState().aadhaar.public_keys;
if (docCategory === 'aadhaar' || docCategory === 'kyc') {
const publicKeys = useProtocolStore.getState()[docCategory].public_keys;
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
return publicKeys
? Object.fromEntries(

View File

@@ -102,7 +102,10 @@ const LoadingScreen: React.FC<LoadingScreenProps> = ({ route }) => {
const initializeProving = async () => {
try {
const selectedDocument = await loadSelectedDocument(selfClient);
if (selectedDocument?.data?.documentCategory === 'aadhaar') {
if (
selectedDocument?.data?.documentCategory === 'aadhaar' ||
selectedDocument?.data?.documentCategory === 'kyc'
) {
await init(selfClient, 'register', true);
} else {
await init(selfClient, 'dsc', true);

View File

@@ -49,6 +49,7 @@ const DevSettingsScreen: React.FC = () => {
handleClearPointEventsPress,
handleResetBackupStatePress,
handleClearBackupEventsPress,
handleClearPendingVerificationsPress,
} = useDangerZoneActions();
return (
@@ -107,6 +108,7 @@ const DevSettingsScreen: React.FC = () => {
onClearPointEvents={handleClearPointEventsPress}
onResetBackupState={handleResetBackupStatePress}
onClearBackupEvents={handleClearBackupEventsPress}
onClearPendingKyc={handleClearPendingVerificationsPress}
/>
</YStack>
</ScrollView>

View File

@@ -1,686 +0,0 @@
// 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;

View File

@@ -6,6 +6,7 @@ import { Alert } from 'react-native';
import { unsafe_clearSecrets } from '@/providers/authProvider';
import { usePassport } from '@/providers/passportDataProvider';
import { usePendingKycStore } from '@/stores/pendingKycStore';
import { usePointEventStore } from '@/stores/pointEventStore';
import { useSettingStore } from '@/stores/settingStore';
@@ -13,6 +14,10 @@ export const useDangerZoneActions = () => {
const { clearDocumentCatalogForMigrationTesting } = usePassport();
const clearPointEvents = usePointEventStore(state => state.clearEvents);
const { resetBackupForPoints } = useSettingStore();
const { pendingVerifications } = usePendingKycStore();
const clearPendingVerifications = usePendingKycStore(
state => state.clearAllPendingVerifications,
);
const handleClearSecretsPress = () => {
Alert.alert(
@@ -187,11 +192,37 @@ export const useDangerZoneActions = () => {
);
};
const handleClearPendingVerificationsPress = () => {
Alert.alert(
'Clear Pending KYC Verifications',
`Are you sure you want to clear all pending KYC verifications?\n\nCurrently ${pendingVerifications.length} verification(s) pending.`,
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Clear',
style: 'destructive',
onPress: () => {
clearPendingVerifications();
Alert.alert(
'Success',
'Pending KYC verifications cleared successfully.',
[{ text: 'OK' }],
);
},
},
],
);
};
return {
handleClearSecretsPress,
handleClearDocumentCatalogPress,
handleClearPointEventsPress,
handleResetBackupStatePress,
handleClearBackupEventsPress,
handleClearPendingVerificationsPress,
};
};

View File

@@ -22,6 +22,7 @@ interface DangerZoneSectionProps {
onClearPointEvents: () => void;
onResetBackupState: () => void;
onClearBackupEvents: () => void;
onClearPendingKyc: () => void;
}
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
@@ -30,6 +31,7 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onClearPointEvents,
onResetBackupState,
onClearBackupEvents,
onClearPendingKyc,
}) => {
const dangerActions = [
{
@@ -57,6 +59,11 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
onPress: onClearBackupEvents,
dangerTheme: true,
},
{
label: 'Clear Pending KYC verifications',
onPress: onClearPendingKyc,
dangerTheme: true,
},
];
return (

View File

@@ -53,29 +53,6 @@ export const DebugShortcutsSection: React.FC<DebugShortcutsSectionProps> = ({
<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

@@ -10,6 +10,7 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Check, Eraser, HousePlus } from '@tamagui/lucide-icons';
import { deserializeApplicantInfo } from '@selfxyz/common';
import type {
DocumentCatalog,
DocumentMetadata,
@@ -142,8 +143,42 @@ const PassportDataSelector = () => {
]);
};
const getDisplayName = (documentType: string): string => {
switch (documentType) {
const getKYCDisplayName = (metadata: DocumentMetadata): string => {
let applicantInfo;
try {
applicantInfo = deserializeApplicantInfo(metadata.data);
} catch (error) {
console.error(
`[ManageDocumentsScreen] Failed to deserialize KYC data for document ${metadata.id}:`,
error,
);
return 'Verified ID';
}
if (!applicantInfo.idType) {
return 'Verified ID';
}
switch (applicantInfo.idType) {
case 'ID_CARD':
return 'ID Card';
case 'DRIVERS_LICENCE':
return "Driver's Licence";
case 'PASSPORT':
return 'Passport';
case 'AADHAAR':
return 'Aadhaar';
default:
return 'Verified ID';
}
};
const getDisplayName = (metadata: DocumentMetadata): string => {
if (metadata.documentCategory === 'kyc') {
return getKYCDisplayName(metadata);
}
switch (metadata.documentType) {
case 'passport':
return 'Passport';
case 'mock_passport':
@@ -157,7 +192,7 @@ const PassportDataSelector = () => {
case 'mock_aadhaar':
return 'Mock Aadhaar';
default:
return documentType;
return metadata.documentType;
}
};
@@ -181,6 +216,9 @@ const PassportDataSelector = () => {
}
} else if (documentCategory === 'aadhaar') {
return 'IND';
} else if (documentCategory === 'kyc') {
const applicantInfo = deserializeApplicantInfo(data);
return applicantInfo.country || null;
}
return null;
} catch {
@@ -313,7 +351,7 @@ const PassportDataSelector = () => {
</Button>
<YStack flex={1}>
<Text color={textBlack} fontWeight="bold" fontSize="$4">
{getDisplayName(metadata.documentType)}
{getDisplayName(metadata)}
</Text>
<Text color={textBlack} fontSize="$3" opacity={0.7}>
{getDocumentInfo(metadata)}

View File

@@ -2,14 +2,8 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Dimensions, Image, Pressable } from 'react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Pressable } from 'react-native';
import {
Button,
ScrollView,
@@ -45,8 +39,11 @@ import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
import LogoInversed from '@/assets/images/logo_inversed.svg';
import UnverifiedHumanImage from '@/assets/images/unverified_human.png';
import EmptyIdCard from '@/components/homescreen/EmptyIdCard';
import ExpiredIdCard from '@/components/homescreen/ExpiredIdCard';
import IdCardLayout from '@/components/homescreen/IdCard';
import PendingIdCard from '@/components/homescreen/PendingIdCard';
import UnregisteredIdCard from '@/components/homescreen/UnregisteredIdCard';
import { useAppUpdates } from '@/hooks/useAppUpdates';
import useConnectionModal from '@/hooks/useConnectionModal';
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
@@ -55,8 +52,13 @@ import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { usePendingKycStore } from '@/stores/pendingKycStore';
import { useSettingStore } from '@/stores/settingStore';
import useUserStore from '@/stores/userStore';
import {
checkDocumentExpiration,
getDocumentAttributes,
} from '@/utils/documentAttributes';
const HomeScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -66,7 +68,8 @@ const HomeScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { setIdDetailsDocumentId } = useUserStore();
const { getAllDocuments, loadDocumentCatalog } = usePassport();
const { getAllDocuments, loadDocumentCatalog, setSelectedDocument } =
usePassport();
const [isNewVersionAvailable, showAppUpdateModal, isModalDismissed] =
useAppUpdates();
const [documentCatalog, setDocumentCatalog] = useState<DocumentCatalog>({
@@ -78,11 +81,18 @@ const HomeScreen: React.FC = () => {
const [loading, setLoading] = useState(true);
const hasIncrementedOnFocus = useRef(false);
const { amount: selfPoints } = usePoints();
const { pendingVerifications, removeExpiredVerifications } =
usePendingKycStore();
// Calculate card dimensions exactly like IdCardLayout does
const { width: screenWidth } = Dimensions.get('window');
const cardWidth = screenWidth * 0.95 - 16; // 95% of screen width minus horizontal padding
useEffect(() => {
removeExpiredVerifications();
}, [removeExpiredVerifications]);
const activePendingVerifications = pendingVerifications.filter(
v => v.status === 'pending' || v.status === 'processing',
);
const { amount: selfPoints } = usePoints();
// DEV MODE: Test referral flow hook (only show alert when screen is focused)
const isFocused = useIsFocused();
@@ -158,10 +168,6 @@ const HomeScreen: React.FC = () => {
// Prevents back navigation
usePreventRemove(true, () => {});
const hasValidRegisteredDocument = useMemo(() => {
return documentCatalog.documents.some(doc => doc.isRegistered === true);
}, [documentCatalog]);
// Calculate bottom padding to prevent button bleeding into system navigation
const bottomPadding = useSafeBottomPadding(20);
@@ -226,56 +232,87 @@ const HomeScreen: React.FC = () => {
paddingBottom: 35, // Add extra bottom padding for shadow
}}
>
{!hasValidRegisteredDocument ? (
<Pressable
onPress={() => {
navigation.navigate('CountryPicker');
{/* Show pending KYC cards at the top */}
{activePendingVerifications.map(verification => (
<PendingIdCard
key={verification.userId}
onClick={() => {
if (
verification.status === 'processing' &&
verification.documentId
) {
navigation.navigate('KYCVerified', {
documentId: verification.documentId,
});
}
}}
>
<View
width={cardWidth}
borderRadius={8}
overflow="hidden"
alignSelf="center"
style={{
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
/>
))}
{/* Show EmptyIdCard only when no documents AND no pending verifications */}
{documentCatalog.documents.length === 0 &&
activePendingVerifications.length === 0 && (
<EmptyIdCard
onRegisterPress={() => {
navigation.navigate('CountryPicker');
}}
>
<Image
source={UnverifiedHumanImage}
style={{ width: cardWidth, height: cardWidth * (418 / 640) }}
resizeMode="cover"
/>
</View>
</Pressable>
) : (
documentCatalog.documents.map((metadata: DocumentMetadata) => {
const documentData = allDocuments[metadata.id];
const isSelected =
documentCatalog.selectedDocumentId === metadata.id;
/>
)}
if (!documentData || !documentData.metadata.isRegistered) {
return null;
}
{/* Show document cards */}
{documentCatalog.documents.map((metadata: DocumentMetadata) => {
const documentData = allDocuments[metadata.id];
const isSelected = documentCatalog.selectedDocumentId === metadata.id;
if (!documentData) {
return null;
}
//return early if the document is a pending KYC document as we are already displaying
//another card.
if (
!documentData.metadata.isRegistered &&
activePendingVerifications.some(
doc => doc.documentId === documentData.metadata.id,
)
) {
return;
}
// Show UnregisteredIdCard for documents not yet registered on-chain
if (!documentData.metadata.isRegistered) {
return (
<Pressable
<UnregisteredIdCard
key={metadata.id}
onPress={() => handleDocumentPress(metadata, documentData.data)}
>
<IdCardLayout
idDocument={documentData.data}
selected={isSelected}
hidden={true}
/>
</Pressable>
onRegisterPress={async () => {
await setSelectedDocument(metadata.id);
navigation.navigate('ConfirmBelonging', {});
}}
/>
);
})
)}
}
// Check if document is expired
const attributes = getDocumentAttributes(documentData.data);
const isExpired = checkDocumentExpiration(attributes.expiryDateSlice);
if (isExpired) {
return <ExpiredIdCard key={metadata.id} />;
}
// Show normal IdCardLayout for valid registered documents
return (
<Pressable
key={metadata.id}
onPress={() => handleDocumentPress(metadata, documentData.data)}
>
<IdCardLayout
idDocument={documentData.data}
selected={isSelected}
hidden={true}
/>
</Pressable>
);
})}
</ScrollView>
<YStack
elevation={8}

View File

@@ -6,9 +6,15 @@ import React from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { useRoute } from '@react-navigation/native';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import {
loadSelectedDocument,
SdkEvents,
useSelfClient,
} from '@selfxyz/mobile-sdk-alpha';
import {
AbstractButton,
Description,
@@ -18,15 +24,66 @@ import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import { setSelectedDocument } from '@/providers/passportDataProvider';
import { usePendingKycStore } from '@/stores/pendingKycStore';
const KYCVerifiedScreen: React.FC = () => {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, 'KYCVerified'>>();
const insets = useSafeAreaInsets();
const selfClient = useSelfClient();
const { pendingVerifications, removePendingVerification } =
usePendingKycStore();
const handleGenerateProof = () => {
const documentId = route.params?.documentId;
const handleGenerateProof = async () => {
buttonTap();
navigation.navigate('ProvingScreenRouter');
try {
if (!documentId) {
console.error(
'[KYCVerifiedScreen] No documentId provided in route params',
);
return;
}
console.log(
'[KYCVerifiedScreen] Triggering proving for documentId:',
documentId,
);
await setSelectedDocument(documentId);
const selectedDocument = await loadSelectedDocument(selfClient);
if (!selectedDocument) {
console.error(
'[KYCVerifiedScreen] No document found to trigger registration',
);
return;
}
const pendingVerification = pendingVerifications.find(
v => v.documentId === documentId,
);
//TODO improvement: instead of removing it here, we could do it in provingMachine's final state(error/completed)
//if we do that, the card will still be displayed in Homescreen as 'Pending' if user click back midway during provingMachine
if (pendingVerification) {
removePendingVerification(pendingVerification.userId);
}
const documentMetadata: {
documentCategory?: DocumentCategory;
signatureAlgorithm?: string;
curveOrExponent?: string;
} = {
documentCategory: 'kyc' as const,
};
console.log('[KYCVerifiedScreen] Emitting DOCUMENT_OWNERSHIP_CONFIRMED');
selfClient.emit(SdkEvents.DOCUMENT_OWNERSHIP_CONFIRMED, documentMetadata);
} catch (err) {
console.error('[KYCVerifiedScreen] Failed to trigger registration:', err);
}
};
return (

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { YStack } from 'tamagui';
@@ -21,6 +21,7 @@ import {
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { useSumsubWebSocket } from '@/hooks/useSumsubWebSocket';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
import {
@@ -49,6 +50,41 @@ const KycSuccessScreen: React.FC<KycSuccessRouteParams> = ({
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
const hasSubscribedRef = useRef<boolean>(false);
const handleWebSocketSuccess = useCallback(() => {
console.log(
'[KycSuccessScreen] Verification complete, registration flow triggered',
);
}, []);
const handleWebSocketError = useCallback((error: string) => {
console.error('[KycSuccessScreen] WebSocket error:', error);
}, []);
const handleVerificationFailed = useCallback((reason: string) => {
console.log('[KycSuccessScreen] Verification failed:', reason);
}, []);
const { subscribe, unsubscribeAll } = useSumsubWebSocket({
onSuccess: handleWebSocketSuccess,
onError: handleWebSocketError,
onVerificationFailed: handleVerificationFailed,
});
useEffect(() => {
if (userId && !hasSubscribedRef.current) {
hasSubscribedRef.current = true;
console.log('[KycSuccessScreen] Subscribing to userId:', userId);
subscribe(userId);
}
return () => {
hasSubscribedRef.current = false;
unsubscribeAll();
};
}, [userId, subscribe, unsubscribeAll]);
const handleReceiveUpdates = useCallback(async () => {
buttonTap();

View File

@@ -81,6 +81,9 @@ function getDocumentDisplayName(
: `${mockPrefix}${base}`;
} else if (category === 'aadhaar') {
return isMock ? 'Dev Aadhaar ID' : 'Aadhaar ID';
} else if (category === 'kyc') {
const idLabel = metadata.idType || 'Verified ID';
return isMock ? `Dev ${idLabel}` : idLabel;
}
return isMock ? `Dev ${metadata.documentType}` : metadata.documentType;
@@ -266,7 +269,7 @@ const DocumentSelectorForProvingScreen: React.FC = () => {
const metadata = documentCatalog.documents.find(
d => d.id === selectedDocumentId,
);
return getDocumentTypeName(metadata?.documentCategory);
return getDocumentTypeName(metadata?.documentCategory, metadata?.idType);
}, [
selectedDocumentId,
documentCatalog.documents,

View File

@@ -83,7 +83,10 @@ const ProvingScreenRouter: React.FC = () => {
// Determine document type from first valid document for display
const firstValidDoc = validDocuments[0];
const documentType = getDocumentTypeName(firstValidDoc?.documentCategory);
const documentType = getDocumentTypeName(
firstValidDoc?.documentCategory,
firstValidDoc?.idType,
);
// Determine if we should skip the selector
const shouldSkip = skipDocumentSelector || validCount === 1;

View File

@@ -0,0 +1,108 @@
// 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 { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type {
PendingKycStatus,
PendingKycVerification,
} from '@selfxyz/common/utils/types';
const VERIFICATION_TIMEOUT_MS = 48 * 60 * 60 * 1000; // 48 hours TODO seshanth
interface PendingKycState {
pendingVerifications: PendingKycVerification[];
addPendingVerification: (userId: string) => void;
updateVerificationStatus: (
userId: string,
status: PendingKycStatus,
errorMessage?: string,
documentId?: string,
) => void;
removePendingVerification: (userId: string) => void;
removeExpiredVerifications: () => void;
clearAllPendingVerifications: () => void;
hasPendingVerification: () => boolean;
getPendingVerification: (
userId: string,
) => PendingKycVerification | undefined;
}
export const usePendingKycStore = create<PendingKycState>()(
persist(
(set, get) => ({
pendingVerifications: [],
addPendingVerification: (userId: string) => {
const now = Date.now();
set(state => ({
pendingVerifications: [
// Remove any existing entry for this userId
...state.pendingVerifications.filter(v => v.userId !== userId),
{
userId,
createdAt: now,
status: 'pending',
timeoutAt: now + VERIFICATION_TIMEOUT_MS,
},
],
}));
},
updateVerificationStatus: (
userId: string,
status: PendingKycStatus,
errorMessage?: string,
documentId?: string,
) => {
set(state => ({
pendingVerifications: state.pendingVerifications.map(v =>
v.userId === userId
? {
...v,
status,
errorMessage,
...(documentId && { documentId }),
}
: v,
),
}));
},
removePendingVerification: (userId: string) => {
set(state => ({
pendingVerifications: state.pendingVerifications.filter(
v => v.userId !== userId,
),
}));
},
removeExpiredVerifications: () => {
const now = Date.now();
set(state => ({
pendingVerifications: state.pendingVerifications.filter(
v => v.timeoutAt > now,
),
}));
},
clearAllPendingVerifications: () => {
set({ pendingVerifications: [] });
},
hasPendingVerification: () =>
get().pendingVerifications.some(v => v.status === 'pending'),
getPendingVerification: (userId: string) =>
get().pendingVerifications.find(v => v.userId === userId),
}),
{
name: 'pending-kyc-storage',
storage: createJSONStorage(() => AsyncStorage),
},
),
);

View File

@@ -0,0 +1,53 @@
// 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 {
deserializeApplicantInfo,
isAadhaarDocument,
isKycDocument,
isMRZDocument,
} from '@selfxyz/common';
const BACKGROUND_COUNT = 6;
/**
* Get a deterministic background index (1-6) based on document data.
* Uses a simple polynomial rolling hash of unique document identifiers.
* The same document will always return the same background index.
*/
export function getBackgroundIndex(document: IDDocument): number {
let hashInput: string;
if (isMRZDocument(document)) {
// For passport/ID card: use MRZ string
hashInput = document.mrz;
} else if (isAadhaarDocument(document)) {
// For Aadhaar: use last 4 digits + name + dob
const fields = document.extractedFields;
hashInput = `${fields?.aadhaarLast4Digits}|${fields?.name}|${fields?.dob}`;
} else if (isKycDocument(document)) {
// For KYC: deserialize applicant info and use idNumber + fullName + dob
try {
const applicantInfo = deserializeApplicantInfo(
document.serializedApplicantInfo,
);
hashInput = `${applicantInfo.idNumber}|${applicantInfo.fullName}|${applicantInfo.dob}`;
} catch {
hashInput = document.serializedApplicantInfo ?? '';
}
} else {
// Fallback for unknown document types
hashInput = '';
}
// Polynomial rolling hash (multiplier 31) for even distribution
let hash = 0;
for (let i = 0; i < hashInput.length; i++) {
// eslint-disable-next-line no-bitwise
hash = (hash * 31 + hashInput.charCodeAt(i)) >>> 0;
}
return (hash % BACKGROUND_COUNT) + 1; // Returns 1-6
}

View File

@@ -2,6 +2,15 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { generateMockDocument } from '@selfxyz/mobile-sdk-alpha';
import {
deleteDocument,
loadDocumentCatalogDirectlyFromKeychain,
storeDocumentWithDeduplication,
updateDocumentRegistrationState,
} from '@/providers/passportDataProvider';
/**
* Constant indicating if the app is running in development mode.
* Safely handles cases where __DEV__ might not be defined.
@@ -9,3 +18,128 @@
*/
export const IS_DEV_MODE = typeof __DEV__ !== 'undefined' && __DEV__;
export const IS_EUCLID_ENABLED = false; // Enabled for proof request UI redesign
/**
* Test documents configuration for visual testing of ID card backgrounds.
* Each document will get a deterministic background based on its unique data.
* Uses 3-letter ISO country codes for proper flag display.
*/
const TEST_DOCUMENTS = [
{
type: 'mock_passport' as const,
country: 'USA',
firstName: 'John',
lastName: 'Smith',
age: 35,
},
{
type: 'mock_passport' as const,
country: 'GBR',
firstName: 'Emma',
lastName: 'Wilson',
age: 28,
},
{
type: 'mock_passport' as const,
country: 'JPN',
firstName: 'Yuki',
lastName: 'Tanaka',
age: 42,
},
{
type: 'mock_id_card' as const,
country: 'DEU',
firstName: 'Hans',
lastName: 'Mueller',
age: 31,
},
{
type: 'mock_id_card' as const,
country: 'FRA',
firstName: 'Marie',
lastName: 'Dubois',
age: 25,
},
{
type: 'mock_id_card' as const,
country: 'CAN',
firstName: 'Michael',
lastName: 'Brown',
age: 38,
},
{
type: 'mock_aadhaar' as const,
country: 'IND',
firstName: 'Raj',
lastName: 'Patel',
age: 30,
},
];
/**
* Clears all existing documents from the catalog.
* Only works in development mode.
*/
async function clearAllDocuments(): Promise<void> {
const catalog = await loadDocumentCatalogDirectlyFromKeychain();
console.log(`Clearing ${catalog.documents.length} existing documents...`);
for (const doc of catalog.documents) {
try {
await deleteDocument(doc.id);
} catch (error) {
console.warn(`Failed to delete document ${doc.id}:`, error);
}
}
}
/**
* Generates and stores multiple mock documents for testing ID card backgrounds.
* Only works in development mode. Call this from HomeScreen to populate
* the document list with various document types and countries.
*
* @returns Number of documents created
*/
export async function generateTestDocuments(): Promise<number> {
if (!IS_DEV_MODE) {
console.warn('generateTestDocuments only works in development mode');
return 0;
}
// Clear existing documents first
await clearAllDocuments();
console.log('Generating test documents for background testing...');
let created = 0;
for (const doc of TEST_DOCUMENTS) {
try {
const mockDoc = await generateMockDocument({
age: doc.age,
expiryYears: 10,
isInOfacList: false,
selectedAlgorithm: 'sha256 rsa 65537 2048',
selectedCountry: doc.country,
selectedDocumentType: doc.type,
firstName: doc.firstName,
lastName: doc.lastName,
});
// Override mock flag to render as "real" document with colorful backgrounds
mockDoc.mock = false;
const documentId = await storeDocumentWithDeduplication(mockDoc);
// Mark as registered so it shows the full card with background (not UnregisteredIdCard)
await updateDocumentRegistrationState(documentId, true);
created++;
console.log(
`Created ${doc.type} for ${doc.firstName} ${doc.lastName} (${doc.country})`,
);
} catch (error) {
console.error(`Failed to create ${doc.type} for ${doc.country}:`, error);
}
}
console.log(`Generated ${created} test documents`);
return created;
}

View File

@@ -3,12 +3,18 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { AadhaarData } from '@selfxyz/common';
import { deserializeApplicantInfo } from '@selfxyz/common';
import {
attributeToPosition,
attributeToPosition_ID,
} from '@selfxyz/common/constants';
import type { PassportData } from '@selfxyz/common/types/passport';
import { isAadhaarDocument, isMRZDocument } from '@selfxyz/common/utils/types';
import type { KycData } from '@selfxyz/common/utils/types';
import {
isAadhaarDocument,
isKycDocument,
isMRZDocument,
} from '@selfxyz/common/utils/types';
/**
* Gets the scan prompt for a document type.
@@ -92,6 +98,101 @@ export function formatDateFromYYMMDD(
return `${dd}/${mm}/${year}`;
}
/**
* Extracts attributes from KYC document data
*/
function getKycAttributes(document: KycData): DocumentAttributes {
try {
// Extract fields from serializedApplicantInfo
const data = deserializeApplicantInfo(document.serializedApplicantInfo);
// Format name like MRZ: surname<<given names
const nameParts = data.fullName.trim().split(/\s+/);
const surname = nameParts[nameParts.length - 1] || '';
const givenNames = nameParts.slice(0, -1).join(' ') || '';
const nameSliceFormatted =
surname && givenNames
? `${surname}<<${givenNames}`
: surname || givenNames || '';
// Format DOB to YYMMDD if provided (assuming ISO format YYYY-MM-DD or similar)
let dobFormatted = '';
let yobSlice = '';
if (data.dob) {
// Try to parse various date formats
const dateMatch = data.dob.match(/(\d{4})-(\d{2})-(\d{2})/); // YYYY-MM-DD
if (dateMatch) {
const [, year, month, day] = dateMatch;
yobSlice = year;
dobFormatted = `${year.slice(-2)}${month}${day}`;
} else if (data.dob.length === 8 && /^\d{8}$/.test(data.dob)) {
// Already in YYYYMMDD format
yobSlice = data.dob.slice(0, 4);
dobFormatted = `${data.dob.slice(2, 4)}${data.dob.slice(4, 6)}${data.dob.slice(6, 8)}`;
} else if (data.dob.length === 6 && /^\d{6}$/.test(data.dob)) {
// Already in YYMMDD format - determine century
const yy = parseInt(data.dob.slice(0, 2), 10);
const currentYear = new Date().getFullYear();
const century = Math.floor(currentYear / 100) * 100;
let fullYear = century + yy;
// For birth: if year is in the future, assume previous century
if (fullYear > 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 attributes from Aadhaar document data
*/
@@ -191,10 +292,12 @@ function getPassportAttributes(
// Helper functions to safely extract document data
export function getDocumentAttributes(
document: PassportData | AadhaarData,
document: PassportData | AadhaarData | KycData,
): DocumentAttributes {
if (isAadhaarDocument(document)) {
return getAadhaarAttributes(document);
} else if (isKycDocument(document)) {
return getKycAttributes(document);
} else if (isMRZDocument(document)) {
return getPassportAttributes(document.mrz, document.documentCategory);
} else {

View File

@@ -4,8 +4,12 @@
/**
* Gets the document type display name for the proof request message.
* For KYC documents, pass idType to display the specific document type (e.g. "Passport", "Driver's Licence").
*/
export function getDocumentTypeName(category: string | undefined): string {
export function getDocumentTypeName(
category: string | undefined,
idType?: string,
): string {
switch (category) {
case 'passport':
return 'Passport';
@@ -13,6 +17,8 @@ export function getDocumentTypeName(category: string | undefined): string {
return 'ID Card';
case 'aadhaar':
return 'Aadhaar';
case 'kyc':
return idType || 'Verified ID';
default:
return 'Document';
}

View File

@@ -113,7 +113,6 @@ describe('navigation', () => {
'ShowRecoveryPhrase',
'Splash',
'StarfallPushCode',
'SumsubTest',
'WebView',
]);
});

View File

@@ -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,23 @@ 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(<KYCVerifiedScreen />);
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(<KYCVerifiedScreen />);
expect(mockUseNavigation).toHaveBeenCalled();
it('should use the documentId from route params', () => {
const { root } = render(<KYCVerifiedScreen />);
// Component should render without errors when documentId is provided
expect(root).toBeTruthy();
});
});

View File

@@ -25,12 +25,33 @@ jest.mock('react-native', () => ({
},
View: ({ children, ...props }: any) => <div {...props}>{children}</div>,
Text: ({ children, ...props }: any) => <span {...props}>{children}</span>,
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) => <div {...props}>{children}</div>,
View: ({ children, ...props }: any) => <div {...props}>{children}</div>,
Text: ({ children, ...props }: any) => <span {...props}>{children}</span>,
styled: (Component: any) => (props: any) => <Component {...props} />,
}));
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<

View File

@@ -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);
});
});

View File

@@ -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';

View File

@@ -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');

View File

@@ -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: <T extends 'ofac' | 'commitment'>(
// 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: <T extends 'ofac' | 'commitment'>(
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';

View File

@@ -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';

View File

@@ -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<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 };
}
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<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 };
}

View File

@@ -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;
};

View File

@@ -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;
}
};

View File

@@ -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')) {

View File

@@ -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);

View File

@@ -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<ProofTypes, 'register'>}${RegisterSuffixes}`;
type DscProofType = `${Extract<ProofTypes, 'dsc'>}${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;

View File

@@ -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,28 @@ 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
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 +89,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 +174,7 @@ export enum AttestationIdHex {
passport = '0x0000000000000000000000000000000000000000000000000000000000000001',
id_card = '0x0000000000000000000000000000000000000000000000000000000000000002',
aadhaar = '0x0000000000000000000000000000000000000000000000000000000000000003',
kyc = '0x0000000000000000000000000000000000000000000000000000000000000004',
}
export function castCSCAProof(proof: any): Proof {
@@ -169,15 +188,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'
);

View File

@@ -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';
@@ -61,6 +68,7 @@ export const textBlack = '#333333';
export const white = '#ffffff';
export const yellow50 = '#FEFCE8';
export const yellow500 = '#FDE047';
export const zinc400 = '#A1A1AA';

View File

@@ -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,
@@ -51,6 +55,7 @@ export {
teal500,
textBlack,
white,
yellow50,
yellow500,
zinc400,
zinc500,

View File

@@ -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;

View File

@@ -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<<given names
const nameParts = data.fullName.trim().split(/\s+/);
const surname = nameParts[nameParts.length - 1] || '';
const givenNames = nameParts.slice(0, -1).join(' ') || '';
const nameSliceFormatted = surname && givenNames ? `${surname}<<${givenNames}` : surname || givenNames || '';
// Format DOB to YYMMDD if provided (assuming ISO format YYYY-MM-DD or similar)
let dobFormatted = '';
let yobSlice = '';
if (data.dob) {
// Try to parse various date formats
const dateMatch = data.dob.match(/(\d{4})-(\d{2})-(\d{2})/); // YYYY-MM-DD
if (dateMatch) {
const [, year, month, day] = dateMatch;
yobSlice = year;
dobFormatted = `${year.slice(-2)}${month}${day}`;
} else if (data.dob.length === 8 && /^\d{8}$/.test(data.dob)) {
// Already in YYYYMMDD format
yobSlice = data.dob.slice(0, 4);
dobFormatted = `${data.dob.slice(2, 4)}${data.dob.slice(4, 6)}${data.dob.slice(6, 8)}`;
} else if (data.dob.length === 6 && /^\d{6}$/.test(data.dob)) {
// Already in YYMMDD format - determine century
const yy = parseInt(data.dob.slice(0, 2), 10);
const currentYear = new Date().getFullYear();
const century = Math.floor(currentYear / 100) * 100;
let fullYear = century + yy;
// For birth: if year is in the future, assume previous century
if (fullYear > 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 {

View File

@@ -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 = {

View File

@@ -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<ProvingState>((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<ProvingState>((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<ProvingState>((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<string, string> format expected by AlternativeCSCA
return publicKeys ? Object.fromEntries(publicKeys.map(key => [key, key])) : {};
}
@@ -1332,7 +1346,12 @@ export const useProvingStore = create<ProvingState>((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');
}

View File

@@ -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<ProtocolState>((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<any> => {
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 } });
}
},
},
}));

View File

@@ -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();

View File

@@ -655,6 +655,7 @@ describe('validatingDocument', () => {
REGISTER: [],
REGISTER_ID: [],
REGISTER_AADHAAR: [],
REGISTER_KYC: [],
DSC: [],
DSC_ID: [],
};

View File

@@ -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"
}