diff --git a/subgraph/core/schema.graphql b/subgraph/core/schema.graphql index 8e68d66c6..8b601f091 100644 --- a/subgraph/core/schema.graphql +++ b/subgraph/core/schema.graphql @@ -305,6 +305,7 @@ type ClassicContribution implements Contribution @entity { localRound: ClassicRound! amount: BigInt! + rewardAmount: BigInt choice: BigInt! rewardWithdrawn: Boolean! } diff --git a/subgraph/core/src/DisputeKitClassic.ts b/subgraph/core/src/DisputeKitClassic.ts index 686c91214..4004af2f4 100644 --- a/subgraph/core/src/DisputeKitClassic.ts +++ b/subgraph/core/src/DisputeKitClassic.ts @@ -126,5 +126,6 @@ export function handleWithdrawal(event: Withdrawal): void { const contribution = ensureClassicContributionFromEvent(event); if (!contribution) return; contribution.rewardWithdrawn = true; + contribution.rewardAmount = event.params._amount; contribution.save(); } diff --git a/subgraph/core/src/entities/ClassicContribution.ts b/subgraph/core/src/entities/ClassicContribution.ts index c21599a51..6c9b59077 100644 --- a/subgraph/core/src/entities/ClassicContribution.ts +++ b/subgraph/core/src/entities/ClassicContribution.ts @@ -3,9 +3,7 @@ import { Contribution as ContributionEvent, Withdrawal } from "../../generated/D import { DISPUTEKIT_ID } from "../DisputeKitClassic"; export function ensureClassicContributionFromEvent(event: T): ClassicContribution | null { - if (!(event instanceof ContributionEvent) && !(event instanceof Withdrawal)) { - return null; - } + if (!(event instanceof ContributionEvent) && !(event instanceof Withdrawal)) return null; const coreDisputeID = event.params._coreDisputeID.toString(); const coreRoundIndex = event.params._coreRoundID.toString(); const roundID = `${DISPUTEKIT_ID}-${coreDisputeID}-${coreRoundIndex}`; @@ -25,9 +23,11 @@ export function ensureClassicContributionFromEvent(event: T): ClassicContribu classicContribution.rewardWithdrawn = false; } else { const currentAmount = classicContribution.amount; - classicContribution.amount = currentAmount.plus(event.params._amount); + // we dont want to increase amount on withdraw event, the amount in that event is reward/reimburse amount + if (event instanceof ContributionEvent) { + classicContribution.amount = currentAmount.plus(event.params._amount); + } } - classicContribution.save(); return classicContribution; } diff --git a/subgraph/package.json b/subgraph/package.json index 4aa832fbd..5dac81eff 100644 --- a/subgraph/package.json +++ b/subgraph/package.json @@ -1,6 +1,6 @@ { "name": "@kleros/kleros-v2-subgraph", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "scripts": { "update:core:arbitrum-sepolia-devnet": "./scripts/update.sh arbitrumSepoliaDevnet arbitrum-sepolia core/subgraph.yaml", diff --git a/web/src/assets/svgs/icons/eth.svg b/web/src/assets/svgs/icons/eth.svg index 449f2f658..da728a2d8 100644 --- a/web/src/assets/svgs/icons/eth.svg +++ b/web/src/assets/svgs/icons/eth.svg @@ -1,3 +1,3 @@ - + diff --git a/web/src/assets/svgs/label-icons/appeal.svg b/web/src/assets/svgs/label-icons/appeal.svg new file mode 100644 index 000000000..f7157ec13 --- /dev/null +++ b/web/src/assets/svgs/label-icons/appeal.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/evidence.svg b/web/src/assets/svgs/label-icons/evidence.svg new file mode 100644 index 000000000..79148204e --- /dev/null +++ b/web/src/assets/svgs/label-icons/evidence.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/forgot-vote.svg b/web/src/assets/svgs/label-icons/forgot-vote.svg new file mode 100644 index 000000000..61df7f53d --- /dev/null +++ b/web/src/assets/svgs/label-icons/forgot-vote.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/funded.svg b/web/src/assets/svgs/label-icons/funded.svg new file mode 100644 index 000000000..7e02541da --- /dev/null +++ b/web/src/assets/svgs/label-icons/funded.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/minus-circle.svg b/web/src/assets/svgs/label-icons/minus-circle.svg new file mode 100644 index 000000000..2d0319676 --- /dev/null +++ b/web/src/assets/svgs/label-icons/minus-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/rewards-lost.svg b/web/src/assets/svgs/label-icons/rewards-lost.svg new file mode 100644 index 000000000..6367e7b16 --- /dev/null +++ b/web/src/assets/svgs/label-icons/rewards-lost.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/rewards-won.svg b/web/src/assets/svgs/label-icons/rewards-won.svg new file mode 100644 index 000000000..54195deb4 --- /dev/null +++ b/web/src/assets/svgs/label-icons/rewards-won.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/vote.svg b/web/src/assets/svgs/label-icons/vote.svg new file mode 100644 index 000000000..da71a498b --- /dev/null +++ b/web/src/assets/svgs/label-icons/vote.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/web/src/assets/svgs/label-icons/voted.svg b/web/src/assets/svgs/label-icons/voted.svg new file mode 100644 index 000000000..2876498b5 --- /dev/null +++ b/web/src/assets/svgs/label-icons/voted.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/DisputeCard/CardLabels/Label.tsx b/web/src/components/DisputeCard/CardLabels/Label.tsx new file mode 100644 index 000000000..f3a7d6e38 --- /dev/null +++ b/web/src/components/DisputeCard/CardLabels/Label.tsx @@ -0,0 +1,63 @@ +import React, { useMemo } from "react"; +import styled, { Theme, useTheme } from "styled-components"; + +const COLORS: Record> = { + red: ["error", "errorLight"], + green: ["success", "successLight"], + blue: ["primaryBlue", "mediumBlue"], + purple: ["secondaryPurple", "mediumPurple"], + lightPurple: ["tint", "mediumPurple"], + grey: ["secondaryText", "lightGrey"], +}; + +export type IColors = keyof typeof COLORS; + +const LabelContainer = styled.div<{ backgroundColor: string }>` + display: inline-flex; + padding: 4px 8px; + align-items: center; + gap: 10px; + border-radius: 300px; + background-color: ${({ backgroundColor }) => backgroundColor}; +`; + +const IconContainer = styled.div<{ contentColor: string }>` + height: 14px; + width: 14px; + display: flex; + align-items: center; + justify-content: center; + > svg { + fill: ${({ contentColor }) => contentColor}; + } +`; + +const StyledText = styled.label<{ contentColor: string }>` + font-size: 12px; + font-weight: 400; + color: ${({ contentColor }) => contentColor}; +`; + +export interface ILabelProps { + text: string; + icon: React.FC>; + color: keyof typeof COLORS; +} + +const Label: React.FC = ({ text, icon: Icon, color }) => { + const theme = useTheme(); + const [contentColor, backgroundColor] = useMemo(() => { + return COLORS[color].map((color) => theme[color]); + }, [theme, color]); + + return ( + + + + + {text} + + ); +}; + +export default Label; diff --git a/web/src/components/DisputeCard/CardLabels/RewardsAndFundLabel.tsx b/web/src/components/DisputeCard/CardLabels/RewardsAndFundLabel.tsx new file mode 100644 index 000000000..1886a68ca --- /dev/null +++ b/web/src/components/DisputeCard/CardLabels/RewardsAndFundLabel.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import styled, { useTheme } from "styled-components"; +import EthIcon from "assets/svgs/icons/eth.svg"; +import PnkIcon from "assets/svgs/icons/kleros.svg"; +import NumberDisplay from "components/NumberDisplay"; + +const Container = styled.div` + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; +`; + +const StyledIcon = styled.div<{ color: string }>` + width: 12px; + height: 12px; + + path { + fill: ${({ color }) => color}; + } +`; + +const StyledLabel = styled.label<{ color: string }>` + color: ${({ color }) => color}; +`; +interface IRewardsAndFundLabel { + value: string; + unit: "ETH" | "PNK"; + isFund?: boolean; +} + +const RewardsAndFundLabel: React.FC = ({ value, unit = "ETH", isFund = false }) => { + const theme = useTheme(); + const isWon = Number(value) > 0; + const color = isWon ? theme.success : theme.error; + return Number(value) !== 0 ? ( + + + + + + + ) : null; +}; + +export default RewardsAndFundLabel; diff --git a/web/src/components/DisputeCard/CardLabels/index.tsx b/web/src/components/DisputeCard/CardLabels/index.tsx new file mode 100644 index 000000000..375f34073 --- /dev/null +++ b/web/src/components/DisputeCard/CardLabels/index.tsx @@ -0,0 +1,144 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; +import { formatEther, formatUnits } from "viem"; +import { useAccount } from "wagmi"; +import Skeleton from "react-loading-skeleton"; +import EvidenceIcon from "svgs/label-icons/evidence.svg"; +import NotDrawnIcon from "svgs/label-icons/minus-circle.svg"; +import CanVoteIcon from "svgs/label-icons/vote.svg"; +import VotedIcon from "svgs/label-icons/voted.svg"; +import ForgotToVoteIcon from "svgs/label-icons/forgot-vote.svg"; +import AppealIcon from "svgs/label-icons/appeal.svg"; +import FundedIcon from "svgs/label-icons/funded.svg"; +import { ClassicContribution } from "src/graphql/graphql"; +import { useLabelInfoQuery } from "hooks/queries/useLabelInfoQuery"; +import { isUndefined } from "utils/index"; +import { getLocalRounds } from "utils/getLocalRounds"; +import Label, { IColors } from "./Label"; +import RewardsAndFundLabel from "./RewardsAndFundLabel"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +`; + +interface ICardLabels { + disputeId: string; + round: number; +} + +const LabelArgs: Record>; color: IColors }> = { + EvidenceTime: { text: "Evidence Time", icon: EvidenceIcon, color: "blue" }, + NotDrawn: { text: "Not Drawn", icon: NotDrawnIcon, color: "grey" }, + CanVote: { text: "Time to vote", icon: CanVoteIcon, color: "blue" }, + Voted: { text: "I voted", icon: VotedIcon, color: "purple" }, + DidNotVote: { text: "Didn't cast a vote", icon: ForgotToVoteIcon, color: "purple" }, + CanFund: { text: "Appeal possible", icon: AppealIcon, color: "lightPurple" }, + Funded: { text: "I funded", icon: FundedIcon, color: "lightPurple" }, +}; + +const getFundingRewards = (contributions: ClassicContribution[], closed: boolean) => { + if (isUndefined(contributions) || contributions.length === 0) return 0; + const contribution = contributions.reduce((acc, val) => { + if (isUndefined(val?.rewardAmount) && isUndefined(val?.amount)) return acc; + if (closed) { + acc += val.rewardAmount === null ? -1 * Number(val.amount) : Number(val.rewardAmount) - Number(val.amount); + } else { + acc += Number(val.amount); + } + return acc; + }, 0); + return Number(formatUnits(BigInt(contribution), 18)); +}; + +const CardLabel: React.FC = ({ disputeId, round }) => { + const { address } = useAccount(); + const { data: labelInfo, isLoading } = useLabelInfoQuery(address?.toLowerCase(), disputeId); + const localRounds = getLocalRounds(labelInfo?.dispute?.disputeKitDispute); + const rounds = labelInfo?.dispute?.rounds; + const currentRound = rounds?.[round]; + + const period = labelInfo?.dispute?.period; + const hasVotedCurrentRound = !isUndefined(currentRound?.drawnJurors?.[0]?.vote?.choice); + const isDrawnCurrentRound = currentRound?.drawnJurors.length !== 0; + const hasVotedInDispute = rounds?.some((item) => !isUndefined(item.drawnJurors?.[0]?.vote?.choice)); + const isDrawnInDispute = rounds?.some((item) => item?.drawnJurors.length); + const hasFundedCurrentRound = localRounds?.[round]?.contributions.length !== 0; + const currentRoundFund = getFundingRewards(localRounds?.[round]?.contributions, period === "execution"); + const shifts = labelInfo?.dispute?.shifts; + + const contributions = useMemo( + () => + localRounds?.reduce((acc, val) => { + acc.push(...val.contributions); + return acc; + }, []), + [localRounds] + ); + + const contributionRewards = useMemo(() => getFundingRewards(contributions, true), [contributions]); + const hasFundedDispute = contributions?.length !== 0; // if ever funded the dispute in any round + + const labelData = useMemo(() => { + if (period === "evidence") return LabelArgs.EvidenceTime; + if (!isDrawnCurrentRound && period === "appeal") + return hasFundedCurrentRound ? LabelArgs.Funded : LabelArgs.CanFund; + + if (!isDrawnCurrentRound && period === "execution" && hasFundedDispute) return LabelArgs.Funded; + if (period === "execution" && hasVotedInDispute) return LabelArgs.Voted; + if (period === "execution" && isDrawnInDispute && !hasVotedInDispute) return LabelArgs.DidNotVote; + if (!isDrawnCurrentRound) return LabelArgs.NotDrawn; + + if (["commit", "vote"].includes(period ?? "") && !hasVotedCurrentRound) return LabelArgs.CanVote; + if (hasVotedCurrentRound) return LabelArgs.Voted; // plus rewards if execution + return LabelArgs.DidNotVote; // plus rewards if execution + }, [ + hasFundedCurrentRound, + hasVotedCurrentRound, + hasFundedDispute, + hasVotedInDispute, + isDrawnCurrentRound, + isDrawnInDispute, + period, + ]); + + const rewardsData = useMemo(() => { + const shift = shifts?.reduce( + (acc, val) => { + acc.ethShift += Number(formatEther(val.ethAmount)); + acc.pnkShift += Number(formatUnits(val.pnkAmount, 18)); + return acc; + }, + { ethShift: 0, pnkShift: 0 } + ); + if (isUndefined(shift)) return undefined; + shift.ethShift += contributionRewards; + return shift; + }, [contributionRewards, shifts]); + + return ( + + {isLoading ? ( + + ) : ( + <> + + {!isUndefined(rewardsData) && period === "execution" ? ( + <> + + + + ) : null} + {!isUndefined(currentRoundFund) && period === "appeal" ? ( + + ) : null} + + )} + + ); +}; + +export default CardLabel; diff --git a/web/src/components/DisputeCard/DisputeInfo.tsx b/web/src/components/DisputeCard/DisputeInfo.tsx index cf7b431b8..5612a5374 100644 --- a/web/src/components/DisputeCard/DisputeInfo.tsx +++ b/web/src/components/DisputeCard/DisputeInfo.tsx @@ -12,6 +12,8 @@ import Field from "../Field"; import { getCourtsPath } from "pages/Courts/CourtDetails"; import { useCourtTree } from "hooks/queries/useCourtTree"; import { responsiveSize } from "styles/responsiveSize"; +import CardLabel from "./CardLabels"; +import { useAccount } from "wagmi"; const Container = styled.div<{ isList: boolean; isOverview?: boolean }>` display: flex; @@ -94,6 +96,7 @@ const getPeriodPhrase = (period: Periods): string => { }; export interface IDisputeInfo { + disputeID?: string; courtId?: string; court?: string; category?: string; @@ -103,6 +106,7 @@ export interface IDisputeInfo { round?: number; overrideIsList?: boolean; isOverview?: boolean; + showLabels?: boolean; } const formatDate = (date: number) => { @@ -113,6 +117,7 @@ const formatDate = (date: number) => { }; const DisputeInfo: React.FC = ({ + disputeID, courtId, court, category, @@ -122,8 +127,10 @@ const DisputeInfo: React.FC = ({ round, overrideIsList, isOverview, + showLabels = false, }) => { const { isList } = useIsList(); + const { isDisconnected } = useAccount(); const displayAsList = isList && !overrideIsList; const { data } = useCourtTree(); const courtPath = getCourtsPath(data?.court, courtId); @@ -206,6 +213,7 @@ const DisputeInfo: React.FC = ({ isOverview={isOverview} /> )} + {showLabels && !isDisconnected ? : null} ); diff --git a/web/src/components/DisputeCard/index.tsx b/web/src/components/DisputeCard/index.tsx index 49f56a168..fb4fb0edc 100644 --- a/web/src/components/DisputeCard/index.tsx +++ b/web/src/components/DisputeCard/index.tsx @@ -18,7 +18,7 @@ import { INVALID_DISPUTE_DATA_ERROR } from "consts/index"; const StyledCard = styled(Card)` width: 100%; - height: ${responsiveSize(280, 296)}; + height: ${responsiveSize(280, 335)}; ${landscapeStyle( () => @@ -122,10 +122,12 @@ const DisputeCard: React.FC = ({ )} diff --git a/web/src/components/NumberDisplay.tsx b/web/src/components/NumberDisplay.tsx index 825c008a9..236c5b90e 100644 --- a/web/src/components/NumberDisplay.tsx +++ b/web/src/components/NumberDisplay.tsx @@ -9,6 +9,18 @@ interface INumberDisplay { isCurrency?: boolean; //currency units are shown in front } +const getFormattedValue = (value: number, decimals: number) => { + const withFixedDecimals = value % 1 !== 0 ? value.toFixed(decimals) : value.toFixed(0); + if (value !== 0) { + if (withFixedDecimals === `0.${"0".repeat(decimals)}`) { + return `< 0.${"0".repeat(decimals - 1)}1`; + } else if (withFixedDecimals === `-0.${"0".repeat(decimals)}`) { + return `> -0.${"0".repeat(decimals - 1)}1`; + } + } + return withFixedDecimals; +}; + const NumberDisplay: React.FC = ({ value, unit, @@ -18,7 +30,7 @@ const NumberDisplay: React.FC = ({ isCurrency = false, }) => { const parsedValue = Number(value); - const formattedValue = parsedValue % 1 !== 0 ? parsedValue.toFixed(decimals) : parsedValue.toFixed(0); + const formattedValue = getFormattedValue(parsedValue, decimals); const tooltipValue = isCurrency ? `${unit} ${value}` : `${value} ${unit}`; const displayUnit = showUnitInDisplay ? unit : ""; const displayValue = isCurrency ? `${displayUnit} ${formattedValue}` : `${formattedValue} ${displayUnit}`; diff --git a/web/src/hooks/queries/useDisputeDetailsQuery.ts b/web/src/hooks/queries/useDisputeDetailsQuery.ts index 221ef05b1..944eebd83 100644 --- a/web/src/hooks/queries/useDisputeDetailsQuery.ts +++ b/web/src/hooks/queries/useDisputeDetailsQuery.ts @@ -33,7 +33,7 @@ const disputeDetailsQuery = graphql(` export const useDisputeDetailsQuery = (id?: string | number) => { const isEnabled = id !== undefined; - return useQuery({ + return useQuery({ queryKey: ["refetchOnBlock", `disputeDetailsQuery${id}`], enabled: isEnabled, queryFn: async () => await graphqlQueryFnHelper(disputeDetailsQuery, { disputeID: id?.toString() }), diff --git a/web/src/hooks/queries/useLabelInfoQuery.ts b/web/src/hooks/queries/useLabelInfoQuery.ts new file mode 100644 index 000000000..85fb406d2 --- /dev/null +++ b/web/src/hooks/queries/useLabelInfoQuery.ts @@ -0,0 +1,46 @@ +import { graphql } from "src/graphql"; +import { LabelInfoQuery } from "src/graphql/graphql"; +import { useQuery } from "@tanstack/react-query"; +import { graphqlQueryFnHelper } from "utils/graphqlQueryFnHelper"; + +const labelQuery = graphql(` + query LabelInfo($address: String, $disputeID: ID!) { + dispute(id: $disputeID) { + period + + shifts(where: { juror: $address }) { + ethAmount + pnkAmount + } + rounds { + drawnJurors(where: { juror: $address }, first: 1) { + vote { + ... on ClassicVote { + choice + } + } + } + } + disputeKitDispute { + localRounds { + ... on ClassicRound { + contributions(where: { contributor: $address }) { + amount + rewardAmount + } + } + } + } + } + } +`); + +export const useLabelInfoQuery = (address?: string | null, disputeID?: string) => { + const isEnabled = !!(address && disputeID); + return useQuery({ + queryKey: [`labelQuery${[address, disputeID]}`], + enabled: isEnabled, + staleTime: 60000, + queryFn: async () => await graphqlQueryFnHelper(labelQuery, { address, disputeID }), + }); +};