diff --git a/backend/src/server.js b/backend/src/server.js index b5164b4..9a08531 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -53,6 +53,7 @@ import { attestClueSolved, attestClueAttempt, queryAttestationsForHunt, queryRet import { calculateLeaderboardForHunt } from "./services/leaderboard.js"; const MAX_DISTANCE_IN_METERS = parseFloat(process.env.MAX_DISTANCE_IN_METERS) || 60; +const DEFAULT_TOTAL_CLUES = 10; // Gemini configuration const GEMINI_MODEL = "gemini-2.5-flash"; @@ -1253,6 +1254,103 @@ app.get("/leaderboard/:huntId", async (req, res) => { } }); +// Get team attestations endpoint (for viewing attestation links) +app.get("/team-attestations/:huntId/:teamIdentifier", async (req, res) => { + try { + const huntId = parseInt(req.params.huntId); + const teamIdentifier = req.params.teamIdentifier; + const totalClues = parseInt(req.query.totalClues) || DEFAULT_TOTAL_CLUES; + const chainId = req.query.chainId; + + if (isNaN(huntId)) { + return res.status(400).json({ + error: "Invalid hunt ID", + }); + } + + if (!teamIdentifier) { + return res.status(400).json({ + error: "Team identifier is required", + }); + } + + if (!chainId) { + return res.status(400).json({ + error: "Chain ID is required", + }); + } + + console.log(`Fetching team attestations for hunt ${huntId}, team ${teamIdentifier}, chainId ${chainId}...`); + + // Get all successful clue solve attestations for this hunt + const allAttestations = await queryAttestationsForHunt(huntId, chainId); + + // Filter for this team's solve attestations + const teamSolveAttestations = allAttestations.filter(attestation => { + const data = JSON.parse(attestation.data); + return data.teamIdentifier === teamIdentifier; + }); + + // Get retry attestations for each clue (including hunt start with clueIndex: 0) + // Using Promise.allSettled for parallel requests + const retryAttestationsPerClue = {}; + + const clueIndices = Array.from({ length: totalClues + 1 }, (_, i) => i); + const retryPromises = clueIndices.map(clueIndex => + queryRetryAttemptsForClue(huntId, clueIndex, teamIdentifier, chainId) + .then(retryAttestations => ({ clueIndex, retryAttestations })) + .catch(error => { + console.error(`Error fetching retry attestations for clue ${clueIndex}:`, error); + return { clueIndex, retryAttestations: [] }; + }) + ); + + const retryResults = await Promise.allSettled(retryPromises); + + for (const result of retryResults) { + if (result.status === 'fulfilled' && result.value && result.value.retryAttestations && result.value.retryAttestations.length > 0) { + const { clueIndex, retryAttestations } = result.value; + retryAttestationsPerClue[clueIndex] = retryAttestations.map(attestation => { + const data = JSON.parse(attestation.data); + return { + attestationId: attestation.attestationId, + solverAddress: data.solverAddress, + attemptCount: parseInt(data.attemptCount), + timestamp: Math.floor(parseInt(attestation.attestTimestamp) / 1000), + }; + }); + } + } + + // Format solve attestations + const solveAttestations = teamSolveAttestations.map(attestation => { + const data = JSON.parse(attestation.data); + return { + attestationId: attestation.attestationId, + clueIndex: parseInt(data.clueIndex), + solverAddress: data.solverAddress, + teamLeaderAddress: data.teamLeaderAddress, + timeTaken: parseInt(data.timeTaken), + attemptCount: parseInt(data.attemptCount), + timestamp: Math.floor(parseInt(attestation.attestTimestamp) / 1000), + }; + }).sort((a, b) => a.clueIndex - b.clueIndex); + + res.json({ + huntId, + teamIdentifier, + solveAttestations, + retryAttestations: retryAttestationsPerClue, + }); + } catch (error) { + console.error("Error fetching team attestations:", error); + res.status(500).json({ + error: "Failed to fetch team attestations", + message: error.message, + }); + } +}); + // Add health check endpoint for Docker app.get("/health", (req, res) => { res.status(200).json({ status: "OK", timestamp: new Date().toISOString() }); diff --git a/frontend/src/components/AttestationsModal.tsx b/frontend/src/components/AttestationsModal.tsx new file mode 100644 index 0000000..0fdc3b5 --- /dev/null +++ b/frontend/src/components/AttestationsModal.tsx @@ -0,0 +1,348 @@ +import { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; +import { Button } from './ui/button'; +import { Card, CardContent } from './ui/card'; +import { FiRefreshCw, FiExternalLink, FiClock, FiUser, FiCheckCircle, FiRepeat } from 'react-icons/fi'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { AddressDisplay } from './AddressDisplay'; +import { useNetworkState } from '../lib/utils'; + +const BACKEND_URL = import.meta.env.VITE_PUBLIC_BACKEND_URL; +const SIGN_EXPLORER_BASE_URL = 'https://scan.sign.global/attestation'; + +// Constants +const DEFAULT_TOTAL_CLUES = 10; +const HUNT_START_CLUE_INDEX = 0; + +// Attestation data interfaces +interface SolveAttestation { + attestationId: string; + clueIndex: number; + solverAddress: string; + teamLeaderAddress: string; + timeTaken: number; + attemptCount: number; + timestamp: number; +} + +interface RetryAttestation { + attestationId: string; + solverAddress: string; + attemptCount: number; + timestamp: number; +} + +interface AttestationsModalProps { + huntId?: string; + huntName?: string; + teamIdentifier?: string; + totalClues?: number; + isOpen: boolean; + onClose: () => void; +} + +export function AttestationsModal({ + huntId, + huntName, + teamIdentifier, + totalClues = DEFAULT_TOTAL_CLUES, + isOpen, + onClose +}: AttestationsModalProps) { + const [solveAttestations, setSolveAttestations] = useState([]); + const [retryAttestations, setRetryAttestations] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const { chainId } = useNetworkState(); + + // Fetch attestations when modal opens + useEffect(() => { + if (isOpen && huntId && teamIdentifier && chainId) { + fetchAttestations(); + } + }, [isOpen, huntId, teamIdentifier, chainId]); + + const fetchAttestations = async () => { + if (!huntId || !teamIdentifier || !chainId) return; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + `${BACKEND_URL}/team-attestations/${huntId}/${teamIdentifier}?chainId=${chainId}&totalClues=${totalClues}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch attestations: ${response.status}`); + } + + const data = await response.json(); + + setSolveAttestations(data.solveAttestations || []); + setRetryAttestations(data.retryAttestations || {}); + } catch (err) { + console.error('Error fetching attestations:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch attestations'); + toast.error('Failed to load attestations'); + } finally { + setIsLoading(false); + } + }; + + const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp * 1000); + return date.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const formatTimeTaken = (seconds: number) => { + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; + }; + + const getAttestationUrl = (attestationId: string) => { + return `${SIGN_EXPLORER_BASE_URL}/${attestationId}`; + }; + + // Get all clue indices that have any attestations + const getClueIndices = () => { + const solveClues = new Set(solveAttestations.map(a => a.clueIndex)); + const retryClues = new Set(Object.keys(retryAttestations).map(k => parseInt(k)).filter(k => k > HUNT_START_CLUE_INDEX)); + return Array.from(new Set([...solveClues, ...retryClues])).sort((a, b) => a - b); + }; + + const clueIndices = getClueIndices(); + const hasHuntStart = retryAttestations[HUNT_START_CLUE_INDEX] && retryAttestations[HUNT_START_CLUE_INDEX].length > 0; + const hasAnyAttestations = solveAttestations.length > 0 || Object.keys(retryAttestations).length > 0; + + return ( + + + + + Your Attestations + + + + +
+ {/* Hunt Name */} +
+

+ {huntName || (huntId ? `Hunt #${huntId}` : 'Treasure Hunt')} +

+
+ + {/* Loading State */} + {isLoading && ( +
+
Loading attestations...
+
+ )} + + {/* Error State */} + {error && !isLoading && ( +
+
Failed to load attestations
+ +
+ )} + + {/* Empty State */} + {!isLoading && !error && !hasAnyAttestations && ( +
+
No attestations found for your team
+
+ )} + + {/* Attestations List */} + {!isLoading && !error && hasAnyAttestations && ( +
+ {/* Hunt Start Attestation */} + {hasHuntStart && ( + + +
+
+ +
+

Hunt Started

+
+
+ {retryAttestations[HUNT_START_CLUE_INDEX].map((attestation, index) => ( +
+
+ + {formatTimestamp(attestation.timestamp)} +
+ + View + +
+ ))} +
+
+
+ )} + + {/* Clue Attestations */} + {clueIndices.map(clueIndex => { + const solveAttestation = solveAttestations.find(a => a.clueIndex === clueIndex); + const retries = retryAttestations[clueIndex] || []; + + return ( + + + {/* Clue Header */} +
+
+
+ {solveAttestation ? ( + + ) : ( + + )} +
+

Clue #{clueIndex}

+
+ {solveAttestation && ( + + Solved + + )} +
+ + {/* Solve Attestation */} + {solveAttestation && ( +
+
+ + Successful Solve + + + View + +
+
+
+ + +
+
+ + + {formatTimeTaken(solveAttestation.timeTaken)} + +
+
+ + {formatTimestamp(solveAttestation.timestamp)} • {solveAttestation.attemptCount} attempts + +
+
+
+ )} + + {/* Retry Attestations */} + {retries.length > 0 && ( +
+ + Attempts ({retries.length}) + +
+ {retries.map((retry, index) => ( +
+
+ #{retry.attemptCount} + + {formatTimestamp(retry.timestamp)} +
+ + View + +
+ ))} +
+
+ )} +
+
+ ); + })} +
+ )} + + {/* Footer Info */} +
+
+

+ Attestations are immutable on-chain records of your hunt progress via{' '} + + Sign Protocol + +

+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/HuntDetails.tsx b/frontend/src/components/HuntDetails.tsx index d3b61a0..707b694 100644 --- a/frontend/src/components/HuntDetails.tsx +++ b/frontend/src/components/HuntDetails.tsx @@ -29,11 +29,13 @@ import { Hunt, Team } from "../types"; import { buttonStyles } from "../lib/styles.ts"; import { withRetry, MAX_RETRIES } from "@/utils/retryUtils"; import { FiRefreshCw } from "react-icons/fi"; -import { BsBarChartFill } from "react-icons/bs"; +import { BsBarChartFill, BsFileEarmarkCheck } from "react-icons/bs"; import { Leaderboard } from "./Leaderboard"; +import { AttestationsModal } from "./AttestationsModal"; import { checkProgressAndNavigate, - getTeamIdentifier + getTeamIdentifier, + getTotalCluesFromStorage } from "../utils/progressUtils"; import { AddressDisplay } from "./AddressDisplay"; @@ -65,6 +67,9 @@ export function HuntDetails() { // Leaderboard state const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false); + // Attestations modal state + const [isAttestationsOpen, setIsAttestationsOpen] = useState(false); + // Video reference for QR scanner const videoRef = useRef(null); @@ -730,14 +735,26 @@ export function HuntDetails() { {huntData?.name || 'Hunt Details'} - +
+ + +
@@ -1054,6 +1071,16 @@ export function HuntDetails() { isOpen={isLeaderboardOpen} onClose={() => setIsLeaderboardOpen(false)} /> + + {/* Attestations Modal */} + setIsAttestationsOpen(false)} + /> ); diff --git a/frontend/src/components/HuntEnd.tsx b/frontend/src/components/HuntEnd.tsx index dafca62..6eef874 100644 --- a/frontend/src/components/HuntEnd.tsx +++ b/frontend/src/components/HuntEnd.tsx @@ -1,10 +1,11 @@ import { useParams } from "react-router-dom"; import { FaCoins, FaRegClock, FaCheckCircle } from "react-icons/fa"; -import { BsBarChartFill } from "react-icons/bs"; +import { BsBarChartFill, BsFileEarmarkCheck } from "react-icons/bs"; import { Confetti } from "./ui/confetti"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Leaderboard } from "./Leaderboard"; +import { AttestationsModal } from "./AttestationsModal"; import { useEffect, useState } from "react"; import { useReadContract, useActiveAccount } from "thirdweb/react"; import { getContract } from "thirdweb"; @@ -14,6 +15,7 @@ import { toast } from "sonner"; import { client } from "../lib/client"; import { Hunt, Team } from "../types"; import { fetchTeamCombinedScore } from "../utils/leaderboardUtils"; +import { getTeamIdentifier, getTotalCluesFromStorage } from "../utils/progressUtils"; // Type guard to ensure address is a valid hex string function isValidHexAddress(address: string): address is `0x${string}` { @@ -24,6 +26,7 @@ export function HuntEnd() { const { huntId } = useParams(); const [progress, setProgress] = useState(0); const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false); + const [isAttestationsOpen, setIsAttestationsOpen] = useState(false); const [teamScore, setTeamScore] = useState(null); const [isLoadingScore, setIsLoadingScore] = useState(true); const [hasShownConfetti, setHasShownConfetti] = useState(false); @@ -57,6 +60,9 @@ export function HuntEnd() { queryOptions: { enabled: !!userWallet }, }) as { data: Team | undefined }; + // Get team identifier for attestations + const teamIdentifier = getTeamIdentifier(teamData, userWallet || ""); + // Fetch team score from leaderboard useEffect(() => { const loadTeamScore = async () => { @@ -126,14 +132,26 @@ export function HuntEnd() { {huntInfo.title} - +
+ + +
@@ -255,6 +273,16 @@ export function HuntEnd() { isOpen={isLeaderboardOpen} onClose={() => setIsLeaderboardOpen(false)} /> + + {/* Attestations Modal */} + setIsAttestationsOpen(false)} + /> ); diff --git a/frontend/src/utils/progressUtils.ts b/frontend/src/utils/progressUtils.ts index d773c27..aeab170 100644 --- a/frontend/src/utils/progressUtils.ts +++ b/frontend/src/utils/progressUtils.ts @@ -282,6 +282,24 @@ export function getTeamIdentifier(teamData: any, userWallet: string): string { return teamData?.teamId?.toString() || userWallet; } +/** + * Get total clues from localStorage for a hunt + * Returns the number of clues stored, or a default value if not found + */ +export function getTotalCluesFromStorage(huntId: string, defaultValue: number = 10): number { + try { + const storedRiddles = localStorage.getItem(`hunt_riddles_${huntId}`); + if (storedRiddles) { + const riddles = JSON.parse(storedRiddles); + return riddles.length || defaultValue; + } + return defaultValue; + } catch (error) { + console.error("Error reading clues from localStorage:", error); + return defaultValue; + } +} + /** * Check progress and navigate to appropriate location when starting a hunt * This is used when a user clicks "Start Hunt" to determine where to go