524 lines
24 KiB
JavaScript
524 lines
24 KiB
JavaScript
import { useEffect, useState, useMemo, useCallback } from "react";
|
|
import ReactGA from "react-ga4";
|
|
import { useParams, useLocation } from 'react-router-dom';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
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 { convertStatusToColor, convertStatusToLabel } from "./helpers";
|
|
import { parseFunctionCalldata } from "./components/functions";
|
|
|
|
import { networkAvgBlockSpeed } from "../../constants";
|
|
|
|
import { useLocalStorage } from "../../hooks/localstorage";
|
|
import { useTokenSymbol, usePastTotalSupply, usePastVotes, useBalance } from "../../hooks/tokens";
|
|
import {
|
|
getVoteValue,
|
|
getVoteTarget,
|
|
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 location = useLocation();
|
|
const { id } = useParams();
|
|
const proposalId = BigInt(id);
|
|
|
|
const [isPendingVote, setIsPendingVote] = useState(-1);
|
|
const [isPendingExecute, setIsPendingExecute] = useState(false);
|
|
const [isPendingRelease, setIsPendingRelease] = 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 queryClient = useQueryClient();
|
|
const { getStorageValue, setStorageValue } = useLocalStorage();
|
|
|
|
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: `${chainId}/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;
|
|
return totalVotes * HUNDRED / totalSupply;
|
|
}, [totalVotes, totalSupply]);
|
|
|
|
const forPercentage = useMemo(() => {
|
|
if (totalSupply._value === 0n) return new DecimalBigNumber(0n, 2);
|
|
return forVotes * HUNDRED / totalSupply;
|
|
}, [forVotes, totalSupply]);
|
|
|
|
const againstPercentage= useMemo(() => {
|
|
if (totalSupply._value === 0n) return new DecimalBigNumber(0n, 2);
|
|
return againstVotes * HUNDRED / totalSupply;
|
|
}, [againstVotes, totalSupply]);
|
|
|
|
const voteWeightPercentage = useMemo(() => {
|
|
if (totalSupply._value === 0n) return new DecimalBigNumber(0n, 2);
|
|
return pastVotes * HUNDRED / totalSupply;
|
|
}, [pastVotes, totalSupply]);
|
|
|
|
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 isPending = useMemo(() => {
|
|
return isPendingExecute || isPendingRelease || isPendingVote > -1;
|
|
}, [isPendingExecute, isPendingRelease, isPendingVote]);
|
|
|
|
const handleVote = useCallback(async (support) => {
|
|
setIsPendingVote(support);
|
|
const result = await castVote(chainId, address, proposalId, support);
|
|
|
|
if (result) {
|
|
const storedVotedProposals = getStorageValue(chainId, address, VOTED_PROPOSALS_PREFIX, []);
|
|
const proposals = JSON.parse(storedVotedProposals || "[]").map(id => BigInt(id));
|
|
proposals.push(proposalId);
|
|
setStorageValue(chainId, address, VOTED_PROPOSALS_PREFIX, proposals.map(id => id.toString()));
|
|
await queryClient.invalidateQueries();
|
|
}
|
|
setIsPendingVote(-1);
|
|
}, [chainId, address, proposalId]);
|
|
|
|
const handleExecute = async () => {
|
|
setIsPendingExecute(true);
|
|
await executeProposal(chainId, address, proposalId);
|
|
await queryClient.invalidateQueries();
|
|
setIsPendingExecute(false);
|
|
}
|
|
|
|
const handleRelease = async () => {
|
|
setIsPendingRelease(true);
|
|
await releaseLocked(chainId, address, proposalId);
|
|
await queryClient.invalidateQueries();
|
|
setIsPendingRelease(false);
|
|
}
|
|
|
|
return (
|
|
<Box>
|
|
<PageTitle
|
|
name={`GDP-${id.slice(-5)}`}
|
|
subtitle={
|
|
<Typography component="span">
|
|
{`Cast ${ghstSymbol} to shape this proposal's outcome`}
|
|
</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
|
|
gap="20px"
|
|
display="flex"
|
|
justifyContent={"space-between"}
|
|
flexDirection={isSmallScreen ? "column" : "row"}
|
|
>
|
|
<Paper
|
|
fullWidth
|
|
enableBackground
|
|
headerContent={
|
|
<Box display="flex" alignItems="center" flexDirection="row" gap="10px">
|
|
{!isSmallScreen &&<Typography variant="h6">
|
|
Progress
|
|
</Typography>}
|
|
<Chip
|
|
sx={{ marginTop: "4px", width: "88px" }}
|
|
label={convertStatusToLabel(proposalState)}
|
|
background={convertStatusToColor(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(), 2)}%)
|
|
</Typography>
|
|
<Typography sx={{ textShadow: "0 0 black", fontWeight: 600 }} variant="body1" color={theme.colors.feedback.error}>
|
|
Against: {formatNumber(againstVotes.toString(), 2)} ({formatNumber(againstPercentage?.toString(), 2)}%)
|
|
</Typography>
|
|
</Box>
|
|
<LinearProgressBar
|
|
barColor={theme.colors.feedback.success}
|
|
barBackground={theme.colors.feedback.error}
|
|
variant="determinate"
|
|
value={getVoteValue(forVotes?._value ?? 0n, totalVotes?._value ?? 0n)}
|
|
target={getVoteTarget(totalVotes?._value ?? 0n, totalSupply?._value ?? 0n)}
|
|
/>
|
|
</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, 5, 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(), 5)} ({formatNumber(quorumPercentage, 2)}%)</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(), 5)} ({formatNumber(votePercentage, 2)}%)</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(), 5)} ({formatNumber(voteWeightPercentage, 2)}%)</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}
|
|
loading={isPendingVote === 1}
|
|
onClick={() => handleVote(1)}
|
|
>
|
|
{isPendingVote === 1 ? "Voting For..." : "For"}
|
|
</SecondaryButton>
|
|
<SecondaryButton
|
|
fullWidth
|
|
disabled={proposalState !== 1 || pastVotes?._value === 0n || isPending}
|
|
loading={isPendingVote === 0}
|
|
onClick={() => handleVote(0)}
|
|
>
|
|
{isPendingVote === 0 ? "Voting Against..." : "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}
|
|
isPending={isPending}
|
|
isPendingExecute={isPendingExecute}
|
|
isPendingRelease={isPendingRelease}
|
|
/>
|
|
</Paper>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Paper
|
|
fullWidth
|
|
enableBackground
|
|
headerContent={
|
|
<Box display="flex" alignItems="center" flexDirection="row" gap="5px">
|
|
<Typography variant="h6">
|
|
Executable Code
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
>
|
|
{isSmallScreen
|
|
? <Box display="flex" flexDirection="column">
|
|
{proposalDetails?.map((metadata, index) => {
|
|
return parseFunctionCalldata({
|
|
metadata,
|
|
index,
|
|
chainId,
|
|
nativeCoin: nativeCurrency,
|
|
isTable: false,
|
|
});
|
|
})}
|
|
</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,
|
|
nativeCoin: nativeCurrency,
|
|
});
|
|
})}</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
}
|
|
</Paper>
|
|
</Container>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const VotingTimeline = ({
|
|
connect,
|
|
handleExecute,
|
|
handleRelease,
|
|
proposalLocked,
|
|
proposalId,
|
|
chainId,
|
|
state,
|
|
address,
|
|
isProposer,
|
|
isPending,
|
|
isPendingExecute,
|
|
isPendingRelease,
|
|
}) => {
|
|
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 && (proposalLocked?._value ?? 0n) > 0n) && <SecondaryButton
|
|
fullWidth
|
|
loading={isPendingRelease}
|
|
disabled={isPending || state < 2}
|
|
onClick={() => address === "" ? connect() : handleRelease()}
|
|
>
|
|
{address === "" ? "Connect" : isPendingRelease ? "Releasing..." : "Release"}
|
|
</SecondaryButton>}
|
|
<SecondaryButton
|
|
fullWidth
|
|
loading={isPending}
|
|
disabled={isPending || state !== 4}
|
|
onClick={() => address === "" ? connect() : handleExecute()}
|
|
>
|
|
{address === ""
|
|
? "Connect"
|
|
: state !== 4
|
|
? convertStatusToLabel(state)
|
|
: isPendingExecute ? "Executing..." : "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;
|