ghost-dao-interface/src/containers/Governance/ProposalDetails.jsx
Uncle Fatso a79a877838
fix for the percentage calculations
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
2026-02-18 18:38:49 +03:00

484 lines
22 KiB
JavaScript

import { useEffect, useState, useMemo } from "react";
import ReactGA from "react-ga4";
import { useParams } from 'react-router-dom';
import { useBlock, useBlockNumber } from 'wagmi'
import {
Box,
Container,
Typography,
Link,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
useMediaQuery,
useTheme
} from "@mui/material";
import Paper from "../../components/Paper/Paper";
import PageTitle from "../../components/PageTitle/PageTitle";
import LinearProgressBar from "../../components/Progress/LinearProgressBar";
import InfoTooltip from "../../components/Tooltip/InfoTooltip";
import Chip from "../../components/Chip/Chip";
import { PrimaryButton, SecondaryButton } from "../../components/Button";
import GhostStyledIcon from "../../components/Icon/GhostIcon";
import ArrowUpIcon from "../../assets/icons/arrow-up.svg?react";
import { formatNumber, formatCurrency, shorten } from "../../helpers";
import { prettifySecondsInDays } from "../../helpers/timeUtil";
import { DecimalBigNumber, } from "../../helpers/DecimalBigNumber";
import { convertStatusToTemplate, convertStatusToLabel } from "./helpers";
import { parseFunctionCalldata } from "./components/functions";
import { networkAvgBlockSpeed } from "../../constants";
import { useTokenSymbol, usePastTotalSupply, usePastVotes, useBalance } from "../../hooks/tokens";
import {
useProposalState,
useProposalProposer,
useProposalLocked,
useProposalQuorum,
useProposalVoteOf,
useProposalVotes,
useProposalDetails,
useProposalSnapshot,
useProposalDeadline,
useProposalVotingDelay,
castVote,
executeProposal,
releaseLocked
} from "../../hooks/governance";
import { VOTED_PROPOSALS_PREFIX } from "./helpers";
///////////////////////////////////////////////////////
import Timeline from '@mui/lab/Timeline';
import TimelineItem from '@mui/lab/TimelineItem';
import TimelineSeparator from '@mui/lab/TimelineSeparator';
import TimelineConnector from '@mui/lab/TimelineConnector';
import TimelineContent from '@mui/lab/TimelineContent';
import TimelineOppositeContent from '@mui/lab/TimelineOppositeContent';
import TimelineDot from '@mui/lab/TimelineDot';
///////////////////////////
import FastfoodIcon from '@mui/icons-material/Fastfood';
import LaptopMacIcon from '@mui/icons-material/LaptopMac';
import HotelIcon from '@mui/icons-material/Hotel';
import RepeatIcon from '@mui/icons-material/Repeat';
const HUNDRED = new DecimalBigNumber(100n, 0);
const ProposalDetails = ({ chainId, address, connect, config }) => {
const { id } = useParams();
const proposalId = BigInt(id);
const [isPending, setIsPending] = useState(false);
const [selectedDiscussionUrl, setSelectedDiscussionUrl] = useState(undefined);
const isSemiSmallScreen = useMediaQuery("(max-width: 745px)");
const isSmallScreen = useMediaQuery("(max-width: 650px)");
const isVerySmallScreen = useMediaQuery("(max-width: 379px)");
const theme = useTheme();
const { symbol: ghstSymbol } = useTokenSymbol(chainId, "GHST");
const { balance } = useBalance(chainId, "GHST", address);
const { state: proposalState } = useProposalState(chainId, proposalId);
const { proposer: proposalProposer } = useProposalProposer(chainId, proposalId);
const { locked: proposalLocked } = useProposalLocked(chainId, proposalId);
const { quorum: proposalQuorum } = useProposalQuorum(chainId, proposalId);
const { voteOf } = useProposalVoteOf(chainId, proposalId, address);
const { forVotes, againstVotes, totalVotes } = useProposalVotes(chainId, proposalId);
const { proposalDetails } = useProposalDetails(chainId, proposalId);
const { snapshot: proposalSnapshot } = useProposalSnapshot(chainId, proposalId);
const { pastTotalSupply: totalSupply } = usePastTotalSupply(chainId, "GHST", proposalSnapshot);
const { pastVotes } = usePastVotes(chainId, "GHST", proposalSnapshot, address);
useEffect(() => {
ReactGA.send({ hitType: "pageview", page: `/governance/${id}` });
}, []);
const quorumPercentage = useMemo(() => {
if (totalSupply._value === 0n) return 0;
return proposalQuorum / totalSupply * HUNDRED;
}, [proposalQuorum, totalSupply]);
const votePercentage = useMemo(() => {
if (totalSupply._value === 0n) return 0;
const value = (totalVotes?._value ?? 0n) * 100n / (totalSupply?._value ?? 1n);
return new DecimalBigNumber(value, 0);
}, [totalVotes, totalSupply]);
const forPercentage = useMemo(() => {
if (totalSupply._value === 0n) return new DecimalBigNumber(0n, 2);
const value = (forVotes?._value ?? 0n) * 10000n / (totalSupply?._value ?? 1n);
return new DecimalBigNumber(value, 2);
}, [forVotes, totalSupply]);
const againstPercentage= useMemo(() => {
if (totalSupply._value === 0n) return new DecimalBigNumber(0n, 2);
const value = (againstVotes?._value ?? 0n) * 10000n / (totalSupply?._value ?? 1n);
return new DecimalBigNumber(value, 2);
}, [againstVotes, totalSupply]);
const voteWeightPercentage = useMemo(() => {
if (totalSupply._value === 0n) return new DecimalBigNumber(0n, 2);
const value = (pastVotes?._value ?? 0n) * 10000n / (totalSupply?._value ?? 1n);
return new DecimalBigNumber(value, 2);
}, [pastVotes, totalSupply]);
const voteValue = useMemo(() => {
if (totalVotes?._value == 0n) {
return 0;
}
return Number(forVotes._value * 100n / totalVotes._value);
}, [forVotes, totalVotes]);
const voteTarget = useMemo(() => {
const first = (5n * againstVotes._value + forVotes._value);
const second = BigInt(Math.floor(Math.sqrt(Number(totalVotes._value))));
const bias = 3n * first + second;
const denominator = totalVotes._value + bias;
if (denominator === 0n) {
return 80;
}
return Number(totalVotes?._value * 100n / denominator);
}, [againstVotes, forVotes, totalVotes]);
const nativeCurrency = useMemo(() => {
const client = config?.getClient();
return client?.chain?.nativeCurrency?.symbol;
}, [config]);
const etherscanLink = useMemo(() => {
const client = config.getClient();
let url = client?.chain?.blockExplorers?.default?.url;
if (url) {
url = url + `/address/${proposalProposer}`;
}
return url;
}, [proposalProposer, config]);
const handleVote = async (against) => {
setIsPending(true);
const support = against ? 0 : 1;
const result = await castVote(chainId, address, proposalId, support);
if (result) {
const toStore = JSON.stringify(proposalId.toString());
localStorage.setItem(VOTED_PROPOSALS_PREFIX, toStore);
}
setIsPending(true);
}
const handleExecute = async () => {
setIsPending(true);
await executeProposal(chainId, address, proposalId);
setIsPending(true);
}
const handleRelease = async (proposalId) => {
setIsPending(true);
await releaseLocked(chainId, address, proposalId);
setIsPending(true);
}
return (
<Box>
<PageTitle
name={`GBP-${id.slice(-5)}`}
subtitle={
<Typography component="span">
Proposal details, need more in-depth description
</Typography>
}
/>
<Container
style={{
paddingLeft: isSmallScreen || isVerySmallScreen ? "0" : "3.3rem",
paddingRight: isSmallScreen || isVerySmallScreen ? "0" : "3.3rem",
minHeight: "calc(100vh - 128px)",
display: "flex",
flexDirection: "column",
justifyContent: "center"
}}
>
<Box sx={{ mt: "15px" }}>
<Box display="flex" justifyContent="space-between" gap="20px">
<Paper
fullWidth
enableBackground
headerContent={
<Box display="flex" alignItems="center" flexDirection="row" gap="10px">
<Typography variant="h6">
Progress
</Typography>
<Chip
sx={{ marginTop: "4px", width: "88px" }}
label={convertStatusToLabel(proposalState)}
template={convertStatusToTemplate(proposalState)}
/>
</Box>
}
topRight={
<PrimaryButton sx={{ padding: "0 !important"}} variant="text" href={"https://forum.ghostchain.io"} >
View Forum
</PrimaryButton>
}
>
<Box height="280px" display="flex" flexDirection="column" justifyContent="space-between" gap="20px">
<Box display="flex" flexDirection="column">
<Box display="flex" justifyContent="space-between">
<Typography sx={{ textShadow: "0 0 black", fontWeight: 600 }} variant="body1" color={theme.colors.feedback.success}>
For: {formatNumber(forVotes.toString(), 2)} ({formatNumber(forPercentage?.toString(), 1)}%)
</Typography>
<Typography sx={{ textShadow: "0 0 black", fontWeight: 600 }} variant="body1" color={theme.colors.feedback.error}>
Against: {formatNumber(againstVotes.toString(), 2)} ({formatNumber(againstPercentage?.toString(), 1)}%)
</Typography>
</Box>
<LinearProgressBar
barColor={theme.colors.feedback.success}
barBackground={theme.colors.feedback.error}
variant="determinate"
value={voteValue}
target={voteTarget}
/>
</Box>
<Box display="flex" flexDirection="column">
<Box display="flex" justifyContent="space-between">
<Typography>Proposer</Typography>
<Link href={etherscanLink} target="_blank" rel="noopener noreferrer">
{`${shorten(proposalProposer)}`}
</Link>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography>Locked</Typography>
<Typography>{formatCurrency(proposalLocked, 4, ghstSymbol)}</Typography>
</Box>
<hr width="100%" />
<Box display="flex" justifyContent="space-between">
<Box display="flex" flexDirection="row">
<Typography>Quorum</Typography>
<InfoTooltip message={`Minimum $${ghstSymbol} turnout required for the proposal to become valid, as percentage of the total $${ghstSymbol} supply at the time when proposal was created`} />
</Box>
<Typography>{formatNumber(proposalQuorum.toString(), 4)} ({formatNumber(quorumPercentage, 1)}%)</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Box display="flex" flexDirection="row">
<Typography>Total</Typography>
<InfoTooltip message={`Total votes for the proposal, as percentage of the total $${ghstSymbol} supply at the time when proposal was created`}/>
</Box>
<Typography>{formatNumber(totalSupply.toString(), 4)} ({formatNumber(votePercentage, 1)}%)</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Box display="flex" flexDirection="row">
<Typography>Votes</Typography>
<InfoTooltip message={`Your voting power, as percentage of total $${ghstSymbol} at the time when proposal was created`} />
</Box>
<Typography>{formatNumber(pastVotes.toString(), 4)} ({formatNumber(voteWeightPercentage, 1)}%)</Typography>
</Box>
</Box>
<Box display="flex" gap="20px">
{address === undefined || address === ""
? <PrimaryButton fullWidth onClick={() => connect()}>Connect</PrimaryButton>
: voteOf === 0n
? <>
<SecondaryButton
fullWidth
disabled={proposalState !== 1 || pastVotes?._value === 0n || isPending}
onClick={() => handleVote(1)}
>
For
</SecondaryButton>
<SecondaryButton
fullWidth
disabled={proposalState !== 1 || pastVotes?._value === 0n || isPending}
onClick={() => handleVote(0)}
>
Against
</SecondaryButton>
</>
: <SecondaryButton
fullWidth
disabled
>
{`Voted ${voteOf === 1n ? "Against" : "For"}`}
</SecondaryButton>
}
</Box>
</Box>
</Paper>
<Paper
fullWidth
enableBackground
headerContent={
<Box height="48px" display="flex" alignItems="center" flexDirection="row" gap="5px">
<Typography variant="h6">
Timeline
</Typography>
</Box>
}
>
<VotingTimeline
proposalLocked={proposalLocked}
connect={connect}
handleExecute={handleExecute}
handleRelease={handleRelease}
state={proposalState}
address={address}
isProposer={proposalProposer === address}
chainId={chainId}
proposalId={id}
/>
</Paper>
</Box>
</Box>
<Paper
fullWidth
enableBackground
headerContent={
<Box display="flex" alignItems="center" flexDirection="row" gap="5px">
<Typography variant="h6">
Executable Code
</Typography>
</Box>
}
>
<TableContainer>
<Table aria-label="Available bonds" style={{ tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<TableCell align="center" style={{ padding: "8px 0" }}>Function</TableCell>
<TableCell align="center" style={{ padding: "8px 0" }}>Target</TableCell>
<TableCell align="center" style={{ padding: "8px 0" }}>Calldata</TableCell>
<TableCell align="center" style={{ padding: "8px 0" }}>Value</TableCell>
</TableRow>
</TableHead>
<TableBody>{proposalDetails?.map((metadata, index) => {
return parseFunctionCalldata(metadata, index, chainId, nativeCurrency);
})}</TableBody>
</Table>
</TableContainer>
</Paper>
</Container>
</Box>
)
}
const VotingTimeline = ({ connect, handleExecute, handleRelease, proposalLocked, proposalId, chainId, state, address, isProposer }) => {
const { delay: propsalVotingDelay } = useProposalVotingDelay(chainId, proposalId);
const { snapshot: proposalSnapshot } = useProposalSnapshot(chainId, proposalId);
const { deadline: proposalDeadline } = useProposalDeadline(chainId, proposalId);
const voteStarted = useMemo(() => {
if (proposalSnapshot && propsalVotingDelay) {
return proposalSnapshot > propsalVotingDelay ? proposalSnapshot - propsalVotingDelay : 0;
}
return 0n;
}, [proposalSnapshot, propsalVotingDelay]);
return (
<Box height="280px" display="flex" flexDirection="column" justifyContent="space-between" gap="20px">
<Timeline sx={{ margin: 0, padding: 0 }}>
<VotingTimelineItem chainId={chainId} occured={voteStarted} message="Proposed on" isFirst />
<VotingTimelineItem chainId={chainId} occured={proposalSnapshot} message="Voting started" />
<VotingTimelineItem chainId={chainId} occured={proposalDeadline} message="Voting ends" />
</Timeline>
<Box width="100%" display="flex" gap="10px">
{isProposer && <SecondaryButton
fullWidth
disabled={(proposalLocked?._value ?? 0n) === 0n || state < 2}
onClick={() => address === "" ? connect() : handleRelease()}
>
{address === "" ? "Connect" : "Release"}
</SecondaryButton>}
<SecondaryButton
fullWidth
disabled={state !== 4}
onClick={() => address === "" ? connect() : handleExecute()}
>
{address === "" ? "Connect" : "Execute"}
</SecondaryButton>
</Box>
</Box>
)
}
const VotingTimelineItem = ({ position, isFirst, isLast, chainId, occured, message }) => {
const { data: blockNumber } = useBlockNumber({ chainId, watch: true });
const { data: blockInfo } = useBlock({
chainId: chainId,
blockNumber: occured,
query: {
enabled: Boolean(occured)
}
});
const timestamp = useMemo(() => {
if (blockInfo && blockNumber > occured) {
const timestamp = Number(blockInfo?.timestamp ?? 0n) * 1000;
return new Date(timestamp).toLocaleString();
}
const blocksRemaining = Number(occured) - Number(blockNumber);
const secondsRemaining = blocksRemaining * Number(networkAvgBlockSpeed(chainId));
const predictedTimestamp = Math.floor(Date.now() / 1000) + secondsRemaining;
return new Date(predictedTimestamp * 1000).toLocaleString();
}, [chainId, occured, blockInfo, blockNumber]);
return (
<TimelineItem>
<TimelineOppositeContent
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "0 16px",
minWidth: "140px",
flex: 0,
}}
>
<Typography>{message}</Typography>
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineConnector sx={{ background: isFirst ? "transparent" : "#fff" }} />
<TimelineDot sx={{ width: "15px", height: "15px", background: "#fff", margin: "12px 0" }} />
<TimelineConnector sx={{ background: isLast ? "transparent" : "#fff" }} />
</TimelineSeparator>
<TimelineContent
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
padding: "0 26px",
}}
>
<Typography>{timestamp}</Typography>
</TimelineContent>
</TimelineItem>
)
}
export default ProposalDetails;