From 66521f23c2c769511a7df3d00c13f145e742d644 Mon Sep 17 00:00:00 2001 From: alcercu <333aleix333@gmail.com> Date: Fri, 18 Aug 2023 10:48:41 +0200 Subject: [PATCH 01/89] feat(wip/web): add commit reveal support --- web/src/pages/Cases/CaseDetails/Timeline.tsx | 22 ++++++++++++------- .../Voting/{Classic.tsx => Classic/index.tsx} | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) rename web/src/pages/Cases/CaseDetails/Voting/{Classic.tsx => Classic/index.tsx} (98%) diff --git a/web/src/pages/Cases/CaseDetails/Timeline.tsx b/web/src/pages/Cases/CaseDetails/Timeline.tsx index cd66ccee2..b352142b9 100644 --- a/web/src/pages/Cases/CaseDetails/Timeline.tsx +++ b/web/src/pages/Cases/CaseDetails/Timeline.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import styled from "styled-components"; import { Periods } from "consts/periods"; import { DisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; @@ -10,7 +10,7 @@ const Timeline: React.FC<{ dispute: DisputeDetailsQuery["dispute"]; currentPeriodIndex: number; }> = ({ currentPeriodIndex, dispute }) => { - const currentItemIndex = currentPeriodToCurrentItem(currentPeriodIndex, dispute?.ruled); + const currentItemIndex = currentPeriodToCurrentItem(currentPeriodIndex, dispute?.court.hiddenVotes); const items = useTimeline(dispute, currentItemIndex, currentItemIndex); return ( @@ -19,14 +19,20 @@ const Timeline: React.FC<{ ); }; -const currentPeriodToCurrentItem = (currentPeriodIndex: number, ruled?: boolean): number => { +const currentPeriodToCurrentItem = (currentPeriodIndex: number, hiddenVotes?: boolean): number => { + if (hiddenVotes) return currentPeriodIndex; if (currentPeriodIndex <= Periods.commit) return currentPeriodIndex; - else if (currentPeriodIndex < Periods.execution) return currentPeriodIndex - 1; - else return ruled ? 5 : currentPeriodIndex - 1; + else return currentPeriodIndex - 1; }; const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: number, currentPeriodIndex: number) => { - const titles = ["Evidence Period", "Voting Period", "Appeal Period", "Executed"]; + const titles = useMemo(() => { + const titles = ["Evidence Period", "Voting Period", "Appeal Period", "Executed"]; + if (dispute?.court.hiddenVotes) { + titles.splice(1, 0, "Appeal Period"); + } + return titles; + }, [dispute]); const deadlineCurrentPeriod = getDeadline( currentPeriodIndex, dispute?.lastPeriodChange, @@ -39,8 +45,8 @@ const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: return ["Time's up!"]; } else if (index < currentItemIndex) { return []; - } else if (index === 3) { - return currentItemIndex === 3 ? ["Pending"] : []; + } else if (index === titles.length) { + return dispute?.ruled ? [] : ["Pending"]; } else if (index === currentItemIndex) { return [secondsToDayHourMinute(countdown)]; } else { diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/index.tsx similarity index 98% rename from web/src/pages/Cases/CaseDetails/Voting/Classic.tsx rename to web/src/pages/Cases/CaseDetails/Voting/Classic/index.tsx index c26588ddf..1856da6b4 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Classic.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/index.tsx @@ -91,7 +91,7 @@ const Classic: React.FC = ({ arbitrable, voteIDs, setIsOpen }) => { return id ? ( - {disputeTemplate?.question} + {disputeTemplate.question} setJustification(e.target.value)} From f350a41668250c98a63b2fa2fc82ad1e089432d5 Mon Sep 17 00:00:00 2001 From: alcercu <333aleix333@gmail.com> Date: Thu, 12 Oct 2023 15:49:38 +0200 Subject: [PATCH 02/89] refactor(web): abstract isDesktop --- web/src/components/CasesDisplay/CasesGrid.tsx | 10 ++++------ web/src/components/CasesDisplay/Filters.tsx | 10 ++++------ web/src/hooks/useIsDesktop.tsx | 10 ++++++++++ web/src/pages/Cases/CasesFetcher.tsx | 10 ++++------ web/src/pages/Home/HeroImage.tsx | 8 +++----- 5 files changed, 25 insertions(+), 23 deletions(-) create mode 100644 web/src/hooks/useIsDesktop.tsx diff --git a/web/src/components/CasesDisplay/CasesGrid.tsx b/web/src/components/CasesDisplay/CasesGrid.tsx index 217da23f0..14b1c2403 100644 --- a/web/src/components/CasesDisplay/CasesGrid.tsx +++ b/web/src/components/CasesDisplay/CasesGrid.tsx @@ -1,14 +1,13 @@ -import React, { useMemo } from "react"; +import React from "react"; import styled from "styled-components"; -import { useWindowSize } from "react-use"; import { useParams } from "react-router-dom"; import { SkeletonDisputeCard, SkeletonDisputeListItem } from "../StyledSkeleton"; import { StandardPagination } from "@kleros/ui-components-library"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import { useIsList } from "context/IsListProvider"; import { isUndefined } from "utils/index"; import { decodeURIFilter } from "utils/uri"; import { DisputeDetailsFragment } from "queries/useCasesQuery"; +import useIsDesktop from "hooks/useIsDesktop"; import DisputeCard from "components/DisputeCard"; import CasesListHeader from "./CasesListHeader"; @@ -47,12 +46,11 @@ const CasesGrid: React.FC = ({ disputes, casesPerPage, totalPages, c const decodedFilter = decodeURIFilter(filter ?? "all"); const { id: searchValue } = decodedFilter; const { isList } = useIsList(); - const { width } = useWindowSize(); - const screenIsBig = useMemo(() => width > BREAKPOINT_LANDSCAPE, [width]); + const isDesktop = useIsDesktop(); return ( <> - {isList && screenIsBig ? ( + {isList && isDesktop ? ( {isUndefined(disputes) diff --git a/web/src/components/CasesDisplay/Filters.tsx b/web/src/components/CasesDisplay/Filters.tsx index 01b5cc639..ebc332eaa 100644 --- a/web/src/components/CasesDisplay/Filters.tsx +++ b/web/src/components/CasesDisplay/Filters.tsx @@ -1,12 +1,11 @@ import React from "react"; import styled, { useTheme } from "styled-components"; import { useNavigate, useParams } from "react-router-dom"; -import { useWindowSize } from "react-use"; import { DropdownSelect } from "@kleros/ui-components-library"; import { useIsList } from "context/IsListProvider"; +import useIsDesktop from "hooks/useIsDesktop"; import ListIcon from "svgs/icons/list.svg"; import GridIcon from "svgs/icons/grid.svg"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import { decodeURIFilter, encodeURIFilter, useRootPath } from "utils/uri"; const Container = styled.div` @@ -59,9 +58,8 @@ const Filters: React.FC = () => { navigate(`${location}/1/${value}/${encodedFilter}`); }; - const { width } = useWindowSize(); const { isList, setIsList } = useIsList(); - const screenIsBig = width > BREAKPOINT_LANDSCAPE; + const isDesktop = useIsDesktop(); return ( @@ -87,14 +85,14 @@ const Filters: React.FC = () => { defaultValue={order} callback={handleOrderChange} /> - {screenIsBig ? ( + {isDesktop ? ( {isList ? ( setIsList(false)} /> ) : ( { - if (screenIsBig) { + if (isDesktop) { setIsList(true); } }} diff --git a/web/src/hooks/useIsDesktop.tsx b/web/src/hooks/useIsDesktop.tsx new file mode 100644 index 000000000..1b0928f12 --- /dev/null +++ b/web/src/hooks/useIsDesktop.tsx @@ -0,0 +1,10 @@ +import { useMemo } from "react"; +import { useWindowSize } from "react-use"; +import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; + +const useIsDesktop = () => { + const { width } = useWindowSize(); + return useMemo(() => width > BREAKPOINT_LANDSCAPE, [width]); +}; + +export default useIsDesktop; diff --git a/web/src/pages/Cases/CasesFetcher.tsx b/web/src/pages/Cases/CasesFetcher.tsx index 80f8cc762..393934e30 100644 --- a/web/src/pages/Cases/CasesFetcher.tsx +++ b/web/src/pages/Cases/CasesFetcher.tsx @@ -1,14 +1,13 @@ import React, { useMemo } from "react"; -import { useWindowSize } from "react-use"; import { useParams, useNavigate } from "react-router-dom"; import { DisputeDetailsFragment, Dispute_Filter, OrderDirection } from "src/graphql/graphql"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import { useCasesQuery } from "queries/useCasesQuery"; import { useCounterQuery, CounterQuery } from "queries/useCounter"; import { useCourtDetails, CourtDetailsQuery } from "queries/useCourtDetails"; +import useIsDesktop from "hooks/useIsDesktop"; +import { isUndefined } from "utils/index"; import { decodeURIFilter, useRootPath } from "utils/uri"; import CasesDisplay from "components/CasesDisplay"; -import { isUndefined } from "utils/index"; const calculateStats = ( isCourtFilter: boolean, @@ -42,9 +41,8 @@ const CasesFetcher: React.FC = () => { const { page, order, filter } = useParams(); const location = useRootPath(); const navigate = useNavigate(); - const { width } = useWindowSize(); - const screenIsBig = width > BREAKPOINT_LANDSCAPE; - const casesPerPage = screenIsBig ? 9 : 3; + const isDesktop = useIsDesktop(); + const casesPerPage = isDesktop ? 9 : 3; const pageNumber = parseInt(page ?? "1"); const disputeSkip = casesPerPage * (pageNumber - 1); const { data: counterData } = useCounterQuery(); diff --git a/web/src/pages/Home/HeroImage.tsx b/web/src/pages/Home/HeroImage.tsx index 3170244b5..d07c177f9 100644 --- a/web/src/pages/Home/HeroImage.tsx +++ b/web/src/pages/Home/HeroImage.tsx @@ -1,18 +1,16 @@ import React from "react"; import { useTheme } from "styled-components"; -import { useWindowSize } from "react-use"; -import { BREAKPOINT_LANDSCAPE } from "styles/landscapeStyle"; import HeroLightMobile from "tsx:svgs/hero/hero-lightmode-mobile.svg"; import HeroDarkMobile from "tsx:svgs/hero/hero-darkmode-mobile.svg"; import HeroLightDesktop from "tsx:svgs/hero/hero-lightmode-desktop.svg"; import HeroDarkDesktop from "tsx:svgs/hero/hero-darkmode-desktop.svg"; +import useIsDesktop from "hooks/useIsDesktop"; const HeroImage = () => { - const { width } = useWindowSize(); const theme = useTheme(); const themeIsLight = theme.name === "light"; - const screenIsBig = width > BREAKPOINT_LANDSCAPE; - return
{screenIsBig ? : }
; + const isDesktop = useIsDesktop(); + return
{isDesktop ? : }
; }; const HeroDesktop: React.FC<{ themeIsLight: boolean }> = ({ themeIsLight }) => { From 8d4931a18324d1ab82a47ccb71985fbaa368f183 Mon Sep 17 00:00:00 2001 From: alcercu <333aleix333@gmail.com> Date: Mon, 30 Oct 2023 12:57:49 +0100 Subject: [PATCH 03/89] feat(web): add commit reveal --- web/src/consts/eip712-messages.ts | 21 ++- web/src/hooks/queries/useDrawQuery.ts | 6 + web/src/hooks/useSigningAccount.tsx | 25 ++++ .../CaseDetails/Voting/Classic/Commit.tsx | 81 ++++++++++ .../Voting/Classic/JustificationArea.tsx | 35 +++++ .../Voting/Classic/OptionsContainer.tsx | 93 ++++++++++++ .../CaseDetails/Voting/Classic/Reveal.tsx | 94 ++++++++++++ .../Cases/CaseDetails/Voting/Classic/Vote.tsx | 66 +++++++++ .../CaseDetails/Voting/Classic/index.tsx | 139 +++--------------- .../CaseDetails/Voting/PendingVotesBox.tsx | 30 ++++ .../CaseDetails/Voting/VotingHistory.tsx | 34 +---- web/src/utils/index.ts | 3 +- 12 files changed, 475 insertions(+), 152 deletions(-) create mode 100644 web/src/hooks/useSigningAccount.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Classic/JustificationArea.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Classic/Reveal.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Classic/Vote.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/PendingVotesBox.tsx diff --git a/web/src/consts/eip712-messages.ts b/web/src/consts/eip712-messages.ts index 01ba1494d..544a8cbf4 100644 --- a/web/src/consts/eip712-messages.ts +++ b/web/src/consts/eip712-messages.ts @@ -1,5 +1,5 @@ export default { - contactDetails: (address: `0x${string}`, nonce, telegram = "", email = "") => + contactDetails: (address: `0x${string}`, nonce: string, telegram = "", email = "") => ({ address: address.toLowerCase() as `0x${string}`, domain: { @@ -21,4 +21,23 @@ export default { nonce, }, } as const), + signingAccount: (address: `0x${string}`) => + ({ + account: address.toLowerCase() as `0x${string}`, + domain: { + name: "Kleros v2", + version: "1", + chainId: 421_613, + }, + types: { + SigningAccount: [{ name: "body", type: "string" }], + }, + primaryType: "SigningAccount", + message: { + body: + "To keep your data safe and to use certain features of Kleros, we ask that you sign these message to " + + "create a secret key for your account. This key is unrelated from your main Ethereum account and will " + + "not be able to send any transactions.", + }, + } as const), }; diff --git a/web/src/hooks/queries/useDrawQuery.ts b/web/src/hooks/queries/useDrawQuery.ts index b94802abc..25bf0a4a5 100644 --- a/web/src/hooks/queries/useDrawQuery.ts +++ b/web/src/hooks/queries/useDrawQuery.ts @@ -8,6 +8,12 @@ const drawQuery = graphql(` query Draw($address: String, $disputeID: String, $roundID: String) { draws(where: { dispute: $disputeID, juror: $address, round: $roundID }) { voteIDNum + vote { + ... on ClassicVote { + commit + commited + } + } } } `); diff --git a/web/src/hooks/useSigningAccount.tsx b/web/src/hooks/useSigningAccount.tsx new file mode 100644 index 000000000..1927c1ab2 --- /dev/null +++ b/web/src/hooks/useSigningAccount.tsx @@ -0,0 +1,25 @@ +import { useLocalStorage } from "react-use"; +import { WalletClient } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { useWalletClient } from "wagmi"; +import messages from "consts/eip712-messages"; +import { isUndefined } from "utils/index"; + +const useSigningAccount = () => { + const { data: wallet } = useWalletClient(); + const key = `signingAccount-${wallet?.account.address}`; + const [signingKey, setSigningKey] = useLocalStorage(key, "0x" as `0x${string}`); + return { + signingAccount: !isUndefined(signingKey) ? privateKeyToAccount(signingKey) : null, + generateSigningAccount: () => (!isUndefined(wallet) ? generateSigningAccount(wallet, setSigningKey) : null), + }; +}; + +const generateSigningAccount = async (wallet: WalletClient, setSigningKey: (signingKey: `0x${string}`) => void) => { + if (isUndefined(wallet.account)) return; + const signingKey = await wallet.signTypedData(messages.signingAccount(wallet.account.address)); + setSigningKey(signingKey); + return privateKeyToAccount(signingKey); +}; + +export default useSigningAccount; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx new file mode 100644 index 000000000..c76e87d62 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx @@ -0,0 +1,81 @@ +import React, { useCallback, useMemo } from "react"; +import styled from "styled-components"; +import { useParams } from "react-router-dom"; +import { useLocalStorage } from "react-use"; +import { keccak256, encodePacked } from "viem"; +import { useWalletClient, usePublicClient } from "wagmi"; +import { prepareWriteDisputeKitClassic } from "hooks/contracts/generated"; +import useSigningAccount from "hooks/useSigningAccount"; +import { wrapWithToast } from "utils/wrapWithToast"; +import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; +import OptionsContainer from "./OptionsContainer"; +import { isUndefined } from "utils/index"; + +const Container = styled.div` + width: 100%; + height: auto; +`; + +interface ICommit { + arbitrable: `0x${string}`; + voteIDs: string[]; + setIsOpen: (val: boolean) => void; +} + +const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen }) => { + const { id } = useParams(); + const parsedDisputeID = useMemo(() => BigInt(id ?? 0), [id]); + const parsedVoteIDs = useMemo(() => voteIDs.map((voteID) => BigInt(voteID)), [voteIDs]); + const { data: disputeData } = useDisputeDetailsQuery(id); + const currentRoundIndex = disputeData?.dispute?.currentRoundIndex; + const { data: walletClient } = useWalletClient(); + const publicClient = usePublicClient(); + const { signingAccount, generateSigningAccount } = useSigningAccount(); + const saltKey = useMemo( + () => `dispute-${id}-round-${currentRoundIndex}-voteids-${voteIDs}`, + [id, currentRoundIndex, voteIDs] + ); + const [_, setSalt] = useLocalStorage(saltKey); + + const handleCommit = useCallback( + async (choice: number) => { + const message = { message: saltKey }; + const salt = !isUndefined(signingAccount) + ? signingAccount.signMessage(message) + : await (async () => { + const account = await generateSigningAccount(); + account!.signMessage(message); + })(); + setSalt(JSON.stringify({ salt, choice })); + const commit = keccak256(encodePacked([BigInt, String], [BigInt(choice), salt])); + const { request } = await prepareWriteDisputeKitClassic({ + functionName: "castCommit", + args: [parsedDisputeID, parsedVoteIDs, commit], + }); + if (walletClient) { + wrapWithToast(async () => await walletClient.writeContract(request), publicClient).then(() => { + setIsOpen(true); + }); + } + }, + [ + saltKey, + setSalt, + parsedVoteIDs, + parsedDisputeID, + publicClient, + setIsOpen, + walletClient, + generateSigningAccount, + signingAccount, + ] + ); + + return id ? ( + + + + ) : null; +}; + +export default Commit; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/JustificationArea.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/JustificationArea.tsx new file mode 100644 index 000000000..1144de92b --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/JustificationArea.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import styled from "styled-components"; +import { Textarea } from "@kleros/ui-components-library"; + +const StyledTextarea = styled(Textarea)` + width: 100%; + height: auto; + textarea { + height: 200px; + border-color: ${({ theme }) => theme.stroke}; + } + small { + font-weight: 400; + hyphens: auto; + } +`; + +interface IJustificationArea { + justification: string; + setJustification: (arg0: string) => void; +} + +const JustificationArea: React.FC = ({ justification, setJustification }) => ( + setJustification(e.target.value)} + placeholder="Justify your vote..." + message={ + "A good justification contributes to case comprehension. " + "Low quality justifications can be challenged." + } + variant="info" + /> +); + +export default JustificationArea; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx new file mode 100644 index 000000000..ea5fa4a5b --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useState } from "react"; +import styled from "styled-components"; +import { useParams } from "react-router-dom"; +import ReactMarkdown from "react-markdown"; +import { Button } from "@kleros/ui-components-library"; +import { isUndefined } from "utils/index"; +import { useDisputeTemplate } from "queries/useDisputeTemplate"; +import { EnsureChain } from "components/EnsureChain"; +import JustificationArea from "./JustificationArea"; + +const MainContainer = styled.div` + width: 100%; + height: auto; +`; + +const OptionsContainer = styled.div` + margin-top: 24px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; +`; + +const RefuseToArbitrateContainer = styled.div` + width: 100%; + background-color: ${({ theme }) => theme.lightBlue}; + padding: 32px; + display: flex; + justify-content: center; +`; + +interface IOptions { + arbitrable: `0x${string}`; + handleSelection: (arg0: number) => Promise; + justification?: string; + setJustification?: (arg0: string) => void; +} + +const Options: React.FC = ({ arbitrable, handleSelection, justification, setJustification }) => { + const { id } = useParams(); + const { data: disputeTemplate } = useDisputeTemplate(id, arbitrable); + const [chosenOption, setChosenOption] = useState(-1); + const [isSending, setIsSending] = useState(false); + + const onClick = useCallback( + async (id: number) => { + setIsSending(true); + setChosenOption(id); + await handleSelection(id); + setChosenOption(-1); + setIsSending(false); + }, + [handleSelection, setChosenOption, setIsSending] + ); + + return id ? ( + <> + + {disputeTemplate.question} + {!isUndefined(justification) && !isUndefined(setJustification) ? ( + + ) : null} + + {disputeTemplate?.answers?.map((answer: { title: string; description: string }, i: number) => { + return ( + +