diff --git a/packages/nextjs/app/polls/[id]/page.tsx b/packages/nextjs/app/polls/[id]/page.tsx index 8b2eedc..974cf34 100644 --- a/packages/nextjs/app/polls/[id]/page.tsx +++ b/packages/nextjs/app/polls/[id]/page.tsx @@ -1,288 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import { genRandomSalt } from "maci-crypto"; -import { Keypair, PCommand, PubKey } from "maci-domainobjs"; -import { useContractRead, useContractWrite } from "wagmi"; -import PollAbi from "~~/abi/Poll"; -import VoteCard from "~~/components/card/VoteCard"; -import { useAuthContext } from "~~/contexts/AuthContext"; -import { useAuthUserOnly } from "~~/hooks/useAuthUserOnly"; -import { useFetchPoll } from "~~/hooks/useFetchPoll"; -import { getPollStatus } from "~~/hooks/useFetchPolls"; -import { PollStatus, PollType } from "~~/types/poll"; -import { getDataFromPinata } from "~~/utils/pinata"; -import { notification } from "~~/utils/scaffold-eth"; +import PollDetail from "~~/components/PollDetail"; -export default function PollDetail() { +export default function PollDetailPage() { const { id } = useParams<{ id: string }>(); - const { data: poll, error, isLoading } = useFetchPoll(id); - const [pollType, setPollType] = useState(PollType.NOT_SELECTED); - - useAuthUserOnly({}); - - const { keypair, stateIndex } = useAuthContext(); - - const [votes, setVotes] = useState<{ index: number; votes: number }[]>([]); - - const [isVotesInvalid, setIsVotesInvalid] = useState>({}); - - const isAnyInvalid = Object.values(isVotesInvalid).some(v => v); - const [result, setResult] = useState<{ candidate: string; votes: number }[] | null>(null); - const [status, setStatus] = useState(); - - useEffect(() => { - if (!poll || !poll.metadata) { - return; - } - - try { - const { pollType } = JSON.parse(poll.metadata); - setPollType(pollType); - } catch (err) { - console.log("err", err); - } - - if (poll.tallyJsonCID) { - (async () => { - try { - const { - results: { tally }, - } = await getDataFromPinata(poll.tallyJsonCID); - if (poll.options.length > tally.length) { - throw new Error("Invalid tally data"); - } - const tallyCounts: number[] = tally.map((v: string) => Number(v)).slice(0, poll.options.length); - const result = []; - for (let i = 0; i < poll.options.length; i++) { - const candidate = poll.options[i]; - const votes = tallyCounts[i]; - result.push({ candidate, votes }); - } - result.sort((a, b) => b.votes - a.votes); - setResult(result); - console.log("data", result); - } catch (err) { - console.log("err", err); - } - })(); - } - - const statusUpdateInterval = setInterval(async () => { - setStatus(getPollStatus(poll)); - }, 1000); - - return () => { - clearInterval(statusUpdateInterval); - }; - }, [poll]); - - const { data: coordinatorPubKeyResult } = useContractRead({ - abi: PollAbi, - address: poll?.pollContracts.poll, - functionName: "coordinatorPubKey", - }); - - const { writeAsync: publishMessage } = useContractWrite({ - abi: PollAbi, - address: poll?.pollContracts.poll, - functionName: "publishMessage", - }); - - const { writeAsync: publishMessageBatch } = useContractWrite({ - abi: PollAbi, - address: poll?.pollContracts.poll, - functionName: "publishMessageBatch", - }); - - const [coordinatorPubKey, setCoordinatorPubKey] = useState(); - - useEffect(() => { - if (!coordinatorPubKeyResult) { - return; - } - - const coordinatorPubKey_ = new PubKey([ - BigInt((coordinatorPubKeyResult as any)[0].toString()), - BigInt((coordinatorPubKeyResult as any)[1].toString()), - ]); - - setCoordinatorPubKey(coordinatorPubKey_); - }, [coordinatorPubKeyResult]); - - const castVote = async () => { - if (!poll || stateIndex == null || !coordinatorPubKey || !keypair) return; - - // check if the votes are valid - if (isAnyInvalid) { - notification.error("Please enter a valid number of votes"); - return; - } - - // check if no votes are selected - if (votes.length === 0) { - notification.error("Please select at least one option to vote"); - return; - } - - // check if the poll is closed - if (status !== PollStatus.OPEN) { - notification.error("Voting is closed for this poll"); - return; - } - - const votesToMessage = votes.map((v, i) => - getMessageAndEncKeyPair( - stateIndex, - poll.id, - BigInt(v.index), - BigInt(v.votes), - BigInt(votes.length - i), - coordinatorPubKey, - keypair, - ), - ); - - try { - if (votesToMessage.length === 1) { - await publishMessage({ - args: [ - votesToMessage[0].message.asContractParam() as unknown as { - msgType: bigint; - data: readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; - }, - votesToMessage[0].encKeyPair.pubKey.asContractParam() as unknown as { x: bigint; y: bigint }, - ], - }); - } else { - await publishMessageBatch({ - args: [ - votesToMessage.map( - v => - v.message.asContractParam() as unknown as { - msgType: bigint; - data: readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; - }, - ), - votesToMessage.map(v => v.encKeyPair.pubKey.asContractParam() as { x: bigint; y: bigint }), - ], - }); - } - - notification.success("Vote casted successfully"); - } catch (err) { - console.log("err", err); - notification.error("Casting vote failed, please try again "); - } - }; - - function getMessageAndEncKeyPair( - stateIndex: bigint, - pollIndex: bigint, - candidateIndex: bigint, - weight: bigint, - nonce: bigint, - coordinatorPubKey: PubKey, - keypair: Keypair, - ) { - const command: PCommand = new PCommand( - stateIndex, - keypair.pubKey, - candidateIndex, - weight, - nonce, - pollIndex, - genRandomSalt(), - ); - - const signature = command.sign(keypair.privKey); - - const encKeyPair = new Keypair(); - - const message = command.encrypt(signature, Keypair.genEcdhSharedKey(encKeyPair.privKey, coordinatorPubKey)); - - return { message, encKeyPair }; - } - - function voteUpdated(index: number, checked: boolean, voteCounts: number) { - if (pollType === PollType.SINGLE_VOTE) { - if (checked) { - setVotes([{ index, votes: voteCounts }]); - } - return; - } - - if (checked) { - setVotes([...votes.filter(v => v.index !== index), { index, votes: voteCounts }]); - } else { - setVotes(votes.filter(v => v.index !== index)); - } - } - - if (isLoading) return
Loading...
; - - if (error) return
Poll not found
; - - return ( -
-
-
-
Vote for {poll?.name}
-
- {poll?.options.map((candidate, index) => ( -
- voteUpdated(index, checked, votes)} - isInvalid={Boolean(isVotesInvalid[index])} - setIsInvalid={status => setIsVotesInvalid({ ...isVotesInvalid, [index]: status })} - /> -
- ))} - {status === PollStatus.OPEN && ( -
- -
- )} - - {result && ( -
-
Results
-
- - - - - - - - - - {result.map((r, i) => ( - - - - - - ))} - -
RankCandidateVotes
{i + 1}{r.candidate}{r.votes}
-
-
- )} -
-
- ); + return ; } diff --git a/packages/nextjs/components/PollDetail.tsx b/packages/nextjs/components/PollDetail.tsx new file mode 100644 index 0000000..af81a18 --- /dev/null +++ b/packages/nextjs/components/PollDetail.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { genRandomSalt } from "maci-crypto"; +import { Keypair, PCommand, PubKey } from "maci-domainobjs"; +import { useContractRead, useContractWrite } from "wagmi"; +import PollAbi from "~~/abi/Poll"; +import VoteCard from "~~/components/card/VoteCard"; +import { useAuthContext } from "~~/contexts/AuthContext"; +import { useAuthUserOnly } from "~~/hooks/useAuthUserOnly"; +import { useFetchPoll } from "~~/hooks/useFetchPoll"; +import { getPollStatus } from "~~/hooks/useFetchPolls"; +import { PollStatus, PollType } from "~~/types/poll"; +import { getDataFromPinata } from "~~/utils/pinata"; +import { notification } from "~~/utils/scaffold-eth"; + +export default function PollDetail({ id }: { id: bigint }) { + const { data: poll, error, isLoading } = useFetchPoll(id); + const [pollType, setPollType] = useState(PollType.NOT_SELECTED); + + useAuthUserOnly({}); + + const { keypair, stateIndex } = useAuthContext(); + + const [votes, setVotes] = useState<{ index: number; votes: number }[]>([]); + + const [isVotesInvalid, setIsVotesInvalid] = useState>({}); + + const isAnyInvalid = Object.values(isVotesInvalid).some(v => v); + const [result, setResult] = useState<{ candidate: string; votes: number }[] | null>(null); + const [status, setStatus] = useState(); + + useEffect(() => { + if (!poll || !poll.metadata) { + return; + } + + try { + const { pollType } = JSON.parse(poll.metadata); + setPollType(pollType); + } catch (err) { + console.log("err", err); + } + + if (poll.tallyJsonCID) { + (async () => { + try { + const { + results: { tally }, + } = await getDataFromPinata(poll.tallyJsonCID); + if (poll.options.length > tally.length) { + throw new Error("Invalid tally data"); + } + const tallyCounts: number[] = tally.map((v: string) => Number(v)).slice(0, poll.options.length); + const result = []; + for (let i = 0; i < poll.options.length; i++) { + const candidate = poll.options[i]; + const votes = tallyCounts[i]; + result.push({ candidate, votes }); + } + result.sort((a, b) => b.votes - a.votes); + setResult(result); + console.log("data", result); + } catch (err) { + console.log("err", err); + } + })(); + } + + const statusUpdateInterval = setInterval(async () => { + setStatus(getPollStatus(poll)); + }, 1000); + + return () => { + clearInterval(statusUpdateInterval); + }; + }, [poll]); + + const { data: coordinatorPubKeyResult } = useContractRead({ + abi: PollAbi, + address: poll?.pollContracts.poll, + functionName: "coordinatorPubKey", + }); + + const { writeAsync: publishMessage } = useContractWrite({ + abi: PollAbi, + address: poll?.pollContracts.poll, + functionName: "publishMessage", + }); + + const { writeAsync: publishMessageBatch } = useContractWrite({ + abi: PollAbi, + address: poll?.pollContracts.poll, + functionName: "publishMessageBatch", + }); + + const [coordinatorPubKey, setCoordinatorPubKey] = useState(); + + useEffect(() => { + if (!coordinatorPubKeyResult) { + return; + } + + const coordinatorPubKey_ = new PubKey([ + BigInt((coordinatorPubKeyResult as any)[0].toString()), + BigInt((coordinatorPubKeyResult as any)[1].toString()), + ]); + + setCoordinatorPubKey(coordinatorPubKey_); + }, [coordinatorPubKeyResult]); + + const castVote = async () => { + if (!poll || stateIndex == null || !coordinatorPubKey || !keypair) return; + + // check if the votes are valid + if (isAnyInvalid) { + notification.error("Please enter a valid number of votes"); + return; + } + + // check if no votes are selected + if (votes.length === 0) { + notification.error("Please select at least one option to vote"); + return; + } + + // check if the poll is closed + if (status !== PollStatus.OPEN) { + notification.error("Voting is closed for this poll"); + return; + } + + const votesToMessage = votes.map((v, i) => + getMessageAndEncKeyPair( + stateIndex, + poll.id, + BigInt(v.index), + BigInt(v.votes), + BigInt(votes.length - i), + coordinatorPubKey, + keypair, + ), + ); + + try { + if (votesToMessage.length === 1) { + await publishMessage({ + args: [ + votesToMessage[0].message.asContractParam() as unknown as { + msgType: bigint; + data: readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; + }, + votesToMessage[0].encKeyPair.pubKey.asContractParam() as unknown as { x: bigint; y: bigint }, + ], + }); + } else { + await publishMessageBatch({ + args: [ + votesToMessage.map( + v => + v.message.asContractParam() as unknown as { + msgType: bigint; + data: readonly [bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint, bigint]; + }, + ), + votesToMessage.map(v => v.encKeyPair.pubKey.asContractParam() as { x: bigint; y: bigint }), + ], + }); + } + + notification.success("Vote casted successfully"); + } catch (err) { + console.log("err", err); + notification.error("Casting vote failed, please try again "); + } + }; + + function getMessageAndEncKeyPair( + stateIndex: bigint, + pollIndex: bigint, + candidateIndex: bigint, + weight: bigint, + nonce: bigint, + coordinatorPubKey: PubKey, + keypair: Keypair, + ) { + const command: PCommand = new PCommand( + stateIndex, + keypair.pubKey, + candidateIndex, + weight, + nonce, + pollIndex, + genRandomSalt(), + ); + + const signature = command.sign(keypair.privKey); + + const encKeyPair = new Keypair(); + + const message = command.encrypt(signature, Keypair.genEcdhSharedKey(encKeyPair.privKey, coordinatorPubKey)); + + return { message, encKeyPair }; + } + + function voteUpdated(index: number, checked: boolean, voteCounts: number) { + if (pollType === PollType.SINGLE_VOTE) { + if (checked) { + setVotes([{ index, votes: voteCounts }]); + } + return; + } + + if (checked) { + setVotes([...votes.filter(v => v.index !== index), { index, votes: voteCounts }]); + } else { + setVotes(votes.filter(v => v.index !== index)); + } + } + + if (isLoading) return
Loading...
; + + if (error) return
Poll not found
; + + return ( +
+
+
+
Vote for {poll?.name}
+
+ {poll?.options.map((candidate, index) => ( +
+ voteUpdated(index, checked, votes)} + isInvalid={Boolean(isVotesInvalid[index])} + setIsInvalid={status => setIsVotesInvalid({ ...isVotesInvalid, [index]: status })} + /> +
+ ))} + {status === PollStatus.OPEN && ( +
+ +
+ )} + + {result && ( +
+
Results
+
+ + + + + + + + + + {result.map((r, i) => ( + + + + + + ))} + +
RankCandidateVotes
{i + 1}{r.candidate}{r.votes}
+
+
+ )} +
+
+ ); +} diff --git a/packages/nextjs/hooks/useFetchPoll.tsx b/packages/nextjs/hooks/useFetchPoll.tsx index 6e897fd..2985f14 100644 --- a/packages/nextjs/hooks/useFetchPoll.tsx +++ b/packages/nextjs/hooks/useFetchPoll.tsx @@ -1,8 +1,8 @@ import { useScaffoldContractRead } from "./scaffold-eth"; -export const useFetchPoll = (id: string) => +export const useFetchPoll = (id: bigint | undefined) => useScaffoldContractRead({ contractName: "MACIWrapper", functionName: "fetchPoll", - args: [BigInt(id)], + args: [id], });