diff --git a/.prettierignore b/.prettierignore index 6dafe33e07..97f2bad507 100644 --- a/.prettierignore +++ b/.prettierignore @@ -41,3 +41,6 @@ test-results/ # submodules examples/CRISP/packages/crisp-contracts/lib/risc0-ethereum templates/default/lib/risc0-ethereum + +.claude/ +.claude/settings.local.json \ No newline at end of file diff --git a/examples/CRISP/client/index.html b/examples/CRISP/client/index.html index 4cd2f99cb5..e1de9c6eb3 100644 --- a/examples/CRISP/client/index.html +++ b/examples/CRISP/client/index.html @@ -19,7 +19,6 @@ - - { } /> } /> } /> + } /> } /> } /> } /> diff --git a/examples/CRISP/client/src/components/Cards/PollCard.tsx b/examples/CRISP/client/src/components/Cards/PollCard.tsx index 54606eb106..927827c7c8 100644 --- a/examples/CRISP/client/src/components/Cards/PollCard.tsx +++ b/examples/CRISP/client/src/components/Cards/PollCard.tsx @@ -15,10 +15,27 @@ import { useVoteManagementContext } from '@/context/voteManagement' const PollCard: React.FC = ({ roundId, options, totalVotes, date, endTime }) => { const navigate = useNavigate() const [results, setResults] = useState(options) - const { roundState, setPollResult } = useVoteManagementContext() + const [isActive, setIsActive] = useState(!hasPollEndedByTimestamp(endTime)) + const { roundState, setPollResult, currentRoundId } = useVoteManagementContext() - const isActive = !hasPollEndedByTimestamp(endTime) - const activeTotalCount = roundState?.vote_count ?? 0 + const isCurrentRound = roundId === currentRoundId + const displayVoteCount = isCurrentRound && isActive ? (roundState?.vote_count ?? totalVotes) : totalVotes + + useEffect(() => { + if (!isActive) return + + const checkPollStatus = () => { + const pollEnded = hasPollEndedByTimestamp(endTime) + if (pollEnded) { + setIsActive(false) + } + } + + checkPollStatus() + const interval = setInterval(checkPollStatus, 1000) + + return () => clearInterval(interval) + }, [endTime, isActive]) useEffect(() => { const newPollOptions = markWinner(options) @@ -26,9 +43,12 @@ const PollCard: React.FC = ({ roundId, options, totalVotes, date, en }, [options]) const handleNavigation = () => { - if (isActive) { + if (isActive && isCurrentRound) { return navigate('/current') } + if (isActive && !isCurrentRound) { + return navigate(`/round/${roundId}`) + } navigate(`/result/${roundId}`) setPollResult({ roundId, @@ -41,22 +61,24 @@ const PollCard: React.FC = ({ roundId, options, totalVotes, date, en return (
-
+
{formatDate(date)}
- +
{isActive && ( -
+
-
Active
+
{isCurrentRound ? 'Live' : 'Active'}
)}
- +
) diff --git a/examples/CRISP/client/src/components/ToastAlert.tsx b/examples/CRISP/client/src/components/ToastAlert.tsx index 8718737c6e..77d05b82f6 100644 --- a/examples/CRISP/client/src/components/ToastAlert.tsx +++ b/examples/CRISP/client/src/components/ToastAlert.tsx @@ -6,61 +6,92 @@ // ToastAlert.tsx import React, { useEffect } from 'react' -import { Link, X } from '@phosphor-icons/react' +import { Link, X, Warning, Info } from '@phosphor-icons/react' type ToastAlertProps = { - type: 'success' | 'danger' + type: 'success' | 'danger' | 'warning' | 'info' linkUrl?: string message: string onClose: () => void + persistent?: boolean + duration?: number + id?: string } -const ToastAlert: React.FC = ({ message, type, linkUrl, onClose }) => { +const DEFAULT_DURATION = 5000 + +const ToastAlert: React.FC = ({ message, type, linkUrl, onClose, persistent = false, duration }) => { useEffect(() => { + if (persistent) return + + const timerDuration = duration || DEFAULT_DURATION const timer = setTimeout(() => { onClose() - }, 5000) // Toast will close after 5 seconds + }, timerDuration) return () => clearTimeout(timer) // Clean up the timer - }, [onClose]) + }, [onClose, persistent, duration]) const alertStyles = { success: { container: 'border-lime-600/80 shadow-button-outlined', text: 'text-lime-600', button: 'text-lime-600 hover:text-lime-700', + icon: null, }, danger: { container: 'border-red-600/80 shadow-danger', text: 'text-red-600', button: 'text-red-600 hover:text-red-700', + icon: null, + }, + warning: { + container: 'border-amber-500/80 shadow-lg', + text: 'text-amber-600', + button: 'text-amber-600 hover:text-amber-700', + icon: Warning, + }, + info: { + container: 'border-blue-500/80 shadow-lg', + text: 'text-blue-600', + button: 'text-blue-600 hover:text-blue-700', + icon: Info, }, } const currentAlertStyle = alertStyles[type] + const IconComponent = currentAlertStyle.icon return ( -
+
-
- {linkUrl && ( - - - {message} - - )} - {!linkUrl &&

{message}

} +
+
+ {IconComponent && } + {linkUrl ? ( + + + {message} + + ) : ( +

{message}

+ )} +
-
+ {persistent && ( +
+ )}
) diff --git a/examples/CRISP/client/src/components/VotingStepIndicator.tsx b/examples/CRISP/client/src/components/VotingStepIndicator.tsx new file mode 100644 index 0000000000..5a5da36dab --- /dev/null +++ b/examples/CRISP/client/src/components/VotingStepIndicator.tsx @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import React from 'react' +import { VotingStep } from '@/hooks/voting/useVoteCasting' +import { CheckIcon, CircleNotchIcon, WarningIcon, PencilSimpleIcon, LockIcon, BroadcastIcon, ShieldCheckIcon } from '@phosphor-icons/react' + +type VotingStepIndicatorProps = { + step: VotingStep + message: string + lastActiveStep?: VotingStep | null +} + +const steps: { key: VotingStep; label: string; icon: React.ElementType }[] = [ + { key: 'signing', label: 'Sign', icon: PencilSimpleIcon }, + { key: 'encrypting', label: 'Encrypt', icon: LockIcon }, + { key: 'generating_proof', label: 'Proof', icon: ShieldCheckIcon }, + { key: 'broadcasting', label: 'Broadcast', icon: BroadcastIcon }, +] + +const VotingStepIndicator: React.FC = ({ step, message, lastActiveStep }) => { + const getStepStatus = (stepKey: VotingStep) => { + const stepOrder = steps.map((s) => s.key) + const currentIndex = step === 'error' ? stepOrder.indexOf(lastActiveStep ?? 'signing') : stepOrder.indexOf(step) + const stepIndex = stepOrder.indexOf(stepKey) + + if (step === 'complete') return 'complete' + if (step === 'error') return stepIndex <= currentIndex ? 'error' : 'pending' + if (stepIndex < currentIndex) return 'complete' + if (stepIndex === currentIndex) return 'active' + return 'pending' + } + + const getStepStyles = (status: string) => { + switch (status) { + case 'complete': + return { + circle: 'bg-lime-500 border-lime-500 text-white', + text: 'text-lime-600', + line: 'bg-lime-500', + } + case 'active': + return { + circle: 'bg-white border-lime-500 text-lime-500 animate-pulse', + text: 'text-lime-600 font-bold', + line: 'bg-slate-200', + } + case 'error': + return { + circle: 'bg-red-500 border-red-500 text-white', + text: 'text-red-600', + line: 'bg-red-300', + } + default: + return { + circle: 'bg-white border-slate-300 text-slate-400', + text: 'text-slate-400', + line: 'bg-slate-200', + } + } + } + + return ( +
+ {/* Step indicators */} +
+ {steps.map((s, index) => { + const status = getStepStatus(s.key) + const styles = getStepStyles(status) + const Icon = s.icon + + return ( + +
+
+ {status === 'complete' ? ( + + ) : status === 'active' ? ( + + ) : status === 'error' ? ( + + ) : ( + + )} +
+ {s.label} +
+ {index < steps.length - 1 &&
} + + ) + })} +
+ + {/* Current step message */} +
+

{message}

+
+
+ ) +} + +export default VotingStepIndicator diff --git a/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.context.tsx b/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.context.tsx index 1df3c0e9f7..34397220c3 100644 --- a/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.context.tsx +++ b/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.context.tsx @@ -6,31 +6,81 @@ import { createGenericContext } from '@/utils/create-generic-context' import { NotificationAlertContextType, NotificationAlertProviderProps } from '@/context/NotificationAlert' -import { useCallback, useState } from 'react' +import { useCallback, useState, useMemo } from 'react' import { NotificationAlert } from '@/model/notification.model' import ToastAlert from '@/components/ToastAlert' +const MAX_TOASTS = 5 + +const generateToastId = (): string => `toast-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + const [useNotificationAlertContext, NotificationAlertContextProvider] = createGenericContext() const NotificationAlertProvider = ({ children }: NotificationAlertProviderProps) => { - const [toast, setToast] = useState(null) + const [toasts, setToasts] = useState([]) + + const closeToast = useCallback((id?: string) => { + if (id) { + setToasts((prev) => prev.filter((t) => t.id !== id)) + } else { + setToasts((prev) => { + const nonPersistentIndex = prev.findIndex((t) => !t.persistent) + if (nonPersistentIndex !== -1) { + return prev.filter((_, i) => i !== nonPersistentIndex) + } + return prev + }) + } + }, []) const showToast = useCallback((toast: NotificationAlert) => { - setToast(toast) + const toastWithId: NotificationAlert = { + ...toast, + id: toast.id || generateToastId(), + } + + setToasts((prev) => { + const newToasts = prev.length >= MAX_TOASTS ? prev.slice(1) : prev + + return [...newToasts, toastWithId] + }) }, []) - const closeToast = useCallback(() => { - setToast(null) + const clearAllToasts = useCallback(() => { + setToasts([]) }, []) + const contextValue = useMemo( + () => ({ + showToast, + closeToast, + clearAllToasts, + }), + [showToast, closeToast, clearAllToasts], + ) + return ( - + {children} - {toast && } +
+ {toasts.map((toast) => ( + closeToast(toast.id)} + /> + ))} +
) } diff --git a/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.types.ts b/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.types.ts index 2e694248e7..e8ae1e9820 100644 --- a/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.types.ts +++ b/examples/CRISP/client/src/context/NotificationAlert/NotificationAlert.types.ts @@ -9,6 +9,8 @@ import { ReactNode } from 'react' export type NotificationAlertContextType = { showToast: (toast: NotificationAlert) => void + closeToast: (id?: string) => void + clearAllToasts: () => void } export type NotificationAlertProviderProps = { diff --git a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx index 4c614137b7..fccf6e2391 100644 --- a/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx +++ b/examples/CRISP/client/src/context/voteManagement/VoteManagement.context.tsx @@ -5,9 +5,9 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { createGenericContext } from '@/utils/create-generic-context' -import { VoteManagementContextType, VoteManagementProviderProps } from '@/context/voteManagement' +import { VoteManagementContextType, VoteManagementProviderProps, VoteStatus } from '@/context/voteManagement' import { useWebAssemblyHook } from '@/hooks/wasm/useWebAssembly' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAccount } from 'wagmi' import { VoteStateLite, VotingRound } from '@/model/vote.model' import { useEnclaveServer } from '@/hooks/enclave/useEnclaveServer' @@ -18,12 +18,27 @@ import { handleGenericError } from '@/utils/handle-generic-error' const [useVoteManagementContext, VoteManagementContextProvider] = createGenericContext() +const generateSessionId = (): string => { + return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}` +} + +const getVoteCacheKey = (sessionId: string, roundId: number, address: string): string => { + return `crisp-vote-status-${sessionId}-${roundId}-${address.toLowerCase()}` +} + +const VOTE_CACHE_DURATION = 5 * 60 * 1000 + const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { /** * Wagmi Account State **/ const { address, isConnected } = useAccount() + /** + * Session ID for cache uniqueness (regenerated on page load) + **/ + const sessionId = useMemo(() => generateSessionId(), []) + /** * Voting Management States **/ @@ -36,6 +51,10 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { const [pastPolls, setPastPolls] = useState([]) const [txUrl, setTxUrl] = useState(undefined) const [pollResult, setPollResult] = useState(null) + const [currentRoundId, setCurrentRoundId] = useState(null) + const [hasVotedInCurrentRound, setHasVotedInCurrentRound] = useState(false) + const [voteStatusLoading, setVoteStatusLoading] = useState(false) + const voteStatusCache = useRef>(new Map()) /** * Voting Management Methods @@ -48,19 +67,68 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { getWebResult, getCurrentRound, broadcastVote, + getVoteStatus, } = useEnclaveServer() + const checkVoteStatus = useCallback( + async (roundId: number, userAddress: string, forceRefresh: boolean = false): Promise => { + if (!userAddress || roundId === null || roundId === undefined) return false + + const cacheKey = getVoteCacheKey(sessionId, roundId, userAddress) + + if (!forceRefresh) { + const cached = voteStatusCache.current.get(cacheKey) + if (cached && Date.now() - cached.lastChecked < VOTE_CACHE_DURATION) { + return cached.hasVoted + } + } + + setVoteStatusLoading(true) + try { + const response = await getVoteStatus({ round_id: roundId, address: userAddress }) + if (response) { + const status: VoteStatus = { + hasVoted: response.has_voted, + roundId: roundId, + lastChecked: Date.now(), + } + voteStatusCache.current.set(cacheKey, status) + return response.has_voted + } + return false + } catch (error) { + console.error('Error checking vote status:', error) + return false + } finally { + setVoteStatusLoading(false) + } + }, + [sessionId, getVoteStatus], + ) + + const markVotedInRound = useCallback( + (roundId: number) => { + if (!user?.address) return + + const cacheKey = getVoteCacheKey(sessionId, roundId, user.address) + const status: VoteStatus = { + hasVoted: true, + roundId: roundId, + lastChecked: Date.now(), + } + voteStatusCache.current.set(cacheKey, status) + + setHasVotedInCurrentRound((prevHasVoted) => { + return roundId === currentRoundId ? true : prevHasVoted + }) + }, + [sessionId, user?.address, currentRoundId], + ) + const initialLoad = async () => { - console.log('Loading wasm') const currentRound = await getCurrentRound() if (currentRound) { - await getRoundStateLite(currentRound.id) - } - } - - const existNewRound = async () => { - const currentRound = await getCurrentRound() - if (currentRound && votingRound && currentRound.id > votingRound.round_id) { + setCurrentRoundId(currentRound.id) await getRoundStateLite(currentRound.id) } } @@ -80,6 +148,7 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { setVotingRound({ round_id: fetchedRoundState.id, pk_bytes: fetchedRoundState.committee_public_key }) setPollOptions(generatePoll({ round_id: fetchedRoundState.id, emojis: fetchedRoundState.emojis })) setRoundEndDate(convertTimestampToDate(fetchedRoundState.start_time, fetchedRoundState.duration)) + setCurrentRoundId(fetchedRoundState.id) } } @@ -109,9 +178,29 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { setUser({ address }) } else { setUser(null) + setHasVotedInCurrentRound(false) + voteStatusCache.current.clear() } }, [isConnected, address]) + useEffect(() => { + let cancelled = false + const checkStatus = async () => { + if (user?.address && currentRoundId !== null && currentRoundId >= 0) { + const hasVoted = await checkVoteStatus(currentRoundId, user.address) + if (!cancelled) { + setHasVotedInCurrentRound(hasVoted) + } + } else { + setHasVotedInCurrentRound(false) + } + } + checkStatus() + return () => { + cancelled = true + } + }, [user?.address, currentRoundId, checkVoteStatus]) + return ( { pastPolls, txUrl, pollResult, + currentRoundId, + hasVotedInCurrentRound, + voteStatusLoading, + sessionId, setPollResult, getWebResultByRound, setTxUrl, - existNewRound, getWebResult, setPastPolls, getPastPolls, @@ -138,6 +230,8 @@ const VoteManagementProvider = ({ children }: VoteManagementProviderProps) => { setVotingRound, setUser, generateProof, + checkVoteStatus, + markVotedInRound, }} > {children} diff --git a/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts b/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts index b34492fc8a..4dd7cfac3d 100644 --- a/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts +++ b/examples/CRISP/client/src/context/voteManagement/VoteManagement.types.ts @@ -6,9 +6,15 @@ import type React from 'react' import { ReactNode } from 'react' -import { BroadcastVoteRequest, BroadcastVoteResponse, VoteStateLite, VotingRound } from '@/model/vote.model' +import { BroadcastVoteRequest, BroadcastVoteResponse, VotingRound, VoteStateLite } from '@/model/vote.model' import { Poll, PollRequestResult, PollResult } from '@/model/poll.model' +export type VoteStatus = { + hasVoted: boolean + roundId: number + lastChecked: number +} + export type VoteManagementContextType = { isLoading: boolean user: { address: string } | null @@ -19,20 +25,31 @@ export type VoteManagementContextType = { pastPolls: PollResult[] txUrl: string | undefined pollResult: PollResult | null + currentRoundId: number | null + hasVotedInCurrentRound: boolean + voteStatusLoading: boolean + sessionId: string setPollResult: React.Dispatch> getWebResultByRound: (round_id: number) => Promise setTxUrl: React.Dispatch> setPollOptions: React.Dispatch> initialLoad: () => Promise - existNewRound: () => Promise getPastPolls: () => Promise setVotingRound: React.Dispatch> setUser: React.Dispatch> - generateProof: (voteId: bigint, publicKey: Uint8Array, address: string, signature: string) => Promise + generateProof: ( + voteId: bigint, + publicKey: Uint8Array, + address: string, + signature: string, + previousCiphertext?: Uint8Array, + ) => Promise broadcastVote: (vote: BroadcastVoteRequest) => Promise getRoundStateLite: (roundCount: number) => Promise setPastPolls: React.Dispatch> getWebResult: () => Promise + checkVoteStatus: (roundId: number, address: string, forceRefresh?: boolean) => Promise + markVotedInRound: (roundId: number) => void } export type VoteManagementProviderProps = { diff --git a/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts b/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts index 0d85667b34..ec6ad335a5 100644 --- a/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts +++ b/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts @@ -5,7 +5,14 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { handleGenericError } from '@/utils/handle-generic-error' -import { BroadcastVoteRequest, BroadcastVoteResponse, CurrentRound, VoteStateLite } from '@/model/vote.model' +import { + BroadcastVoteRequest, + BroadcastVoteResponse, + CurrentRound, + VoteStateLite, + VoteStatusRequest, + VoteStatusResponse, +} from '@/model/vote.model' import { useApi } from '../generic/useFetchApi' import { PollRequestResult } from '@/model/poll.model' @@ -19,16 +26,18 @@ const EnclaveEndpoints = { GetWebResult: `${ENCLAVE_API}/state/result`, GetWebAllResult: `${ENCLAVE_API}/state/all`, BroadcastVote: `${ENCLAVE_API}/voting/broadcast`, + GetVoteStatus: `${ENCLAVE_API}/voting/status`, } as const export const useEnclaveServer = () => { - const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetRoundStateLite, GetWebResult } = EnclaveEndpoints + const { GetCurrentRound, GetWebAllResult, BroadcastVote, GetRoundStateLite, GetWebResult, GetVoteStatus } = EnclaveEndpoints const { fetchData, isLoading } = useApi() const getCurrentRound = () => fetchData(GetCurrentRound) const getRoundStateLite = (round_id: number) => fetchData(GetRoundStateLite, 'post', { round_id }) const broadcastVote = (vote: BroadcastVoteRequest) => fetchData(BroadcastVote, 'post', vote) const getWebResult = () => fetchData(GetWebAllResult, 'get') const getWebResultByRound = (round_id: number) => fetchData(GetWebResult, 'post', { round_id }) + const getVoteStatus = (request: VoteStatusRequest) => fetchData(GetVoteStatus, 'post', request) return { isLoading, @@ -37,5 +46,6 @@ export const useEnclaveServer = () => { getCurrentRound, getRoundStateLite, broadcastVote, + getVoteStatus, } } diff --git a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts index 485b0f529f..4342ba7e1d 100644 --- a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts +++ b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts @@ -11,27 +11,78 @@ import { useSignMessage } from 'wagmi' import { useVoteManagementContext } from '@/context/voteManagement' import { useNotificationAlertContext } from '@/context/NotificationAlert/NotificationAlert.context.tsx' import { Poll } from '@/model/poll.model' -import { BroadcastVoteRequest } from '@/model/vote.model' -import { SIGNATURE_MESSAGE } from '@crisp-e3/sdk' +import { BroadcastVoteRequest, VoteStateLite, VotingRound } from '@/model/vote.model' -export const useVoteCasting = () => { - const { user, roundState, votingRound, generateProof, broadcastVote, setTxUrl } = useVoteManagementContext() +import { encryptVote, SIGNATURE_MESSAGE } from '@crisp-e3/sdk' + +export type VotingStep = 'idle' | 'signing' | 'encrypting' | 'generating_proof' | 'broadcasting' | 'confirming' | 'complete' | 'error' + +const extractCleanErrorMessage = (errorMessage: string | undefined): string => { + if (!errorMessage) return 'Failed to broadcast the vote. Please try again.' + + if (errorMessage.includes('Internal error') || errorMessage.includes('-32603')) { + return 'Transaction failed. The blockchain rejected the vote. Please try again.' + } + if (errorMessage.includes('insufficient funds')) { + return 'Insufficient funds to process the transaction.' + } + if (errorMessage.includes('nonce')) { + return 'Transaction conflict. Please try again.' + } + if (errorMessage.includes('gas')) { + return 'Transaction failed due to gas issues. Please try again.' + } + if (errorMessage.includes('reverted')) { + return 'Transaction was reverted by the contract.' + } + + if (errorMessage.length > 100) { + return 'Vote broadcast failed. Please try again.' + } + + return errorMessage +} + +export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVotingRound?: VotingRound | null) => { + const { + user, + roundState: contextRoundState, + votingRound: contextVotingRound, + generateProof, + broadcastVote, + setTxUrl, + markVotedInRound, + hasVotedInCurrentRound, + } = useVoteManagementContext() + + const roundState = customRoundState ?? contextRoundState + const votingRound = customVotingRound ?? contextVotingRound const { signMessageAsync } = useSignMessage() const { showToast } = useNotificationAlertContext() const navigate = useNavigate() const [isLoading, setIsLoading] = useState(false) + const [votingStep, setVotingStep] = useState('idle') + const [lastActiveStep, setLastActiveStep] = useState(null) + const [stepMessage, setStepMessage] = useState('') const handleProofGeneration = useCallback( - async (vote: Poll, address: string, signature: string) => { + async (vote: Poll, address: string, signature: string, previousCiphertext?: Uint8Array) => { if (!votingRound) throw new Error('No voting round available for proof generation') - return generateProof(BigInt(vote.value), new Uint8Array(votingRound.pk_bytes), address, signature) + return generateProof(BigInt(vote.value), new Uint8Array(votingRound.pk_bytes), address, signature, previousCiphertext) }, [generateProof, votingRound], ) + const resetVotingState = useCallback(() => { + setVotingStep('idle') + setLastActiveStep(null) + setStepMessage('') + setIsLoading(false) + }, []) + const castVoteWithProof = useCallback( - async (pollSelected: Poll | null) => { + async (pollSelected: Poll | null, isVoteUpdate: boolean = false) => { if (!pollSelected) { console.log('Cannot cast vote: Poll option not selected.') showToast({ type: 'danger', message: 'Please select a poll option first.' }) @@ -39,22 +90,60 @@ export const useVoteCasting = () => { } if (!user || !roundState) { console.error('Cannot cast vote: Missing user or round state.') - showToast({ type: 'danger', message: 'Cannot cast vote. Ensure you are connected, and the round is active.' }) + showToast({ + type: 'danger', + message: 'Cannot cast vote. Ensure you are connected, and the round is active.', + persistent: true, + }) return } setIsLoading(true) - console.log('Processing vote...') - - // For now just sign and do not do nothing with the signature - const signature = await signMessageAsync({ message: SIGNATURE_MESSAGE }) + const actionText = isVoteUpdate ? 'Updating vote' : 'Processing vote' + console.log(`${actionText}...`) try { - const encodedProof = await handleProofGeneration(pollSelected, user.address, signature) + // Step 1: Signing + setVotingStep('signing') + setLastActiveStep('signing') + setStepMessage('Please sign the message in your wallet...') + // const message = `Vote for round ${roundState.id}` + + let signature: string + try { + signature = await signMessageAsync({ message: SIGNATURE_MESSAGE }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (signError) { + console.log('User rejected signature or signing failed') + showToast({ type: 'danger', message: 'Signature cancelled or failed.' }) + resetVotingState() + return + } + + // Step 2: Encrypting vote + setVotingStep('encrypting') + setLastActiveStep('encrypting') + setStepMessage('') + + // @todo get this from the contract or server + const newEncryptionTemp = encryptVote({ yes: 0n, no: 0n }, new Uint8Array(votingRound!.pk_bytes)) + const previousCiphertext = isVoteUpdate ? newEncryptionTemp : undefined + const encodedProof = await handleProofGeneration(pollSelected, user.address, signature, previousCiphertext) if (!encodedProof) { - throw new Error('Failed to generate proof.') + throw new Error('Failed to encrypt vote.') } + // Step 3: Generating proof + setVotingStep('generating_proof') + setLastActiveStep('generating_proof') + + // small delay for UX + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Step 4: Broadcasting + setVotingStep('broadcasting') + setLastActiveStep('broadcasting') + const voteRequest: BroadcastVoteRequest = { round_id: roundState.id, encoded_proof: encodedProof, @@ -66,32 +155,37 @@ export const useVoteCasting = () => { if (broadcastVoteResponse) { switch (broadcastVoteResponse.status) { case 'success': { + setVotingStep('complete') + setStepMessage('Vote submitted successfully!') + const url = `https://sepolia.etherscan.io/tx/${broadcastVoteResponse.tx_hash}` setTxUrl(url) + + markVotedInRound(roundState.id) + + const successMessage = broadcastVoteResponse.is_vote_update ? 'Vote updated successfully!' : 'Vote submitted successfully!' showToast({ type: 'success', - message: broadcastVoteResponse.message || 'Successfully voted', + message: successMessage, linkUrl: url, }) navigate(`/result/${roundState.id}/confirmation`) break } - case 'user_already_voted': - showToast({ - type: 'danger', - message: broadcastVoteResponse.message || 'User has already voted', - }) - break case 'failed_broadcast': + setVotingStep('error') showToast({ type: 'danger', - message: 'Failed to broadcast the vote', + message: extractCleanErrorMessage(broadcastVoteResponse.message), + persistent: true, }) break default: + setVotingStep('error') showToast({ type: 'danger', - message: broadcastVoteResponse.message || 'Error broadcasting the vote', + message: extractCleanErrorMessage(broadcastVoteResponse.message), + persistent: true, }) break } @@ -99,14 +193,38 @@ export const useVoteCasting = () => { throw new Error('Received no response after broadcasting vote.') } } catch (error) { + setVotingStep('error') console.error('Vote processing failed:', error) - showToast({ type: 'danger', message: `Vote failed: ${error instanceof Error ? error.message : String(error)}` }) + showToast({ + type: 'danger', + message: `Vote failed: ${error instanceof Error ? error.message : String(error)}`, + persistent: true, + }) } finally { setIsLoading(false) } }, - [user, roundState, votingRound, generateProof, broadcastVote, setTxUrl, showToast, navigate, handleProofGeneration, signMessageAsync], + [ + user, + roundState, + broadcastVote, + setTxUrl, + showToast, + navigate, + handleProofGeneration, + signMessageAsync, + markVotedInRound, + resetVotingState, + ], ) - return { castVoteWithProof, isLoading } + return { + castVoteWithProof, + isLoading, + votingStep, + lastActiveStep, + stepMessage, + resetVotingState, + hasVotedInCurrentRound, + } } diff --git a/examples/CRISP/client/src/hooks/wasm/useWebAssembly.tsx b/examples/CRISP/client/src/hooks/wasm/useWebAssembly.tsx index f3b371408d..4cd6eaed53 100644 --- a/examples/CRISP/client/src/hooks/wasm/useWebAssembly.tsx +++ b/examples/CRISP/client/src/hooks/wasm/useWebAssembly.tsx @@ -23,7 +23,13 @@ export const useWebAssemblyHook = () => { } }, []) - const generateProof = async (voteId: bigint, publicKey: Uint8Array, address: string, signature: string): Promise => { + const generateProof = async ( + voteId: bigint, + publicKey: Uint8Array, + address: string, + signature: string, + previousCiphertext?: Uint8Array, + ): Promise => { if (!worker) { console.error('WebAssembly worker not initialized') return @@ -31,7 +37,7 @@ export const useWebAssemblyHook = () => { return new Promise((resolve, reject) => { setIsLoading(true) - worker.postMessage({ type: 'generate_proof', data: { voteId, publicKey, address, signature } }) + worker.postMessage({ type: 'generate_proof', data: { voteId, publicKey, address, signature, previousCiphertext } }) worker.onmessage = async (event) => { const { type, success, encodedProof, error } = event.data if (type === 'generate_proof') { diff --git a/examples/CRISP/client/src/model/notification.model.ts b/examples/CRISP/client/src/model/notification.model.ts index 1621746638..281122fb4c 100644 --- a/examples/CRISP/client/src/model/notification.model.ts +++ b/examples/CRISP/client/src/model/notification.model.ts @@ -6,6 +6,9 @@ export interface NotificationAlert { message: string - type: 'success' | 'danger' + type: 'success' | 'danger' | 'warning' | 'info' linkUrl?: string + persistent?: boolean + id?: string + duration?: number } diff --git a/examples/CRISP/client/src/model/vote.model.ts b/examples/CRISP/client/src/model/vote.model.ts index 0281b48eb2..e72c32df37 100644 --- a/examples/CRISP/client/src/model/vote.model.ts +++ b/examples/CRISP/client/src/model/vote.model.ts @@ -27,11 +27,24 @@ export interface BroadcastVoteRequest { address: string } -export type VoteResponseStatus = 'success' | 'user_already_voted' | 'failed_broadcast' +export type VoteResponseStatus = 'success' | 'failed_broadcast' export interface BroadcastVoteResponse { status: VoteResponseStatus tx_hash?: string message?: string + is_vote_update?: boolean +} + +export interface VoteStatusRequest { + round_id: number + address: string +} + +export interface VoteStatusResponse { + round_id: number + address: string + has_voted: boolean + round_status?: string } export interface VoteStateLite { diff --git a/examples/CRISP/client/src/pages/DailyPoll/DailyPoll.tsx b/examples/CRISP/client/src/pages/DailyPoll/DailyPoll.tsx index 1fccb86f31..8555285da0 100644 --- a/examples/CRISP/client/src/pages/DailyPoll/DailyPoll.tsx +++ b/examples/CRISP/client/src/pages/DailyPoll/DailyPoll.tsx @@ -4,33 +4,20 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import React, { Fragment, useEffect, useState } from 'react' +import React, { Fragment, useMemo } from 'react' import DailyPollSection from '@/pages/Landing/components/DailyPoll' import { useVoteManagementContext } from '@/context/voteManagement' import { convertTimestampToDate } from '@/utils/methods' const DailyPoll: React.FC = () => { - const { existNewRound, roundState } = useVoteManagementContext() - const [newRoundLoading, setNewRoundLoading] = useState(false) - const endTime = roundState && convertTimestampToDate(roundState?.start_time, roundState?.duration) + const { roundState, isLoading } = useVoteManagementContext() + const endTime = useMemo(() => (roundState ? convertTimestampToDate(roundState.start_time, roundState.duration) : null), [roundState]) - useEffect(() => { - const checkRound = async () => { - setNewRoundLoading(true) - await existNewRound() - } - checkRound() - }, []) - - useEffect(() => { - if (roundState) { - setNewRoundLoading(false) - } - }, [roundState]) + const loading = isLoading || !roundState return ( - + ) } diff --git a/examples/CRISP/client/src/pages/HistoricPoll/HistoricPoll.tsx b/examples/CRISP/client/src/pages/HistoricPoll/HistoricPoll.tsx index 87b1ababe2..dc92d46721 100644 --- a/examples/CRISP/client/src/pages/HistoricPoll/HistoricPoll.tsx +++ b/examples/CRISP/client/src/pages/HistoricPoll/HistoricPoll.tsx @@ -28,7 +28,7 @@ const HistoricPoll: React.FC = () => { behavior: 'smooth', }) setLoadingMore(false) - }, 1000) // 1 second delay + }, 1000) }, [loadingMore, isLoading]) useEffect(() => { @@ -38,10 +38,11 @@ const HistoricPoll: React.FC = () => { } fetchPastPolls() } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [votingRound]) useEffect(() => { - setVisiblePolls(pastPolls.slice(0, 12)) // Initialize with the first 12 polls + setVisiblePolls(pastPolls.slice(0, 12)) }, [pastPolls]) useEffect(() => { @@ -77,7 +78,7 @@ const HistoricPoll: React.FC = () => {
)} - {!pastPolls.length && !isLoading &&

There are no historic polls.

} + {!pastPolls.length && !isLoading &&

There are no historic polls.

} {visiblePolls.length > 0 && (
{visiblePolls.map((pollResult: PollResult, index: number) => { diff --git a/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx b/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx index 152b3c5bd5..8badae8ccb 100644 --- a/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx +++ b/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx @@ -15,19 +15,21 @@ import { hasPollEnded } from '@/utils/methods' import CountdownTimer from '@/components/CountdownTime' import { useModal } from 'connectkit' import { useVoteCasting } from '@/hooks/voting/useVoteCasting' +import VotingStepIndicator from '@/components/VotingStepIndicator' type DailyPollSectionProps = { loading?: boolean endTime: Date | null + title?: string } -const DailyPollSection: React.FC = ({ loading, endTime }) => { - const { user, pollOptions, setPollOptions, roundState } = useVoteManagementContext() +const DailyPollSection: React.FC = ({ loading, endTime, title = 'Daily Poll' }) => { + const { user, pollOptions, setPollOptions, roundState, hasVotedInCurrentRound, voteStatusLoading } = useVoteManagementContext() const isEnded = roundState ? hasPollEnded(roundState?.duration, roundState?.start_time) : false const [pollSelected, setPollSelected] = useState(null) const [noPollSelected, setNoPollSelected] = useState(true) const { setOpen } = useModal() - const { castVoteWithProof, isLoading: isCastingVote } = useVoteCasting() + const { castVoteWithProof, isLoading: isCastingVote, votingStep, lastActiveStep, stepMessage } = useVoteCasting() const statusClass = !isEnded ? 'lime' : 'red' @@ -56,7 +58,7 @@ const DailyPollSection: React.FC = ({ loading, endTime }) return } - await castVoteWithProof(pollSelected) + await castVoteWithProof(pollSelected, hasVotedInCurrentRound) } return ( @@ -68,12 +70,12 @@ const DailyPollSection: React.FC = ({ loading, endTime })
-

Daily Poll

+

{title}

Choose your favorite

- {!roundState &&

There are is no current daily poll.

} + {!roundState &&

No active poll found.

}
{roundState && ( -
+
@@ -83,6 +85,16 @@ const DailyPollSection: React.FC = ({ loading, endTime })
{roundState.vote_count} votes
+ {hasVotedInCurrentRound && ( +
+ You voted +
+ )} + {voteStatusLoading && ( +
+ Checking... +
+ )}
)} @@ -91,13 +103,8 @@ const DailyPollSection: React.FC = ({ loading, endTime })
)} - {isCastingVote && ( -
-

Casting Vote

- -
- )} - {loading && } + {isCastingVote && } + {loading && !isCastingVote && }
{pollOptions.map((poll) => (
@@ -108,14 +115,18 @@ const DailyPollSection: React.FC = ({ loading, endTime }) ))}
{roundState && ( -
- {noPollSelected && !isEnded &&
Select your favorite
} +
+ {noPollSelected && !isEnded && ( +
+ {hasVotedInCurrentRound ? 'Select an option to update your vote' : 'Select your favorite'} +
+ )}
)} diff --git a/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx b/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx new file mode 100644 index 0000000000..c4b9d13f54 --- /dev/null +++ b/examples/CRISP/client/src/pages/RoundPoll/RoundPoll.tsx @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import React, { Fragment, useEffect, useMemo, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import DailyPollSection from '@/pages/Landing/components/DailyPoll' +import { useVoteManagementContext } from '@/context/voteManagement' +import { convertTimestampToDate } from '@/utils/methods' +import LoadingAnimation from '@/components/LoadingAnimation' + +const RoundPoll: React.FC = () => { + const { roundId } = useParams<{ roundId: string }>() + const navigate = useNavigate() + const { roundState, getRoundStateLite, isLoading, currentRoundId } = useVoteManagementContext() + const [loading, setLoading] = useState(true) + + const parsedRoundId = roundId ? parseInt(roundId, 10) : null + const isValidRoundId = parsedRoundId !== null && !isNaN(parsedRoundId) + + // If this is the current round, redirect to /current + useEffect(() => { + if (isValidRoundId && currentRoundId !== null && parsedRoundId === currentRoundId) { + navigate('/current', { replace: true }) + } + }, [isValidRoundId, parsedRoundId, currentRoundId, navigate]) + + // Load the specific round + useEffect(() => { + const loadRound = async () => { + if (isValidRoundId && parsedRoundId !== null) { + setLoading(true) + await getRoundStateLite(parsedRoundId) + setLoading(false) + } + } + loadRound() + }, [isValidRoundId, parsedRoundId, getRoundStateLite]) + + const endTime = useMemo(() => (roundState ? convertTimestampToDate(roundState.start_time, roundState.duration) : null), [roundState]) + + const title = `Round #${roundId}` + + if (loading || isLoading) { + return ( +
+ +
+ ) + } + + return ( + + + + ) +} + +export default RoundPoll diff --git a/examples/CRISP/client/src/pages/RoundPoll/index.ts b/examples/CRISP/client/src/pages/RoundPoll/index.ts new file mode 100644 index 0000000000..3e153c5c32 --- /dev/null +++ b/examples/CRISP/client/src/pages/RoundPoll/index.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +export { default } from './RoundPoll' diff --git a/examples/CRISP/client/src/utils/methods.ts b/examples/CRISP/client/src/utils/methods.ts index 18d1ef8a1f..a606354a8c 100644 --- a/examples/CRISP/client/src/utils/methods.ts +++ b/examples/CRISP/client/src/utils/methods.ts @@ -17,7 +17,7 @@ export const markWinner = (options: PollOption[]) => { export const convertTimestampToDate = (timestamp: number, secondsToAdd: number = 0): Date => { const date = new Date(timestamp * 1000) - date.setSeconds(date.getMinutes() + secondsToAdd) + date.setSeconds(date.getSeconds() + secondsToAdd) return date } @@ -48,7 +48,7 @@ export const formatDate = (isoDateString: string): string => { hour12: true, }) - return `${dateFormatter.format(date)} - ${timeFormatter.format(date)}` + return `${dateFormatter.format(date)} - ${timeFormatter.format(date)}` } export const convertPollData = (request: PollRequestResult[]): PollResult[] => { @@ -113,9 +113,9 @@ export const convertVoteStateLite = (voteState: VoteStateLite): PollResult => { } } -export const debounce = (func: (...args: any[]) => void, wait: number) => { +export const debounce = void>(func: T, wait: number) => { let timeout: ReturnType - return (...args: any[]) => { + return (...args: Parameters) => { clearTimeout(timeout) timeout = setTimeout(() => func(...args), wait) } diff --git a/examples/CRISP/packages/crisp-sdk/src/types.ts b/examples/CRISP/packages/crisp-sdk/src/types.ts index 550eb043f6..52a6209619 100644 --- a/examples/CRISP/packages/crisp-sdk/src/types.ts +++ b/examples/CRISP/packages/crisp-sdk/src/types.ts @@ -192,4 +192,5 @@ export type VoteProofInputs = { balance: bigint vote: Vote signature: `0x${string}` + previousCiphertext?: Uint8Array } diff --git a/examples/CRISP/scripts/dev_server.sh b/examples/CRISP/scripts/dev_server.sh index 928ad6c88b..3e1aa838bf 100755 --- a/examples/CRISP/scripts/dev_server.sh +++ b/examples/CRISP/scripts/dev_server.sh @@ -4,4 +4,4 @@ set -e export CARGO_INCREMENTAL=1 -(cd ./server && cargo run --bin server) +(cd ./server && rm -rf database && cargo run --bin server) diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index d10c083c46..9cc3fc8f0c 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -54,6 +54,22 @@ pub struct VoteResponse { pub status: VoteResponseStatus, pub tx_hash: Option, pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_vote_update: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct VoteStatusRequest { + pub round_id: u64, + pub address: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct VoteStatusResponse { + pub round_id: u64, + pub address: String, + pub has_voted: bool, + pub round_status: Option, } #[derive(Debug, Deserialize, Serialize)] diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index 1c417092d3..264b7a296d 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -94,9 +94,19 @@ impl CrispE3Repository { pub async fn insert_ciphertext_input(&mut self, vote: Vec, index: u64) -> Result<()> { let key = self.crisp_key(); - self.store.modify(&key, |e3_obj: Option| { + self.store + .modify(&key, |e3_obj: Option| { e3_obj.map(|mut e| { - e.ciphertext_inputs.push((vote.clone(), index)); + // We check if we already have a vote at this index (re-vote case) + // If we do, we update the vote + // If we don't, we append the vote + if let Some(existing) = + e.ciphertext_inputs.iter_mut().find(|(_, i)| *i == index) + { + existing.0 = vote.clone(); + } else { + e.ciphertext_inputs.push((vote.clone(), index)); + } e }) }) @@ -137,7 +147,7 @@ impl CrispE3Repository { pub async fn get_vote_count(&self) -> Result { let e3_crisp = self.get_crisp().await?; - Ok(u64::try_from(e3_crisp.ciphertext_inputs.len())?) + Ok(u64::try_from(e3_crisp.has_voted.len())?) } pub async fn update_status(&mut self, value: &str) -> Result<()> { @@ -205,7 +215,7 @@ impl CrispE3Repository { status: e3_crisp.status, chain_id: e3.chain_id, duration: e3.duration, - vote_count: u64::try_from(e3_crisp.ciphertext_inputs.len())?, + vote_count: u64::try_from(e3_crisp.has_voted.len())?, start_time: e3_crisp.start_time, start_block: e3.request_block, enclave_address: e3.enclave_address, @@ -219,7 +229,7 @@ impl CrispE3Repository { let e3_crisp = self.get_crisp().await?; Ok(e3_crisp.ciphertext_inputs) } - + pub async fn set_ciphertext_output(&mut self, data: Vec) -> Result<()> { self.get_e3_repo().set_ciphertext_output(data).await?; Ok(()) diff --git a/examples/CRISP/server/src/server/routes/voting.rs b/examples/CRISP/server/src/server/routes/voting.rs index d75ef36629..0484a8097b 100644 --- a/examples/CRISP/server/src/server/routes/voting.rs +++ b/examples/CRISP/server/src/server/routes/voting.rs @@ -5,29 +5,78 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use crate::server::{ - app_data::AppData, - database::SledDB, - models::{VoteRequest, VoteResponse, VoteResponseStatus}, - repo::CrispE3Repository, - CONFIG, + CONFIG, app_data::AppData, database::SledDB, models::{ + VoteRequest, VoteResponse, VoteResponseStatus, VoteStatusRequest, VoteStatusResponse + }, repo::CrispE3Repository }; use actix_web::{web, HttpResponse, Responder}; -use alloy::primitives::{Bytes, U256}; +use alloy::{ + primitives::{Bytes, U256}, +}; use e3_sdk::evm_helpers::contracts::{EnclaveContract, EnclaveWrite}; use eyre::Error; use log::{error, info}; pub fn setup_routes(config: &mut web::ServiceConfig) { config.service( - web::scope("/voting").route("/broadcast", web::post().to(broadcast_encrypted_vote)), + web::scope("/voting") + .route("/broadcast", web::post().to(broadcast_encrypted_vote)) + .route("/status", web::post().to(get_vote_status)), ); } +/// Get the vote status for a user in a specific round +/// +/// # Arguments +/// +/// * `VoteStatusRequest` - The request containing round_id and address +/// +/// # Returns +/// +/// * A JSON response with the vote status +async fn get_vote_status( + data: web::Json, + store: web::Data, +) -> impl Responder { + let request = data.into_inner(); + info!( + "[e3_id={}] Checking vote status for address: {}", + request.round_id, request.address + ); + + let has_voted = match store + .e3(request.round_id) + .has_voted(request.address.clone()) + .await + { + Ok(voted) => voted, + Err(e) => { + error!( + "[e3_id={}] Database error checking vote status: {:?}", + request.round_id, e + ); + return HttpResponse::InternalServerError().json("Internal server error"); + } + }; + + let round_status = match store.e3(request.round_id).get_e3_state_lite().await { + Ok(state) => Some(state.status), + Err(_) => None, + }; + + HttpResponse::Ok().json(VoteStatusResponse { + round_id: request.round_id, + address: request.address, + has_voted, + round_status, + }) +} + /// Broadcast an encrypted vote to the blockchain /// /// # Arguments /// -/// * `VoteRequest` - The vote data to be broadcast +/// * `EncryptedVote` - The vote data to be broadcast /// /// # Returns /// @@ -36,72 +85,70 @@ async fn broadcast_encrypted_vote( data: web::Json, store: web::Data, ) -> impl Responder { - let vote_request = data.into_inner(); - info!( - "[e3_id={}] Broadcasting encrypted vote", - vote_request.round_id - ); - // Validate and update vote status + let vote = data.into_inner(); + info!("[e3_id={}] Broadcasting encrypted vote", vote.round_id); + + // Check if user has already voted let has_voted = match store - .e3(vote_request.round_id) - .has_voted(vote_request.address.clone()) + .e3(vote.round_id) + .has_voted(vote.address.clone()) .await { Ok(voted) => voted, Err(e) => { error!( "[e3_id={}] Database error checking vote status: {:?}", - vote_request.round_id, e + vote.round_id, e ); return HttpResponse::InternalServerError().json("Internal server error"); } }; - if has_voted { - info!("[e3_id={}] User has already voted", vote_request.round_id); - return HttpResponse::Ok().json(VoteResponse { - status: VoteResponseStatus::UserAlreadyVoted, - tx_hash: None, - message: Some("User Has Already Voted".to_string()), - }); + let is_vote_update = has_voted; + if is_vote_update { + info!("[e3_id={}] User is updating their vote", vote.round_id); } - let mut repo = store.e3(vote_request.round_id); + let mut repo = store.e3(vote.round_id); - if let Err(e) = repo - .insert_voter_address(vote_request.address.clone()) - .await - { - error!( - "[e3_id={}] Database error inserting voter: {:?}", - vote_request.round_id, e - ); - return HttpResponse::InternalServerError().json("Internal server error"); + if !has_voted { + if let Err(e) = repo.insert_voter_address(vote.address.clone()).await { + error!( + "[e3_id={}] Database error inserting voter: {:?}", + vote.round_id, e + ); + return HttpResponse::InternalServerError().json("Internal server error"); + } } - let e3_id = U256::from(vote_request.round_id); + let e3_id = U256::from(vote.round_id); // encoded_proof is already encoded in JavaScript, just decode from hex - let hex_str = vote_request + let hex_str = vote .encoded_proof .strip_prefix("0x") - .unwrap_or(&vote_request.encoded_proof); + .unwrap_or(&vote.encoded_proof); let encoded_proof = match hex::decode(hex_str) { Ok(decoded) => Bytes::from(decoded), Err(e) => { error!( "[e3_id={}] Failed to decode encoded_proof: {:?}", - vote_request.round_id, e + vote.round_id, e ); // Rollback voter insertion before returning error - let _ = match repo.remove_voter_address(&vote_request.address).await { - Ok(_) => (), - Err(e) => error!("Error rolling back the vote: {e}"), - }; + + if !is_vote_update { + let _ = match repo.remove_voter_address(&vote.address).await { + Ok(_) => (), + Err(e) => error!("Error rolling back the vote: {e}"), + }; + } + return HttpResponse::BadRequest().json(VoteResponse { status: VoteResponseStatus::FailedBroadcast, tx_hash: None, message: Some("Invalid hex encoded proof".to_string()), + is_vote_update: Some(is_vote_update), }); } }; @@ -116,33 +163,57 @@ async fn broadcast_encrypted_vote( { Ok(c) => c, Err(e) => { - error!( - "[e3_id={}] Contract creation failed: {:?}", - vote_request.round_id, e - ); - // Rollback voter insertion before returning error - let _ = match repo.remove_voter_address(&vote_request.address).await { - Ok(_) => (), - Err(e) => error!("Error rolling back the vote: {e}"), - }; + error!("[e3_id={}] Contract creation error: {:?}", vote.round_id, e); return HttpResponse::InternalServerError().json("Internal server error"); } }; match contract.publish_input(e3_id, encoded_proof).await { Ok(hash) => { + let message = if is_vote_update { + "Vote Updated Successfully" + } else { + "Vote Successful" + }; info!( - "[e3_id={}] Vote broadcasted successfully", - vote_request.round_id + "[e3_id={}] Vote broadcasted successfully (update: {})", + vote.round_id, is_vote_update ); HttpResponse::Ok().json(VoteResponse { status: VoteResponseStatus::Success, tx_hash: Some(hash.transaction_hash.to_string()), - message: Some("Vote Successful".to_string()), + message: Some(message.to_string()), + is_vote_update: Some(is_vote_update), }) } - Err(e) => handle_vote_error(e, repo, &vote_request.address).await, + Err(e) => handle_vote_error(e, repo, &vote.address, has_voted).await, + } +} + +/// Extract an error message from an error +fn extract_error_message(e: &Error) -> String { + let error_str = e.to_string(); + + if error_str.contains("Internal error") || error_str.contains("-32603") { + return "Transaction rejected by the blockchain".to_string(); + } + if error_str.contains("insufficient funds") { + return "Insufficient funds to process transaction".to_string(); + } + if error_str.contains("nonce") { + return "Transaction conflict, please try again".to_string(); } + if error_str.contains("gas") { + return "Transaction failed due to gas issues".to_string(); + } + if error_str.contains("reverted") { + return "Transaction was reverted by the contract".to_string(); + } + if error_str.contains("timeout") || error_str.contains("Timeout") { + return "Transaction timed out, please try again".to_string(); + } + + "Transaction failed, please try again".to_string() } /// Handle the vote error @@ -150,25 +221,32 @@ async fn broadcast_encrypted_vote( /// # Arguments /// /// * `e` - The error that occurred -/// * `state_data` - The state data to be rolled back -/// * `key` - The key for the state data +/// * `repo` - The repository to rollback /// * `address` - The address for the vote +/// * `was_update` - Whether this was a vote update (don't rollback if true) async fn handle_vote_error( e: Error, mut repo: CrispE3Repository, address: &str, + was_update: bool, ) -> HttpResponse { - info!("Error while sending vote transaction: {:?}", e); + // Log the full error for debugging + error!("Error while sending vote transaction: {:?}", e); - // Rollback the vote - match repo.remove_voter_address(address).await { - Ok(_) => (), - Err(err) => error!("Error rolling back the vote: {err}"), - }; + // Only rollback the vote if this was a new vote, not an update + if !was_update { + match repo.remove_voter_address(address).await { + Ok(_) => (), + Err(err) => error!("Error rolling back the vote: {err}"), + }; + } + + let user_message = extract_error_message(&e); HttpResponse::Ok().json(VoteResponse { status: VoteResponseStatus::FailedBroadcast, tx_hash: None, - message: Some("Failed to broadcast vote".to_string()), + message: Some(user_message), + is_vote_update: Some(was_update), }) }