Skip to content

Commit ad34b56

Browse files
authored
Merge pull request #1671 from kleros/feat(web)/Extra-statistics-on-the-Home-page
feat(web): extra statistic on homepage
2 parents 22e1485 + 94c4074 commit ad34b56

16 files changed

+423
-4
lines changed

subgraph/core/schema.graphql

+2
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,11 @@ type Court @entity {
142142
numberClosedDisputes: BigInt!
143143
numberVotingDisputes: BigInt!
144144
numberAppealingDisputes: BigInt!
145+
numberVotes: BigInt!
145146
stakedJurors: [JurorTokensPerCourt!]! @derivedFrom(field: "court")
146147
numberStakedJurors: BigInt!
147148
stake: BigInt!
149+
effectiveStake: BigInt!
148150
delayedStake: BigInt!
149151
paidETH: BigInt!
150152
paidPNK: BigInt!

subgraph/core/src/KlerosCore.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ export function handleDisputeCreation(event: DisputeCreation): void {
7575
const court = Court.load(courtID);
7676
if (!court) return;
7777
court.numberDisputes = court.numberDisputes.plus(ONE);
78+
79+
const roundInfo = contract.getRoundInfo(disputeID, ZERO);
80+
court.numberVotes = court.numberVotes.plus(roundInfo.nbVotes);
81+
7882
court.save();
7983
createDisputeFromEvent(event);
80-
const roundInfo = contract.getRoundInfo(disputeID, ZERO);
8184
createRoundFromRoundInfo(KlerosCore.bind(event.address), disputeID, ZERO, roundInfo);
8285
const arbitrable = event.params._arbitrable.toHexString();
8386
updateArbitrableCases(arbitrable, ONE);
@@ -164,6 +167,15 @@ export function handleAppealDecision(event: AppealDecision): void {
164167
dispute.currentRound = roundID;
165168
dispute.save();
166169
const roundInfo = contract.getRoundInfo(disputeID, newRoundIndex);
170+
171+
const disputeStorage = contract.disputes(disputeID);
172+
const courtID = disputeStorage.value0.toString();
173+
const court = Court.load(courtID);
174+
if (!court) return;
175+
176+
court.numberVotes = court.numberVotes.plus(roundInfo.nbVotes);
177+
court.save();
178+
167179
createRoundFromRoundInfo(KlerosCore.bind(event.address), disputeID, newRoundIndex, roundInfo);
168180
}
169181

subgraph/core/src/entities/Court.ts

+31
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,35 @@ import { CourtCreated } from "../../generated/KlerosCore/KlerosCore";
33
import { Court } from "../../generated/schema";
44
import { ZERO } from "../utils";
55

6+
// This function calculates the "effective" stake, which is the specific stake
7+
// of the current court + the specific stake of all of its children courts
8+
export function updateEffectiveStake(courtID: string): void {
9+
let court = Court.load(courtID);
10+
if (!court) return;
11+
12+
while (court) {
13+
let totalStake = court.stake;
14+
15+
const childrenCourts = court.children.load();
16+
17+
for (let i = 0; i < childrenCourts.length; i++) {
18+
const childCourt = Court.load(childrenCourts[i].id);
19+
if (childCourt) {
20+
totalStake = totalStake.plus(childCourt.effectiveStake);
21+
}
22+
}
23+
24+
court.effectiveStake = totalStake;
25+
court.save();
26+
27+
if (court.parent && court.parent !== null) {
28+
court = Court.load(court.parent as string);
29+
} else {
30+
break;
31+
}
32+
}
33+
}
34+
635
export function createCourtFromEvent(event: CourtCreated): void {
736
const court = new Court(event.params._courtID.toString());
837
court.hiddenVotes = event.params._hiddenVotes;
@@ -17,8 +46,10 @@ export function createCourtFromEvent(event: CourtCreated): void {
1746
court.numberClosedDisputes = ZERO;
1847
court.numberVotingDisputes = ZERO;
1948
court.numberAppealingDisputes = ZERO;
49+
court.numberVotes = ZERO;
2050
court.numberStakedJurors = ZERO;
2151
court.stake = ZERO;
52+
court.effectiveStake = ZERO;
2253
court.delayedStake = ZERO;
2354
court.paidETH = ZERO;
2455
court.paidPNK = ZERO;

subgraph/core/src/entities/JurorTokensPerCourt.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { updateActiveJurors, getDelta, updateStakedPNK } from "../datapoint";
44
import { ensureUser } from "./User";
55
import { ONE, ZERO } from "../utils";
66
import { SortitionModule } from "../../generated/SortitionModule/SortitionModule";
7+
import { updateEffectiveStake } from "./Court";
78

89
export function ensureJurorTokensPerCourt(jurorAddress: string, courtID: string): JurorTokensPerCourt {
910
const id = `${jurorAddress}-${courtID}`;
@@ -59,6 +60,7 @@ export function updateJurorStake(
5960
updateActiveJurors(activeJurorsDelta, timestamp);
6061
juror.save();
6162
court.save();
63+
updateEffectiveStake(courtID);
6264
}
6365

6466
export function updateJurorDelayedStake(jurorAddress: string, courtID: string, amount: BigInt): void {

subgraph/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@kleros/kleros-v2-subgraph",
3-
"version": "0.7.2",
3+
"version": "0.7.4",
44
"license": "MIT",
55
"scripts": {
66
"update:core:arbitrum-sepolia-devnet": "./scripts/update.sh arbitrumSepoliaDevnet arbitrum-sepolia core/subgraph.yaml",
Loading

web/src/components/DisputeView/DisputeCardView.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ const CardContainer = styled.div`
2828
flex-direction: column;
2929
justify-content: space-between;
3030
`;
31+
32+
const StyledCaseCardTitleSkeleton = styled(StyledSkeleton)`
33+
margin-bottom: 16px;
34+
`;
35+
3136
const TruncatedTitle = ({ text, maxLength }) => {
3237
const truncatedText = text.length <= maxLength ? text : text.slice(0, maxLength) + "…";
3338
return <h3>{truncatedText}</h3>;
@@ -54,7 +59,7 @@ const DisputeCardView: React.FC<IDisputeCardView> = ({ isLoading, ...props }) =>
5459
<StyledCard hover onClick={() => navigate(`/cases/${props?.disputeID?.toString()}`)}>
5560
<PeriodBanner id={parseInt(props?.disputeID)} period={props?.period} />
5661
<CardContainer>
57-
{isLoading ? <StyledSkeleton /> : <TruncatedTitle text={props?.title} maxLength={100} />}
62+
{isLoading ? <StyledCaseCardTitleSkeleton /> : <TruncatedTitle text={props?.title} maxLength={100} />}
5863
<DisputeInfo {...props} />
5964
</CardContainer>
6065
</StyledCard>
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from "react";
2+
import styled from "styled-components";
3+
4+
import { StyledSkeleton } from "components/StyledSkeleton";
5+
import { isUndefined } from "utils/index";
6+
7+
const Container = styled.div`
8+
display: flex;
9+
gap: 8px;
10+
align-items: center;
11+
margin-top: 24px;
12+
`;
13+
14+
const SVGContainer = styled.div`
15+
display: flex;
16+
height: 14px;
17+
width: 14px;
18+
align-items: center;
19+
justify-content: center;
20+
svg {
21+
fill: ${({ theme }) => theme.secondaryPurple};
22+
}
23+
`;
24+
25+
const TextContainer = styled.div`
26+
display: flex;
27+
align-items: center;
28+
gap: 8px;
29+
flex-wrap: wrap;
30+
justify-content: center;
31+
`;
32+
33+
const StyledP = styled.p`
34+
font-size: 14px;
35+
font-weight: 600;
36+
margin: 0;
37+
`;
38+
39+
const StyledExtraStatTitleSkeleton = styled(StyledSkeleton)`
40+
width: 100px;
41+
`;
42+
43+
export interface IExtraStatsDisplay {
44+
title: string;
45+
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
46+
content?: React.ReactNode;
47+
text?: string;
48+
}
49+
50+
const ExtraStatsDisplay: React.FC<IExtraStatsDisplay> = ({ title, text, content, icon: Icon, ...props }) => {
51+
return (
52+
<Container {...props}>
53+
<SVGContainer>{<Icon />}</SVGContainer>
54+
<TextContainer>
55+
<label>{title}:</label>
56+
{content ? content : <StyledP>{!isUndefined(text) ? text : <StyledExtraStatTitleSkeleton />}</StyledP>}
57+
</TextContainer>
58+
</Container>
59+
);
60+
};
61+
62+
export default ExtraStatsDisplay;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { arbitrum, arbitrumSepolia } from "viem/chains";
2+
3+
export const averageBlockTimeInSeconds = { [arbitrum.id]: 0.26, [arbitrumSepolia.id]: 0.268 };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
3+
import { useGraphqlBatcher } from "context/GraphqlBatcher";
4+
import { isUndefined } from "utils/index";
5+
6+
import { graphql } from "src/graphql";
7+
import { HomePageBlockQuery } from "src/graphql/graphql";
8+
export type { HomePageBlockQuery };
9+
10+
const homePageBlockQuery = graphql(`
11+
query HomePageBlock($blockNumber: Int) {
12+
presentCourts: courts(orderBy: id, orderDirection: asc) {
13+
id
14+
parent {
15+
id
16+
}
17+
name
18+
numberDisputes
19+
numberVotes
20+
feeForJuror
21+
effectiveStake
22+
}
23+
pastCourts: courts(orderBy: id, orderDirection: asc, block: { number: $blockNumber }) {
24+
id
25+
parent {
26+
id
27+
}
28+
name
29+
numberDisputes
30+
numberVotes
31+
feeForJuror
32+
effectiveStake
33+
}
34+
}
35+
`);
36+
37+
type Court = HomePageBlockQuery["presentCourts"][number];
38+
type CourtWithTree = Court & {
39+
numberDisputes: number;
40+
numberVotes: number;
41+
feeForJuror: bigint;
42+
effectiveStake: bigint;
43+
treeNumberDisputes: number;
44+
treeNumberVotes: number;
45+
votesPerPnk: number;
46+
treeVotesPerPnk: number;
47+
expectedRewardPerPnk: number;
48+
treeExpectedRewardPerPnk: number;
49+
};
50+
51+
export type HomePageBlockStats = {
52+
mostDisputedCourt: CourtWithTree;
53+
bestDrawingChancesCourt: CourtWithTree;
54+
bestExpectedRewardCourt: CourtWithTree;
55+
courts: CourtWithTree[];
56+
};
57+
58+
export const useHomePageBlockQuery = (blockNumber: number | undefined, allTime: boolean) => {
59+
const isEnabled = !isUndefined(blockNumber) || allTime;
60+
const { graphqlBatcher } = useGraphqlBatcher();
61+
62+
return useQuery<HomePageBlockStats>({
63+
queryKey: [`homePageBlockQuery${blockNumber}-${allTime}`],
64+
enabled: isEnabled,
65+
staleTime: Infinity,
66+
queryFn: async () => {
67+
const data = await graphqlBatcher.fetch({
68+
id: crypto.randomUUID(),
69+
document: homePageBlockQuery,
70+
variables: { blockNumber },
71+
});
72+
73+
return processData(data, allTime);
74+
},
75+
});
76+
};
77+
78+
const processData = (data: HomePageBlockQuery, allTime: boolean) => {
79+
const presentCourts = data.presentCourts;
80+
const pastCourts = data.pastCourts;
81+
const processedCourts: CourtWithTree[] = Array(presentCourts.length);
82+
const processed = new Set();
83+
84+
const processCourt = (id: number): CourtWithTree => {
85+
if (processed.has(id)) return processedCourts[id];
86+
87+
processed.add(id);
88+
const court =
89+
!allTime && id < data.pastCourts.length
90+
? addTreeValuesWithDiff(presentCourts[id], pastCourts[id])
91+
: addTreeValues(presentCourts[id]);
92+
const parentIndex = court.parent ? Number(court.parent.id) - 1 : 0;
93+
94+
if (id === parentIndex) {
95+
processedCourts[id] = court;
96+
return court;
97+
}
98+
99+
processedCourts[id] = {
100+
...court,
101+
treeNumberDisputes: court.treeNumberDisputes + processCourt(parentIndex).treeNumberDisputes,
102+
treeNumberVotes: court.treeNumberVotes + processCourt(parentIndex).treeNumberVotes,
103+
treeVotesPerPnk: court.treeVotesPerPnk + processCourt(parentIndex).treeVotesPerPnk,
104+
treeExpectedRewardPerPnk: court.treeExpectedRewardPerPnk + processCourt(parentIndex).treeExpectedRewardPerPnk,
105+
};
106+
107+
return processedCourts[id];
108+
};
109+
110+
for (const court of presentCourts.toReversed()) {
111+
processCourt(Number(court.id) - 1);
112+
}
113+
114+
processedCourts.reverse();
115+
116+
return {
117+
mostDisputedCourt: getCourtMostDisputes(processedCourts),
118+
bestDrawingChancesCourt: getCourtBestDrawingChances(processedCourts),
119+
bestExpectedRewardCourt: getBestExpectedRewardCourt(processedCourts),
120+
courts: processedCourts,
121+
};
122+
};
123+
124+
const addTreeValues = (court: Court): CourtWithTree => {
125+
const votesPerPnk = Number(court.numberVotes) / (Number(court.effectiveStake) / 1e18);
126+
const expectedRewardPerPnk = votesPerPnk * (Number(court.feeForJuror) / 1e18);
127+
return {
128+
...court,
129+
numberDisputes: Number(court.numberDisputes),
130+
numberVotes: Number(court.numberVotes),
131+
feeForJuror: BigInt(court.feeForJuror) / BigInt(1e18),
132+
effectiveStake: BigInt(court.effectiveStake),
133+
treeNumberDisputes: Number(court.numberDisputes),
134+
treeNumberVotes: Number(court.numberVotes),
135+
votesPerPnk,
136+
treeVotesPerPnk: votesPerPnk,
137+
expectedRewardPerPnk,
138+
treeExpectedRewardPerPnk: expectedRewardPerPnk,
139+
};
140+
};
141+
142+
const addTreeValuesWithDiff = (presentCourt: Court, pastCourt: Court): CourtWithTree => {
143+
const presentCourtWithTree = addTreeValues(presentCourt);
144+
const pastCourtWithTree = addTreeValues(pastCourt);
145+
const diffNumberVotes = presentCourtWithTree.numberVotes - pastCourtWithTree.numberVotes;
146+
const avgEffectiveStake = (presentCourtWithTree.effectiveStake + pastCourtWithTree.effectiveStake) / 2n;
147+
const votesPerPnk = diffNumberVotes / Number(avgEffectiveStake);
148+
const expectedRewardPerPnk = votesPerPnk * Number(presentCourt.feeForJuror);
149+
return {
150+
...presentCourt,
151+
numberDisputes: presentCourtWithTree.numberDisputes - pastCourtWithTree.numberDisputes,
152+
treeNumberDisputes: presentCourtWithTree.treeNumberDisputes - pastCourtWithTree.treeNumberDisputes,
153+
numberVotes: diffNumberVotes,
154+
treeNumberVotes: presentCourtWithTree.treeNumberVotes - pastCourtWithTree.treeNumberVotes,
155+
effectiveStake: avgEffectiveStake,
156+
votesPerPnk,
157+
treeVotesPerPnk: votesPerPnk,
158+
expectedRewardPerPnk,
159+
treeExpectedRewardPerPnk: expectedRewardPerPnk,
160+
};
161+
};
162+
163+
const getCourtMostDisputes = (courts: CourtWithTree[]) =>
164+
courts.toSorted((a: CourtWithTree, b: CourtWithTree) => b.numberDisputes - a.numberDisputes)[0];
165+
const getCourtBestDrawingChances = (courts: CourtWithTree[]) =>
166+
courts.toSorted((a, b) => b.treeVotesPerPnk - a.treeVotesPerPnk)[0];
167+
const getBestExpectedRewardCourt = (courts: CourtWithTree[]) =>
168+
courts.toSorted((a, b) => b.treeExpectedRewardPerPnk - a.treeExpectedRewardPerPnk)[0];

0 commit comments

Comments
 (0)