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"