484 lines
22 KiB
JavaScript
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;
|