diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle
index c51292a48..924a684c8 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 148
- versionName "2.9.17"
+ versionCode 149
+ versionName "2.9.18"
manifestPlaceholders = [appAuthRedirectScheme: 'com.proofofpassportapp']
externalNativeBuild {
cmake {
diff --git a/app/app.json b/app/app.json
index 27c195db3..cc9938042 100644
--- a/app/app.json
+++ b/app/app.json
@@ -4,7 +4,7 @@
"expo": {
"name": "Self App",
"slug": "self-app",
- "version": "2.9.17",
+ "version": "2.9.18",
"platforms": ["ios", "android"],
"ios": {
"bundleIdentifier": "com.proofofpassportapp"
diff --git a/app/ios/OpenPassport/Info.plist b/app/ios/OpenPassport/Info.plist
index 1621fccc2..7cdad8669 100644
--- a/app/ios/OpenPassport/Info.plist
+++ b/app/ios/OpenPassport/Info.plist
@@ -21,7 +21,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2.9.17
+ 2.9.18
CFBundleSignature
????
CFBundleURLTypes
diff --git a/app/ios/Self.xcodeproj/project.pbxproj b/app/ios/Self.xcodeproj/project.pbxproj
index d88ab8f35..22ec9580d 100644
--- a/app/ios/Self.xcodeproj/project.pbxproj
+++ b/app/ios/Self.xcodeproj/project.pbxproj
@@ -593,7 +593,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
- MARKETING_VERSION = 2.9.17;
+ MARKETING_VERSION = 2.9.18;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -735,7 +735,7 @@
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/MoproKit/Libs",
);
- MARKETING_VERSION = 2.9.17;
+ MARKETING_VERSION = 2.9.18;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/app/package.json b/app/package.json
index 30d341b90..6391d2e3e 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@selfxyz/mobile-app",
- "version": "2.9.17",
+ "version": "2.9.18",
"private": true,
"type": "module",
"scripts": {
diff --git a/app/src/screens/app/GratificationScreen.tsx b/app/src/screens/app/GratificationScreen.tsx
index b780a1d96..0348e47aa 100644
--- a/app/src/screens/app/GratificationScreen.tsx
+++ b/app/src/screens/app/GratificationScreen.tsx
@@ -11,7 +11,8 @@ import {
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, View, YStack } from 'tamagui';
-import { useNavigation, useRoute } from '@react-navigation/native';
+import type { StaticScreenProps } from '@react-navigation/native';
+import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { X } from '@tamagui/lucide-icons';
@@ -30,13 +31,15 @@ import SelfLogo from '@/assets/logos/self.svg';
import { SystemBars } from '@/components/SystemBars';
import type { RootStackParamList } from '@/navigation';
-const GratificationScreen: React.FC = () => {
+type GratificationScreenProps = StaticScreenProps<{
+ points?: number;
+}>;
+
+const GratificationScreen: React.FC = ({ route }) => {
const { top, bottom } = useSafeAreaInsets();
const navigation =
useNavigation>();
- const route = useRoute();
- const params = route.params as { points?: number } | undefined;
- const pointsEarned = params?.points ?? 0;
+ const pointsEarned = route.params?.points ?? 0;
const [isAnimationFinished, setIsAnimationFinished] = useState(false);
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
diff --git a/app/src/screens/verification/ProofRequestStatusScreen.tsx b/app/src/screens/verification/ProofRequestStatusScreen.tsx
index 904d12a25..f8d5e327b 100644
--- a/app/src/screens/verification/ProofRequestStatusScreen.tsx
+++ b/app/src/screens/verification/ProofRequestStatusScreen.tsx
@@ -31,10 +31,16 @@ import {
} from '@/integrations/haptics';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import type { RootStackParamList } from '@/navigation';
+import {
+ hasUserAnIdentityDocumentRegistered,
+ hasUserDoneThePointsDisclosure,
+} from '@/services/points';
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
+const PREREQ_CHECK_TIMEOUT_MS = 3000;
+
const SuccessScreen: React.FC = () => {
const selfClient = useSelfClient();
const { trackEvent } = selfClient;
@@ -68,23 +74,37 @@ const SuccessScreen: React.FC = () => {
buttonTap();
const completedSessionId = sessionId;
+ const cleanupLater = () => {
+ setTimeout(() => {
+ if (useProvingStore.getState().uuid === completedSessionId) {
+ selfClient.getSelfAppState().cleanSelfApp();
+ }
+ }, 2000);
+ };
+
if (whitelistedPoints !== null) {
- navigation.navigate('Gratification', {
- points: whitelistedPoints,
- });
- setTimeout(() => {
- if (useProvingStore.getState().uuid === completedSessionId) {
- selfClient.getSelfAppState().cleanSelfApp();
- }
- }, 2000);
- } else {
- goHome();
- setTimeout(() => {
- if (useProvingStore.getState().uuid === completedSessionId) {
- selfClient.getSelfAppState().cleanSelfApp();
- }
- }, 2000);
+ // 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.
+ const timeout = new Promise(resolve =>
+ setTimeout(() => resolve(false), PREREQ_CHECK_TIMEOUT_MS),
+ );
+ const [hasDocument, hasDisclosed] = await Promise.all([
+ Promise.race([hasUserAnIdentityDocumentRegistered(), timeout]),
+ Promise.race([hasUserDoneThePointsDisclosure(), timeout]),
+ ]);
+
+ if (hasDocument && hasDisclosed) {
+ navigation.navigate('Gratification', {
+ points: whitelistedPoints,
+ });
+ cleanupLater();
+ return;
+ }
}
+
+ goHome();
+ cleanupLater();
}, [
whitelistedPoints,
navigation,
diff --git a/app/src/services/logging/logger/lokiTransport.ts b/app/src/services/logging/logger/lokiTransport.ts
index 6f85de7bc..446959ede 100644
--- a/app/src/services/logging/logger/lokiTransport.ts
+++ b/app/src/services/logging/logger/lokiTransport.ts
@@ -5,6 +5,7 @@
import type { AppStateStatus } from 'react-native';
import { AppState } from 'react-native';
import type { transportFunctionType } from 'react-native-logs';
+import { v4 as uuidv4 } from 'uuid';
import { registerDocumentChangeCallback } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
@@ -31,7 +32,7 @@ interface LokiPayload {
}
// Per-session ID for grouping logs in Grafana (not persistent, not user-identifiable)
-const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+const sessionId = uuidv4();
// Batch management state
let batch: LokiLogEntry[] = [];
diff --git a/app/tests/src/screens/GratificationScreen.test.tsx b/app/tests/src/screens/GratificationScreen.test.tsx
index 41ab91d39..fd914bcce 100644
--- a/app/tests/src/screens/GratificationScreen.test.tsx
+++ b/app/tests/src/screens/GratificationScreen.test.tsx
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
-import { useNavigation, useRoute } from '@react-navigation/native';
+import { useNavigation } from '@react-navigation/native';
import { render, waitFor } from '@testing-library/react-native';
import GratificationScreen from '@/screens/app/GratificationScreen';
@@ -48,7 +48,6 @@ jest.mock('react-native-safe-area-context', () => ({
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
- useRoute: jest.fn(),
}));
// Mock Tamagui components to avoid theme provider requirement
@@ -103,7 +102,9 @@ jest.mock('@/assets/logos/self.svg', () => 'SelfLogo');
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
>;
-const mockUseRoute = useRoute as jest.MockedFunction;
+
+const renderScreen = (params: { points?: number } = {}) =>
+ render();
describe('GratificationScreen', () => {
const mockNavigate = jest.fn();
@@ -116,18 +117,10 @@ describe('GratificationScreen', () => {
navigate: mockNavigate,
goBack: mockGoBack,
} as any);
-
- mockUseRoute.mockReturnValue({
- params: {},
- } as any);
});
it('should use default points value when not provided', async () => {
- mockUseRoute.mockReturnValue({
- params: {},
- } as any);
-
- const { getByText } = render();
+ const { getByText } = renderScreen();
await waitFor(() => {
expect(getByText('0')).toBeTruthy();
@@ -135,11 +128,7 @@ describe('GratificationScreen', () => {
});
it('should use custom points value when provided', async () => {
- mockUseRoute.mockReturnValue({
- params: { points: 50 },
- } as any);
-
- const { getByText } = render();
+ const { getByText } = renderScreen({ points: 50 });
await waitFor(() => {
expect(getByText('50')).toBeTruthy();
@@ -147,11 +136,7 @@ describe('GratificationScreen', () => {
});
it('should display referral points value (24) when passed', async () => {
- mockUseRoute.mockReturnValue({
- params: { points: 24 },
- } as any);
-
- const { getByText } = render();
+ const { getByText } = renderScreen({ points: 24 });
await waitFor(() => {
expect(getByText('24')).toBeTruthy();
diff --git a/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx b/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx
index a34436dd7..7f16d9750 100644
--- a/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx
+++ b/app/tests/src/screens/verification/ProofRequestStatusScreen.test.tsx
@@ -131,6 +131,11 @@ jest.mock('@/services/points/utils', () => ({
getWhiteListedDisclosureAddresses: jest.fn(),
}));
+jest.mock('@/services/points', () => ({
+ hasUserAnIdentityDocumentRegistered: jest.fn(),
+ hasUserDoneThePointsDisclosure: jest.fn(),
+}));
+
jest.mock('@/stores/proofHistoryStore', () => ({
useProofHistoryStore: jest.fn(),
}));
@@ -157,6 +162,11 @@ const { getWhiteListedDisclosureAddresses } = jest.requireMock(
) as {
getWhiteListedDisclosureAddresses: jest.Mock;
};
+const { hasUserAnIdentityDocumentRegistered, hasUserDoneThePointsDisclosure } =
+ jest.requireMock('@/services/points') as {
+ hasUserAnIdentityDocumentRegistered: jest.Mock;
+ hasUserDoneThePointsDisclosure: jest.Mock;
+ };
const { useProofHistoryStore } = jest.requireMock(
'@/stores/proofHistoryStore',
) as {
@@ -211,6 +221,8 @@ describe('ProofRequestStatusScreen', () => {
updateProofStatus: mockUpdateProofStatus,
});
getWhiteListedDisclosureAddresses.mockResolvedValue([]);
+ hasUserAnIdentityDocumentRegistered.mockResolvedValue(true);
+ hasUserDoneThePointsDisclosure.mockResolvedValue(true);
const useProvingStore = Object.assign(
(selector: (state: typeof provingState) => unknown) =>
@@ -282,8 +294,10 @@ describe('ProofRequestStatusScreen', () => {
fireEvent.press(screen.getByTestId('primary-button'));
- expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
- points: 25,
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('Gratification', {
+ points: 25,
+ });
});
expect(mockGoHome).not.toHaveBeenCalled();
@@ -294,6 +308,80 @@ describe('ProofRequestStatusScreen', () => {
expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
});
+ it('falls back to Home when whitelisted but points prerequisites are not met', async () => {
+ selfAppState.selfApp.endpoint = '0xABC';
+ getWhiteListedDisclosureAddresses.mockResolvedValue([
+ {
+ contract_address: '0xabc',
+ points_per_disclosure: 25,
+ },
+ ]);
+ hasUserDoneThePointsDisclosure.mockResolvedValue(false);
+
+ render();
+
+ await waitFor(() => {
+ expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1);
+ });
+
+ fireEvent.press(screen.getByTestId('primary-button'));
+
+ await waitFor(() => {
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+ });
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ 'Gratification',
+ expect.anything(),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to Home when a prerequisite check stalls past the timeout', async () => {
+ selfAppState.selfApp.endpoint = '0xABC';
+ getWhiteListedDisclosureAddresses.mockResolvedValue([
+ {
+ contract_address: '0xabc',
+ points_per_disclosure: 25,
+ },
+ ]);
+ // Simulate a hung network call — never resolves.
+ hasUserDoneThePointsDisclosure.mockImplementation(
+ () => new Promise(() => {}),
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(getWhiteListedDisclosureAddresses).toHaveBeenCalledTimes(1);
+ });
+
+ fireEvent.press(screen.getByTestId('primary-button'));
+
+ // Advance past the 3s prereq timeout.
+ await act(async () => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ await waitFor(() => {
+ expect(mockGoHome).toHaveBeenCalledTimes(1);
+ });
+ expect(mockNavigate).not.toHaveBeenCalledWith(
+ 'Gratification',
+ expect.anything(),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(mockCleanSelfApp).toHaveBeenCalledTimes(1);
+ });
+
it('does not clear self app state if a newer session replaces the completed one', async () => {
render();
diff --git a/app/version.json b/app/version.json
index 7335f7191..ee3e922e3 100644
--- a/app/version.json
+++ b/app/version.json
@@ -4,7 +4,7 @@
"lastDeployed": "2026-04-14T21:02:24.000Z"
},
"android": {
- "build": 148,
- "lastDeployed": "2026-04-14T21:02:24.000Z"
+ "build": 149,
+ "lastDeployed": "2026-04-20T00:22:24.761Z"
}
}