diff --git a/web/package.json b/web/package.json index 3fb8abd7c..14966def3 100644 --- a/web/package.json +++ b/web/package.json @@ -75,6 +75,7 @@ "typescript": "^5.6.3", "vite": "^5.4.11", "vite-plugin-node-polyfills": "^0.23.0", + "vite-plugin-static-copy": "^3.0.0", "vite-plugin-svgr": "^4.3.0", "vite-tsconfig-paths": "^4.3.2" }, @@ -94,6 +95,7 @@ "@reown/appkit-adapter-wagmi": "^1.7.1", "@sentry/react": "^7.120.0", "@sentry/tracing": "^7.120.0", + "@shutter-network/shutter-sdk": "^0.0.1", "@solana/wallet-adapter-react": "^0.15.36", "@solana/web3.js": "^1.98.0", "@tanstack/react-query": "^5.69.0", diff --git a/web/src/hooks/queries/useDisputeDetailsQuery.ts b/web/src/hooks/queries/useDisputeDetailsQuery.ts index 2aed81c0f..f49b08fe9 100644 --- a/web/src/hooks/queries/useDisputeDetailsQuery.ts +++ b/web/src/hooks/queries/useDisputeDetailsQuery.ts @@ -28,6 +28,9 @@ const disputeDetailsQuery = graphql(` currentRound { id nbVotes + disputeKit { + id + } } currentRoundIndex isCrossChain diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Shutter/Fund.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Shutter/Fund.tsx new file mode 100644 index 000000000..fe2a40c72 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Appeal/Shutter/Fund.tsx @@ -0,0 +1,147 @@ +import React, { useMemo, useState } from "react"; +import styled from "styled-components"; + +import { useParams } from "react-router-dom"; +import { useDebounce } from "react-use"; +import { useAccount, useBalance, usePublicClient } from "wagmi"; +import { Field, Button } from "@kleros/ui-components-library"; + +import { REFETCH_INTERVAL } from "consts/index"; +import { useSimulateDisputeKitShutterFundAppeal, useWriteDisputeKitShutterFundAppeal } from "hooks/contracts/generated"; +import { useSelectedOptionContext, useFundingContext, useCountdownContext } from "hooks/useClassicAppealContext"; +import { useParsedAmount } from "hooks/useParsedAmount"; + +import { isUndefined } from "utils/index"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import { EnsureChain } from "components/EnsureChain"; +import { ErrorButtonMessage } from "components/ErrorButtonMessage"; +import ClosedCircleIcon from "components/StyledIcons/ClosedCircleIcon"; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +`; + +const StyledField = styled(Field)` + width: 100%; + & > input { + text-align: center; + } + &:before { + position: absolute; + content: "ETH"; + right: 32px; + top: 50%; + transform: translateY(-50%); + color: ${({ theme }) => theme.primaryText}; + } +`; + +const StyledButton = styled(Button)` + margin: auto; + margin-top: 4px; +`; + +const StyledLabel = styled.label` + align-self: flex-start; +`; + +const useNeedFund = () => { + const { loserSideCountdown } = useCountdownContext(); + const { fundedChoices, winningChoice } = useFundingContext(); + return ( + (loserSideCountdown ?? 0) > 0 || + (!isUndefined(fundedChoices) && + !isUndefined(winningChoice) && + fundedChoices.length > 0 && + !fundedChoices.includes(winningChoice)) + ); +}; + +const useFundAppeal = (parsedAmount: bigint, insufficientBalance: boolean) => { + const { id } = useParams(); + const { selectedOption } = useSelectedOptionContext(); + const { + data: fundAppealConfig, + isLoading, + isError, + } = useSimulateDisputeKitShutterFundAppeal({ + query: { enabled: !isUndefined(id) && !isUndefined(selectedOption) && !insufficientBalance }, + args: [BigInt(id ?? 0), BigInt(selectedOption?.id ?? 0)], + value: parsedAmount, + }); + const { writeContractAsync: fundAppeal } = useWriteDisputeKitShutterFundAppeal(); + return { fundAppeal, fundAppealConfig, isLoading, isError }; +}; + +interface IFund { + amount: `${number}`; + setAmount: (val: string) => void; + setIsOpen: (val: boolean) => void; +} + +const Fund: React.FC = ({ amount, setAmount, setIsOpen }) => { + const needFund = useNeedFund(); + const { address, isDisconnected } = useAccount(); + const { data: balance } = useBalance({ + query: { refetchInterval: REFETCH_INTERVAL }, + address, + }); + const publicClient = usePublicClient(); + const [isSending, setIsSending] = useState(false); + const [debouncedAmount, setDebouncedAmount] = useState<`${number}` | "">(""); + useDebounce(() => setDebouncedAmount(amount), 500, [amount]); + const parsedAmount = useParsedAmount(debouncedAmount as `${number}`); + const insufficientBalance = useMemo(() => balance && balance.value < parsedAmount, [balance, parsedAmount]); + const { fundAppealConfig, fundAppeal, isLoading, isError } = useFundAppeal(parsedAmount, insufficientBalance); + const isFundDisabled = useMemo( + () => + isDisconnected || + isSending || + !balance || + insufficientBalance || + Number(parsedAmount) <= 0 || + isError || + isLoading, + [isDisconnected, isSending, balance, insufficientBalance, parsedAmount, isError, isLoading] + ); + + return needFund ? ( + + How much ETH do you want to contribute? + setAmount(e.target.value)} + placeholder="Amount to fund" + /> + +
+ { + if (fundAppeal && fundAppealConfig && publicClient) { + setIsSending(true); + wrapWithToast(async () => await fundAppeal(fundAppealConfig.request), publicClient) + .then((res) => setIsOpen(res.status)) + .finally(() => setIsSending(false)); + } + }} + /> + {insufficientBalance && ( + + Insufficient balance + + )} +
+
+
+ ) : null; +}; + +export default Fund; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/Shutter/index.tsx b/web/src/pages/Cases/CaseDetails/Appeal/Shutter/index.tsx new file mode 100644 index 000000000..b13b9150e --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Appeal/Shutter/index.tsx @@ -0,0 +1,49 @@ +import React, { useState } from "react"; +import { useSelectedOptionContext } from "hooks/useClassicAppealContext"; +import Popup, { PopupType } from "components/Popup"; +import AppealIcon from "svgs/icons/appeal.svg"; +import HowItWorks from "components/HowItWorks"; +import Appeal from "components/Popup/MiniGuides/Appeal"; +import { AppealHeader, StyledTitle } from ".."; +import Options from "../Classic/Options"; +import Fund from "./Fund"; + +interface IShutter { + isAppealMiniGuideOpen: boolean; + toggleAppealMiniGuide: () => void; +} + +const Shutter: React.FC = ({ isAppealMiniGuideOpen, toggleAppealMiniGuide }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [amount, setAmount] = useState(""); + const { selectedOption } = useSelectedOptionContext(); + + return ( + <> + {isPopupOpen && ( + + )} + + Appeal crowdfunding + + + + + + + ); +}; + +export default Shutter; diff --git a/web/src/pages/Cases/CaseDetails/Appeal/index.tsx b/web/src/pages/Cases/CaseDetails/Appeal/index.tsx index fb0d71070..3983dc93a 100644 --- a/web/src/pages/Cases/CaseDetails/Appeal/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Appeal/index.tsx @@ -2,15 +2,18 @@ import React from "react"; import styled, { css } from "styled-components"; import { useToggle } from "react-use"; +import { useParams } from "react-router-dom"; import { Periods } from "consts/periods"; -import { ClassicAppealProvider } from "hooks/useClassicAppealContext"; +import { getDisputeKitName } from "consts/index"; +import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import { landscapeStyle } from "styles/landscapeStyle"; import { responsiveSize } from "styles/responsiveSize"; import AppealHistory from "./AppealHistory"; import Classic from "./Classic"; +import Shutter from "./Shutter"; const Container = styled.div` padding: 16px; @@ -44,11 +47,24 @@ export const StyledTitle = styled.h1` const Appeal: React.FC<{ currentPeriodIndex: number }> = ({ currentPeriodIndex }) => { const [isAppealMiniGuideOpen, toggleAppealMiniGuide] = useToggle(false); + const { id } = useParams(); + const { data: disputeData } = useDisputeDetailsQuery(id); + const disputeKitId = disputeData?.dispute?.currentRound?.disputeKit?.id; + const disputeKitName = disputeKitId ? getDisputeKitName(Number(disputeKitId))?.toLowerCase() : ""; + const isClassicDisputeKit = disputeKitName?.includes("classic") ?? false; + const isShutterDisputeKit = disputeKitName?.includes("shutter") ?? false; return ( {Periods.appeal === currentPeriodIndex ? ( - + <> + {isClassicDisputeKit && ( + + )} + {isShutterDisputeKit && ( + + )} + ) : ( )} diff --git a/web/src/pages/Cases/CaseDetails/Timeline.tsx b/web/src/pages/Cases/CaseDetails/Timeline.tsx index dfaed7e82..0dbf8fdfd 100644 --- a/web/src/pages/Cases/CaseDetails/Timeline.tsx +++ b/web/src/pages/Cases/CaseDetails/Timeline.tsx @@ -140,7 +140,7 @@ const useTimeline = (dispute: DisputeDetailsQuery["dispute"], currentItemIndex: })); }; -const getDeadline = ( +export const getDeadline = ( currentPeriodIndex: number, lastPeriodChange?: string, timesPerPeriod?: string[] diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx index f22f46ec8..6338b3945 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/Commit.tsx @@ -13,7 +13,7 @@ import { wrapWithToast } from "utils/wrapWithToast"; import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; -import OptionsContainer from "./OptionsContainer"; +import OptionsContainer from "../OptionsContainer"; const Container = styled.div` width: 100%; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/Reveal.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/Reveal.tsx index 8d4818b21..6099ea0c5 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Classic/Reveal.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/Reveal.tsx @@ -19,7 +19,7 @@ import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import InfoCard from "components/InfoCard"; -import JustificationArea from "./JustificationArea"; +import JustificationArea from "../JustificationArea"; import { Answer } from "@kleros/kleros-sdk"; import { EnsureChain } from "components/EnsureChain"; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/Vote.tsx b/web/src/pages/Cases/CaseDetails/Voting/Classic/Vote.tsx index 299f28c7a..6f7dbe088 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Classic/Vote.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Classic/Vote.tsx @@ -9,7 +9,7 @@ import { wrapWithToast } from "utils/wrapWithToast"; import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; -import OptionsContainer from "./OptionsContainer"; +import OptionsContainer from "../OptionsContainer"; const Container = styled.div` width: 100%; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/JustificationArea.tsx b/web/src/pages/Cases/CaseDetails/Voting/JustificationArea.tsx similarity index 100% rename from web/src/pages/Cases/CaseDetails/Voting/Classic/JustificationArea.tsx rename to web/src/pages/Cases/CaseDetails/Voting/JustificationArea.tsx diff --git a/web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx b/web/src/pages/Cases/CaseDetails/Voting/OptionsContainer.tsx similarity index 94% rename from web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx rename to web/src/pages/Cases/CaseDetails/Voting/OptionsContainer.tsx index 640c3dd9b..f58d132b0 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/OptionsContainer.tsx @@ -46,6 +46,7 @@ const RefuseToArbitrateContainer = styled.div` const StyledEnsureChain = styled(EnsureChain)` align-self: center; `; + interface IOptions { arbitrable: `0x${string}`; handleSelection: (arg0: bigint) => Promise; @@ -69,11 +70,16 @@ const Options: React.FC = ({ arbitrable, handleSelection, justificatio async (id: bigint) => { setIsSending(true); setChosenOption(id); - await handleSelection(id); - setChosenOption(BigInt(-1)); - setIsSending(false); + try { + await handleSelection(id); + } catch (error) { + console.error(error); + } finally { + setChosenOption(BigInt(-1)); + setIsSending(false); + } }, - [handleSelection, setChosenOption, setIsSending] + [handleSelection] ); return id ? ( diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx new file mode 100644 index 000000000..c49826916 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { useParams } from "react-router-dom"; +import { useLocalStorage } from "react-use"; +import { keccak256, stringToHex, encodeAbiParameters } from "viem"; +import { useWalletClient, usePublicClient, useConfig } from "wagmi"; + +import { simulateDisputeKitShutterCastCommitShutter } from "hooks/contracts/generated"; +import useSigningAccount from "hooks/useSigningAccount"; +import { useCountdown } from "hooks/useCountdown"; +import { DisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; + +import { isUndefined } from "utils/index"; +import { encrypt } from "utils/shutter"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import OptionsContainer from "../OptionsContainer"; +import { getDeadline } from "../../Timeline"; + +const Container = styled.div` + width: 100%; + height: auto; +`; + +interface ICommit { + arbitrable: `0x${string}`; + voteIDs: string[]; + setIsOpen: (val: boolean) => void; + refetch: () => void; + dispute: DisputeDetailsQuery["dispute"]; + currentPeriodIndex: number; +} + +const SEPARATOR = "-"; + +/** + * This hashing function must be follow the same logic as DisputeKitClassic.hashVote() + */ +const hashVote = (choice: bigint, salt: bigint, justification: string): `0x${string}` => { + const justificationHash = keccak256(stringToHex(justification)); + + // Encode and hash the parameters together (mimics Solidity's abi.encode) + const encodedParams = encodeAbiParameters( + [ + { type: "uint256" }, // choice + { type: "uint256" }, // salt + { type: "bytes32" }, // justificationHash + ], + [choice, salt, justificationHash] + ); + + return keccak256(encodedParams); +}; + +const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch, dispute, currentPeriodIndex }) => { + const { id } = useParams(); + const parsedDisputeID = useMemo(() => BigInt(id ?? 0), [id]); + const parsedVoteIDs = useMemo(() => voteIDs.map((voteID) => BigInt(voteID)), [voteIDs]); + const { data: walletClient } = useWalletClient(); + const publicClient = usePublicClient(); + const wagmiConfig = useConfig(); + const { signingAccount, generateSigningAccount } = useSigningAccount(); + const [justification, setJustification] = useState(""); + const saltKey = useMemo(() => `shutter-dispute-${id}-voteids-${voteIDs}`, [id, voteIDs]); + const [_, setSalt] = useLocalStorage(saltKey); + const deadlineCommitPeriod = getDeadline( + currentPeriodIndex, + dispute?.lastPeriodChange, + dispute?.court.timesPerPeriod + ); + const countdownToVotingPeriod = useCountdown(deadlineCommitPeriod); + + const handleCommit = useCallback( + async (choice: bigint) => { + const message = { message: saltKey }; + const rawSalt = !isUndefined(signingAccount) + ? await signingAccount.signMessage(message) + : await (async () => { + const account = await generateSigningAccount(); + return await account?.signMessage(message); + })(); + if (isUndefined(rawSalt)) return; + + const salt = keccak256(rawSalt); + setSalt(JSON.stringify({ salt, choice: choice.toString(), justification })); + + const encodedMessage = `${choice.toString()}${SEPARATOR}${salt}${SEPARATOR}${justification}`; + /* an extra 300 seconds (5 minutes) of decryptionDelay is enforced after Commit period is over + to avoid premature decryption and voting attacks if no one passes the Commit period quickly */ + const decryptionDelay = countdownToVotingPeriod + 300; + const { encryptedCommitment, identity } = await encrypt(encodedMessage, decryptionDelay); + + const commitHash = hashVote(choice, BigInt(salt), justification); + + const { request } = await simulateDisputeKitShutterCastCommitShutter(wagmiConfig, { + args: [parsedDisputeID, parsedVoteIDs, commitHash, identity as `0x${string}`, encryptedCommitment], + }); + if (walletClient && publicClient) { + await wrapWithToast(async () => await walletClient.writeContract(request), publicClient).then(({ status }) => { + setIsOpen(status); + }); + } + refetch(); + }, + [ + wagmiConfig, + justification, + saltKey, + setSalt, + parsedVoteIDs, + parsedDisputeID, + publicClient, + setIsOpen, + walletClient, + generateSigningAccount, + signingAccount, + refetch, + countdownToVotingPeriod, + ] + ); + + return id ? ( + + + + ) : null; +}; + +export default Commit; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Reveal.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Reveal.tsx new file mode 100644 index 000000000..ec318981a --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Reveal.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { Button } from "@kleros/ui-components-library"; +import { useWalletClient, usePublicClient } from "wagmi"; +import { useParams } from "react-router-dom"; +import { useLocalStorage } from "react-use"; + +import { wrapWithToast } from "utils/wrapWithToast"; +import { isUndefined } from "utils/index"; + +import { useSimulateDisputeKitShutterCastVoteShutter } from "hooks/contracts/generated"; + +const Container = styled.div` + width: 100%; + height: auto; + display: flex; + justify-content: center; + margin-top: 16px; +`; + +interface IReveal { + voteIDs: string[]; + setIsOpen: (val: boolean) => void; +} + +const Reveal: React.FC = ({ voteIDs, setIsOpen }) => { + const { id } = useParams(); + const parsedDisputeID = useMemo(() => BigInt(id ?? 0), [id]); + const parsedVoteIDs = useMemo(() => voteIDs.map((voteID) => BigInt(voteID)), [voteIDs]); + const saltKey = useMemo(() => `shutter-dispute-${id}-voteids-${voteIDs}`, [id, voteIDs]); + + const [storedData, _, removeStoredData] = useLocalStorage(saltKey); + + const { data: walletClient } = useWalletClient(); + const publicClient = usePublicClient(); + + const [isRevealing, setIsRevealing] = useState(false); + + const parsedStoredData = useMemo(() => { + if (isUndefined(storedData)) return undefined; + try { + const data = JSON.parse(storedData); + if (isUndefined(data.salt) || isUndefined(data.choice) || isUndefined(data.justification)) { + throw new Error("Invalid stored data"); + } + return data; + } catch (error) { + console.error("Error parsing stored data:", error); + return undefined; + } + }, [storedData]); + + const { + data: simulateData, + isLoading: isSimulating, + error: simulateError, + } = useSimulateDisputeKitShutterCastVoteShutter({ + query: { + enabled: !isUndefined(parsedStoredData), + }, + args: [ + parsedDisputeID, + parsedVoteIDs, + BigInt(parsedStoredData?.choice ?? 0), + BigInt(parsedStoredData?.salt ?? 0), + parsedStoredData?.justification ?? "", + ], + }); + + const handleReveal = useCallback(async () => { + if (isUndefined(parsedStoredData) || isUndefined(simulateData)) { + console.error("No committed vote found or simulation not ready."); + return; + } + + setIsRevealing(true); + try { + const { request } = simulateData; + if (walletClient && publicClient) { + await wrapWithToast(async () => await walletClient.writeContract(request), publicClient).then(({ status }) => { + if (status) { + removeStoredData(); + } + setIsOpen(status); + }); + } + } catch (error) { + console.error("Error revealing vote:", error); + } finally { + setIsRevealing(false); + } + }, [parsedStoredData, simulateData, walletClient, publicClient, setIsOpen, removeStoredData]); + + return ( + + {!isUndefined(parsedStoredData) ? ( +