Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ type PollCardResultProps = {
isActive?: boolean
}
const PollCardResult: React.FC<PollCardResultProps> = ({ isResult, results, totalVotes, isActive }) => {
const validVotes = results.reduce((sum, poll) => sum + Number.parseInt(poll.votes.toString(), 10), 0)

const calculatePercentage = (votes: number) => {
return ((votes / totalVotes) * 100).toFixed(0)
return ((votes / validVotes) * 100).toFixed(0)
}

return (
Expand Down
5 changes: 5 additions & 0 deletions examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BroadcastVoteRequest,
BroadcastVoteResponse,
CurrentRound,
EligibleVoter,
VoteStateLite,
VoteStatusRequest,
VoteStatusResponse,
Expand All @@ -27,6 +28,7 @@ const EnclaveEndpoints = {
GetWebAllResult: `${ENCLAVE_API}/state/all`,
BroadcastVote: `${ENCLAVE_API}/voting/broadcast`,
GetVoteStatus: `${ENCLAVE_API}/voting/status`,
GetEligibleVoters: `${ENCLAVE_API}/state/eligible-addresses`,
} as const

export const useEnclaveServer = () => {
Expand All @@ -38,6 +40,8 @@ export const useEnclaveServer = () => {
const getWebResult = () => fetchData<PollRequestResult[], void>(GetWebAllResult, 'get')
const getWebResultByRound = (round_id: number) => fetchData<PollRequestResult, { round_id: number }>(GetWebResult, 'post', { round_id })
const getVoteStatus = (request: VoteStatusRequest) => fetchData<VoteStatusResponse, VoteStatusRequest>(GetVoteStatus, 'post', request)
const getEligibleVoters = (round_id: number) =>
fetchData<EligibleVoter[], { round_id: number }>(EnclaveEndpoints.GetEligibleVoters, 'post', { round_id })

return {
isLoading,
Expand All @@ -47,5 +51,6 @@ export const useEnclaveServer = () => {
getRoundStateLite,
broadcastVote,
getVoteStatus,
getEligibleVoters,
}
}
186 changes: 136 additions & 50 deletions examples/CRISP/client/src/hooks/voting/useVoteCasting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { useNotificationAlertContext } from '@/context/NotificationAlert/Notific
import { Poll } from '@/model/poll.model'
import { BroadcastVoteRequest, Vote, VoteStateLite, VotingRound } from '@/model/vote.model'
import { hashMessage } from 'viem'
import { useEnclaveServer } from '../enclave/useEnclaveServer'
import { getRandomVoterToMask } from '@/utils/voters'

export type VotingStep = 'idle' | 'signing' | 'encrypting' | 'generating_proof' | 'broadcasting' | 'confirming' | 'complete' | 'error'

Expand Down Expand Up @@ -42,6 +44,15 @@ const extractCleanErrorMessage = (errorMessage: string | undefined): string => {
return errorMessage
}

interface VoteData {
vote: Vote
slotAddress: string
balance: bigint
signature: string
messageHash: `0x${string}`
error?: string
}

export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVotingRound?: VotingRound | null) => {
const {
user,
Expand All @@ -58,15 +69,17 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
const votingRound = customVotingRound ?? contextVotingRound

const { signMessageAsync } = useSignMessage()
const { getEligibleVoters } = useEnclaveServer()
const { showToast } = useNotificationAlertContext()
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState<boolean>(false)
const [isVoting, setIsVoting] = useState<boolean>(false)
const [isMasking, setIsMasking] = useState<boolean>(false)
const [votingStep, setVotingStep] = useState<VotingStep>('idle')
const [lastActiveStep, setLastActiveStep] = useState<VotingStep | null>(null)
const [stepMessage, setStepMessage] = useState<string>('')

const handleProofGeneration = useCallback(
async (vote: Vote, address: string, balance: bigint, signature: string, messageHash: `0x${string}`, isMasking: boolean) => {
async (vote: Vote, address: string, balance: bigint, signature: string, messageHash: `0x${string}`, isAMask: boolean) => {
if (!votingRound) throw new Error('No voting round available for proof generation')
return generateProof(
votingRound.round_id,
Expand All @@ -76,7 +89,7 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
balance,
signature,
messageHash,
isMasking,
isAMask,
)
},
[generateProof, votingRound],
Expand All @@ -86,12 +99,96 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
setVotingStep('idle')
setLastActiveStep(null)
setStepMessage('')
setIsLoading(false)
setIsVoting(false)
setIsMasking(false)
}, [])

/**
* Handles masking a vote by selecting a random eligible voter.
*/
const handleMask = useCallback(async (): Promise<VoteData> => {
if (!user || !roundState) {
throw new Error('Cannot mask vote: Missing user or round state.')
}

const eligibleVoters = await getEligibleVoters(roundState.id)

if (!eligibleVoters || eligibleVoters.length === 0) {
throw new Error('No eligible voters available for masking')
}

try {
const randomVoterToMask = getRandomVoterToMask(eligibleVoters)

return {
vote: { yes: 0n, no: 0n },
slotAddress: randomVoterToMask.address,
balance: BigInt(randomVoterToMask.balance),
signature: '',
messageHash: '' as `0x${string}`,
}
} catch (error) {
return {
vote: { yes: 0n, no: 0n },
slotAddress: '',
balance: 0n,
signature: '',
messageHash: '' as `0x${string}`,
error: (error as Error).message,
}
}
}, [user, roundState, getEligibleVoters])

/**
* Handles the voting process including signing the message.
*/
const handleVote = useCallback(
async (pollSelected: Poll, slotAddress: string): Promise<VoteData> => {
if (!roundState) {
throw new Error('No round state available for voting')
}

// Step 1: Signing
setVotingStep('signing')
setLastActiveStep('signing')
setStepMessage('Please sign the message in your wallet...')

const message = `Vote for round ${roundState.id}`
const messageHash = hashMessage(message)

// vote is either 0 or 1, so we need to encode the vote accordingly.
const balance = 1n
const vote = pollSelected.value === 0 ? { yes: balance, no: 0n } : { yes: 0n, no: balance }

let signature: string
try {
signature = await signMessageAsync({ message })
return {
signature,
messageHash,
vote,
slotAddress,
balance,
}
} catch (error) {
console.log('User rejected signature or signing failed', error)
resetVotingState()
return {
signature: '',
messageHash: '' as `0x${string}`,
vote: { yes: 0n, no: 0n },
slotAddress: '',
balance: 0n,
error: 'User rejected signature or signing failed',
}
}
Comment thread
ctrlc03 marked this conversation as resolved.
},
[roundState, signMessageAsync, resetVotingState],
)

const castVoteWithProof = useCallback(
async (pollSelected: Poll | null, isVoteUpdate: boolean = false, isMasking: boolean = false) => {
if (!pollSelected) {
async (pollSelected: Poll | null, isAMask: boolean = false) => {
if (!isAMask && !pollSelected) {
console.log('Cannot cast vote: Poll option not selected.')
showToast({ type: 'danger', message: 'Please select a poll option first.' })
return
Expand All @@ -106,40 +203,34 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
return
}

setIsLoading(true)
const actionText = isVoteUpdate ? 'Updating vote' : 'Processing vote'
console.log(`${actionText}...`)

try {
// Step 1: Signing
setVotingStep('signing')
setLastActiveStep('signing')
setStepMessage('Please sign the message in your wallet...')

const message = `Vote for round ${roundState.id}`
const messageHash = hashMessage(message)

let signature: string
try {
signature = await signMessageAsync({ 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
let voteData

if (isAMask) {
setIsMasking(true)
voteData = await handleMask()
} else {
setIsVoting(true)
voteData = await handleVote(pollSelected!, user.address)
}

if (voteData.error) {
throw new Error(voteData.error)
}

// Step 2: Encrypting vote
setVotingStep('encrypting')
setLastActiveStep('encrypting')
setStepMessage('')

// vote is either 0 or 1, so we need to encode the vote accordingly.
const balance = 1n
const vote = pollSelected.value === 0 ? { yes: balance, no: 0n } : { yes: 0n, no: balance }

const encodedProof = await handleProofGeneration(vote, user.address, balance, signature, messageHash, isMasking)
const encodedProof = await handleProofGeneration(
voteData.vote,
voteData.slotAddress,
voteData.balance,
voteData.signature,
voteData.messageHash,
isAMask,
)

if (!encodedProof) {
throw new Error('Failed to encrypt vote.')
Expand All @@ -159,7 +250,7 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
const voteRequest: BroadcastVoteRequest = {
round_id: roundState.id,
encoded_proof: encodedProof,
address: user.address,
address: voteData.slotAddress,
}

const broadcastVoteResponse = await broadcastVote(voteRequest)
Expand All @@ -168,14 +259,18 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
switch (broadcastVoteResponse.status) {
case 'success': {
setVotingStep('complete')
setStepMessage('Vote submitted successfully!')
setStepMessage(`${isAMask ? 'Masking' : 'Vote'} submitted successfully!'`)

const url = `https://sepolia.etherscan.io/tx/${broadcastVoteResponse.tx_hash}`
setTxUrl(url)

markVotedInRound(roundState.id)
if (!isAMask) markVotedInRound(roundState.id)

const successMessage = broadcastVoteResponse.is_vote_update ? 'Vote updated successfully!' : 'Vote submitted successfully!'
const successMessage = isAMask
? 'Slot masked successfully'
: broadcastVoteResponse.is_vote_update
? 'Vote updated successfully!'
: 'Vote submitted successfully!'
showToast({
type: 'success',
message: successMessage,
Expand Down Expand Up @@ -213,26 +308,17 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo
persistent: true,
})
} finally {
setIsLoading(false)
setIsVoting(false)
setIsMasking(false)
}
},
[
user,
roundState,
broadcastVote,
setTxUrl,
showToast,
navigate,
handleProofGeneration,
signMessageAsync,
markVotedInRound,
resetVotingState,
],
[user, roundState, broadcastVote, setTxUrl, showToast, navigate, handleProofGeneration, markVotedInRound, handleMask, handleVote],
)

return {
castVoteWithProof,
isLoading,
isVoting,
isMasking,
votingStep,
lastActiveStep,
stepMessage,
Expand Down
5 changes: 5 additions & 0 deletions examples/CRISP/client/src/model/vote.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ export interface Vote {
yes: bigint
no: bigint
}

export interface EligibleVoter {
address: string
balance: number
}
21 changes: 14 additions & 7 deletions examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const DailyPollSection: React.FC<DailyPollSectionProps> = ({ loading, endTime, t
const [pollSelected, setPollSelected] = useState<Poll | null>(null)
const [noPollSelected, setNoPollSelected] = useState<boolean>(true)
const { setOpen } = useModal()
const { castVoteWithProof, isLoading: isCastingVote, votingStep, lastActiveStep, stepMessage } = useVoteCasting()
const { castVoteWithProof, isVoting: isCastingVote, isMasking, votingStep, lastActiveStep, stepMessage } = useVoteCasting()

useEffect(() => {
;(async () => {
Expand Down Expand Up @@ -64,13 +64,13 @@ const DailyPollSection: React.FC<DailyPollSectionProps> = ({ loading, endTime, t
}
}

const castVote = async () => {
const castVote = async (isMasking: boolean) => {
if (!user) {
setOpen(true)
return
}

await castVoteWithProof(pollSelected, hasVotedInCurrentRound)
await castVoteWithProof(pollSelected, isMasking)
}

return (
Expand Down Expand Up @@ -119,8 +119,8 @@ const DailyPollSection: React.FC<DailyPollSectionProps> = ({ loading, endTime, t
<CountdownTimer endTime={endTime} />
</div>
)}
{isCastingVote && <VotingStepIndicator step={votingStep} message={stepMessage} lastActiveStep={lastActiveStep} />}
{loading && !isCastingVote && <LoadingAnimation isLoading={loading} />}
{(isCastingVote || isMasking) && <VotingStepIndicator step={votingStep} message={stepMessage} lastActiveStep={lastActiveStep} />}
{loading && !isCastingVote && !isMasking && <LoadingAnimation isLoading={loading} />}
<div className=' grid w-full grid-cols-2 gap-4 md:gap-8'>
{pollOptions.map((poll) => (
<div data-test-id={`poll-button-${poll.value}`} key={poll.label} className='col-span-2 md:col-span-1'>
Expand All @@ -139,11 +139,18 @@ const DailyPollSection: React.FC<DailyPollSectionProps> = ({ loading, endTime, t
)}
<button
className={`button-outlined button-max ${noPollSelected ? 'button-disabled' : ''}`}
disabled={noPollSelected || loading || !roundState || isEnded || isCastingVote}
onClick={castVote}
disabled={noPollSelected || loading || !roundState || isEnded || isCastingVote || isMasking}
onClick={() => castVote(false)}
>
{isCastingVote ? 'Processing Vote...' : hasVotedInCurrentRound ? 'Update Vote' : 'Cast Vote'}
</button>
<button
className='button-outlined button-max'
disabled={loading || !roundState || isEnded || isCastingVote || isMasking}
onClick={() => castVote(true)}
>
{isMasking ? 'Masking vote...' : 'Mask vote'}
</button>
</div>
)}
</div>
Expand Down
Loading
Loading