Merge pull request #1741 from selfxyz/release/staging-2026-02-12
Release to Staging v2.9.16 - 2026-02-12
@@ -8,11 +8,12 @@ gem "cocoapods", ">= 1.13", "!= 1.15.0", "!= 1.15.1"
|
||||
gem "activesupport", ">= 6.1.7.5", "!= 7.1.0"
|
||||
|
||||
# Add fastlane for CI/CD
|
||||
gem "fastlane", "~> 2.230.0"
|
||||
gem "fastlane", "~> 2.232.0"
|
||||
|
||||
group :development do
|
||||
gem "dotenv"
|
||||
gem "nokogiri", "~> 1.18", platform: :ruby
|
||||
gem "bundler-audit", "~> 0.9", require: false
|
||||
end
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), "fastlane", "Pluginfile")
|
||||
|
||||
@@ -45,6 +45,9 @@ GEM
|
||||
base64 (0.2.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bundler-audit (0.9.3)
|
||||
bundler (>= 1.2.0)
|
||||
thor (~> 1.0)
|
||||
claide (1.1.0)
|
||||
cocoapods (1.16.2)
|
||||
addressable (~> 2.8)
|
||||
@@ -130,15 +133,16 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.230.0)
|
||||
fastlane (2.232.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
@@ -153,7 +157,7 @@ GEM
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
@@ -166,6 +170,7 @@ GEM
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2.0)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
@@ -186,38 +191,40 @@ GEM
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
google-apis-androidpublisher_v3 (0.96.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-iamcredentials_v1 (0.26.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.60.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
google-cloud-storage (1.58.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
@@ -253,6 +260,7 @@ GEM
|
||||
racc (~> 1.4)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
prism (1.9.0)
|
||||
public_suffix (4.0.7)
|
||||
@@ -282,6 +290,7 @@ GEM
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.5.0)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
@@ -311,9 +320,10 @@ PLATFORMS
|
||||
|
||||
DEPENDENCIES
|
||||
activesupport (>= 6.1.7.5, != 7.1.0)
|
||||
bundler-audit (~> 0.9)
|
||||
cocoapods (>= 1.13, != 1.15.1, != 1.15.0)
|
||||
dotenv
|
||||
fastlane (~> 2.230.0)
|
||||
fastlane (~> 2.232.0)
|
||||
fastlane-plugin-increment_version_code
|
||||
fastlane-plugin-versioning_android
|
||||
nokogiri (~> 1.18)
|
||||
|
||||
@@ -134,8 +134,8 @@ android {
|
||||
applicationId "com.proofofpassportapp"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 140
|
||||
versionName "2.9.15"
|
||||
versionCode 142
|
||||
versionName "2.9.16"
|
||||
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- Remove FOREGROUND_SERVICE_MICROPHONE merged in by Sumsub SDK (VideoIdent is disabled) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.9.15</string>
|
||||
<string>2.9.16</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
"$(PROJECT_DIR)",
|
||||
"$(PROJECT_DIR)/MoproKit/Libs",
|
||||
);
|
||||
MARKETING_VERSION = 2.9.15;
|
||||
MARKETING_VERSION = 2.9.16;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -688,7 +688,7 @@
|
||||
"$(PROJECT_DIR)",
|
||||
"$(PROJECT_DIR)/MoproKit/Libs",
|
||||
);
|
||||
MARKETING_VERSION = 2.9.15;
|
||||
MARKETING_VERSION = 2.9.16;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfxyz/mobile-app",
|
||||
"version": "2.9.15",
|
||||
"version": "2.9.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -17,8 +17,8 @@ if (!platform || !['android', 'ios'].includes(platform)) {
|
||||
// Bundle size thresholds in MB - easy to update!
|
||||
const BUNDLE_THRESHOLDS_MB = {
|
||||
// TODO: fix temporary bundle bump
|
||||
ios: 45,
|
||||
android: 45,
|
||||
ios: 46,
|
||||
android: 46,
|
||||
};
|
||||
|
||||
function formatBytes(bytes) {
|
||||
|
||||
BIN
app/src/assets/images/card_background_id1.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
app/src/assets/images/card_background_id2.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
app/src/assets/images/card_background_id3.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
app/src/assets/images/card_background_id4.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
app/src/assets/images/card_background_id5.png
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
app/src/assets/images/card_background_id6.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
14
app/src/assets/images/dev_card_logo.svg
Normal 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 |
56
app/src/assets/images/dev_card_wave.svg
Normal 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 |
12
app/src/assets/images/self_logo_inactive.svg
Normal 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 |
12
app/src/assets/images/self_logo_pending.svg
Normal 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 |
12
app/src/assets/images/self_logo_unverified.svg
Normal 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 |
BIN
app/src/assets/images/wave_overlay.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
app/src/assets/images/wave_pattern_body.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
app/src/assets/images/wave_pattern_pending.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/src/assets/images/wave_pattern_transparent.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
154
app/src/components/homescreen/EmptyIdCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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 { Image } 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';
|
||||
import { cardStyles } from '@/components/homescreen/cardStyles';
|
||||
import { useCardDimensions } from '@/hooks/useCardDimensions';
|
||||
|
||||
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 {
|
||||
cardWidth,
|
||||
borderRadius,
|
||||
scale,
|
||||
headerHeight,
|
||||
figmaPadding,
|
||||
logoSize,
|
||||
headerGap,
|
||||
expandedAspectRatio,
|
||||
fontSize,
|
||||
} = useCardDimensions();
|
||||
|
||||
return (
|
||||
<YStack width="100%" alignItems="center" justifyContent="center">
|
||||
<YStack
|
||||
width={cardWidth}
|
||||
aspectRatio={expandedAspectRatio}
|
||||
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={[cardStyles.body, { backgroundColor: white }]}>
|
||||
{/* Wave pattern background - exact same as unverified_human.png */}
|
||||
<Image
|
||||
source={WavePatternBody}
|
||||
style={cardStyles.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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyIdCard;
|
||||
145
app/src/components/homescreen/ExpiredIdCard.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// 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 { Image } 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';
|
||||
import { cardStyles } from '@/components/homescreen/cardStyles';
|
||||
import { useCardDimensions } from '@/hooks/useCardDimensions';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
cardWidth,
|
||||
borderRadius,
|
||||
scale,
|
||||
headerHeight,
|
||||
figmaPadding,
|
||||
logoSize,
|
||||
headerGap,
|
||||
expandedAspectRatio,
|
||||
fontSize,
|
||||
} = useCardDimensions();
|
||||
|
||||
return (
|
||||
<YStack width="100%" alignItems="center" justifyContent="center">
|
||||
<YStack
|
||||
width={cardWidth}
|
||||
aspectRatio={expandedAspectRatio}
|
||||
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 style={cardStyles.body}>
|
||||
{/* Wave pattern background */}
|
||||
<Image
|
||||
source={WavePatternBody}
|
||||
style={cardStyles.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={fontSize.badge}
|
||||
fontWeight="500"
|
||||
color={white}
|
||||
letterSpacing={0.6}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
EXPIRED ID
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpiredIdCard;
|
||||
298
app/src/components/homescreen/KycIdCard.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
// 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 { Image, 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';
|
||||
import { cardStyles } from '@/components/homescreen/cardStyles';
|
||||
import { useCardDimensions } from '@/hooks/useCardDimensions';
|
||||
|
||||
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 with error handling
|
||||
let country = '';
|
||||
let idType = '';
|
||||
let idNumber = '';
|
||||
|
||||
try {
|
||||
const applicantInfo = deserializeApplicantInfo(
|
||||
idDocument.serializedApplicantInfo,
|
||||
);
|
||||
country = applicantInfo.country || '';
|
||||
idType = applicantInfo.idType || '';
|
||||
idNumber = applicantInfo.idNumber || '';
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[KycIdCard] Failed to deserialize applicant info, using fallback values:',
|
||||
error,
|
||||
);
|
||||
// Fallback to safe defaults - component will render generic "ID CARD" display
|
||||
}
|
||||
|
||||
const docTitle = getKycDocTitle(idType);
|
||||
const countryAdj = getCountryAdjective(country);
|
||||
|
||||
const {
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
borderRadius,
|
||||
headerHeight,
|
||||
figmaPadding,
|
||||
logoSize,
|
||||
headerGap,
|
||||
fontSize,
|
||||
} = useCardDimensions(selected);
|
||||
const padding = cardWidth * 0.04;
|
||||
|
||||
// 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}`;
|
||||
|
||||
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={logoSize} />
|
||||
|
||||
{/* 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={logoSize * 0.56 * 5} height={logoSize} />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* Body Section - Colorful wave pattern (same as IdCard real documents) */}
|
||||
{selected && (
|
||||
<YStack style={cardStyles.body}>
|
||||
{/* Pre-composited background image (colorful gradient + chrome wave) */}
|
||||
<Image
|
||||
source={CardBackgroundId1}
|
||||
style={cardStyles.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>
|
||||
);
|
||||
};
|
||||
|
||||
export default KycIdCard;
|
||||
161
app/src/components/homescreen/PendingIdCard.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
// 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 { Image } 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';
|
||||
import { cardStyles } from '@/components/homescreen/cardStyles';
|
||||
import { useCardDimensions } from '@/hooks/useCardDimensions';
|
||||
|
||||
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 {
|
||||
cardWidth,
|
||||
borderRadius,
|
||||
scale,
|
||||
headerHeight,
|
||||
figmaPadding,
|
||||
logoSize,
|
||||
headerGap,
|
||||
expandedAspectRatio,
|
||||
fontSize,
|
||||
} = useCardDimensions();
|
||||
|
||||
return (
|
||||
<YStack width="100%" alignItems="center" justifyContent="center">
|
||||
<YStack
|
||||
width={cardWidth}
|
||||
aspectRatio={expandedAspectRatio}
|
||||
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 style={cardStyles.body}>
|
||||
{/* Wave pattern background */}
|
||||
<Image
|
||||
source={WavePatternPending}
|
||||
style={cardStyles.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={fontSize.badge}
|
||||
fontWeight="500"
|
||||
color={amber700}
|
||||
letterSpacing={0.6}
|
||||
textTransform="uppercase"
|
||||
>
|
||||
Pending
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingIdCard;
|
||||
160
app/src/components/homescreen/UnregisteredIdCard.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// 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 { Image } 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';
|
||||
import { cardStyles } from '@/components/homescreen/cardStyles';
|
||||
import { useCardDimensions } from '@/hooks/useCardDimensions';
|
||||
|
||||
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 {
|
||||
cardWidth,
|
||||
borderRadius,
|
||||
scale,
|
||||
headerHeight,
|
||||
figmaPadding,
|
||||
logoSize,
|
||||
headerGap,
|
||||
expandedAspectRatio,
|
||||
fontSize,
|
||||
} = useCardDimensions();
|
||||
|
||||
return (
|
||||
<YStack width="100%" alignItems="center" justifyContent="center">
|
||||
<YStack
|
||||
width={cardWidth}
|
||||
aspectRatio={expandedAspectRatio}
|
||||
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={[cardStyles.body, { backgroundColor: white }]}>
|
||||
{/* Wave pattern background */}
|
||||
<Image
|
||||
source={WavePatternBody}
|
||||
style={cardStyles.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 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Complete Registration"
|
||||
>
|
||||
<Text
|
||||
fontFamily={dinot}
|
||||
fontSize={fontSize.button}
|
||||
fontWeight="500"
|
||||
color={white}
|
||||
textAlign="center"
|
||||
>
|
||||
Complete Registration
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnregisteredIdCard;
|
||||
39
app/src/components/homescreen/cardSecurityBadge.ts
Normal 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
|
||||
}
|
||||
31
app/src/components/homescreen/cardStyles.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 { StyleSheet } from 'react-native';
|
||||
|
||||
export const cardStyles = StyleSheet.create({
|
||||
backgroundImage: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
wavePattern: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export interface BottomVerifyBarProps {
|
||||
isReadyToProve: boolean;
|
||||
isDocumentExpired: boolean;
|
||||
testID?: string;
|
||||
hasCheckedForInactiveDocument: boolean;
|
||||
}
|
||||
|
||||
export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
|
||||
@@ -28,6 +29,7 @@ export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
|
||||
isReadyToProve,
|
||||
isDocumentExpired,
|
||||
testID = 'bottom-verify-bar',
|
||||
hasCheckedForInactiveDocument,
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -46,6 +48,7 @@ export const BottomVerifyBar: React.FC<BottomVerifyBarProps> = ({
|
||||
isScrollable={isScrollable}
|
||||
isReadyToProve={isReadyToProve}
|
||||
isDocumentExpired={isDocumentExpired}
|
||||
hasCheckedForInactiveDocument={hasCheckedForInactiveDocument}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
81
app/src/hooks/useCardDimensions.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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 { useWindowDimensions } from 'react-native';
|
||||
|
||||
const CARD_WIDTH_FACTOR = 0.95;
|
||||
const CARD_HORIZONTAL_OFFSET = 16;
|
||||
|
||||
// Figma reference dimensions
|
||||
const FIGMA_CARD_WIDTH = 353;
|
||||
const FIGMA_CARD_HEIGHT = 224;
|
||||
const FIGMA_HEADER_HEIGHT = 67;
|
||||
const FIGMA_PADDING = 14;
|
||||
const FIGMA_LOGO_SIZE = 32;
|
||||
const FIGMA_HEADER_GAP = 12;
|
||||
const FIGMA_HEADER_FONT_SIZE = 20;
|
||||
const FIGMA_SUBTITLE_FONT_SIZE = 7;
|
||||
const FIGMA_BADGE_FONT_SIZE = 10;
|
||||
const FIGMA_BOTTOM_LABEL_FONT_SIZE = 15;
|
||||
const FIGMA_BOTTOM_ID_FONT_SIZE = 10;
|
||||
const FIGMA_BUTTON_FONT_SIZE = 16;
|
||||
const FIGMA_BORDER_RADIUS = 12;
|
||||
|
||||
export interface CardDimensions {
|
||||
cardWidth: number;
|
||||
cardHeight: number;
|
||||
borderRadius: number;
|
||||
scale: number;
|
||||
headerHeight: number;
|
||||
figmaPadding: number;
|
||||
logoSize: number;
|
||||
headerGap: number;
|
||||
expandedAspectRatio: number;
|
||||
collapsedAspectRatio: number;
|
||||
fontSize: CardFontSizes;
|
||||
}
|
||||
|
||||
export interface CardFontSizes {
|
||||
header: number;
|
||||
subtitle: number;
|
||||
badge: number;
|
||||
bottomLabel: number;
|
||||
bottomId: number;
|
||||
button: number;
|
||||
}
|
||||
|
||||
export function useCardDimensions(selected = true): CardDimensions {
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const cardWidth = width * CARD_WIDTH_FACTOR - CARD_HORIZONTAL_OFFSET;
|
||||
const scale = cardWidth / FIGMA_CARD_WIDTH;
|
||||
|
||||
const expandedAspectRatio = FIGMA_CARD_WIDTH / FIGMA_CARD_HEIGHT;
|
||||
const collapsedAspectRatio = FIGMA_CARD_WIDTH / FIGMA_HEADER_HEIGHT;
|
||||
|
||||
return {
|
||||
cardWidth,
|
||||
cardHeight: selected
|
||||
? cardWidth / expandedAspectRatio
|
||||
: cardWidth / collapsedAspectRatio,
|
||||
borderRadius: FIGMA_BORDER_RADIUS,
|
||||
scale,
|
||||
headerHeight: FIGMA_HEADER_HEIGHT * scale,
|
||||
figmaPadding: FIGMA_PADDING * scale,
|
||||
logoSize: FIGMA_LOGO_SIZE * scale,
|
||||
headerGap: FIGMA_HEADER_GAP * scale,
|
||||
expandedAspectRatio,
|
||||
collapsedAspectRatio,
|
||||
fontSize: {
|
||||
header: FIGMA_HEADER_FONT_SIZE * scale,
|
||||
subtitle: FIGMA_SUBTITLE_FONT_SIZE * scale,
|
||||
badge: FIGMA_BADGE_FONT_SIZE * scale,
|
||||
bottomLabel: FIGMA_BOTTOM_LABEL_FONT_SIZE * scale,
|
||||
bottomId: FIGMA_BOTTOM_ID_FONT_SIZE * scale,
|
||||
button: FIGMA_BUTTON_FONT_SIZE * scale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default useCardDimensions;
|
||||
127
app/src/hooks/usePendingKycRecovery.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// 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) {
|
||||
console.log(
|
||||
'[PendingKycRecovery] Resuming processing verification, navigating to KYCVerified:',
|
||||
processingWithDocument.userId,
|
||||
);
|
||||
if (navigationRef.isReady()) {
|
||||
navigationRef.navigate('KYCVerified', {
|
||||
documentId: processingWithDocument.documentId,
|
||||
});
|
||||
// Only mark as attempted after successful navigation
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation not ready yet - poll until ready
|
||||
console.log(
|
||||
'[PendingKycRecovery] Navigation not ready, polling for readiness:',
|
||||
processingWithDocument.userId,
|
||||
);
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
if (navigationRef.isReady()) {
|
||||
console.log(
|
||||
'[PendingKycRecovery] Navigation ready, navigating for:',
|
||||
processingWithDocument.userId,
|
||||
);
|
||||
navigationRef.navigate('KYCVerified', {
|
||||
documentId: processingWithDocument.documentId,
|
||||
});
|
||||
hasAttemptedRecoveryRef.current.add(processingWithDocument.userId);
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 100); // Poll every 100ms
|
||||
|
||||
// Cleanup polling on unmount or dependency change
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
@@ -24,9 +24,11 @@ export interface UseSumsubLauncherOptions {
|
||||
*/
|
||||
errorSource: FallbackErrorSource;
|
||||
/**
|
||||
* Optional callback to handle successful verification
|
||||
* Optional callback to handle successful verification.
|
||||
* Receives the Sumsub result and the userId from the access token.
|
||||
* If not provided, defaults to navigating to KycSuccess with the userId.
|
||||
*/
|
||||
onSuccess?: (result: SumsubResult) => void | Promise<void>;
|
||||
onSuccess?: (result: SumsubResult, userId: string) => void | Promise<void>;
|
||||
/**
|
||||
* Optional callback to handle user cancellation
|
||||
*/
|
||||
@@ -96,8 +98,12 @@ export const useSumsubLauncher = (options: UseSumsubLauncherOptions) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle success
|
||||
await onSuccess?.(result);
|
||||
// Handle success - navigate to KycSuccess by default
|
||||
if (onSuccess) {
|
||||
await onSuccess(result, accessToken.userId);
|
||||
} else {
|
||||
navigation.navigate('KycSuccess', { userId: accessToken.userId });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
201
app/src/hooks/useSumsubWebSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -159,6 +159,7 @@ export type OnboardingRoutesParamList = {
|
||||
| {
|
||||
status?: string;
|
||||
userId?: string;
|
||||
documentId?: string;
|
||||
}
|
||||
| undefined;
|
||||
KycFailure: {
|
||||
|
||||
@@ -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,
|
||||
@@ -61,7 +62,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 +836,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 +848,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 +861,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);
|
||||
@@ -871,20 +870,45 @@ export async function storeDocumentWithDeduplication(
|
||||
// Store new document using contentHash as service name
|
||||
await storeDocumentDirectlyToKeychain(contentHash, passportData);
|
||||
|
||||
const documentCategory =
|
||||
passportData.documentCategory ||
|
||||
inferDocumentCategory(
|
||||
(passportData as PassportData | AadhaarData).documentType,
|
||||
);
|
||||
|
||||
// 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,
|
||||
hasExpirationDate:
|
||||
documentCategory === 'id_card' || documentCategory === 'passport',
|
||||
...(isKycDocument(passportData)
|
||||
? (() => {
|
||||
try {
|
||||
const parsedApplicantInfo = deserializeApplicantInfo(
|
||||
passportData.serializedApplicantInfo,
|
||||
);
|
||||
return parsedApplicantInfo.idType
|
||||
? { idType: parsedApplicantInfo.idType }
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})()
|
||||
: {}),
|
||||
};
|
||||
|
||||
catalog.documents.push(metadata);
|
||||
@@ -894,9 +918,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,10 +15,10 @@ import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons';
|
||||
|
||||
import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components';
|
||||
import {
|
||||
amber500,
|
||||
black,
|
||||
neutral700,
|
||||
slate800,
|
||||
warmCream,
|
||||
white,
|
||||
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
|
||||
|
||||
@@ -150,7 +150,7 @@ const SocialButton: React.FC<SocialButtonProps> = ({ Icon, href }) => {
|
||||
unstyled
|
||||
hitSlop={8}
|
||||
onPress={onPress}
|
||||
icon={<Icon height={32} width={32} color={amber500} />}
|
||||
icon={<Icon height={32} width={32} color={warmCream} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -309,7 +309,7 @@ const SettingsScreen: React.FC = () => {
|
||||
<SocialButton key={i} Icon={Icon} href={href} />
|
||||
))}
|
||||
</XStack>
|
||||
<BodyText style={{ color: amber500, fontSize: 15 }}>
|
||||
<BodyText style={{ color: warmCream, fontSize: 15 }}>
|
||||
SELF
|
||||
</BodyText>
|
||||
{/* Dont remove if not viewing on ios */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { ScrollView } from 'react-native';
|
||||
import { Alert, ScrollView } from 'react-native';
|
||||
import { YStack } from 'tamagui';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
@@ -13,6 +13,10 @@ import { useSafeBottomPadding } from '@selfxyz/mobile-sdk-alpha/hooks';
|
||||
import BugIcon from '@/assets/icons/bug_icon.svg';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import {
|
||||
loadDocumentCatalogDirectlyFromKeychain,
|
||||
saveDocumentCatalogDirectlyToKeychain,
|
||||
} from '@/providers/passportDataProvider';
|
||||
import { ErrorInjectionSelector } from '@/screens/dev/components/ErrorInjectionSelector';
|
||||
import { LogLevelSelector } from '@/screens/dev/components/LogLevelSelector';
|
||||
import { ParameterSection } from '@/screens/dev/components/ParameterSection';
|
||||
@@ -37,8 +41,6 @@ const DevSettingsScreen: React.FC = () => {
|
||||
const setLoggingSeverity = useSettingStore(state => state.setLoggingSeverity);
|
||||
const useStrongBox = useSettingStore(state => state.useStrongBox);
|
||||
const setUseStrongBox = useSettingStore(state => state.setUseStrongBox);
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
const setKycEnabled = useSettingStore(state => state.setKycEnabled);
|
||||
|
||||
// Custom hooks
|
||||
const { hasNotificationPermission, subscribedTopics, handleTopicToggle } =
|
||||
@@ -49,8 +51,60 @@ const DevSettingsScreen: React.FC = () => {
|
||||
handleClearPointEventsPress,
|
||||
handleResetBackupStatePress,
|
||||
handleClearBackupEventsPress,
|
||||
handleClearPendingVerificationsPress,
|
||||
} = useDangerZoneActions();
|
||||
|
||||
const handleRemoveExpirationDateFlagPress = () => {
|
||||
Alert.alert(
|
||||
'Remove Expiration Date Flag',
|
||||
'Are you sure you want to remove the expiration date flag for the current (selected) document?.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const catalog = await loadDocumentCatalogDirectlyFromKeychain();
|
||||
const selectedDocumentId = catalog.selectedDocumentId;
|
||||
const selectedDocument = catalog.documents.find(
|
||||
document => document.id === selectedDocumentId,
|
||||
);
|
||||
|
||||
if (!selectedDocument) {
|
||||
Alert.alert(
|
||||
'No Document Selected',
|
||||
'Please select a document before removing the expiration date flag.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
delete selectedDocument.hasExpirationDate;
|
||||
|
||||
await saveDocumentCatalogDirectlyToKeychain(catalog);
|
||||
|
||||
Alert.alert(
|
||||
'Success',
|
||||
'Expiration date flag removed successfully.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to remove expiration date flag:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
Alert.alert(
|
||||
'Error',
|
||||
'Failed to remove expiration date flag. Please try again.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
@@ -67,8 +121,6 @@ const DevSettingsScreen: React.FC = () => {
|
||||
|
||||
{IS_DEV_MODE && (
|
||||
<DevTogglesSection
|
||||
kycEnabled={kycEnabled}
|
||||
setKycEnabled={setKycEnabled}
|
||||
useStrongBox={useStrongBox}
|
||||
setUseStrongBox={setUseStrongBox}
|
||||
/>
|
||||
@@ -107,6 +159,8 @@ const DevSettingsScreen: React.FC = () => {
|
||||
onClearPointEvents={handleClearPointEventsPress}
|
||||
onResetBackupState={handleResetBackupStatePress}
|
||||
onClearBackupEvents={handleClearBackupEventsPress}
|
||||
onClearPendingKyc={handleClearPendingVerificationsPress}
|
||||
onRemoveExpirationDateFlag={handleRemoveExpirationDateFlagPress}
|
||||
/>
|
||||
</YStack>
|
||||
</ScrollView>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ interface DangerZoneSectionProps {
|
||||
onClearPointEvents: () => void;
|
||||
onResetBackupState: () => void;
|
||||
onClearBackupEvents: () => void;
|
||||
onClearPendingKyc: () => void;
|
||||
onRemoveExpirationDateFlag: () => void;
|
||||
}
|
||||
|
||||
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
@@ -30,6 +32,8 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
onClearPointEvents,
|
||||
onResetBackupState,
|
||||
onClearBackupEvents,
|
||||
onClearPendingKyc,
|
||||
onRemoveExpirationDateFlag,
|
||||
}) => {
|
||||
const dangerActions = [
|
||||
{
|
||||
@@ -57,6 +61,16 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||
onPress: onClearBackupEvents,
|
||||
dangerTheme: true,
|
||||
},
|
||||
{
|
||||
label: 'Clear Pending KYC verifications',
|
||||
onPress: onClearPendingKyc,
|
||||
dangerTheme: true,
|
||||
},
|
||||
{
|
||||
label: 'Remove expiration date flag',
|
||||
onPress: onRemoveExpirationDateFlag,
|
||||
dangerTheme: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -10,15 +10,11 @@ import { ParameterSection } from '@/screens/dev/components/ParameterSection';
|
||||
import { TopicToggleButton } from '@/screens/dev/components/TopicToggleButton';
|
||||
|
||||
interface DevTogglesSectionProps {
|
||||
kycEnabled: boolean;
|
||||
setKycEnabled: (enabled: boolean) => void;
|
||||
useStrongBox: boolean;
|
||||
setUseStrongBox: (useStrongBox: boolean) => void;
|
||||
}
|
||||
|
||||
export const DevTogglesSection: React.FC<DevTogglesSectionProps> = ({
|
||||
kycEnabled,
|
||||
setKycEnabled,
|
||||
useStrongBox,
|
||||
setUseStrongBox,
|
||||
}) => {
|
||||
@@ -44,11 +40,6 @@ export const DevTogglesSection: React.FC<DevTogglesSectionProps> = ({
|
||||
title="Options"
|
||||
description="Development and security options"
|
||||
>
|
||||
<TopicToggleButton
|
||||
label="KYC Flow"
|
||||
isSubscribed={kycEnabled}
|
||||
onToggle={() => setKycEnabled(!kycEnabled)}
|
||||
/>
|
||||
{Platform.OS === 'android' && (
|
||||
<TopicToggleButton
|
||||
label="Use StrongBox"
|
||||
|
||||
@@ -30,7 +30,6 @@ import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
type AadhaarUploadErrorRouteParams = {
|
||||
@@ -67,7 +66,6 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<AadhaarUploadErrorRoute>();
|
||||
const { trackEvent } = useSelfClient();
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
|
||||
const errorType = route.params?.errorType || 'general';
|
||||
const { title, description } = getErrorMessages(errorType);
|
||||
@@ -82,9 +80,6 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
onError: () => {
|
||||
// Stay on this screen - user can try again
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Success - provider handles its own success UI
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -216,44 +211,40 @@ const AadhaarUploadErrorScreen: React.FC = () => {
|
||||
paddingBottom={paddingBottom}
|
||||
gap={10}
|
||||
>
|
||||
{kycEnabled && (
|
||||
<>
|
||||
{/* Secondary Button - White fill, black text, rounded */}
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={100}
|
||||
height={52}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={handleTryAlternative}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
fontFamily: dinot,
|
||||
color: black,
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</BodyText>
|
||||
</Button>
|
||||
{/* Secondary Button - White fill, black text, rounded */}
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={100}
|
||||
height={52}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={handleTryAlternative}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
fontFamily: dinot,
|
||||
color: black,
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</BodyText>
|
||||
</Button>
|
||||
|
||||
{/* Footer Text - Not italic */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify
|
||||
your document.
|
||||
</BodyText>
|
||||
</>
|
||||
)}
|
||||
{/* Footer Text - Not italic */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify your
|
||||
document.
|
||||
</BodyText>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
// Normalize idType for fuzzy matching (handles "drivers_licence", "NATIONAL ID", etc.)
|
||||
const normalized = applicantInfo.idType
|
||||
.toLowerCase()
|
||||
.replace(/[_\s]+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (normalized.includes('driver')) return "Driver's Licence";
|
||||
if (normalized.includes('passport')) return 'Passport';
|
||||
if (normalized.includes('aadhaar')) return 'Aadhaar';
|
||||
if (normalized.includes('national')) return 'National ID';
|
||||
if (normalized.includes('residence')) return 'Residence Permit';
|
||||
return 'ID Card';
|
||||
};
|
||||
|
||||
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)}
|
||||
|
||||
@@ -20,7 +20,6 @@ import useHapticNavigation from '@/hooks/useHapticNavigation';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flush as flushAnalytics } from '@/services/analytics';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
const tips: TipProps[] = [
|
||||
{
|
||||
@@ -55,7 +54,6 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
countryCode,
|
||||
errorSource: 'mrz_scan_failed',
|
||||
@@ -82,25 +80,21 @@ const DocumentCameraTroubleScreen: React.FC = () => {
|
||||
page quickly and clearly!
|
||||
</Caption>
|
||||
|
||||
{kycEnabled && (
|
||||
<>
|
||||
<Caption
|
||||
size="large"
|
||||
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
|
||||
>
|
||||
Or try an alternative verification method:
|
||||
</Caption>
|
||||
<Caption
|
||||
size="large"
|
||||
style={{ color: slate500, marginTop: 12, marginBottom: 8 }}
|
||||
>
|
||||
Or try an alternative verification method:
|
||||
</Caption>
|
||||
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
</SecondaryButton>
|
||||
</>
|
||||
)}
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { selectionChange } from '@/integrations/haptics';
|
||||
import SimpleScrolledTitleLayout from '@/layouts/SimpleScrolledTitleLayout';
|
||||
import { flushAllAnalytics } from '@/services/analytics';
|
||||
import { openSupportForm, SUPPORT_FORM_BUTTON_TEXT } from '@/services/support';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
const tips: TipProps[] = [
|
||||
{
|
||||
@@ -62,7 +61,6 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
const { useMRZStore } = selfClient;
|
||||
const { countryCode } = useMRZStore();
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
const { launchSumsubVerification, isLoading } = useSumsubLauncher({
|
||||
countryCode,
|
||||
errorSource: 'nfc_scan_failed',
|
||||
@@ -97,16 +95,14 @@ const DocumentNFCTroubleScreen: React.FC = () => {
|
||||
{SUPPORT_FORM_BUTTON_TEXT}
|
||||
</SecondaryButton>
|
||||
|
||||
{kycEnabled && (
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<SecondaryButton
|
||||
onPress={launchSumsubVerification}
|
||||
disabled={isLoading}
|
||||
textColor={slate700}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Try Alternative Verification'}
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
type RegistrationFallbackMRZRouteParams = {
|
||||
@@ -61,7 +60,6 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
const { trackEvent, useMRZStore } = selfClient;
|
||||
const storeCountryCode = useMRZStore(state => state.countryCode);
|
||||
const documentType = useMRZStore(state => state.documentType);
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
|
||||
// Use country code from route params, or fall back to MRZ store
|
||||
const countryCode = route.params?.countryCode || storeCountryCode || '';
|
||||
@@ -79,10 +77,6 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Success - provider handles its own success UI
|
||||
// The screen will be navigated away by the provider's flow
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -216,44 +210,40 @@ const RegistrationFallbackMRZScreen: React.FC = () => {
|
||||
paddingBottom={paddingBottom}
|
||||
gap={10}
|
||||
>
|
||||
{kycEnabled && (
|
||||
<>
|
||||
{/* Secondary Button - White fill, black text, rounded */}
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={100}
|
||||
height={52}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={handleTryAlternative}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
fontFamily: dinot,
|
||||
color: black,
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</BodyText>
|
||||
</Button>
|
||||
{/* Secondary Button - White fill, black text, rounded */}
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={100}
|
||||
height={52}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={handleTryAlternative}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
fontFamily: dinot,
|
||||
color: black,
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</BodyText>
|
||||
</Button>
|
||||
|
||||
{/* Footer Text - Not italic */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify
|
||||
your document.
|
||||
</BodyText>
|
||||
</>
|
||||
)}
|
||||
{/* Footer Text - Not italic */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify your
|
||||
document.
|
||||
</BodyText>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,6 @@ import { NavBar } from '@/components/navbar/BaseNavBar';
|
||||
import { useSumsubLauncher } from '@/hooks/useSumsubLauncher';
|
||||
import { buttonTap } from '@/integrations/haptics';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
type RegistrationFallbackNFCRouteParams = {
|
||||
@@ -62,7 +61,6 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
const { trackEvent, useMRZStore } = selfClient;
|
||||
const storeCountryCode = useMRZStore(state => state.countryCode);
|
||||
const documentType = useMRZStore(state => state.documentType);
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
|
||||
// Use country code from route params, or fall back to MRZ store
|
||||
const countryCode = route.params?.countryCode || storeCountryCode || '';
|
||||
@@ -80,10 +78,6 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
// Stay on this screen - user can try again
|
||||
// Error is already logged in the hook
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Success - provider handles its own success UI
|
||||
// The screen will be navigated away by the provider's flow
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -242,44 +236,40 @@ const RegistrationFallbackNFCScreen: React.FC = () => {
|
||||
paddingBottom={paddingBottom}
|
||||
gap={10}
|
||||
>
|
||||
{kycEnabled && (
|
||||
<>
|
||||
{/* Secondary Button - White fill, black text, rounded */}
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={100}
|
||||
height={52}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={handleTryAlternative}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
fontFamily: dinot,
|
||||
color: black,
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</BodyText>
|
||||
</Button>
|
||||
{/* Secondary Button - White fill, black text, rounded */}
|
||||
<Button
|
||||
backgroundColor={white}
|
||||
borderWidth={1}
|
||||
borderColor={slate200}
|
||||
borderRadius={100}
|
||||
height={52}
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={handleTryAlternative}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
fontFamily: dinot,
|
||||
color: black,
|
||||
}}
|
||||
>
|
||||
{isRetrying ? 'Loading...' : 'Try a different method'}
|
||||
</BodyText>
|
||||
</Button>
|
||||
|
||||
{/* Footer Text - Not italic */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify
|
||||
your document.
|
||||
</BodyText>
|
||||
</>
|
||||
)}
|
||||
{/* Footer Text - Not italic */}
|
||||
<BodyText
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
color: slate500,
|
||||
}}
|
||||
>
|
||||
Registering with alternative methods may take longer to verify your
|
||||
document.
|
||||
</BodyText>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import IDSelection from '@selfxyz/mobile-sdk-alpha/onboarding/id-selection-scree
|
||||
|
||||
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
import { extraYPadding } from '@/utils/styleUtils';
|
||||
|
||||
type IDPickerScreenRouteProp = RouteProp<RootStackParamList, 'IDPicker'>;
|
||||
@@ -22,7 +21,6 @@ const IDPickerScreen: React.FC = () => {
|
||||
const route = useRoute<IDPickerScreenRouteProp>();
|
||||
const { countryCode = '', documentTypes = [] } = route.params || {};
|
||||
const bottom = useSafeAreaInsets().bottom;
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
|
||||
return (
|
||||
<YStack
|
||||
@@ -31,11 +29,7 @@ const IDPickerScreen: React.FC = () => {
|
||||
paddingBottom={bottom + extraYPadding + 24}
|
||||
>
|
||||
<DocumentFlowNavBar title="GETTING STARTED" />
|
||||
<IDSelection
|
||||
countryCode={countryCode}
|
||||
documentTypes={documentTypes}
|
||||
showKyc={kycEnabled}
|
||||
/>
|
||||
<IDSelection countryCode={countryCode} documentTypes={documentTypes} />
|
||||
</YStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import type { RootStackParamList } from '@/navigation';
|
||||
import { useFeedback } from '@/providers/feedbackProvider';
|
||||
import { useSettingStore } from '@/stores/settingStore';
|
||||
|
||||
type LogoConfirmationScreenRouteProp = RouteProp<
|
||||
RootStackParamList,
|
||||
@@ -47,7 +46,6 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { showModal } = useFeedback();
|
||||
const navigateToOnboarding = useHapticNavigation('DocumentOnboarding');
|
||||
const kycEnabled = useSettingStore(state => state.kycEnabled);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
buttonTap();
|
||||
@@ -72,12 +70,27 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
});
|
||||
|
||||
// User cancelled/dismissed without completing verification
|
||||
const cancelledStatuses = ['Initial', 'Incomplete', 'Interrupted'];
|
||||
if (cancelledStatuses.includes(result.status)) {
|
||||
if (
|
||||
!result.success &&
|
||||
['Initial', 'Incomplete', 'Interrupted'].includes(result.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// User completed verification - navigate to KycSuccessScreen
|
||||
// Verification failed (provider error/rejection)
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
'Sumsub verification failed:',
|
||||
result.errorType ?? result.status,
|
||||
);
|
||||
navigation.navigate('KycFailure', {
|
||||
countryCode,
|
||||
canRetry: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verification succeeded - navigate to KycSuccessScreen
|
||||
navigation.navigate('KycSuccess', { userId: accessToken.userId });
|
||||
} catch {
|
||||
console.error('Error launching Sumsub verification');
|
||||
@@ -138,9 +151,7 @@ const LogoConfirmationScreen: React.FC = () => {
|
||||
<ExpandableBottomLayout.BottomSection backgroundColor={slate100}>
|
||||
<ButtonsContainer>
|
||||
<PrimaryButton onPress={handleConfirm}>Yes</PrimaryButton>
|
||||
{kycEnabled && (
|
||||
<SecondaryButton onPress={handleNotFound}>No</SecondaryButton>
|
||||
)}
|
||||
<SecondaryButton onPress={handleNotFound}>No</SecondaryButton>
|
||||
</ButtonsContainer>
|
||||
</ExpandableBottomLayout.BottomSection>
|
||||
</ExpandableBottomLayout.Layout>
|
||||
|
||||
@@ -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,14 @@ 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';
|
||||
import { isDocumentInactive } from '@/utils/documents';
|
||||
|
||||
const HomeScreen: React.FC = () => {
|
||||
const selfClient = useSelfClient();
|
||||
@@ -66,7 +69,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>({
|
||||
@@ -77,13 +81,23 @@ const HomeScreen: React.FC = () => {
|
||||
>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const hasIncrementedOnFocus = useRef(false);
|
||||
const [isSelectedDocumentInactive, setIsSelectedDocumentInactive] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
|
||||
const { pendingVerifications, removeExpiredVerifications } =
|
||||
usePendingKycStore();
|
||||
|
||||
useEffect(() => {
|
||||
removeExpiredVerifications();
|
||||
}, [removeExpiredVerifications]);
|
||||
|
||||
const activePendingVerifications = pendingVerifications.filter(
|
||||
v => v.status === 'pending' || v.status === 'processing',
|
||||
);
|
||||
|
||||
const { amount: selfPoints } = usePoints();
|
||||
|
||||
// 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
|
||||
|
||||
// DEV MODE: Test referral flow hook (only show alert when screen is focused)
|
||||
const isFocused = useIsFocused();
|
||||
const route = useRoute();
|
||||
@@ -116,12 +130,28 @@ const HomeScreen: React.FC = () => {
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const catalog = await loadDocumentCatalog();
|
||||
const docs = await getAllDocuments();
|
||||
|
||||
setDocumentCatalog(catalog);
|
||||
setAllDocuments(docs);
|
||||
|
||||
if (catalog.selectedDocumentId) {
|
||||
const documentData = docs[catalog.selectedDocumentId];
|
||||
|
||||
if (documentData) {
|
||||
try {
|
||||
setIsSelectedDocumentInactive(
|
||||
isDocumentInactive(documentData.metadata),
|
||||
);
|
||||
} catch (error) {
|
||||
// we don't want to block the home screen from loading
|
||||
console.warn('Failed to check if document is inactive:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load documents:', error);
|
||||
}
|
||||
@@ -158,10 +188,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 +252,92 @@ 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}
|
||||
isInactive={
|
||||
isSelected &&
|
||||
isSelectedDocumentInactive === true &&
|
||||
!metadata.mock
|
||||
}
|
||||
selected={isSelected}
|
||||
hidden={true}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<YStack
|
||||
elevation={8}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Image, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { ScrollView, Text, View, XStack, YStack } from 'tamagui';
|
||||
@@ -22,7 +22,10 @@ import CloudBackupIcon from '@/assets/icons/cloud_backup.svg';
|
||||
import PushNotificationsIcon from '@/assets/icons/push_notifications.svg';
|
||||
import StarIcon from '@/assets/icons/star.svg';
|
||||
import Referral from '@/assets/images/referral.png';
|
||||
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
import {
|
||||
getModalCallbacks,
|
||||
unregisterModalCallbacks,
|
||||
} from '@/utils/modalCallbackRegistry';
|
||||
|
||||
type PointsInfoScreenProps = StaticScreenProps<
|
||||
| {
|
||||
@@ -93,7 +96,34 @@ const PointsInfoScreen: React.FC<PointsInfoScreenProps> = ({
|
||||
}) => {
|
||||
const { showNextButton, callbackId } = params || {};
|
||||
const { left, right, bottom } = useSafeAreaInsets();
|
||||
const callbacks = callbackId ? getModalCallbacks(callbackId) : undefined;
|
||||
const callbacks = useMemo(
|
||||
() => (callbackId ? getModalCallbacks(callbackId) : undefined),
|
||||
[callbackId],
|
||||
);
|
||||
const buttonPressedRef = useRef(false);
|
||||
|
||||
// Handle button press: mark as pressed and call the callback
|
||||
const handleNextPress = useCallback(() => {
|
||||
if (callbackId !== undefined) {
|
||||
buttonPressedRef.current = true;
|
||||
}
|
||||
callbacks?.onButtonPress();
|
||||
}, [callbackId, callbacks]);
|
||||
|
||||
// Cleanup: Call onModalDismiss and unregister callbacks when component unmounts
|
||||
// Only call onModalDismiss if user navigated back (didn't press the button)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (callbackId !== undefined) {
|
||||
// Always unregister on unmount to prevent memory leaks
|
||||
if (!buttonPressedRef.current) {
|
||||
// User navigated back without pressing "Next" - call onModalDismiss to clear referrer
|
||||
callbacks?.onModalDismiss();
|
||||
}
|
||||
unregisterModalCallbacks(callbackId);
|
||||
}
|
||||
};
|
||||
}, [callbackId, callbacks]);
|
||||
|
||||
return (
|
||||
<YStack flex={1} gap={40} paddingBottom={bottom} backgroundColor={white}>
|
||||
@@ -140,7 +170,7 @@ const PointsInfoScreen: React.FC<PointsInfoScreenProps> = ({
|
||||
</ScrollView>
|
||||
{showNextButton && (
|
||||
<View paddingTop={20} paddingLeft={20 + left} paddingRight={20 + right}>
|
||||
<PrimaryButton onPress={callbacks?.onButtonPress}>Next</PrimaryButton>
|
||||
<PrimaryButton onPress={handleNextPress}>Next</PrimaryButton>
|
||||
</View>
|
||||
)}
|
||||
</YStack>
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } 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,75 @@ 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 [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const documentId = route.params?.documentId;
|
||||
|
||||
const handleGenerateProof = async () => {
|
||||
// Prevent multiple concurrent proof generations
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleGenerateProof = () => {
|
||||
buttonTap();
|
||||
navigation.navigate('ProvingScreenRouter');
|
||||
setIsLoading(true);
|
||||
|
||||
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);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,8 +116,9 @@ const KYCVerifiedScreen: React.FC = () => {
|
||||
bgColor={white}
|
||||
color={black}
|
||||
onPress={handleGenerateProof}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Generate proof
|
||||
{isLoading ? 'Generating...' : 'Generate proof'}
|
||||
</AbstractButton>
|
||||
</YStack>
|
||||
</View>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import type { DocumentMetadata } from '@selfxyz/common';
|
||||
import { isMRZDocument } from '@selfxyz/common';
|
||||
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
|
||||
@@ -51,10 +52,12 @@ import {
|
||||
} from '@/services/points';
|
||||
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
|
||||
import { ProofStatus } from '@/stores/proofTypes';
|
||||
import { registerModalCallbacks } from '@/utils';
|
||||
import {
|
||||
checkDocumentExpiration,
|
||||
getDocumentAttributes,
|
||||
} from '@/utils/documentAttributes';
|
||||
import { isDocumentInactive } from '@/utils/documents';
|
||||
import { getDocumentTypeName } from '@/utils/documentUtils';
|
||||
|
||||
const ProveScreen: React.FC = () => {
|
||||
@@ -85,6 +88,9 @@ const ProveScreen: React.FC = () => {
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
const hasInitializedScrollStateRef = useRef(false);
|
||||
|
||||
const [hasCheckedForInactiveDocument, setHasCheckedForInactiveDocument] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isContentShorterThanScrollView = useMemo(
|
||||
() => scrollViewContentHeight <= scrollViewHeight + 50,
|
||||
[scrollViewContentHeight, scrollViewHeight],
|
||||
@@ -114,8 +120,70 @@ const ProveScreen: React.FC = () => {
|
||||
|
||||
const { addProofHistory } = useProofHistoryStore();
|
||||
const { loadDocumentCatalog } = usePassport();
|
||||
const navigateToDocumentOnboarding = useCallback(
|
||||
(documentMetadata: DocumentMetadata) => {
|
||||
switch (documentMetadata.documentCategory) {
|
||||
case 'passport':
|
||||
case 'id_card':
|
||||
navigate('DocumentOnboarding');
|
||||
break;
|
||||
case 'aadhaar':
|
||||
navigate('AadhaarUpload', { countryCode: 'IND' });
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't check twice
|
||||
if (hasCheckedForInactiveDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkForInactiveDocument = async () => {
|
||||
const catalog = await loadDocumentCatalog();
|
||||
const selectedDocumentId = catalog.selectedDocumentId;
|
||||
|
||||
for (const documentMetadata of catalog.documents) {
|
||||
if (
|
||||
documentMetadata.id === selectedDocumentId &&
|
||||
isDocumentInactive(documentMetadata)
|
||||
) {
|
||||
const callbackId = registerModalCallbacks({
|
||||
onButtonPress: () => navigateToDocumentOnboarding(documentMetadata),
|
||||
onModalDismiss: () => navigate('Home' as never),
|
||||
});
|
||||
|
||||
navigate('Modal', {
|
||||
titleText: 'Your ID needs to be reactivated to continue',
|
||||
bodyText:
|
||||
'Make sure that you have your document and recovery method ready.',
|
||||
buttonText: 'Continue',
|
||||
secondaryButtonText: 'Not now',
|
||||
callbackId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setHasCheckedForInactiveDocument(true);
|
||||
};
|
||||
|
||||
checkForInactiveDocument();
|
||||
}, [
|
||||
loadDocumentCatalog,
|
||||
navigateToDocumentOnboarding,
|
||||
navigate,
|
||||
hasCheckedForInactiveDocument,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCheckedForInactiveDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addHistory = async () => {
|
||||
if (provingStore.uuid && selectedApp) {
|
||||
const catalog = await loadDocumentCatalog();
|
||||
@@ -137,9 +205,19 @@ const ProveScreen: React.FC = () => {
|
||||
}
|
||||
};
|
||||
addHistory();
|
||||
}, [addProofHistory, loadDocumentCatalog, provingStore.uuid, selectedApp]);
|
||||
}, [
|
||||
addProofHistory,
|
||||
provingStore.uuid,
|
||||
selectedApp,
|
||||
loadDocumentCatalog,
|
||||
hasCheckedForInactiveDocument,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCheckedForInactiveDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for actual measurements before determining initial scroll state
|
||||
// Both start at 0, causing false-positive on first render
|
||||
const hasMeasurements = scrollViewContentHeight > 0 && scrollViewHeight > 0;
|
||||
@@ -161,10 +239,11 @@ const ProveScreen: React.FC = () => {
|
||||
isContentShorterThanScrollView,
|
||||
scrollViewContentHeight,
|
||||
scrollViewHeight,
|
||||
hasCheckedForInactiveDocument,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused || !selectedApp) {
|
||||
if (!isFocused || !selectedApp || !hasCheckedForInactiveDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,12 +308,21 @@ const ProveScreen: React.FC = () => {
|
||||
//removed provingStore from dependencies because it causes infinite re-render on longpressing the button
|
||||
//as it sets provingStore.setUserConfirmed()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedApp?.sessionId, isFocused, selfClient]);
|
||||
}, [
|
||||
selectedApp?.sessionId,
|
||||
isFocused,
|
||||
selfClient,
|
||||
hasCheckedForInactiveDocument,
|
||||
]);
|
||||
|
||||
// Enhance selfApp with user's points address if not already set
|
||||
useEffect(() => {
|
||||
console.log('useEffect selectedApp', selectedApp);
|
||||
if (!selectedApp || selectedApp.selfDefinedData) {
|
||||
if (
|
||||
!selectedApp ||
|
||||
selectedApp.selfDefinedData ||
|
||||
!hasCheckedForInactiveDocument
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -277,11 +365,11 @@ const ProveScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
enhanceApp();
|
||||
}, [selectedApp, selfClient]);
|
||||
}, [selectedApp, selfClient, hasCheckedForInactiveDocument]);
|
||||
|
||||
function onVerify() {
|
||||
provingStore.setUserConfirmed(selfClient);
|
||||
buttonTap();
|
||||
provingStore.setUserConfirmed(selfClient);
|
||||
trackEvent(ProofEvents.PROOF_VERIFY_CONFIRMATION_ACCEPTED, {
|
||||
appName: selectedApp?.appName,
|
||||
sessionId: provingStore.uuid,
|
||||
@@ -388,6 +476,7 @@ const ProveScreen: React.FC = () => {
|
||||
isReadyToProve={isReadyToProve}
|
||||
isDocumentExpired={isDocumentExpired}
|
||||
testID="prove-screen-verify-bar"
|
||||
hasCheckedForInactiveDocument={hasCheckedForInactiveDocument}
|
||||
/>
|
||||
|
||||
{formattedUserId && selectedApp?.userId && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
108
app/src/stores/pendingKycStore.ts
Normal 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),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -21,7 +21,6 @@ interface PersistedSettingsState {
|
||||
homeScreenViewCount: number;
|
||||
incrementHomeScreenViewCount: () => void;
|
||||
isDevMode: boolean;
|
||||
kycEnabled: boolean;
|
||||
loggingSeverity: LoggingSeverity;
|
||||
pointsAddress: string | null;
|
||||
removeSubscribedTopic: (topic: string) => void;
|
||||
@@ -33,7 +32,6 @@ interface PersistedSettingsState {
|
||||
setFcmToken: (token: string | null) => void;
|
||||
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
|
||||
setKeychainMigrationCompleted: () => void;
|
||||
setKycEnabled: (enabled: boolean) => void;
|
||||
setLoggingSeverity: (severity: LoggingSeverity) => void;
|
||||
setPointsAddress: (address: string | null) => void;
|
||||
setSkipDocumentSelector: (value: boolean) => void;
|
||||
@@ -150,10 +148,6 @@ export const useSettingStore = create<SettingsState>()(
|
||||
useStrongBox: false,
|
||||
setUseStrongBox: (useStrongBox: boolean) => set({ useStrongBox }),
|
||||
|
||||
// KYC flow toggle (default: false, dev-only feature)
|
||||
kycEnabled: false,
|
||||
setKycEnabled: (enabled: boolean) => set({ kycEnabled: enabled }),
|
||||
|
||||
// Non-persisted state (will not be saved to storage)
|
||||
hideNetworkModal: false,
|
||||
setHideNetworkModal: (hideNetworkModal: boolean) => {
|
||||
|
||||
53
app/src/utils/cardBackgroundSelector.ts
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
22
app/src/utils/documents.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { DocumentMetadata } from '@selfxyz/common';
|
||||
|
||||
export const isDocumentInactive = (metadata: DocumentMetadata): boolean => {
|
||||
if (
|
||||
metadata.documentCategory === 'id_card' ||
|
||||
metadata.documentCategory === 'passport' ||
|
||||
metadata.documentCategory === 'kyc'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//for aadhaar migration
|
||||
if (metadata.hasExpirationDate === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
175
app/tests/src/components/homescreen/UnregisteredIdCard.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react-native';
|
||||
|
||||
import UnregisteredIdCard from '@/components/homescreen/UnregisteredIdCard';
|
||||
|
||||
jest.mock('react-native', () => ({
|
||||
__esModule: true,
|
||||
Image: ({ ...props }: any) => <mock-image {...props} />,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock Tamagui components
|
||||
jest.mock('tamagui', () => {
|
||||
const MockYStack = ({ children, onPress, ...props }: any) => (
|
||||
<div {...props} onClick={onPress}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
const MockXStack = ({ children, ...props }: any) => (
|
||||
<div {...props}>{children}</div>
|
||||
);
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<span {...props}>{children}</span>
|
||||
);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Text: MockText,
|
||||
XStack: MockXStack,
|
||||
YStack: MockYStack,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SVG
|
||||
jest.mock('@/assets/images/self_logo_inactive.svg', () => 'SelfLogoInactive');
|
||||
jest.mock('@/assets/images/wave_pattern_body.png', () => 'WavePatternBody');
|
||||
|
||||
// Mock hooks
|
||||
jest.mock('@/hooks/useCardDimensions', () => ({
|
||||
useCardDimensions: jest.fn(() => ({
|
||||
cardWidth: 300,
|
||||
borderRadius: 10,
|
||||
scale: 1,
|
||||
headerHeight: 80,
|
||||
figmaPadding: 16,
|
||||
logoSize: 40,
|
||||
headerGap: 10,
|
||||
expandedAspectRatio: 1.5,
|
||||
fontSize: {
|
||||
header: 18,
|
||||
subtitle: 12,
|
||||
button: 16,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('UnregisteredIdCard', () => {
|
||||
const mockOnRegisterPress = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
expect(() => {
|
||||
render(<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should display "UNREGISTERED ID" text', () => {
|
||||
const { root } = render(
|
||||
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
|
||||
);
|
||||
|
||||
const unregisteredText = root.findAll(
|
||||
node => node.type === 'span' && node.props.children === 'UNREGISTERED ID',
|
||||
);
|
||||
expect(unregisteredText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should display "Complete Registration" button text', () => {
|
||||
const { root } = render(
|
||||
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
|
||||
);
|
||||
|
||||
const buttonText = root.findAll(
|
||||
node =>
|
||||
node.type === 'span' && node.props.children === 'Complete Registration',
|
||||
);
|
||||
expect(buttonText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should call onRegisterPress when button is pressed', () => {
|
||||
const { root } = render(
|
||||
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
|
||||
);
|
||||
|
||||
// Find the clickable YStack (button container)
|
||||
const buttonContainers = root.findAll(
|
||||
node => node.type === 'div' && node.props.onClick,
|
||||
);
|
||||
|
||||
// Find the button with "Complete Registration" text
|
||||
const registerButton = buttonContainers.find(container => {
|
||||
const textNodes = container.findAll(
|
||||
node =>
|
||||
node.type === 'span' &&
|
||||
node.props.children === 'Complete Registration',
|
||||
);
|
||||
return textNodes.length > 0;
|
||||
});
|
||||
|
||||
expect(registerButton).toBeTruthy();
|
||||
|
||||
// Simulate press by calling onClick directly
|
||||
registerButton!.props.onClick();
|
||||
|
||||
expect(mockOnRegisterPress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have button accessibility role', () => {
|
||||
const { root } = render(
|
||||
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
|
||||
);
|
||||
|
||||
// Find the YStack with accessibilityRole="button"
|
||||
const buttonWithRole = root.findAll(
|
||||
node =>
|
||||
node.type === 'div' && node.props.accessibilityRole === 'button',
|
||||
);
|
||||
|
||||
expect(buttonWithRole.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible label for screen readers', () => {
|
||||
const { root } = render(
|
||||
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
|
||||
);
|
||||
|
||||
// Find the YStack with accessibilityLabel
|
||||
const buttonWithLabel = root.findAll(
|
||||
node =>
|
||||
node.type === 'div' &&
|
||||
node.props.accessibilityLabel === 'Complete Registration',
|
||||
);
|
||||
|
||||
expect(buttonWithLabel.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have both accessibility role and label on the same element', () => {
|
||||
const { root } = render(
|
||||
<UnregisteredIdCard onRegisterPress={mockOnRegisterPress} />,
|
||||
);
|
||||
|
||||
// Find the YStack with both properties
|
||||
const accessibleButton = root.findAll(
|
||||
node =>
|
||||
node.type === 'div' &&
|
||||
node.props.accessibilityRole === 'button' &&
|
||||
node.props.accessibilityLabel === 'Complete Registration',
|
||||
);
|
||||
|
||||
expect(accessibleButton.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
app/tests/src/hooks/usePendingKycRecovery.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// 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 { renderHook, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import { usePendingKycRecovery } from '@/hooks/usePendingKycRecovery';
|
||||
import { navigationRef } from '@/navigation';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/hooks/useSumsubWebSocket', () => ({
|
||||
useSumsubWebSocket: jest.fn(() => ({
|
||||
subscribe: jest.fn(),
|
||||
unsubscribeAll: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/pendingKycStore', () => ({
|
||||
usePendingKycStore: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/navigation', () => ({
|
||||
navigationRef: {
|
||||
isReady: jest.fn(),
|
||||
navigate: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockNavigationRef = navigationRef as jest.Mocked<typeof navigationRef>;
|
||||
|
||||
describe('usePendingKycRecovery', () => {
|
||||
const mockSubscribe = jest.fn();
|
||||
const mockUnsubscribeAll = jest.fn();
|
||||
const mockRemoveExpiredVerifications = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Setup default mocks
|
||||
const { useSumsubWebSocket } = jest.requireMock(
|
||||
'@/hooks/useSumsubWebSocket',
|
||||
);
|
||||
useSumsubWebSocket.mockReturnValue({
|
||||
subscribe: mockSubscribe,
|
||||
unsubscribeAll: mockUnsubscribeAll,
|
||||
});
|
||||
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
mockNavigationRef.isReady.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should remove expired verifications on mount', () => {
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
expect(mockRemoveExpiredVerifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should unsubscribe all on unmount', () => {
|
||||
const { unmount } = renderHook(() => usePendingKycRecovery());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockUnsubscribeAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should navigate to KYCVerified when processing verification exists and navigation is ready', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
status: 'processing',
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
mockNavigationRef.isReady.mockReturnValue(true);
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KYCVerified', {
|
||||
documentId: 'doc-456',
|
||||
});
|
||||
});
|
||||
|
||||
it('should poll for navigation readiness when not initially ready', async () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
status: 'processing',
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
// Navigation not ready initially
|
||||
mockNavigationRef.isReady.mockReturnValue(false);
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
// Should not navigate immediately
|
||||
expect(mockNavigationRef.navigate).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate navigation becoming ready after 300ms
|
||||
jest.advanceTimersByTime(300);
|
||||
mockNavigationRef.isReady.mockReturnValue(true);
|
||||
|
||||
// Advance timers to trigger polling
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KYCVerified', {
|
||||
documentId: 'doc-456',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not attempt recovery for same userId twice', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
const verification = {
|
||||
userId: 'user-123',
|
||||
status: 'processing' as const,
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
};
|
||||
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [verification],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
mockNavigationRef.isReady.mockReturnValue(true);
|
||||
|
||||
const { rerender } = renderHook(() => usePendingKycRecovery());
|
||||
|
||||
expect(mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with same verification
|
||||
rerender();
|
||||
|
||||
// Should not navigate again for same userId
|
||||
expect(mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should subscribe to pending verification when no processing verification exists', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-789',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
expect(mockSubscribe).toHaveBeenCalledWith('user-789');
|
||||
});
|
||||
|
||||
it('should skip expired verifications', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-expired',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() - 1000, // Expired
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
// Should not subscribe to expired verification
|
||||
expect(mockSubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clean up polling interval on unmount', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
status: 'processing',
|
||||
documentId: 'doc-456',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
mockNavigationRef.isReady.mockReturnValue(false);
|
||||
|
||||
const { unmount } = renderHook(() => usePendingKycRecovery());
|
||||
|
||||
// Advance timers to ensure interval is created
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
// Unmount should clear the interval
|
||||
unmount();
|
||||
|
||||
// Advance timers further - navigate should not be called after unmount
|
||||
mockNavigationRef.isReady.mockReturnValue(true);
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
expect(mockNavigationRef.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prioritize processing verification over pending verification', () => {
|
||||
const { usePendingKycStore } = jest.requireMock('@/stores/pendingKycStore');
|
||||
usePendingKycStore.mockReturnValue({
|
||||
pendingVerifications: [
|
||||
{
|
||||
userId: 'user-pending',
|
||||
status: 'pending',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
{
|
||||
userId: 'user-processing',
|
||||
status: 'processing',
|
||||
documentId: 'doc-789',
|
||||
timeoutAt: Date.now() + 10000,
|
||||
},
|
||||
],
|
||||
removeExpiredVerifications: mockRemoveExpiredVerifications,
|
||||
});
|
||||
|
||||
mockNavigationRef.isReady.mockReturnValue(true);
|
||||
|
||||
renderHook(() => usePendingKycRecovery());
|
||||
|
||||
// Should navigate to processing verification, not subscribe to pending
|
||||
expect(mockNavigationRef.navigate).toHaveBeenCalledWith('KYCVerified', {
|
||||
documentId: 'doc-789',
|
||||
});
|
||||
expect(mockSubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
// This pattern avoids hoisting issues with jest.mock
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import { scan } from '@/integrations/nfc/nfcScanner';
|
||||
import { parseScanResponse, scan } from '@/integrations/nfc/nfcScanner';
|
||||
import { PassportReader } from '@/integrations/nfc/passportReader';
|
||||
|
||||
// Declare global variable for platform OS that can be modified per-test
|
||||
@@ -37,23 +37,6 @@ jest.mock('react-native', () => ({
|
||||
// Ensure the Node Buffer implementation is available to the module under test
|
||||
global.Buffer = Buffer;
|
||||
|
||||
// The static import above captures Platform.OS at load time. To test different platforms,
|
||||
// we need to clear the module cache and re-import with the current global.mockPlatformOS.
|
||||
const getFreshParseScanResponse = () => {
|
||||
jest.resetModules();
|
||||
jest.doMock('react-native', () => ({
|
||||
Platform: {
|
||||
get OS() {
|
||||
return global.mockPlatformOS;
|
||||
},
|
||||
Version: 14,
|
||||
select: (obj: Record<string, unknown>) =>
|
||||
obj[global.mockPlatformOS] || obj.default,
|
||||
},
|
||||
}));
|
||||
return require('@/integrations/nfc/nfcScanner').parseScanResponse;
|
||||
};
|
||||
|
||||
describe('parseScanResponse', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -63,7 +46,6 @@ describe('parseScanResponse', () => {
|
||||
|
||||
it('parses iOS response', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const parseScanResponse = getFreshParseScanResponse();
|
||||
const mrz =
|
||||
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
|
||||
const response = JSON.stringify({
|
||||
@@ -128,7 +110,6 @@ describe('parseScanResponse', () => {
|
||||
it('parses Android response', () => {
|
||||
// Set Platform.OS to android for this test
|
||||
global.mockPlatformOS = 'android';
|
||||
const parseScanResponse = getFreshParseScanResponse();
|
||||
|
||||
const mrz =
|
||||
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
|
||||
@@ -196,7 +177,6 @@ describe('parseScanResponse', () => {
|
||||
|
||||
it('handles malformed iOS response', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const parseScanResponse = getFreshParseScanResponse();
|
||||
const response = '{"invalid": "json"';
|
||||
|
||||
expect(() => parseScanResponse(response)).toThrow();
|
||||
@@ -205,7 +185,6 @@ describe('parseScanResponse', () => {
|
||||
it('handles malformed Android response', () => {
|
||||
// Set Platform.OS to android for this test
|
||||
global.mockPlatformOS = 'android';
|
||||
const parseScanResponse = getFreshParseScanResponse();
|
||||
|
||||
const response = {
|
||||
mrz: 'valid_mrz',
|
||||
@@ -218,7 +197,6 @@ describe('parseScanResponse', () => {
|
||||
|
||||
it('handles missing required fields', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const parseScanResponse = getFreshParseScanResponse();
|
||||
const response = JSON.stringify({
|
||||
// Providing minimal data but missing critical passportMRZ field
|
||||
dataGroupHashes: JSON.stringify({
|
||||
@@ -238,7 +216,6 @@ describe('parseScanResponse', () => {
|
||||
|
||||
it('handles invalid hex data in dataGroupHashes', () => {
|
||||
// Platform.OS is already mocked as 'ios' by default
|
||||
const parseScanResponse = getFreshParseScanResponse();
|
||||
const response = JSON.stringify({
|
||||
dataGroupHashes: JSON.stringify({
|
||||
DG1: { sodHash: 'invalid_hex' },
|
||||
|
||||
@@ -113,7 +113,6 @@ describe('navigation', () => {
|
||||
'ShowRecoveryPhrase',
|
||||
'Splash',
|
||||
'StarfallPushCode',
|
||||
'SumsubTest',
|
||||
'WebView',
|
||||
]);
|
||||
});
|
||||
|
||||
316
app/tests/src/screens/dev/DevSettingsScreen.test.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { render, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import DevSettingsScreen from '@/screens/dev/DevSettingsScreen';
|
||||
|
||||
// Mock Alert
|
||||
jest.spyOn(Alert, 'alert');
|
||||
|
||||
// Mock react-native
|
||||
jest.mock('react-native', () => ({
|
||||
__esModule: true,
|
||||
Alert: {
|
||||
alert: jest.fn(),
|
||||
},
|
||||
ScrollView: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(() => ({ bottom: 0 })),
|
||||
}));
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(() => ({ navigate: jest.fn() })),
|
||||
}));
|
||||
|
||||
// Mock Tamagui
|
||||
jest.mock('tamagui', () => ({
|
||||
YStack: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks and stores
|
||||
jest.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: jest.fn(selector => {
|
||||
const state = {
|
||||
loggingSeverity: 'info',
|
||||
setLoggingSeverity: jest.fn(),
|
||||
useStrongBox: false,
|
||||
setUseStrongBox: jest.fn(),
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/providers/passportDataProvider', () => ({
|
||||
loadDocumentCatalogDirectlyFromKeychain: jest.fn(),
|
||||
saveDocumentCatalogDirectlyToKeychain: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/screens/dev/hooks/useDangerZoneActions', () => ({
|
||||
useDangerZoneActions: jest.fn(() => ({
|
||||
handleClearSecretsPress: jest.fn(),
|
||||
handleClearDocumentCatalogPress: jest.fn(),
|
||||
handleClearPointEventsPress: jest.fn(),
|
||||
handleResetBackupStatePress: jest.fn(),
|
||||
handleClearBackupEventsPress: jest.fn(),
|
||||
handleClearPendingVerificationsPress: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/screens/dev/hooks/useNotificationHandlers', () => ({
|
||||
useNotificationHandlers: jest.fn(() => ({
|
||||
hasNotificationPermission: false,
|
||||
subscribedTopics: [],
|
||||
handleTopicToggle: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock sections
|
||||
jest.mock('@/screens/dev/sections', () => ({
|
||||
DangerZoneSection: ({ onRemoveExpirationDateFlag, ...props }: any) => (
|
||||
<div {...props}>
|
||||
<button onClick={onRemoveExpirationDateFlag}>
|
||||
Remove Expiration Date Flag
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
DebugShortcutsSection: () => <div>DebugShortcuts</div>,
|
||||
DevTogglesSection: () => <div>DevToggles</div>,
|
||||
PushNotificationsSection: () => <div>PushNotifications</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/screens/dev/components/ParameterSection', () => ({
|
||||
ParameterSection: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/screens/dev/components/LogLevelSelector', () => ({
|
||||
LogLevelSelector: () => <div>LogLevelSelector</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/screens/dev/components/ErrorInjectionSelector', () => ({
|
||||
ErrorInjectionSelector: () => <div>ErrorInjectionSelector</div>,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ErrorBoundary', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock icons
|
||||
jest.mock('@/assets/icons/bug_icon.svg', () => 'BugIcon');
|
||||
|
||||
describe('DevSettingsScreen - handleRemoveExpirationDateFlagPress', () => {
|
||||
let mockLoadDocumentCatalog: jest.Mock;
|
||||
let mockSaveDocumentCatalog: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const passportProvider = jest.requireMock(
|
||||
'@/providers/passportDataProvider',
|
||||
);
|
||||
mockLoadDocumentCatalog =
|
||||
passportProvider.loadDocumentCatalogDirectlyFromKeychain;
|
||||
mockSaveDocumentCatalog =
|
||||
passportProvider.saveDocumentCatalogDirectlyToKeychain;
|
||||
});
|
||||
|
||||
it('should show confirmation alert when Remove Expiration Date Flag is pressed', () => {
|
||||
const { root } = render(<DevSettingsScreen />);
|
||||
|
||||
const button = root.findByType('button');
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
button.props.onClick();
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Remove Expiration Date Flag',
|
||||
'Are you sure you want to remove the expiration date flag for the current (selected) document?.',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ text: 'Cancel', style: 'cancel' }),
|
||||
expect.objectContaining({ text: 'Remove', style: 'destructive' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully remove expiration date flag when document is selected', async () => {
|
||||
const mockCatalog = {
|
||||
selectedDocumentId: 'doc-123',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-123',
|
||||
hasExpirationDate: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLoadDocumentCatalog.mockResolvedValue(mockCatalog);
|
||||
mockSaveDocumentCatalog.mockResolvedValue(undefined);
|
||||
|
||||
const { root } = render(<DevSettingsScreen />);
|
||||
|
||||
const button = root.findByType('button');
|
||||
button.props.onClick();
|
||||
|
||||
// Get the onPress callback from the alert
|
||||
const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
|
||||
const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
|
||||
|
||||
// Execute the remove action
|
||||
await removeButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockLoadDocumentCatalog).toHaveBeenCalled();
|
||||
expect(mockSaveDocumentCatalog).toHaveBeenCalledWith({
|
||||
selectedDocumentId: 'doc-123',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-123',
|
||||
// hasExpirationDate should be deleted
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Success alert should be shown
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Success',
|
||||
'Expiration date flag removed successfully.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error alert when no document is selected', async () => {
|
||||
const mockCatalog = {
|
||||
selectedDocumentId: 'non-existent-doc',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-123',
|
||||
hasExpirationDate: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLoadDocumentCatalog.mockResolvedValue(mockCatalog);
|
||||
|
||||
const { root } = render(<DevSettingsScreen />);
|
||||
|
||||
const button = root.findByType('button');
|
||||
button.props.onClick();
|
||||
|
||||
const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
|
||||
const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
|
||||
|
||||
await removeButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'No Document Selected',
|
||||
'Please select a document before removing the expiration date flag.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
});
|
||||
|
||||
// Should not attempt to save
|
||||
expect(mockSaveDocumentCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error alert when loadDocumentCatalog fails', async () => {
|
||||
const mockError = new Error('Failed to load catalog');
|
||||
mockLoadDocumentCatalog.mockRejectedValue(mockError);
|
||||
|
||||
// Mock console.error to avoid test output clutter
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const { root } = render(<DevSettingsScreen />);
|
||||
|
||||
const button = root.findByType('button');
|
||||
button.props.onClick();
|
||||
|
||||
const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
|
||||
const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
|
||||
|
||||
await removeButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to remove expiration date flag:',
|
||||
'Failed to load catalog',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Failed to remove expiration date flag. Please try again.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should show error alert when saveDocumentCatalog fails', async () => {
|
||||
const mockCatalog = {
|
||||
selectedDocumentId: 'doc-123',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-123',
|
||||
hasExpirationDate: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockLoadDocumentCatalog.mockResolvedValue(mockCatalog);
|
||||
mockSaveDocumentCatalog.mockRejectedValue(new Error('Failed to save'));
|
||||
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const { root } = render(<DevSettingsScreen />);
|
||||
|
||||
const button = root.findByType('button');
|
||||
button.props.onClick();
|
||||
|
||||
const alertCall = (Alert.alert as jest.Mock).mock.calls[0];
|
||||
const removeButton = alertCall[2].find((btn: any) => btn.text === 'Remove');
|
||||
|
||||
await removeButton.onPress();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Failed to remove expiration date flag. Please try again.',
|
||||
[{ text: 'OK' }],
|
||||
);
|
||||
});
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not call saveDocumentCatalog when user cancels', async () => {
|
||||
const { root } = render(<DevSettingsScreen />);
|
||||
|
||||
const button = root.findByType('button');
|
||||
button.props.onClick();
|
||||
|
||||
// User cancels - should not load or save anything
|
||||
expect(mockLoadDocumentCatalog).not.toHaveBeenCalled();
|
||||
expect(mockSaveDocumentCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
339
app/tests/src/screens/home/PointsInfoScreen.test.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react-native';
|
||||
|
||||
import PointsInfoScreen from '@/screens/home/PointsInfoScreen';
|
||||
import { unregisterModalCallbacks } from '@/utils/modalCallbackRegistry';
|
||||
|
||||
jest.mock('react-native', () => {
|
||||
const MockView = ({ children, ...props }: any) => (
|
||||
<mock-view {...props}>{children}</mock-view>
|
||||
);
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<mock-text {...props}>{children}</mock-text>
|
||||
);
|
||||
const MockImage = ({ ...props }: any) => <mock-image {...props} />;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
Image: MockImage,
|
||||
Platform: { OS: 'ios', select: jest.fn() },
|
||||
StyleSheet: {
|
||||
create: (styles: any) => styles,
|
||||
flatten: (style: any) => style,
|
||||
},
|
||||
Text: MockText,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Tamagui components
|
||||
jest.mock('tamagui', () => {
|
||||
const View: any = 'View';
|
||||
const Text: any = 'Text';
|
||||
const createViewComponent = (displayName: string) => {
|
||||
const MockComponent = ({ children, ...props }: any) => (
|
||||
<View {...props} testID={displayName}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
MockComponent.displayName = displayName;
|
||||
return MockComponent;
|
||||
};
|
||||
|
||||
const MockYStack = createViewComponent('YStack');
|
||||
const MockXStack = createViewComponent('XStack');
|
||||
const MockView = createViewComponent('View');
|
||||
const MockScrollView = createViewComponent('ScrollView');
|
||||
|
||||
const MockText = ({ children, ...props }: any) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
);
|
||||
MockText.displayName = 'Text';
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
YStack: MockYStack,
|
||||
XStack: MockXStack,
|
||||
View: MockView,
|
||||
Text: MockText,
|
||||
ScrollView: MockScrollView,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock mobile SDK components
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
|
||||
PrimaryButton: ({ children, onPress, ...props }: any) => (
|
||||
<mock-view {...props} onPress={onPress} testID="primary-button">
|
||||
{children}
|
||||
</mock-view>
|
||||
),
|
||||
Title: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock SVG icons
|
||||
jest.mock('@/assets/icons/checkmark_square.svg', () => 'CheckmarkSquareIcon');
|
||||
jest.mock('@/assets/icons/cloud_backup.svg', () => 'CloudBackupIcon');
|
||||
jest.mock(
|
||||
'@/assets/icons/push_notifications.svg',
|
||||
() => 'PushNotificationsIcon',
|
||||
);
|
||||
jest.mock('@/assets/icons/star.svg', () => 'StarIcon');
|
||||
|
||||
// Mock images
|
||||
jest.mock('@/assets/images/referral.png', () => 'ReferralImage');
|
||||
|
||||
jest.mock('@/utils/modalCallbackRegistry', () => ({
|
||||
getModalCallbacks: jest.fn(),
|
||||
registerModalCallbacks: jest.fn(),
|
||||
unregisterModalCallbacks: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUnregisterModalCallbacks =
|
||||
unregisterModalCallbacks as jest.MockedFunction<
|
||||
typeof unregisterModalCallbacks
|
||||
>;
|
||||
|
||||
// Mock getModalCallbacks at module level
|
||||
const { getModalCallbacks } = jest.requireMock('@/utils/modalCallbackRegistry');
|
||||
|
||||
describe('PointsInfoScreen', () => {
|
||||
const mockOnButtonPress = jest.fn();
|
||||
const mockOnModalDismiss = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup getModalCallbacks to return our mock callbacks
|
||||
getModalCallbacks.mockImplementation((id: number) => {
|
||||
if (id === 1) {
|
||||
return {
|
||||
onButtonPress: mockOnButtonPress,
|
||||
onModalDismiss: mockOnModalDismiss,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
expect(() => {
|
||||
render(<PointsInfoScreen route={{ params: undefined }} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not show Next button when showNextButton is false', () => {
|
||||
const { queryByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: false, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify button is not rendered
|
||||
const nextButton = queryByTestId('primary-button');
|
||||
expect(nextButton).toBeNull();
|
||||
});
|
||||
|
||||
it('should show Next button when showNextButton is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify button is rendered
|
||||
const nextButton = getByTestId('primary-button');
|
||||
expect(nextButton).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Callback handling', () => {
|
||||
it('should call onModalDismiss and unregister callbacks when component unmounts without button press', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially, no callbacks should be called
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
expect(mockOnButtonPress).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount the component (simulating user navigating back)
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// onModalDismiss should be called to clear referrer
|
||||
expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
|
||||
// Callbacks should be unregistered to prevent memory leak
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
// onButtonPress should not be called (user didn't press the button)
|
||||
expect(mockOnButtonPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onModalDismiss on unmount even when showNextButton is false', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: false, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Callbacks should be called even if button is not shown (callbackId is present)
|
||||
expect(mockOnModalDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle missing callbacks gracefully', () => {
|
||||
// Mock getModalCallbacks to return undefined
|
||||
getModalCallbacks.mockReturnValue(undefined);
|
||||
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 999 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not throw when unmounting with missing callbacks
|
||||
expect(() => {
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still attempt to unregister
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(999);
|
||||
});
|
||||
|
||||
it('should handle missing callbackId gracefully', () => {
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen route={{ params: { showNextButton: true } }} />,
|
||||
);
|
||||
|
||||
// Should not throw when unmounting without callbackId
|
||||
expect(() => {
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
// Should not attempt to unregister if no callbackId
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button press handling', () => {
|
||||
it('should call onButtonPress and unregister callbacks when Next button is pressed, then not call onModalDismiss on unmount', () => {
|
||||
const { getByTestId, unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const primaryButton = getByTestId('primary-button');
|
||||
|
||||
// Press the button
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
// onButtonPress should be called
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
|
||||
// Callbacks should NOT be unregistered yet (component still mounted)
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
// onModalDismiss should NOT be called (button was pressed)
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
|
||||
// Clear mock calls from button press
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Unmount the component
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// onModalDismiss should NOT be called (button was pressed, not navigated back)
|
||||
expect(mockOnModalDismiss).not.toHaveBeenCalled();
|
||||
// Callbacks should be unregistered to prevent memory leak
|
||||
expect(mockUnregisterModalCallbacks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should allow multiple button presses without unregistering callbacks (regression test)', () => {
|
||||
const { getByTestId } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const primaryButton = getByTestId('primary-button');
|
||||
|
||||
// Press the button first time
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(1);
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
|
||||
// Press the button again (simulating returning to this screen after modal dismissal)
|
||||
act(() => {
|
||||
primaryButton.props.onPress();
|
||||
});
|
||||
|
||||
// onButtonPress should be called again
|
||||
expect(mockOnButtonPress).toHaveBeenCalledTimes(2);
|
||||
// Callbacks should still NOT be unregistered (component still mounted)
|
||||
expect(mockUnregisterModalCallbacks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Referrer cleanup integration', () => {
|
||||
it('should ensure cleanup is called in correct order for referrer clearing', () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const onModalDismissWithTracking = jest.fn(() => {
|
||||
callOrder.push('onModalDismiss');
|
||||
});
|
||||
|
||||
const unregisterWithTracking = jest.fn(() => {
|
||||
callOrder.push('unregister');
|
||||
});
|
||||
|
||||
getModalCallbacks.mockReturnValue({
|
||||
onButtonPress: mockOnButtonPress,
|
||||
onModalDismiss: onModalDismissWithTracking,
|
||||
});
|
||||
|
||||
mockUnregisterModalCallbacks.mockImplementation(unregisterWithTracking);
|
||||
|
||||
const { unmount } = render(
|
||||
<PointsInfoScreen
|
||||
route={{ params: { showNextButton: true, callbackId: 1 } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
// Verify onModalDismiss is called before unregister
|
||||
expect(callOrder).toEqual(['onModalDismiss', 'unregister']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,7 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { fireEvent, render } from '@testing-library/react-native';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react-native';
|
||||
|
||||
import * as haptics from '@/integrations/haptics';
|
||||
import KYCVerifiedScreen from '@/screens/kyc/KYCVerifiedScreen';
|
||||
@@ -35,6 +34,9 @@ jest.mock('react-native-safe-area-context', () => ({
|
||||
|
||||
jest.mock('@react-navigation/native', () => ({
|
||||
useNavigation: jest.fn(),
|
||||
useRoute: jest.fn(() => ({
|
||||
params: { documentId: 'test-document-id' },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Tamagui components
|
||||
@@ -81,19 +83,33 @@ jest.mock('@/config/sentry', () => ({
|
||||
captureException: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseNavigation = useNavigation as jest.MockedFunction<
|
||||
typeof useNavigation
|
||||
>;
|
||||
const mockEmit = jest.fn();
|
||||
const mockSelfClient = { emit: mockEmit };
|
||||
|
||||
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
|
||||
useSelfClient: jest.fn(() => mockSelfClient),
|
||||
loadSelectedDocument: jest.fn(() =>
|
||||
Promise.resolve({ documentCategory: 'kyc' }),
|
||||
),
|
||||
SdkEvents: {
|
||||
DOCUMENT_OWNERSHIP_CONFIRMED: 'DOCUMENT_OWNERSHIP_CONFIRMED',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/stores/pendingKycStore', () => ({
|
||||
usePendingKycStore: jest.fn(() => ({
|
||||
pendingVerifications: [],
|
||||
removePendingVerification: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@/providers/passportDataProvider', () => ({
|
||||
setSelectedDocument: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
describe('KYCVerifiedScreen', () => {
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockUseNavigation.mockReturnValue({
|
||||
navigate: mockNavigate,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('should render the screen without errors', () => {
|
||||
@@ -140,17 +156,98 @@ describe('KYCVerifiedScreen', () => {
|
||||
expect(haptics.buttonTap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should navigate to ProvingScreenRouter when "Generate proof" is pressed', () => {
|
||||
it('should emit DOCUMENT_OWNERSHIP_CONFIRMED when "Generate proof" is pressed', async () => {
|
||||
const { root } = render(<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();
|
||||
});
|
||||
|
||||
describe('Loading state', () => {
|
||||
it('should show "Generating..." text while loading', async () => {
|
||||
const { root } = render(<KYCVerifiedScreen />);
|
||||
const button = root.findAllByType('button')[0];
|
||||
|
||||
// Initially shows "Generate proof"
|
||||
expect(button.props.children).toBe('Generate proof');
|
||||
expect(button.props.disabled).toBeFalsy();
|
||||
|
||||
// Press the button
|
||||
fireEvent.press(button);
|
||||
|
||||
// Should show "Generating..." while loading
|
||||
await waitFor(() => {
|
||||
const updatedButton = root.findAllByType('button')[0];
|
||||
expect(updatedButton.props.children).toBe('Generating...');
|
||||
expect(updatedButton.props.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent multiple concurrent proof generations', async () => {
|
||||
const { root } = render(<KYCVerifiedScreen />);
|
||||
const button = root.findAllByType('button')[0];
|
||||
|
||||
// Press the button multiple times rapidly
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
fireEvent.press(button);
|
||||
|
||||
await waitFor(() => {
|
||||
// Emit should only be called once
|
||||
expect(mockEmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-enable button after proof generation completes', async () => {
|
||||
const { root } = render(<KYCVerifiedScreen />);
|
||||
const button = root.findAllByType('button')[0];
|
||||
|
||||
fireEvent.press(button);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await waitFor(() => {
|
||||
expect(mockEmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Button should be re-enabled after completion
|
||||
await waitFor(() => {
|
||||
const updatedButton = root.findAllByType('button')[0];
|
||||
expect(updatedButton.props.disabled).toBeFalsy();
|
||||
expect(updatedButton.props.children).toBe('Generate proof');
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-enable button after error', async () => {
|
||||
// Mock an error in setSelectedDocument
|
||||
const { setSelectedDocument } = jest.requireMock(
|
||||
'@/providers/passportDataProvider',
|
||||
);
|
||||
setSelectedDocument.mockRejectedValueOnce(new Error('Test error'));
|
||||
|
||||
const { root } = render(<KYCVerifiedScreen />);
|
||||
const button = root.findAllByType('button')[0];
|
||||
|
||||
fireEvent.press(button);
|
||||
|
||||
// Wait for error handling
|
||||
await waitFor(() => {
|
||||
const updatedButton = root.findAllByType('button')[0];
|
||||
expect(updatedButton.props.disabled).toBeFalsy();
|
||||
expect(updatedButton.props.children).toBe('Generate proof');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<
|
||||
|
||||
62
app/tests/src/utils/cardBackgroundSelector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
132
app/tests/src/utils/documents.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { DocumentMetadata } from '@selfxyz/common';
|
||||
|
||||
import { isDocumentInactive } from '@/utils/documents';
|
||||
|
||||
const createMockMetadata = (
|
||||
overrides: Partial<DocumentMetadata> = {},
|
||||
): DocumentMetadata =>
|
||||
({
|
||||
id: 'test-doc-id',
|
||||
documentType: 'aadhaar',
|
||||
documentCategory: 'aadhaar',
|
||||
data: 'test-data',
|
||||
mock: false,
|
||||
isRegistered: true,
|
||||
registeredAt: Date.now(),
|
||||
...overrides,
|
||||
}) as DocumentMetadata;
|
||||
|
||||
describe('isDocumentInactive', () => {
|
||||
describe('registered pre-document expiration', () => {
|
||||
describe('when hasExpirationDate is undefined', () => {
|
||||
it('returns true for aadhaar document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'aadhaar',
|
||||
hasExpirationDate: undefined,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for passport document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'passport',
|
||||
hasExpirationDate: undefined,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for id_card document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'id_card',
|
||||
hasExpirationDate: undefined,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('registered post-document expiration', () => {
|
||||
describe('when hasExpirationDate is true', () => {
|
||||
it('returns false for aadhaar document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'aadhaar',
|
||||
hasExpirationDate: true,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for passport document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'passport',
|
||||
hasExpirationDate: true,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for id_card document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'id_card',
|
||||
hasExpirationDate: true,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when hasExpirationDate is false', () => {
|
||||
it('returns false for aadhaar document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'aadhaar',
|
||||
hasExpirationDate: false,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for passport document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'passport',
|
||||
hasExpirationDate: false,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for id_card document', () => {
|
||||
const metadata = createMockMetadata({
|
||||
documentCategory: 'id_card',
|
||||
hasExpirationDate: false,
|
||||
});
|
||||
|
||||
const result = isDocumentInactive(metadata);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ describe('Points API - Signature Logic', () => {
|
||||
|
||||
let mockWallet: any;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
let originalBufferFrom: typeof Buffer.from;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -101,6 +102,11 @@ describe('Points API - Signature Logic', () => {
|
||||
// Mock ethers.getBytes
|
||||
(ethers.getBytes as jest.Mock).mockReturnValue(mockSignatureBytes);
|
||||
|
||||
// Save original Buffer.from before mocking (global.Buffer is shared across
|
||||
// all test files in the same worker, so we must restore it to avoid
|
||||
// poisoning other test files like nfcScanner.test.ts)
|
||||
originalBufferFrom = global.Buffer.from;
|
||||
|
||||
// Mock Buffer.from for base64 conversion
|
||||
global.Buffer.from = jest.fn().mockReturnValue({
|
||||
toString: jest.fn().mockReturnValue(mockSignatureBase64),
|
||||
@@ -113,6 +119,7 @@ describe('Points API - Signature Logic', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.Buffer.from = originalBufferFrom;
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"ios": {
|
||||
"build": 212,
|
||||
"lastDeployed": "2026-02-06T23:20:10.343Z"
|
||||
"build": 213,
|
||||
"lastDeployed": "2026-02-09T22:47:48.603Z"
|
||||
},
|
||||
"android": {
|
||||
"build": 140,
|
||||
"lastDeployed": "2026-02-05T00:58:22Z"
|
||||
"build": 142,
|
||||
"lastDeployed": "2026-02-10T00:22:34Z"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -574,13 +574,13 @@ export function processQRDataSimple(qrData: string) {
|
||||
const extractedFields = extractQRDataFields(qrDataBytes);
|
||||
|
||||
// Calculate qrHash exclude timestamp (positions 9-25, 17 bytes)
|
||||
// const qrDataWithoutTimestamp = [
|
||||
// ...Array.from(qrDataPadded.slice(0, 9)),
|
||||
// ...Array.from(qrDataPadded.slice(9, 26)).map((x) => 0),
|
||||
// ...Array.from(qrDataPadded.slice(26)),
|
||||
// ];
|
||||
// const qrHash = packBytesAndPoseidon(qrDataWithoutTimestamp);
|
||||
const qrHash = packBytesAndPoseidon(Array.from(qrDataPadded));
|
||||
const qrDataWithoutTimestamp = [
|
||||
...Array.from(qrDataPadded.slice(0, 9)),
|
||||
...Array.from(qrDataPadded.slice(9, 26)).map((x) => 0),
|
||||
...Array.from(qrDataPadded.slice(26)),
|
||||
];
|
||||
const qrHash = packBytesAndPoseidon(qrDataWithoutTimestamp);
|
||||
// const qrHash = packBytesAndPoseidon(Array.from(qrDataPadded));
|
||||
const photo = extractPhoto(Array.from(qrDataPadded), photoEOI + 1);
|
||||
|
||||
const photoHash = packBytesAndPoseidon(photo.bytes.map(Number));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
43
common/src/utils/kyc/utils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ExtractedQRData } from './aadhaar/utils.js';
|
||||
import type { CertificateData } from './certificate_parsing/dataStructure.js';
|
||||
import type { KycField } from './kyc/constants.js';
|
||||
import type { PassportMetadata } from './passports/passport_parsing/parsePassportData.js';
|
||||
import { KycField } from './kyc/constants.js';
|
||||
|
||||
// Base interface for common fields
|
||||
interface BaseIDData {
|
||||
@@ -22,16 +22,11 @@ export interface AadhaarData extends BaseIDData {
|
||||
photoHash?: string;
|
||||
}
|
||||
|
||||
// export interface KycData extends BaseIDData {
|
||||
// documentCategory: 'kyc';
|
||||
// serializedRealData: string;
|
||||
// kycFields: KycField[];
|
||||
// }
|
||||
|
||||
export type DeployedCircuits = {
|
||||
REGISTER: string[];
|
||||
REGISTER_ID: string[];
|
||||
REGISTER_AADHAAR: string[];
|
||||
REGISTER_KYC: string[];
|
||||
DSC: string[];
|
||||
DSC_ID: string[];
|
||||
};
|
||||
@@ -51,19 +46,29 @@ export interface DocumentMetadata {
|
||||
mock: boolean; // whether this is a mock document
|
||||
isRegistered?: boolean; // whether the document is registered onChain
|
||||
registeredAt?: number; // timestamp (epoch ms) when document was registered
|
||||
hasExpirationDate?: boolean; // whether the document has an expiration date
|
||||
idType?: string; // for KYC documents: the ID type used (e.g. "passport", "drivers_licence")
|
||||
}
|
||||
|
||||
export type DocumentType =
|
||||
| 'passport'
|
||||
| 'id_card'
|
||||
| 'aadhaar'
|
||||
| 'drivers_licence'
|
||||
| 'mock_passport'
|
||||
| 'mock_id_card'
|
||||
| 'mock_aadhaar';
|
||||
|
||||
export type Environment = 'prod' | 'stg';
|
||||
|
||||
export type IDDocument = AadhaarData | PassportData;
|
||||
export type IDDocument = AadhaarData | KycData | PassportData;
|
||||
|
||||
export interface KycData extends BaseIDData {
|
||||
documentCategory: 'kyc';
|
||||
serializedApplicantInfo: string;
|
||||
signature: string;
|
||||
pubkey: string[];
|
||||
}
|
||||
|
||||
export type OfacTree = {
|
||||
passportNoAndNationality: any;
|
||||
@@ -85,6 +90,20 @@ export interface PassportData extends BaseIDData {
|
||||
passportMetadata?: PassportMetadata;
|
||||
}
|
||||
|
||||
// pending - pending sumsub verification
|
||||
// processing - sumsub verification completed and pending onchain confirmation
|
||||
// failed - sumsub verification failed
|
||||
export type PendingKycStatus = 'pending' | 'processing' | 'failed';
|
||||
|
||||
export interface PendingKycVerification {
|
||||
userId: string; // Correlation key from fetchAccessToken()
|
||||
createdAt: number; // Timestamp when verification started
|
||||
status: PendingKycStatus; // Current status
|
||||
errorMessage?: string; // Error message if failed
|
||||
timeoutAt: number; // When to consider timed out
|
||||
documentId?: string; // Content hash of stored KYC document
|
||||
}
|
||||
|
||||
export type Proof = {
|
||||
proof: {
|
||||
a: [string, string];
|
||||
@@ -156,6 +175,7 @@ export enum AttestationIdHex {
|
||||
passport = '0x0000000000000000000000000000000000000000000000000000000000000001',
|
||||
id_card = '0x0000000000000000000000000000000000000000000000000000000000000002',
|
||||
aadhaar = '0x0000000000000000000000000000000000000000000000000000000000000003',
|
||||
kyc = '0x0000000000000000000000000000000000000000000000000000000000000004',
|
||||
}
|
||||
|
||||
export function castCSCAProof(proof: any): Proof {
|
||||
@@ -169,15 +189,15 @@ export function castCSCAProof(proof: any): Proof {
|
||||
};
|
||||
}
|
||||
|
||||
export function isAadhaarDocument(
|
||||
passportData: PassportData | AadhaarData
|
||||
): passportData is AadhaarData {
|
||||
export function isAadhaarDocument(passportData: IDDocument): passportData is AadhaarData {
|
||||
return passportData.documentCategory === 'aadhaar';
|
||||
}
|
||||
|
||||
export function isMRZDocument(
|
||||
passportData: PassportData | AadhaarData
|
||||
): passportData is PassportData {
|
||||
export function isKycDocument(passportData: IDDocument): passportData is KycData {
|
||||
return passportData.documentCategory === 'kyc';
|
||||
}
|
||||
|
||||
export function isMRZDocument(passportData: IDDocument): passportData is PassportData {
|
||||
return (
|
||||
passportData.documentCategory === 'passport' || passportData.documentCategory === 'id_card'
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import {console} from "hardhat/console.sol";
|
||||
* @dev This contract orchestrates multi-step verification processes including document attestation,
|
||||
* zero-knowledge proofs, OFAC compliance, and attribute disclosure control.
|
||||
*
|
||||
* @custom:version 2.12.0
|
||||
* @custom:version 2.13.0
|
||||
*/
|
||||
contract IdentityVerificationHubImplV2 is ImplRoot {
|
||||
/// @custom:storage-location erc7201:self.storage.IdentityVerificationHub
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "./registry.schema.json",
|
||||
"lastUpdated": "2025-12-10T06:17:50.863Z",
|
||||
"lastUpdated": "2026-02-09T11:26:31.105Z",
|
||||
"contracts": {
|
||||
"IdentityVerificationHub": {
|
||||
"source": "IdentityVerificationHubImplV2",
|
||||
@@ -22,6 +22,11 @@
|
||||
"type": "uups-proxy",
|
||||
"description": "Aadhaar identity registry"
|
||||
},
|
||||
"IdentityRegistryKyc": {
|
||||
"source": "IdentityRegistryKycImplV1",
|
||||
"type": "uups-proxy",
|
||||
"description": "KYC identity registry"
|
||||
},
|
||||
"PCR0Manager": {
|
||||
"source": "PCR0Manager",
|
||||
"type": "non-upgradeable",
|
||||
@@ -45,8 +50,8 @@
|
||||
"deployments": {
|
||||
"IdentityVerificationHub": {
|
||||
"proxy": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
|
||||
"currentVersion": "2.12.0",
|
||||
"currentImpl": "0x05FB9D7830889cc389E88198f6A224eA87F01151"
|
||||
"currentVersion": "2.13.0",
|
||||
"currentImpl": "0x0D911083b2F2236D79EF20bb58AAf6009a1220B5"
|
||||
},
|
||||
"IdentityRegistry": {
|
||||
"proxy": "0x37F5CB8cB1f6B00aa768D8aA99F1A9289802A968",
|
||||
@@ -63,6 +68,11 @@
|
||||
"currentVersion": "1.2.0",
|
||||
"currentImpl": "0xbD861A9cecf7B0A9631029d55A8CE1155e50697c"
|
||||
},
|
||||
"IdentityRegistryKyc": {
|
||||
"proxy": "0x9cABdeBC3aF136efD69EB881e02118AC612c63b9",
|
||||
"currentVersion": "1.0.0",
|
||||
"currentImpl": "0x82FA9D41939229B6189cf326e855c6d6db2aAa57"
|
||||
},
|
||||
"PCR0Manager": {
|
||||
"address": "0x9743fe2C1c3D2b068c56dE314e9B10DA9c904717",
|
||||
"currentVersion": "1.2.0"
|
||||
@@ -73,6 +83,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"celo-sepolia": {
|
||||
"chainId": 11142220,
|
||||
"governance": {
|
||||
"securityMultisig": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
|
||||
"operationsMultisig": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
|
||||
"securityThreshold": "1/1",
|
||||
"operationsThreshold": "1/1"
|
||||
},
|
||||
"deployments": {
|
||||
"IdentityVerificationHub": {
|
||||
"proxy": "0x16ECBA51e18a4a7e61fdC417f0d47AFEeDfbed74",
|
||||
"currentVersion": "2.13.0",
|
||||
"currentImpl": "0x244c93516Abd58E1952452d3D8C4Ce7D454776B8"
|
||||
}
|
||||
}
|
||||
},
|
||||
"localhost": {
|
||||
"chainId": 31337,
|
||||
"governance": {
|
||||
@@ -97,6 +123,12 @@
|
||||
"deployedAt": "2025-12-10T05:43:58.258Z",
|
||||
"deployedBy": "0xCaEe7aAF115F04D836E2D362A7c07F04db436bd0",
|
||||
"gitCommit": ""
|
||||
},
|
||||
"celo-sepolia": {
|
||||
"impl": "0x92d637c5e6EFa17320B663f97cc4d44176984dAd",
|
||||
"deployedAt": "2026-02-02T13:39:44.500Z",
|
||||
"deployedBy": "0x846F1cF04ec494303e4B90440b130bb01913E703",
|
||||
"gitCommit": "61a41950"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,6 +143,40 @@
|
||||
"deployedAt": "",
|
||||
"deployedBy": "",
|
||||
"gitCommit": ""
|
||||
},
|
||||
"celo-sepolia": {
|
||||
"impl": "0x48985ec4f71cBC8f387c5C77143110018560c7eD",
|
||||
"deployedAt": "",
|
||||
"deployedBy": "0x846f1cf04ec494303e4b90440b130bb01913e703",
|
||||
"gitCommit": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"2.13.0": {
|
||||
"initializerVersion": 12,
|
||||
"initializerFunction": "",
|
||||
"changelog": "Upgrade to v2.13.0",
|
||||
"gitTag": "identityverificationhub-v2.13.0",
|
||||
"deployments": {
|
||||
"celo-sepolia": {
|
||||
"impl": "0x244c93516Abd58E1952452d3D8C4Ce7D454776B8",
|
||||
"deployedAt": "2026-02-02T14:47:21.882Z",
|
||||
"deployedBy": "0x82D8DaC3a386dec55a0a44DffBd3113e8A7D139B",
|
||||
"gitCommit": "33bca485"
|
||||
}
|
||||
}
|
||||
},
|
||||
"2.13.0": {
|
||||
"initializerVersion": 12,
|
||||
"initializerFunction": "",
|
||||
"changelog": "Upgrade to v2.13.0",
|
||||
"gitTag": "identityverificationhub-v2.13.0",
|
||||
"deployments": {
|
||||
"celo": {
|
||||
"impl": "0x0D911083b2F2236D79EF20bb58AAf6009a1220B5",
|
||||
"deployedAt": "2026-02-09T11:26:30.941Z",
|
||||
"deployedBy": "0xC1C860804EFdA544fe79194d1a37e60b846CEdeb",
|
||||
"gitCommit": "88ae00b1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +286,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"IdentityRegistryKyc": {
|
||||
"1.0.0": {
|
||||
"initializerVersion": 1,
|
||||
"initializerFunction": "initialize",
|
||||
"changelog": "Initial KYC registry deployment",
|
||||
"gitTag": "",
|
||||
"deployments": {
|
||||
"celo": {
|
||||
"impl": "0x82FA9D41939229B6189cf326e855c6d6db2aAa57",
|
||||
"deployedAt": "2026-02-09T00:00:00.000Z",
|
||||
"deployedBy": "",
|
||||
"gitCommit": "03876a86284b0ed794fbff7aae142e62a3212624"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +86,16 @@
|
||||
"DeployAadhaarRegistryModule#PoseidonT3": "0xC9B4a92d98dbFC76D440233b8598910cA2da353f",
|
||||
"DeployAadhaarRegistryModule#IdentityRegistryAadhaarImplV1": "0x70D543432782D460C96753b52c2aC2797f26924B",
|
||||
"DeployAadhaarRegistryModule#IdentityRegistry": "0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4",
|
||||
"UpdateAllRegistries#a3": "0xd603Fa8C8f4694E8DD1DcE1f27C0C3fc91e32Ac4",
|
||||
"DeployHubV2#IdentityVerificationHub": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
|
||||
"UpdateHubRegistries#IdentityVerificationHubImplV2": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
|
||||
"DeployNewHubAndUpgradee#IdentityVerificationHubV2": "0xe57F4773bd9c9d8b6Cd70431117d353298B9f5BF",
|
||||
"DeployNewHubAndUpgradee#CustomVerifier": "0x026696925F7DA40EE8B372442750A70BA9C006fA",
|
||||
"DeployNewHubAndUpgradee#IdentityVerificationHubImplV2": "0xa267e58B2d6BA9fc07Af06471423AFb56e4e82B3"
|
||||
"DeployNewHubAndUpgradee#IdentityVerificationHubImplV2": "0xa267e58B2d6BA9fc07Af06471423AFb56e4e82B3",
|
||||
"DeployKycRegistryModule#PoseidonT3": "0x3a74EeCfF282539905F4a43c5EF4f5F155D1579F",
|
||||
"DeployKycRegistryModule#Verifier_gcp_jwt": "0x87785cC7E9Bc70f87E6F454235214bDEc853C044",
|
||||
"DeployKycRegistryModule#IdentityRegistryKycImplV1": "0x82FA9D41939229B6189cf326e855c6d6db2aAa57",
|
||||
"DeployKycRegistryModule#IdentityRegistry": "0x9cABdeBC3aF136efD69EB881e02118AC612c63b9",
|
||||
"UpdateAllRegistries#a3": "0x9cABdeBC3aF136efD69EB881e02118AC612c63b9",
|
||||
"DeployAllVerifiers#Verifier_register_kyc": "0xbc15010D9748A5e7c0B947D0c0aCb31bD57a0626",
|
||||
"DeployAllVerifiers#Verifier_vc_and_disclose_kyc": "0xdB0454156bBa5e5b9CA97be350eCc178ddE20b0f"
|
||||
}
|
||||
|
||||