Merge pull request #1474 from selfxyz/release/staging-2025-12-05

Release to Staging - 2025-12-05
This commit is contained in:
Justin Hernandez
2025-12-06 01:00:51 -08:00
committed by GitHub
98 changed files with 2057 additions and 1013 deletions

View File

@@ -214,9 +214,6 @@ NOTICE
**/*.zip
**/*.tar.gz
# Patch files
patches/
# ========================================
# Project Specific Patterns
# ========================================

View File

@@ -24,12 +24,22 @@ outputs:
runs:
using: "composite"
steps:
- id: get-hash
name: Hash lock file
shell: bash
run: |
if [ -f "${{ inputs.lock-file }}" ]; then
echo "hash=$(shasum -a 256 "${{ inputs.lock-file }}" | awk '{ print $1 }')" >> $GITHUB_OUTPUT
else
echo "::warning::Lock file '${{ inputs.lock-file }}' not found."
echo "hash=no-lock-file" >> $GITHUB_OUTPUT
fi
- id: cache
name: Cache Ruby gems
uses: actions/cache@v4
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-gems-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }}
key: ${{ runner.os }}-gems-${{ inputs.cache-version }}-${{ steps.get-hash.outputs.hash }}
restore-keys: |
${{ runner.os }}-gems-${{ inputs.cache-version }}-
${{ runner.os }}-gems-

View File

@@ -29,7 +29,7 @@ runs:
uses: actions/cache@v4
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-gradle-${{ inputs.cache-version }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
key: ${{ runner.os }}-gradle-${{ inputs.cache-version }}-${{ hashFiles('**/build.gradle', '**/settings.gradle', '**/gradle-wrapper.properties', '**/gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-${{ inputs.cache-version }}-
${{ runner.os }}-gradle-

View File

@@ -9,7 +9,7 @@ inputs:
default: |
ios/Pods
~/Library/Caches/CocoaPods
lock-file:
lockfile:
description: Path to Podfile.lock
required: false
default: ios/Podfile.lock
@@ -31,7 +31,7 @@ runs:
uses: actions/cache@v4
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-pods-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }}
key: ${{ runner.os }}-pods-${{ inputs.cache-version }}-${{ hashFiles(inputs.lockfile) }}
restore-keys: |
${{ runner.os }}-pods-${{ inputs.cache-version }}-
${{ runner.os }}-pods-

View File

@@ -25,12 +25,22 @@ outputs:
runs:
using: "composite"
steps:
- id: get-hash
name: Hash lock file
shell: bash
run: |
if [ -f "${{ inputs.lock-file }}" ]; then
echo "hash=$(shasum -a 256 "${{ inputs.lock-file }}" | awk '{ print $1 }')" >> $GITHUB_OUTPUT
else
echo "::warning::Lock file '${{ inputs.lock-file }}' not found."
echo "hash=no-lock-file" >> $GITHUB_OUTPUT
fi
- id: cache
name: Cache Yarn dependencies
uses: actions/cache@v4
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-yarn-${{ inputs.cache-version }}-${{ hashFiles(inputs.lock-file) }}
key: ${{ runner.os }}-yarn-${{ inputs.cache-version }}-${{ steps.get-hash.outputs.hash }}
restore-keys: |
${{ runner.os }}-yarn-${{ inputs.cache-version }}-
${{ runner.os }}-yarn-

View File

@@ -22,9 +22,11 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version-file: .nvmrc
cache: "yarn"
cache-dependency-path: yarn.lock
cache-dependency-path: |
yarn.lock
.yarnrc.yml
- name: Install dependencies
uses: nick-fields/retry@v3

25
.github/actions/yarnrc-hash/action.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Compute .yarnrc.yml hash
description: Compute a stable hash for .yarnrc.yml to use in cache keys.
outputs:
hash:
description: Hash of .yarnrc.yml (or "no-yarnrc" if the file is missing)
value: ${{ steps.compute-yarnrc-hash.outputs.hash }}
runs:
using: composite
steps:
- name: Compute .yarnrc.yml hash
id: compute-yarnrc-hash
shell: bash
run: |
if [ -f .yarnrc.yml ]; then
if command -v shasum >/dev/null 2>&1; then
echo "hash=$(shasum -a 256 .yarnrc.yml | awk '{ print $1 }')" >> "$GITHUB_OUTPUT"
else
echo "hash=$(sha256sum .yarnrc.yml | awk '{ print $1 }')" >> "$GITHUB_OUTPUT"
fi
else
echo "hash=no-yarnrc" >> "$GITHUB_OUTPUT"
fi

View File

@@ -1,21 +0,0 @@
name: GitGuardian Scan
on:
pull_request:
jobs:
gitguardian:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # fetch all history so multiple commits can be scanned
- name: GitGuardian scan
uses: GitGuardian/ggshield/actions/secret@v1.41.0
env:
GITHUB_PUSH_BEFORE_SHA: ${{ github.event.before }}
GITHUB_PUSH_BASE_SHA: ${{ github.event.base }}
GITHUB_PULL_BASE_SHA: ${{ github.event.pull_request.base.sha }}
GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }}

View File

