From 7cf13b47c9549075e18024ad42e0508d143b82b2 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Thu, 8 May 2025 16:52:22 +0530 Subject: [PATCH 01/11] feat(web): batch-disputes --- web/src/context/NewDisputeContext.tsx | 41 +++++- web/src/hooks/useTransactionBatcher.tsx | 8 +- .../SubmitBatchDisputesButton.tsx | 119 ++++++++++++++++++ .../NavigationButtons/SubmitDisputeButton.tsx | 23 +++- .../Resolver/NavigationButtons/index.tsx | 10 +- web/src/pages/Resolver/Preview/index.tsx | 65 +++++++++- web/src/pages/Resolver/index.tsx | 6 +- 7 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 web/src/pages/Resolver/NavigationButtons/SubmitBatchDisputesButton.tsx diff --git a/web/src/context/NewDisputeContext.tsx b/web/src/context/NewDisputeContext.tsx index f15e2440b..4f7333849 100644 --- a/web/src/context/NewDisputeContext.tsx +++ b/web/src/context/NewDisputeContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useState, useContext, useMemo, useCallback } from "react"; +import React, { createContext, useState, useContext, useMemo, useCallback, useEffect } from "react"; +import { useLocation } from "react-router-dom"; import { Address } from "viem"; import { DEFAULT_CHAIN } from "consts/chains"; @@ -7,6 +8,8 @@ import { klerosCoreAddress } from "hooks/contracts/generated"; import { useLocalStorage } from "hooks/useLocalStorage"; import { isEmpty, isUndefined } from "utils/index"; +export const MIN_DISPUTE_BATCH_SIZE = 2; + export type Answer = { id: string; title: string; @@ -58,6 +61,10 @@ interface INewDisputeContext { setIsSubmittingCase: (isSubmittingCase: boolean) => void; isPolicyUploading: boolean; setIsPolicyUploading: (isPolicyUploading: boolean) => void; + isBatchCreation: boolean; + setIsBatchCreation: (isBatchCreation: boolean) => void; + batchSize: number; + setBatchSize: (batchSize?: number) => void; } const getInitialDisputeData = (): IDisputeData => ({ @@ -90,13 +97,26 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [disputeData, setDisputeData] = useLocalStorage("disputeData", initialDisputeData); const [isSubmittingCase, setIsSubmittingCase] = useState(false); const [isPolicyUploading, setIsPolicyUploading] = useState(false); + const [isBatchCreation, setIsBatchCreation] = useState(false); + const [batchSize, setBatchSize] = useLocalStorage("disputeBatchSize", MIN_DISPUTE_BATCH_SIZE); const disputeTemplate = useMemo(() => constructDisputeTemplate(disputeData), [disputeData]); + const location = useLocation(); const resetDisputeData = useCallback(() => { const freshData = getInitialDisputeData(); setDisputeData(freshData); - }, [setDisputeData]); + setBatchSize(MIN_DISPUTE_BATCH_SIZE); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Cleanup function to clear local storage when user leaves the route + if (location.pathname.includes("/resolver")) return; + + resetDisputeData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname]); const contextValues = useMemo( () => ({ @@ -108,8 +128,23 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsSubmittingCase, isPolicyUploading, setIsPolicyUploading, + isBatchCreation, + setIsBatchCreation, + batchSize, + setBatchSize, }), - [disputeData, disputeTemplate, resetDisputeData, isSubmittingCase, isPolicyUploading, setDisputeData] + [ + disputeData, + disputeTemplate, + resetDisputeData, + isSubmittingCase, + isPolicyUploading, + setDisputeData, + isBatchCreation, + setIsBatchCreation, + batchSize, + setBatchSize, + ] ); return {children}; diff --git a/web/src/hooks/useTransactionBatcher.tsx b/web/src/hooks/useTransactionBatcher.tsx index 8cb10abad..7324fd21f 100644 --- a/web/src/hooks/useTransactionBatcher.tsx +++ b/web/src/hooks/useTransactionBatcher.tsx @@ -37,10 +37,15 @@ const useTransactionBatcher = ( options: TransactionBatcherOptions = { enabled: true } ) => { const validatedConfigs = configs ?? []; + const totalValue = validatedConfigs.reduce((sum, config) => { + return sum + (config?.value ?? BigInt(0)); + }, BigInt(0)); + const { data: batchConfig, isLoading, isError, + error, } = useSimulateTransactionBatcherBatchSend({ query: { enabled: !isUndefined(configs) && options.enabled, @@ -50,6 +55,7 @@ const useTransactionBatcher = ( validatedConfigs.map((config) => config?.value ?? BigInt(0)), validatedConfigs.map((config) => encodeFunctionData(config)), ], + value: totalValue, }); const { writeContractAsync } = useWriteTransactionBatcherBatchSend(); @@ -58,7 +64,7 @@ const useTransactionBatcher = ( [writeContractAsync] ); - return { executeBatch, batchConfig, isError, isLoading }; + return { executeBatch, batchConfig, isError, isLoading, error }; }; export default useTransactionBatcher; diff --git a/web/src/pages/Resolver/NavigationButtons/SubmitBatchDisputesButton.tsx b/web/src/pages/Resolver/NavigationButtons/SubmitBatchDisputesButton.tsx new file mode 100644 index 000000000..83c40e58a --- /dev/null +++ b/web/src/pages/Resolver/NavigationButtons/SubmitBatchDisputesButton.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; + +import { useNavigate } from "react-router-dom"; +import { useAccount, useBalance, usePublicClient } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { DEFAULT_CHAIN } from "consts/chains"; +import { MIN_DISPUTE_BATCH_SIZE, useNewDisputeContext } from "context/NewDisputeContext"; +import { disputeResolverAbi, disputeResolverAddress } from "hooks/contracts/generated"; +import useTransactionBatcher from "hooks/useTransactionBatcher"; +import { isUndefined } from "utils/index"; +import { parseWagmiError } from "utils/parseWagmiError"; +import { prepareArbitratorExtradata } from "utils/prepareArbitratorExtradata"; +import { wrapWithToast } from "utils/wrapWithToast"; + +import { EnsureChain } from "components/EnsureChain"; +import { ErrorButtonMessage } from "components/ErrorButtonMessage"; +import ClosedCircleIcon from "components/StyledIcons/ClosedCircleIcon"; + +import { isTemplateValid } from "./SubmitDisputeButton"; + +const StyledButton = styled(Button)``; + +const SubmitBatchDisputesButton: React.FC = () => { + const publicClient = usePublicClient(); + const navigate = useNavigate(); + const { disputeTemplate, disputeData, resetDisputeData, isSubmittingCase, setIsSubmittingCase, batchSize } = + useNewDisputeContext(); + + const { address, chainId } = useAccount(); + const { data: userBalance, isLoading: isBalanceLoading } = useBalance({ address }); + + const insufficientBalance = useMemo(() => { + const arbitrationCost = disputeData.arbitrationCost ? BigInt(disputeData.arbitrationCost) : BigInt(0); + return userBalance && userBalance.value < arbitrationCost * BigInt(batchSize ?? MIN_DISPUTE_BATCH_SIZE); + }, [userBalance, disputeData, batchSize]); + + const { + executeBatch, + batchConfig, + isLoading: isLoadingConfig, + error, + isError, + } = useTransactionBatcher( + Array.from({ length: batchSize }, () => ({ + abi: disputeResolverAbi, + address: disputeResolverAddress[chainId ?? DEFAULT_CHAIN], + functionName: "createDisputeForTemplate", + args: [ + // TODO: decide which dispute kit to use + prepareArbitratorExtradata(disputeData.courtId ?? "1", disputeData.numberOfJurors ?? 3, 1), + JSON.stringify(disputeTemplate), + "", + BigInt(disputeTemplate.answers.length), + ], + value: BigInt(disputeData.arbitrationCost ?? 0), + })), + { + enabled: !insufficientBalance && isTemplateValid(disputeTemplate), + } + ); + + const isButtonDisabled = useMemo( + () => + isError || + isSubmittingCase || + !isTemplateValid(disputeTemplate) || + isBalanceLoading || + insufficientBalance || + isLoadingConfig, + [isSubmittingCase, insufficientBalance, isBalanceLoading, disputeTemplate, isLoadingConfig, isError] + ); + + const errorMsg = useMemo(() => { + if (insufficientBalance) return "Insufficient balance"; + else if (error) { + return parseWagmiError(error); + } + return null; + }, [error, insufficientBalance]); + + return ( + <> + +
+ { + if (batchConfig && publicClient) { + setIsSubmittingCase(true); + wrapWithToast(async () => await executeBatch(batchConfig), publicClient) + .then((res) => { + if (res.status && !isUndefined(res.result)) { + resetDisputeData(); + navigate("/cases/display/1/desc/all"); + } + }) + .finally(() => { + setIsSubmittingCase(false); + }); + } + }} + /> + {errorMsg && ( + + {errorMsg} + + )} +
+
+ + ); +}; + +export default SubmitBatchDisputesButton; diff --git a/web/src/pages/Resolver/NavigationButtons/SubmitDisputeButton.tsx b/web/src/pages/Resolver/NavigationButtons/SubmitDisputeButton.tsx index 5db0a357f..5e0285a08 100644 --- a/web/src/pages/Resolver/NavigationButtons/SubmitDisputeButton.tsx +++ b/web/src/pages/Resolver/NavigationButtons/SubmitDisputeButton.tsx @@ -43,7 +43,12 @@ const SubmitDisputeButton: React.FC = () => { }, [userBalance, disputeData]); // TODO: decide which dispute kit to use - const { data: submitCaseConfig, error } = useSimulateDisputeResolverCreateDisputeForTemplate({ + const { + data: submitCaseConfig, + error, + isLoading: isLoadingConfig, + isError, + } = useSimulateDisputeResolverCreateDisputeForTemplate({ query: { enabled: !insufficientBalance && isTemplateValid(disputeTemplate), }, @@ -59,8 +64,14 @@ const SubmitDisputeButton: React.FC = () => { const { writeContractAsync: submitCase } = useWriteDisputeResolverCreateDisputeForTemplate(); const isButtonDisabled = useMemo( - () => isSubmittingCase || !isTemplateValid(disputeTemplate) || isBalanceLoading || insufficientBalance, - [isSubmittingCase, insufficientBalance, isBalanceLoading, disputeTemplate] + () => + isError || + isSubmittingCase || + !isTemplateValid(disputeTemplate) || + isBalanceLoading || + insufficientBalance || + isLoadingConfig, + [isSubmittingCase, insufficientBalance, isBalanceLoading, disputeTemplate, isLoadingConfig, isError] ); const errorMsg = useMemo(() => { @@ -79,9 +90,9 @@ const SubmitDisputeButton: React.FC = () => { { - if (submitCaseConfig) { + if (submitCaseConfig && publicClient) { setIsSubmittingCase(true); wrapWithToast(async () => await submitCase(submitCaseConfig.request), publicClient) .then((res) => { @@ -120,7 +131,7 @@ const SubmitDisputeButton: React.FC = () => { ); }; -const isTemplateValid = (disputeTemplate: IDisputeTemplate) => { +export const isTemplateValid = (disputeTemplate: IDisputeTemplate) => { const areVotingOptionsFilled = disputeTemplate.question !== "" && disputeTemplate.answers.every((answer) => answer.title !== "" && answer.description !== ""); diff --git a/web/src/pages/Resolver/NavigationButtons/index.tsx b/web/src/pages/Resolver/NavigationButtons/index.tsx index 2133ec0fa..e7b1a7a3f 100644 --- a/web/src/pages/Resolver/NavigationButtons/index.tsx +++ b/web/src/pages/Resolver/NavigationButtons/index.tsx @@ -1,10 +1,15 @@ import React from "react"; import styled from "styled-components"; +import { useNewDisputeContext } from "context/NewDisputeContext"; + +import { isUndefined } from "src/utils"; + import { responsiveSize } from "styles/responsiveSize"; import NextButton from "./NextButton"; import PreviousButton from "./PreviousButton"; +import SubmitBatchDisputesButton from "./SubmitBatchDisputesButton"; import SubmitDisputeButton from "./SubmitDisputeButton"; const Container = styled.div` @@ -21,10 +26,13 @@ interface NavigationButtonsProps { } const NavigationButtons: React.FC = ({ prevRoute, nextRoute }) => { + const { isBatchCreation } = useNewDisputeContext(); + + const SubmitButton = isBatchCreation ? SubmitBatchDisputesButton : SubmitDisputeButton; return ( - {prevRoute === "/resolver/policy" ? : } + {isUndefined(nextRoute) ? : } ); }; diff --git a/web/src/pages/Resolver/Preview/index.tsx b/web/src/pages/Resolver/Preview/index.tsx index cc70d88e8..a98de0eb3 100644 --- a/web/src/pages/Resolver/Preview/index.tsx +++ b/web/src/pages/Resolver/Preview/index.tsx @@ -1,7 +1,7 @@ import React from "react"; import styled, { css } from "styled-components"; -import { Card } from "@kleros/ui-components-library"; +import { Card, Checkbox } from "@kleros/ui-components-library"; import { useNewDisputeContext } from "context/NewDisputeContext"; @@ -14,6 +14,8 @@ import { DisputeContext } from "components/DisputePreview/DisputeContext"; import { Policies } from "components/DisputePreview/Policies"; import DisputeInfo from "components/DisputeView/DisputeInfo"; import { Divider } from "components/Divider"; +import PlusMinusField from "components/PlusMinusField"; +import WithHelpTooltip from "components/WithHelpTooltip"; import NavigationButtons from "../NavigationButtons"; @@ -23,14 +25,16 @@ const Container = styled.div` display: flex; flex-direction: column; align-items: center; + gap: 16px; `; const StyledCard = styled(Card)` width: 100%; height: auto; min-height: 100px; - margin-bottom: ${responsiveSize(130, 70)}; + position: relative; `; + const PreviewContainer = styled.div` width: 100%; height: auto; @@ -52,8 +56,45 @@ const Header = styled.h2` )} `; +const BatchCreationContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + align-items: start; + align-self: flex-start; + margin-bottom: ${responsiveSize(130, 70)}; +`; + +const FieldContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: start; +`; + +const FieldLabel = styled.p` + padding: 0; + margin: 0; + font-size: 16px; + color: ${({ theme }) => theme.secondaryText}; +`; + +const StyledPlusMinusField = styled(PlusMinusField)` + margin: 0; +`; + +const Overlay = styled.div` + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 2; +`; + const Preview: React.FC = () => { - const { disputeData, disputeTemplate } = useNewDisputeContext(); + const { disputeData, disputeTemplate, isBatchCreation, setIsBatchCreation, batchSize, setBatchSize } = + useNewDisputeContext(); const { data: courtPolicy } = useCourtPolicy(disputeData.courtId); const courtName = courtPolicy?.name; @@ -61,6 +102,7 @@ const Preview: React.FC = () => {
Preview
+ @@ -76,6 +118,23 @@ const Preview: React.FC = () => { + + + setIsBatchCreation(!isBatchCreation)} + /> + + + {isBatchCreation ? ( + + Number of cases to be created: {batchSize} + setBatchSize(val)} /> + + ) : null} +
); diff --git a/web/src/pages/Resolver/index.tsx b/web/src/pages/Resolver/index.tsx index 1cece5e56..386f16c75 100644 --- a/web/src/pages/Resolver/index.tsx +++ b/web/src/pages/Resolver/index.tsx @@ -1,12 +1,10 @@ -import React, { useEffect } from "react"; +import React from "react"; import styled, { css } from "styled-components"; import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { useToggle } from "react-use"; import { useAccount } from "wagmi"; -import { useNewDisputeContext } from "context/NewDisputeContext"; - import { MAX_WIDTH_LANDSCAPE, landscapeStyle } from "styles/landscapeStyle"; import { responsiveSize } from "styles/responsiveSize"; @@ -81,9 +79,7 @@ const DisputeResolver: React.FC = () => { const [isDisputeResolverMiniGuideOpen, toggleDisputeResolverMiniGuide] = useToggle(false); const { isConnected } = useAccount(); const isPreviewPage = location.pathname.includes("/preview"); - const { resetDisputeData } = useNewDisputeContext(); - useEffect(() => resetDisputeData(), []); return ( From ad342812716c16bc0c0ae6a7ea40aea846f72ca9 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Fri, 9 May 2025 16:14:47 +0530 Subject: [PATCH 02/11] feat(web): case-duplication-in-resolver --- .../hooks/queries/usePopulatedDisputeData.ts | 2 +- web/src/hooks/queries/useRoundDetailsQuery.ts | 37 +++++ web/src/pages/Resolver/Landing.tsx | 128 ++++++++++++++++++ .../NotablePersons/PersonFields.tsx | 21 ++- web/src/pages/Resolver/index.tsx | 7 +- 5 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 web/src/hooks/queries/useRoundDetailsQuery.ts create mode 100644 web/src/pages/Resolver/Landing.tsx diff --git a/web/src/hooks/queries/usePopulatedDisputeData.ts b/web/src/hooks/queries/usePopulatedDisputeData.ts index d7268bce4..853d8acb3 100644 --- a/web/src/hooks/queries/usePopulatedDisputeData.ts +++ b/web/src/hooks/queries/usePopulatedDisputeData.ts @@ -5,8 +5,8 @@ import { executeActions } from "@kleros/kleros-sdk/src/dataMappings/executeActio import { DisputeDetails } from "@kleros/kleros-sdk/src/dataMappings/utils/disputeDetailsTypes"; import { populateTemplate } from "@kleros/kleros-sdk/src/dataMappings/utils/populateTemplate"; -import { useGraphqlBatcher } from "context/GraphqlBatcher"; import { DEFAULT_CHAIN } from "consts/chains"; +import { useGraphqlBatcher } from "context/GraphqlBatcher"; import { debounceErrorToast } from "utils/debounceErrorToast"; import { isUndefined } from "utils/index"; diff --git a/web/src/hooks/queries/useRoundDetailsQuery.ts b/web/src/hooks/queries/useRoundDetailsQuery.ts new file mode 100644 index 000000000..6a85db659 --- /dev/null +++ b/web/src/hooks/queries/useRoundDetailsQuery.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { REFETCH_INTERVAL, STALE_TIME } from "consts/index"; +import { useGraphqlBatcher } from "context/GraphqlBatcher"; + +import { graphql } from "src/graphql"; +import { RoundDetailsQuery } from "src/graphql/graphql"; +import { isUndefined } from "src/utils"; + +const roundDetailsQuery = graphql(` + query RoundDetails($roundID: ID!) { + round(id: $roundID) { + court { + id + } + nbVotes + } + } +`); + +export const useRoundDetailsQuery = (disputeId?: string, roundIndex?: number) => { + const isEnabled = !isUndefined(disputeId) && !isUndefined(roundIndex); + const { graphqlBatcher } = useGraphqlBatcher(); + + return useQuery({ + queryKey: [`roundDetailsQuery${disputeId}-${roundIndex}`], + enabled: isEnabled, + refetchInterval: REFETCH_INTERVAL, + staleTime: STALE_TIME, + queryFn: async () => + await graphqlBatcher.fetch({ + id: crypto.randomUUID(), + document: roundDetailsQuery, + variables: { roundID: `${disputeId}-${roundIndex}` }, + }), + }); +}; diff --git a/web/src/pages/Resolver/Landing.tsx b/web/src/pages/Resolver/Landing.tsx new file mode 100644 index 000000000..06b25ae43 --- /dev/null +++ b/web/src/pages/Resolver/Landing.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import styled, { css } from "styled-components"; + +import { useNavigate } from "react-router-dom"; +import { useDebounce } from "react-use"; + +import { Button } from "@kleros/ui-components-library"; + +import { AliasArray, Answer, useNewDisputeContext } from "context/NewDisputeContext"; + +import { useDisputeDetailsQuery } from "queries/useDisputeDetailsQuery"; +import { usePopulatedDisputeData } from "queries/usePopulatedDisputeData"; +import { useRoundDetailsQuery } from "queries/useRoundDetailsQuery"; + +import { isUndefined } from "src/utils"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { responsiveSize } from "styles/responsiveSize"; + +import { Divider } from "components/Divider"; +import LabeledInput from "components/LabeledInput"; + +import Header from "./Header"; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + align-items: center; + width: 84vw; + + ${landscapeStyle( + () => css` + width: ${responsiveSize(442, 700, 900)}; + + padding-bottom: 240px; + gap: 48px; + ` + )} +`; + +const ErrorMsg = styled.small` + font-size: 16px; + color: ${({ theme }) => theme.error}; +`; + +const Landing: React.FC = () => { + const navigate = useNavigate(); + + const [disputeID, setDisputeID] = useState(); + const [debouncedDisputeID, setDebouncedDisputeID] = useState(); + const { disputeData, setDisputeData } = useNewDisputeContext(); + useDebounce(() => setDebouncedDisputeID(disputeID), 500, [disputeID]); + + const { data: dispute } = useDisputeDetailsQuery(debouncedDisputeID); + const { + data: populatedDispute, + isError: isErrorPopulatedDisputeQuery, + isLoading, + } = usePopulatedDisputeData(debouncedDisputeID, dispute?.dispute?.arbitrated.id as `0x${string}`); + + // we want the genesis round's court and numberOfJurors + const { data: roundData, isError: isErrorRoundQuery } = useRoundDetailsQuery(debouncedDisputeID, 0); + + const isInvalidDispute = + !isLoading && + !isUndefined(populatedDispute) && + (isErrorRoundQuery || isErrorPopulatedDisputeQuery || Object.keys(populatedDispute).length === 0); + + useEffect(() => { + if (isUndefined(populatedDispute) || isUndefined(roundData) || isInvalidDispute) return; + + const answers = populatedDispute.answers.reduce((acc, val) => { + acc.push({ ...val, id: parseInt(val.id, 16).toString() }); + return acc; + }, []); + + let aliasesArray: AliasArray[] | undefined; + if (!isUndefined(populatedDispute.aliases)) { + aliasesArray = Object.entries(populatedDispute.aliases).map(([key, value], index) => ({ + name: key, + address: value, + id: (index + 1).toString(), + })); + } + + setDisputeData({ + ...disputeData, + title: populatedDispute.title, + description: populatedDispute.description, + category: populatedDispute.category, + policyURI: populatedDispute.policyURI, + question: populatedDispute.question, + courtId: roundData.round?.court.id, + numberOfJurors: roundData.round?.nbVotes, + answers, + aliasesArray, + }); + }, [populatedDispute, roundData, isInvalidDispute]); + + const showContinueButton = + !isUndefined(disputeData) && !isUndefined(populatedDispute) && !isInvalidDispute && !isUndefined(roundData); + return ( + +
+