From 5ac64f5fa5495bb9f718736216d6e416d377d0aa Mon Sep 17 00:00:00 2001 From: kemuru <102478601+kemuru@users.noreply.github.com> Date: Thu, 15 May 2025 04:08:04 +0200 Subject: [PATCH 01/10] feat: shutter support in dispute commiting --- web/package.json | 1 + .../hooks/queries/useDisputeDetailsQuery.ts | 3 + .../CaseDetails/Voting/Classic/Commit.tsx | 2 +- .../CaseDetails/Voting/Classic/Reveal.tsx | 2 +- .../Cases/CaseDetails/Voting/Classic/Vote.tsx | 2 +- .../{Classic => }/JustificationArea.tsx | 0 .../Voting/{Classic => }/OptionsContainer.tsx | 0 .../CaseDetails/Voting/Shutter/Commit.tsx | 102 +++++++++ .../Cases/CaseDetails/Voting/Shutter/Vote.tsx | 5 + .../CaseDetails/Voting/Shutter/index.tsx | 27 +++ .../pages/Cases/CaseDetails/Voting/index.tsx | 11 +- web/src/utils/shutter.ts | 205 ++++++++++++++++++ yarn.lock | 1 + 13 files changed, 357 insertions(+), 4 deletions(-) rename web/src/pages/Cases/CaseDetails/Voting/{Classic => }/JustificationArea.tsx (100%) rename web/src/pages/Cases/CaseDetails/Voting/{Classic => }/OptionsContainer.tsx (100%) create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Shutter/Vote.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx create mode 100644 web/src/utils/shutter.ts diff --git a/web/package.json b/web/package.json index 3fb8abd7c..7043f07b1 100644 --- a/web/package.json +++ b/web/package.json @@ -94,6 +94,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/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 100% rename from web/src/pages/Cases/CaseDetails/Voting/Classic/OptionsContainer.tsx rename to web/src/pages/Cases/CaseDetails/Voting/OptionsContainer.tsx 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..a5518cc40 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx @@ -0,0 +1,102 @@ +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, encodePacked } from "viem"; +import { useWalletClient, usePublicClient, useConfig } from "wagmi"; + +import { simulateDisputeKitShutterCastCommit } from "hooks/contracts/generated"; +import useSigningAccount from "hooks/useSigningAccount"; +import { isUndefined } from "utils/index"; +import { wrapWithToast } from "utils/wrapWithToast"; +import { encrypt } from "utils/shutter"; +import OptionsContainer from "../OptionsContainer"; + +const Container = styled.div` + width: 100%; + height: auto; +`; + +interface ICommit { + arbitrable: `0x${string}`; + voteIDs: string[]; + setIsOpen: (val: boolean) => void; + refetch: () => void; +} + +const SEPARATOR = "␟"; + +const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) => { + 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 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}`; + const { encryptedCommitment, identity } = await encrypt(encodedMessage); + + const commitHash = keccak256( + encodePacked(["uint256", "uint256", "string"], [choice, BigInt(salt), justification]) + ); + + const { request } = await simulateDisputeKitShutterCastCommit(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, + ] + ); + + return id ? ( + + + + ) : null; +}; + +export default Commit; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Vote.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Vote.tsx new file mode 100644 index 000000000..dbcf50c39 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Vote.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +const Vote: React.FC = () => null; + +export default Vote; diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx new file mode 100644 index 000000000..d0d70ce06 --- /dev/null +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx @@ -0,0 +1,27 @@ +import React, { useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { useAccount } from "wagmi"; + +import { useDrawQuery } from "hooks/queries/useDrawQuery"; +import { useVotingContext } from "hooks/useVotingContext"; +import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; + +import ShutterCommit from "./Commit"; + +interface IShutter { + arbitrable: `0x${string}`; + setIsOpen: (val: boolean) => void; +} + +const Shutter: React.FC = ({ arbitrable, setIsOpen }) => { + const { id } = useParams(); + const { address } = useAccount(); + const { data: disputeData } = useDisputeDetailsQuery(id); + const { data: drawData, refetch } = useDrawQuery(address?.toLowerCase(), id, disputeData?.dispute?.currentRound.id); + const { isCommitPeriod, commited } = useVotingContext(); + const voteIDs = useMemo(() => drawData?.draws?.map((draw) => draw.voteIDNum) as string[], [drawData]); + + return id && isCommitPeriod && !commited ? : null; +}; + +export default Shutter; diff --git a/web/src/pages/Cases/CaseDetails/Voting/index.tsx b/web/src/pages/Cases/CaseDetails/Voting/index.tsx index 8b9146292..bb1140a58 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/index.tsx @@ -25,8 +25,11 @@ import InfoCard from "components/InfoCard"; import Popup, { PopupType } from "components/Popup"; import Classic from "./Classic"; +import Shutter from "./Shutter"; import VotingHistory from "./VotingHistory"; +import { getDisputeKitName } from "consts/index"; + const Container = styled.div` padding: 20px 16px 16px; @@ -66,6 +69,11 @@ const Voting: React.FC = ({ arbitrable, currentPeriodIndex }) => { const timesPerPeriod = disputeData?.dispute?.court?.timesPerPeriod; const finalDate = useFinalDate(lastPeriodChange, currentPeriodIndex, timesPerPeriod); + const disputeKitId = disputeData?.dispute?.currentRound?.disputeKit?.id; + const disputeKitName = disputeKitId ? getDisputeKitName(Number(disputeKitId)) : undefined; + const isClassicDisputeKit = disputeKitName?.toLowerCase().includes("classic") ?? false; + const isShutterDisputeKit = disputeKitName?.toLowerCase().includes("shutter") ?? false; + const isCommitOrVotePeriod = useMemo( () => [Periods.vote, Periods.commit].includes(currentPeriodIndex), [currentPeriodIndex] @@ -107,7 +115,8 @@ const Voting: React.FC = ({ arbitrable, currentPeriodIndex }) => { {userWasDrawn && isCommitOrVotePeriod && !voted ? ( <> - + {isClassicDisputeKit ? : null} + {isShutterDisputeKit ? : null} ) : ( diff --git a/web/src/utils/shutter.ts b/web/src/utils/shutter.ts new file mode 100644 index 000000000..4b03f71ca --- /dev/null +++ b/web/src/utils/shutter.ts @@ -0,0 +1,205 @@ +import { encryptData, decrypt as shutterDecrypt } from "@shutter-network/shutter-sdk"; +import { stringToHex, hexToString, Hex } from "viem"; + +// Time in seconds to wait before the message can be decrypted +export const DECRYPTION_DELAY = 5; + +interface ShutterApiMessageData { + eon: number; + identity: string; + identity_prefix: string; + eon_key: string; + tx_hash: string; +} + +interface ShutterApiResponse { + message: ShutterApiMessageData; + error?: string; +} + +interface ShutterDecryptionKeyData { + decryption_key: string; + identity: string; + decryption_timestamp: number; +} + +/** + * Fetches encryption data from the Shutter API + * @param decryptionTimestamp Unix timestamp when decryption should be possible + * @returns Promise with the eon key and identity + */ +async function fetchShutterData(decryptionTimestamp: number): Promise { + try { + console.log(`Sending request to Shutter API with decryption timestamp: ${decryptionTimestamp}`); + + // Generate a random identity prefix + const identityPrefix = generateRandomBytes32(); + console.log(`Generated identity prefix: ${identityPrefix}`); + + const response = await fetch("https://shutter-api.shutter.network/api/register_identity", { + method: "POST", + headers: { + accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + decryptionTimestamp, + identityPrefix, + }), + }); + + // Log the response status + console.log(`API response status: ${response.status}`); + + // Get the response text + const responseText = await response.text(); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}: ${responseText}`); + } + + // Parse the JSON response + let jsonResponse: ShutterApiResponse; + try { + jsonResponse = JSON.parse(responseText); + } catch (error) { + throw new Error(`Failed to parse API response as JSON: ${responseText}`); + } + + // Check if we have the message data + if (!jsonResponse.message) { + throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`); + } + + return jsonResponse.message; + } catch (error) { + console.error("Error fetching data from Shutter API:", error); + throw error; + } +} + +/** + * Fetches the decryption key from the Shutter API + * @param identity The identity used for encryption + * @returns Promise with the decryption key data + */ +async function fetchDecryptionKey(identity: string): Promise { + console.log(`Fetching decryption key for identity: ${identity}`); + + const response = await fetch(`https://shutter-api.shutter.network/api/get_decryption_key?identity=${identity}`, { + method: "GET", + headers: { + accept: "application/json", + }, + }); + + // Get the response text + const responseText = await response.text(); + + // Try to parse the error response even if the request failed + let jsonResponse; + try { + jsonResponse = JSON.parse(responseText); + } catch (error) { + throw new Error(`Failed to parse API response as JSON: ${responseText}`); + } + + // Handle the "too early" error case specifically + if (!response.ok) { + if (jsonResponse?.description?.includes("timestamp not reached yet")) { + throw new Error( + `Cannot decrypt yet: The decryption timestamp has not been reached.\n` + + `Please wait at least ${DECRYPTION_DELAY} seconds after encryption before attempting to decrypt.\n` + + `Error details: ${jsonResponse.description}` + ); + } + throw new Error(`API request failed with status ${response.status}: ${responseText}`); + } + + // Check if we have the message data + if (!jsonResponse.message) { + throw new Error(`API response missing message data: ${JSON.stringify(jsonResponse)}`); + } + + return jsonResponse.message; +} + +/** + * Ensures a string is a valid hex string with 0x prefix + * @param hexString The hex string to validate + * @returns The validated hex string with 0x prefix + */ +function ensureHexString(hexString: string | undefined): `0x${string}` { + if (!hexString) { + throw new Error("Hex string is undefined or null"); + } + + // Add 0x prefix if it doesn't exist + const prefixedHex = hexString.startsWith("0x") ? hexString : `0x${hexString}`; + return prefixedHex as `0x${string}`; +} + +/** + * Generates a random 32 bytes + * @returns Random 32 bytes as a hex string with 0x prefix + */ +function generateRandomBytes32(): `0x${string}` { + return ("0x" + + window.crypto + .getRandomValues(new Uint8Array(32)) + .reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex; +} + +/** + * Encrypts a message using the Shutter API + * @param message The message to encrypt + * @returns Promise with the encrypted commitment and identity + */ +export async function encrypt(message: string): Promise<{ encryptedCommitment: string; identity: string }> { + // Set decryption timestamp + const decryptionTimestamp = Math.floor(Date.now() / 1000) + DECRYPTION_DELAY; + + // Fetch encryption data from Shutter API + console.log(`Fetching encryption data for decryption at timestamp ${decryptionTimestamp}...`); + const shutterData = await fetchShutterData(decryptionTimestamp); + + // Extract the eon key and identity from the response and ensure they have the correct format + const eonKeyHex = ensureHexString(shutterData.eon_key); + const identityHex = ensureHexString(shutterData.identity); + + // Message to encrypt + const msgHex = stringToHex(message); + + // Generate a random sigma + const sigmaHex = generateRandomBytes32(); + + console.log("Eon Key:", eonKeyHex); + console.log("Identity:", identityHex); + console.log("Sigma:", sigmaHex); + + // Encrypt the message + const encryptedCommitment = await encryptData(msgHex, identityHex, eonKeyHex, sigmaHex); + + return { encryptedCommitment, identity: identityHex }; +} + +/** + * Decrypts a message using the Shutter API + * @param encryptedMessage The encrypted message to decrypt + * @param identity The identity used for encryption + * @returns Promise with the decrypted message + */ +export async function decrypt(encryptedMessage: string, identity: string): Promise { + // Fetch the decryption key + const decryptionKeyData = await fetchDecryptionKey(identity); + console.log("Decryption key:", decryptionKeyData.decryption_key); + + // Ensure the decryption key is properly formatted + const decryptionKey = ensureHexString(decryptionKeyData.decryption_key); + + // Decrypt the message + const decryptedHexMessage = await shutterDecrypt(encryptedMessage, decryptionKey); + + // Convert the decrypted hex message back to a string + return hexToString(decryptedHexMessage as `0x${string}`); +} diff --git a/yarn.lock b/yarn.lock index c38896dd1..6105e7802 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5743,6 +5743,7 @@ __metadata: "@reown/appkit-adapter-wagmi": "npm:^1.7.1" "@sentry/react": "npm:^7.120.0" "@sentry/tracing": "npm:^7.120.0" + "@shutter-network/shutter-sdk": "npm:^0.0.1" "@solana/wallet-adapter-react": "npm:^0.15.36" "@solana/web3.js": "npm:^1.98.0" "@tanstack/react-query": "npm:^5.69.0" From 8a5b2f476348f8e69842ad003950cdbbbbc3d755 Mon Sep 17 00:00:00 2001 From: kemuru <102478601+kemuru@users.noreply.github.com> Date: Thu, 15 May 2025 17:12:57 +0200 Subject: [PATCH 02/10] feat: shutter appeal support --- .../Cases/CaseDetails/Appeal/Shutter/Fund.tsx | 147 ++++++++++++++++++ .../CaseDetails/Appeal/Shutter/index.tsx | 49 ++++++ .../pages/Cases/CaseDetails/Appeal/index.tsx | 20 ++- web/src/pages/Resolver/index.tsx | 2 +- 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 web/src/pages/Cases/CaseDetails/Appeal/Shutter/Fund.tsx create mode 100644 web/src/pages/Cases/CaseDetails/Appeal/Shutter/index.tsx 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/Resolver/index.tsx b/web/src/pages/Resolver/index.tsx index 1faab4541..812f1e241 100644 --- a/web/src/pages/Resolver/index.tsx +++ b/web/src/pages/Resolver/index.tsx @@ -107,7 +107,7 @@ const DisputeResolver: React.FC = () => { {!isConnected || !isVerified ? ( <> - Justise as a Service + Justice as a Service You send your disputes. Kleros sends back decisions. ) : null} From 729777d120fed58b6f6c0c0a1285dc93c3c5f6ce Mon Sep 17 00:00:00 2001 From: kemuru <102478601+kemuru@users.noreply.github.com> Date: Tue, 20 May 2025 19:53:24 +0200 Subject: [PATCH 03/10] feat: postinstall script, use correct commit function --- web/package.json | 3 ++- web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx | 6 +++--- web/src/utils/shutter.ts | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/package.json b/web/package.json index 7043f07b1..c31cccf9d 100644 --- a/web/package.json +++ b/web/package.json @@ -43,7 +43,8 @@ "check-types": "tsc --noEmit", "generate": "yarn generate:gql && yarn generate:hooks", "generate:gql": "graphql-codegen --require tsconfig-paths/register", - "generate:hooks": "NODE_NO_WARNINGS=1 wagmi generate" + "generate:hooks": "NODE_NO_WARNINGS=1 wagmi generate", + "postinstall": "mkdir -p src/public/node_modules/@shutter-network/shutter-sdk/dist && cp -R ../node_modules/@shutter-network/shutter-sdk/dist/* src/public/node_modules/@shutter-network/shutter-sdk/dist" }, "prettier": "@kleros/kleros-v2-prettier-config", "devDependencies": { diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx index a5518cc40..889162f93 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx @@ -5,7 +5,7 @@ import { useLocalStorage } from "react-use"; import { keccak256, encodePacked } from "viem"; import { useWalletClient, usePublicClient, useConfig } from "wagmi"; -import { simulateDisputeKitShutterCastCommit } from "hooks/contracts/generated"; +import { simulateDisputeKitShutterCastCommitShutter } from "hooks/contracts/generated"; import useSigningAccount from "hooks/useSigningAccount"; import { isUndefined } from "utils/index"; import { wrapWithToast } from "utils/wrapWithToast"; @@ -24,7 +24,7 @@ interface ICommit { refetch: () => void; } -const SEPARATOR = "␟"; +const SEPARATOR = "-"; const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) => { const { id } = useParams(); @@ -59,7 +59,7 @@ const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) encodePacked(["uint256", "uint256", "string"], [choice, BigInt(salt), justification]) ); - const { request } = await simulateDisputeKitShutterCastCommit(wagmiConfig, { + const { request } = await simulateDisputeKitShutterCastCommitShutter(wagmiConfig, { args: [parsedDisputeID, parsedVoteIDs, commitHash, identity as `0x${string}`, encryptedCommitment], }); if (walletClient && publicClient) { diff --git a/web/src/utils/shutter.ts b/web/src/utils/shutter.ts index 4b03f71ca..05b57611f 100644 --- a/web/src/utils/shutter.ts +++ b/web/src/utils/shutter.ts @@ -2,7 +2,7 @@ import { encryptData, decrypt as shutterDecrypt } from "@shutter-network/shutter import { stringToHex, hexToString, Hex } from "viem"; // Time in seconds to wait before the message can be decrypted -export const DECRYPTION_DELAY = 5; +export const DECRYPTION_DELAY = 120; interface ShutterApiMessageData { eon: number; @@ -145,7 +145,7 @@ function ensureHexString(hexString: string | undefined): `0x${string}` { */ function generateRandomBytes32(): `0x${string}` { return ("0x" + - window.crypto + crypto .getRandomValues(new Uint8Array(32)) .reduce((acc, byte) => acc + byte.toString(16).padStart(2, "0"), "")) as Hex; } From 8bf377267cf899a295b1aa0c5a896f982f6da9ff Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Tue, 20 May 2025 19:59:02 +0100 Subject: [PATCH 04/10] fix: vote hashing during commitment must follow DisputeKitShutter.hashVote() --- .../CaseDetails/Voting/Shutter/Commit.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx index 889162f93..718ff4ee3 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx @@ -1,15 +1,17 @@ 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, encodePacked } from "viem"; +import { keccak256, stringToHex, encodeAbiParameters } from "viem"; import { useWalletClient, usePublicClient, useConfig } from "wagmi"; import { simulateDisputeKitShutterCastCommitShutter } from "hooks/contracts/generated"; import useSigningAccount from "hooks/useSigningAccount"; import { isUndefined } from "utils/index"; -import { wrapWithToast } from "utils/wrapWithToast"; import { encrypt } from "utils/shutter"; +import { wrapWithToast } from "utils/wrapWithToast"; + import OptionsContainer from "../OptionsContainer"; const Container = styled.div` @@ -26,6 +28,25 @@ interface ICommit { 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 }) => { const { id } = useParams(); const parsedDisputeID = useMemo(() => BigInt(id ?? 0), [id]); @@ -55,9 +76,7 @@ const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) const encodedMessage = `${choice.toString()}${SEPARATOR}${salt}${SEPARATOR}${justification}`; const { encryptedCommitment, identity } = await encrypt(encodedMessage); - const commitHash = keccak256( - encodePacked(["uint256", "uint256", "string"], [choice, BigInt(salt), justification]) - ); + const commitHash = hashVote(choice, BigInt(salt), justification); const { request } = await simulateDisputeKitShutterCastCommitShutter(wagmiConfig, { args: [parsedDisputeID, parsedVoteIDs, commitHash, identity as `0x${string}`, encryptedCommitment], From 8c74d4a202b93a8c07ca1edd743993354789e4c2 Mon Sep 17 00:00:00 2001 From: kemuru <102478601+kemuru@users.noreply.github.com> Date: Thu, 22 May 2025 02:48:41 +0200 Subject: [PATCH 05/10] feat: decryption delay is the remaining of the commit period --- web/src/pages/Cases/CaseDetails/Timeline.tsx | 2 +- .../CaseDetails/Voting/Shutter/Commit.tsx | 19 +++++++++++++++++-- .../CaseDetails/Voting/Shutter/index.tsx | 10 +++++++--- .../pages/Cases/CaseDetails/Voting/index.tsx | 9 ++++++--- web/src/pages/Cases/CaseDetails/index.tsx | 2 +- web/src/utils/shutter.ts | 12 ++++++------ 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/web/src/pages/Cases/CaseDetails/Timeline.tsx b/web/src/pages/Cases/CaseDetails/Timeline.tsx index 7a90b25b8..825332778 100644 --- a/web/src/pages/Cases/CaseDetails/Timeline.tsx +++ b/web/src/pages/Cases/CaseDetails/Timeline.tsx @@ -141,7 +141,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/Shutter/Commit.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx index 718ff4ee3..a444a0226 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/Commit.tsx @@ -8,11 +8,15 @@ 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%; @@ -24,6 +28,8 @@ interface ICommit { voteIDs: string[]; setIsOpen: (val: boolean) => void; refetch: () => void; + dispute: DisputeDetailsQuery["dispute"]; + currentPeriodIndex: number; } const SEPARATOR = "-"; @@ -47,7 +53,7 @@ const hashVote = (choice: bigint, salt: bigint, justification: string): `0x${str return keccak256(encodedParams); }; -const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) => { +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]); @@ -58,6 +64,12 @@ const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) 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) => { @@ -74,7 +86,9 @@ const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) setSalt(JSON.stringify({ salt, choice: choice.toString(), justification })); const encodedMessage = `${choice.toString()}${SEPARATOR}${salt}${SEPARATOR}${justification}`; - const { encryptedCommitment, identity } = await encrypt(encodedMessage); + // a minimum of 60 seconds of decryptionDelay is enforced to give the threshold crypto nodes time to coordinate + const decryptionDelay = Math.max(countdownToVotingPeriod, 60); + const { encryptedCommitment, identity } = await encrypt(encodedMessage, decryptionDelay); const commitHash = hashVote(choice, BigInt(salt), justification); @@ -101,6 +115,7 @@ const Commit: React.FC = ({ arbitrable, voteIDs, setIsOpen, refetch }) generateSigningAccount, signingAccount, refetch, + countdownToVotingPeriod, ] ); diff --git a/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx b/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx index d0d70ce06..2841b65b4 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/Shutter/index.tsx @@ -4,16 +4,18 @@ import { useAccount } from "wagmi"; import { useDrawQuery } from "hooks/queries/useDrawQuery"; import { useVotingContext } from "hooks/useVotingContext"; -import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; +import { DisputeDetailsQuery, useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import ShutterCommit from "./Commit"; interface IShutter { arbitrable: `0x${string}`; setIsOpen: (val: boolean) => void; + dispute: DisputeDetailsQuery["dispute"]; + currentPeriodIndex: number; } -const Shutter: React.FC = ({ arbitrable, setIsOpen }) => { +const Shutter: React.FC = ({ arbitrable, setIsOpen, dispute, currentPeriodIndex }) => { const { id } = useParams(); const { address } = useAccount(); const { data: disputeData } = useDisputeDetailsQuery(id); @@ -21,7 +23,9 @@ const Shutter: React.FC = ({ arbitrable, setIsOpen }) => { const { isCommitPeriod, commited } = useVotingContext(); const voteIDs = useMemo(() => drawData?.draws?.map((draw) => draw.voteIDNum) as string[], [drawData]); - return id && isCommitPeriod && !commited ? : null; + return id && isCommitPeriod && !commited ? ( + + ) : null; }; export default Shutter; diff --git a/web/src/pages/Cases/CaseDetails/Voting/index.tsx b/web/src/pages/Cases/CaseDetails/Voting/index.tsx index bb1140a58..bdb53fa00 100644 --- a/web/src/pages/Cases/CaseDetails/Voting/index.tsx +++ b/web/src/pages/Cases/CaseDetails/Voting/index.tsx @@ -15,7 +15,7 @@ import { isUndefined } from "utils/index"; import { isLastRound } from "utils/isLastRound"; import { useAppealCost } from "queries/useAppealCost"; -import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; +import { DisputeDetailsQuery, useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; import { responsiveSize } from "styles/responsiveSize"; import { landscapeStyle } from "styles/landscapeStyle"; @@ -55,9 +55,10 @@ const useFinalDate = (lastPeriodChange: string, currentPeriodIndex?: number, tim interface IVoting { arbitrable?: `0x${string}`; currentPeriodIndex: number; + dispute: DisputeDetailsQuery["dispute"]; } -const Voting: React.FC = ({ arbitrable, currentPeriodIndex }) => { +const Voting: React.FC = ({ arbitrable, currentPeriodIndex, dispute }) => { const { id } = useParams(); const { isDisconnected } = useAccount(); const { data: disputeData } = useDisputeDetailsQuery(id); @@ -116,7 +117,9 @@ const Voting: React.FC = ({ arbitrable, currentPeriodIndex }) => { <> {isClassicDisputeKit ? : null} - {isShutterDisputeKit ? : null} + {isShutterDisputeKit ? ( + + ) : null} ) : ( diff --git a/web/src/pages/Cases/CaseDetails/index.tsx b/web/src/pages/Cases/CaseDetails/index.tsx index ab4c0b0a9..d2bf60721 100644 --- a/web/src/pages/Cases/CaseDetails/index.tsx +++ b/web/src/pages/Cases/CaseDetails/index.tsx @@ -79,7 +79,7 @@ const CaseDetails: React.FC = () => { } /> } /> - } /> + } /> } /> } /> diff --git a/web/src/utils/shutter.ts b/web/src/utils/shutter.ts index 05b57611f..1b4ccd377 100644 --- a/web/src/utils/shutter.ts +++ b/web/src/utils/shutter.ts @@ -1,9 +1,6 @@ import { encryptData, decrypt as shutterDecrypt } from "@shutter-network/shutter-sdk"; import { stringToHex, hexToString, Hex } from "viem"; -// Time in seconds to wait before the message can be decrypted -export const DECRYPTION_DELAY = 120; - interface ShutterApiMessageData { eon: number; identity: string; @@ -109,7 +106,7 @@ async function fetchDecryptionKey(identity: string): Promise { +export async function encrypt( + message: string, + decryptionDelay: number +): Promise<{ encryptedCommitment: string; identity: string }> { // Set decryption timestamp - const decryptionTimestamp = Math.floor(Date.now() / 1000) + DECRYPTION_DELAY; + const decryptionTimestamp = Math.floor(Date.now() / 1000) + decryptionDelay; // Fetch encryption data from Shutter API console.log(`Fetching encryption data for decryption at timestamp ${decryptionTimestamp}...`); From bae8db4677753cefd8a40eea051c72075538c34d Mon Sep 17 00:00:00 2001 From: kemuru <102478601+kemuru@users.noreply.github.com> Date: Fri, 23 May 2025 19:39:53 +0200 Subject: [PATCH 06/10] chore: remove postinstall script, tell vite where to find it --- web/package.json | 4 ++-- web/vite.config.js | 13 +++++++++++++ yarn.lock | 47 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/web/package.json b/web/package.json index c31cccf9d..14966def3 100644 --- a/web/package.json +++ b/web/package.json @@ -43,8 +43,7 @@ "check-types": "tsc --noEmit", "generate": "yarn generate:gql && yarn generate:hooks", "generate:gql": "graphql-codegen --require tsconfig-paths/register", - "generate:hooks": "NODE_NO_WARNINGS=1 wagmi generate", - "postinstall": "mkdir -p src/public/node_modules/@shutter-network/shutter-sdk/dist && cp -R ../node_modules/@shutter-network/shutter-sdk/dist/* src/public/node_modules/@shutter-network/shutter-sdk/dist" + "generate:hooks": "NODE_NO_WARNINGS=1 wagmi generate" }, "prettier": "@kleros/kleros-v2-prettier-config", "devDependencies": { @@ -76,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" }, diff --git a/web/vite.config.js b/web/vite.config.js index 72937a581..cdcec781e 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -2,6 +2,11 @@ import { defineConfig } from "vite"; import { nodePolyfills } from "vite-plugin-node-polyfills"; import svgr from "vite-plugin-svgr"; import tsconfigPaths from "vite-tsconfig-paths"; +import { viteStaticCopy } from "vite-plugin-static-copy"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ root: "src", @@ -18,6 +23,14 @@ export default defineConfig({ }, envPrefix: ["REACT_APP", "ALCHEMY", "WALLETCONNECT_PROJECT_ID"], plugins: [ + viteStaticCopy({ + targets: [ + { + src: resolve(__dirname, "../node_modules/@shutter-network/shutter-sdk/dist/*"), + dest: "node_modules/@shutter-network/shutter-sdk/dist", + }, + ], + }), svgr({ include: ["**/*.svg", "tsx:**/*.svg"], exclude: ["../node_modules/**/*"], diff --git a/yarn.lock b/yarn.lock index 036ae59e0..cb6444c88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5798,6 +5798,7 @@ __metadata: viem: "npm:^2.24.1" vite: "npm:^5.4.11" vite-plugin-node-polyfills: "npm:^0.23.0" + vite-plugin-static-copy: "npm:^3.0.0" vite-plugin-svgr: "npm:^4.3.0" vite-tsconfig-paths: "npm:^4.3.2" wagmi: "npm:^2.14.15" @@ -18199,6 +18200,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.4": + version: 6.4.4 + resolution: "fdir@npm:6.4.4" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10/d0000d6b790059b35f4ed19acc8847a66452e0bc68b28766c929ffd523e5ec2083811fc8a545e4a1d4945ce70e887b3a610c145c681073b506143ae3076342ed + languageName: node + linkType: hard + "fflate@npm:^0.8.1": version: 0.8.1 resolution: "fflate@npm:0.8.1" @@ -18563,7 +18576,7 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:11.3.0": +"fs-extra@npm:11.3.0, fs-extra@npm:^11.3.0": version: 11.3.0 resolution: "fs-extra@npm:11.3.0" dependencies: @@ -26001,6 +26014,13 @@ __metadata: languageName: node linkType: hard +"p-map@npm:^7.0.3": + version: 7.0.3 + resolution: "p-map@npm:7.0.3" + checksum: 10/2ef48ccfc6dd387253d71bf502604f7893ed62090b2c9d73387f10006c342606b05233da0e4f29388227b61eb5aeface6197e166520c465c234552eeab2fe633 + languageName: node + linkType: hard + "p-queue@npm:^8.0.1": version: 8.1.0 resolution: "p-queue@npm:8.1.0" @@ -31893,6 +31913,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.13": + version: 0.2.13 + resolution: "tinyglobby@npm:0.2.13" + dependencies: + fdir: "npm:^6.4.4" + picomatch: "npm:^4.0.2" + checksum: 10/b04557ee58ad2be5f2d2cbb4b441476436c92bb45ba2e1fc464d686b793392b305ed0bcb8b877429e9b5036bdd46770c161a08384c0720b6682b7cd6ac80e403 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.6": version: 0.2.10 resolution: "tinyglobby@npm:0.2.10" @@ -33549,6 +33579,21 @@ __metadata: languageName: node linkType: hard +"vite-plugin-static-copy@npm:^3.0.0": + version: 3.0.0 + resolution: "vite-plugin-static-copy@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.3" + fs-extra: "npm:^11.3.0" + p-map: "npm:^7.0.3" + picocolors: "npm:^1.1.1" + tinyglobby: "npm:^0.2.13" + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + checksum: 10/06146ba263df82c2e040de4795ec1360db82a3ed4474672332c0893df1a8c3f202163135fbd0a85de066acaf0f05076f337b2f9ad753971c8f0c18099e6c35cc + languageName: node + linkType: hard + "vite-plugin-svgr@npm:^4.3.0": version: 4.3.0 resolution: "vite-plugin-svgr@npm:4.3.0" From 2e5d2a7bc3fa45ca48accda5a037325131120b05 Mon Sep 17 00:00:00 2001 From: kemuru <102478601+kemuru@users.noreply.github.com> Date: Mon, 26 May 2025 14:44:18 +0200 Subject: [PATCH 07/10] feat: support for revealing shutter commit from the frontend --- .../CaseDetails/Voting/Shutter/Reveal.tsx | 109 ++++++++++++++++++ .../Cases/CaseDetails/Voting/Shutter/Vote.tsx | 5 - .../CaseDetails/Voting/Shutter/index.tsx | 5 +- 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 web/src/pages/Cases/CaseDetails/Voting/Shutter/Reveal.tsx delete mode 100644 web/src/pages/Cases/CaseDetails/Voting/Shutter/Vote.tsx 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..8ed762b98 --- /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 (!data.salt || !data.choice || !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) ? ( +