diff --git a/examples/CRISP/client/src/components/Cards/PollCardResult.tsx b/examples/CRISP/client/src/components/Cards/PollCardResult.tsx index 00b44658c2..1acc4dfdad 100644 --- a/examples/CRISP/client/src/components/Cards/PollCardResult.tsx +++ b/examples/CRISP/client/src/components/Cards/PollCardResult.tsx @@ -19,8 +19,10 @@ type PollCardResultProps = { isActive?: boolean } const PollCardResult: React.FC = ({ 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 ( diff --git a/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts b/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts index ec6ad335a5..b410aa534b 100644 --- a/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts +++ b/examples/CRISP/client/src/hooks/enclave/useEnclaveServer.ts @@ -9,6 +9,7 @@ import { BroadcastVoteRequest, BroadcastVoteResponse, CurrentRound, + EligibleVoter, VoteStateLite, VoteStatusRequest, VoteStatusResponse, @@ -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 = () => { @@ -38,6 +40,8 @@ export const useEnclaveServer = () => { const getWebResult = () => fetchData(GetWebAllResult, 'get') const getWebResultByRound = (round_id: number) => fetchData(GetWebResult, 'post', { round_id }) const getVoteStatus = (request: VoteStatusRequest) => fetchData(GetVoteStatus, 'post', request) + const getEligibleVoters = (round_id: number) => + fetchData(EnclaveEndpoints.GetEligibleVoters, 'post', { round_id }) return { isLoading, @@ -47,5 +51,6 @@ export const useEnclaveServer = () => { getRoundStateLite, broadcastVote, getVoteStatus, + getEligibleVoters, } } diff --git a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts index 0d90ce0fca..b2edcc00a8 100644 --- a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts +++ b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts @@ -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' @@ -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, @@ -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(false) + const [isVoting, setIsVoting] = useState(false) + const [isMasking, setIsMasking] = useState(false) const [votingStep, setVotingStep] = useState('idle') const [lastActiveStep, setLastActiveStep] = useState(null) const [stepMessage, setStepMessage] = useState('') 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, @@ -76,7 +89,7 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo balance, signature, messageHash, - isMasking, + isAMask, ) }, [generateProof, votingRound], @@ -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 => { + 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 => { + 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', + } + } + }, + [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 @@ -106,28 +203,19 @@ 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 @@ -135,11 +223,14 @@ export const useVoteCasting = (customRoundState?: VoteStateLite | null, customVo 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.') @@ -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) @@ -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, @@ -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, diff --git a/examples/CRISP/client/src/model/vote.model.ts b/examples/CRISP/client/src/model/vote.model.ts index 148824c536..319e0fc044 100644 --- a/examples/CRISP/client/src/model/vote.model.ts +++ b/examples/CRISP/client/src/model/vote.model.ts @@ -60,3 +60,8 @@ export interface Vote { yes: bigint no: bigint } + +export interface EligibleVoter { + address: string + balance: number +} diff --git a/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx b/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx index ea4271316c..aeac48d9de 100644 --- a/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx +++ b/examples/CRISP/client/src/pages/Landing/components/DailyPoll.tsx @@ -30,7 +30,7 @@ const DailyPollSection: React.FC = ({ loading, endTime, t const [pollSelected, setPollSelected] = useState(null) const [noPollSelected, setNoPollSelected] = useState(true) const { setOpen } = useModal() - const { castVoteWithProof, isLoading: isCastingVote, votingStep, lastActiveStep, stepMessage } = useVoteCasting() + const { castVoteWithProof, isVoting: isCastingVote, isMasking, votingStep, lastActiveStep, stepMessage } = useVoteCasting() useEffect(() => { ;(async () => { @@ -64,13 +64,13 @@ const DailyPollSection: React.FC = ({ 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 ( @@ -119,8 +119,8 @@ const DailyPollSection: React.FC = ({ loading, endTime, t )} - {isCastingVote && } - {loading && !isCastingVote && } + {(isCastingVote || isMasking) && } + {loading && !isCastingVote && !isMasking && }
{pollOptions.map((poll) => (
@@ -139,11 +139,18 @@ const DailyPollSection: React.FC = ({ loading, endTime, t )} +
)}
diff --git a/examples/CRISP/client/src/utils/voters.ts b/examples/CRISP/client/src/utils/voters.ts new file mode 100644 index 0000000000..bfe24d070a --- /dev/null +++ b/examples/CRISP/client/src/utils/voters.ts @@ -0,0 +1,22 @@ +// 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 { EligibleVoter } from '@/model/vote.model' + +/** + * Get a random voter details from a list of eligible voters + * @param addresses The list of eligible voters + * @returns The randomly selected voter details + */ +export const getRandomVoterToMask = (voters: EligibleVoter[]): EligibleVoter => { + if (voters.length === 0) { + throw new Error('No eligible voters available to select from.') + } + + const randomIndex = crypto.getRandomValues(new Uint32Array(1))[0] % voters.length + + return voters[randomIndex] +} diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index ab1aaaf668..2722558d7f 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -118,6 +118,10 @@ pub async fn register_e3_requested( .into()); } + // Store eligible addresses in the repository. + repo.set_eligible_addresses(token_holders.clone()) + .await?; + // Compute Poseidon hashes for token holder address + balance pairs. let token_holder_hashes = compute_token_holder_hashes(&token_holders) .with_context(|| "Failed to compute token holder hashes")?; diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index e91a839fb9..45ef577b4d 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -223,6 +223,7 @@ pub struct E3Crisp { pub votes_option_1: String, pub votes_option_2: String, pub token_holder_hashes: Vec, + pub eligible_addresses: Vec, pub token_address: String, pub balance_threshold: String, pub ciphertext_inputs: Vec<(Vec, u64)>, @@ -241,3 +242,11 @@ impl From for WebResultRequest { } } } + +/// Represents a token holder with their address and balance. +/// Balance is stored as a string to preserve precision for large numbers. +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct TokenHolder { + pub address: String, + pub balance: String, +} diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index d2a44a0125..7c86dd6a03 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -4,6 +4,8 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use crate::server::models::TokenHolder; + use super::{ database::generate_emoji, models::{CurrentRound, E3Crisp, E3StateLite, WebResultRequest}, @@ -124,7 +126,7 @@ impl CrispE3Repository { return Ok(Some(vote)); } } - + Ok(None) } @@ -141,6 +143,7 @@ impl CrispE3Repository { votes_option_2: "0".to_string(), emojis: generate_emoji(), token_holder_hashes: vec![], + eligible_addresses: vec![], token_address, balance_threshold, ciphertext_inputs: vec![], @@ -310,6 +313,27 @@ impl CrispE3Repository { Ok(e3_crisp.token_holder_hashes) } + pub async fn set_eligible_addresses(&mut self, holders: Vec) -> Result<()> { + let key = self.crisp_key(); + // Placeholder for future implementation + + self.store + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + e.eligible_addresses = holders.clone(); + e + }) + }) + .await + .map_err(|_| eyre::eyre!("Could not set eligible_addresses for '{key}'"))?; + Ok(()) + } + + pub async fn get_eligible_addresses(&self) -> Result> { + let e3_crisp = self.get_crisp().await?; + Ok(e3_crisp.eligible_addresses) + } + fn crisp_key(&self) -> String { let e3_id = self.e3_id; format!("_e3:crisp:{e3_id}") diff --git a/examples/CRISP/server/src/server/routes/state.rs b/examples/CRISP/server/src/server/routes/state.rs index a24f770f30..69a27e4f02 100644 --- a/examples/CRISP/server/src/server/routes/state.rs +++ b/examples/CRISP/server/src/server/routes/state.rs @@ -7,9 +7,12 @@ use std::str::FromStr; use crate::server::{ - CONFIG, app_data::AppData, models::{ - GetRoundRequest, IsSlotEmptyRequest, IsSlotEmptyResponse, PreviousCiphertextRequest, PreviousCiphertextResponse, WebhookPayload - } + app_data::AppData, + models::{ + GetRoundRequest, IsSlotEmptyRequest, IsSlotEmptyResponse, PreviousCiphertextRequest, + PreviousCiphertextResponse, WebhookPayload, + }, + CONFIG, }; use actix_web::{web, HttpResponse, Responder}; use alloy::primitives::{Address, Bytes, U256}; @@ -30,6 +33,10 @@ pub fn setup_routes(config: &mut web::ServiceConfig) { .route("/add-result", web::post().to(handle_program_server_result)) // Get the token holders hashes for a given round .route("/token-holders", web::post().to(get_token_holders_hashes)) + .route( + "/eligible-addresses", + web::post().to(handle_get_eligible_addresses), + ) .route( "/previous-ciphertext", web::post().to(handle_get_previous_ciphertext), @@ -283,9 +290,7 @@ async fn get_token_holders_hashes( /// Check if a slot is empty given an address /// # Arguments /// * `IsSlotEmptyRequest` - The request containing round_id and address -async fn handle_is_slot_empty( - data: web::Json, -) -> impl Responder { +async fn handle_is_slot_empty(data: web::Json) -> impl Responder { let incoming = data.into_inner(); let contract = @@ -314,10 +319,29 @@ async fn handle_is_slot_empty( Ok(empty) => empty, Err(e) => { error!("Error checking if slot is empty: {:?}", e); - return HttpResponse::InternalServerError() - .body("Failed to check if slot is empty"); + return HttpResponse::InternalServerError().body("Failed to check if slot is empty"); } }; - HttpResponse::Ok().json(IsSlotEmptyResponse { is_empty } ) + HttpResponse::Ok().json(IsSlotEmptyResponse { is_empty }) +} + +/// Get the eligible addresses for a given round +/// # Arguments +/// * `GetRoundRequest` - The request data containing the round ID +/// # Returns +/// * A JSON response containing the list of eligible addresses and their balances +async fn handle_get_eligible_addresses( + data: web::Json, + store: web::Data, +) -> impl Responder { + let incoming = data.into_inner(); + + match store.e3(incoming.round_id).get_eligible_addresses().await { + Ok(addresses) => HttpResponse::Ok().json(addresses), + Err(e) => { + error!("Error getting eligible addresses: {:?}", e); + HttpResponse::InternalServerError().body("Failed to get eligible addresses") + } + } } diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index 1f6c5a3e01..5259fc0698 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -4,15 +4,18 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use crate::server::CONFIG; use alloy::primitives::{Address, U256}; use alloy::providers::ProviderBuilder; use alloy::sol; use eyre::{eyre, Context, Result}; // Add this import use reqwest; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize}; use std::collections::{HashMap, HashSet}; use tokio::time::{sleep, Duration}; +use crate::server::{ + CONFIG, + models::TokenHolder +}; // Define the Votes contract interface for getPastVotes sol! { @@ -28,14 +31,6 @@ sol! { pub const ETHERSCAN_API_URL: &str = "https://api.etherscan.io/v2/api"; const ZERO_ADDRESS: Address = Address::ZERO; -/// Represents a token holder with their address and balance. -/// Balance is stored as a string to preserve precision for large numbers. -#[derive(Debug, Serialize, Deserialize, PartialEq)] -pub struct TokenHolder { - pub address: String, - pub balance: String, -} - // Response types #[derive(Debug, Deserialize)] struct EtherscanResponse { diff --git a/examples/CRISP/server/src/server/token_holders/hashes.rs b/examples/CRISP/server/src/server/token_holders/hashes.rs index a40d8c55fd..c3483fe1d8 100644 --- a/examples/CRISP/server/src/server/token_holders/hashes.rs +++ b/examples/CRISP/server/src/server/token_holders/hashes.rs @@ -11,7 +11,7 @@ use light_poseidon::{Poseidon, PoseidonHasher}; use num_bigint::BigUint; use std::str::FromStr; -use super::etherscan::TokenHolder; +use crate::server::models::TokenHolder; /// Computes Poseidon hashes for token holder address + balance pairs. ///