diff --git a/app/src/components/NavBar/PointsNavBar.tsx b/app/src/components/NavBar/PointsNavBar.tsx index 5d008850a..1283d9693 100644 --- a/app/src/components/NavBar/PointsNavBar.tsx +++ b/app/src/components/NavBar/PointsNavBar.tsx @@ -31,7 +31,7 @@ export const PointsNavBar = (props: NativeStackHeaderProps) => { color={black} onPress={() => { buttonTap(); - props.navigation.goBack(); + props.navigation.navigate('Home'); }} /> diff --git a/app/src/components/PointHistoryList.tsx b/app/src/components/PointHistoryList.tsx index 5c00bdba4..0e7c32616 100644 --- a/app/src/components/PointHistoryList.tsx +++ b/app/src/components/PointHistoryList.tsx @@ -68,15 +68,15 @@ export const PointHistoryList: React.FC = ({ }) => { const selfClient = useSelfClient(); const [refreshing, setRefreshing] = useState(false); - // Subscribe to events directly from store - component will auto-update when store changes const pointEvents = usePointEventStore(state => state.getAllPointEvents()); const isLoading = usePointEventStore(state => state.isLoading); const refreshPoints = usePointEventStore(state => state.refreshPoints); const refreshIncomingPoints = usePointEventStore( state => state.refreshIncomingPoints, ); - // loadEvents only needs to be called once on mount. - // and it is called in Points.ts + const loadDisclosureEvents = usePointEventStore( + state => state.loadDisclosureEvents, + ); const formatDate = (timestamp: number) => { return new Date(timestamp).toLocaleTimeString([], { @@ -271,14 +271,15 @@ export const PointHistoryList: React.FC = ({ [], ); - // Pull-to-refresh handler const onRefresh = useCallback(() => { selfClient.trackEvent(PointEvents.REFRESH_HISTORY); setRefreshing(true); - Promise.all([refreshPoints(), refreshIncomingPoints()]).finally(() => - setRefreshing(false), - ); - }, [selfClient, refreshPoints, refreshIncomingPoints]); + Promise.all([ + refreshPoints(), + refreshIncomingPoints(), + loadDisclosureEvents(), + ]).finally(() => setRefreshing(false)); + }, [selfClient, refreshPoints, refreshIncomingPoints, loadDisclosureEvents]); const keyExtractor = useCallback((item: PointEvent) => item.id, []); diff --git a/app/src/screens/app/GratificationScreen.tsx b/app/src/screens/app/GratificationScreen.tsx index 8175beed1..20f5fca07 100644 --- a/app/src/screens/app/GratificationScreen.tsx +++ b/app/src/screens/app/GratificationScreen.tsx @@ -14,13 +14,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text, View, YStack } from 'tamagui'; import { useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { X } from '@tamagui/lucide-icons'; import { DelayedLottieView } from '@selfxyz/mobile-sdk-alpha'; import youWinAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/youWin.json'; import { PrimaryButton } from '@selfxyz/mobile-sdk-alpha/components'; import GratificationBg from '@/images/gratification_bg.svg'; -import ArrowLeft from '@/images/icons/arrow_left.svg'; import LogoWhite from '@/images/icons/logo_white.svg'; import type { RootStackParamList } from '@/navigation'; import { black, slate700, white } from '@/utils/colors'; @@ -46,7 +46,7 @@ const GratificationScreen: React.FC = () => { }; const handleBackPress = () => { - navigation.goBack(); + navigation.navigate('Points' as never); }; const handleAnimationFinish = useCallback(() => { @@ -129,7 +129,7 @@ const GratificationScreen: React.FC = () => { alignItems="center" justifyContent="center" > - + diff --git a/app/src/screens/verification/ProofRequestStatusScreen.tsx b/app/src/screens/verification/ProofRequestStatusScreen.tsx index 22fe04ef0..77b1fcea5 100644 --- a/app/src/screens/verification/ProofRequestStatusScreen.tsx +++ b/app/src/screens/verification/ProofRequestStatusScreen.tsx @@ -8,7 +8,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Linking, StyleSheet, View } from 'react-native'; import { SystemBars } from 'react-native-edge-to-edge'; import { ScrollView, Spinner } from 'tamagui'; -import { useIsFocused } from '@react-navigation/native'; +import { useIsFocused, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import loadingAnimation from '@selfxyz/mobile-sdk-alpha/animations/loading/misc.json'; @@ -25,6 +26,7 @@ import failAnimation from '@/assets/animations/proof_failed.json'; import succesAnimation from '@/assets/animations/proof_success.json'; import useHapticNavigation from '@/hooks/useHapticNavigation'; import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout'; +import type { RootStackParamList } from '@/navigation'; import { useProofHistoryStore } from '@/stores/proofHistoryStore'; import { ProofStatus } from '@/stores/proofTypes'; import { black, white } from '@/utils/colors'; @@ -33,6 +35,7 @@ import { notificationError, notificationSuccess, } from '@/utils/haptic'; +import { getWhiteListedDisclosureAddresses } from '@/utils/points/utils'; const SuccessScreen: React.FC = () => { const selfClient = useSelfClient(); @@ -41,6 +44,8 @@ const SuccessScreen: React.FC = () => { const selfApp = useSelfAppStore(state => state.selfApp); const appName = selfApp?.appName; const goHome = useHapticNavigation('Home'); + const navigation = + useNavigation>(); const { updateProofStatus } = useProofHistoryStore(); @@ -55,15 +60,28 @@ const SuccessScreen: React.FC = () => { useState(loadingAnimation); const [countdown, setCountdown] = useState(null); const [countdownStarted, setCountdownStarted] = useState(false); + const [whitelistedPoints, setWhitelistedPoints] = useState( + null, + ); const timerRef = useRef(null); - const onOkPress = useCallback(() => { + const onOkPress = useCallback(async () => { buttonTap(); - goHome(); - setTimeout(() => { - selfClient.getSelfAppState().cleanSelfApp(); - }, 2000); // Wait 2 seconds to user coming back to the home screen. If we don't wait the appname will change and user will see it. - }, [goHome, selfClient]); + + if (whitelistedPoints !== null) { + navigation.navigate('Gratification', { + points: whitelistedPoints, + }); + setTimeout(() => { + selfClient.getSelfAppState().cleanSelfApp(); + }, 2000); + } else { + goHome(); + setTimeout(() => { + selfClient.getSelfAppState().cleanSelfApp(); + }, 2000); + } + }, [whitelistedPoints, navigation, goHome, selfClient]); function cancelDeeplinkCallbackRedirect() { setCountdown(null); @@ -88,7 +106,28 @@ const SuccessScreen: React.FC = () => { sessionId, appName, }); - // Start countdown for redirect (only if we are on this screen and haven't started yet) + + if (selfApp?.endpoint && whitelistedPoints === null) { + const checkWhitelist = async () => { + try { + const whitelistedContracts = + await getWhiteListedDisclosureAddresses(); + const endpoint = selfApp.endpoint.toLowerCase(); + const whitelistedContract = whitelistedContracts.find( + c => c.contract_address.toLowerCase() === endpoint, + ); + + if (whitelistedContract) { + setWhitelistedPoints(whitelistedContract.points_per_disclosure); + } + } catch (error) { + console.error('Error checking whitelist:', error); + } + }; + + checkWhitelist(); + } + if (isFocused && !countdownStarted && selfApp?.deeplinkCallback) { if (selfApp?.deeplinkCallback) { try { @@ -133,7 +172,9 @@ const SuccessScreen: React.FC = () => { reason, updateProofStatus, selfApp?.deeplinkCallback, + selfApp?.endpoint, countdownStarted, + whitelistedPoints, ]); useEffect(() => { diff --git a/app/src/screens/verification/ProveScreen.tsx b/app/src/screens/verification/ProveScreen.tsx index e0cd3c471..1e10f4f37 100644 --- a/app/src/screens/verification/ProveScreen.tsx +++ b/app/src/screens/verification/ProveScreen.tsx @@ -44,6 +44,7 @@ import { ProofStatus } from '@/stores/proofTypes'; import { black, slate300, white } from '@/utils/colors'; import { formatUserId } from '@/utils/formatUserId'; import { buttonTap } from '@/utils/haptic'; +import { getPointsAddress } from '@/utils/points/utils'; const ProveScreen: React.FC = () => { const selfClient = useSelfClient(); @@ -83,11 +84,12 @@ const ProveScreen: React.FC = () => { sessionId: provingStore.uuid!, userId: selectedApp.userId, userIdType: selectedApp.userIdType, + endpoint: selectedApp.endpoint, endpointType: selectedApp.endpointType, status: ProofStatus.PENDING, logoBase64: selectedApp.logoBase64, disclosures: JSON.stringify(selectedApp.disclosures), - documentId: selectedDocumentId || '', // Fallback to empty if none selected + documentId: selectedDocumentId || '', }); } }; @@ -115,6 +117,29 @@ const ProveScreen: React.FC = () => { selectedAppRef.current = selectedApp; }, [selectedApp, isFocused, provingStore, selfClient]); + // Enhance selfApp with user's points address if not already set + useEffect(() => { + console.log('useEffect selectedApp', selectedApp); + if (!selectedApp || selectedApp.selfDefinedData) { + return; + } + + const enhanceApp = async () => { + const address = await getPointsAddress(); + + // Only update if still the same session + if (selectedAppRef.current?.sessionId === selectedApp.sessionId) { + console.log('enhancing app with points address', address); + selfClient.getSelfAppState().setSelfApp({ + ...selectedApp, + selfDefinedData: address.toLowerCase(), + }); + } + }; + + enhanceApp(); + }, [selectedApp, selfClient]); + const disclosureOptions = useMemo(() => { return (selectedApp?.disclosures as SelfAppDisclosureConfig) || []; }, [selectedApp?.disclosures]); diff --git a/app/src/stores/database.ts b/app/src/stores/database.ts index 55d94008c..717420019 100644 --- a/app/src/stores/database.ts +++ b/app/src/stores/database.ts @@ -87,6 +87,7 @@ export const database: ProofDB = { sessionId TEXT NOT NULL UNIQUE, userId TEXT NOT NULL, userIdType TEXT NOT NULL, + endpoint TEXT, endpointType TEXT NOT NULL, status TEXT NOT NULL, errorCode TEXT, @@ -108,10 +109,11 @@ export const database: ProofDB = { try { const [insertResult] = await db.executeSql( - `INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpoint, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ proof.appName, + proof.endpoint || null, proof.endpointType, proof.status, proof.errorCode || null, @@ -133,12 +135,40 @@ export const database: ProofDB = { } catch (error) { if ((error as Error).message.includes('no column named documentId')) { await addDocumentIdColumn(); - // Then retry the insert (copy the executeSql call here) const [insertResult] = await db.executeSql( - `INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpoint, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ proof.appName, + proof.endpoint || null, + proof.endpointType, + proof.status, + proof.errorCode || null, + proof.errorReason || null, + timestamp, + proof.disclosures, + proof.logoBase64 || null, + proof.userId, + proof.userIdType, + proof.sessionId, + proof.documentId, + ], + ); + return { + id: insertResult.insertId.toString(), + timestamp, + rowsAffected: insertResult.rowsAffected, + }; + } else if ( + (error as Error).message.includes('no column named endpoint') + ) { + await addEndpointColumn(); + const [insertResult] = await db.executeSql( + `INSERT OR IGNORE INTO ${TABLE_NAME} (appName, endpoint, endpointType, status, errorCode, errorReason, timestamp, disclosures, logoBase64, userId, userIdType, sessionId, documentId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + proof.appName, + proof.endpoint || null, proof.endpointType, proof.status, proof.errorCode || null, @@ -184,3 +214,8 @@ async function addDocumentIdColumn() { `ALTER TABLE ${TABLE_NAME} ADD COLUMN documentId TEXT NOT NULL DEFAULT ''`, ); } + +async function addEndpointColumn() { + const db = await openDatabase(); + await db.executeSql(`ALTER TABLE ${TABLE_NAME} ADD COLUMN endpoint TEXT`); +} diff --git a/app/src/stores/pointEventStore.ts b/app/src/stores/pointEventStore.ts index 40616aec4..39658213b 100644 --- a/app/src/stores/pointEventStore.ts +++ b/app/src/stores/pointEventStore.ts @@ -22,6 +22,7 @@ interface PointEventState { events: PointEvent[]; isLoading: boolean; loadEvents: () => Promise; + loadDisclosureEvents: () => Promise; addEvent: ( title: string, type: PointEventType, @@ -38,7 +39,6 @@ interface PointEventState { lastUpdated: number | null; promise: Promise | null; }; - // these are the real points that are on chain. each sunday noon UTC they get updated based on incoming points points: number; refreshPoints: () => Promise; fetchIncomingPoints: () => Promise; @@ -82,13 +82,11 @@ export const usePointEventStore = create()((set, get) => ({ if (stored) { try { const parsed = JSON.parse(stored); - // Validate that parsed data is an array if (!Array.isArray(parsed)) { console.error('Invalid stored events format, expected array'); set({ events: [], isLoading: false }); return; } - // Validate each event has required fields const events: PointEvent[] = parsed.filter((event: unknown) => { if ( typeof event === 'object' && @@ -103,12 +101,9 @@ export const usePointEventStore = create()((set, get) => ({ return false; }) as PointEvent[]; set({ events, isLoading: false }); - // Resume polling for any pending events that were interrupted by app restart - // (New events are polled immediately in recordEvents.ts when created) get() .getUnprocessedEvents() .forEach(event => { - // Use event.id as job_id (id is the job_id) pollEventProcessingStatus(event.id).then(result => { if (result === 'completed') { get().markEventAsProcessed(event.id); @@ -119,19 +114,36 @@ export const usePointEventStore = create()((set, get) => ({ }); } catch (parseError) { console.error('Error parsing stored events:', parseError); - // Clear corrupted data await AsyncStorage.removeItem(STORAGE_KEY); set({ events: [], isLoading: false }); } } else { set({ isLoading: false }); } + await get().loadDisclosureEvents(); } catch (error) { console.error('Error loading point events:', error); set({ isLoading: false }); } }, + loadDisclosureEvents: async () => { + try { + const { getDisclosurePointEvents } = await import( + '@/utils/points/getEvents' + ); + const { useProofHistoryStore } = await import( + '@/stores/proofHistoryStore' + ); + await useProofHistoryStore.getState().initDatabase(); + const disclosureEvents = await getDisclosurePointEvents(); + const existingEvents = get().events.filter(e => e.type !== 'disclosure'); + set({ events: [...existingEvents, ...disclosureEvents] }); + } catch (error) { + console.error('Error loading disclosure events:', error); + } + }, + fetchIncomingPoints: async () => { if (get().incomingPoints.promise) { return await get().incomingPoints.promise; diff --git a/app/src/stores/proofHistoryStore.ts b/app/src/stores/proofHistoryStore.ts index 48d25116d..57465d314 100644 --- a/app/src/stores/proofHistoryStore.ts +++ b/app/src/stores/proofHistoryStore.ts @@ -174,6 +174,7 @@ export const useProofHistoryStore = create()((set, get) => { id: row.id.toString(), sessionId: row.sessionId, appName: row.appName, + endpoint: row.endpoint, endpointType: row.endpointType, status: row.status, errorCode: row.errorCode, diff --git a/app/src/stores/proofTypes.ts b/app/src/stores/proofTypes.ts index 41c61fe21..4faa3ac2b 100644 --- a/app/src/stores/proofTypes.ts +++ b/app/src/stores/proofTypes.ts @@ -35,6 +35,7 @@ export interface ProofHistory { sessionId: string; userId: string; userIdType: UserIdType; + endpoint?: string; endpointType: EndpointType; status: ProofStatus; errorCode?: string; diff --git a/app/src/utils/points/getEvents.ts b/app/src/utils/points/getEvents.ts index bcc0af372..b03c564b9 100644 --- a/app/src/utils/points/getEvents.ts +++ b/app/src/utils/points/getEvents.ts @@ -3,6 +3,7 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import type { PointEvent, PointEventType } from '@/utils/points/types'; +import { getWhiteListedDisclosureAddresses } from '@/utils/points/utils'; /** * Shared helper to get events from store filtered by type. @@ -35,7 +36,49 @@ export const getBackupPointEvents = async (): Promise => { }; export const getDisclosurePointEvents = async (): Promise => { - return getEventsByType('disclosure'); + try { + const [whitelistedContracts, { useProofHistoryStore }] = await Promise.all([ + getWhiteListedDisclosureAddresses(), + import('@/stores/proofHistoryStore'), + ]); + + if (whitelistedContracts.length === 0) { + return []; + } + + const whitelistedMap = new Map( + whitelistedContracts.map(c => [ + c.contract_address.toLowerCase(), + c.points_per_disclosure, + ]), + ); + + const proofHistory = useProofHistoryStore.getState().proofHistory; + const disclosureEvents: PointEvent[] = []; + + for (const proof of proofHistory) { + if (proof.status !== 'success' || !proof.endpoint) continue; + + const endpoint = proof.endpoint.toLowerCase(); + + if (!whitelistedMap.has(endpoint)) continue; + + const points = whitelistedMap.get(endpoint)!; + disclosureEvents.push({ + id: proof.sessionId, + title: `${proof.appName} disclosure`, + type: 'disclosure', + timestamp: proof.timestamp, + points, + status: 'completed', + }); + } + + return disclosureEvents; + } catch (error) { + console.error('Error loading disclosure point events:', error); + return []; + } }; export const getPushNotificationPointEvents = async (): Promise< diff --git a/app/src/utils/points/utils.ts b/app/src/utils/points/utils.ts index 33c87aeee..8ecdcf7eb 100644 --- a/app/src/utils/points/utils.ts +++ b/app/src/utils/points/utils.ts @@ -10,6 +10,12 @@ import { getOrGeneratePointsAddress } from '@/providers/authProvider'; import { POINTS_API_BASE_URL } from '@/utils/points/constants'; import type { IncomingPoints } from '@/utils/points/types'; +export type WhitelistedContract = { + contract_address: string; + points_per_disclosure: number; + num_disclosures: number; +}; + export const formatTimeUntilDate = (targetDate: Date): string => { const now = new Date(); const diffMs = targetDate.getTime() - now.getTime(); @@ -103,9 +109,23 @@ export const getTotalPoints = async (address: string): Promise => { }; export const getWhiteListedDisclosureAddresses = async (): Promise< - string[] + WhitelistedContract[] > => { - return []; + try { + const response = await fetch( + `${POINTS_API_BASE_URL}/whitelisted-addresses`, + ); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.contracts || []; + } catch (error) { + console.error('Error fetching whitelisted addresses:', error); + return []; + } }; export const hasUserAnIdentityDocumentRegistered = @@ -146,7 +166,6 @@ export const hasUserDoneThePointsDisclosure = async (): Promise => { }; export const pointsSelfApp = async () => { - const userAddress = (await getPointsAddress())?.toLowerCase(); const endpoint = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0'; const builder = new SelfAppBuilder({ appName: '✨ Self Points', @@ -158,7 +177,6 @@ export const pointsSelfApp = async () => { disclosures: {}, logoBase64: 'https://storage.googleapis.com/self-logo-reverse/Self%20Logomark%20Reverse.png', - selfDefinedData: userAddress, header: '', });