diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index f00627118..bec0fe25e 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -16,7 +16,7 @@ reviews:
enable_prompt_for_ai_agents: false
review_status: true
auto_review:
- enabled: true
+ enabled: false
drafts: false
base_branches: ["main", "dev", "staging"]
tools:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
deleted file mode 100644
index 8d503fe22..000000000
--- a/.github/pull_request_template.md
+++ /dev/null
@@ -1,7 +0,0 @@
-## Summary
-
-
-
-## Test plan
-
-
diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml
index aee38e37e..a8a4682bf 100644
--- a/.github/workflows/mobile-e2e.yml
+++ b/.github/workflows/mobile-e2e.yml
@@ -11,7 +11,7 @@ env:
GH_GEMS_CACHE_VERSION: v1 # Ruby gems cache version
# Performance optimizations
YARN_TASK_POOL_CONCURRENCY: 2 # Limit parallel native module builds to prevent OOM on self-hosted runners
- GRADLE_OPTS: -Dorg.gradle.daemon=false -Dorg.gradle.workers.max=2 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs='-Xmx6144m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8'
+ GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs='-Xmx4096m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8'
CI: true
# Disable Maestro analytics in CI
MAESTRO_CLI_NO_ANALYTICS: true
@@ -324,7 +324,31 @@ jobs:
env:
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
CI: true
+
+ - name: Capture private module SHAs for APK cache key
+ run: |
+ set -euo pipefail
+ : > .apk-cache-inputs.txt
+ for dir in app/android/android-passport-nfc-reader app/android/react-native-passport-reader; do
+ if [ -d "$dir/.git" ]; then
+ sha=$(git -C "$dir" rev-parse HEAD)
+ echo "$(basename "$dir")=$sha" >> .apk-cache-inputs.txt
+ fi
+ done
+ cat .apk-cache-inputs.txt
+
+ - name: Stage APK path for cache restore
+ run: rm -f app/android/app/build/outputs/apk/debug/app-debug.apk
+
+ - name: Restore Android APK cache
+ id: apk-cache
+ uses: actions/cache/restore@v4
+ with:
+ path: app/android/app/build/outputs/apk/debug/app-debug.apk
+ key: ${{ runner.os }}-apk-v1-${{ hashFiles('app/android/app/src/**', 'app/android/app/build.gradle', 'app/android/app/proguard-rules.pro', 'app/android/build.gradle', 'app/android/settings.gradle', 'app/android/gradle.properties', 'app/android/gradle/wrapper/**', 'app/android/gradlew', 'app/src/**', 'app/App.tsx', 'app/index.js', 'app/app.json', 'app/package.json', 'app/babel.config.cjs', 'app/metro.config.cjs', 'app/react-native.config.cjs', 'app/patches/**', 'patches/**', 'packages/mobile-sdk-alpha/src/**', 'packages/mobile-sdk-alpha/package.json', 'yarn.lock', '.nvmrc', '.apk-cache-inputs.txt') }}
+
- name: Build Android APK
+ if: steps.apk-cache.outputs.cache-hit != 'true'
run: |
echo "Building Android APK..."
chmod +x app/android/gradlew
@@ -343,15 +367,44 @@ jobs:
MONITOR_PID=$!
trap 'kill "${MONITOR_PID}" 2>/dev/null || true' EXIT
- (
- cd app/android &&
- E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --build-cache --no-configuration-cache
- ) || { echo "❌ Android build failed"; exit 1; }
+ build_attempt() {
+ (
+ cd app/android &&
+ E2E_BUILD=true ./gradlew assembleDebug -PbundleInDebug=true --quiet --build-cache --no-configuration-cache
+ )
+ }
+
+ BUILD_OK=false
+ for attempt in 1 2; do
+ echo "Gradle build attempt ${attempt}/2..."
+ if build_attempt; then
+ BUILD_OK=true
+ break
+ fi
+ echo "⚠️ Gradle build failed on attempt ${attempt}."
+ if [ "${attempt}" -lt 2 ]; then
+ echo "Stopping Gradle daemon and retrying in 15s..."
+ (cd app/android && ./gradlew --stop) || true
+ sleep 15
+ fi
+ done
kill "${MONITOR_PID}" 2>/dev/null || true
+ if [ "${BUILD_OK}" != "true" ]; then
+ echo "❌ Android build failed after retries"
+ exit 1
+ fi
echo "✅ Android build succeeded"
+ - name: Save Android APK cache
+ if: steps.apk-cache.outputs.cache-hit != 'true' && success()
+ uses: actions/cache/save@v4
+ with:
+ path: app/android/app/build/outputs/apk/debug/app-debug.apk
+ key: ${{ steps.apk-cache.outputs.cache-primary-key }}
+
- name: Clean up Gradle build artifacts
+ if: steps.apk-cache.outputs.cache-hit != 'true'
uses: ./.github/actions/cleanup-gradle-artifacts
- name: Verify APK and android-passport-nfc-reader integration
env:
diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle
index 924a684c8..026c84d7a 100644
--- a/app/android/app/build.gradle
+++ b/app/android/app/build.gradle
@@ -132,8 +132,8 @@ android {
applicationId "com.proofofpassportapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 149
- versionName "2.9.18"
+ versionCode 150
+ versionName "2.9.19"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {
diff --git a/app/app.json b/app/app.json
index cc9938042..b656e89df 100644
--- a/app/app.json
+++ b/app/app.json
@@ -4,7 +4,7 @@
"expo": {
"name": "Self App",
"slug": "self-app",
- "version": "2.9.18",
+ "version": "2.9.19",
"platforms": ["ios", "android"],
"ios": {
"bundleIdentifier": "com.proofofpassportapp"
diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist
index 7cdad8669..e3b1b9740 100644
--- a/app/ios/OpenPassport/Info.plist
+++ b/app/ios/OpenPassport/Info.plist
@@ -21,7 +21,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.9.18
+ 2.9.19
CFBundleSignature
????
CFBundleURLTypes
diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj
index 22ec9580d..96021982f 100644
--- a/app/ios/Self.xcodeproj/project.pbxproj
+++ b/app/ios/Self.xcodeproj/project.pbxproj
@@ -477,7 +477,7 @@
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassportDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 218;
+ CURRENT_PROJECT_VERSION = 219;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_APP_SANDBOX = NO;
ENABLE_BITCODE = NO;
@@ -593,7 +593,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
- MARKETING_VERSION = 2.9.18;
+ MARKETING_VERSION = 2.9.19;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -620,7 +620,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = OpenPassport/OpenPassport.entitlements;
- CURRENT_PROJECT_VERSION = 218;
+ CURRENT_PROJECT_VERSION = 219;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_APP_SANDBOX = NO;
ENABLE_RESOURCE_ACCESS_CAMERA = YES;
@@ -735,7 +735,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
- MARKETING_VERSION = 2.9.18;
+ MARKETING_VERSION = 2.9.19;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/app/package.json b/app/package.json
index 6391d2e3e..640037fb1 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
- "version": "2.9.18",
+ "version": "2.9.19",
"private": true,
"type": "module",
"scripts": {
diff --git a/app/src/components/support/SupportUuidRow.tsx b/app/src/components/support/SupportUuidRow.tsx
index 79f3dbcd2..2b720d4e0 100644
--- a/app/src/components/support/SupportUuidRow.tsx
+++ b/app/src/components/support/SupportUuidRow.tsx
@@ -26,7 +26,7 @@ const SupportUuidRow: React.FC = ({
title = 'Diagnostic ID',
}) => {
const [expanded, setExpanded] = useState(!collapsedByDefault);
- const { supportUuid, copy } = useSupportUuid();
+ const { isEnabled, supportUuid, copy } = useSupportUuid();
const diagnosticIdText = supportUuid ?? 'Loading diagnostic ID...';
const handleCopy = useCallback(() => {
@@ -36,6 +36,10 @@ const SupportUuidRow: React.FC = ({
const toggle = useCallback(() => setExpanded(prev => !prev), []);
+ if (!isEnabled) {
+ return null;
+ }
+
if (!expanded) {
return (
);
}
diff --git a/app/src/config/sentry.ts b/app/src/config/sentry.ts
index 799146919..f2a1b0de5 100644
--- a/app/src/config/sentry.ts
+++ b/app/src/config/sentry.ts
@@ -300,12 +300,16 @@ export const logProofEvent = (
extra?: Record,
) => logEvent(level, 'proof', message, context, extra);
-export const setSupportUuidInSentry = (supportUuid: string | null) => {
+export const setSupportUuidInSentry = (
+ supportUuid: string | null,
+ enabled = true,
+) => {
if (isSentryDisabled) {
return;
}
- setTag('support_uuid', supportUuid ?? 'unset');
+ setTag('support_uuid_enabled', enabled ? 'true' : 'false');
+ setTag('support_uuid', enabled ? (supportUuid ?? 'unset') : 'disabled');
};
export const wrapWithSentry = (App: React.ComponentType) => {
diff --git a/app/src/config/sentry.web.ts b/app/src/config/sentry.web.ts
index 5156a0ef7..3f0e738dc 100644
--- a/app/src/config/sentry.web.ts
+++ b/app/src/config/sentry.web.ts
@@ -10,6 +10,7 @@ import {
captureMessage as sentryCaptureMessage,
feedbackIntegration,
init as sentryInit,
+ setTag,
withProfiler,
withScope,
} from '@sentry/react';
@@ -267,7 +268,16 @@ export const logProofEvent = (
extra?: Record,
) => logEvent(level, 'proof', message, context, extra);
-export const setSupportUuidInSentry = (_supportUuid: string | null) => {};
+export const setSupportUuidInSentry = (
+ supportUuid: string | null,
+ enabled = true,
+) => {
+ if (isSentryDisabled) {
+ return;
+ }
+ setTag('support_uuid_enabled', enabled ? 'true' : 'false');
+ setTag('support_uuid', enabled ? (supportUuid ?? 'unset') : 'disabled');
+};
export const wrapWithSentry = (App: React.ComponentType) => {
return isSentryDisabled ? App : withProfiler(App);
diff --git a/app/src/hooks/useSupportUuid.ts b/app/src/hooks/useSupportUuid.ts
index 9112817e3..fb11aedf9 100644
--- a/app/src/hooks/useSupportUuid.ts
+++ b/app/src/hooks/useSupportUuid.ts
@@ -8,27 +8,33 @@ import {
copySupportUuid,
getSupportUuid,
regenerateSupportUuid,
+ setSupportUuidCollectionEnabled,
} from '@/services/supportUuid';
import { useSettingStore } from '@/stores/settingStore';
export interface UseSupportUuidResult {
+ isEnabled: boolean;
supportUuid: string | null;
isReady: boolean;
- copy: () => string;
- regenerate: () => string;
+ copy: () => string | null;
+ regenerate: () => string | null;
+ setEnabled: (enabled: boolean) => string | null;
}
export function useSupportUuid(): UseSupportUuidResult {
+ const supportUuidEnabled = useSettingStore(state => state.supportUuidEnabled);
const supportUuid = useSettingStore(state => state.supportUuid);
useEffect(() => {
- if (!supportUuid) getSupportUuid();
- }, [supportUuid]);
+ if (supportUuidEnabled && !supportUuid) getSupportUuid();
+ }, [supportUuidEnabled, supportUuid]);
return {
+ isEnabled: supportUuidEnabled,
supportUuid,
- isReady: supportUuid != null,
+ isReady: !supportUuidEnabled || supportUuid != null,
copy: copySupportUuid,
regenerate: regenerateSupportUuid,
+ setEnabled: setSupportUuidCollectionEnabled,
};
}
diff --git a/app/src/navigation/devTools.tsx b/app/src/navigation/devTools.tsx
index dafe7b612..a75eaaac5 100644
--- a/app/src/navigation/devTools.tsx
+++ b/app/src/navigation/devTools.tsx
@@ -14,6 +14,7 @@ import DevLoadingScreen from '@/screens/dev/DevLoadingScreen';
import DevPrivateKeyScreen from '@/screens/dev/DevPrivateKeyScreen';
import DevSettingsScreen from '@/screens/dev/DevSettingsScreen';
import SocialLoginDemoScreen from '@/screens/dev/SocialLoginDemoScreen';
+import TroubleshootingScreen from '@/screens/dev/TroubleshootingScreen';
const devHeaderOptions: NativeStackNavigationOptions = {
headerStyle: {
@@ -89,6 +90,13 @@ const devScreens = {
title: 'Social Login Demo',
} as NativeStackNavigationOptions,
},
+ Troubleshooting: {
+ screen: TroubleshootingScreen,
+ options: {
+ ...devHeaderOptions,
+ title: 'Troubleshooting',
+ } as NativeStackNavigationOptions,
+ },
};
export default devScreens;
diff --git a/app/src/screens/account/settings/SettingsScreen.tsx b/app/src/screens/account/settings/SettingsScreen.tsx
index 3c50f3cee..904ed37aa 100644
--- a/app/src/screens/account/settings/SettingsScreen.tsx
+++ b/app/src/screens/account/settings/SettingsScreen.tsx
@@ -3,7 +3,7 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import type { PropsWithChildren } from 'react';
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { Linking, Platform, Share, View as RNView } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -11,7 +11,7 @@ import type { SvgProps } from 'react-native-svg';
import { Button, ScrollView, View, XStack, YStack } from 'tamagui';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
-import { Bug, FileText, Settings2 } from '@tamagui/lucide-icons';
+import { Bug, FileText, Settings2, Wrench } from '@tamagui/lucide-icons';
import { BodyText, pressedStyle } from '@selfxyz/mobile-sdk-alpha/components';
import {
@@ -80,6 +80,7 @@ const ROUTE_ICONS: Record> = {
Support: Feedback,
share: ShareIcon,
DevSettings: Bug as React.FC,
+ Troubleshooting: Wrench as React.FC,
};
const social = [
@@ -133,6 +134,7 @@ const SocialButton: React.FC = ({
const SettingsScreen: React.FC = () => {
const { isDevMode, setDevModeOn } = useSettingStore();
+ const [isTroubleshootingMode, setTroubleshootingMode] = useState(false);
const navigation =
useNavigation>();
const { hasRealDocument } = useHasRealDocument('SettingsScreen');
@@ -147,16 +149,25 @@ const SettingsScreen: React.FC = () => {
platform: CURRENT_PLATFORM,
hasRealDocument: hasRealDocument === true,
isDevMode,
+ isTroubleshootingMode,
}),
- [hasRealDocument, isDevMode],
+ [hasRealDocument, isDevMode, isTroubleshootingMode],
);
+ const troubleshootingTap = Gesture.Tap()
+ .numberOfTaps(3)
+ .onStart(() => {
+ setTroubleshootingMode(true);
+ });
+
const devModeTap = Gesture.Tap()
.numberOfTaps(5)
.onStart(() => {
setDevModeOn();
});
+ const combinedTap = Gesture.Exclusive(devModeTap, troubleshootingTap);
+
const onMenuPress = useCallback(
(menuRoute: SettingsRouteKey) => {
return async () => {
@@ -180,7 +191,7 @@ const SettingsScreen: React.FC = () => {
);
const { bottom } = useSafeAreaInsets();
return (
-
+
{
- const { supportUuid, copy, regenerate } = useSupportUuid();
+ const { isEnabled, supportUuid, copy, regenerate, setEnabled } =
+ useSupportUuid();
const openSupportForm = useOpenSupportForm();
const diagnosticIdText = supportUuid ?? 'Loading diagnostic ID...';
@@ -31,7 +34,7 @@ const SupportScreen: React.FC = () => {
const handleRegenerate = useCallback(() => {
Alert.alert(
'Regenerate diagnostic ID?',
- 'This will immediately replace the current ID for future support diagnostics.',
+ "Use this if you've shared your ID publicly or want a fresh one for a new issue. Future support requests will use the new ID.",
[
{ text: 'Cancel', style: 'cancel' },
{
@@ -39,13 +42,20 @@ const SupportScreen: React.FC = () => {
style: 'destructive',
onPress: () => {
regenerate();
- Alert.alert('Updated', 'Diagnostic ID regenerated successfully.');
+ Alert.alert('Updated', 'Your diagnostic ID has been replaced.');
},
},
],
);
}, [regenerate]);
+ const handleSupportUuidToggle = useCallback(
+ (enabled: boolean) => {
+ setEnabled(enabled);
+ },
+ [setEnabled],
+ );
+
return (
@@ -59,49 +69,97 @@ const SupportScreen: React.FC = () => {
- Share the diagnostic ID below when contacting support so we can
- locate your logs.
+ A diagnostic ID helps our team find the activity related to your
+ report. It's not tied to your identity.
-
-
- Diagnostic ID
-
-
- {diagnosticIdText}
-
-
+
+
+
+ Share diagnostic ID
+
+
+ {isEnabled
+ ? 'Share the diagnostic ID below with support so we can find the activity related to your report. Turn this off to keep it out of support requests and error screens.'
+ : 'Diagnostic ID is off. Turn it on to include it in future support requests.'}
+
+
+
+
-
+ {isEnabled ? (
+ <>
+
+
+ Diagnostic ID
+
+
+ {diagnosticIdText}
+
+
-
+
+
+
+ >
+ ) : null}
);
};
+const styles = StyleSheet.create({
+ settingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 16,
+ },
+ settingTextContainer: {
+ flex: 1,
+ gap: 4,
+ },
+ settingLabel: {
+ fontSize: 16,
+ fontFamily: dinot,
+ fontWeight: '500',
+ color: black,
+ },
+ settingDescription: {
+ fontSize: 14,
+ fontFamily: dinot,
+ color: slate500,
+ },
+});
+
export default SupportScreen;
diff --git a/app/src/screens/account/settings/settingsMenu.ts b/app/src/screens/account/settings/settingsMenu.ts
index e27bc4af4..d71586c74 100644
--- a/app/src/screens/account/settings/settingsMenu.ts
+++ b/app/src/screens/account/settings/settingsMenu.ts
@@ -11,6 +11,7 @@ export type SettingsGatingContext = {
platform: SettingsPlatform;
hasRealDocument: boolean;
isDevMode: boolean;
+ isTroubleshootingMode: boolean;
};
export type SettingsPlatform = 'ios' | 'android' | 'web';
@@ -21,7 +22,8 @@ export type SettingsRouteKey =
| 'ProofSettings'
| 'Support'
| 'share'
- | 'DevSettings';
+ | 'DevSettings'
+ | 'Troubleshooting';
export const DEBUG_SETTINGS_ENTRY: SettingsEntry = {
label: 'Debug menu',
@@ -42,6 +44,11 @@ export const SETTINGS_ENTRIES_WEB: readonly SettingsEntry[] = [
{ label: 'Get support', route: 'Support' },
];
+export const TROUBLESHOOTING_ENTRY: SettingsEntry = {
+ label: 'Troubleshooting',
+ route: 'Troubleshooting',
+};
+
export const baseEntriesForPlatform = (
platform: SettingsPlatform,
): readonly SettingsEntry[] =>
@@ -55,10 +62,14 @@ export const baseEntriesForPlatform = (
export const buildSettingsMenu = (
context: SettingsGatingContext,
): SettingsEntry[] => {
- const { platform, isDevMode } = context;
+ const { platform, isDevMode, isTroubleshootingMode } = context;
const base = baseEntriesForPlatform(platform);
- const withDebug = isDevMode ? [...base, DEBUG_SETTINGS_ENTRY] : base;
- return withDebug.filter(entry => shouldShowSettingsEntry(entry, context));
+ const entries = [
+ ...base,
+ ...(isTroubleshootingMode || isDevMode ? [TROUBLESHOOTING_ENTRY] : []),
+ ...(isDevMode ? [DEBUG_SETTINGS_ENTRY] : []),
+ ];
+ return entries.filter(entry => shouldShowSettingsEntry(entry, context));
};
export const shouldShowSettingsEntry = (
diff --git a/app/src/screens/dev/TroubleshootingScreen.tsx b/app/src/screens/dev/TroubleshootingScreen.tsx
new file mode 100644
index 000000000..93baf288e
--- /dev/null
+++ b/app/src/screens/dev/TroubleshootingScreen.tsx
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import { poseidon2 } from 'poseidon-lite';
+import React, { useState } from 'react';
+import { Button, H4, Paragraph, Spinner, Text, YStack } from 'tamagui';
+
+import { hashEndpointWithScope } from '@selfxyz/common/utils/scope';
+import {
+ black,
+ red500,
+ slate200,
+ slate500,
+ teal500,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
+
+import { unsafe_getPrivateKey } from '@/providers/authProvider';
+import { POINTS_API_BASE_URL } from '@/services/points/constants';
+import { getPointsAddress } from '@/services/points/utils';
+
+const POINTS_ENDPOINT = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0';
+const POINTS_SCOPE = 'minimal-disclosure-quest';
+
+const TroubleshootingScreen: React.FC = () => {
+ const [status, setStatus] = useState<
+ 'idle' | 'loading' | 'success' | 'error'
+ >('idle');
+ const [message, setMessage] = useState('');
+
+ const handleFix = async () => {
+ setStatus('loading');
+ setMessage('');
+
+ try {
+ const secret = await unsafe_getPrivateKey();
+ if (!secret) {
+ setStatus('error');
+ setMessage(
+ 'Could not retrieve secret. Biometric auth may have failed.',
+ );
+ return;
+ }
+
+ const scopeHash = hashEndpointWithScope(POINTS_ENDPOINT, POINTS_SCOPE);
+ const nullifier = poseidon2([
+ BigInt(secret),
+ BigInt(scopeHash),
+ ]).toString();
+ const userAddress = await getPointsAddress();
+
+ const response = await fetch(
+ `${POINTS_API_BASE_URL}/points-disclose-fix`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ nullifier,
+ points_address: userAddress.toLowerCase(),
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => null);
+ setStatus('error');
+ setMessage(data?.message ?? `Request failed (${response.status})`);
+ return;
+ }
+
+ setStatus('success');
+ setMessage('Disclosure status fixed successfully.');
+ } catch (error) {
+ setStatus('error');
+ setMessage(
+ error instanceof Error
+ ? error.message
+ : 'An unexpected error occurred.',
+ );
+ }
+ };
+
+ return (
+
+
+ Fix points disclosure
+
+ If your points haven't updated after a successful verification, tap
+ below to repair your disclosure state. This is safe to run more than
+ once.
+
+
+
+
+
+ {message !== '' && (
+
+ {message}
+
+ )}
+
+ );
+};
+
+export default TroubleshootingScreen;
diff --git a/app/src/screens/verification/ProofRequestStatusScreen.tsx b/app/src/screens/verification/ProofRequestStatusScreen.tsx
index f8d5e327b..bac4fca96 100644
--- a/app/src/screens/verification/ProofRequestStatusScreen.tsx
+++ b/app/src/screens/verification/ProofRequestStatusScreen.tsx
@@ -4,7 +4,7 @@
import type { LottieViewProps } from 'lottie-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { Linking, StyleSheet, View } from 'react-native';
+import { Linking, Platform, StyleSheet, Text, View } from 'react-native';
import { ScrollView, Spinner } from 'tamagui';
import { useIsFocused, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -19,10 +19,16 @@ import {
typography,
} from '@selfxyz/mobile-sdk-alpha/components';
import { ProofEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
-import { black, white } from '@selfxyz/mobile-sdk-alpha/constants/colors';
+import {
+ black,
+ slate200,
+ slate500,
+ white,
+} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import failAnimation from '@/assets/animations/proof_failed.json';
import succesAnimation from '@/assets/animations/proof_success.json';
+import { captureException } from '@/config/sentry';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import {
buttonTap,
@@ -40,6 +46,22 @@ import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
const PREREQ_CHECK_TIMEOUT_MS = 3000;
+// Give each active proving state a generous 90s window before exposing an
+// escape hatch. This avoids trapping users on silent stalls without cutting off
+// normal slow-device proving too aggressively.
+const PROVING_STALL_TIMEOUT_MS = 90_000;
+const PROOF_TIMEOUT_ERROR_CODE = 'proof_timeout';
+const PROOF_TIMEOUT_REASON = 'timed_out_after_90s';
+const STALL_TIMEOUT_STATES = new Set([
+ 'parsing_id_document',
+ 'fetching_data',
+ 'validating_document',
+ 'init_tee_connexion',
+ 'ready_to_prove',
+ 'listening_for_status',
+ 'proving',
+ 'post_proving',
+]);
const SuccessScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -64,17 +86,35 @@ const SuccessScreen: React.FC = () => {
useState(loadingAnimation);
const [countdown, setCountdown] = useState(null);
const [countdownStarted, setCountdownStarted] = useState(false);
+ const [hasTimedOut, setHasTimedOut] = useState(false);
+ const [isDismissingTimedOutProof, setIsDismissingTimedOutProof] =
+ useState(false);
const [whitelistedPoints, setWhitelistedPoints] = useState<
number | null | undefined
>(undefined);
const timerRef = useRef(null);
+ const provingTimeoutRef = useRef(null);
+ const timedOutSessionIdRef = useRef(null);
+ const dismissingTimedOutProofRef = useRef(false);
+ const timedOutAnalyticsTrackedRef = useRef(false);
+
+ const displayState = hasTimedOut ? 'failure' : currentState;
+ const displayReason = hasTimedOut
+ ? PROOF_TIMEOUT_REASON
+ : (reason ?? undefined);
const onOkPress = useCallback(async () => {
- if (whitelistedPoints === undefined) return;
+ // The whitelistedPoints guard only applies to the success path — on
+ // failure/error it is never populated, and blocking would trap the user.
+ if (currentState === 'completed' && whitelistedPoints === undefined) return;
buttonTap();
const completedSessionId = sessionId;
const cleanupLater = () => {
+ // If completedSessionId is null (early failure before TEE negotiation),
+ // skip the delayed cleanup: a retry started in the next 2s would also
+ // have a null uuid and get wiped by cleanSelfApp.
+ if (completedSessionId === null) return;
setTimeout(() => {
if (useProvingStore.getState().uuid === completedSessionId) {
selfClient.getSelfAppState().cleanSelfApp();
@@ -82,7 +122,7 @@ const SuccessScreen: React.FC = () => {
}, 2000);
};
- if (whitelistedPoints !== null) {
+ if (currentState === 'completed' && whitelistedPoints !== null) {
// Bound the prereq checks so a stalled network call can't trap the user
// on this screen. On timeout we fall through to goHome() — the safe
// default, since Gratification would just bounce them via the guardrail.
@@ -106,6 +146,7 @@ const SuccessScreen: React.FC = () => {
goHome();
cleanupLater();
}, [
+ currentState,
whitelistedPoints,
navigation,
goHome,
@@ -114,6 +155,41 @@ const SuccessScreen: React.FC = () => {
useProvingStore,
]);
+ const clearProvingTimeout = useCallback(() => {
+ if (provingTimeoutRef.current) {
+ clearTimeout(provingTimeoutRef.current);
+ provingTimeoutRef.current = null;
+ }
+ }, []);
+
+ const onTimedOutDismiss = useCallback(async () => {
+ if (dismissingTimedOutProofRef.current) {
+ return;
+ }
+
+ dismissingTimedOutProofRef.current = true;
+ setIsDismissingTimedOutProof(true);
+ buttonTap();
+
+ try {
+ await useProvingStore.getState().cancel(selfClient);
+ } catch (error) {
+ captureException(
+ error instanceof Error ? error : new Error(String(error)),
+ {
+ module: 'proof-request-status-screen',
+ action: 'dismiss_timed_out_proof',
+ sessionId: timedOutSessionIdRef.current,
+ },
+ );
+ } finally {
+ dismissingTimedOutProofRef.current = false;
+ setIsDismissingTimedOutProof(false);
+ selfClient.getSelfAppState().cleanSelfApp();
+ goHome();
+ }
+ }, [goHome, selfClient, useProvingStore]);
+
function cancelDeeplinkCallbackRedirect() {
setCountdown(null);
}
@@ -126,6 +202,13 @@ const SuccessScreen: React.FC = () => {
setCountdown(null);
}
+ useEffect(() => {
+ setHasTimedOut(false);
+ timedOutAnalyticsTrackedRef.current = false;
+ setCountdownStarted(false);
+ setCountdown(null);
+ }, [sessionId]);
+
useEffect(() => {
if (currentState !== 'completed') return;
@@ -154,10 +237,38 @@ const SuccessScreen: React.FC = () => {
}, [currentState, selfApp?.endpoint]);
useEffect(() => {
- if (currentState === 'completed') {
+ if (hasTimedOut) {
+ setAnimationSource(failAnimation);
+
+ if (!timedOutAnalyticsTrackedRef.current) {
+ timedOutAnalyticsTrackedRef.current = true;
+ notificationError();
+ // sessionId (uuid) is only assigned once TEE negotiation starts, so
+ // pre-TEE stall states (parsing_id_document, fetching_data, etc.)
+ // have no uuid to attach to proof history.
+ if (sessionId) {
+ updateProofStatus(
+ sessionId,
+ ProofStatus.FAILURE,
+ PROOF_TIMEOUT_ERROR_CODE,
+ PROOF_TIMEOUT_REASON,
+ );
+ }
+ trackEvent(ProofEvents.PROOF_FAILED, {
+ sessionId,
+ appName,
+ errorCode: PROOF_TIMEOUT_ERROR_CODE,
+ reason: PROOF_TIMEOUT_REASON,
+ state: 'timeout',
+ });
+ }
+ } else if (currentState === 'completed') {
+ timedOutAnalyticsTrackedRef.current = false;
notificationSuccess();
setAnimationSource(succesAnimation);
- updateProofStatus(sessionId!, ProofStatus.SUCCESS);
+ if (sessionId) {
+ updateProofStatus(sessionId, ProofStatus.SUCCESS);
+ }
trackEvent(ProofEvents.PROOF_COMPLETED, {
sessionId,
appName,
@@ -177,14 +288,17 @@ const SuccessScreen: React.FC = () => {
}
}
} else if (currentState === 'failure' || currentState === 'error') {
+ timedOutAnalyticsTrackedRef.current = false;
notificationError();
setAnimationSource(failAnimation);
- updateProofStatus(
- sessionId!,
- ProofStatus.FAILURE,
- errorCode ?? undefined,
- reason ?? undefined,
- );
+ if (sessionId) {
+ updateProofStatus(
+ sessionId,
+ ProofStatus.FAILURE,
+ errorCode ?? undefined,
+ reason ?? undefined,
+ );
+ }
trackEvent(ProofEvents.PROOF_FAILED, {
sessionId,
appName,
@@ -193,9 +307,11 @@ const SuccessScreen: React.FC = () => {
state: currentState,
});
} else {
+ timedOutAnalyticsTrackedRef.current = false;
setAnimationSource(loadingAnimation);
}
}, [
+ hasTimedOut,
trackEvent,
currentState,
isFocused,
@@ -208,6 +324,28 @@ const SuccessScreen: React.FC = () => {
countdownStarted,
]);
+ useEffect(() => {
+ if (hasTimedOut) {
+ clearProvingTimeout();
+ return;
+ }
+
+ if (!isFocused || !STALL_TIMEOUT_STATES.has(currentState)) {
+ clearProvingTimeout();
+ return;
+ }
+
+ clearProvingTimeout();
+ provingTimeoutRef.current = setTimeout(() => {
+ timedOutSessionIdRef.current = sessionId;
+ setHasTimedOut(true);
+ }, PROVING_STALL_TIMEOUT_MS);
+
+ return () => {
+ clearProvingTimeout();
+ };
+ }, [clearProvingTimeout, currentState, hasTimedOut, isFocused, sessionId]);
+
useEffect(() => {
if (countdown === null) return;
if (countdown > 0) {
@@ -236,8 +374,9 @@ const SuccessScreen: React.FC = () => {
}
return () => {
cancelCountdown();
+ clearProvingTimeout();
};
- }, [isFocused]);
+ }, [clearProvingTimeout, isFocused]);
return (
@@ -262,11 +401,14 @@ const SuccessScreen: React.FC = () => {
backgroundColor={white}
>
- {getTitle(currentState)}
+ {getTitle(displayState)}
{
0))
}
onPress={
- countdown !== null && countdown > 0
- ? cancelDeeplinkCallbackRedirect
- : onOkPress
+ hasTimedOut
+ ? onTimedOutDismiss
+ : countdown !== null && countdown > 0
+ ? cancelDeeplinkCallbackRedirect
+ : onOkPress
}
>
- {currentState !== 'completed' &&
- currentState !== 'error' &&
- currentState !== 'failure' ? (
+ {isDismissingTimedOutProof ? (
+
+ ) : displayState === 'failure' || displayState === 'error' ? (
+ 'Dismiss'
+ ) : displayState !== 'completed' ? (
) : countdown !== null && countdown > 0 ? (
'Cancel'
- ) : whitelistedPoints === undefined ? (
+ ) : currentState === 'completed' &&
+ whitelistedPoints === undefined ? (
) : (
'OK'
@@ -319,16 +467,45 @@ function getTitle(currentState: string) {
}
}
+// Maps low-level proving errors to actionable, user-facing guidance.
+// Raw `reason` is still shown below in a scrollable details box for support.
+function getUserFacingErrorMessage(
+ currentState: string,
+ reason: string | undefined,
+ errorCode: string | undefined,
+ appName: string,
+): string {
+ if (
+ reason === PROOF_TIMEOUT_REASON ||
+ errorCode === PROOF_TIMEOUT_ERROR_CODE
+ ) {
+ return `The proof request from ${appName} took too long to finish. Please try again, or refresh the QR code in ${appName} and scan again.`;
+ }
+ if (currentState === 'error') {
+ return `Unable to prove your identity to ${appName} due to a technical issue. Please try again.`;
+ }
+ const invalidRootPattern = /InvalidRoot/i;
+ if (
+ (reason && invalidRootPattern.test(reason)) ||
+ (errorCode && invalidRootPattern.test(errorCode))
+ ) {
+ return `The QR code from ${appName} is out of date. Please refresh it in ${appName} and scan again.`;
+ }
+ return `Unable to prove your identity to ${appName}. Please try again, or contact support if the issue persists.`;
+}
+
function Info({
currentState,
appName,
reason,
+ errorCode,
countdown,
deeplinkCallback,
}: {
currentState: string;
appName: string;
reason?: string;
+ errorCode?: string;
countdown?: number | null;
deeplinkCallback?: string;
}) {
@@ -360,30 +537,30 @@ function Info({
);
} else if (currentState === 'error' || currentState === 'failure') {
+ const userMessage = getUserFacingErrorMessage(
+ currentState,
+ reason,
+ errorCode,
+ appName,
+ );
return (
-
-
- Unable to prove your identity to{' '}
- {appName}
- {currentState === 'error' && '. Due to technical issues.'}
-
+
+ {userMessage}
{currentState === 'failure' && reason && (
- <>
-
-
- Reason:
-
-
-
-
-
-
- {reason}
-
-
-
-
- >
+
+
+ Details
+
+
+
+ {reason}
+
+
+
)}
);
@@ -415,4 +592,25 @@ export const styles = StyleSheet.create({
marginBottom: 20,
gap: 10,
},
+ reasonBox: {
+ alignSelf: 'stretch',
+ borderWidth: 1,
+ borderColor: slate200,
+ borderRadius: 8,
+ padding: 12,
+ gap: 6,
+ },
+ reasonLabel: {
+ fontSize: 12,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ reasonScroll: {
+ maxHeight: 120,
+ },
+ reasonText: {
+ color: slate500,
+ fontSize: 12,
+ fontFamily: Platform.select({ ios: 'Courier', android: 'monospace' }),
+ },
});
diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx
index 799c4f19f..097934719 100644
--- a/app/src/screens/verification/ProveScreen.tsx
+++ b/app/src/screens/verification/ProveScreen.tsx
@@ -39,6 +39,7 @@ import {
truncateAddress,
WalletAddressModal,
} from '@/components/proof-request';
+import { captureMessage } from '@/config/sentry';
import { useSelfAppData } from '@/hooks/useSelfAppData';
import { buttonTap } from '@/integrations/haptics';
import type { RootStackParamList } from '@/navigation';
@@ -49,6 +50,7 @@ import {
import {
getPointsAddress,
getWhiteListedDisclosureAddresses,
+ NULLIFIER_ALREADY_USED_ERROR_PREFIX,
} from '@/services/points';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
@@ -102,6 +104,7 @@ const ProveScreen: React.FC = () => {
);
const provingStore = useProvingStore();
const currentState = useProvingStore(state => state.currentState);
+ const reason = useProvingStore(state => state.reason);
const isReadyToProve = currentState === 'ready_to_prove';
// Use window dimensions for dynamic scroll offset padding
@@ -299,6 +302,40 @@ const ProveScreen: React.FC = () => {
!isExpired &&
selectedAppRef.current?.sessionId !== selectedApp.sessionId
) {
+ // Set selfDefinedData before init so the proving machine has the address
+ if (
+ !selectedApp.selfDefinedData &&
+ !processedSessionsRef.current.has(selectedApp.sessionId)
+ ) {
+ try {
+ const [address, whitelistedAddresses] = await Promise.all([
+ getPointsAddress(),
+ getWhiteListedDisclosureAddresses(),
+ ]);
+
+ const isWhitelisted = whitelistedAddresses.some(
+ contract =>
+ contract.contract_address.toLowerCase() ===
+ selectedApp.endpoint?.toLowerCase(),
+ );
+
+ if (isWhitelisted) {
+ console.log(
+ 'enhancing app with whitelisted points address',
+ address,
+ );
+ selfClient.getSelfAppState().setSelfApp({
+ ...selectedApp,
+ selfDefinedData: address.toLowerCase(),
+ });
+ }
+
+ processedSessionsRef.current.add(selectedApp.sessionId);
+ } catch (error) {
+ console.error('Failed enhancing app with points address:', error);
+ }
+ }
+
provingStore.init(selfClient, 'disclose');
}
selectedAppRef.current = selectedApp;
@@ -315,57 +352,53 @@ const ProveScreen: React.FC = () => {
hasCheckedForInactiveDocument,
]);
- // Enhance selfApp with user's points address if not already set
+ // Track "already disclosed" failures for points disclosures so we can fix
+ // the backend state for affected users.
useEffect(() => {
- console.log('useEffect selectedApp', selectedApp);
if (
- !selectedApp ||
- selectedApp.selfDefinedData ||
- !hasCheckedForInactiveDocument
+ currentState !== 'failure' ||
+ !(
+ reason?.includes(NULLIFIER_ALREADY_USED_ERROR_PREFIX) ||
+ reason?.includes('NullifierAlreadyUsed')
+ )
) {
return;
}
- const sessionId = selectedApp.sessionId;
-
- if (processedSessionsRef.current.has(sessionId)) {
- return;
- }
-
- const enhanceApp = async () => {
- const currentSessionId = sessionId;
-
+ const trackAlreadyDisclosed = async () => {
try {
- const address = await getPointsAddress();
const whitelistedAddresses = await getWhiteListedDisclosureAddresses();
-
- const isWhitelisted = whitelistedAddresses.some(
+ const isPointsDisclosure = whitelistedAddresses.some(
contract =>
- contract.contract_address.toLowerCase() === address.toLowerCase(),
+ contract.contract_address.toLowerCase() ===
+ selectedApp?.endpoint?.toLowerCase(),
);
- const currentApp = selfClient.getSelfAppState().selfApp;
- if (currentApp?.sessionId === currentSessionId) {
- if (isWhitelisted) {
- console.log(
- 'enhancing app with whitelisted points address',
- address,
- );
- selfClient.getSelfAppState().setSelfApp({
- ...currentApp,
- selfDefinedData: address.toLowerCase(),
- });
- }
+ if (!isPointsDisclosure) {
+ return;
}
- processedSessionsRef.current.add(currentSessionId);
+ const pointsAddress =
+ selectedApp?.selfDefinedData || (await getPointsAddress());
+
+ trackEvent(ProofEvents.POINTS_NULLIFIER_ALREADY_USED, {
+ pointsAddress,
+ endpoint: selectedApp?.endpoint,
+ sessionId: provingStore.uuid,
+ });
+
+ captureMessage('Points disclosure already registered on-chain', {
+ pointsAddress,
+ endpoint: selectedApp?.endpoint,
+ sessionId: provingStore.uuid,
+ });
} catch (error) {
- console.error('Failed enhancing app:', error);
+ console.error('Failed tracking NullifierAlreadyUsed event:', error);
}
};
- enhanceApp();
- }, [selectedApp, selfClient, hasCheckedForInactiveDocument]);
+ trackAlreadyDisclosed();
+ }, [currentState, reason, selectedApp, provingStore.uuid, trackEvent]);
function onVerify() {
buttonTap();
diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts
index 4b867175b..063096b19 100644
--- a/app/src/services/analytics.ts
+++ b/app/src/services/analytics.ts
@@ -231,22 +231,14 @@ const identifyInSegment = (nextSupportUuid: string) => {
});
};
-export const resetAnalyticsIdentityForSupportUuid = (
- nextSupportUuid: string,
-) => {
- supportUuid = nextSupportUuid;
-
- if (segmentClient) {
- segmentClient
- .reset()
- .catch(err => {
- if (__DEV__) console.warn('Failed to reset Segment identity:', err);
- })
- .then(() => identifyInSegment(nextSupportUuid));
- } else {
- identifyInSegment(nextSupportUuid);
- }
+const resetSegmentIdentity = () => {
+ if (!segmentClient) return Promise.resolve();
+ return segmentClient.reset().catch(err => {
+ if (__DEV__) console.warn('Failed to reset Segment identity:', err);
+ });
+};
+const resetMixpanelIdentity = () => {
if (PassportReader && typeof PassportReader.resetIdentity === 'function') {
try {
PassportReader.resetIdentity();
@@ -256,7 +248,9 @@ export const resetAnalyticsIdentityForSupportUuid = (
}
}
}
+};
+const setMixpanelDistinctId = (nextSupportUuid: string) => {
if (PassportReader && typeof PassportReader.setDistinctId === 'function') {
try {
PassportReader.setDistinctId(nextSupportUuid);
@@ -268,20 +262,37 @@ export const resetAnalyticsIdentityForSupportUuid = (
}
};
-export const setAnalyticsSupportUuid = (nextSupportUuid: string) => {
+export const resetAnalyticsIdentityForSupportUuid = (
+ nextSupportUuid: string,
+) => {
supportUuid = nextSupportUuid;
- identifyInSegment(nextSupportUuid);
+ if (segmentClient) {
+ resetSegmentIdentity().then(() => identifyInSegment(nextSupportUuid));
+ } else {
+ identifyInSegment(nextSupportUuid);
+ }
- if (PassportReader && typeof PassportReader.setDistinctId === 'function') {
- try {
- PassportReader.setDistinctId(nextSupportUuid);
- } catch (error) {
- if (__DEV__) {
- console.warn('Failed to set Mixpanel distinct_id:', error);
+ resetMixpanelIdentity();
+ setMixpanelDistinctId(nextSupportUuid);
+};
+
+export const setAnalyticsSupportUuid = (nextSupportUuid: string | null) => {
+ supportUuid = nextSupportUuid;
+
+ if (!nextSupportUuid) {
+ for (const evt of eventQueue) {
+ if (evt.properties) {
+ delete (evt.properties as Record).support_uuid;
}
}
+ resetSegmentIdentity();
+ resetMixpanelIdentity();
+ return;
}
+
+ identifyInSegment(nextSupportUuid);
+ setMixpanelDistinctId(nextSupportUuid);
};
/**
diff --git a/app/src/services/logging/logger/lokiTransport.ts b/app/src/services/logging/logger/lokiTransport.ts
index 446959ede..194c139d2 100644
--- a/app/src/services/logging/logger/lokiTransport.ts
+++ b/app/src/services/logging/logger/lokiTransport.ts
@@ -200,15 +200,23 @@ const lokiTransport: transportFunctionType = props => {
message: string;
timestamp: string;
session_id: string;
- support_uuid: string;
+ support_uuid_enabled: boolean;
+ support_uuid?: string;
data?: unknown;
- } = {
- level: level.text,
- message: actualMessage,
- timestamp,
- session_id: sessionId,
- support_uuid: useSettingStore.getState().supportUuid ?? 'unset',
- };
+ } = (() => {
+ const { supportUuid, supportUuidEnabled } = useSettingStore.getState();
+
+ return {
+ level: level.text,
+ message: actualMessage,
+ timestamp,
+ session_id: sessionId,
+ support_uuid_enabled: supportUuidEnabled,
+ ...(supportUuidEnabled
+ ? { support_uuid: supportUuid ?? 'unset' }
+ : undefined),
+ };
+ })();
if (actualData) {
logObject.data = actualData;
diff --git a/app/src/services/points/index.ts b/app/src/services/points/index.ts
index 40f4578d3..694fd8b9b 100644
--- a/app/src/services/points/index.ts
+++ b/app/src/services/points/index.ts
@@ -8,10 +8,9 @@ export type {
PointEvent,
PointEventType,
} from '@/services/points/types';
-export { POINT_VALUES } from '@/services/points/types';
-
// Re-export all utility functions
export {
+ NULLIFIER_ALREADY_USED_ERROR_PREFIX,
formatTimeUntilDate,
getIncomingPoints,
getNextSundayNoonUTC,
@@ -23,6 +22,8 @@ export {
pointsSelfApp,
} from '@/services/points/utils';
+export { POINT_VALUES } from '@/services/points/types';
+
// Re-export event getter functions
export {
getAllPointEvents,
diff --git a/app/src/services/points/utils.ts b/app/src/services/points/utils.ts
index 6a9942a4a..f1739d737 100644
--- a/app/src/services/points/utils.ts
+++ b/app/src/services/points/utils.ts
@@ -17,6 +17,8 @@ export type WhitelistedContract = {
num_disclosures: number;
};
+export const NULLIFIER_ALREADY_USED_ERROR_PREFIX = '0xdc215c0a';
+
export const formatTimeUntilDate = (targetDate: Date): string => {
const now = new Date();
const diffMs = targetDate.getTime() - now.getTime();
diff --git a/app/src/services/supportUuid.ts b/app/src/services/supportUuid.ts
index 0c98c13fe..d10da260b 100644
--- a/app/src/services/supportUuid.ts
+++ b/app/src/services/supportUuid.ts
@@ -12,8 +12,12 @@ import {
} from '@/services/analytics';
import { useSettingStore } from '@/stores/settingStore';
-const ensureSupportUuid = (): string => {
+const ensureSupportUuid = (): string | null => {
const state = useSettingStore.getState();
+ if (!state.supportUuidEnabled) {
+ return null;
+ }
+
if (state.supportUuid) {
return state.supportUuid;
}
@@ -28,43 +32,75 @@ export const appendSupportUuidToUrl = (url: string): string => {
try {
const parsed = new URL(url);
- parsed.searchParams.set('support_uuid', supportUuid);
+ if (supportUuid) {
+ parsed.searchParams.set('support_uuid', supportUuid);
+ } else {
+ parsed.searchParams.delete('support_uuid');
+ }
return parsed.toString();
} catch {
- // Fallback for malformed URLs / unsupported URL parsing edge-cases.
- // Preserve any fragment by appending the query before `#`.
- const hashIndex = url.indexOf('#');
- const baseUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
- const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
- const separator = baseUrl.includes('?') ? '&' : '?';
- return `${baseUrl}${separator}support_uuid=${encodeURIComponent(supportUuid)}${hash}`;
+ return url;
}
};
-export const copySupportUuid = (): string => {
+export const copySupportUuid = (): string | null => {
const supportUuid = ensureSupportUuid();
+ if (!supportUuid) {
+ return null;
+ }
+
Clipboard.setString(supportUuid);
return supportUuid;
};
-export const getSupportUuid = (): string => {
+export const getSupportUuid = (): string | null => {
return ensureSupportUuid();
};
-export const initializeSupportUuidContext = (): string => {
+export const initializeSupportUuidContext = (): string | null => {
+ const { supportUuidEnabled } = useSettingStore.getState();
const supportUuid = ensureSupportUuid();
- setSupportUuidInSentry(supportUuid);
- setAnalyticsSupportUuid(supportUuid);
+ setSupportUuidInSentry(supportUuid, supportUuidEnabled);
+ setAnalyticsSupportUuid(supportUuidEnabled ? supportUuid : null);
return supportUuid;
};
-export const regenerateSupportUuid = (): string => {
- const nextUuid = uuidv4();
+export const regenerateSupportUuid = (): string | null => {
const state = useSettingStore.getState();
+ if (!state.supportUuidEnabled) {
+ return null;
+ }
+
+ const nextUuid = uuidv4();
state.setSupportUuid(nextUuid);
- setSupportUuidInSentry(nextUuid);
+ setSupportUuidInSentry(nextUuid, true);
resetAnalyticsIdentityForSupportUuid(nextUuid);
return nextUuid;
};
+
+export const setSupportUuidCollectionEnabled = (
+ enabled: boolean,
+): string | null => {
+ const state = useSettingStore.getState();
+
+ if (enabled === state.supportUuidEnabled) {
+ return enabled ? ensureSupportUuid() : null;
+ }
+
+ state.setSupportUuidEnabled(enabled);
+
+ if (!enabled) {
+ state.setSupportUuid(null);
+ setSupportUuidInSentry(null, false);
+ setAnalyticsSupportUuid(null);
+ return null;
+ }
+
+ const nextUuid = uuidv4();
+ state.setSupportUuid(nextUuid);
+ setSupportUuidInSentry(nextUuid, true);
+ resetAnalyticsIdentityForSupportUuid(nextUuid);
+ return nextUuid;
+};
diff --git a/app/src/stores/settingStore.ts b/app/src/stores/settingStore.ts
index 339486ed3..20553f7e3 100644
--- a/app/src/stores/settingStore.ts
+++ b/app/src/stores/settingStore.ts
@@ -23,6 +23,7 @@ interface PersistedSettingsState {
isDevMode: boolean;
loggingSeverity: LoggingSeverity;
pointsAddress: string | null;
+ supportUuidEnabled: boolean;
supportUuid: string | null;
removeSubscribedTopic: (topic: string) => void;
resetBackupForPoints: () => void;
@@ -35,6 +36,7 @@ interface PersistedSettingsState {
setKeychainMigrationCompleted: () => void;
setLoggingSeverity: (severity: LoggingSeverity) => void;
setPointsAddress: (address: string | null) => void;
+ setSupportUuidEnabled: (enabled: boolean) => void;
setSupportUuid: (supportUuid: string | null) => void;
setSkipDocumentSelector: (value: boolean) => void;
setSubscribedTopics: (topics: string[]) => void;
@@ -141,6 +143,9 @@ export const useSettingStore = create()(
setPointsAddress: (address: string | null) =>
set({ pointsAddress: address }),
+ supportUuidEnabled: true,
+ setSupportUuidEnabled: (supportUuidEnabled: boolean) =>
+ set({ supportUuidEnabled }),
supportUuid: null,
setSupportUuid: (supportUuid: string | null) => set({ supportUuid }),
diff --git a/app/tests/src/components/support/SupportUuidRow.test.tsx b/app/tests/src/components/support/SupportUuidRow.test.tsx
new file mode 100644
index 000000000..7364a43d4
--- /dev/null
+++ b/app/tests/src/components/support/SupportUuidRow.test.tsx
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
+// SPDX-License-Identifier: BUSL-1.1
+// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
+
+import React from 'react';
+import { render } from '@testing-library/react-native';
+
+import SupportUuidRow from '@/components/support/SupportUuidRow';
+import { useSupportUuid } from '@/hooks/useSupportUuid';
+
+jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
+ BodyText: ({ children, ...props }: any) => (
+ {children}
+ ),
+}));
+
+jest.mock('@/hooks/useSupportUuid', () => ({
+ useSupportUuid: jest.fn(),
+}));
+
+describe('SupportUuidRow', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('does not render when diagnostic IDs are disabled', () => {
+ (useSupportUuid as jest.Mock).mockReturnValue({
+ isEnabled: false,
+ supportUuid: null,
+ isReady: true,
+ copy: jest.fn(),
+ regenerate: jest.fn(),
+ setEnabled: jest.fn(),
+ });
+
+ const { toJSON } = render();
+
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders the collapsed affordance when diagnostic IDs are enabled', () => {
+ (useSupportUuid as jest.Mock).mockReturnValue({
+ isEnabled: true,
+ supportUuid: '11111111-1111-1111-1111-111111111111',
+ isReady: true,
+ copy: jest.fn(),
+ regenerate: jest.fn(),
+ setEnabled: jest.fn(),
+ });
+
+ const { toJSON } = render();
+
+ expect(JSON.stringify(toJSON())).toContain('Show diagnostic ID');
+ });
+});
diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx
index b2a41e0a4..a5cebe049 100644
--- a/app/tests/src/navigation.test.tsx
+++ b/app/tests/src/navigation.test.tsx
@@ -111,6 +111,7 @@ describe('navigation', () => {
'Splash',
'StarfallPushCode',
'Support',
+ 'Troubleshooting',
'WebView',
]);
});
diff --git a/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx b/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx
index 7f16d9750..0b004fb21 100644
--- a/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx
+++ b/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx
@@ -27,6 +27,7 @@ declare global {
'mock-top': any;
'mock-bottom': any;
'mock-scroll': any;
+ 'mock-rn-text': any;
}
}
}
@@ -36,6 +37,11 @@ jest.mock('react-native', () => ({
Linking: {
openURL: jest.fn(),
},
+ Platform: {
+ OS: 'ios',
+ select: (spec: { ios?: unknown; android?: unknown; default?: unknown }) =>
+ spec.ios ?? spec.default,
+ },
StyleSheet: {
create: (styles: unknown) => styles,
flatten: (style: unknown) => style,
@@ -43,6 +49,9 @@ jest.mock('react-native', () => ({
View: ({ children, ...props }: any) => (
{children}
),
+ Text: ({ children, ...props }: any) => (
+ {children}
+ ),
}));
jest.mock('@react-navigation/native', () => ({
@@ -61,6 +70,8 @@ jest.mock('tamagui', () => ({
jest.mock('@selfxyz/mobile-sdk-alpha/constants/colors', () => ({
black: '#000000',
white: '#ffffff',
+ slate200: '#E2E8F0',
+ slate500: '#64748B',
}));
jest.mock('@selfxyz/mobile-sdk-alpha/constants/analytics', () => ({
@@ -113,6 +124,10 @@ jest.mock('@/integrations/haptics', () => ({
notificationSuccess: jest.fn(),
}));
+jest.mock('@/config/sentry', () => ({
+ captureException: jest.fn(),
+}));
+
jest.mock('@/layouts/ExpandableBottomLayout', () => ({
ExpandableBottomLayout: {
Layout: ({ children, ...props }: any) => (
@@ -157,6 +172,9 @@ const { buttonTap, notificationSuccess } = jest.requireMock(
buttonTap: jest.Mock;
notificationSuccess: jest.Mock;
};
+const { captureException } = jest.requireMock('@/config/sentry') as {
+ captureException: jest.Mock;
+};
const { getWhiteListedDisclosureAddresses } = jest.requireMock(
'@/services/points/utils',
) as {
@@ -179,12 +197,14 @@ describe('ProofRequestStatusScreen', () => {
const mockTrackEvent = jest.fn();
const mockCleanSelfApp = jest.fn();
const mockUpdateProofStatus = jest.fn();
+ const mockCancelProof = jest.fn();
let provingState: {
currentState: string;
reason: string | null;
uuid: string;
error_code: string | null;
+ cancel: jest.Mock;
};
let selfAppState: {
selfApp: {
@@ -197,12 +217,15 @@ describe('ProofRequestStatusScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
+ mockCancelProof.mockReset();
+ mockCancelProof.mockResolvedValue(undefined);
provingState = {
currentState: 'completed',
reason: null,
uuid: 'session-1',
error_code: null,
+ cancel: mockCancelProof,
};
selfAppState = {
selfApp: {
@@ -277,6 +300,30 @@ describe('ProofRequestStatusScreen', () => {
});
});
+ it('keeps the button disabled on completed while whitelist fetch is pending', async () => {
+ selfAppState.selfApp.endpoint = '0xABC';
+ // Simulate a never-resolving whitelist lookup to hold whitelistedPoints === undefined.
+ getWhiteListedDisclosureAddresses.mockImplementation(
+ () => new Promise(() => {}),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1);
+ });
+
+ const button = screen.getByTestId('primary-button');
+ expect(button.props.disabled).toBe(true);
+
+ fireEvent.press(button);
+
+ expect(buttonTap).not.toHaveBeenCalled();
+ expect(mockGoHome).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockCleanSelfApp).not.toHaveBeenCalled();
+ });
+
it('navigates to Gratification when the endpoint is whitelisted for points', async () => {
selfAppState.selfApp.endpoint = '0xABC';
getWhiteListedDisclosureAddresses.mockResolvedValue([
@@ -399,6 +446,82 @@ describe('ProofRequestStatusScreen', () => {
expect(mockCleanSelfApp).not.toHaveBeenCalled();
});
+ describe('failure / error', () => {
+ it('shows Dismiss on failure and routing Home clears session on press', async () => {
+ provingState.currentState = 'failure';
+ provingState.reason = '[InvalidRoot]: Onchain root does not exist';
+
+ render();
+
+ const button = screen.getByTestId('primary-button');
+ expect(button.props.children).toBe('Dismiss');
+ expect(button.props.disabled).toBe(false);
+
+ fireEvent.press(button);
+
+ expect(buttonTap).toHaveBeenCalledTimes(1);
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows Dismiss on error and still exits even without a reason', async () => {
+ provingState.currentState = 'error';
+ provingState.reason = null;
+
+ render();
+
+ const button = screen.getByTestId('primary-button');
+ expect(button.props.children).toBe('Dismiss');
+
+ fireEvent.press(button);
+
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the QR-refresh copy for InvalidRoot failures', async () => {
+ provingState.currentState = 'failure';
+ provingState.reason =
+ '[InvalidRoot]: Onchain root does not exist, received: 4589...';
+
+ const { toJSON } = render();
+
+ const tree = JSON.stringify(toJSON());
+ expect(tree).toMatch(/QR code from Verifier is out of date/i);
+ });
+
+ it('renders a fallback copy for unknown failure reasons', async () => {
+ provingState.currentState = 'failure';
+ provingState.reason = 'Something else went wrong';
+
+ const { toJSON } = render();
+
+ const tree = JSON.stringify(toJSON());
+ expect(tree).toMatch(
+ /Unable to prove your identity to Verifier\. Please try again/i,
+ );
+ });
+
+ it('renders the raw reason in the selectable details box for support', async () => {
+ const reason =
+ '[InvalidRoot]: Onchain root does not exist, received: 4589506917688709078187632663628833702807225';
+ provingState.currentState = 'failure';
+ provingState.reason = reason;
+
+ const { toJSON } = render();
+
+ const tree = JSON.stringify(toJSON());
+ expect(tree).toContain(reason);
+ expect(tree).toContain('Details');
+ // The raw reason must be selectable so users/support can copy it.
+ expect(tree).toMatch(/"selectable":true/);
+ });
+ });
+
it('cancels deeplink redirect before it opens the external URL', async () => {
selfAppState.selfApp.deeplinkCallback =
'https://callback.self.xyz/complete';
@@ -419,4 +542,267 @@ describe('ProofRequestStatusScreen', () => {
expect(Linking.openURL).not.toHaveBeenCalled();
expect(mockGoHome).not.toHaveBeenCalled();
});
+
+ it('times out stalled proving on mount and lets the user dismiss safely', async () => {
+ provingState.currentState = 'proving';
+
+ const { toJSON } = render();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+
+ const tree = JSON.stringify(toJSON());
+ expect(tree).toMatch(/took too long to finish/i);
+ expect(tree).toContain('timed_out_after_90s');
+ expect(mockUpdateProofStatus).toHaveBeenCalledWith(
+ 'session-1',
+ ProofStatus.FAILURE,
+ 'proof_timeout',
+ 'timed_out_after_90s',
+ );
+
+ await act(async () => {
+ fireEvent.press(screen.getByTestId('primary-button'));
+ });
+
+ await waitFor(() => {
+ expect(mockCancelProof).toHaveBeenCalledTimes(1);
+ });
+ expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not write proof history when timeout fires before a session id exists', async () => {
+ provingState.currentState = 'fetching_data';
+ provingState.uuid = null as any;
+
+ render();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+
+ expect(mockUpdateProofStatus).not.toHaveBeenCalled();
+ expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_FAILED', {
+ sessionId: null,
+ appName: 'Verifier',
+ errorCode: 'proof_timeout',
+ reason: 'timed_out_after_90s',
+ state: 'timeout',
+ });
+ });
+
+ it('does not write proof history for early completion without a session id', async () => {
+ provingState.currentState = 'completed';
+ provingState.uuid = null as any;
+
+ render();
+
+ await waitFor(() => {
+ expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_COMPLETED', {
+ sessionId: null,
+ appName: 'Verifier',
+ });
+ });
+
+ expect(mockUpdateProofStatus).not.toHaveBeenCalled();
+ });
+
+ it('does not write proof history for early failure without a session id', async () => {
+ provingState.currentState = 'error';
+ provingState.uuid = null as any;
+ provingState.error_code = 'early_error';
+ provingState.reason = 'failed before tee';
+
+ render();
+
+ await waitFor(() => {
+ expect(mockTrackEvent).toHaveBeenCalledWith('PROOF_FAILED', {
+ sessionId: null,
+ appName: 'Verifier',
+ errorCode: 'early_error',
+ reason: 'failed before tee',
+ state: 'error',
+ });
+ });
+
+ expect(mockUpdateProofStatus).not.toHaveBeenCalled();
+ });
+
+ it('times out when stuck in post_proving', async () => {
+ provingState.currentState = 'post_proving';
+
+ render();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+ });
+
+ it('does not schedule delayed cleanup when acknowledging a failure with no session id', async () => {
+ provingState.currentState = 'failure';
+ provingState.uuid = null as any;
+ provingState.reason = 'early failure';
+
+ render();
+
+ fireEvent.press(screen.getByTestId('primary-button'));
+
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(mockCleanSelfApp).not.toHaveBeenCalled();
+ });
+
+ it('resets the stall timer when the proving state changes', async () => {
+ provingState.currentState = 'fetching_data';
+ const { rerender } = render();
+
+ act(() => {
+ jest.advanceTimersByTime(89_000);
+ });
+
+ expect(screen.queryByText('Proof Failed')).toBeNull();
+
+ provingState.currentState = 'proving';
+ rerender();
+
+ act(() => {
+ jest.advanceTimersByTime(2_000);
+ });
+
+ expect(screen.queryByText('Proof Failed')).toBeNull();
+
+ act(() => {
+ jest.advanceTimersByTime(88_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+ });
+
+ it('resets timeout state when a new session id arrives', async () => {
+ provingState.currentState = 'proving';
+ const { rerender } = render();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+
+ provingState.uuid = 'session-2';
+ rerender();
+
+ await waitFor(() => {
+ expect(screen.queryByText('timed_out_after_90s')).toBeNull();
+ });
+ expect(screen.getByTestId('primary-button').props.children).not.toBe(
+ 'Dismiss',
+ );
+ });
+
+ it('clears the stall timer when the screen loses focus', () => {
+ let focused = true;
+ (useIsFocused as jest.Mock).mockImplementation(() => focused);
+ provingState.currentState = 'proving';
+
+ const { rerender } = render();
+
+ act(() => {
+ jest.advanceTimersByTime(45_000);
+ });
+
+ focused = false;
+ rerender();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ expect(screen.queryByText('Proof Failed')).toBeNull();
+ });
+
+ it('still exits home if cancelling a timed-out proof throws', async () => {
+ provingState.currentState = 'proving';
+ mockCancelProof.mockRejectedValueOnce(new Error('close failed'));
+
+ render();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+
+ await act(async () => {
+ fireEvent.press(screen.getByTestId('primary-button'));
+ });
+
+ await waitFor(() => {
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+ });
+ expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
+ expect(captureException).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores repeated dismiss taps while timed-out cancellation is in flight', async () => {
+ provingState.currentState = 'proving';
+ mockCancelProof.mockImplementation(
+ () => new Promise(resolve => setTimeout(resolve, 10)),
+ );
+
+ render();
+
+ act(() => {
+ jest.advanceTimersByTime(90_000);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('primary-button').props.children).toBe(
+ 'Dismiss',
+ );
+ });
+
+ await act(async () => {
+ fireEvent.press(screen.getByTestId('primary-button'));
+ fireEvent.press(screen.getByTestId('primary-button'));
+ jest.advanceTimersByTime(10);
+ });
+
+ expect(mockCancelProof).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/tests/src/services/analytics.test.ts b/app/tests/src/services/analytics.test.ts
index 525970658..223041130 100644
--- a/app/tests/src/services/analytics.test.ts
+++ b/app/tests/src/services/analytics.test.ts
@@ -366,6 +366,11 @@ describe('analytics', () => {
expect(mockSegmentClient.identify).toHaveBeenCalledWith('uuid-1');
});
+ it('resets analytics identity when support UUID is cleared', () => {
+ setAnalyticsSupportUuid(null);
+ expect(mockSegmentClient.reset).toHaveBeenCalled();
+ });
+
it('resets and re-identifies on resetAnalyticsIdentityForSupportUuid', async () => {
resetAnalyticsIdentityForSupportUuid('uuid-2');
expect(mockSegmentClient.reset).toHaveBeenCalled();
diff --git a/app/tests/src/services/logging/lokiTransport.test.ts b/app/tests/src/services/logging/lokiTransport.test.ts
index 92d8431cf..b0890235f 100644
--- a/app/tests/src/services/logging/lokiTransport.test.ts
+++ b/app/tests/src/services/logging/lokiTransport.test.ts
@@ -29,10 +29,16 @@ jest.mock('../../../../env', () => ({
}));
jest.mock('@/stores/settingStore', () => {
- const state = { supportUuid: null as string | null };
+ const state = {
+ supportUuid: null as string | null,
+ supportUuidEnabled: true,
+ };
return {
useSettingStore: {
getState: () => ({
+ get supportUuidEnabled() {
+ return state.supportUuidEnabled;
+ },
get supportUuid() {
return state.supportUuid;
},
@@ -44,7 +50,7 @@ jest.mock('@/stores/settingStore', () => {
const storeState = (
useSettingStore as unknown as {
- __state: { supportUuid: string | null };
+ __state: { supportUuid: string | null; supportUuidEnabled: boolean };
}
).__state;
@@ -65,6 +71,7 @@ describe('lokiTransport', () => {
fetchMock = jest.fn().mockResolvedValue({ ok: true });
(global as unknown as { fetch: jest.Mock }).fetch = fetchMock;
storeState.supportUuid = null;
+ storeState.supportUuidEnabled = true;
});
afterEach(() => {
@@ -84,6 +91,7 @@ describe('lokiTransport', () => {
const payload = JSON.parse(init.body as string);
const logLine = JSON.parse(payload.streams[0].values[0][1]);
expect(logLine.support_uuid).toBe('abc-123');
+ expect(logLine.support_uuid_enabled).toBe(true);
});
it('falls back to "unset" when no support UUID is stored', async () => {
@@ -96,6 +104,21 @@ describe('lokiTransport', () => {
const payload = JSON.parse(init.body as string);
const logLine = JSON.parse(payload.streams[0].values[0][1]);
expect(logLine.support_uuid).toBe('unset');
+ expect(logLine.support_uuid_enabled).toBe(true);
+ });
+
+ it('omits support_uuid when diagnostic IDs are disabled', async () => {
+ storeState.supportUuidEnabled = false;
+ callTransport();
+ flushLokiTransport();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const [, init] = fetchMock.mock.calls[0];
+ const payload = JSON.parse(init.body as string);
+ const logLine = JSON.parse(payload.streams[0].values[0][1]);
+ expect(logLine).not.toHaveProperty('support_uuid');
+ expect(logLine.support_uuid_enabled).toBe(false);
});
it('does not put support_uuid or session_id in Loki stream labels', async () => {
diff --git a/app/tests/src/services/supportUuid.test.ts b/app/tests/src/services/supportUuid.test.ts
index 16a67135f..75348ff61 100644
--- a/app/tests/src/services/supportUuid.test.ts
+++ b/app/tests/src/services/supportUuid.test.ts
@@ -15,23 +15,35 @@ import {
getSupportUuid,
initializeSupportUuidContext,
regenerateSupportUuid,
+ setSupportUuidCollectionEnabled,
} from '@/services/supportUuid';
import { useSettingStore } from '@/stores/settingStore';
jest.mock('@/stores/settingStore', () => {
- const state = { supportUuid: null as string | null };
+ const state = {
+ supportUuid: null as string | null,
+ supportUuidEnabled: true,
+ };
const setSupportUuid = jest.fn((next: string | null) => {
state.supportUuid = next;
});
+ const setSupportUuidEnabled = jest.fn((next: boolean) => {
+ state.supportUuidEnabled = next;
+ });
return {
useSettingStore: {
getState: () => ({
+ get supportUuidEnabled() {
+ return state.supportUuidEnabled;
+ },
get supportUuid() {
return state.supportUuid;
},
+ setSupportUuidEnabled,
setSupportUuid,
}),
__state: state,
+ __setSupportUuidEnabled: setSupportUuidEnabled,
__setSupportUuid: setSupportUuid,
},
};
@@ -52,15 +64,21 @@ jest.mock('@react-native-clipboard/clipboard', () => ({
}));
const storeState = (
- useSettingStore as unknown as { __state: { supportUuid: string | null } }
+ useSettingStore as unknown as {
+ __state: { supportUuid: string | null; supportUuidEnabled: boolean };
+ }
).__state;
const mockSetSupportUuid = (
useSettingStore as unknown as { __setSupportUuid: jest.Mock }
).__setSupportUuid;
+const mockSetSupportUuidEnabled = (
+ useSettingStore as unknown as { __setSupportUuidEnabled: jest.Mock }
+).__setSupportUuidEnabled;
describe('supportUuid service', () => {
beforeEach(() => {
storeState.supportUuid = null;
+ storeState.supportUuidEnabled = true;
jest.clearAllMocks();
});
@@ -76,6 +94,12 @@ describe('supportUuid service', () => {
expect(getSupportUuid()).toBe(storeState.supportUuid);
expect(mockSetSupportUuid).not.toHaveBeenCalled();
});
+
+ it('returns null when diagnostic IDs are disabled', () => {
+ storeState.supportUuidEnabled = false;
+ expect(getSupportUuid()).toBeNull();
+ expect(mockSetSupportUuid).not.toHaveBeenCalled();
+ });
});
describe('appendSupportUuidToUrl', () => {
@@ -104,24 +128,43 @@ describe('supportUuid service', () => {
expect(result).not.toContain('support_uuid=stale');
});
- it('preserves fragments when falling back on malformed URLs', () => {
+ it('returns the URL unchanged when URL parsing fails', () => {
const urlSpy = jest.spyOn(global, 'URL').mockImplementationOnce(() => {
throw new TypeError('invalid');
});
- const result = appendSupportUuidToUrl('support?x=1#section');
- expect(result).toBe(
- `support?x=1&support_uuid=${storeState.supportUuid}#section`,
+ expect(appendSupportUuidToUrl('support?x=1#section')).toBe(
+ 'support?x=1#section',
);
urlSpy.mockRestore();
});
+
+ it('strips support_uuid from the URL when diagnostic IDs are disabled', () => {
+ storeState.supportUuidEnabled = false;
+ expect(
+ appendSupportUuidToUrl('https://example.com/help?x=1&support_uuid=abc'),
+ ).toBe('https://example.com/help?x=1');
+ expect(
+ appendSupportUuidToUrl('https://example.com/help?support_uuid=abc'),
+ ).toBe('https://example.com/help');
+ expect(appendSupportUuidToUrl('https://example.com/help?x=1')).toBe(
+ 'https://example.com/help?x=1',
+ );
+ });
});
describe('initializeSupportUuidContext', () => {
it('wires the UUID into Sentry and analytics', () => {
const uuid = initializeSupportUuidContext();
- expect(setSupportUuidInSentry).toHaveBeenCalledWith(uuid);
+ expect(setSupportUuidInSentry).toHaveBeenCalledWith(uuid, true);
expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(uuid);
});
+
+ it('clears support UUID context when diagnostic IDs are disabled', () => {
+ storeState.supportUuidEnabled = false;
+ expect(initializeSupportUuidContext()).toBeNull();
+ expect(setSupportUuidInSentry).toHaveBeenCalledWith(null, false);
+ expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(null);
+ });
});
describe('regenerateSupportUuid', () => {
@@ -132,9 +175,16 @@ describe('supportUuid service', () => {
expect(next).not.toBe('old-uuid');
expect(mockSetSupportUuid).toHaveBeenCalledWith(next);
expect(setSupportUuidInSentry).toHaveBeenCalledTimes(1);
- expect(setSupportUuidInSentry).toHaveBeenCalledWith(next);
+ expect(setSupportUuidInSentry).toHaveBeenCalledWith(next, true);
expect(resetAnalyticsIdentityForSupportUuid).toHaveBeenCalledWith(next);
});
+
+ it('returns null when regenerate is called while disabled', () => {
+ storeState.supportUuidEnabled = false;
+ expect(regenerateSupportUuid()).toBeNull();
+ expect(mockSetSupportUuid).not.toHaveBeenCalled();
+ expect(resetAnalyticsIdentityForSupportUuid).not.toHaveBeenCalled();
+ });
});
describe('copySupportUuid', () => {
@@ -144,5 +194,37 @@ describe('supportUuid service', () => {
expect(uuid).toBe(storeState.supportUuid);
expect(Clipboard.setString).toHaveBeenCalledWith(storeState.supportUuid);
});
+
+ it('returns null when copy is called while disabled', () => {
+ storeState.supportUuidEnabled = false;
+ expect(copySupportUuid()).toBeNull();
+ expect(Clipboard.setString).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('setSupportUuidCollectionEnabled', () => {
+ it('disables diagnostic IDs and clears the current UUID', () => {
+ storeState.supportUuid = 'existing-uuid';
+ expect(setSupportUuidCollectionEnabled(false)).toBeNull();
+
+ expect(mockSetSupportUuidEnabled).toHaveBeenCalledWith(false);
+ expect(mockSetSupportUuid).toHaveBeenCalledWith(null);
+ expect(setSupportUuidInSentry).toHaveBeenCalledWith(null, false);
+ expect(setAnalyticsSupportUuid).toHaveBeenCalledWith(null);
+ });
+
+ it('re-enables diagnostic IDs with a fresh UUID', () => {
+ storeState.supportUuidEnabled = false;
+
+ const nextUuid = setSupportUuidCollectionEnabled(true);
+
+ expect(nextUuid).toMatch(/^[0-9a-f-]{36}$/);
+ expect(mockSetSupportUuidEnabled).toHaveBeenCalledWith(true);
+ expect(mockSetSupportUuid).toHaveBeenCalledWith(nextUuid);
+ expect(setSupportUuidInSentry).toHaveBeenCalledWith(nextUuid, true);
+ expect(resetAnalyticsIdentityForSupportUuid).toHaveBeenCalledWith(
+ nextUuid,
+ );
+ });
});
});
diff --git a/app/version.json b/app/version.json
index ee3e922e3..51fc5f356 100644
--- a/app/version.json
+++ b/app/version.json
@@ -1,10 +1,10 @@
{
"ios": {
- "build": 218,
- "lastDeployed": "2026-04-14T21:02:24.000Z"
+ "build": 219,
+ "lastDeployed": "2026-04-20T03:28:28.779Z"
},
"android": {
- "build": 149,
- "lastDeployed": "2026-04-20T00:22:24.761Z"
+ "build": 150,
+ "lastDeployed": "2026-04-20T03:28:28.779Z"
}
}
diff --git a/packages/mobile-sdk-alpha/src/constants/analytics.ts b/packages/mobile-sdk-alpha/src/constants/analytics.ts
index 36d5483df..116eeca82 100644
--- a/packages/mobile-sdk-alpha/src/constants/analytics.ts
+++ b/packages/mobile-sdk-alpha/src/constants/analytics.ts
@@ -185,6 +185,7 @@ export const ProofEvents = {
PROOF_COMPLETED: 'Proof: Proof Completed',
PROOF_DISCLOSURES_SCROLLED: 'Proof: Proof Disclosures Scrolled',
PROOF_FAILED: 'Proof: Proof Failed',
+ POINTS_NULLIFIER_ALREADY_USED: 'Proof: Points Nullifier Already Used',
PROOF_RESULT_ACKNOWLEDGED: 'Proof: Proof Result Acknowledged',
PROOF_VERIFY_CONFIRMATION_ACCEPTED: 'Proof: Verify Confirmation Accepted',
PROOF_VERIFY_LONG_PRESS: 'Proof: Verify Button Long Pressed',
diff --git a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts
index 7b8a6e6d7..768b6b3f3 100644
--- a/packages/mobile-sdk-alpha/src/proving/provingMachine.ts
+++ b/packages/mobile-sdk-alpha/src/proving/provingMachine.ts
@@ -235,6 +235,7 @@ export interface ProvingState {
circuitType: 'dsc' | 'disclose' | 'register',
userConfirmed?: boolean,
) => Promise;
+ cancel: (selfClient: SelfClient) => Promise;
parseIDDocument: (selfClient: SelfClient) => Promise;
startFetchingData: (selfClient: SelfClient) => Promise;
validatingDocument: (selfClient: SelfClient) => Promise;
@@ -393,6 +394,29 @@ export const getPostVerificationRoute = () => {
export const useProvingStore = create((set, get) => {
let actor: AnyActorRef | null = null;
+ function resetProvingState(partial?: Partial) {
+ set({
+ currentState: 'idle',
+ attestation: null,
+ serverPublicKey: null,
+ sharedKey: null,
+ wsConnection: null,
+ wsHandlers: null,
+ wsReconnectAttempts: 0,
+ socketConnection: null,
+ uuid: null,
+ userConfirmed: false,
+ passportData: null,
+ secret: null,
+ circuitType: null,
+ endpointType: null,
+ env: null,
+ error_code: null,
+ reason: null,
+ ...partial,
+ });
+ }
+
function setupActorSubscriptions(newActor: AnyActorRef, selfClient: SelfClient) {
let lastTransition = Date.now();
let lastEvent: AnyEventObject = { type: 'init' };
@@ -547,6 +571,38 @@ export const useProvingStore = create((set, get) => {
error_code: null,
reason: null,
endpointType: null,
+ cancel: async (selfClient: SelfClient) => {
+ const context = createProofContext(selfClient, 'cancel');
+ selfClient.logProofEvent('warn', 'Proving flow cancelled by UI', context);
+ let cancellationError: Error | null = null;
+
+ // Stop and null the actor BEFORE closing the socket. _closeConnections
+ // triggers the socket 'disconnect' handler, which reads module-scope
+ // `actor` and would otherwise fire a spurious PROVE_ERROR on the
+ // about-to-be-stopped actor.
+ if (actor) {
+ try {
+ actor.stop();
+ } catch (error) {
+ cancellationError = error instanceof Error ? error : new Error(String(error));
+ } finally {
+ actor = null;
+ }
+ }
+
+ try {
+ get()._closeConnections(selfClient);
+ } catch (error) {
+ cancellationError ??= error instanceof Error ? error : new Error(String(error));
+ }
+
+ selfClient.navigation?.disableKeychainErrorModal?.();
+ resetProvingState();
+
+ if (cancellationError) {
+ throw cancellationError;
+ }
+ },
_handleWebSocketMessage: async (event: MessageEvent, selfClient: SelfClient) => {
if (!actor) {
console.error('Cannot process message: State machine not initialized.');
@@ -1010,23 +1066,11 @@ export const useProvingStore = create((set, get) => {
} catch (error) {
console.error('Error stopping actor:', error);
}
+ actor = null;
}
- set({
- currentState: 'idle',
- attestation: null,
- serverPublicKey: null,
- sharedKey: null,
- wsConnection: null,
- socketConnection: null,
- uuid: null,
- userConfirmed: userConfirmed,
- passportData: null,
- secret: null,
+ resetProvingState({
+ userConfirmed,
circuitType,
- endpointType: null,
- env: null,
- error_code: null,
- reason: null,
});
actor = createActor(provingMachine);
diff --git a/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts b/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts
index 1ec8631db..2f8f1e0c5 100644
--- a/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts
+++ b/packages/mobile-sdk-alpha/tests/proving/provingMachine.integration.test.ts
@@ -105,6 +105,10 @@ describe('provingMachine Socket.IO Integration', () => {
emit: vi.fn(),
getPrivateKey: vi.fn(() => Promise.resolve('mock-private-key')),
logProofEvent: vi.fn(),
+ navigation: {
+ disableKeychainErrorModal: vi.fn(),
+ enableKeychainErrorModal: vi.fn(),
+ },
getSelfAppState: () => ({
selfApp: {},
}),
@@ -281,4 +285,63 @@ describe('provingMachine Socket.IO Integration', () => {
// Should still track the status received event (covered elsewhere)
});
});
+
+ describe('cancel', () => {
+ it('closes active connections and resets proving state to idle', async () => {
+ mockActor.stop.mockClear();
+ const removeEventListener = vi.fn();
+ const closeWs = vi.fn();
+ const closeSocket = vi.fn();
+
+ useProvingStore.setState({
+ currentState: 'proving',
+ uuid: 'session-1',
+ userConfirmed: true,
+ circuitType: 'register',
+ error_code: 'E001',
+ reason: 'stalled',
+ wsConnection: {
+ removeEventListener,
+ close: closeWs,
+ } as any,
+ wsHandlers: {
+ message: vi.fn(),
+ open: vi.fn(),
+ error: vi.fn(),
+ close: vi.fn(),
+ },
+ socketConnection: {
+ close: closeSocket,
+ } as any,
+ } as any);
+
+ await useProvingStore.getState().cancel(mockSelfClient);
+
+ const finalState = useProvingStore.getState();
+ expect(removeEventListener).toHaveBeenCalledTimes(4);
+ expect(closeWs).toHaveBeenCalledTimes(1);
+ expect(closeSocket).toHaveBeenCalledTimes(1);
+ expect(mockActor.stop).toHaveBeenCalledTimes(1);
+ expect(mockSelfClient.navigation.disableKeychainErrorModal).toHaveBeenCalledTimes(1);
+ expect(finalState.currentState).toBe('idle');
+ expect(finalState.uuid).toBe(null);
+ expect(finalState.reason).toBe(null);
+ expect(finalState.error_code).toBe(null);
+ expect(finalState.socketConnection).toBe(null);
+ expect(finalState.wsConnection).toBe(null);
+ });
+
+ it('allows init to run again after cancel', async () => {
+ const store = useProvingStore.getState();
+
+ await store.cancel(mockSelfClient);
+ await store.init(mockSelfClient, 'register', true);
+
+ expect(mockActor.stop).toHaveBeenCalled();
+ expect(mockActor.start).toHaveBeenCalledTimes(2);
+ expect(useProvingStore.getState().currentState).toBe('idle');
+ expect(useProvingStore.getState().circuitType).toBe('register');
+ expect(useProvingStore.getState().userConfirmed).toBe(true);
+ });
+ });
});