Merge pull request #2006 from selfxyz/release/staging-2026-04-21

Release to Staging v2.9.19 - 2026-04-21
This commit is contained in:
Justin Hernandez
2026-04-21 13:43:40 -07:00
committed by GitHub
35 changed files with 1475 additions and 241 deletions

View File

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

View File

@@ -1,7 +0,0 @@
## Summary
<!-- Brief description of changes -->
## Test plan
<!-- How was this tested? -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
"version": "2.9.18",
"version": "2.9.19",
"private": true,
"type": "module",
"scripts": {

View File

@@ -26,7 +26,7 @@ const SupportUuidRow: React.FC<SupportUuidRowProps> = ({
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<SupportUuidRowProps> = ({
const toggle = useCallback(() => setExpanded(prev => !prev), []);
if (!isEnabled) {
return null;
}
if (!expanded) {
return (
<Button
@@ -47,7 +51,7 @@ const SupportUuidRow: React.FC<SupportUuidRowProps> = ({
paddingVertical={10}
paddingHorizontal={12}
>
<BodyText style={{ color: slate500 }}>Show diagnostic info</BodyText>
<BodyText style={{ color: slate500 }}>Show diagnostic ID</BodyText>
</Button>
);
}

View File

@@ -300,12 +300,16 @@ export const logProofEvent = (
extra?: Record<string, unknown>,
) => 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) => {

View File

@@ -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<string, unknown>,
) => 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);

View File

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

View File

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

View File

@@ -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<SettingsRouteKey, React.FC<SvgProps>> = {
Support: Feedback,
share: ShareIcon,
DevSettings: Bug as React.FC<SvgProps>,
Troubleshooting: Wrench as React.FC<SvgProps>,
};
const social = [
@@ -133,6 +134,7 @@ const SocialButton: React.FC<SocialButtonProps> = ({
const SettingsScreen: React.FC = () => {
const { isDevMode, setDevModeOn } = useSettingStore();
const [isTroubleshootingMode, setTroubleshootingMode] = useState(false);
const navigation =
useNavigation<NativeStackNavigationProp<MinimalRootStackParamList>>();
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 (
<GestureDetector gesture={devModeTap}>
<GestureDetector gesture={combinedTap}>
<RNView collapsable={false}>
<View backgroundColor={white}>
<YStack

View File

@@ -3,23 +3,26 @@
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React, { useCallback } from 'react';
import { Alert } from 'react-native';
import { Alert, StyleSheet, Switch, View } from 'react-native';
import { Button, ScrollView, YStack } from 'tamagui';
import { BodyText } from '@selfxyz/mobile-sdk-alpha/components';
import {
black,
blue600,
slate100,
slate200,
slate500,
white,
} from '@selfxyz/mobile-sdk-alpha/constants/colors';
import { dinot } from '@selfxyz/mobile-sdk-alpha/constants/fonts';
import useOpenSupportForm from '@/hooks/useOpenSupportForm';
import { useSupportUuid } from '@/hooks/useSupportUuid';
const SupportScreen: React.FC = () => {
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 (
<ScrollView flex={1} backgroundColor={slate100}>
<YStack padding={20} gap={20}>
@@ -59,49 +69,97 @@ const SupportScreen: React.FC = () => {
<YStack gap={8}>
<BodyText style={{ color: slate500, fontSize: 13 }}>
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.
</BodyText>
<YStack
borderWidth={1}
borderColor={slate200}
borderRadius={12}
backgroundColor={white}
padding={16}
gap={8}
>
<BodyText style={{ color: black, fontSize: 16 }}>
Diagnostic ID
</BodyText>
<BodyText style={{ color: slate500, fontSize: 14 }}>
{diagnosticIdText}
</BodyText>
</YStack>
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<BodyText style={styles.settingLabel}>
Share diagnostic ID
</BodyText>
<BodyText style={styles.settingDescription}>
{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.'}
</BodyText>
</View>
<Switch
value={isEnabled}
onValueChange={handleSupportUuidToggle}
trackColor={{ false: slate200, true: blue600 }}
thumbColor={white}
testID="support-uuid-toggle"
/>
</View>
<Button
backgroundColor={white}
borderColor={slate200}
borderWidth={1}
borderRadius={12}
onPress={handleCopy}
>
<BodyText style={{ color: black }}>Copy diagnostic ID</BodyText>
</Button>
{isEnabled ? (
<>
<YStack
borderWidth={1}
borderColor={slate200}
borderRadius={12}
backgroundColor={white}
padding={16}
gap={8}
>
<BodyText style={{ color: black, fontSize: 16 }}>
Diagnostic ID
</BodyText>
<BodyText style={{ color: slate500, fontSize: 14 }}>
{diagnosticIdText}
</BodyText>
</YStack>
<Button
backgroundColor={white}
borderColor={slate200}
borderWidth={1}
borderRadius={12}
onPress={handleRegenerate}
>
<BodyText style={{ color: black }}>Regenerate</BodyText>
</Button>
<Button
backgroundColor={white}
borderColor={slate200}
borderWidth={1}
borderRadius={12}
onPress={handleCopy}
>
<BodyText style={{ color: black }}>Copy diagnostic ID</BodyText>
</Button>
<Button
backgroundColor={white}
borderColor={slate200}
borderWidth={1}
borderRadius={12}
onPress={handleRegenerate}
>
<BodyText style={{ color: black }}>Regenerate</BodyText>
</Button>
</>
) : null}
</YStack>
</YStack>
</ScrollView>
);
};
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;

View File

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

View File

@@ -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 (
<YStack padding="$4" gap="$4">
<YStack gap="$2">
<H4>Fix points disclosure</H4>
<Paragraph color={slate500}>
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.
</Paragraph>
</YStack>
<Button
backgroundColor={status === 'success' ? teal500 : black}
color={white}
borderColor={slate200}
borderRadius="$3"
height="$5"
disabled={status === 'loading'}
onPress={handleFix}
>
{status === 'loading' ? (
<Spinner color={white} />
) : status === 'success' ? (
'Fixed'
) : (
'Fix Points Issue'
)}
</Button>
{message !== '' && (
<Text fontSize="$3" color={status === 'error' ? red500 : teal500}>
{message}
</Text>
)}
</YStack>
);
};
export default TroubleshootingScreen;

View File

@@ -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<LottieViewProps['source']>(loadingAnimation);
const [countdown, setCountdown] = useState<number | null>(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<NodeJS.Timeout | null>(null);
const provingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const timedOutSessionIdRef = useRef<string | null>(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 (
<ExpandableBottomLayout.Layout backgroundColor={white}>
@@ -262,11 +401,14 @@ const SuccessScreen: React.FC = () => {
backgroundColor={white}
>
<View style={styles.content}>
<Title size="large">{getTitle(currentState)}</Title>
<Title size="large">{getTitle(displayState)}</Title>
<Info
currentState={currentState}
currentState={displayState}
appName={appName ?? 'The app'}
reason={reason ?? undefined}
reason={displayReason}
errorCode={
hasTimedOut ? PROOF_TIMEOUT_ERROR_CODE : (errorCode ?? undefined)
}
countdown={countdown}
deeplinkCallback={selfApp?.deeplinkCallback?.replace(
/^https?:\/\//,
@@ -277,26 +419,32 @@ const SuccessScreen: React.FC = () => {
<PrimaryButton
trackEvent={ProofEvents.PROOF_RESULT_ACKNOWLEDGED}
disabled={
(currentState !== 'completed' &&
currentState !== 'error' &&
currentState !== 'failure') ||
(currentState === 'completed' &&
isDismissingTimedOutProof ||
(displayState !== 'completed' &&
displayState !== 'error' &&
displayState !== 'failure') ||
(displayState === 'completed' &&
whitelistedPoints === undefined &&
!(countdown !== null && countdown > 0))
}
onPress={
countdown !== null && countdown > 0
? cancelDeeplinkCallbackRedirect
: onOkPress
hasTimedOut
? onTimedOutDismiss
: countdown !== null && countdown > 0
? cancelDeeplinkCallbackRedirect
: onOkPress
}
>
{currentState !== 'completed' &&
currentState !== 'error' &&
currentState !== 'failure' ? (
{isDismissingTimedOutProof ? (
<Spinner />
) : displayState === 'failure' || displayState === 'error' ? (
'Dismiss'
) : displayState !== 'completed' ? (
<Spinner />
) : countdown !== null && countdown > 0 ? (
'Cancel'
) : whitelistedPoints === undefined ? (
) : currentState === 'completed' &&
whitelistedPoints === undefined ? (
<Spinner />
) : (
'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({
</Description>
);
} else if (currentState === 'error' || currentState === 'failure') {
const userMessage = getUserFacingErrorMessage(
currentState,
reason,
errorCode,
appName,
);
return (
<View style={{ gap: 8 }}>
<Description>
Unable to prove your identity to{' '}
<BodyText style={typography.strong}>{appName}</BodyText>
{currentState === 'error' && '. Due to technical issues.'}
</Description>
<View style={{ gap: 12 }}>
<Description>{userMessage}</Description>
{currentState === 'failure' && reason && (
<>
<Description>
<BodyText style={[typography.strong, { fontSize: 14 }]}>
Reason:
</BodyText>
</Description>
<View style={{ maxHeight: 60 }}>
<ScrollView showsVerticalScrollIndicator={true}>
<Description>
<BodyText style={[typography.strong, { fontSize: 14 }]}>
{reason}
</BodyText>
</Description>
</ScrollView>
</View>
</>
<View style={styles.reasonBox}>
<BodyText style={[typography.strong, styles.reasonLabel]}>
Details
</BodyText>
<ScrollView
style={styles.reasonScroll}
showsVerticalScrollIndicator={true}
nestedScrollEnabled
>
<Text selectable style={styles.reasonText}>
{reason}
</Text>
</ScrollView>
</View>
)}
</View>
);
@@ -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' }),
},
});

View File

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

View File

@@ -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<string, unknown>).support_uuid;
}
}
resetSegmentIdentity();
resetMixpanelIdentity();
return;
}
identifyInSegment(nextSupportUuid);
setMixpanelDistinctId(nextSupportUuid);
};
/**

View File

@@ -200,15 +200,23 @@ const lokiTransport: transportFunctionType<LokiTransportOptions> = 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;

View File

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

View File

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

View File

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

View File

@@ -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<SettingsState>()(
setPointsAddress: (address: string | null) =>
set({ pointsAddress: address }),
supportUuidEnabled: true,
setSupportUuidEnabled: (supportUuidEnabled: boolean) =>
set({ supportUuidEnabled }),
supportUuid: null,
setSupportUuid: (supportUuid: string | null) => set({ supportUuid }),

View File

@@ -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) => (
<mock-text {...props}>{children}</mock-text>
),
}));
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(<SupportUuidRow />);
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(<SupportUuidRow />);
expect(JSON.stringify(toJSON())).toContain('Show diagnostic ID');
});
});

View File

@@ -111,6 +111,7 @@ describe('navigation', () => {
'Splash',
'StarfallPushCode',
'Support',
'Troubleshooting',
'WebView',
]);
});

View File

@@ -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) => (
<mock-view {...props}>{children}</mock-view>
),
Text: ({ children, ...props }: any) => (
<mock-rn-text {...props}>{children}</mock-rn-text>
),
}));
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
act(() => {
jest.advanceTimersByTime(89_000);
});
expect(screen.queryByText('Proof Failed')).toBeNull();
provingState.currentState = 'proving';
rerender(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
act(() => {
jest.advanceTimersByTime(90_000);
});
await waitFor(() => {
expect(screen.getByTestId('primary-button').props.children).toBe(
'Dismiss',
);
});
provingState.uuid = 'session-2';
rerender(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
act(() => {
jest.advanceTimersByTime(45_000);
});
focused = false;
rerender(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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(<ProofRequestStatusScreen />);
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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -235,6 +235,7 @@ export interface ProvingState {
circuitType: 'dsc' | 'disclose' | 'register',
userConfirmed?: boolean,
) => Promise<void>;
cancel: (selfClient: SelfClient) => Promise<void>;
parseIDDocument: (selfClient: SelfClient) => Promise<void>;
startFetchingData: (selfClient: SelfClient) => Promise<void>;
validatingDocument: (selfClient: SelfClient) => Promise<void>;
@@ -393,6 +394,29 @@ export const getPostVerificationRoute = () => {
export const useProvingStore = create<ProvingState>((set, get) => {
let actor: AnyActorRef | null = null;
function resetProvingState(partial?: Partial<ProvingState>) {
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<ProvingState>((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<ProvingState>((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);

View File

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