@@ -37,32 +37,26 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache Node Modules
uses: actions/cache@v4
- name: Cache Yarn
uses: ./.github/actions/cache-yarn
with:
path: |
.yarn/cache
node_modules
app/node_modules
key: ${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-node-${{ env.NODE_VERSION_SANITIZED }}-yarn-
- name: Cache Ruby Bundler
uses: actions/cache@v4
cache-version: node-${{ env.NODE_VERSION_SANITIZED }}
- name: Cache Bundler
uses: ./.github/actions/cache-bundler
with:
path: app/vendor/bundle
key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ hashFiles('app/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-
lock-file: app/Gemfile.lock
cache-version: ruby${{ env.RUBY_VERSION }}
- name: Cache Gradle
uses: actions/cache@v4
uses: ./.github/actions/cache-gradle
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('app/android/**/gradle-wrapper.properties', 'app/android/**/gradle-wrapper.jar') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install Mobile Dependencies
uses: ./.github/actions/mobile-setup
with:
@@ -100,30 +94,25 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache Node Modules
uses: actions/cache@v4
- name: Cache Yarn
uses: ./.github/actions/cache-yarn
with:
path: |
.yarn/cache
node_modules
app/node_modules
key: ${{ runner.os }}-node${{ env.NODE_VERSION }}-yarn-${{ hashFiles('yarn.lock') }}
restore-keys: |
${{ runner.os }}-node${{ env.NODE_VERSION }}-yarn-
- name: Cache Ruby Bundler
uses: actions/cache@v4
cache-version: node-${{ env.NODE_VERSION_SANITIZED }}
- name: Cache Bundler
uses: ./.github/actions/cache-bundler
with:
path: app/vendor/bundle
key: ${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-${{ hashFiles('app/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-ruby${{ env.RUBY_VERSION }}-gems-
lock-file: app/Gemfile.lock
cache-version: ruby${{ env.RUBY_VERSION }}
- name: Cache CocoaPods
uses: actions/cache@v4
uses: ./.github/actions/cache-pods
with:
path: app/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('app/ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
lockfile: app/ios/Podfile.lock
- name: Install Mobile Dependencies
uses: ./.github/actions/mobile-setup
with:

View File

@@ -277,7 +277,7 @@ jobs:
path: |
app/ios/Pods
~/Library/Caches/CocoaPods
lock-file: app/ios/Podfile.lock
lockfile: app/ios/Podfile.lock
cache-version: ${{ env.GH_CACHE_VERSION }}
- name: Cache Xcode build
uses: actions/cache@v4

View File

@@ -367,6 +367,10 @@ jobs:
echo "Xcode path:"
xcode-select -p
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
- name: Cache Yarn artifacts
id: yarn-cache
uses: ./.github/actions/cache-yarn
@@ -375,7 +379,7 @@ jobs:
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
- name: Cache Ruby gems
id: gems-cache
@@ -392,7 +396,7 @@ jobs:
path: |
${{ env.APP_PATH }}/ios/Pods
~/Library/Caches/CocoaPods
lock-file: app/ios/Podfile.lock
lockfile: app/ios/Podfile.lock
cache-version: ${{ env.GH_CACHE_VERSION }}-${{ env.GH_PODS_CACHE_VERSION }}
- name: Log cache status
@@ -976,6 +980,10 @@ jobs:
# Use version-manager script to apply versions
node ${{ env.APP_PATH }}/scripts/version-manager.cjs apply "$VERSION" "$IOS_BUILD" "$ANDROID_BUILD"
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
- name: Cache Yarn artifacts
id: yarn-cache
uses: ./.github/actions/cache-yarn
@@ -984,7 +992,7 @@ jobs:
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
- name: Cache Ruby gems
id: gems-cache

View File

@@ -56,6 +56,9 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
- name: Cache Yarn dependencies
uses: ./.github/actions/cache-yarn
with:
@@ -63,7 +66,7 @@ jobs:
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
@@ -246,6 +249,9 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- run: corepack enable
- run: corepack prepare yarn@4.6.0 --activate
- name: Compute .yarnrc.yml hash
id: yarnrc-hash
uses: ./.github/actions/yarnrc-hash
- name: Cache Yarn dependencies
uses: ./.github/actions/cache-yarn
with:
@@ -253,7 +259,7 @@ jobs:
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ hashFiles('.yarnrc.yml') }}
cache-version: ${{ env.GH_CACHE_VERSION }}-node-${{ env.NODE_VERSION_SANITIZED }}-${{ steps.yarnrc-hash.outputs.hash }}
- name: Toggle Yarn hardened mode for trusted PRs
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
run: echo "YARN_ENABLE_HARDENED_MODE=0" >> $GITHUB_ENV
@@ -330,7 +336,7 @@ jobs:
path: |
app/ios/Pods
~/Library/Caches/CocoaPods
lock-file: app/ios/Podfile.lock
lockfile: app/ios/Podfile.lock
# DerivedData caching disabled - caused intermittent build failures due to stale cache
# Pod caching still speeds up pod install significantly
- name: Verify iOS Runtime

View File

@@ -31,6 +31,7 @@ on:
jobs:
android-e2e:
name: Android E2E Tests Demo App
# Currently build-only for Android. E2E steps are preserved but skipped (if: false).
# To re-enable full E2E: change `if: false` to `if: true` on emulator steps.
concurrency:
@@ -192,6 +193,7 @@ jobs:
ios-e2e:
timeout-minutes: 60
runs-on: macos-latest-large
name: iOS E2E Tests Demo App
concurrency:
group: ${{ github.workflow }}-ios-${{ github.ref }}
cancel-in-progress: true
@@ -291,7 +293,7 @@ jobs:
path: |
packages/mobile-sdk-demo/ios/Pods
~/Library/Caches/CocoaPods
lock-file: packages/mobile-sdk-demo/ios/Podfile.lock
lockfile: packages/mobile-sdk-demo/ios/Podfile.lock
# DerivedData caching disabled - caused intermittent build failures due to stale cache
# Pod caching still speeds up pod install significantly
- name: Verify iOS Runtime

View File

@@ -14,7 +14,7 @@ on:
workflow_dispatch:
permissions:
id-token: write # Required for OIDC
id-token: write # Required for OIDC
contents: read
jobs:

View File

@@ -68,6 +68,7 @@ jobs:
shell: bash
run: |
yarn workspace @selfxyz/common build
yarn workspace @selfxyz/sdk-common build
yarn workspace @selfxyz/qrcode build
- name: Cache build artifacts
@@ -258,6 +259,9 @@ jobs:
key: qrcode-sdk-build-${{ env.GH_SDK_CACHE_VERSION }}-${{ github.sha }}
fail-on-cache-miss: true
- name: Build SDK common dependency
run: yarn workspace @selfxyz/sdk-common build
- name: Run tests
run: yarn workspace @selfxyz/qrcode test

View File

@@ -658,6 +658,8 @@ regexes = [
stopwords = [
"000000",
"6fe4476ee5a1832882e326b506d14126",
"8853c3c635164864da68a6dbbcec7148506c3bcf",
"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature",
"_ec2_",
"aaaaaa",
"about",
@@ -3188,4 +3190,3 @@ id = "zendesk-secret-key"
description = "Detected a Zendesk Secret Key, risking unauthorized access to customer support services and sensitive ticketing data."
regex = '''(?i)[\w.-]{0,50}?(?:zendesk)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)'''
keywords = ["zendesk"]

View File

@@ -3,10 +3,8 @@
1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:73
1b461a626e0a4a93d4e1c727e7aed8c955aa728c:common/src/utils/passports/validate.test.ts:generic-api-key:74
8bc1e85075f73906767652ab35d5563efce2a931:packages/mobile-sdk-alpha/src/animations/passport_verify.json:aws-access-token:6
f506113a22e5b147132834e4659f5af308448389:app/tests/utils/deeplinks.test.ts:generic-api-key:183
5a67b5cc50f291401d1da4e51706d0cfcf1c2316:app/tests/utils/deeplinks.test.ts:generic-api-key:182
0e4555eee6589aa9cca68f451227b149277d8c90:app/tests/src/utils/points/api.test.ts:generic-api-key:34
app/ios/Podfile.lock:generic-api-key:2594
app/tests/src/navigation/deeplinks.test.ts:generic-api-key:208
circuits/circuits/gcp_jwt_verifier/example_jwt.txt:jwt:1
cadd7ae5b768c261230f84426eac879c1853ce70:app/ios/Podfile.lock:generic-api-key:2586
aeb8287078f088ecd8e9430e3f6a9f2c593ef1fc:app/src/utils/points/constants.ts:generic-api-key:7
app/src/services/points/constants.ts:generic-api-key:10

View File

@@ -24,7 +24,7 @@ Currently, Self supports electronic passports, biometric ID cards following the
**Passports:** Biometric passports have the [biometric passport logo](https://en.wikipedia.org/wiki/Biometric_passport) on their front cover.
**Aadhaar:** Indian [Aadhaar](https://en.wikipedia.org/wiki/Aadhaar) cards are supported for privacy-preserving identity verification.
**Aadhaar:** Indian [Aadhaar](https://en.wikipedia.org/wiki/Aadhaar) cards are supported for privacy-preserving identity verification. Use the mAadhaar app to generate a QR code and import it into Self.
**Coverage:** Checkout our [coverage map here](http://map.self.xyz/) to see supported documents and countries.
@@ -90,11 +90,13 @@ These guides provide comprehensive context for AI-assisted development with Chat
We are actively looking for contributors. Please check the [open issues](https://github.com/selfxyz/self/issues) if you don't know where to start! We offer bounties for significant contributions.
> **Important:** Please open your pull request from the `staging` branch. Pull requests from other branches will be automatically closed.
> **Important:** Please read and follow the guidelines in [contribute.md](contribute.md) when opening your pull request.
## Contact us
[Contact us](https://t.me/selfprotocolbuilder) on telegram for feedback or questions.
- [Discord](https://discord.gg/AQ3TrX6dce) for technical support or reporting a bug.
- [Telegram's Self builder channel](https://t.me/selfprotocolbuilder) for technical questions about the sdk implementation.
- [Telegram's Self public group](https://t.me/selfxyz) for general questions and updates.
Thanks [Rémi](https://github.com/remicolin), [Florent](https://github.com/0xturboblitz), [Ayman](https://github.com/Nesopie), [Justin](https://github.com/transphorm), [Seshanth](https://github.com/seshanthS), [Nico](https://github.com/motemotech) and all other contributors for building Self.

View File

@@ -14,16 +14,16 @@ GEM
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1184.0)
aws-sdk-core (3.237.0)
aws-partitions (1.1190.0)
aws-sdk-core (3.239.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -31,10 +31,10 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.117.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.203.1)
aws-sdk-s3 (1.206.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -87,7 +87,7 @@ GEM
commander (4.6.0)
highline (~> 2.0.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
connection_pool (3.0.2)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
@@ -222,7 +222,7 @@ GEM
i18n (1.14.7)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.16.0)
json (2.17.1)
jwt (2.10.2)
base64
logger (1.7.0)
@@ -231,7 +231,7 @@ GEM
mini_portile2 (2.8.9)
minitest (5.26.2)
molinillo (0.8.0)
multi_json (1.17.0)
multi_json (1.18.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -311,4 +311,4 @@ RUBY VERSION
ruby 3.2.7p253
BUNDLED WITH
2.4.19
2.6.9

View File

@@ -135,7 +135,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 121
versionName "2.9.2"
versionName "2.9.4"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {

View File

@@ -1,5 +1,4 @@
ENABLE_DEBUG_LOGS=
GITGUARDIAN_API_KEY=
GITLEAKS_LICENSE=
GOOGLE_SIGNIN_ANDROID_CLIENT_ID=
GRAFANA_LOKI_URL=

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.9.2</string>
<string>2.9.4</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -546,7 +546,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.2;
MARKETING_VERSION = 2.9.4;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -686,7 +686,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
MARKETING_VERSION = 2.9.2;
MARKETING_VERSION = 2.9.4;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@@ -31,6 +31,7 @@ module.exports = {
moduleNameMapper: {
'^@env$': '<rootDir>/tests/__setup__/@env.js',
'\\.svg$': '<rootDir>/tests/__setup__/svgMock.js',
'\\.(png|jpg|jpeg|gif|webp)$': '<rootDir>/tests/__setup__/imageMock.js',
'^@/(.*)$': '<rootDir>/src/$1',
'^@$': '<rootDir>/src',
'^@tests/(.*)$': '<rootDir>/tests/src/$1',

View File

@@ -5,6 +5,11 @@
/* global jest */
/** @jest-environment jsdom */
// Set up Buffer globally for tests that need it
const { Buffer } = require('buffer');
global.Buffer = Buffer;
// Mock React Native PixelRatio globally before anything else loads
const mockPixelRatio = {
get: jest.fn(() => 2),
@@ -16,21 +21,136 @@ const mockPixelRatio = {
global.PixelRatio = mockPixelRatio;
// Also make it available for require() calls
const Module = require('module');
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === 'react-native') {
const RN = originalRequire.apply(this, arguments);
if (!RN.PixelRatio || !RN.PixelRatio.getFontScale) {
RN.PixelRatio = mockPixelRatio;
}
return RN;
}
return originalRequire.apply(this, arguments);
// Define NativeModules early so it's available for react-native mock
// This will be assigned to global.NativeModules later, but we define it here
// so the react-native mock can reference it
const NativeModules = {
PassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
ReactNativeBiometrics: {
isSensorAvailable: jest.fn().mockResolvedValue({
available: true,
biometryType: 'TouchID',
}),
createKeys: jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' }),
deleteKeys: jest.fn().mockResolvedValue(true),
createSignature: jest
.fn()
.mockResolvedValue({ signature: 'mock-signature' }),
simplePrompt: jest.fn().mockResolvedValue({ success: true }),
},
NativeLoggerBridge: {
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
RNPassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
};
// Assign to global so it's available everywhere
global.NativeModules = NativeModules;
// Mock react-native comprehensively - single source of truth for all tests
// Note: NativeModules will be defined later and assigned to global.NativeModules
// This mock accesses it at runtime via global.NativeModules
jest.mock('react-native', () => {
// Create AppState mock with listener tracking
// Expose listeners array globally so tests can access it
const appStateListeners = [];
global.mockAppStateListeners = appStateListeners;
const mockAppState = {
currentState: 'active',
addEventListener: jest.fn((eventType, handler) => {
appStateListeners.push(handler);
return {
remove: () => {
const index = appStateListeners.indexOf(handler);
if (index >= 0) {
appStateListeners.splice(index, 1);
}
},
};
}),
};
return {
__esModule: true,
AppState: mockAppState,
Platform: {
OS: 'ios',
select: jest.fn(obj => obj.ios || obj.default),
Version: 14,
},
// NativeModules is defined above and assigned to global.NativeModules
// Use getter to access it at runtime (jest.mock is hoisted)
get NativeModules() {
return global.NativeModules || {};
},
NativeEventEmitter: jest.fn().mockImplementation(nativeModule => {
return {
addListener: jest.fn(),
removeListener: jest.fn(),
removeAllListeners: jest.fn(),
emit: jest.fn(),
};
}),
PixelRatio: mockPixelRatio,
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
},
Linking: {
getInitialURL: jest.fn().mockResolvedValue(null),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
openURL: jest.fn().mockResolvedValue(undefined),
canOpenURL: jest.fn().mockResolvedValue(true),
},
StyleSheet: {
create: jest.fn(styles => styles),
flatten: jest.fn(style => style),
hairlineWidth: 1,
absoluteFillObject: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
TouchableHighlight: 'TouchableHighlight',
Image: 'Image',
ActivityIndicator: 'ActivityIndicator',
SafeAreaView: 'SafeAreaView',
requireNativeComponent: jest.fn(name => {
// Return a mock component function for any native component
const MockNativeComponent = jest.fn(props => props.children || null);
MockNativeComponent.displayName = `Mock(${name})`;
return MockNativeComponent;
}),
};
});
require('react-native-gesture-handler/jestSetup');
// Mock NativeAnimatedHelper - using virtual mock during RN 0.76.9 prep phase
@@ -51,6 +171,23 @@ global.__fbBatchedBridgeConfig = {
// Set up global React Native test environment
global.__DEV__ = true;
// Set up global mock navigation ref for tests
global.mockNavigationRef = {
isReady: jest.fn(() => true),
getCurrentRoute: jest.fn(() => ({ name: 'Home' })),
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
addListener: jest.fn(() => jest.fn()),
removeListener: jest.fn(),
};
// Load grouped mocks
require('./tests/__setup__/mocks/navigation');
require('./tests/__setup__/mocks/ui');
// Mock TurboModuleRegistry to provide required native modules for BOTH main app and mobile-sdk-alpha
jest.mock('react-native/Libraries/TurboModule/TurboModuleRegistry', () => ({
getEnforcing: jest.fn(name => {
@@ -125,21 +262,44 @@ jest.mock(
startDetecting: jest.fn(),
};
const RN = jest.requireActual('react-native');
// Override the PixelRatio immediately
RN.PixelRatio = PixelRatio;
// Make sure both the default and named exports work
const mockedRN = {
...RN,
// Return a simple object with all the mocks we need
// Avoid nested requireActual/requireMock to prevent OOM in CI
return {
__esModule: true,
PixelRatio,
default: {
...RN,
PixelRatio,
Platform: {
OS: 'ios',
select: jest.fn(obj => obj.ios || obj.default),
Version: 14,
},
Dimensions: {
get: jest.fn(() => ({
window: { width: 375, height: 667, scale: 2 },
screen: { width: 375, height: 667, scale: 2 },
})),
},
StyleSheet: {
create: jest.fn(styles => styles),
flatten: jest.fn(style => style),
hairlineWidth: 1,
absoluteFillObject: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
View: 'View',
Text: 'Text',
ScrollView: 'ScrollView',
TouchableOpacity: 'TouchableOpacity',
requireNativeComponent: jest.fn(name => {
const MockNativeComponent = jest.fn(props => props.children || null);
MockNativeComponent.displayName = `Mock(${name})`;
return MockNativeComponent;
}),
};
return mockedRN;
},
{ virtual: true },
);
@@ -291,12 +451,19 @@ jest.mock('react-native-gesture-handler', () => {
const MockFlatList = jest.fn(props => null);
return {
...jest.requireActual('react-native-gesture-handler/jestSetup'),
// Provide gesture handler mock without requireActual to avoid OOM
GestureHandlerRootView: ({ children }) => children,
ScrollView: MockScrollView,
TouchableOpacity: MockTouchableOpacity,
TouchableHighlight: MockTouchableHighlight,
FlatList: MockFlatList,
Directions: {},
State: {},
Swipeable: jest.fn(() => null),
DrawerLayout: jest.fn(() => null),
PanGestureHandler: jest.fn(() => null),
TapGestureHandler: jest.fn(() => null),
LongPressGestureHandler: jest.fn(() => null),
};
});
@@ -745,20 +912,8 @@ jest.mock('react-native-passport-reader', () => {
};
});
// Mock NativeModules without requiring react-native to avoid memory issues
// Create a minimal NativeModules mock for PassportReader
const NativeModules = {
PassportReader: {
configure: jest.fn(),
scanPassport: jest.fn(),
trackEvent: jest.fn(),
flush: jest.fn(),
reset: jest.fn(),
},
};
// Make it available globally for any code that expects it
global.NativeModules = NativeModules;
// NativeModules is already defined at the top of the file and assigned to global.NativeModules
// No need to redefine it here
// Mock @/integrations/nfc/passportReader to properly expose the interface expected by tests
jest.mock('./src/integrations/nfc/passportReader', () => {
@@ -1001,226 +1156,60 @@ jest.mock('react-native-svg', () => {
});
// Mock React Navigation
jest.mock('@react-navigation/native', () => {
const actualNav = jest.requireActual('@react-navigation/native');
// Mock react-native-biometrics to prevent NativeModules errors
jest.mock('react-native-biometrics', () => {
class MockReactNativeBiometrics {
constructor(options) {
// Constructor accepts options but doesn't need to do anything
}
isSensorAvailable = jest.fn().mockResolvedValue({
available: true,
biometryType: 'TouchID',
});
createKeys = jest.fn().mockResolvedValue({ publicKey: 'mock-public-key' });
deleteKeys = jest.fn().mockResolvedValue(true);
createSignature = jest
.fn()
.mockResolvedValue({ signature: 'mock-signature' });
simplePrompt = jest.fn().mockResolvedValue({ success: true });
}
return {
...actualNav,
useFocusEffect: jest.fn(callback => {
// Immediately invoke the effect for testing without requiring a container
return callback();
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
})),
createNavigationContainerRef: jest.fn(() => ({
current: null,
getCurrentRoute: jest.fn(),
})),
createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })),
__esModule: true,
default: MockReactNativeBiometrics,
};
});
jest.mock('@react-navigation/native-stack', () => ({
createNativeStackNavigator: jest.fn(() => ({
displayName: 'MockStackNavigator',
})),
createNavigatorFactory: jest.fn(),
// Mock NativeAppState native module to prevent getCurrentAppState errors
jest.mock('react-native/Libraries/AppState/NativeAppState', () => ({
__esModule: true,
default: {
getConstants: jest.fn(() => ({ initialAppState: 'active' })),
getCurrentAppState: jest.fn(() => Promise.resolve({ app_state: 'active' })),
addListener: jest.fn(),
removeListeners: jest.fn(),
},
}));
// Mock core navigation to avoid requiring a NavigationContainer for hooks
jest.mock('@react-navigation/core', () => {
const actualCore = jest.requireActual('@react-navigation/core');
return {
...actualCore,
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
})),
};
});
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
// Note: Individual test files can override this with their own more specific mocks
jest.mock('react-native-webview', () => {
// Avoid requiring React to prevent nested require memory issues
// Return a simple pass-through mock - tests can override with JSX mocks if needed
const MockWebView = jest.fn(() => null);
MockWebView.displayName = 'MockWebView';
// Mock AppState to prevent getCurrentAppState errors
jest.mock('react-native/Libraries/AppState/AppState', () => {
// Use the global appStateListeners array so tests can access it
const appStateListeners = global.mockAppStateListeners || [];
return {
__esModule: true,
default: MockWebView,
WebView: MockWebView,
default: {
currentState: 'active',
addEventListener: jest.fn((eventType, handler) => {
appStateListeners.push(handler);
return {
remove: () => {
const index = appStateListeners.indexOf(handler);
if (index >= 0) {
appStateListeners.splice(index, 1);
}
},
};
}),
},
};
});
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Avoid requiring React to prevent nested require memory issues
// These need to pass through children so WebView is rendered
const Layout = ({ children, ...props }) => children;
const TopSection = ({ children, ...props }) => children;
const BottomSection = ({ children, ...props }) => children;
const FullSection = ({ children, ...props }) => children;
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
};
});
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Button needs to render a host element with onPress so tests can interact with it
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
// Render as a mock-touchable-opacity host element so fireEvent.press works
// This allows tests to query by testID and press the button
return (
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
{icon || children || null}
</mock-touchable-opacity>
);
});
Button.displayName = 'MockButton';
const XStack = jest.fn(({ children, ...props }) => children || null);
XStack.displayName = 'MockXStack';
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
return {
__esModule: true,
Button,
XStack,
// Provide minimal Text to satisfy potential usages
Text,
};
});
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
jest.mock('tamagui', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Helper to create a simple pass-through mock component
const createMockComponent = displayName => {
const Component = jest.fn(props => props.children || null);
Component.displayName = displayName;
return Component;
};
// Mock styled function - simplified version that returns the component
const styled = jest.fn(Component => Component);
// Create all Tamagui component mocks
const Button = createMockComponent('MockButton');
const XStack = createMockComponent('MockXStack');
const YStack = createMockComponent('MockYStack');
const ZStack = createMockComponent('MockZStack');
const Text = createMockComponent('MockText');
const View = createMockComponent('MockView');
const ScrollView = createMockComponent('MockScrollView');
const Spinner = createMockComponent('MockSpinner');
const Image = createMockComponent('MockImage');
const Card = createMockComponent('MockCard');
const Separator = createMockComponent('MockSeparator');
const TextArea = createMockComponent('MockTextArea');
const Input = createMockComponent('MockInput');
const Anchor = createMockComponent('MockAnchor');
// Mock Select component with nested components
const Select = Object.assign(createMockComponent('MockSelect'), {
Trigger: createMockComponent('MockSelectTrigger'),
Value: createMockComponent('MockSelectValue'),
Content: createMockComponent('MockSelectContent'),
Item: createMockComponent('MockSelectItem'),
Group: createMockComponent('MockSelectGroup'),
Label: createMockComponent('MockSelectLabel'),
Viewport: createMockComponent('MockSelectViewport'),
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
});
// Mock Sheet component with nested components
const Sheet = Object.assign(createMockComponent('MockSheet'), {
Frame: createMockComponent('MockSheetFrame'),
Overlay: createMockComponent('MockSheetOverlay'),
Handle: createMockComponent('MockSheetHandle'),
ScrollView: createMockComponent('MockSheetScrollView'),
});
// Mock Adapt component
const Adapt = createMockComponent('MockAdapt');
// Mock TamaguiProvider - simple pass-through that renders children
const TamaguiProvider = jest.fn(({ children }) => children || null);
TamaguiProvider.displayName = 'MockTamaguiProvider';
// Mock configuration factory functions
const createFont = jest.fn(() => ({}));
const createTamagui = jest.fn(() => ({}));
return {
__esModule: true,
styled,
Button,
XStack,
YStack,
ZStack,
Text,
View,
ScrollView,
Spinner,
Image,
Card,
Separator,
TextArea,
Input,
Anchor,
Select,
Sheet,
Adapt,
TamaguiProvider,
createFont,
createTamagui,
// Provide default exports for other common components
default: jest.fn(() => null),
};
});
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
// Avoid requiring React to prevent nested require memory issues
// Return mock components that can be queried by testID
const makeIcon = name => {
// Use a mock element tag that React can render
const Icon = props => ({
$$typeof: Symbol.for('react.element'),
type: `mock-icon-${name}`,
props: { testID: `icon-${name}`, ...props },
key: null,
ref: null,
});
Icon.displayName = `MockIcon(${name})`;
return Icon;
};
return {
__esModule: true,
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
};
});
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
// Avoid requiring React to prevent nested require memory issues
const WebViewFooter = jest.fn(() => null);
return { __esModule: true, WebViewFooter };
});

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.9.2",
"version": "2.9.4",
"private": true,
"type": "module",
"scripts": {
@@ -29,11 +29,14 @@
"format": "yarn nice",
"ia": "yarn install-app",
"imports:fix": "node ./scripts/alias-imports.cjs",
"postinstall": "npx patch-package --patch-dir ../patches || true",
"install-app": "yarn install-app:setup && yarn clean:xcode-env-local",
"install-app:mobile-deploy": "yarn install && yarn build:deps && yarn clean:xcode-env-local",
"install-app:setup": "yarn install && yarn build:deps && yarn setup:android-deps && cd ios && bundle install && scripts/pod-install-with-cache-fix.sh && cd ..",
"ios": "yarn build:deps && node scripts/run-ios-simulator.cjs",
"ios:fastlane-debug": "yarn reinstall && bundle exec fastlane --verbose ios internal_test",
"jest:clear": "node ./node_modules/jest/bin/jest.js --clearCache",
"jest:run": "node ./node_modules/jest/bin/jest.js",
"lint": "eslint . --cache --cache-location .eslintcache",
"lint:fix": "eslint --fix . --cache --cache-location .eslintcache",
"mobile-deploy": "node scripts/mobile-deploy-confirm.cjs both",
@@ -42,8 +45,7 @@
"mobile-local-deploy": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs both",
"mobile-local-deploy:android": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs android",
"mobile-local-deploy:ios": "FORCE_UPLOAD_LOCAL_DEV=true node scripts/mobile-deploy-confirm.cjs ios",
"nice": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn build:deps; fi; yarn imports:fix && yarn lint:fix && yarn fmt:fix'",
"postinstall": "npx patch-package --patch-dir ../patches || true",
"nice": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn build:deps; fi; yarn imports:fix && yarn lint:fix'",
"reinstall": "yarn --top-level run reinstall-app",
"release": "./scripts/release.sh",
"release:major": "./scripts/release.sh major",
@@ -67,17 +69,20 @@
"test:tree-shaking": "node ./scripts/test-tree-shaking.cjs",
"test:web-build": "yarn jest:run tests/web-build-render.test.ts --testTimeout=180000",
"types": "tsc --noEmit",
"jest:run": "node ./node_modules/jest/bin/jest.js",
"watch:sdk": "yarn workspace @selfxyz/mobile-sdk-alpha watch",
"web": "vite",
"web:build": "yarn build:deps && vite build",
"web:preview": "vite preview"
},
"resolutions": {
"punycode": "npm:punycode.js@2.3.1"
"punycode": "npm:punycode.js@2.3.1",
"react-native-blur-effect": "1.1.3",
"react-native-webview": "13.16.0"
},
"overrides": {
"punycode": "npm:punycode.js@2.3.1"
"punycode": "npm:punycode.js@2.3.1",
"react-native-blur-effect": "1.1.3",
"react-native-webview": "13.16.0"
},
"dependencies": {
"@babel/runtime": "^7.28.3",
@@ -137,6 +142,7 @@
"react-native": "0.76.9",
"react-native-app-auth": "^8.0.3",
"react-native-biometrics": "^3.0.1",
"react-native-blur-effect": "^1.1.3",
"react-native-check-version": "^1.3.0",
"react-native-cloud-storage": "^2.2.2",
"react-native-device-info": "^14.0.4",

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36">
<path fill="currentColor" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
</svg>

After

Width:  |  Height:  |  Size: 774 B

View File

@@ -6,6 +6,8 @@ import React from 'react';
import { Platform } from 'react-native';
import { Anchor, styled } from 'tamagui';
import { androidBackupDocsUrl, appleICloudDocsUrl } from '@/consts/links';
const StyledAnchor = styled(Anchor, {
fontSize: 15,
fontFamily: 'DINOT-Medium',
@@ -15,16 +17,13 @@ const StyledAnchor = styled(Anchor, {
const BackupDocumentationLink: React.FC = () => {
if (Platform.OS === 'ios') {
return (
<StyledAnchor unstyled href="https://support.apple.com/en-us/102651">
<StyledAnchor unstyled href={appleICloudDocsUrl}>
iCloud data
</StyledAnchor>
);
}
return (
<StyledAnchor
unstyled
href="https://developer.android.com/identity/data/autobackup"
>
<StyledAnchor unstyled href={androidBackupDocsUrl}>
Android Backup
</StyledAnchor>
);

View File

@@ -21,6 +21,7 @@ import CogHollowIcon from '@/assets/icons/cog_hollow.svg';
import PlusCircleIcon from '@/assets/icons/plus_circle.svg';
import ScanIcon from '@/assets/icons/qr_scan.svg';
import { NavBar } from '@/components/navbar/BaseNavBar';
import { apiBaseUrl } from '@/consts/links';
import { buttonTap } from '@/integrations/haptics';
import { extraYPadding } from '@/utils/styleUtils';
@@ -40,7 +41,7 @@ export const HomeNavBar = (props: NativeStackHeaderProps) => {
if (uuidRegex.test(content)) {
try {
const response = await fetch(
`https://api.self.xyz/consume-deferred-linking-token?token=${content}`,
`${apiBaseUrl}/consume-deferred-linking-token?token=${content}`,
);
const result = await response.json();
if (result.status !== 'success') {

View File

@@ -29,6 +29,7 @@ import StarBlackIcon from '@/assets/icons/star_black.svg';
import LogoInversed from '@/assets/images/logo_inversed.svg';
import MajongImage from '@/assets/images/majong.png';
import { PointHistoryList } from '@/components/PointHistoryList';
import { appsUrl } from '@/consts/links';
import { useIncomingPoints, usePoints } from '@/hooks/usePoints';
import { usePointsGuardrail } from '@/hooks/usePointsGuardrail';
import type { RootStackParamList } from '@/navigation';
@@ -428,7 +429,7 @@ const Points: React.FC = () => {
onPress={() => {
selfClient.trackEvent(PointEvents.EXPLORE_APPS);
navigation.navigate('WebView', {
url: 'https://apps.self.xyz',
url: appsUrl,
title: 'Explore Apps',
});
}}

View File

@@ -2,22 +2,41 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// =============================================================================
// External Links
// =============================================================================
// All external URLs used in the mobile app are centralized here for easy
// maintenance and testing. Links are sorted alphabetically.
export const androidBackupDocsUrl =
'https://developer.android.com/identity/data/autobackup';
export const apiBaseUrl = 'https://api.self.xyz';
export const apiPingUrl = 'https://api.self.xyz/ping';
export const appStoreUrl = 'https://apps.apple.com/app/self-zk/id6478563710';
export const appleICloudDocsUrl = 'https://support.apple.com/en-us/102651';
export const appsUrl = 'https://apps.self.xyz';
export const discordUrl = 'https://discord.gg/selfxyz';
export const gitHubUrl = 'https://github.com/selfxyz/self';
export const googleDriveAppDataScope =
'https://www.googleapis.com/auth/drive.appdata';
export const googleOAuthAuthorizationEndpoint =
'https://accounts.google.com/o/oauth2/v2/auth';
export const googleOAuthTokenEndpoint = 'https://oauth2.googleapis.com/token';
export const notificationApiStagingUrl =
'https://notification.staging.self.xyz';
export const notificationApiUrl = 'https://notification.self.xyz';
export const playStoreUrl =
'https://play.google.com/store/apps/details?id=com.proofofpassportapp';
export const pointsApiBaseUrl = 'https://points.self.xyz';
export const privacyUrl = 'https://self.xyz/privacy';
export const referralBaseUrl = 'https://referral.self.xyz';
export const selfLogoReverseUrl =
'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png';
export const selfUrl = 'https://self.xyz';
export const supportedBiometricIdsUrl =
'https://docs.self.xyz/use-self/self-map-countries-list';
export const telegramUrl = 'https://t.me/selfprotocolbuilder';
export const telegramUrl = 'https://t.me/selfxyz';
export const termsUrl = 'https://self.xyz/terms';
export const turnkeyOAuthRedirectAndroidUri = 'https://redirect.self.xyz';
export const turnkeyOAuthRedirectIosUri = 'https://oauth-redirect.turnkey.com';
export const xUrl = 'https://x.com/selfprotocol';

View File

@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export type RecoveryPromptAllowedRoute =
(typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number];
export const RECOVERY_PROMPT_ALLOWED_ROUTES = ['Home'] as const;

View File

@@ -2,11 +2,16 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
// Turnkey OAuth redirect URIs
export const TURNKEY_OAUTH_REDIRECT_URI_ANDROID = 'https://redirect.self.xyz';
import {
turnkeyOAuthRedirectAndroidUri,
turnkeyOAuthRedirectIosUri,
} from '@/consts/links';
export const TURNKEY_OAUTH_REDIRECT_URI_IOS =
'https://oauth-redirect.turnkey.com';
// Turnkey OAuth redirect URIs
export const TURNKEY_OAUTH_REDIRECT_URI_ANDROID =
turnkeyOAuthRedirectAndroidUri;
export const TURNKEY_OAUTH_REDIRECT_URI_IOS = turnkeyOAuthRedirectIosUri;
// Re-export all mocks for easier imports
export { parseScanResponse, scan } from '@/devtools/mocks/nfcScanner';

View File

@@ -7,6 +7,7 @@ import { Linking, Platform } from 'react-native';
import { SettingsEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import { apiPingUrl } from '@/consts/links';
import { useModal } from '@/hooks/useModal';
import { useNetInfo } from '@/hooks/useNetInfo';
import { navigationRef } from '@/navigation';
@@ -34,7 +35,7 @@ const connectionModalParams = {
export default function useConnectionModal() {
const { isConnected, isInternetReachable } = useNetInfo({
reachabilityUrl: 'https://api.self.xyz/ping',
reachabilityUrl: apiPingUrl,
});
const { showModal, dismissModal, visible } = useModal(connectionModalParams);
//isConnected and isInternetReachable can be null for unknown state

View File

@@ -3,10 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useCallback, useRef, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation';
import { navigationRef } from '@/navigation';
import type { ModalParams } from '@/screens/app/ModalScreen';
import {
getModalCallbacks,
@@ -16,23 +14,47 @@ import {
export const useModal = (params: ModalParams) => {
const [visible, setVisible] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const callbackIdRef = useRef<number>();
const handleModalDismiss = useCallback(() => {
setVisible(false);
params.onModalDismiss();
}, [params]);
const handleModalButtonPress = useCallback(() => {
setVisible(false);
return params.onButtonPress();
}, [params]);
const showModal = useCallback(() => {
if (!navigationRef.isReady()) {
// Navigation not ready yet; avoid throwing and simply skip showing
return;
}
setVisible(true);
const { onButtonPress, onModalDismiss, ...rest } = params;
const id = registerModalCallbacks({ onButtonPress, onModalDismiss });
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onButtonPress: _ignored,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onModalDismiss: _ignored2,
...rest
} = params;
const id = registerModalCallbacks({
onButtonPress: handleModalButtonPress,
onModalDismiss: handleModalDismiss,
});
callbackIdRef.current = id;
navigation.navigate('Modal', { ...rest, callbackId: id });
}, [params, navigation]);
navigationRef.navigate('Modal', { ...rest, callbackId: id });
}, [handleModalButtonPress, handleModalDismiss, params]);
const dismissModal = useCallback(() => {
setVisible(false);
const routes = navigation.getState()?.routes;
if (!navigationRef.isReady()) {
return;
}
const routes = navigationRef.getState()?.routes;
if (routes?.at(routes.length - 1)?.name === 'Modal') {
navigation.goBack();
navigationRef.goBack();
}
if (callbackIdRef.current !== undefined) {
const callbacks = getModalCallbacks(callbackIdRef.current);
@@ -47,7 +69,7 @@ export const useModal = (params: ModalParams) => {
unregisterModalCallbacks(callbackIdRef.current);
callbackIdRef.current = undefined;
}
}, [navigation]);
}, []);
return {
showModal,

View File

@@ -2,64 +2,167 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { AppStateStatus } from 'react-native';
import { AppState } from 'react-native';
import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts';
import { useModal } from '@/hooks/useModal';
import { navigationRef } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
// TODO: need to debug and test the logic. it pops up too often.
export default function useRecoveryPrompts() {
const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES;
type UseRecoveryPromptsOptions = {
allowedRoutes?: readonly string[];
};
export default function useRecoveryPrompts({
allowedRoutes = DEFAULT_ALLOWED_ROUTES,
}: UseRecoveryPromptsOptions = {}) {
const { homeScreenViewCount, cloudBackupEnabled, hasViewedRecoveryPhrase } =
useSettingStore();
const { getAllDocuments } = usePassport();
const hasRecoveryEnabled = cloudBackupEnabled || hasViewedRecoveryPhrase;
const { showModal, visible } = useModal({
titleText: 'Protect your account',
bodyText:
'Enable cloud backup or save your recovery phrase so you can recover your account.',
buttonText: 'Back up now',
onButtonPress: async () => {
if (navigationRef.isReady()) {
navigationRef.navigate('CloudBackupSettings', {
nextScreen: 'SaveRecoveryPhrase',
});
if (!navigationRef.isReady()) {
return;
}
navigationRef.navigate('CloudBackupSettings', {
nextScreen: 'SaveRecoveryPhrase',
});
},
onModalDismiss: () => {},
} as const);
useEffect(() => {
async function maybePrompt() {
if (!navigationRef.isReady()) {
const lastPromptCount = useRef<number | null>(null);
const appStateStatus = useRef<AppStateStatus>(
(AppState.currentState as AppStateStatus | null) ?? 'active',
);
const allowedRouteSet = useMemo(
() => new Set(allowedRoutes),
[allowedRoutes],
);
const isRouteEligible = useCallback(
(routeName: string | undefined): routeName is string => {
if (!routeName) {
return false;
}
if (!allowedRouteSet.has(routeName)) {
return false;
}
return true;
},
[allowedRouteSet],
);
const maybePrompt = useCallback(async () => {
if (!navigationRef.isReady()) {
return;
}
if (appStateStatus.current !== 'active') {
return;
}
const currentRouteName = navigationRef.getCurrentRoute?.()?.name;
if (!isRouteEligible(currentRouteName)) {
return;
}
if (hasRecoveryEnabled) {
return;
}
try {
const docs = await getAllDocuments();
const hasRegisteredDocument = Object.values(docs).some(
doc => doc.metadata.isRegistered === true,
);
if (!hasRegisteredDocument) {
return;
}
if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) {
try {
const docs = await getAllDocuments();
if (Object.keys(docs).length === 0) {
return;
}
const shouldPrompt =
loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0);
if (shouldPrompt) {
showModal();
}
} catch {
// Silently fail to avoid breaking the hook
// If we can't get documents, we shouldn't show the prompt
return;
const shouldPrompt =
homeScreenViewCount >= 5 && homeScreenViewCount % 5 === 0;
if (
shouldPrompt &&
!visible &&
lastPromptCount.current !== homeScreenViewCount
) {
// Double-check route eligibility right before showing modal
// to prevent showing on wrong screen if user navigated during async call
const currentRouteNameAfterAsync =
navigationRef.getCurrentRoute?.()?.name;
if (isRouteEligible(currentRouteNameAfterAsync)) {
showModal();
lastPromptCount.current = homeScreenViewCount;
}
}
} catch {
// Silently fail to avoid breaking the hook
// If we can't get documents, we shouldn't show the prompt
return;
}
maybePrompt().catch(() => {});
}, [
loginCount,
cloudBackupEnabled,
hasViewedRecoveryPhrase,
showModal,
getAllDocuments,
hasRecoveryEnabled,
homeScreenViewCount,
isRouteEligible,
showModal,
visible,
]);
useEffect(() => {
const runMaybePrompt = () => {
maybePrompt().catch(() => {
// Ignore promise rejection - already handled in maybePrompt
});
};
runMaybePrompt();
const handleAppStateChange = (nextState: AppStateStatus) => {
const previousState = appStateStatus.current;
appStateStatus.current = nextState;
if (
(previousState === 'background' || previousState === 'inactive') &&
nextState === 'active'
) {
runMaybePrompt();
}
};
const appStateSubscription = AppState.addEventListener(
'change',
handleAppStateChange,
);
const navigationUnsubscribe = navigationRef.addListener?.(
'state',
runMaybePrompt,
);
return () => {
appStateSubscription.remove();
if (typeof navigationUnsubscribe === 'function') {
navigationUnsubscribe();
}
};
}, [maybePrompt]);
return { visible };
}

View File

@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from 'react';
import { referralBaseUrl } from '@/consts/links';
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { useSettingStore } from '@/stores/settingStore';
@@ -15,8 +16,7 @@ interface ReferralMessageResult {
const buildReferralMessageFromAddress = (
userPointsAddress: string,
): ReferralMessageResult => {
const baseDomain = 'https://referral.self.xyz';
const referralLink = `${baseDomain}/referral/${userPointsAddress}`;
const referralLink = `${referralBaseUrl}/referral/${userPointsAddress}`;
return {
message: `Join Self and use my referral link:\n\n${referralLink}`,
referralLink,

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Buffer } from 'buffer';
import { Platform } from 'react-native';
import type { PassportData } from '@selfxyz/common/types';

View File

@@ -17,6 +17,7 @@ import type { DocumentCategory } from '@selfxyz/common/utils/types';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { DefaultNavBar } from '@/components/navbar';
import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import AppLayout from '@/layouts/AppLayout';
import accountScreens from '@/navigation/account';
import appScreens from '@/navigation/app';
@@ -198,6 +199,7 @@ const { trackScreenView } = analytics();
const Navigation = createStaticNavigation(AppNavigation);
const NavigationWithTracking = () => {
useRecoveryPrompts();
const selfClient = useSelfClient();
const trackScreen = () => {
const currentRoute = navigationRef.getCurrentRoute();

View File

@@ -8,6 +8,7 @@ import type {
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import { selfUrl } from '@/consts/links';
import type { SharedRoutesParamList } from '@/navigation/types';
import ComingSoonScreen from '@/screens/shared/ComingSoonScreen';
import { WebViewScreen } from '@/screens/shared/WebViewScreen';
@@ -33,7 +34,7 @@ const sharedScreens: { [K in ScreenName]: ScreenConfig<K> } = {
headerShown: false,
} as NativeStackNavigationOptions,
initialParams: {
url: 'https://self.xyz',
url: selfUrl,
title: undefined,
shareTitle: undefined,
shareMessage: undefined,

View File

@@ -260,7 +260,6 @@ export const AuthProvider = ({
setIsAuthenticatingPromise(null);
setIsAuthenticated(true);
useSettingStore.getState().incrementLoginCount();
trackEvent(AuthEvents.BIOMETRIC_LOGIN_SUCCESS);
setAuthenticatedTimeout(previousTimeout => {
if (previousTimeout) {
@@ -369,6 +368,11 @@ export async function getOrGeneratePointsAddress(
return pointsAddr;
}
export function getPrivateKeyFromMnemonic(mnemonic: string) {
const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic);
return wallet.privateKey;
}
export async function hasSecretStored() {
const seed = await Keychain.getGenericPassword({ service: SERVICE_NAME });
return !!seed;
@@ -470,8 +474,7 @@ export async function unsafe_getPrivateKey(keychainOptions?: KeychainOptions) {
return null;
}
const mnemonic = JSON.parse(foundMnemonic) as Mnemonic;
const wallet = ethers.HDNodeWallet.fromPhrase(mnemonic.phrase);
return wallet.privateKey;
return getPrivateKeyFromMnemonic(mnemonic.phrase);
}
export const useAuth = () => {

View File

@@ -89,6 +89,23 @@ export const SelfClientProvider = ({ children }: PropsWithChildren) => {
},
},
documents: selfClientDocumentsAdapter,
navigation: {
goBack: () => {
if (navigationRef.isReady()) {
navigationRef.goBack();
}
},
goTo: (routeName, params) => {
if (navigationRef.isReady()) {
if (params !== undefined) {
// @ts-expect-error
navigationRef.navigate(routeName, params);
} else {
navigationRef.navigate(routeName as never);
}
}
},
},
crypto: {
async hash(
data: Uint8Array,

View File

@@ -32,9 +32,9 @@ import RestoreAccountSvg from '@/assets/icons/restore_account.svg';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
import { useAuth } from '@/providers/authProvider';
import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider';
import {
loadPassportDataAndSecret,
loadPassportData,
reStorePassportDataWithRightCSCA,
} from '@/providers/passportDataProvider';
import { STORAGE_NAME, useBackupMnemonic } from '@/services/cloud-backup';
@@ -50,7 +50,8 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
// const { turnkeyWallets, refreshWallets } = useTurnkeyUtils();
// const { getMnemonic } = useTurnkeyUtils();
// const { authState } = useTurnkey();
const [restoring, setRestoring] = useState(false);
const [_restoringFromTurnkey, _setRestoringFromTurnkey] = useState(false);
const [restoringFromCloud, setRestoringFromCloud] = useState(false);
const { cloudBackupEnabled, toggleCloudBackupEnabled, biometricsAvailable } =
useSettingStore();
const { download } = useBackupMnemonic();
@@ -73,6 +74,7 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
async (
mnemonic: Mnemonic,
isCloudRestore: boolean = false,
setRestoring: (value: boolean) => void,
): Promise<boolean> => {
try {
const result = await restoreAccountFromMnemonic(mnemonic.phrase);
@@ -85,32 +87,55 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
return false;
}
const passportDataAndSecret =
(await loadPassportDataAndSecret()) as string;
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const { isRegistered, csca } =
await isUserRegisteredWithAlternativeCSCA(passportData, secret, {
getCommitmentTree(docCategory) {
return useProtocolStore.getState()[docCategory].commitment_tree;
},
getAltCSCA(docCategory) {
if (docCategory === 'aadhaar') {
const publicKeys =
useProtocolStore.getState().aadhaar.public_keys;
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
return publicKeys
? Object.fromEntries(publicKeys.map(key => [key, key]))
: {};
}
const passportData = await loadPassportData();
const secret = getPrivateKeyFromMnemonic(mnemonic.phrase);
return useProtocolStore.getState()[docCategory].alternative_csca;
},
if (!passportData || !secret) {
console.warn('Failed to load passport data or secret');
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_AUTH, {
reason: 'no_passport_data_or_secret',
});
navigation.navigate({ name: 'Home', params: {} });
setRestoring(false);
return false;
}
const passportDataParsed = JSON.parse(passportData);
const { isRegistered, csca } =
await isUserRegisteredWithAlternativeCSCA(
passportDataParsed,
secret as string,
{
getCommitmentTree(docCategory) {
return useProtocolStore.getState()[docCategory].commitment_tree;
},
getAltCSCA(docCategory) {
if (docCategory === 'aadhaar') {
const publicKeys =
useProtocolStore.getState().aadhaar.public_keys;
// Convert string[] to Record<string, string> format expected by AlternativeCSCA
return publicKeys
? Object.fromEntries(publicKeys.map(key => [key, key]))
: {};
}
return useProtocolStore.getState()[docCategory]
.alternative_csca;
},
},
);
if (!isRegistered) {
console.warn(
'Secret provided did not match a registered ID. Please try again.',
);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED);
trackEvent(
BackupEvents.CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED,
{
reason: 'document_not_registered',
hasCSCA: !!csca,
},
);
navigation.navigate({ name: 'Home', params: {} });
setRestoring(false);
return false;
@@ -118,7 +143,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
if (isCloudRestore && !cloudBackupEnabled) {
toggleCloudBackupEnabled();
}
reStorePassportDataWithRightCSCA(passportData, csca as string);
await reStorePassportDataWithRightCSCA(
passportDataParsed,
csca as string,
);
await markCurrentDocumentAsRegistered(selfClient);
trackEvent(BackupEvents.CLOUD_RESTORE_SUCCESS);
trackEvent(BackupEvents.ACCOUNT_RECOVERY_COMPLETED);
@@ -126,7 +154,10 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
setRestoring(false);
return true;
} catch (e: unknown) {
console.error(e);
console.error(
'Restore account error:',
e instanceof Error ? e.message : 'Unknown error',
);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
setRestoring(false);
return false;
@@ -146,7 +177,7 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
// DISABLED FOR NOW: Turnkey functionality
// const onRestoreFromTurnkeyPress = useCallback(async () => {
// setRestoring(true);
// setRestoringFromTurnkey(true);
// try {
// const mnemonicPhrase = await getMnemonic();
// const mnemonic: Mnemonic = {
@@ -157,7 +188,11 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
// },
// entropy: '',
// };
// const success = await restoreAccountFlow(mnemonic);
// const success = await restoreAccountFlow(
// mnemonic,
// false,
// setRestoringFromTurnkey,
// );
// if (success) {
// setTurnkeyBackupEnabled(true);
// }
@@ -165,19 +200,22 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
// console.error('Turnkey restore error:', error);
// trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
// } finally {
// setRestoring(false);
// setRestoringFromTurnkey(false);
// }
// }, [getMnemonic, restoreAccountFlow, setTurnkeyBackupEnabled, trackEvent]);
const onRestoreFromCloudPress = useCallback(async () => {
setRestoring(true);
setRestoringFromCloud(true);
try {
const mnemonic = await download();
await restoreAccountFlow(mnemonic, true);
await restoreAccountFlow(mnemonic, true, setRestoringFromCloud);
} catch (error) {
console.error('Cloud restore error:', error);
console.error(
'Cloud restore error:',
error instanceof Error ? error.message : 'Unknown error',
);
trackEvent(BackupEvents.CLOUD_RESTORE_FAILED_UNKNOWN);
setRestoring(false);
setRestoringFromCloud(false);
}
}, [download, restoreAccountFlow, trackEvent]);
@@ -218,23 +256,23 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
onPress={onRestoreFromTurnkeyPress}
testID="button-from-turnkey"
disabled={
restoring ||
restoringFromTurnkey ||
!biometricsAvailable ||
(authState === AuthState.Authenticated &&
turnkeyWallets.length === 0)
}
>
{restoring ? 'Restoring' : 'Restore'} from Turnkey
{restoring ? '' : ''}
{restoringFromTurnkey ? 'Restoring' : 'Restore'} from Turnkey
{restoringFromTurnkey ? '' : ''}
</PrimaryButton> */}
<PrimaryButton
trackEvent={BackupEvents.CLOUD_BACKUP_STARTED}
onPress={onRestoreFromCloudPress}
testID="button-from-teststorage"
disabled={restoring || !biometricsAvailable}
disabled={restoringFromCloud || !biometricsAvailable}
>
{restoring ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
{restoring ? '' : ''}
{restoringFromCloud ? 'Restoring' : 'Restore'} from {STORAGE_NAME}
{restoringFromCloud ? '' : ''}
</PrimaryButton>
<XStack gap={64} alignItems="center" justifyContent="space-between">
<Separator flexGrow={1} />
@@ -244,7 +282,6 @@ const AccountRecoveryChoiceScreen: React.FC = () => {
<SecondaryButton
trackEvent={BackupEvents.MANUAL_RECOVERY_SELECTED}
onPress={handleManualRecoveryPress}
disabled={restoring}
>
<XStack alignItems="center" justifyContent="center">
<Keyboard height={25} width={40} color={slate500} />

View File

@@ -31,9 +31,9 @@ import {
import Paste from '@/assets/icons/paste.svg';
import type { RootStackParamList } from '@/navigation';
import { useAuth } from '@/providers/authProvider';
import { getPrivateKeyFromMnemonic, useAuth } from '@/providers/authProvider';
import {
loadPassportDataAndSecret,
loadPassportData,
reStorePassportDataWithRightCSCA,
} from '@/providers/passportDataProvider';
@@ -74,8 +74,10 @@ const RecoverWithPhraseScreen: React.FC = () => {
return;
}
const passportDataAndSecret = await loadPassportDataAndSecret();
if (!passportDataAndSecret) {
const passportData = await loadPassportData();
const secret = getPrivateKeyFromMnemonic(slimMnemonic);
if (!passportData || !secret) {
console.warn(
'No passport data found on device. Please scan or import your document.',
);
@@ -86,9 +88,10 @@ const RecoverWithPhraseScreen: React.FC = () => {
setRestoring(false);
return;
}
const { passportData, secret } = JSON.parse(passportDataAndSecret);
const passportDataParsed = JSON.parse(passportData);
const { isRegistered, csca } = await isUserRegisteredWithAlternativeCSCA(
passportData,
passportDataParsed,
secret as string,
{
getCommitmentTree(docCategory) {
@@ -122,7 +125,7 @@ const RecoverWithPhraseScreen: React.FC = () => {
}
if (csca) {
await reStorePassportDataWithRightCSCA(passportData, csca);
await reStorePassportDataWithRightCSCA(passportDataParsed, csca);
}
await markCurrentDocumentAsRegistered(selfClient);

View File

@@ -23,6 +23,7 @@ import {
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import Discord from '@/assets/icons/discord.svg';
import Github from '@/assets/icons/github.svg';
import Cloud from '@/assets/icons/settings_cloud_backup.svg';
import Data from '@/assets/icons/settings_data.svg';
@@ -35,6 +36,7 @@ import Web from '@/assets/icons/webpage.svg';
import X from '@/assets/icons/x.svg';
import {
appStoreUrl,
discordUrl,
gitHubUrl,
playStoreUrl,
selfUrl,
@@ -106,6 +108,7 @@ const social = [
[Github, gitHubUrl],
[Web, selfUrl],
[Telegram, telegramUrl],
[Discord, discordUrl],
] as [React.FC<SvgProps>, string][];
const MenuButton: React.FC<MenuButtonProps> = ({ children, Icon, onPress }) => (

View File

@@ -9,6 +9,7 @@ import type { StaticScreenProps } from '@react-navigation/native';
import { useFocusEffect, useIsFocused } from '@react-navigation/native';
import type { DocumentCategory } from '@selfxyz/common/utils/types';
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha';
import {
advercase,
dinot,
@@ -17,7 +18,6 @@ import {
} from '@selfxyz/mobile-sdk-alpha';
import failAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/fail.json';
import proveLoadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/prove.json';
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha/browser';
import {
black,
slate400,

View File

@@ -65,32 +65,26 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
return;
}
// Dismiss the modal BEFORE calling the callback
// This prevents race conditions when the callback navigates to another screen
try {
// Try to execute the callback first
await callbacks.onButtonPress();
navigation.goBack();
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error(
'Navigation error while dismissing modal:',
navigationError,
);
// Don't execute callback if modal couldn't be dismissed
return;
}
try {
// If callback succeeds, try to navigate back
navigation.goBack();
// Only unregister after successful navigation
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error('Navigation error:', navigationError);
// Don't cleanup if navigation fails - modal might still be visible
}
// Now execute the callback (which may navigate to another screen)
// This only runs if dismissal succeeded
try {
await callbacks.onButtonPress();
} catch (callbackError) {
console.error('Callback error:', callbackError);
// If callback fails, we should still try to navigate and cleanup
try {
navigation.goBack();
unregisterModalCallbacks(params.callbackId);
} catch (navigationError) {
console.error(
'Navigation error after callback failure:',
navigationError,
);
// Don't cleanup if navigation fails
}
}
}, [callbacks, navigation, params.callbackId]);
@@ -108,6 +102,9 @@ const ModalScreen: React.FC<ModalScreenProps> = ({ route: { params } }) => {
padding={20}
borderRadius={10}
marginHorizontal={8}
width="79.5%"
maxWidth={460}
alignSelf="center"
>
<YStack gap={40}>
<XStack alignItems="center" justifyContent="space-between">

View File

@@ -174,7 +174,7 @@ const AadhaarUploadScreen: React.FC = () => {
<BodyText
style={{ fontWeight: 'bold', fontSize: 18, textAlign: 'center' }}
>
Generate a QR code from the mAadaar app
Generate a QR code from the Aadhaar app
</BodyText>
<BodyText
style={{ fontSize: 16, textAlign: 'center', color: slate500 }}

View File

@@ -80,7 +80,7 @@ const DocumentCameraScreen: React.FC = () => {
</YStack>
<Additional style={styles.disclaimer}>
SELF WILL NOT CAPTURE AN IMAGE OF YOUR PASSPORT.
Self will not capture an image of your ID.
</Additional>
<SecondaryButton

View File

@@ -2,17 +2,8 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { YStack } from '@selfxyz/mobile-sdk-alpha/components';
import { slate100 } from '@selfxyz/mobile-sdk-alpha/constants/colors';
import SDKCountryPickerScreen from '@selfxyz/mobile-sdk-alpha/onboarding/country-picker-screen';
import { DocumentFlowNavBar } from '@/components/navbar/DocumentFlowNavBar';
export default function CountryPickerScreen() {
return (
<YStack flex={1} backgroundColor={slate100}>
<DocumentFlowNavBar title="GETTING STARTED" />
<SDKCountryPickerScreen />
</YStack>
);
return <SDKCountryPickerScreen />;
}

View File

@@ -47,6 +47,7 @@ import { useReferralConfirmation } from '@/hooks/useReferralConfirmation';
import { useTestReferralFlow } from '@/hooks/useTestReferralFlow';
import type { RootStackParamList } from '@/navigation';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
import useUserStore from '@/stores/userStore';
const HomeScreen: React.FC = () => {
@@ -67,6 +68,7 @@ const HomeScreen: React.FC = () => {
Record<string, { data: IDDocument; metadata: DocumentMetadata }>
>({});
const [loading, setLoading] = useState(true);
const hasIncrementedOnFocus = useRef(false);
const { amount: selfPoints } = usePoints();
@@ -124,6 +126,21 @@ const HomeScreen: React.FC = () => {
}, [loadDocuments]),
);
useFocusEffect(
useCallback(() => {
if (hasIncrementedOnFocus.current) {
return;
}
hasIncrementedOnFocus.current = true;
useSettingStore.getState().incrementHomeScreenViewCount();
return () => {
hasIncrementedOnFocus.current = false;
};
}, []),
);
useFocusEffect(() => {
if (isNewVersionAvailable && !isModalDismissed) {
showAppUpdateModal();

View File

@@ -23,6 +23,7 @@ import {
import { WebViewNavBar } from '@/components/navbar/WebViewNavBar';
import { WebViewFooter } from '@/components/WebViewFooter';
import { selfUrl } from '@/consts/links';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { SharedRoutesParamList } from '@/navigation/types';
@@ -39,7 +40,7 @@ type WebViewScreenProps = NativeStackScreenProps<
'WebView'
>;
const defaultUrl = 'https://self.xyz';
const defaultUrl = selfUrl;
export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
const navigation = useNavigation();

View File

@@ -7,6 +7,12 @@ import { authorize } from 'react-native-app-auth';
import { GOOGLE_SIGNIN_ANDROID_CLIENT_ID } from '@env';
import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper';
import {
googleDriveAppDataScope,
googleOAuthAuthorizationEndpoint,
googleOAuthTokenEndpoint,
} from '@/consts/links';
// Ensure the client ID is available at runtime (skip in test environment)
const isTestEnvironment =
process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID;
@@ -22,10 +28,10 @@ const config: AuthConfiguration = {
// ensure this prints the correct values before calling authorize
clientId: GOOGLE_SIGNIN_ANDROID_CLIENT_ID || 'mock-client-id',
redirectUrl: 'com.proofofpassportapp:/oauth2redirect',
scopes: ['https://www.googleapis.com/auth/drive.appdata'],
scopes: [googleDriveAppDataScope],
serviceConfiguration: {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
authorizationEndpoint: googleOAuthAuthorizationEndpoint,
tokenEndpoint: googleOAuthTokenEndpoint,
},
additionalParameters: { access_type: 'offline', prompt: 'consent' as const },
};

View File

@@ -2,6 +2,8 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { notificationApiStagingUrl, notificationApiUrl } from '@/consts/links';
export interface DeviceTokenRegistration {
session_id: string;
device_token: string;
@@ -18,9 +20,9 @@ export interface RemoteMessage {
[key: string]: unknown;
}
export const API_URL = 'https://notification.self.xyz';
export const API_URL = notificationApiUrl;
export const API_URL_STAGING = 'https://notification.staging.self.xyz';
export const API_URL_STAGING = notificationApiStagingUrl;
export const getStateMessage = (state: string): string => {
switch (state) {
case 'idle':

View File

@@ -2,4 +2,9 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
export const POINTS_API_BASE_URL = 'https://points.self.xyz';
import { pointsApiBaseUrl } from '@/consts/links';
export const POINTS_API_BASE_URL = pointsApiBaseUrl;
export const POINTS_TOKEN_CONTRACT_ADDRESS =
'0xfa6279293dfa5b38486ee179e4ddf6806c517a49'; // gitleaks:allow

View File

@@ -6,6 +6,7 @@ import { v4 } from 'uuid';
import { SelfAppBuilder } from '@selfxyz/common/utils/appType';
import { selfLogoReverseUrl } from '@/consts/links';
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { POINTS_API_BASE_URL } from '@/services/points/constants';
import type { IncomingPoints } from '@/services/points/types';
@@ -175,8 +176,7 @@ export const pointsSelfApp = async () => {
userId: v4(),
userIdType: 'uuid',
disclosures: {},
logoBase64:
'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png',
logoBase64: selfLogoReverseUrl,
header: '',
});

View File

@@ -7,34 +7,34 @@ import { createJSONStorage, persist } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
interface PersistedSettingsState {
hasPrivacyNoteBeenDismissed: boolean;
dismissPrivacyNote: () => void;
addSubscribedTopic: (topic: string) => void;
biometricsAvailable: boolean;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
cloudBackupEnabled: boolean;
toggleCloudBackupEnabled: () => void;
loginCount: number;
incrementLoginCount: () => void;
hasViewedRecoveryPhrase: boolean;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
isDevMode: boolean;
setDevModeOn: () => void;
setDevModeOff: () => void;
hasCompletedKeychainMigration: boolean;
setKeychainMigrationCompleted: () => void;
dismissPrivacyNote: () => void;
fcmToken: string | null;
hasCompletedBackupForPoints: boolean;
hasCompletedKeychainMigration: boolean;
hasPrivacyNoteBeenDismissed: boolean;
hasViewedRecoveryPhrase: boolean;
homeScreenViewCount: number;
incrementHomeScreenViewCount: () => void;
isDevMode: boolean;
pointsAddress: string | null;
removeSubscribedTopic: (topic: string) => void;
resetBackupForPoints: () => void;
setBackupForPointsCompleted: () => void;
setBiometricsAvailable: (biometricsAvailable: boolean) => void;
setDevModeOff: () => void;
setDevModeOn: () => void;
setFcmToken: (token: string | null) => void;
turnkeyBackupEnabled: boolean;
setHasViewedRecoveryPhrase: (viewed: boolean) => void;
setKeychainMigrationCompleted: () => void;
setPointsAddress: (address: string | null) => void;
setSubscribedTopics: (topics: string[]) => void;
setTurnkeyBackupEnabled: (turnkeyBackupEnabled: boolean) => void;
subscribedTopics: string[];
setSubscribedTopics: (topics: string[]) => void;
addSubscribedTopic: (topic: string) => void;
removeSubscribedTopic: (topic: string) => void;
hasCompletedBackupForPoints: boolean;
setBackupForPointsCompleted: () => void;
resetBackupForPoints: () => void;
pointsAddress: string | null;
setPointsAddress: (address: string | null) => void;
toggleCloudBackupEnabled: () => void;
turnkeyBackupEnabled: boolean;
}
interface NonPersistedSettingsState {
@@ -64,20 +64,33 @@ export const useSettingStore = create<SettingsState>()(
toggleCloudBackupEnabled: () =>
set(oldState => ({
cloudBackupEnabled: !oldState.cloudBackupEnabled,
loginCount: oldState.cloudBackupEnabled ? oldState.loginCount : 0,
homeScreenViewCount: oldState.cloudBackupEnabled
? oldState.homeScreenViewCount
: 0,
})),
loginCount: 0,
incrementLoginCount: () =>
set(oldState => ({ loginCount: oldState.loginCount + 1 })),
homeScreenViewCount: 0,
incrementHomeScreenViewCount: () =>
set(oldState => {
if (
oldState.cloudBackupEnabled ||
oldState.hasViewedRecoveryPhrase === true
) {
return oldState;
}
const nextCount = oldState.homeScreenViewCount + 1;
return {
homeScreenViewCount: nextCount >= 100 ? 0 : nextCount,
};
}),
hasViewedRecoveryPhrase: false,
setHasViewedRecoveryPhrase: viewed =>
set(oldState => ({
hasViewedRecoveryPhrase: viewed,
loginCount:
homeScreenViewCount:
viewed && !oldState.hasViewedRecoveryPhrase
? 0
: oldState.loginCount,
: oldState.homeScreenViewCount,
})),
isDevMode: false,

View File

@@ -0,0 +1,6 @@
// 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.
// Mock for image files in Jest tests
module.exports = 'test-file-stub';

View File

@@ -0,0 +1,71 @@
// 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.
// Grouped navigation mocks to avoid cluttering jest.setup.js
jest.mock('@react-navigation/native', () => {
// Avoid nested requireActual to prevent OOM in CI
// Create mock navigator without requiring React
const MockNavigator = (props, _ref) => props.children;
MockNavigator.displayName = 'MockNavigator';
return {
useFocusEffect: jest.fn(callback => {
// Immediately invoke the effect for testing without requiring a container
return callback();
}),
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
})),
useRoute: jest.fn(() => ({
key: 'mock-route-key',
name: 'MockRoute',
params: {},
})),
useIsFocused: jest.fn(() => true),
useLinkTo: jest.fn(() => jest.fn()),
createNavigationContainerRef: jest.fn(() => global.mockNavigationRef),
createStaticNavigation: jest.fn(() => MockNavigator),
NavigationContainer: ({ children }) => children,
DefaultTheme: {},
DarkTheme: {},
};
});
jest.mock('@react-navigation/native-stack', () => ({
createNativeStackNavigator: jest.fn(config => config),
createNavigatorFactory: jest.fn(),
}));
// Mock core navigation to avoid requiring a NavigationContainer for hooks
jest.mock('@react-navigation/core', () => {
// Avoid nested requireActual to prevent OOM in CI
return {
useNavigation: jest.fn(() => ({
navigate: jest.fn(),
goBack: jest.fn(),
canGoBack: jest.fn(() => true),
dispatch: jest.fn(),
getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })),
})),
useRoute: jest.fn(() => ({
key: 'mock-route-key',
name: 'MockRoute',
params: {},
})),
useIsFocused: jest.fn(() => true),
useLinkTo: jest.fn(() => jest.fn()),
NavigationContext: {
Provider: ({ children }) => children,
Consumer: ({ children }) => children(null),
},
NavigationRouteContext: {
Provider: ({ children }) => children,
Consumer: ({ children }) => children(null),
},
};
});

View File

@@ -0,0 +1,199 @@
// 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.
// UI-related mocks grouped to keep jest.setup.js concise
// Mock react-native-webview globally to avoid ESM parsing and native behaviors
// Note: Individual test files can override this with their own more specific mocks
jest.mock('react-native-webview', () => {
// Avoid requiring React to prevent nested require memory issues
// Return a simple pass-through mock - tests can override with JSX mocks if needed
const MockWebView = jest.fn(() => null);
MockWebView.displayName = 'MockWebView';
return {
__esModule: true,
default: MockWebView,
WebView: MockWebView,
};
});
// Mock ExpandableBottomLayout to simple containers to avoid SDK internals in tests
jest.mock('@/layouts/ExpandableBottomLayout', () => {
// Avoid requiring React to prevent nested require memory issues
// These need to pass through children so WebView is rendered
const Layout = ({ children, ...props }) => children;
const TopSection = ({ children, ...props }) => children;
const BottomSection = ({ children, ...props }) => children;
const FullSection = ({ children, ...props }) => children;
return {
__esModule: true,
ExpandableBottomLayout: { Layout, TopSection, BottomSection, FullSection },
};
});
// Mock mobile-sdk-alpha components used by NavBar (Button, XStack)
jest.mock('@selfxyz/mobile-sdk-alpha/components', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Button needs to render a host element with onPress so tests can interact with it
const Button = jest.fn(({ testID, icon, onPress, children, ...props }) => {
// Render as a mock-touchable-opacity host element so fireEvent.press works
// This allows tests to query by testID and press the button
return (
<mock-touchable-opacity testID={testID} onPress={onPress} {...props}>
{icon || children || null}
</mock-touchable-opacity>
);
});
Button.displayName = 'MockButton';
const XStack = jest.fn(({ children, ...props }) => children || null);
XStack.displayName = 'MockXStack';
const Text = jest.fn(({ children, ...props }) => children || null);
Text.displayName = 'MockText';
return {
__esModule: true,
Button,
XStack,
// Provide minimal Text to satisfy potential usages
Text,
};
});
// Mock Tamagui to avoid hermes-parser WASM memory issues during transformation
jest.mock('tamagui', () => {
// Avoid requiring React to prevent nested require memory issues
// Create mock components that work with React testing library
// Helper to create a simple pass-through mock component
const createMockComponent = displayName => {
const Component = jest.fn(props => props.children || null);
Component.displayName = displayName;
return Component;
};
// Mock styled function - simplified version that returns the component
const styled = jest.fn(Component => Component);
// Create all Tamagui component mocks
const Button = createMockComponent('MockButton');
const XStack = createMockComponent('MockXStack');
const YStack = createMockComponent('MockYStack');
const ZStack = createMockComponent('MockZStack');
const Text = createMockComponent('MockText');
const View = createMockComponent('MockView');
const ScrollView = createMockComponent('MockScrollView');
const Spinner = createMockComponent('MockSpinner');
const Image = createMockComponent('MockImage');
const Card = createMockComponent('MockCard');
const Separator = createMockComponent('MockSeparator');
const TextArea = createMockComponent('MockTextArea');
const Input = createMockComponent('MockInput');
const Anchor = createMockComponent('MockAnchor');
// Mock Select component with nested components
const Select = Object.assign(createMockComponent('MockSelect'), {
Trigger: createMockComponent('MockSelectTrigger'),
Value: createMockComponent('MockSelectValue'),
Content: createMockComponent('MockSelectContent'),
Item: createMockComponent('MockSelectItem'),
Group: createMockComponent('MockSelectGroup'),
Label: createMockComponent('MockSelectLabel'),
Viewport: createMockComponent('MockSelectViewport'),
ScrollUpButton: createMockComponent('MockSelectScrollUpButton'),
ScrollDownButton: createMockComponent('MockSelectScrollDownButton'),
});
// Mock Sheet component with nested components
const Sheet = Object.assign(createMockComponent('MockSheet'), {
Frame: createMockComponent('MockSheetFrame'),
Overlay: createMockComponent('MockSheetOverlay'),
Handle: createMockComponent('MockSheetHandle'),
ScrollView: createMockComponent('MockSheetScrollView'),
});
// Mock Adapt component
const Adapt = createMockComponent('MockAdapt');
// Mock TamaguiProvider - simple pass-through that renders children
const TamaguiProvider = jest.fn(({ children }) => children || null);
TamaguiProvider.displayName = 'MockTamaguiProvider';
// Mock configuration factory functions
const createFont = jest.fn(() => ({}));
const createTamagui = jest.fn(() => ({}));
return {
__esModule: true,
styled,
Button,
XStack,
YStack,
ZStack,
Text,
View,
ScrollView,
Spinner,
Image,
Card,
Separator,
TextArea,
Input,
Anchor,
Select,
Sheet,
Adapt,
TamaguiProvider,
createFont,
createTamagui,
// Provide default exports for other common components
default: jest.fn(() => null),
};
});
// Mock Tamagui lucide icons to simple components to avoid theme context
jest.mock('@tamagui/lucide-icons', () => {
// Avoid requiring React to prevent nested require memory issues
// Return mock components that can be queried by testID
const makeIcon = name => {
// Use a mock element tag that React can render
const Icon = props => ({
$$typeof: Symbol.for('react.element'),
type: `mock-icon-${name}`,
props: { testID: `icon-${name}`, ...props },
key: null,
ref: null,
});
Icon.displayName = `MockIcon(${name})`;
return Icon;
};
return {
__esModule: true,
ExternalLink: makeIcon('external-link'),
X: makeIcon('x'),
Clipboard: makeIcon('clipboard'),
};
});
// Mock WebViewFooter to avoid SDK rendering complexity
jest.mock('@/components/WebViewFooter', () => {
// Avoid requiring React to prevent nested require memory issues
const WebViewFooter = jest.fn(() => null);
return { __esModule: true, WebViewFooter };
});
// Mock screens that use mobile-sdk-alpha flows with PixelRatio issues or missing dependencies
jest.mock('@/screens/documents/selection/ConfirmBelongingScreen', () => {
const MockScreen = jest.fn(() => null);
MockScreen.displayName = 'MockConfirmBelongingScreen';
return { __esModule: true, default: MockScreen };
});
jest.mock('@/screens/documents/selection/CountryPickerScreen', () => {
const MockScreen = jest.fn(() => null);
MockScreen.displayName = 'MockCountryPickerScreen';
return { __esModule: true, default: MockScreen };
});

View File

@@ -0,0 +1,129 @@
// 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 { describe, expect, it } from '@jest/globals';
import * as links from '@/consts/links';
describe('links', () => {
describe('URL format validation', () => {
it('should export only valid HTTPS URLs', () => {
const allLinks = Object.entries(links);
allLinks.forEach(([_name, url]) => {
expect(url).toMatch(/^https:\/\/.+/);
expect(url).not.toContain(' ');
// Ensure no trailing slashes (consistency)
expect(url).not.toMatch(/\/$/);
});
});
it('should have unique URLs (no duplicates)', () => {
const allUrls = Object.values(links);
const uniqueUrls = new Set(allUrls);
expect(uniqueUrls.size).toBe(allUrls.length);
});
});
describe('Critical URL validation', () => {
it('should have correct Telegram URL', () => {
expect(links.telegramUrl).toBe('https://t.me/selfxyz');
});
it('should have correct Discord URL', () => {
expect(links.discordUrl).toBe('https://discord.gg/selfxyz');
});
it('should have correct GitHub URL', () => {
expect(links.gitHubUrl).toBe('https://github.com/selfxyz/self');
});
it('should have correct X (Twitter) URL', () => {
expect(links.xUrl).toBe('https://x.com/selfprotocol');
});
it('should have correct Self main URL', () => {
expect(links.selfUrl).toBe('https://self.xyz');
});
it('should have correct privacy policy URL', () => {
expect(links.privacyUrl).toBe('https://self.xyz/privacy');
});
it('should have correct terms URL', () => {
expect(links.termsUrl).toBe('https://self.xyz/terms');
});
});
describe('Self platform URLs', () => {
it('should use self.xyz domain for platform URLs', () => {
expect(links.selfUrl).toContain('self.xyz');
expect(links.privacyUrl).toContain('self.xyz');
expect(links.termsUrl).toContain('self.xyz');
expect(links.appsUrl).toContain('self.xyz');
expect(links.referralBaseUrl).toContain('self.xyz');
expect(links.apiBaseUrl).toContain('self.xyz');
expect(links.pointsApiBaseUrl).toContain('self.xyz');
expect(links.notificationApiUrl).toContain('self.xyz');
});
});
describe('App store URLs', () => {
it('should have valid App Store URL', () => {
expect(links.appStoreUrl).toMatch(/^https:\/\/apps\.apple\.com\//);
expect(links.appStoreUrl).toContain('id6478563710');
});
it('should have valid Play Store URL', () => {
expect(links.playStoreUrl).toMatch(
/^https:\/\/play\.google\.com\/store\//,
);
expect(links.playStoreUrl).toContain('com.proofofpassportapp');
});
});
describe('OAuth URLs', () => {
it('should have valid Google OAuth endpoints', () => {
expect(links.googleOAuthAuthorizationEndpoint).toBe(
'https://accounts.google.com/o/oauth2/v2/auth',
);
expect(links.googleOAuthTokenEndpoint).toBe(
'https://oauth2.googleapis.com/token',
);
});
it('should have valid Turnkey redirect URIs', () => {
expect(links.turnkeyOAuthRedirectAndroidUri).toContain('self.xyz');
expect(links.turnkeyOAuthRedirectIosUri).toContain('turnkey.com');
});
});
describe('Export completeness', () => {
it('should export at least 20 links', () => {
// Ensures we don't accidentally remove links
const linkCount = Object.keys(links).length;
expect(linkCount).toBeGreaterThanOrEqual(20);
});
it('should have descriptive variable names', () => {
const allLinks = Object.keys(links);
allLinks.forEach(name => {
// Should be camelCase
expect(name).toMatch(/^[a-z][a-zA-Z0-9]*$/);
// Should end with Url, Uri, Endpoint, or Scope
const isValid =
name.endsWith('Url') ||
name.endsWith('Uri') ||
name.endsWith('Endpoint') ||
name.endsWith('Scope');
if (!isValid) {
console.log(`Invalid variable name: ${name}`);
}
expect(isValid).toBe(true);
});
});
});
});

View File

@@ -2,32 +2,22 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useNavigation } from '@react-navigation/native';
import { act, renderHook } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal';
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
const mockGetState = jest.fn(() => ({
routes: [{ name: 'Home' }, { name: 'Modal' }],
}));
describe('useModal', () => {
beforeEach(() => {
(useNavigation as jest.Mock).mockReturnValue({
navigate: mockNavigate,
goBack: mockGoBack,
getState: mockGetState,
// Reset all mocks including the global navigationRef
jest.clearAllMocks();
// Set up the navigation ref mock with proper methods
global.mockNavigationRef.isReady.mockReturnValue(true);
global.mockNavigationRef.getState.mockReturnValue({
routes: [{ name: 'Home' }, { name: 'Modal' }],
index: 1,
});
mockNavigate.mockClear();
mockGoBack.mockClear();
mockGetState.mockClear();
});
it('should navigate to Modal with callbackId and handle dismissal', () => {
@@ -45,8 +35,10 @@ describe('useModal', () => {
act(() => result.current.showModal());
expect(mockNavigate).toHaveBeenCalledTimes(1);
const params = mockNavigate.mock.calls[0][1];
expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
const [screenName, params] =
global.mockNavigationRef.navigate.mock.calls[0];
expect(screenName).toBe('Modal');
expect(params).toMatchObject({
titleText: 'Title',
bodyText: 'Body',
@@ -58,7 +50,7 @@ describe('useModal', () => {
act(() => result.current.dismissModal());
expect(mockGoBack).toHaveBeenCalled();
expect(global.mockNavigationRef.goBack).toHaveBeenCalled();
expect(onModalDismiss).toHaveBeenCalled();
expect(getModalCallbacks(id)).toBeUndefined();
});

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { AppState } from 'react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal';
@@ -9,56 +10,175 @@ import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
const navigationStateListeners: Array<() => void> = [];
let isNavigationReady = true;
// Use global appStateListeners from jest.setup.js mock
const appStateListeners = global.mockAppStateListeners || [];
jest.mock('@/hooks/useModal');
jest.mock('@/providers/passportDataProvider');
jest.mock('@/navigation', () => ({
navigationRef: {
isReady: jest.fn(() => true),
navigate: jest.fn(),
},
navigationRef: global.mockNavigationRef,
}));
// Use global react-native mock from jest.setup.js - no need to mock here
const showModal = jest.fn();
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
const getAllDocuments = jest.fn();
(usePassport as jest.Mock).mockReturnValue({ getAllDocuments });
const getAppState = (): {
currentState: string;
addEventListener: jest.Mock;
} =>
AppState as unknown as {
currentState: string;
addEventListener: jest.Mock;
};
describe('useRecoveryPrompts', () => {
beforeEach(() => {
showModal.mockClear();
getAllDocuments.mockResolvedValue({ doc1: {} as any });
jest.clearAllMocks();
navigationStateListeners.length = 0;
appStateListeners.length = 0;
isNavigationReady = true;
// Setup the global navigation ref mock
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
global.mockNavigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' });
global.mockNavigationRef.addListener.mockImplementation(
(_: string, callback: () => void) => {
navigationStateListeners.push(callback);
return () => {
const index = navigationStateListeners.indexOf(callback);
if (index >= 0) {
navigationStateListeners.splice(index, 1);
}
};
},
);
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
getAllDocuments.mockResolvedValue({
doc1: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
const mockAppState = getAppState();
mockAppState.currentState = 'active';
act(() => {
useSettingStore.setState({
loginCount: 0,
homeScreenViewCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('shows modal on first login', async () => {
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
it('does not show modal before the fifth home view', async () => {
for (const count of [1, 2, 3, 4]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('does not show modal when login count is 4', async () => {
it('waits for navigation readiness before prompting', async () => {
isNavigationReady = false;
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
act(() => {
useSettingStore.setState({ loginCount: 4 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
isNavigationReady = true;
navigationStateListeners.forEach(listener => listener());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal on eighth login', async () => {
it('respects custom allow list overrides', async () => {
act(() => {
useSettingStore.setState({ loginCount: 8 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
showModal.mockClear();
global.mockNavigationRef.getCurrentRoute.mockReturnValue({
name: 'Settings',
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('prompts when returning from background on eligible route', async () => {
// This test verifies that the hook registers an app state listener
// and that the prompt logic can be triggered multiple times for different view counts
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
const { rerender, unmount } = renderHook(() => useRecoveryPrompts());
// Wait for initial prompt
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
// Clear and test with a different login count that should trigger again
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: 10 }); // next multiple of 5
});
rerender();
// Wait for second prompt with new login count
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
unmount();
});
it('does not show modal for non-multiple-of-five view counts', async () => {
for (const count of [6, 7, 8, 9]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('shows modal on fifth home view', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -68,7 +188,10 @@ describe('useRecoveryPrompts', () => {
it('does not show modal if backup already enabled', async () => {
act(() => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
useSettingStore.setState({
homeScreenViewCount: 5,
cloudBackupEnabled: true,
});
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -76,12 +199,8 @@ describe('useRecoveryPrompts', () => {
});
});
it('does not show modal when navigation is not ready', async () => {
const navigationRef = require('@/navigation').navigationRef;
navigationRef.isReady.mockReturnValueOnce(false);
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
it('does not show modal if already visible', async () => {
(useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true });
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -91,7 +210,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when recovery phrase has been viewed', async () => {
act(() => {
useSettingStore.setState({
loginCount: 1,
homeScreenViewCount: 5,
hasViewedRecoveryPhrase: true,
});
});
@@ -104,7 +223,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when no documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({});
act(() => {
useSettingStore.setState({ loginCount: 1 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -112,11 +231,51 @@ describe('useRecoveryPrompts', () => {
});
});
it('shows modal for other valid login counts', async () => {
for (const count of [2, 3, 13, 18]) {
it('does not show modal when only unregistered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: undefined } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('shows modal when registered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal for other valid view counts (multiples of five)', async () => {
for (const count of [5, 10, 15]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ loginCount: count });
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -125,6 +284,32 @@ describe('useRecoveryPrompts', () => {
}
});
it('does not show modal again for same login count when state changes', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
showModal.mockClear();
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: false });
});
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('returns correct visible state', () => {
const { result } = renderHook(() => useRecoveryPrompts());
expect(result.current.visible).toBe(false);
@@ -134,8 +319,9 @@ describe('useRecoveryPrompts', () => {
renderHook(() => useRecoveryPrompts());
expect(useModal).toHaveBeenCalledWith({
titleText: 'Protect your account',
bodyText:
bodyText: expect.stringContaining(
'Enable cloud backup or save your recovery phrase so you can recover your account.',
),
buttonText: 'Back up now',
onButtonPress: expect.any(Function),
onModalDismiss: expect.any(Function),

View File

@@ -0,0 +1,107 @@
// 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';
jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn());
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: jest.fn(() => ({})),
}));
jest.mock('@/navigation/deeplinks', () => ({
setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()),
}));
jest.mock('@/services/analytics', () => ({
__esModule: true,
default: jest.fn(() => ({
trackEvent: jest.fn(),
trackScreenView: jest.fn(),
flush: jest.fn(),
})),
}));
describe('navigation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should have the correct navigation screens', () => {
// Unmock @/navigation for this test to get the real navigationScreens
jest.unmock('@/navigation');
jest.isolateModules(() => {
const navigationScreens = require('@/navigation').navigationScreens;
const listOfScreens = Object.keys(navigationScreens).sort();
expect(listOfScreens).toEqual([
'AadhaarUpload',
'AadhaarUploadError',
'AadhaarUploadSuccess',
'AccountRecovery',
'AccountRecoveryChoice',
'AccountVerifiedSuccess',
'CloudBackupSettings',
'ComingSoon',
'ConfirmBelonging',
'CountryPicker',
'CreateMock',
'DeferredLinkingInfo',
'DevFeatureFlags',
'DevHapticFeedback',
'DevLoadingScreen',
'DevPrivateKey',
'DevSettings',
'Disclaimer',
'DocumentCamera',
'DocumentCameraTrouble',
'DocumentDataInfo',
'DocumentDataNotFound',
'DocumentNFCMethodSelection',
'DocumentNFCScan',
'DocumentNFCTrouble',
'DocumentOnboarding',
'Gratification',
'Home',
'IDPicker',
'IdDetails',
'Loading',
'ManageDocuments',
'MockDataDeepLink',
'Modal',
'Points',
'PointsInfo',
'ProofHistory',
'ProofHistoryDetail',
'ProofRequestStatus',
'Prove',
'QRCodeTrouble',
'QRCodeViewFinder',
'RecoverWithPhrase',
'Referral',
'SaveRecoveryPhrase',
'Settings',
'ShowRecoveryPhrase',
'Splash',
'WebView',
]);
});
});
it('wires recovery prompts hook into navigation', () => {
// Temporarily restore the React mock and unmock @/navigation for this test
jest.unmock('@/navigation');
const useRecoveryPrompts =
require('@/hooks/useRecoveryPrompts') as jest.Mock;
// Since we're testing the wiring and not the actual rendering,
// we can just check if the module exports the default component
// and verify the hook is called when the component is imported
const navigation = require('@/navigation');
expect(navigation.default).toBeDefined();
// Render the component to trigger the hooks
const NavigationWithTracking = navigation.default;
render(<NavigationWithTracking />);
expect(useRecoveryPrompts).toHaveBeenCalledWith();
});
});

View File

@@ -228,7 +228,7 @@ describe('deeplinks', () => {
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature';
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature'; // gitleaks:allow
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
@@ -265,7 +265,7 @@ describe('deeplinks', () => {
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; // gitleaks:allow
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
@@ -530,7 +530,7 @@ describe('deeplinks', () => {
it('returns id_token and scope parameters', () => {
const url =
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile'; // gitleaks:allow
const result = parseAndValidateUrlParams(url);
expect(result.id_token).toBeTruthy();
expect(result.scope).toBe('email profile');

View File

@@ -12,23 +12,27 @@ describe('provingUtils', () => {
const plaintext = 'hello world';
const encrypted = encryptAES256GCM(plaintext, forge.util.createBuffer(key));
// Convert arrays to Uint8Array first to ensure proper byte conversion
const nonceBytes = new Uint8Array(encrypted.nonce);
const authTagBytes = new Uint8Array(encrypted.auth_tag);
const cipherTextBytes = new Uint8Array(encrypted.cipher_text);
// Validate tag length (128 bits = 16 bytes)
expect(authTagBytes.length).toBe(16);
const decipher = forge.cipher.createDecipher(
'AES-GCM',
forge.util.createBuffer(key),
);
decipher.start({
iv: forge.util.createBuffer(
Buffer.from(encrypted.nonce).toString('binary'),
),
iv: forge.util.createBuffer(Buffer.from(nonceBytes).toString('binary')),
tagLength: 128,
tag: forge.util.createBuffer(
Buffer.from(encrypted.auth_tag).toString('binary'),
Buffer.from(authTagBytes).toString('binary'),
),
});
decipher.update(
forge.util.createBuffer(
Buffer.from(encrypted.cipher_text).toString('binary'),
),
forge.util.createBuffer(Buffer.from(cipherTextBytes).toString('binary')),
);
const success = decipher.finish();
const decrypted = decipher.output.toString();

View File

@@ -1,157 +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 { act } from '@testing-library/react-native';
import { useSettingStore } from '@/stores/settingStore';
describe('settingStore', () => {
beforeEach(() => {
act(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('increments login count', () => {
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
});
it('increments login count multiple times', () => {
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('increments login count from non-zero initial value', () => {
act(() => {
useSettingStore.setState({ loginCount: 5 });
});
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(6);
});
it('resets login count when recovery phrase viewed', () => {
act(() => {
useSettingStore.setState({ loginCount: 2 });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to false', () => {
act(() => {
useSettingStore.setState({
loginCount: 3,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('resets login count when enabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when disabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(4);
});
it('handles sequential actions that reset login count', () => {
// Increment login count
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle cloud backup (should reset to 0)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
// Increment again
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
// Set recovery phrase viewed (should reset to 0)
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to true when already true', () => {
act(() => {
useSettingStore.setState({
loginCount: 5,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(5);
});
it('handles complex sequence of mixed operations', () => {
// Start with some increments
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
// Disable cloud backup (should not reset)
act(() => {
useSettingStore.setState({ cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Set recovery phrase viewed to false (should not reset)
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Enable cloud backup (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('maintains login count when toggling cloud backup from true to false then back to true', () => {
// Start with cloud backup enabled and some login count
act(() => {
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
});
// Toggle to disable (should not reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle to enable (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
});

View File

@@ -1,10 +1,10 @@
{
"ios": {
"build": 190,
"lastDeployed": "2025-11-19T05:35:50.564Z"
"build": 192,
"lastDeployed": "2025-12-05T00:06:05.459Z"
},
"android": {
"build": 122,
"lastDeployed": "2025-11-19T05:35:50.564Z"
"build": 123,
"lastDeployed": "2025-11-21T00:06:05.459Z"
}
}

View File

@@ -8,6 +8,7 @@ include "../utils/gcp_jwt/verifyCertificateSignature.circom";
include "../utils/gcp_jwt/verifyJSONFieldExtraction.circom";
include "circomlib/circuits/comparators.circom";
include "@openpassport/zk-email-circuits/utils/array.circom";
include "@openpassport/zk-email-circuits/utils/bytes.circom";
/// @title GCPJWTVerifier
/// @notice Verifies GCP JWT signature and full x5c certificate chain
@@ -70,11 +71,11 @@ template GCPJWTVerifier(
// GCP spec: nonce must be 10-74 bytes decoded
// Base64url encoding: 10 bytes = 14 chars, 74 bytes = 99 chars
// https://cloud.google.com/confidential-computing/confidential-space/docs/connect-external-resources
// EAT nonce (payload.eat_nonce[0])
var MAX_EAT_NONCE_B64_LENGTH = 99; // Max length for base64url string (74 bytes decoded = 99 b64url chars)
var MAX_EAT_NONCE_B64_LENGTH = 74; // Max length for base64url string (74 bytes decoded = 99 b64url chars)
var MAX_EAT_NONCE_KEY_LENGTH = 10; // Length of "eat_nonce" key (without quotes)
var EAT_NONCE_PACKED_CHUNKS = computeIntChunkLength(MAX_EAT_NONCE_B64_LENGTH);
signal input eat_nonce_0_b64_length; // Length of base64url string
signal input eat_nonce_0_key_offset; // Offset in payload where "eat_nonce" key starts (after opening quote)
signal input eat_nonce_0_value_offset; // Offset in payload where eat_nonce[0] value appears
@@ -83,6 +84,7 @@ template GCPJWTVerifier(
var MAX_IMAGE_DIGEST_LENGTH = 71; // "sha256:" + 64 hex chars
var IMAGE_HASH_LENGTH = 64; // Just the hex hash portion
var MAX_IMAGE_DIGEST_KEY_LENGTH = 12; // Length of "image_digest" key (without quotes)
var IMAGE_HASH_PACKED_CHUNKS = computeIntChunkLength(IMAGE_HASH_LENGTH);
signal input image_digest_length; // Length of full string (should be 71)
signal input image_digest_key_offset; // Offset in payload where "image_digest" key starts (after opening quote)
signal input image_digest_value_offset; // Offset in payload where image_digest value appears
@@ -91,8 +93,8 @@ template GCPJWTVerifier(
var maxPayloadLength = (maxB64PayloadLength * 3) \ 4;
signal output rootCAPubkeyHash; // Root CA (x5c[2]) pubkey, trust anchor
signal output eat_nonce_0_b64_output[MAX_EAT_NONCE_B64_LENGTH]; // eat_nonce[0] base64url string
signal output image_hash[IMAGE_HASH_LENGTH]; // Container image SHA256 hash (without "sha256:" prefix)
signal output eat_nonce_0_b64_packed[EAT_NONCE_PACKED_CHUNKS]; // eat_nonce[0] base64url string packed with PackBytes
signal output image_hash_packed[IMAGE_HASH_PACKED_CHUNKS]; // Container image SHA256 hash (64 hex chars) packed with PackBytes
// Verify JWT Signature (using x5c[0] public key)
component jwtVerifier = JWTVerifier(n, k, maxMessageLength, maxB64HeaderLength, maxB64PayloadLength);
@@ -162,7 +164,7 @@ template GCPJWTVerifier(
// Validate nonce maximum length (74 bytes decoded = 99 base64url chars)
component length_max_check = LessEqThan(log2Ceil(MAX_EAT_NONCE_B64_LENGTH));
length_max_check.in[0] <== eat_nonce_0_b64_length;
length_max_check.in[1] <== 99;
length_max_check.in[1] <== MAX_EAT_NONCE_B64_LENGTH;
length_max_check.out === 1;
// Validate nonce offset bounds (prevent reading beyond payload)
@@ -195,7 +197,7 @@ template GCPJWTVerifier(
eatNonceExtractor.expected_key_name <== expected_eat_nonce_key;
// Output the extracted base64url string
eat_nonce_0_b64_output <== eatNonceExtractor.extracted_value;
eat_nonce_0_b64_packed <== PackBytes(MAX_EAT_NONCE_B64_LENGTH)(eatNonceExtractor.extracted_value);
// Validate length is exactly 71 ("sha256:" + 64 hex chars)
image_digest_length === 71;
@@ -244,9 +246,12 @@ template GCPJWTVerifier(
extracted_image_digest[6] === 58; // ':'
// Extract and output only the 64-char hash (skip "sha256:" prefix)
signal image_hash_bytes[IMAGE_HASH_LENGTH];
for (var i = 0; i < IMAGE_HASH_LENGTH; i++) {
image_hash[i] <== extracted_image_digest[7 + i];
image_hash_bytes[i] <== extracted_image_digest[7 + i];
}
image_hash_packed <== PackBytes(IMAGE_HASH_LENGTH)(image_hash_bytes);
}
component main = GCPJWTVerifier(1, 120, 35);

View File

@@ -251,10 +251,6 @@ async function main() {
);
}
// Decode for verification/logging (not used in circuit)
const eatNonce0Buffer = Buffer.from(eatNonce0Base64url, 'base64url');
console.log(`[INFO] eat_nonce[0] decoded: ${eatNonce0Buffer.length} bytes`);
// Find offset of eat_nonce[0] in the decoded payload JSON
// Decode the payload from base64url to get the exact JSON string
const payloadJSON = Buffer.from(payloadB64, 'base64url').toString('utf8');
@@ -285,6 +281,30 @@ async function main() {
eatNonce0CharCodes[i] = eatNonce0Base64url.charCodeAt(i);
}
const eatNonce1Base64url = payload.eat_nonce[1];
console.log(`[INFO] eat_nonce[1] (base64url): ${eatNonce1Base64url}`);
console.log(`[INFO] eat_nonce[1] string length: ${eatNonce1Base64url.length} characters`);
if (eatNonce1Base64url.length > MAX_EAT_NONCE_B64_LENGTH) {
throw new Error(
`[ERROR] eat_nonce[1] length ${eatNonce1Base64url.length} exceeds max ${MAX_EAT_NONCE_B64_LENGTH}`
);
}
const eatNonce1ValueOffset = payloadJSON.indexOf(eatNonce1Base64url);
if (eatNonce1ValueOffset === -1) {
console.error('[ERROR] Could not find eat_nonce[1] value in decoded payload JSON');
console.error('[DEBUG] Payload JSON:', payloadJSON);
console.error('[DEBUG] Looking for:', eatNonce1Base64url);
throw new Error('[ERROR] Could not find eat_nonce[1] value in decoded payload JSON');
}
console.log(`[INFO] eat_nonce[1] value offset in payload: ${eatNonce1ValueOffset}`);
const eatNonce1CharCodes = new Array(MAX_EAT_NONCE_B64_LENGTH).fill(0);
for (let i = 0; i < eatNonce1Base64url.length; i++) {
eatNonce1CharCodes[i] = eatNonce1Base64url.charCodeAt(i);
}
// Extract image_digest from payload.submods.container.image_digest
if (!payload.submods?.container?.image_digest) {
throw new Error('[ERROR] No image_digest found in payload.submods.container');
@@ -378,6 +398,9 @@ async function main() {
eat_nonce_0_key_offset: eatNonce0KeyOffset.toString(),
eat_nonce_0_value_offset: eatNonce0ValueOffset.toString(),
// EAT nonce[1] (circuit will extract value directly from payload)
eat_nonce_1_b64_length: eatNonce1Base64url.length.toString(),
// Container image digest (circuit will extract value directly from payload)
image_digest_length: imageDigest.length.toString(),
image_digest_key_offset: imageDigestKeyOffset.toString(),

View File

@@ -74,39 +74,30 @@ template ExtractAndVerifyJSONField(
// Check character at colon+1: must be '[' (91) or space (32)
signal char_after_colon <== ItemAtIndex(maxJSONLength)(json, colon_position + 1);
signal value_start <== ItemAtIndex(maxJSONLength)(json, value_offset);
// is_bracket: 1 if char is '[', 0 otherwise
component is_bracket = IsEqual();
is_bracket.in[0] <== char_after_colon;
is_bracket.in[1] <== 91; // '['
// is_space: 1 if char is space, 0 otherwise
component is_space = IsEqual();
is_space.in[0] <== char_after_colon;
is_space.in[1] <== 32; // ' '
// is_quote: 1 if char is quote, 0 otherwise
component is_quote = IsEqual();
is_quote.in[0] <== char_after_colon;
is_quote.in[1] <== 34; // "
// Exactly one must be true: char is either '[' or space
is_bracket.out + is_space.out === 1;
// Exactly one must be true: char is either [ or quote
is_bracket.out + is_quote.out === 1;
// If bracket at colon+1: check quote at colon+2, value at colon+3
// If space at colon+1: check bracket at colon+2, quote at colon+3, value at colon+4
// When is_bracket=1 (no space): expect quote at colon+2
// When is_bracket=1 : expect quote at colon+2
signal char_at_plus2 <== ItemAtIndex(maxJSONLength)(json, colon_position + 2);
// When is_space=1: expect bracket at colon+2
// Constraint: if is_bracket=1, char_at_plus2 must be quote(34)
// if is_space=1, char_at_plus2 must be bracket(91)
// if is_quote=1, char_at_plus2 must be value[0]
is_bracket.out * (char_at_plus2 - 34) === 0; // If bracket at +1, quote at +2
is_space.out * (char_at_plus2 - 91) === 0; // If space at +1, bracket at +2
// When is_space=1: check quote at colon+3
signal char_at_plus3 <== ItemAtIndex(maxJSONLength)(json, colon_position + 3);
is_space.out * (char_at_plus3 - 34) === 0; // If space at +1, quote at +3
// Enforce value_offset based on pattern
// Pattern 1 (no space): :[" -> value at colon+3
// Pattern 2 (space): : [" -> value at colon+4
signal expected_value_offset <== colon_position + 3 + is_space.out;
value_offset === expected_value_offset;
component is_value_after_quote = IsEqual();
is_value_after_quote.in[0] <== char_at_plus2;
is_value_after_quote.in[1] <== value_start;
is_quote.out * (1 - is_value_after_quote.out) === 0;
// Extract value from JSON and output directly
extracted_value <== SelectSubArray(
@@ -114,10 +105,20 @@ template ExtractAndVerifyJSONField(
maxValueLength
)(json, value_offset, value_length);
// Validate value ends with closing quote and bracket: "value"]
// Validate value ends with closing quote and then either ']' or ',' after
signal closing_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length);
closing_quote === 34; // ASCII code for "
signal closing_bracket <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1);
closing_bracket === 93; // ASCII code for ]
// The character following the closing quote must be either ']' (93) or ',' (44)
signal char_after_quote <== ItemAtIndex(maxJSONLength)(json, value_offset + value_length + 1);
component is_closing_bracket = IsEqual();
is_closing_bracket.in[0] <== char_after_quote;
is_closing_bracket.in[1] <== 93; // ']'
component is_comma = IsEqual();
is_comma.in[0] <== char_after_quote;
is_comma.in[1] <== 44; // ','
// Exactly one of the two must be true
is_closing_bracket.out + is_comma.out === 1;
}

View File

@@ -25,6 +25,7 @@
"test-disclose-id": "yarn test-base 'tests/disclose/vc_and_disclose_id.test.ts' --exit",
"test-dsc": "yarn test-base --max-old-space-size=51200 'tests/dsc/dsc.test.ts' --exit",
"test-ecdsa": "yarn test-base 'tests/utils/ecdsa.test.ts' --exit",
"test-gcp-jwt-verifier": "yarn test-base 'tests/gcp_jwt_verifier/gcp_jwt_verifier.test.ts' --exit",
"test-is-older-than": "yarn test-base 'tests/other_circuits/is_older_than.test.ts' --exit",
"test-is-valid": "yarn test-base 'tests/other_circuits/is_valid.test.ts' --exit",
"test-not-in-list": "yarn test-base 'tests/other_circuits/prove_country_is_not_in_list.test.ts' --exit",
@@ -49,9 +50,9 @@
"@zk-email/jwt-tx-builder-circuits": "0.1.0",
"@zk-email/jwt-tx-builder-helpers": "0.1.0",
"@zk-email/zk-regex-circom": "^1.2.1",
"@zk-kit/binary-merkle-root.circom": "https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree",
"@zk-kit/binary-merkle-root.circom": "npm:@selfxyz/binary-merkle-root.circom@^0.0.1",
"@zk-kit/circuits": "^1.0.0-beta",
"anon-aadhaar-circuits": "https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main",
"anon-aadhaar-circuits": "https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits",
"asn1": "^0.2.6",
"asn1.js": "^5.4.1",
"asn1js": "^3.0.5",

View File

@@ -17,9 +17,28 @@ import nameAndYobAadhaarjson from '../consts/ofac/nameAndYobAadhaarSMT.json' wit
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// const privateKeyPath = path.join(__dirname, '../../../node_modules/anon-aadhaar-circuits/assets/testPrivateKey.pem');
// Dynamically resolve the anon-aadhaar-circuits package location
function resolvePackagePath(packageName: string, subpath: string): string {
try {
// Try to resolve the package's package.json
const packageJsonPath = require.resolve(`${packageName}/package.json`, {
paths: [__dirname],
});
const packageDir = path.dirname(packageJsonPath);
return path.join(packageDir, subpath);
} catch (error) {
// Fallback to traditional node_modules search
const modulePath = path.join(__dirname, '../../node_modules', packageName, subpath);
if (fs.existsSync(modulePath)) {
return modulePath;
}
throw new Error(`Could not resolve ${packageName}/${subpath}`);
}
}
const privateKeyPem = fs.readFileSync(
path.join(__dirname, '../../node_modules/anon-aadhaar-circuits/assets/testPrivateKey.pem'),
resolvePackagePath('anon-aadhaar-circuits', 'assets/testPrivateKey.pem'),
'utf8'
);

View File

@@ -1,4 +1,5 @@
import forge from 'node-forge';
import { Buffer } from 'buffer';
import { WS_DB_RELAYER, WS_DB_RELAYER_STAGING } from '../constants/index.js';
import { initElliptic } from '../utils/certificate_parsing/elliptic.js';

View File

@@ -9,6 +9,7 @@ This is the implementation of contracts for verification and management of ident
When you do the upgrade, be careful with this storage patterns
- You can not change the order in which the contract state variables are declared, nor their type.
- The upgradeable contracts currently target OpenZeppelin 5.x.
Pls see this page for more details:
https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable#modifying-your-contracts

View File

@@ -79,8 +79,8 @@
"@nomiclabs/hardhat-ethers": "^2.2.3",
"@openpassport/zk-kit-lean-imt": "^0.0.6",
"@openpassport/zk-kit-smt": "^0.0.1",
"@openzeppelin/contracts": "^5.0.2",
"@openzeppelin/contracts-upgradeable": "^5.1.0",
"@openzeppelin/contracts": "5.4.0",
"@openzeppelin/contracts-upgradeable": "5.4.0",
"@selfxyz/common": "workspace:^",
"@zk-kit/imt": "^2.0.0-beta.4",
"@zk-kit/imt.sol": "^2.0.0-beta.12",

57
contribute.md Normal file
View File

@@ -0,0 +1,57 @@
# Contributing to Self
Thank you for your interest in contributing to Self! Please read the following guidelines before submitting your contribution.
## Security Vulnerabilities
**Do not open a public PR or GitHub issue for security bugs.**
If you discover a security vulnerability, please report it responsibly by emailing **team@self.xyz**. This helps protect our users while we work on a fix. Security researchers may be eligible for a bounty.
## Ground Rules
### What We Don't Accept
- **No README-only or typo-fix PRs** — We do not accept pull requests that only fix typos or update documentation. Focus your contributions on meaningful code changes.
### Branching Strategy
- **Always branch from `dev`** — All pull requests must be opened against the `dev` branch. PRs targeting `main` or other branches will be rejected.
### For Complex Features
- **Open an issue first** — If your contribution targets core components or introduces complex features, please start by opening an issue describing your implementation plan. This allows maintainers to provide feedback before you invest significant time in development.
## Code Standards
### Naming Conventions
- **Follow the naming conventions of the subrepo** — Each workspace (`app`, `circuits`, `common`, `contracts`, `sdk/*`, etc.) may have its own conventions. Review existing code in the target workspace and match its style.
### Formatting & Linting
Before submitting your PR, ensure your code passes all formatting and linting checks:
```bash
# Format your code
yarn format
# Run linting
yarn lint
# For workspace-specific formatting (recommended)
yarn workspaces foreach -A -p -v --topological-dev --since=origin/dev run nice --if-present
```
## Pull Request Checklist
- [ ] Branch is based on `dev`
- [ ] Code follows the naming conventions of the target workspace
- [ ] `yarn lint` passes
- [ ] `yarn format` has been run
- [ ] For complex changes: issue was opened and discussed first
- [ ] Commit messages are clear and follow conventional format
## Questions?
If you're unsure about anything, feel free to open an issue for discussion before starting work.

View File

@@ -4,5 +4,10 @@ path = ".gitleaks.toml"
[allowlist]
description = "Project-specific overrides"
paths = [
'''(?:^|/)Podfile\.lock$'''
'''(?:^|/)Podfile\.lock$''',
'''(?:^|/)app/src/services/points/constants\.ts$''',
]
regexes = [
'''(?i)(?:token|key|address)[\w\s]*contract[\w\s]*=\s*['"]0x[a-f0-9]{40}['"]''',
'''POINTS_TOKEN_CONTRACT_ADDRESS\s*=\s*['"]0x[a-f0-9]{40}['"]''',
]

View File

@@ -47,13 +47,16 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-native": "0.76.9",
"react-native-passkey": "3.3.1"
"react-native-blur-effect": "1.1.3",
"react-native-passkey": "3.3.1",
"react-native-webview": "13.16.0"
},
"dependencies": {
"@babel/runtime": "^7.28.3",
"js-sha1": "^0.7.0",
"react": "^18.3.1",
"react-native": "0.76.9"
"react-native": "0.76.9",
"uuid": "^13.0.0"
},
"devDependencies": {
"@react-native-community/cli-server-api": "^16.0.3",

View File

@@ -134,10 +134,10 @@
"build:ts-only": "tsup && yarn postbuild",
"fmt": "prettier --check .",
"fmt:fix": "prettier --write .",
"format": "sh -c 'if [ -z \"$SKIP_BUILD_DEPS\" ]; then yarn nice; else yarn fmt:fix; fi'",
"format": "yarn nice",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"nice": "yarn lint:fix && yarn fmt:fix",
"nice": "yarn lint:fix",
"prepublishOnly": "npm run build && npm run typecheck && npm run validate:exports && npm run validate:pkg",
"report:exports": "node ./scripts/report-exports.mjs",
"test": "vitest run",
@@ -151,6 +151,7 @@
"dependencies": {
"@babel/runtime": "^7.28.3",
"@selfxyz/common": "workspace:^",
"@selfxyz/euclid": "^0.4.1",
"@xstate/react": "^5.0.5",
"node-forge": "^1.3.1",
"react-native-nfc-manager": "^3.17.1",
@@ -191,9 +192,11 @@
"lottie-react-native": "7.2.2",
"react": "^18.3.1",
"react-native": "0.76.9",
"react-native-blur-effect": "^1.1.3",
"react-native-haptic-feedback": "*",
"react-native-localize": "*",
"react-native-svg": "*"
"react-native-svg": "*",
"react-native-webview": "^13.16.0"
},
"packageManager": "yarn@4.6.0",
"publishConfig": {

View File

@@ -42,7 +42,7 @@ const optionalDefaults: Required<Pick<Adapters, 'clock' | 'logger'>> = {
},
};
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents'] as const;
const REQUIRED_ADAPTERS = ['auth', 'scanner', 'network', 'crypto', 'documents', 'navigation'] as const;
export const createListenersMap = (): {
map: Map<SDKEvent, Set<(p: any) => void>>;
@@ -212,7 +212,12 @@ export function createSelfClient({
getMRZState: () => {
return useMRZStore.getState();
},
goBack: () => {
adapters.navigation.goBack();
},
goTo: (routeName, params) => {
adapters.navigation.goTo(routeName, params);
},
// for reactivity (if needed)
useProvingStore,
useSelfAppStore,

View File

@@ -73,6 +73,7 @@ export const BackupEvents = {
CLOUD_RESTORE_FAILED_PASSPORT_NOT_REGISTERED: 'Backup: Cloud Restore Failed: Passport Not Registered',
CLOUD_RESTORE_FAILED_UNKNOWN: 'Backup: Cloud Restore Failed: Unknown Error',
CLOUD_RESTORE_SUCCESS: 'Backup: Cloud Restore Success',
TURNKEY_RESTORE_FAILED: 'Backup: Turnkey Restore Failed',
CREATE_NEW_ACCOUNT: 'Backup: Create New Account',
MANUAL_RECOVERY_SELECTED: 'Backup: Manual Recovery Selected',
};

View File

@@ -2,67 +2,26 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { memo, useCallback } from 'react';
import { ActivityIndicator, FlatList, StyleSheet, TouchableOpacity, View } from 'react-native';
import { useCallback, useState } from 'react';
import { commonNames } from '@selfxyz/common/constants/countries';
import { CountryPickerScreen as CountryPickerUI } from '@selfxyz/euclid';
import { BodyText, RoundFlag, XStack, YStack } from '../../components';
import { black, slate100, slate500 } from '../../constants/colors';
import { advercase, dinot } from '../../constants/fonts';
import { RoundFlag } from '../../components';
import { useSelfClient } from '../../context';
import { useCountries } from '../../documents/useCountries';
import { buttonTap } from '../../haptic';
import { SdkEvents } from '../../types/events';
interface CountryListItem {
key: string;
countryCode: string;
}
const ITEM_HEIGHT = 65;
const FLAG_SIZE = 32;
const CountryItem = memo<{
countryCode: string;
onSelect: (code: string) => void;
}>(({ countryCode, onSelect }) => {
const countryName = commonNames[countryCode as keyof typeof commonNames];
if (!countryName) return null;
return (
<TouchableOpacity onPress={() => onSelect(countryCode)} style={styles.countryItemContainer}>
<XStack style={styles.countryItemContent}>
<RoundFlag countryCode={countryCode} size={FLAG_SIZE} />
<BodyText style={styles.countryItemText}>{countryName}</BodyText>
</XStack>
</TouchableOpacity>
);
});
CountryItem.displayName = 'CountryItem';
const Loading = () => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" />
</View>
);
Loading.displayName = 'Loading';
const CountryPickerScreen: React.FC = () => {
const selfClient = useSelfClient();
const [searchValue, setSearchValue] = useState('');
const { countryData, countryList, loading, userCountryCode, showSuggestion } = useCountries();
const onPressCountry = useCallback(
const onCountrySelect = useCallback(
(countryCode: string) => {
buttonTap();
// if (__DEV__) {
// console.log('Selected country code:', countryCode);
// console.log('Current countryData:', countryData);
// console.log('Available country codes:', Object.keys(countryData));
// }
const documentTypes = countryData[countryCode];
if (__DEV__) {
console.log('documentTypes for', countryCode, ':', documentTypes);
@@ -87,105 +46,34 @@ const CountryPickerScreen: React.FC = () => {
[countryData, selfClient],
);
const renderItem = useCallback(
({ item }: { item: CountryListItem }) => <CountryItem countryCode={item.countryCode} onSelect={onPressCountry} />,
[onPressCountry],
);
const renderFlag = useCallback((countryCode: string, size: number) => {
return <RoundFlag countryCode={countryCode} size={size} />;
}, []);
const keyExtractor = useCallback((item: CountryListItem) => item.countryCode, []);
const getCountryName = useCallback((countryCode: string) => {
return commonNames[countryCode as keyof typeof commonNames] || countryCode;
}, []);
const getItemLayout = useCallback(
(_data: ArrayLike<CountryListItem> | null | undefined, index: number) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
}),
[],
);
const onSearchChange = useCallback((value: string) => {
setSearchValue(value);
}, []);
return (
<YStack flex={1} paddingTop="$4" paddingHorizontal="$4" backgroundColor={slate100}>
<YStack marginTop="$4" marginBottom="$6">
<BodyText style={styles.titleText}>Select the country that issued your ID</BodyText>
<BodyText style={styles.subtitleText}>
Self has support for over 300 ID types. You can select the type of ID in the next step
</BodyText>
</YStack>
{loading ? (
<Loading />
) : (
<YStack flex={1}>
{showSuggestion && (
<YStack marginBottom="$2">
<BodyText style={styles.sectionLabel}>SUGGESTION</BodyText>
<CountryItem
countryCode={userCountryCode as string /*safe due to showSuggestion*/}
onSelect={onPressCountry}
/>
<BodyText style={styles.sectionLabelBottom}>SELECT AN ISSUING COUNTRY</BodyText>
</YStack>
)}
<FlatList
data={countryList}
renderItem={renderItem}
keyExtractor={keyExtractor}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
maxToRenderPerBatch={10}
windowSize={10}
initialNumToRender={10}
updateCellsBatchingPeriod={50}
getItemLayout={getItemLayout}
/>
</YStack>
)}
</YStack>
<CountryPickerUI
isLoading={loading}
countries={countryList}
onCountrySelect={onCountrySelect}
suggestionCountryCode={userCountryCode ?? undefined}
showSuggestion={!!showSuggestion}
renderFlag={renderFlag}
getCountryName={getCountryName}
searchValue={searchValue}
onClose={selfClient.goBack}
onInfoPress={() => console.log('Info pressed TODO: Implement')}
onSearchChange={onSearchChange}
/>
);
};
CountryPickerScreen.displayName = 'CountryPickerScreen';
const styles = StyleSheet.create({
countryItemContainer: {
paddingVertical: 13,
},
countryItemContent: {
alignItems: 'center',
gap: 16,
},
countryItemText: {
fontSize: 16,
color: black,
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
titleText: {
fontSize: 29,
fontFamily: advercase,
color: black,
},
subtitleText: {
fontSize: 16,
color: slate500,
marginTop: 20,
},
sectionLabel: {
fontSize: 16,
color: black,
fontFamily: dinot,
letterSpacing: 0.8,
marginBottom: 8,
},
sectionLabelBottom: {
fontSize: 16,
color: black,
fontFamily: dinot,
letterSpacing: 0.8,
marginTop: 20,
},
});
export default CountryPickerScreen;

View File

@@ -79,7 +79,7 @@ export const DocumentCameraScreen = ({ onBack, onSuccess, safeAreaInsets }: Prop
</XStack>
</YStack>
<Additional style={styles.disclaimer}>SELF WILL NOT CAPTURE AN IMAGE OF YOUR PASSPORT.</Additional>
<Additional style={styles.disclaimer}>Self will not capture an image of your ID.</Additional>
<SecondaryButton trackEvent={PassportEvents.CAMERA_SCREEN_CLOSED} onPress={onBack ?? (() => {})}>
Cancel

View File

@@ -17,8 +17,10 @@ export type {
MRZValidation,
NFCScanResult,
NFCScannerAdapter,
NavigationAdapter,
NetworkAdapter,
Progress,
RouteName,
SelfClient,
StorageAdapter,
TrackEventParams,

View File

@@ -199,6 +199,36 @@ export interface Adapters {
auth: AuthAdapter;
/** Required document persistence layer. Implementations must be idempotent. */
documents: DocumentsAdapter;
/** Required navigation adapter for handling screen transitions. */
navigation: NavigationAdapter;
}
/**
* Map these route names to your navigation configuration.
* Includes all screens that the SDK may navigate to across host applications.
*/
export type RouteName =
// Document acquisition flow
| 'DocumentCamera'
| 'DocumentOnboarding'
| 'CountryPicker'
| 'IDPicker'
| 'DocumentNFCScan'
| 'ManageDocuments'
// Account/onboarding flow
| 'Home'
| 'AccountVerifiedSuccess'
| 'AccountRecoveryChoice'
| 'SaveRecoveryPhrase'
// Error/fallback screens
| 'ComingSoon'
| 'DocumentDataNotFound'
// Settings
| 'Settings';
export interface NavigationAdapter {
goBack(): void;
goTo(routeName: RouteName, params?: Record<string, unknown>): void;
}
/**
@@ -284,6 +314,8 @@ export interface SelfClient {
scanNFC(opts: NFCScanOpts & { signal?: AbortSignal }): Promise<NFCScanResult>;
/** Parses MRZ text and returns structured fields plus checksum metadata. */
extractMRZInfo(mrz: string): MRZInfo;
goBack(): void;
goTo(routeName: RouteName, params?: Record<string, unknown>): void;
/**
* Convenience wrapper around {@link AnalyticsAdapter.trackEvent}. Calls are

View File

@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../src';
import { createListenersMap, createSelfClient, SdkEvents } from '../src/index';
import type { AuthAdapter } from '../src/types/public';
import type { AuthAdapter, NavigationAdapter } from '../src/types/public';
describe('createSelfClient', () => {
// Test eager validation during client creation
@@ -27,21 +27,21 @@ describe('createSelfClient', () => {
it('throws when network adapter missing during creation', () => {
// @ts-expect-error -- missing adapters
expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth } })).toThrow(
expect(() => createSelfClient({ config: {}, adapters: { scanner, crypto, documents, auth, navigation } })).toThrow(
'network adapter not provided',
);
});
it('throws when crypto adapter missing during creation', () => {
// @ts-expect-error -- missing adapters
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth } })).toThrow(
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, documents, auth, navigation } })).toThrow(
'crypto adapter not provided',
);
});
it('throws when documents adapter missing during creation', () => {
// @ts-expect-error -- missing adapters
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth } })).toThrow(
expect(() => createSelfClient({ config: {}, adapters: { scanner, network, crypto, auth, navigation } })).toThrow(
'documents adapter not provided',
);
});
@@ -49,7 +49,7 @@ describe('createSelfClient', () => {
it('creates client successfully with all required adapters', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
adapters: { scanner, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
expect(client).toBeTruthy();
@@ -59,7 +59,7 @@ describe('createSelfClient', () => {
const scanMock = vi.fn().mockResolvedValue({ passportData: { mock: true } });
const client = createSelfClient({
config: {},
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
const result = await client.scanNFC({
@@ -85,7 +85,7 @@ describe('createSelfClient', () => {
const scanMock = vi.fn().mockRejectedValue(err);
const client = createSelfClient({
config: {},
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth },
adapters: { scanner: { scan: scanMock }, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
await expect(
@@ -106,7 +106,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
adapters: { scanner, network, crypto, documents, auth, navigation },
listeners: listeners.map,
});
@@ -134,7 +134,7 @@ describe('createSelfClient', () => {
it('parses MRZ via client', () => {
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth },
adapters: { scanner, network, crypto, documents, auth, navigation },
listeners: new Map(),
});
const sample = `P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10`;
@@ -149,6 +149,7 @@ describe('createSelfClient', () => {
const client = createSelfClient({
config: {},
adapters: {
navigation,
scanner,
network,
crypto,
@@ -171,7 +172,7 @@ describe('createSelfClient', () => {
const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key'));
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } },
listeners: new Map(),
});
@@ -181,7 +182,7 @@ describe('createSelfClient', () => {
const getPrivateKey = vi.fn(() => Promise.resolve('stubbed-private-key'));
const client = createSelfClient({
config: {},
adapters: { scanner, network, crypto, documents, auth: { getPrivateKey } },
adapters: { scanner, network, crypto, documents, navigation, auth: { getPrivateKey } },
listeners: new Map(),
});
await expect(client.hasPrivateKey()).resolves.toBe(true);
@@ -222,3 +223,8 @@ const documents: DocumentsAdapter = {
saveDocument: async () => {},
deleteDocument: async () => {},
};
const navigation: NavigationAdapter = {
goBack: vi.fn(),
goTo: vi.fn(),
};

View File

@@ -35,6 +35,10 @@ const createMockSelfClientWithDocumentsAdapter = (documentsAdapter: DocumentsAda
}),
},
},
navigation: {
goBack: () => {},
goTo: (_routeName: string, _params?: Record<string, any>) => {},
},
scanner: {
scan: async () => ({
passportData: {

View File

@@ -3,6 +3,8 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
/* eslint-disable sort-exports/sort-exports */
import type { NavigationAdapter } from 'src/types/public';
import type { CryptoAdapter, DocumentsAdapter, NetworkAdapter, NFCScannerAdapter } from '../../src';
// Shared test data
@@ -60,12 +62,18 @@ const mockAuth = {
getPrivateKey: async () => 'stubbed-private-key',
};
const mockNavigation: NavigationAdapter = {
goBack: vi.fn(),
goTo: vi.fn(),
};
export const mockAdapters = {
scanner: mockScanner,
network: mockNetwork,
crypto: mockCrypto,
documents: mockDocuments,
auth: mockAuth,
navigation: mockNavigation,
};
// Shared test expectations

View File

@@ -12,14 +12,14 @@
"prebuild": "yarn workspace @selfxyz/mobile-sdk-alpha build",
"build": "yarn prebuild && tsc -p tsconfig.json --noEmit --pretty false",
"clean": "rm -rf ios/build android/app/build android/build && cd android && ./gradlew clean && cd ..",
"format": "prettier --write .",
"format": "yarn nice",
"ia": "yarn install-app",
"install-app": "yarn install && yarn prebuild && cd ios && pod install && cd ..",
"preios": "yarn prebuild",
"ios": "react-native run-ios",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"nice": "yarn lint:fix && yarn format",
"nice": "yarn lint:fix",
"reinstall": "yarn clean && yarn install && yarn prebuild && cd ios && pod install && cd ..",
"start": "react-native start",
"test": "vitest run",
@@ -42,6 +42,7 @@
"lottie-react-native": "7.2.2",
"react": "^18.3.1",
"react-native": "0.76.9",
"react-native-blur-effect": "1.1.3",
"react-native-get-random-values": "^1.11.0",
"react-native-haptic-feedback": "^2.3.3",
"react-native-keychain": "^10.0.0",
@@ -49,6 +50,7 @@
"react-native-safe-area-context": "^5.6.1",
"react-native-svg": "15.12.1",
"react-native-vector-icons": "^10.3.0",
"react-native-webview": "13.16.0",
"stream-browserify": "^3.0.0",
"util": "^0.12.5"
},

View File

@@ -11,6 +11,7 @@ import {
createListenersMap,
SdkEvents,
type Adapters,
type RouteName,
type TrackEventParams,
type WsConn,
reactNativeScannerAdapter,
@@ -18,8 +19,40 @@ import {
import { persistentDocumentsAdapter } from '../utils/documentStore';
import { getOrCreateSecret } from '../utils/secureStorage';
import type { ScreenName } from '../navigation/NavigationProvider';
import { useNavigation } from '../navigation/NavigationProvider';
/**
* Maps SDK RouteName values to demo app ScreenName values.
* Routes not in this map are not supported in the demo app.
*/
const ROUTE_TO_SCREEN_MAP: Partial<Record<RouteName, ScreenName>> = {
Home: 'Home',
CountryPicker: 'CountrySelection',
IDPicker: 'IDSelection',
DocumentCamera: 'MRZ',
DocumentNFCScan: 'NFC',
ManageDocuments: 'Documents',
AccountVerifiedSuccess: 'Success',
// Routes not implemented in demo app:
// 'DocumentOnboarding': null,
// 'SaveRecoveryPhrase': null,
// 'AccountRecoveryChoice': null,
// 'ComingSoon': null,
// 'DocumentDataNotFound': null,
// 'Settings': null,
} as const;
/**
* Translates SDK RouteName to demo app ScreenName.
*
* @param routeName - The route name from the SDK
* @returns The corresponding demo app screen name, or null if not supported
*/
function translateRouteToScreen(routeName: RouteName): ScreenName | null {
return ROUTE_TO_SCREEN_MAP[routeName] ?? null;
}
const createFetch = () => {
const fetchImpl = globalThis.fetch;
if (!fetchImpl) {
@@ -129,6 +162,23 @@ export function SelfClientProvider({ children, onNavigate }: SelfClientProviderP
},
ws: createWsAdapter(),
},
navigation: {
goBack: () => {
navigation.goBack();
},
goTo: (routeName, params) => {
const screenName = translateRouteToScreen(routeName);
if (screenName) {
// SDK passes generic Record<string, unknown>, but demo navigation expects specific types
// This is safe because we control the route mapping
navigation.navigate(screenName, params as any);
} else {
console.warn(
`[SelfClientProvider] SDK route "${routeName}" is not mapped to a demo screen. Ignoring navigation request.`,
);
}
},
},
documents: persistentDocumentsAdapter,
crypto: {
async hash(data: Uint8Array): Promise<Uint8Array> {

View File

@@ -0,0 +1,44 @@
diff --git a/node_modules/ethereum-cryptography/utils.js b/node_modules/ethereum-cryptography/utils.js
index cedfa36..b494c49 100644
--- a/node_modules/ethereum-cryptography/utils.js
+++ b/node_modules/ethereum-cryptography/utils.js
@@ -8,11 +8,18 @@ exports.bytesToUtf8 = bytesToUtf8;
exports.hexToBytes = hexToBytes;
exports.equalsBytes = equalsBytes;
exports.wrapHash = wrapHash;
-const _assert_1 = __importDefault(require("@noble/hashes/_assert"));
+const assertModule = __importDefault(require("@noble/hashes/_assert"));
const utils_1 = require("@noble/hashes/utils");
-const assertBool = _assert_1.default.bool;
+const assertBool = (assertModule.default && assertModule.default.bool) ||
+ assertModule.bool ||
+ ((value) => {
+ if (typeof value !== "boolean")
+ throw new TypeError(`Expected boolean, not ${value}`);
+ });
exports.assertBool = assertBool;
-const assertBytes = _assert_1.default.bytes;
+const assertBytes = (assertModule.default && assertModule.default.bytes) ||
+ assertModule.bytes ||
+ assertModule.abytes;
exports.assertBytes = assertBytes;
var utils_2 = require("@noble/hashes/utils");
Object.defineProperty(exports, "bytesToHex", { enumerable: true, get: function () { return utils_2.bytesToHex; } });
diff --git a/node_modules/ethereum-cryptography/esm/utils.js b/node_modules/ethereum-cryptography/esm/utils.js
index 8e771ea..b3eed9d 100644
--- a/node_modules/ethereum-cryptography/esm/utils.js
+++ b/node_modules/ethereum-cryptography/esm/utils.js
@@ -1,7 +1,11 @@
import assert from "@noble/hashes/_assert";
import { hexToBytes as _hexToBytes } from "@noble/hashes/utils";
-const assertBool = assert.bool;
-const assertBytes = assert.bytes;
+const assertBool = (assert?.bool) ||
+ ((value) => {
+ if (typeof value !== "boolean")
+ throw new TypeError(`Expected boolean, not ${value}`);
+ });
+const assertBytes = assert.bytes || assert.abytes;
export { assertBool, assertBytes };
export { bytesToHex, bytesToHex as toHex, concatBytes, createView, utf8ToBytes } from "@noble/hashes/utils";
// buf.toString('utf8') -> bytesToUtf8(buf)

View File

@@ -90,18 +90,46 @@ if (!isExecutableAvailableOnPath('patch-package')) {
// Run patch-package with better error handling
try {
const rootPatchRun = spawnSync('patch-package', ['--patch-dir', 'patches'], {
cwd: repositoryRootPath,
shell: true,
stdio: isCI ? 'pipe' : 'inherit',
timeout: 30000
});
if (rootPatchRun.status === 0) {
if (!isCI) console.log('✓ Patches applied to root workspace');
} else {
const errorOutput = rootPatchRun.stderr?.toString() || rootPatchRun.stdout?.toString() || '';
console.error(`patch-package failed for root workspace (exit code ${rootPatchRun.status})`);
if (errorOutput) console.error(errorOutput);
if (!isCI) process.exit(1);
}
// Also patch app/node_modules if it exists
const appPath = path.join(repositoryRootPath, 'app');
const appNodeModules = path.join(appPath, 'node_modules');
if (fs.existsSync(appNodeModules)) {
const appPatchRun = spawnSync('patch-package', ['--patch-dir', '../patches'], {
cwd: appPath,
// Workspaces with isolated node_modules due to limited hoisting
const workspaceRoots = [
{ name: 'app', path: path.join(repositoryRootPath, 'app') },
{ name: 'contracts', path: path.join(repositoryRootPath, 'contracts') }
];
for (const workspace of workspaceRoots) {
const workspaceNodeModules = path.join(workspace.path, 'node_modules');
if (!fs.existsSync(workspaceNodeModules)) continue;
const workspacePatchRun = spawnSync('patch-package', ['--patch-dir', '../patches'], {
cwd: workspace.path,
shell: true,
stdio: isCI ? 'pipe' : 'inherit',
timeout: 30000
});
if (appPatchRun.status === 0 && !isCI) {
console.log('✓ Patches applied to app workspace');
if (workspacePatchRun.status === 0) {
if (!isCI) console.log(`✓ Patches applied to ${workspace.name} workspace`);
} else {
const errorOutput = workspacePatchRun.stderr?.toString() || workspacePatchRun.stdout?.toString() || '';
console.error(`patch-package failed for ${workspace.name} workspace (exit code ${workspacePatchRun.status})`);
if (errorOutput) console.error(errorOutput);
if (!isCI) process.exit(1);
}
}
} catch (error) {

View File

@@ -97,7 +97,7 @@ const SelfQRcode = ({
return <BounceLoader loading={true} size={200} color="#94FBAB" />;
case QRcodeSteps.PROOF_GENERATION_FAILED:
return (
//@ts-ignore
// @ts-expect-error Lottie typings don't match the default export shape
<LottieComponent
animationData={X_ANIMATION}
style={{ width: 200, height: 200 }}
@@ -109,7 +109,7 @@ const SelfQRcode = ({
);
case QRcodeSteps.PROOF_VERIFIED:
return (
//@ts-ignore
// @ts-expect-error Lottie typings don't match the default export shape
<LottieComponent
animationData={CHECK_ANIMATION}
style={{ width: 200, height: 200 }}

View File

@@ -69,7 +69,7 @@
"prepublishOnly": "yarn build",
"publish": "yarn npm publish --access public",
"test": "echo 'no tests found'",
"types": "yarn build"
"types": "yarn workspace @selfxyz/sdk-common build && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@selfxyz/sdk-common": "workspace:^",

View File

@@ -1,11 +1,11 @@
{
"name": "@selfxyz/sdk-common",
"type": "module",
"version": "1.0.0",
"description": "",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"keywords": [],
"license": "ISC",
"author": "",
"type": "module",
"exports": {
".": {
"import": {
@@ -18,20 +18,20 @@
}
}
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
"files": [
"./dist/**/*"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"typescript": "^5.9.2"
"build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"uuid": "^13.0.0"
},
"devDependencies": {
"typescript": "^5.9.2"
}
}

View File

@@ -291,4 +291,4 @@ For support and questions:
- Create an issue in this repository
- Check the [Self Protocol documentation](https://docs.self.id)
- Join our [Discord community](https://discord.gg/worldcoin)
- Join our [Discord community](https://discord.gg/selfxyz)

View File

@@ -5836,19 +5836,19 @@ __metadata:
languageName: node
linkType: hard
"@openzeppelin/contracts-upgradeable@npm:^5.1.0":
version: 5.5.0
resolution: "@openzeppelin/contracts-upgradeable@npm:5.5.0"
"@openzeppelin/contracts-upgradeable@npm:5.4.0":
version: 5.4.0
resolution: "@openzeppelin/contracts-upgradeable@npm:5.4.0"
peerDependencies:
"@openzeppelin/contracts": 5.5.0
checksum: 10c0/66a0050e8e4d2d68acbf14e272387a1b859a57353d32ac929389b40660d0bc1ff590c904f464f20e307a6632cfcaf86a9400e6c83bc466f5d174595bfc5e0e31
"@openzeppelin/contracts": 5.4.0
checksum: 10c0/0bf78ad1a42151eb41eb7ff6645714a65649766643514b704d398927fe72533d2a143557306610db93f625124dfef9ee06b2c9ca624ada16074962cbcb622b6b
languageName: node
linkType: hard
"@openzeppelin/contracts@npm:^5.0.2":
version: 5.5.0
resolution: "@openzeppelin/contracts@npm:5.5.0"
checksum: 10c0/884ab2850ff380c11d042ce50ebecf8fad2f784105554d8826a4160126d17e96b8a636400e5640ac227d7e8f8754477835ead8acb2668355b83e2509ca7fca63
"@openzeppelin/contracts@npm:5.4.0":
version: 5.4.0
resolution: "@openzeppelin/contracts@npm:5.4.0"
checksum: 10c0/7e05646dee4905fea4493f0fb2ad208abaff84b9f5ef1b9e1a5fc61d0e74d9bc30759bdf0d09a08049cfef6a290623330d83469eadc990a7bba6c2cd09a27e1d
languageName: node
linkType: hard
@@ -7577,9 +7577,9 @@ __metadata:
"@zk-email/jwt-tx-builder-circuits": "npm:0.1.0"
"@zk-email/jwt-tx-builder-helpers": "npm:0.1.0"
"@zk-email/zk-regex-circom": "npm:^1.2.1"
"@zk-kit/binary-merkle-root.circom": "https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree"
"@zk-kit/binary-merkle-root.circom": "npm:@selfxyz/binary-merkle-root.circom@^0.0.1"
"@zk-kit/circuits": "npm:^1.0.0-beta"
anon-aadhaar-circuits: "https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main"
anon-aadhaar-circuits: "https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits"
asn1: "npm:^0.2.6"
asn1.js: "npm:^5.4.1"
asn1js: "npm:^3.0.5"
@@ -7678,8 +7678,8 @@ __metadata:
"@nomiclabs/hardhat-ethers": "npm:^2.2.3"
"@openpassport/zk-kit-lean-imt": "npm:^0.0.6"
"@openpassport/zk-kit-smt": "npm:^0.0.1"
"@openzeppelin/contracts": "npm:^5.0.2"
"@openzeppelin/contracts-upgradeable": "npm:^5.1.0"
"@openzeppelin/contracts": "npm:5.4.0"
"@openzeppelin/contracts-upgradeable": "npm:5.4.0"
"@selfxyz/common": "workspace:^"
"@typechain/ethers-v6": "npm:^0.4.3"
"@typechain/hardhat": "npm:^8.0.3"
@@ -7750,6 +7750,19 @@ __metadata:
languageName: unknown
linkType: soft
"@selfxyz/euclid@npm:^0.4.1":
version: 0.4.1
resolution: "@selfxyz/euclid@npm:0.4.1"
peerDependencies:
react: ">=18.2.0"
react-native: ">=0.72.0"
react-native-blur-effect: ^1.1.3
react-native-svg: ">=15.14.0"
react-native-webview: ^13.16.0
checksum: 10c0/f25a30b936d5ab1c154008296c64e0b4f97d91cf16e420b9bc3d2f4d9196ae426d1c2b28af653e36a9a580f78a42953f6bca3e9e9fbb36a15860636b9a0cb5fd
languageName: node
linkType: hard
"@selfxyz/mobile-app@workspace:app":
version: 0.0.0-use.local
resolution: "@selfxyz/mobile-app@workspace:app"
@@ -7862,6 +7875,7 @@ __metadata:
react-native: "npm:0.76.9"
react-native-app-auth: "npm:^8.0.3"
react-native-biometrics: "npm:^3.0.1"
react-native-blur-effect: "npm:^1.1.3"
react-native-check-version: "npm:^1.3.0"
react-native-cloud-storage: "npm:^2.2.2"
react-native-device-info: "npm:^14.0.4"
@@ -7909,6 +7923,7 @@ __metadata:
dependencies:
"@babel/runtime": "npm:^7.28.3"
"@selfxyz/common": "workspace:^"
"@selfxyz/euclid": "npm:^0.4.1"
"@testing-library/react": "npm:^14.1.2"
"@types/react": "npm:^18.3.4"
"@types/react-dom": "npm:^18.3.0"
@@ -7946,9 +7961,11 @@ __metadata:
lottie-react-native: 7.2.2
react: ^18.3.1
react-native: 0.76.9
react-native-blur-effect: ^1.1.3
react-native-haptic-feedback: "*"
react-native-localize: "*"
react-native-svg: "*"
react-native-webview: ^13.16.0
languageName: unknown
linkType: soft
@@ -14121,12 +14138,12 @@ __metadata:
languageName: node
linkType: hard
"@zk-kit/binary-merkle-root.circom@https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree":
version: 1.0.0
resolution: "@zk-kit/binary-merkle-root.circom@https://gitpkg.vercel.app/Vishalkulkarni45/zk-kit.circom/packages/binary-merkle-root?fix/bin-merkle-tree"
"@zk-kit/binary-merkle-root.circom@npm:@selfxyz/binary-merkle-root.circom@^0.0.1":
version: 0.0.1
resolution: "@selfxyz/binary-merkle-root.circom@npm:0.0.1"
dependencies:
circomlib: "npm:^2.0.5"
checksum: 10c0/f7bef284bae30b261610c5e2791c8aa7cc6ab42f5b4caf1e4b251543f506989dfb27d27963979a1db2c67b9c3d3561ce287fa8e0391bddee567c62963cd8f743
checksum: 10c0/616426859e67702ef61c28651e38ebf218d560af13630e183f034f8cc66f40e01fa3b7ecb4ab5ed41ceb9c1f0ec8b3e62bdf7f184de38f1d7a8d7d1058a04fd5
languageName: node
linkType: hard
@@ -14505,13 +14522,13 @@ __metadata:
languageName: node
linkType: hard
"anon-aadhaar-circuits@https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main":
"anon-aadhaar-circuits@https://github.com/selfxyz/anon-aadhaar.git#commit=1b9efa501cff3cf25dc260b060bf611229e316a4&workspace=@anon-aadhaar/circuits":
version: 2.4.3
resolution: "anon-aadhaar-circuits@https://gitpkg.vercel.app/selfxyz/anon-aadhaar/packages/circuits?main"
resolution: "anon-aadhaar-circuits@https://github.com/selfxyz/anon-aadhaar.git#workspace=%40anon-aadhaar%2Fcircuits&commit=1b9efa501cff3cf25dc260b060bf611229e316a4"
dependencies:
"@anon-aadhaar/core": "npm:^2.4.3"
"@zk-email/circuits": "npm:^6.1.1"
checksum: 10c0/93138d1c251988402482f1719ed37764b962250a51deb67bf5b855b91a6f89df2776ffe6135e8accc7a0d57dd13e7c210fc02fc6562af249ea4305f24d7d55f4
checksum: 10c0/1e092f002e6a413fd034016320eedfb789158996f707d0c8c2055450baa35660fd90657e34e05c4a23094ed397e9088b6e9feb3463287bf9a0b272cc1fde592f
languageName: node
linkType: hard
@@ -25613,6 +25630,7 @@ __metadata:
react: "npm:^18.3.1"
react-dom: "npm:^18.3.1"
react-native: "npm:0.76.9"
react-native-blur-effect: "npm:1.1.3"
react-native-get-random-values: "npm:^1.11.0"
react-native-haptic-feedback: "npm:^2.3.3"
react-native-keychain: "npm:^10.0.0"
@@ -25621,6 +25639,7 @@ __metadata:
react-native-svg: "npm:15.12.1"
react-native-svg-transformer: "npm:^1.5.1"
react-native-vector-icons: "npm:^10.3.0"
react-native-webview: "npm:13.16.0"
stream-browserify: "npm:^3.0.0"
typescript: "npm:^5.9.2"
util: "npm:^0.12.5"
@@ -28332,6 +28351,17 @@ __metadata:
languageName: node
linkType: hard
"react-native-blur-effect@npm:1.1.3":
version: 1.1.3
resolution: "react-native-blur-effect@npm:1.1.3"
peerDependencies:
react: ^17.0.2
react-native: ^0.66.4
react-native-webview: ^13.6.2
checksum: 10c0/5036214ac36fd430c7cea41bf0f14b2aa18338ae7f3e5df4142775dd4462f26ea3bc53710397bfe01c3a2c4450c219978f86dbc5d1989deefa39ca3c4ac80bb6
languageName: node
linkType: hard
"react-native-check-version@npm:^1.3.0":
version: 1.4.0
resolution: "react-native-check-version@npm:1.4.0"
@@ -28716,7 +28746,7 @@ __metadata:
languageName: node
linkType: hard
"react-native-webview@npm:^13.16.0":
"react-native-webview@npm:13.16.0":
version: 13.16.0
resolution: "react-native-webview@npm:13.16.0"
dependencies:
@@ -29936,6 +29966,7 @@ __metadata:
react: "npm:^18.3.1"
react-native: "npm:0.76.9"
typescript: "npm:^5.9.2"
uuid: "npm:^13.0.0"
languageName: unknown
linkType: soft