diff --git a/subgraph/core/schema.graphql b/subgraph/core/schema.graphql index 36192bc77..6f7c13e5f 100644 --- a/subgraph/core/schema.graphql +++ b/subgraph/core/schema.graphql @@ -142,9 +142,11 @@ type Court @entity { numberClosedDisputes: BigInt! numberVotingDisputes: BigInt! numberAppealingDisputes: BigInt! + numberVotes: BigInt! stakedJurors: [JurorTokensPerCourt!]! @derivedFrom(field: "court") numberStakedJurors: BigInt! stake: BigInt! + effectiveStake: BigInt! delayedStake: BigInt! paidETH: BigInt! paidPNK: BigInt! diff --git a/subgraph/core/src/KlerosCore.ts b/subgraph/core/src/KlerosCore.ts index 650d5a2ff..c252fcce1 100644 --- a/subgraph/core/src/KlerosCore.ts +++ b/subgraph/core/src/KlerosCore.ts @@ -75,9 +75,12 @@ export function handleDisputeCreation(event: DisputeCreation): void { const court = Court.load(courtID); if (!court) return; court.numberDisputes = court.numberDisputes.plus(ONE); + + const roundInfo = contract.getRoundInfo(disputeID, ZERO); + court.numberVotes = court.numberVotes.plus(roundInfo.nbVotes); + court.save(); createDisputeFromEvent(event); - const roundInfo = contract.getRoundInfo(disputeID, ZERO); createRoundFromRoundInfo(KlerosCore.bind(event.address), disputeID, ZERO, roundInfo); const arbitrable = event.params._arbitrable.toHexString(); updateArbitrableCases(arbitrable, ONE); @@ -164,6 +167,15 @@ export function handleAppealDecision(event: AppealDecision): void { dispute.currentRound = roundID; dispute.save(); const roundInfo = contract.getRoundInfo(disputeID, newRoundIndex); + + const disputeStorage = contract.disputes(disputeID); + const courtID = disputeStorage.value0.toString(); + const court = Court.load(courtID); + if (!court) return; + + court.numberVotes = court.numberVotes.plus(roundInfo.nbVotes); + court.save(); + createRoundFromRoundInfo(KlerosCore.bind(event.address), disputeID, newRoundIndex, roundInfo); } diff --git a/subgraph/core/src/entities/Court.ts b/subgraph/core/src/entities/Court.ts index 3d9866f99..03f20e977 100644 --- a/subgraph/core/src/entities/Court.ts +++ b/subgraph/core/src/entities/Court.ts @@ -3,6 +3,35 @@ import { CourtCreated } from "../../generated/KlerosCore/KlerosCore"; import { Court } from "../../generated/schema"; import { ZERO } from "../utils"; +// This function calculates the "effective" stake, which is the specific stake +// of the current court + the specific stake of all of its children courts +export function updateEffectiveStake(courtID: string): void { + let court = Court.load(courtID); + if (!court) return; + + while (court) { + let totalStake = court.stake; + + const childrenCourts = court.children.load(); + + for (let i = 0; i < childrenCourts.length; i++) { + const childCourt = Court.load(childrenCourts[i].id); + if (childCourt) { + totalStake = totalStake.plus(childCourt.effectiveStake); + } + } + + court.effectiveStake = totalStake; + court.save(); + + if (court.parent && court.parent !== null) { + court = Court.load(court.parent as string); + } else { + break; + } + } +} + export function createCourtFromEvent(event: CourtCreated): void { const court = new Court(event.params._courtID.toString()); court.hiddenVotes = event.params._hiddenVotes; @@ -17,8 +46,10 @@ export function createCourtFromEvent(event: CourtCreated): void { court.numberClosedDisputes = ZERO; court.numberVotingDisputes = ZERO; court.numberAppealingDisputes = ZERO; + court.numberVotes = ZERO; court.numberStakedJurors = ZERO; court.stake = ZERO; + court.effectiveStake = ZERO; court.delayedStake = ZERO; court.paidETH = ZERO; court.paidPNK = ZERO; diff --git a/subgraph/core/src/entities/JurorTokensPerCourt.ts b/subgraph/core/src/entities/JurorTokensPerCourt.ts index d2abd910a..42eba9e5f 100644 --- a/subgraph/core/src/entities/JurorTokensPerCourt.ts +++ b/subgraph/core/src/entities/JurorTokensPerCourt.ts @@ -4,6 +4,7 @@ import { updateActiveJurors, getDelta, updateStakedPNK } from "../datapoint"; import { ensureUser } from "./User"; import { ONE, ZERO } from "../utils"; import { SortitionModule } from "../../generated/SortitionModule/SortitionModule"; +import { updateEffectiveStake } from "./Court"; export function ensureJurorTokensPerCourt(jurorAddress: string, courtID: string): JurorTokensPerCourt { const id = `${jurorAddress}-${courtID}`; @@ -59,6 +60,7 @@ export function updateJurorStake( updateActiveJurors(activeJurorsDelta, timestamp); juror.save(); court.save(); + updateEffectiveStake(courtID); } export function updateJurorDelayedStake(jurorAddress: string, courtID: string, amount: BigInt): void { diff --git a/subgraph/package.json b/subgraph/package.json index 0fef6122f..9448c3b0b 100644 --- a/subgraph/package.json +++ b/subgraph/package.json @@ -1,6 +1,6 @@ { "name": "@kleros/kleros-v2-subgraph", - "version": "0.7.2", + "version": "0.7.4", "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/long-arrow-up.svg b/web/src/assets/svgs/icons/long-arrow-up.svg new file mode 100644 index 000000000..e9cb6227d --- /dev/null +++ b/web/src/assets/svgs/icons/long-arrow-up.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/src/components/DisputeView/DisputeCardView.tsx b/web/src/components/DisputeView/DisputeCardView.tsx index deece5ba7..ae692da31 100644 --- a/web/src/components/DisputeView/DisputeCardView.tsx +++ b/web/src/components/DisputeView/DisputeCardView.tsx @@ -28,6 +28,11 @@ const CardContainer = styled.div` flex-direction: column; justify-content: space-between; `; + +const StyledCaseCardTitleSkeleton = styled(StyledSkeleton)` + margin-bottom: 16px; +`; + const TruncatedTitle = ({ text, maxLength }) => { const truncatedText = text.length <= maxLength ? text : text.slice(0, maxLength) + "…"; return

{truncatedText}

; @@ -54,7 +59,7 @@ const DisputeCardView: React.FC = ({ isLoading, ...props }) => navigate(`/cases/${props?.disputeID?.toString()}`)}> - {isLoading ? : } + {isLoading ? : } diff --git a/web/src/components/ExtraStatsDisplay.tsx b/web/src/components/ExtraStatsDisplay.tsx new file mode 100644 index 000000000..165e90fc0 --- /dev/null +++ b/web/src/components/ExtraStatsDisplay.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import styled from "styled-components"; + +import { StyledSkeleton } from "components/StyledSkeleton"; +import { isUndefined } from "utils/index"; + +const Container = styled.div` + display: flex; + gap: 8px; + align-items: center; + margin-top: 24px; +`; + +const SVGContainer = styled.div` + display: flex; + height: 14px; + width: 14px; + align-items: center; + justify-content: center; + svg { + fill: ${({ theme }) => theme.secondaryPurple}; + } +`; + +const TextContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +`; + +const StyledP = styled.p` + font-size: 14px; + font-weight: 600; + margin: 0; +`; + +const StyledExtraStatTitleSkeleton = styled(StyledSkeleton)` + width: 100px; +`; + +export interface IExtraStatsDisplay { + title: string; + icon: React.FunctionComponent>; + content?: React.ReactNode; + text?: string; +} + +const ExtraStatsDisplay: React.FC = ({ title, text, content, icon: Icon, ...props }) => { + return ( + + {} + + + {content ? content : {!isUndefined(text) ? text : }} + + + ); +}; + +export default ExtraStatsDisplay; diff --git a/web/src/consts/averageBlockTimeInSeconds.ts b/web/src/consts/averageBlockTimeInSeconds.ts new file mode 100644 index 000000000..a3750bd8a --- /dev/null +++ b/web/src/consts/averageBlockTimeInSeconds.ts @@ -0,0 +1,3 @@ +import { arbitrum, arbitrumSepolia } from "viem/chains"; + +export const averageBlockTimeInSeconds = { [arbitrum.id]: 0.26, [arbitrumSepolia.id]: 0.268 }; diff --git a/web/src/hooks/queries/useHomePageBlockQuery.ts b/web/src/hooks/queries/useHomePageBlockQuery.ts new file mode 100644 index 000000000..67b3c97ad --- /dev/null +++ b/web/src/hooks/queries/useHomePageBlockQuery.ts @@ -0,0 +1,168 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useGraphqlBatcher } from "context/GraphqlBatcher"; +import { isUndefined } from "utils/index"; + +import { graphql } from "src/graphql"; +import { HomePageBlockQuery } from "src/graphql/graphql"; +export type { HomePageBlockQuery }; + +const homePageBlockQuery = graphql(` + query HomePageBlock($blockNumber: Int) { + presentCourts: courts(orderBy: id, orderDirection: asc) { + id + parent { + id + } + name + numberDisputes + numberVotes + feeForJuror + effectiveStake + } + pastCourts: courts(orderBy: id, orderDirection: asc, block: { number: $blockNumber }) { + id + parent { + id + } + name + numberDisputes + numberVotes + feeForJuror + effectiveStake + } + } +`); + +type Court = HomePageBlockQuery["presentCourts"][number]; +type CourtWithTree = Court & { + numberDisputes: number; + numberVotes: number; + feeForJuror: bigint; + effectiveStake: bigint; + treeNumberDisputes: number; + treeNumberVotes: number; + votesPerPnk: number; + treeVotesPerPnk: number; + expectedRewardPerPnk: number; + treeExpectedRewardPerPnk: number; +}; + +export type HomePageBlockStats = { + mostDisputedCourt: CourtWithTree; + bestDrawingChancesCourt: CourtWithTree; + bestExpectedRewardCourt: CourtWithTree; + courts: CourtWithTree[]; +}; + +export const useHomePageBlockQuery = (blockNumber: number | undefined, allTime: boolean) => { + const isEnabled = !isUndefined(blockNumber) || allTime; + const { graphqlBatcher } = useGraphqlBatcher(); + + return useQuery({ + queryKey: [`homePageBlockQuery${blockNumber}-${allTime}`], + enabled: isEnabled, + staleTime: Infinity, + queryFn: async () => { + const data = await graphqlBatcher.fetch({ + id: crypto.randomUUID(), + document: homePageBlockQuery, + variables: { blockNumber }, + }); + + return processData(data, allTime); + }, + }); +}; + +const processData = (data: HomePageBlockQuery, allTime: boolean) => { + const presentCourts = data.presentCourts; + const pastCourts = data.pastCourts; + const processedCourts: CourtWithTree[] = Array(presentCourts.length); + const processed = new Set(); + + const processCourt = (id: number): CourtWithTree => { + if (processed.has(id)) return processedCourts[id]; + + processed.add(id); + const court = + !allTime && id < data.pastCourts.length + ? addTreeValuesWithDiff(presentCourts[id], pastCourts[id]) + : addTreeValues(presentCourts[id]); + const parentIndex = court.parent ? Number(court.parent.id) - 1 : 0; + + if (id === parentIndex) { + processedCourts[id] = court; + return court; + } + + processedCourts[id] = { + ...court, + treeNumberDisputes: court.treeNumberDisputes + processCourt(parentIndex).treeNumberDisputes, + treeNumberVotes: court.treeNumberVotes + processCourt(parentIndex).treeNumberVotes, + treeVotesPerPnk: court.treeVotesPerPnk + processCourt(parentIndex).treeVotesPerPnk, + treeExpectedRewardPerPnk: court.treeExpectedRewardPerPnk + processCourt(parentIndex).treeExpectedRewardPerPnk, + }; + + return processedCourts[id]; + }; + + for (const court of presentCourts.toReversed()) { + processCourt(Number(court.id) - 1); + } + + processedCourts.reverse(); + + return { + mostDisputedCourt: getCourtMostDisputes(processedCourts), + bestDrawingChancesCourt: getCourtBestDrawingChances(processedCourts), + bestExpectedRewardCourt: getBestExpectedRewardCourt(processedCourts), + courts: processedCourts, + }; +}; + +const addTreeValues = (court: Court): CourtWithTree => { + const votesPerPnk = Number(court.numberVotes) / (Number(court.effectiveStake) / 1e18); + const expectedRewardPerPnk = votesPerPnk * (Number(court.feeForJuror) / 1e18); + return { + ...court, + numberDisputes: Number(court.numberDisputes), + numberVotes: Number(court.numberVotes), + feeForJuror: BigInt(court.feeForJuror) / BigInt(1e18), + effectiveStake: BigInt(court.effectiveStake), + treeNumberDisputes: Number(court.numberDisputes), + treeNumberVotes: Number(court.numberVotes), + votesPerPnk, + treeVotesPerPnk: votesPerPnk, + expectedRewardPerPnk, + treeExpectedRewardPerPnk: expectedRewardPerPnk, + }; +}; + +const addTreeValuesWithDiff = (presentCourt: Court, pastCourt: Court): CourtWithTree => { + const presentCourtWithTree = addTreeValues(presentCourt); + const pastCourtWithTree = addTreeValues(pastCourt); + const diffNumberVotes = presentCourtWithTree.numberVotes - pastCourtWithTree.numberVotes; + const avgEffectiveStake = (presentCourtWithTree.effectiveStake + pastCourtWithTree.effectiveStake) / 2n; + const votesPerPnk = diffNumberVotes / Number(avgEffectiveStake); + const expectedRewardPerPnk = votesPerPnk * Number(presentCourt.feeForJuror); + return { + ...presentCourt, + numberDisputes: presentCourtWithTree.numberDisputes - pastCourtWithTree.numberDisputes, + treeNumberDisputes: presentCourtWithTree.treeNumberDisputes - pastCourtWithTree.treeNumberDisputes, + numberVotes: diffNumberVotes, + treeNumberVotes: presentCourtWithTree.treeNumberVotes - pastCourtWithTree.treeNumberVotes, + effectiveStake: avgEffectiveStake, + votesPerPnk, + treeVotesPerPnk: votesPerPnk, + expectedRewardPerPnk, + treeExpectedRewardPerPnk: expectedRewardPerPnk, + }; +}; + +const getCourtMostDisputes = (courts: CourtWithTree[]) => + courts.toSorted((a: CourtWithTree, b: CourtWithTree) => b.numberDisputes - a.numberDisputes)[0]; +const getCourtBestDrawingChances = (courts: CourtWithTree[]) => + courts.toSorted((a, b) => b.treeVotesPerPnk - a.treeVotesPerPnk)[0]; +const getBestExpectedRewardCourt = (courts: CourtWithTree[]) => + courts.toSorted((a, b) => b.treeExpectedRewardPerPnk - a.treeExpectedRewardPerPnk)[0]; diff --git a/web/src/hooks/queries/useHomePageExtraStats.ts b/web/src/hooks/queries/useHomePageExtraStats.ts new file mode 100644 index 000000000..cd2a0b47a --- /dev/null +++ b/web/src/hooks/queries/useHomePageExtraStats.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +import { UseQueryResult } from "@tanstack/react-query"; +import { useBlockNumber } from "wagmi"; + +import { averageBlockTimeInSeconds } from "consts/averageBlockTimeInSeconds"; +import { DEFAULT_CHAIN } from "consts/chains"; + +import { useHomePageBlockQuery, HomePageBlockStats } from "./useHomePageBlockQuery"; + +type ReturnType = UseQueryResult; + +export const useHomePageExtraStats = (days: number | string): ReturnType => { + const [pastBlockNumber, setPastBlockNumber] = useState(); + const currentBlockNumber = useBlockNumber({ chainId: DEFAULT_CHAIN }); + + useEffect(() => { + if (typeof days !== "string" && currentBlockNumber?.data) { + const timeInBlocks = Math.floor((days * 24 * 3600) / averageBlockTimeInSeconds[DEFAULT_CHAIN]); + setPastBlockNumber(Number(currentBlockNumber.data) - timeInBlocks); + } + }, [currentBlockNumber, days]); + + const data = useHomePageBlockQuery(pastBlockNumber, days === "allTime"); + + return data; +}; diff --git a/web/src/hooks/queries/useHomePageQuery.ts b/web/src/hooks/queries/useHomePageQuery.ts index c9d16b404..3ebfec2dc 100644 --- a/web/src/hooks/queries/useHomePageQuery.ts +++ b/web/src/hooks/queries/useHomePageQuery.ts @@ -19,9 +19,12 @@ const homePageQuery = graphql(` activeJurors cases } - courts { + courts(orderBy: id, orderDirection: asc) { + id name numberDisputes + feeForJuror + stake } } `); diff --git a/web/src/pages/Home/CourtOverview/ExtraStats.tsx b/web/src/pages/Home/CourtOverview/ExtraStats.tsx new file mode 100644 index 000000000..f0fbdd4fb --- /dev/null +++ b/web/src/pages/Home/CourtOverview/ExtraStats.tsx @@ -0,0 +1,87 @@ +import React, { useState } from "react"; +import styled from "styled-components"; + +import { DropdownSelect } from "@kleros/ui-components-library"; + +import LawBalance from "svgs/icons/law-balance.svg"; +import LongArrowUp from "svgs/icons/long-arrow-up.svg"; + +import { useHomePageExtraStats } from "hooks/queries/useHomePageExtraStats"; + +import ExtraStatsDisplay from "components/ExtraStatsDisplay"; + +const StyledCard = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0 32px; + justify-content: center; +`; + +interface IStat { + title: string; + getText: (data) => string; + icon: React.FC>; +} + +const stats: IStat[] = [ + { + title: "Most Cases", + getText: ({ data }) => data?.mostDisputedCourt?.name, + icon: LongArrowUp, + }, + { + title: "Highest drawing chance", + getText: ({ data }) => data?.bestDrawingChancesCourt?.name, + icon: LongArrowUp, + }, + { + title: "Highest rewards chance", + getText: ({ data }) => data?.bestExpectedRewardCourt?.name, + icon: LongArrowUp, + }, +]; + +const timeRanges = [ + { value: 7, text: "Last 7 days" }, + { value: 30, text: "Last 30 days" }, + { value: 90, text: "Last 90 days" }, + // we can uncomment these as the contract deployment time increases + // { value: 180, text: "Last 180 days" }, + // { value: 365, text: "Last 365 days" }, + { value: "allTime", text: "All Time" }, +]; + +const ExtraStats = () => { + const [selectedRange, setSelectedRange] = useState(timeRanges[0].value); + const data = useHomePageExtraStats(selectedRange); + + const handleTimeRangeChange = (value: string | number) => { + setSelectedRange(value); + }; + + return ( + + ({ + value: range.value, + text: range.text, + }))} + defaultValue={selectedRange} + callback={handleTimeRangeChange} + /> + } + icon={LawBalance} + /> + {stats.map(({ title, getText, icon }) => ( + + ))} + + ); +}; + +export default ExtraStats; diff --git a/web/src/pages/Home/CourtOverview/Header.tsx b/web/src/pages/Home/CourtOverview/Header.tsx index d29858002..c06c981ef 100644 --- a/web/src/pages/Home/CourtOverview/Header.tsx +++ b/web/src/pages/Home/CourtOverview/Header.tsx @@ -11,7 +11,9 @@ import { responsiveSize } from "styles/responsiveSize"; const StyledHeader = styled.div` display: flex; + flex-wrap: wrap; justify-content: space-between; + gap: 0 12px; `; const StyledH1 = styled.h1` diff --git a/web/src/pages/Home/CourtOverview/index.tsx b/web/src/pages/Home/CourtOverview/index.tsx index cbb1a5d96..688f7c486 100644 --- a/web/src/pages/Home/CourtOverview/index.tsx +++ b/web/src/pages/Home/CourtOverview/index.tsx @@ -2,6 +2,7 @@ import React from "react"; import styled from "styled-components"; import Chart from "./Chart"; +import ExtraStats from "./ExtraStats"; import Header from "./Header"; import Stats from "./Stats"; @@ -15,6 +16,7 @@ const CourtOverview: React.FC = () => (
+ ); diff --git a/web/tsconfig.json b/web/tsconfig.json index bbfe6fb2d..0f569d03f 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -58,6 +58,9 @@ "allowSyntheticDefaultImports": true, "removeComments": true, "isolatedModules": true, + "lib": [ + "ESNext.Array" + ], "types": [ "vite/client", "vite-plugin-svgr/client"