diff --git a/@types/custom.d.ts b/@types/custom.d.ts index 4261298f..9ce7a96d 100644 --- a/@types/custom.d.ts +++ b/@types/custom.d.ts @@ -5,4 +5,4 @@ declare module "*.svg" { declare module "*.png"; declare module "*.jpg"; declare module "*.jpeg"; -declare module "*.gif"; +declare module "*.gif"; \ No newline at end of file diff --git a/apollo/treasuryProposals.ts b/apollo/treasuryProposals.ts new file mode 100644 index 00000000..bda52e56 --- /dev/null +++ b/apollo/treasuryProposals.ts @@ -0,0 +1,27 @@ +import { gql } from "@apollo/client"; + +export const ENS_QUERY = gql` + query getENS($address: String!) { + domains(where: { resolvedAddress: $address } + orderBy: registration__registrationDate + orderDirection: desc + ) { + name + resolvedAddress { + id + } + createdAt + } +} +`; + +export const GET_PROPOSALS_BY_IDS = gql` + query getProposalsByIds($ids: [String!]!) { + treasuryProposals(where: { id_in: $ids }) { + id + description + voteStart + voteEnd + } +} +`; \ No newline at end of file diff --git a/components/TreasuryVotingWidget/index.tsx b/components/TreasuryVotingWidget/index.tsx index fc8f7789..b90bb4b6 100644 --- a/components/TreasuryVotingWidget/index.tsx +++ b/components/TreasuryVotingWidget/index.tsx @@ -5,7 +5,7 @@ import { useAccountAddress } from "hooks"; import numeral from "numeral"; import { useMemo } from "react"; import { abbreviateNumber, fromWei, shortenAddress} from "@lib/utils"; -import VoteButton from "../VoteButton"; +import VoteButton from "../Votes/VoteButton"; import { ProposalVotingPower } from "@lib/api/types/get-treasury-proposal"; import { ProposalExtended } from "@lib/api/treasury"; import QueueExecuteButton from "@components/QueueExecuteButton"; diff --git a/components/VoteButton/index.tsx b/components/Votes/VoteButton/index.tsx similarity index 100% rename from components/VoteButton/index.tsx rename to components/Votes/VoteButton/index.tsx diff --git a/components/Votes/VoteDetail/index.tsx b/components/Votes/VoteDetail/index.tsx new file mode 100644 index 00000000..f50cb8da --- /dev/null +++ b/components/Votes/VoteDetail/index.tsx @@ -0,0 +1,106 @@ +"use client"; + +import React from "react"; +import { Box, Heading, Text, Link, Badge } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { VOTING_SUPPORT, Vote } from "@lib/api/types/votes"; +import { formatAddress } from "utils/formatAddress"; +import { formatLpt } from "@lib/utils"; + +interface VoteDetailItemProps { + vote: Vote; +} + +const Index: React.FC = ({ vote }) => { + const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + return ( + + + + {vote.proposalTitle} + + + + + + Proposal ID: + {" "} + + {formatAddress(vote.proposalId)} + + + + + + Support: + + + {support.text} + + + + + + Weight: + {" "} + + {formatLpt(vote.weight)} + + + + + + Reason: + {" "} + + {vote.reason || "No reason provided"} + + + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatAddress(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + ); +}; + +export default Index; diff --git a/components/Votes/VoteModal/index.tsx b/components/Votes/VoteModal/index.tsx new file mode 100644 index 00000000..b6214c67 --- /dev/null +++ b/components/Votes/VoteModal/index.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { Box, Button } from "@livepeer/design-system"; + +interface VoteModalProps { + onClose: () => void; + children: React.ReactNode; +} + +const Index: React.FC = ({ onClose, children }) => + createPortal( + e.stopPropagation()} + > + e.stopPropagation()} + > + + + + + + {children} + + + , + document.body + ); + +export default Index; diff --git a/components/Votes/VotePopover/index.tsx b/components/Votes/VotePopover/index.tsx new file mode 100644 index 00000000..5dfc0c3a --- /dev/null +++ b/components/Votes/VotePopover/index.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; +import Spinner from '@components/Spinner'; +import { Flex, Text } from '@livepeer/design-system'; +import VoteModal from '../VoteModal'; +import VoteDetail from '../VoteDetail'; +import { useInfuraVoterVotes } from '../../../hooks/TreasuryVotes/useInfuraVoterVotes'; + +interface VoterPopoverProps { + voter: string; + onClose: () => void; +} + +const Index: React.FC = ({ voter, onClose }) => { + const { votes, isLoading } = useInfuraVoterVotes(voter); + + return ( + + {isLoading ? ( + + + + ) : votes.length > 0 ? ( + votes.map((vote, idx) => ( + + )) + ) : ( + + No votes found for this voter. + + )} + + ); +}; + +export default Index; diff --git a/components/Votes/VoteTable/Views/DesktopVoteTable.tsx b/components/Votes/VoteTable/Views/DesktopVoteTable.tsx new file mode 100644 index 00000000..20d099e3 --- /dev/null +++ b/components/Votes/VoteTable/Views/DesktopVoteTable.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { Box, Flex, Text } from "@livepeer/design-system"; +import { Vote } from "../../../../lib/api/types/votes"; +import { VoteView } from "./VoteItem"; + +export interface VoteTableProps { + votes: Vote[]; + counts: { yes: number; no: number; abstain: number }; + formatWeight: (weight: string) => string; + onSelect: (voter: string) => void; +} + +export const DesktopVoteTable: React.FC = ({ + votes, + counts, + formatWeight, + onSelect, +}) => ( + + + Vote Results + + + Yes ({counts.yes}) + | + No ({counts.no}) + | + + Abstain ({counts.abstain}) + + + + + Click on a vote to view a voters proposal voting history. + + + + + {["Voter", "Support", "Weight", "Reason", "Vote Txn"].map((label) => ( + + {label} + + ))} + + + + {votes.map((vote) => { + return ( + + ); + })} + + + +); diff --git a/components/Votes/VoteTable/Views/MobileVoteTable.tsx b/components/Votes/VoteTable/Views/MobileVoteTable.tsx new file mode 100644 index 00000000..2f722a8b --- /dev/null +++ b/components/Votes/VoteTable/Views/MobileVoteTable.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { Flex, Text, Box } from "@livepeer/design-system"; +import { VoteTableProps } from "./DesktopVoteTable"; +import { VoteView } from "./VoteItem"; + +interface MobileVoteCardsProps extends VoteTableProps {} + +export const MobileVoteCards: React.FC = ({ + votes, + counts, + formatWeight, + onSelect, +}) => ( + + + Vote Results + + + Yes ({counts.yes}) + | + No ({counts.no}) + | + + Abstain ({counts.abstain}) + + + + + Click on a vote to view a voters proposal voting history. + + + {votes.map((vote) => { + return ( + + ); + })} + +); + diff --git a/components/Votes/VoteTable/Views/VoteItem.tsx b/components/Votes/VoteTable/Views/VoteItem.tsx new file mode 100644 index 00000000..3c2c2e62 --- /dev/null +++ b/components/Votes/VoteTable/Views/VoteItem.tsx @@ -0,0 +1,236 @@ +import { Vote, VOTING_SUPPORT } from "../../../../lib/api/types/votes"; +import { Card, Heading, Link, Text, Box, Badge } from "@livepeer/design-system"; +import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { formatAddress } from "utils/formatAddress"; + +interface VoteViewProps { + vote: Vote; + onSelect: (voter: string) => void; + formatWeight: (weight: string) => string; + isMobile?: boolean; + } + + export function VoteView({ vote, onSelect, formatWeight, isMobile }: VoteViewProps) { + return isMobile ? ( + + ) : ( + + ); + } + + function MobileVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { + const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + return ( + { + if ((e.target as HTMLElement).closest("a")) return; + e.stopPropagation(); + onSelect(vote.voter); + }} + > + + e.stopPropagation()} + > + {vote.ensName} + + + + + Support: + + + {support.text} + + + + + Weight: + {" "} + {formatWeight(vote.weight)} + + + + Reason: + {" "} + {vote.reason} + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatAddress(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + ); + } + + function DesktopVoteView({ vote, onSelect, formatWeight }: VoteViewProps) { + const support = VOTING_SUPPORT[vote.choiceID] || VOTING_SUPPORT["2"]; + return ( + td": { padding: "$2 $3" }, + }} + onClickCapture={(e) => { + if ((e.target as HTMLElement).closest("a")) return; + e.stopPropagation(); + onSelect(vote.voter); + }} + > + + e.stopPropagation()} + > + + {vote.ensName} + + + + + + {support.text} + + + + + {formatWeight(vote.weight)} + + + + {vote.reason} + + + {vote.transactionHash ? ( + e.stopPropagation()} + css={{ + display: "inline-block", + transition: "transform 0.2s ease", + "&:hover": { transform: "scale(1.1)" }, + }} + > + + {formatAddress(vote.transactionHash)} + + + + ) : ( + N/A + )} + + + ); + } + \ No newline at end of file diff --git a/components/Votes/VoteTable/index.tsx b/components/Votes/VoteTable/index.tsx new file mode 100644 index 00000000..1ee9d73d --- /dev/null +++ b/components/Votes/VoteTable/index.tsx @@ -0,0 +1,87 @@ +"use client"; + +import React, { useState } from "react"; +import { useWindowSize } from "react-use"; +import Spinner from "@components/Spinner"; +import VoterPopover from "../VotePopover"; +import { DesktopVoteTable } from "./Views/DesktopVoteTable"; +import { MobileVoteCards } from "./Views/MobileVoteTable"; +import { Text, Flex } from "@livepeer/design-system"; +import { useFetchVotes } from "../../../hooks/TreasuryVotes/useFetchVotes"; +import { Vote } from "@lib/api/types/votes"; +import { lptFormatter } from "@lib/utils"; + +interface VoteTableProps { + proposalId: string; +} + +const countVotes = (votes : Vote[]) => { + return { + yes: votes.filter((v) => v.choiceID === "1").length || 0, + no: votes.filter((v) => v.choiceID === "0").length || 0, + abstain: votes.filter((v) => v.choiceID === "2").length || 0, + }; +} + +const Index: React.FC = ({ proposalId }) => { + const { votes, loading, error } = useFetchVotes(proposalId); + const { width } = useWindowSize(); + const isDesktop = width >= 768; + + const [selectedVoter, setSelectedVoter] = useState(null); + const counts = countVotes(votes); + const totalWeight = votes.reduce((sum, v) => sum + parseFloat(v.weight), 0); + + const formatWeight = (w: string) => + `${lptFormatter.format(parseFloat(w) / 1e18)} LPT (${ + totalWeight > 0 ? ((parseFloat(w) / totalWeight) * 100).toFixed(2) : "0" + }%)`; + + if (loading){ + return ( + + + + ); + } + if (error) + return ( + + Error loading votes: {error} + + ); + + if (!votes.length) + return ( + + No votes found for this proposal. + + ); + + const VoteListView = isDesktop ? DesktopVoteTable : MobileVoteCards; + + return ( + <> + + {selectedVoter && ( + setSelectedVoter(null)} + /> + )} + + ); +}; + +export default Index; diff --git a/components/Votes/VotingWidget/index.tsx b/components/Votes/VotingWidget/index.tsx new file mode 100644 index 00000000..7d739cc1 --- /dev/null +++ b/components/Votes/VotingWidget/index.tsx @@ -0,0 +1,539 @@ +import { PollExtended } from "@lib/api/polls"; +import { + Box, + Button, + Dialog, + DialogClose, + DialogContent, + DialogTitle, + Flex, + Heading, + Text, + useSnackbar, +} from "@livepeer/design-system"; +import { Cross1Icon } from "@modulz/radix-icons"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; +import { useEffect, useMemo, useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import Check from "../../../public/img/check.svg"; +import Copy from "../../../public/img/copy.svg"; +import { abbreviateNumber } from "@lib/utils"; +import VoteButton from "../VoteButton"; +import { formatPercent, getVotingPower, VotingResponse } from "utils/voting"; + +dayjs.extend(duration); + +const Index = ({ data }: { data: VotingResponse }) => { + const accountAddress = useAccountAddress(); + + const [modalOpen, setModalOpen] = useState(false); + + const pendingFeesAndStake = usePendingFeesAndStakeData( + data?.myAccount?.delegator?.id + ); + + const votingPower = useMemo( + () => + getVotingPower( + accountAddress ?? "", + data?.myAccount, + data?.vote, + pendingFeesAndStake?.pendingStake + ? pendingFeesAndStake?.pendingStake + : "0" + ), + [accountAddress, data, pendingFeesAndStake] + ); + + const delegateId = useMemo( + () => data?.myAccount?.delegator?.delegate?.id ?? "", + [data] + ); + + return ( + + + + + Do you support LIP-{data?.poll?.attributes?.lip ?? "ERR"}? + + + + + + + + Yes + + + {formatPercent(data.poll.percent.yes)} + + + + + + No + + + {formatPercent(data.poll.percent.no)} + + + + + + + {accountAddress ? ( + + ) : ( + + + + Connect your wallet to vote. + + + )} + + + {data.poll.status === "active" && ( + + )} + + + ); +}; + +export default Index; + +interface ActivePollViewProps { + setModalOpen: (open: boolean) => void; +} +function ActivePollView({ setModalOpen }: ActivePollViewProps) { + return ( + + + Are you an orchestrator?{" "} + setModalOpen(true)} + css={{ color: "$primary11", cursor: "pointer" }} + > + Follow these instructions + {" "} + if you prefer to vote with the Livepeer CLI. + + + ); +} + +interface DisplayFormmatedPollVotesProps { + data: VotingResponse; +} +function DisplayFormmatedPollVotes({ data }: DisplayFormmatedPollVotesProps) { + return ( + + {data.poll.votes.length}{" "} + {`${ + data.poll.votes.length > 1 || data.poll.votes.length === 0 + ? "votes" + : "vote" + }`}{" "} + · {abbreviateNumber(data.poll.stake.voters, 4)} LPT ·{" "} + {data.poll.status !== "active" + ? "Final Results" + : dayjs + .duration(dayjs().unix() - data.poll.estimatedEndTime, "seconds") + .humanize() + " left"} + + ); +} + +interface RenderAccountAddressProps { + data: VotingResponse; + votingPower: string | number; + accountAddress: string; + pendingFeesAndStake: any; + delegateId: string; +} + +function RenderAccountAddress({ + data, + votingPower, + accountAddress, + pendingFeesAndStake, + delegateId, +}: RenderAccountAddressProps) { + return ( + <> + + + + My Delegate Vote{" "} + {delegateId && + `(${delegateId.replace(delegateId?.slice(5, 39), "…")})`} + + + {data?.delegateVote?.choiceID + ? data?.delegateVote?.choiceID + : "N/A"} + + + + + My Vote ({accountAddress.replace(accountAddress.slice(5, 39), "…")}) + + + {data?.vote?.choiceID ? data?.vote?.choiceID : "N/A"} + + + {((!data?.vote?.choiceID && data.poll.status === "active") || + data?.vote?.choiceID) && ( + + + My Voting Power + + + + {abbreviateNumber(votingPower, 4)} LPT ( + {( + (+votingPower / + (data.poll.stake.nonVoters + data.poll.stake.voters)) * + 100 + ).toPrecision(2)} + %) + + + + )} + + {data.poll.status === "active" && ( + + )} + + ); +} + +interface LivepeerCLIInstructionsModalProps { + data: PollExtended; + setModalOpen: (open: boolean) => void; + modalOpen: boolean; +} + +function LivepeerCLIInstructionsModal({ + setModalOpen, + modalOpen, + data, +}: LivepeerCLIInstructionsModalProps) { + const [copied, setCopied] = useState(false); + const [openSnackbar] = useSnackbar(); + + useEffect(() => { + if (!copied) return; + + const timer = setTimeout(() => { + setCopied(false); + }, 5000); + + return () => clearTimeout(timer); // cleanup on re-run or unmount + }, [copied]); + + return ( + + + + + + Livepeer CLI Voting Instructions + + + + + + + + + + + Run the Livepeer CLI and select the option to "Vote on a + poll". When prompted for a contract address, copy and paste + this poll's contract address: + + + {data.id} + { + setCopied(true); + openSnackbar("Copied to clipboard"); + }} + > + + {copied ? ( + + ) : ( + + )} + + + + + + + The Livepeer CLI will prompt you for your vote. Enter 0 to vote + "Yes" or 1 to vote "No". + + + + + Once your vote is confirmed, check back here to see it reflected + in the UI. + + + + + + ); +} + +interface RenderVoteButtonProps { + vote: VotingResponse["vote"]; + poll: PollExtended; + pendingStake: string; +} +function RenderVoteButton({ vote, poll, pendingStake }: RenderVoteButtonProps) { + switch (vote?.choiceID) { + case "Yes": + return ( + 0)} + css={{ mt: "$4", width: "100%" }} + variant="red" + size="4" + choiceId={1} + pollAddress={poll?.id} + > + Change Vote To No + + ); + case "No": + return ( + 0)} + css={{ mt: "$4", width: "100%" }} + size="4" + variant="primary" + choiceId={0} + pollAddress={poll?.id} + > + Change Vote To Yes + + ); + default: + return ( + + 0)} + variant="primary" + choiceId={0} + size="4" + pollAddress={poll?.id} + > + Yes + + 0)} + variant="red" + size="4" + choiceId={1} + pollAddress={poll?.id} + > + No + + + ); + } +} diff --git a/components/VotingWidget/index.tsx b/components/VotingWidget/index.tsx deleted file mode 100644 index 808c37b1..00000000 --- a/components/VotingWidget/index.tsx +++ /dev/null @@ -1,532 +0,0 @@ -import { PollExtended } from "@lib/api/polls"; -import { - Box, - Button, - Dialog, - DialogClose, - DialogContent, - DialogTitle, - Flex, - Heading, - Text, - useSnackbar, -} from "@livepeer/design-system"; -import { Cross1Icon } from "@modulz/radix-icons"; -import { AccountQuery, PollChoice } from "apollo"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; -import numeral from "numeral"; -import { useEffect, useMemo, useState } from "react"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import { abbreviateNumber, fromWei } from "../../lib/utils"; -import Check from "../../public/img/check.svg"; -import Copy from "../../public/img/copy.svg"; -import VoteButton from "../VoteButton"; - -dayjs.extend(duration); - -type Props = { - poll: PollExtended; - delegateVote: - | { - __typename: "Vote"; - choiceID?: PollChoice; - voteStake: string; - nonVoteStake: string; - } - | undefined - | null; - vote: - | { - __typename: "Vote"; - choiceID?: PollChoice; - voteStake: string; - nonVoteStake: string; - } - | undefined - | null; - myAccount: AccountQuery; -}; - -const formatPercent = (percent: number) => numeral(percent).format("0.0000%"); - -const Index = ({ data }: { data: Props }) => { - const accountAddress = useAccountAddress(); - const [copied, setCopied] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [openSnackbar] = useSnackbar(); - - useEffect(() => { - if (copied) { - setTimeout(() => { - setCopied(false); - }, 5000); - } - }, [copied]); - - const pendingFeesAndStake = usePendingFeesAndStakeData( - data?.myAccount?.delegator?.id - ); - - const votingPower = useMemo( - () => - getVotingPower( - accountAddress ?? "", - data?.myAccount, - data?.vote, - pendingFeesAndStake?.pendingStake - ? pendingFeesAndStake?.pendingStake - : "0" - ), - [accountAddress, data, pendingFeesAndStake] - ); - - let delegate: any = null; - if (data?.myAccount?.delegator?.delegate) { - delegate = data?.myAccount?.delegator?.delegate; - } - - return ( - - - - - Do you support LIP-{data?.poll?.attributes?.lip ?? "ERR"}? - - - - - - - - Yes - - - {formatPercent(data.poll.percent.yes)} - - - - - - No - - - {formatPercent(data.poll.percent.no)} - - - - - {data.poll.votes.length}{" "} - {`${ - data.poll.votes.length > 1 || data.poll.votes.length === 0 - ? "votes" - : "vote" - }`}{" "} - · {abbreviateNumber(data.poll.stake.voters, 4)} LPT ·{" "} - {data.poll.status !== "active" - ? "Final Results" - : dayjs - .duration( - dayjs().unix() - data.poll.estimatedEndTime, - "seconds" - ) - .humanize() + " left"} - - - - {accountAddress ? ( - <> - - - - My Delegate Vote{" "} - {delegate && - `(${delegate?.id?.replace( - delegate?.id?.slice(5, 39), - "…" - )})`} - - - {data?.delegateVote?.choiceID - ? data?.delegateVote?.choiceID - : "N/A"} - - - - - My Vote ( - {accountAddress.replace(accountAddress.slice(5, 39), "…")}) - - - {data?.vote?.choiceID ? data?.vote?.choiceID : "N/A"} - - - {((!data?.vote?.choiceID && data.poll.status === "active") || - data?.vote?.choiceID) && ( - - - My Voting Power - - - - {abbreviateNumber(votingPower, 4)} LPT ( - {( - (+votingPower / - (data.poll.stake.nonVoters + - data.poll.stake.voters)) * - 100 - ).toPrecision(2)} - %) - - - - )} - - {data.poll.status === "active" && - data && - renderVoteButton( - data?.myAccount, - data?.vote, - data?.poll, - pendingFeesAndStake?.pendingStake ?? "" - )} - - ) : ( - - - - Connect your wallet to vote. - - - )} - - - {data.poll.status === "active" && ( - - - Are you an orchestrator?{" "} - setModalOpen(true)} - css={{ color: "$primary11", cursor: "pointer" }} - > - Follow these instructions - {" "} - if you prefer to vote with the Livepeer CLI. - - - )} - - - - - - Livepeer CLI Voting Instructions - - - - - - - - - - - Run the Livepeer CLI and select the option to "Vote on a - poll". When prompted for a contract address, copy and paste - this poll's contract address: - - - {data.poll.id} - { - setCopied(true); - openSnackbar("Copied to clipboard"); - }} - > - - {copied ? ( - - ) : ( - - )} - - - - - - - The Livepeer CLI will prompt you for your vote. Enter 0 to vote - "Yes" or 1 to vote "No". - - - - - Once your vote is confirmed, check back here to see it reflected - in the UI. - - - - - - - ); -}; - -export default Index; - -function renderVoteButton( - myAccount: Props["myAccount"], - vote: Props["vote"], - poll: Props["poll"], - pendingStake: string -) { - switch (vote?.choiceID) { - case "Yes": - return ( - 0)} - css={{ mt: "$4", width: "100%" }} - variant="red" - size="4" - choiceId={1} - pollAddress={poll?.id} - > - Change Vote To No - - ); - case "No": - return ( - 0)} - css={{ mt: "$4", width: "100%" }} - size="4" - variant="primary" - choiceId={0} - pollAddress={poll?.id} - > - Change Vote To Yes - - ); - default: - return ( - - 0)} - variant="primary" - choiceId={0} - size="4" - pollAddress={poll?.id} - > - Yes - - 0)} - variant="red" - size="4" - choiceId={1} - pollAddress={poll?.id} - > - No - - - ); - } -} - -function getVotingPower( - accountAddress: string, - myAccount: Props["myAccount"], - vote: Props["vote"], - pendingStake?: string -) { - // if account is a delegate its voting power is its total stake minus its delegators' vote stake (nonVoteStake) - if (accountAddress === myAccount?.delegator?.delegate?.id) { - if (vote?.voteStake) { - return +vote.voteStake - +vote?.nonVoteStake; - } - return ( - +myAccount?.delegator?.delegate?.totalStake - - (vote?.nonVoteStake ? +vote?.nonVoteStake : 0) - ); - } - - return fromWei(pendingStake ? pendingStake : "0"); -} diff --git a/hooks/TreasuryVotes/useFetchVotes.ts b/hooks/TreasuryVotes/useFetchVotes.ts new file mode 100644 index 00000000..285dae38 --- /dev/null +++ b/hooks/TreasuryVotes/useFetchVotes.ts @@ -0,0 +1,95 @@ +import { formatAddress } from "../../utils/formatAddress"; +import { + CONTRACT_ADDRESS, + VOTECAST_TOPIC0, + provider, + contractInterface, +} from "@lib/chains"; + +import { Vote } from "../../lib/api/types/votes"; +import { getEnsForVotes } from "@lib/api/ens"; +import { useEffect, useState } from "react"; + +export const useFetchVotes = (proposalId: string) => { + const [votes, setVotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!proposalId) { + setVotes([]); + setLoading(false); + return; + } + + const fetchVotes = async () => { + try { + const logs = await provider.getLogs({ + address: CONTRACT_ADDRESS, + fromBlock: "earliest", + toBlock: "latest", + topics: [VOTECAST_TOPIC0], + }); + + const decodedVotes = logs + .map((log) => { + const decoded = contractInterface.parseLog(log); + return { + transactionHash: log.transactionHash, + voter: decoded?.args.voter.toLowerCase() || "", + choiceID: decoded?.args.support.toString() || "", + proposalId: decoded?.args.proposalId.toString() || "", + weight: decoded?.args.weight.toString() || "0", + reason: decoded?.args.reason || "No reason provided", + }; + }) + .filter((vote) => vote.proposalId === proposalId) + + .sort((a, b) => parseFloat(b.weight) - parseFloat(a.weight)); + + const uniqueVoters = Array.from( + new Set(decodedVotes.map((v) => v.voter)) + ); + const localEnsCache: { [address: string]: string } = {}; + + await Promise.all( + uniqueVoters.map(async (address) => { + try { + if (localEnsCache[address]) { + return; + } + const ensAddress = await getEnsForVotes(address); + + if (ensAddress && ensAddress.name) { + localEnsCache[address] = ensAddress.name; + } else { + localEnsCache[address] = formatAddress(address); + } + } catch (e) { + console.warn(`Failed to fetch ENS for ${address}`, e); + } + }) + ); + + setVotes( + decodedVotes.map((vote) => ({ + ...vote, + ensName: localEnsCache[vote.voter], + })) + ); + } catch (error) { + console.error("Error fetching logs from Infura:", error); + setError("Failed to fetch votes"); + } finally { + setLoading(false); + } + }; + fetchVotes(); + }, [proposalId]); + + return { + votes, + loading, + error, + }; +}; diff --git a/hooks/TreasuryVotes/useInfuraVoterVotes.ts b/hooks/TreasuryVotes/useInfuraVoterVotes.ts new file mode 100644 index 00000000..8b4f240c --- /dev/null +++ b/hooks/TreasuryVotes/useInfuraVoterVotes.ts @@ -0,0 +1,65 @@ +import { useState, useEffect, useMemo } from 'react' +import { provider, VOTECAST_TOPIC0, contractInterface, CONTRACT_ADDRESS } from '@lib/chains' +import { useQuery } from '@apollo/client' +import { ethers } from "ethers"; +import { GET_PROPOSALS_BY_IDS } from "../../apollo/treasuryProposals"; +import { Vote } from '../../lib/api/types/votes'; + +export function useInfuraVoterVotes(voter: string) { + const [logsLoading, setLogsLoading] = useState(true) + const [rawVotes, setRawVotes] = useState([]) + const proposalIds = useMemo(() => Array.from(new Set(rawVotes.map(v => v.proposalId))), [rawVotes]) + + useEffect(() => { + let cancelled = false + async function fetch() { + setLogsLoading(true) + try { + const topic = ethers.utils.zeroPad(voter, 32) + const logs = await provider.getLogs({ address: CONTRACT_ADDRESS, fromBlock:'earliest', toBlock:'latest', topics: [VOTECAST_TOPIC0, ethers.utils.hexlify(topic)] }) + if (cancelled) return + const transactions = logs.map(log => { + const args = contractInterface.parseLog(log).args + return { + + transactionHash: log.transactionHash, + voter: args.voter, + choiceID: args.support.toString(), + proposalId: args.proposalId.toString(), + weight: args.weight.toString(), + reason: args.reason ?? "", + } + }) + setRawVotes(transactions) + } catch(e) { + console.error(e) + } finally { + if (!cancelled) setLogsLoading(false) + } + } + fetch() + return () => { cancelled = true } + }, [voter]) + + const { data, loading: proposalsLoading } = useQuery(GET_PROPOSALS_BY_IDS, { + skip: proposalIds.length === 0, + variables: { ids: proposalIds }, + }) + + const votes: Vote[] = useMemo(() => { + if (logsLoading || proposalsLoading) return [] + const map = new Map() + data?.treasuryProposals?.forEach((p: any) => { + map.set(p.id, { description: p.description || '', voteEnd: p.voteEnd || 0 }) + }) + return rawVotes + .map(r => { + const meta = map.get(r.proposalId) ?? { description: '', voteEnd: 0 } + const title = (meta.description.split('\n')[0] || '').replace(/^#\s*/, '') || 'Unknown Proposal' + return { ...r, endVote: meta.voteEnd, description: meta.description, proposalTitle: title } + }) + .sort((a, b) => b.endVote - a.endVote) + }, [rawVotes, data, logsLoading, proposalsLoading]) + + return { votes, isLoading: logsLoading || proposalsLoading } +} diff --git a/hooks/useSwr.tsx b/hooks/useSwr.tsx index 0dd87b72..6c417e9e 100644 --- a/hooks/useSwr.tsx +++ b/hooks/useSwr.tsx @@ -81,7 +81,7 @@ export const useScoreData = (address: string | undefined | null) => { export const useCurrentRoundData = () => { const { data } = useSWR(`/current-round`, { - refreshInterval: 10000, + refreshInterval: 10000, }); return data ?? null; diff --git a/lib/api/ens.ts b/lib/api/ens.ts index 12ba7292..a9fa45c3 100644 --- a/lib/api/ens.ts +++ b/lib/api/ens.ts @@ -100,3 +100,15 @@ export const nl2br = (str, is_xhtml = true) => { "$1" + breakTag + "$2" ); }; + +export const getEnsForVotes = async (address: string | null | undefined) => { + const idShort = address?.replace(address?.slice(6, 38), "…"); + + const name = address ? await l1Provider.lookupAddress(address) : null; + + return { + id: address ?? "", + idShort: idShort ?? "", + name, + }; +}; \ No newline at end of file diff --git a/lib/api/types/votes.ts b/lib/api/types/votes.ts new file mode 100644 index 00000000..d17796d0 --- /dev/null +++ b/lib/api/types/votes.ts @@ -0,0 +1,21 @@ + +export interface Vote { + transactionHash: string; + voter: string; + choiceID: string; + proposalId: string; + weight: string; + reason?: string; + ensName?: string; + endVote?: number; + description?: string; + proposalTitle?: string; +} + +export const VOTING_SUPPORT = { + '0': { text: 'No', style: { color: '$red9', fontWeight: 600 } }, + '1': { text: 'Yes', style: { color: '$green9', fontWeight: 600 } }, + '2': { text: 'Abstain',style: { color: '$yellow9', fontWeight: 600 } }, +} as const; + +export type SupportKey = keyof typeof VOTING_SUPPORT; \ No newline at end of file diff --git a/lib/axios.ts b/lib/axios.ts index a35e91d8..35d6946e 100644 --- a/lib/axios.ts +++ b/lib/axios.ts @@ -5,5 +5,6 @@ export const axiosClient = defaultAxios.create({ timeout: 10000, }); + export const fetcher = (url: string) => axiosClient.get(url).then((res) => res.data); diff --git a/lib/chains.ts b/lib/chains.ts index 0685ffd9..96f4933a 100644 --- a/lib/chains.ts +++ b/lib/chains.ts @@ -5,10 +5,6 @@ import * as chain from "@wagmi/core/chains"; import { ethers } from "ethers"; import { Address, - Client, - HttpTransport, - PublicActions, - PublicRpcSchema, createPublicClient, http, } from "viem"; @@ -255,6 +251,22 @@ export const l2Provider = new ethers.providers.JsonRpcProvider( INFURA_NETWORK_URLS[DEFAULT_CHAIN_ID] ); + +// Votecast Constants +export const INFURA_RPC_URL = `https://arbitrum-mainnet.infura.io/v3/${process.env.NEXT_PUBLIC_INFURA_KEY}`; +export const CONTRACT_ADDRESS = "0xcfe4e2879b786c3aa075813f0e364bb5accb6aa0"; + +export const VOTECAST_TOPIC0 = ethers.utils.id( + "VoteCast(address,uint256,uint8,uint256,string)" +); + +export const provider = new ethers.providers.JsonRpcProvider(INFURA_RPC_URL); + +export const contractInterface = new ethers.utils.Interface([ + "event VoteCast(address indexed voter, uint256 proposalId, uint8 support, uint256 weight, string reason)", +]); + + export function isL2ChainId(chainId: number | undefined): boolean { return L2_CHAIN_IDS.some((e) => e.id === chainId); } diff --git a/lib/utils.tsx b/lib/utils.ts similarity index 95% rename from lib/utils.tsx rename to lib/utils.ts index 806e1d11..ee0dae15 100644 --- a/lib/utils.tsx +++ b/lib/utils.ts @@ -5,6 +5,15 @@ import { StakingAction } from "hooks"; import { CHAIN_INFO, DEFAULT_CHAIN_ID, INFURA_NETWORK_URLS } from "lib/chains"; import Numeral from "numeral"; +export const lptFormatter = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +export const formatLpt = (w: string) => { + return `${lptFormatter.format(parseFloat(w) / 1e18)} LPT`; +} + export const provider = new ethers.providers.JsonRpcProvider( INFURA_NETWORK_URLS[DEFAULT_CHAIN_ID] ); @@ -420,7 +429,19 @@ export function toTitleCase(str) { }); } -export const fromWei = (wei: BigNumberish) => formatEther(wei); +export const fromWei = (wei: BigNumberish) => { + try { + const valueStr = + typeof wei === "number" || wei instanceof Number + ? wei.toString() + : wei; + + return formatEther(BigNumber.from(valueStr)); + } catch (e) { + console.error("fromWei error:", e, "input was:", wei); + return "0"; + } +}; export const toWei = (ether: BigNumberish) => parseUnits(ether.toString(), "ether").toBigInt(); diff --git a/pages/treasury/[proposal].tsx b/pages/treasury/[proposal].tsx index dd556e55..65c8ef44 100644 --- a/pages/treasury/[proposal].tsx +++ b/pages/treasury/[proposal].tsx @@ -1,5 +1,6 @@ import { getLayout, LAYOUT_MAX_WIDTH } from "@layouts/main"; import { useRouter } from "next/router"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { abbreviateNumber, fromWei, shortenAddress } from "@lib/utils"; import MarkdownRenderer from "@components/MarkdownRenderer"; import BottomDrawer from "@components/BottomDrawer"; @@ -18,7 +19,6 @@ import { } from "@livepeer/design-system"; import dayjs from "dayjs"; import Head from "next/head"; -import { useMemo } from "react"; import { useWindowSize } from "react-use"; import { useAccountAddress, @@ -29,12 +29,14 @@ import { useProposalVotingPowerData, useTreasuryProposalState, } from "../../hooks"; +import { useFetchVotes } from '../../hooks/TreasuryVotes/useFetchVotes'; import FourZeroFour from "../404"; import { useProtocolQuery, useTreasuryProposalQuery } from "apollo"; import { sentenceCase } from "change-case"; import relativeTime from "dayjs/plugin/relativeTime"; import numeral from "numeral"; import { BadgeVariantByState } from "@components/TreasuryProposalRow"; +import VoteList from "@components/Votes/VoteTable"; import TreasuryVotingWidget from "@components/TreasuryVotingWidget"; import { getProposalExtended } from "@lib/api/treasury"; import { decodeFunctionData } from "viem"; @@ -61,6 +63,7 @@ const formatDateTime = (date: dayjs.Dayjs) => { const Proposal = () => { const router = useRouter(); const { width } = useWindowSize(); + const [isDesktop, setIsDesktop] = useState(false); const { setBottomDrawerOpen } = useExplorerStore(); const { query } = router; @@ -80,6 +83,13 @@ const Proposal = () => { const { data: protocolQuery } = useProtocolQuery(); const currentRound = useCurrentRoundData(); + const { votes, loading: votesLoading } = useFetchVotes(proposalId ?? ""); + const [votesOpen, setVotesOpen] = useState(false); + + useEffect(() => { + setIsDesktop(width >= 768); + }, [width]); + const proposal = useMemo(() => { if (!proposalQuery || !state || !protocolQuery || !currentRound) { return null; @@ -94,6 +104,25 @@ const Proposal = () => { const proposerId = useEnsData(proposal?.proposer.id); + const votesContent = useCallback(() => { + if (votesLoading) { + return ( + + + + ); + } + if (votes.length === 0) return No votes yet.; + return ; + }, [votesLoading, votes.length, proposal?.id]); + const actions = useMemo(() => { if (!proposal || !contractAddresses) { return null; @@ -157,25 +186,25 @@ const Proposal = () => { Livepeer Explorer - Treasury - + - + @@ -222,8 +251,8 @@ const Proposal = () => { variant="primary" css={{ display: "flex", - mt: "$3", - mr: "$3", + marginTop: "$3", + marginRight: "$3", "@bp3": { display: "none", }, @@ -241,7 +270,7 @@ const Proposal = () => { display: "grid", gridGap: "$3", gridTemplateColumns: "100%", - mb: "$3", + marginBottom: "$3", "@bp2": { gridTemplateColumns: "repeat(auto-fit, minmax(128px, 1fr))", }, @@ -264,11 +293,11 @@ const Proposal = () => { } meta={ - + { { {formatPercent(proposal.votes.percent.voters)} } meta={ - + { @@ -417,10 +446,10 @@ const Proposal = () => { > {action.lptTransfer ? ( <> - + LPT Transfer: - + Receiver: @@ -447,7 +476,7 @@ const Proposal = () => { - + Amount: @@ -456,7 +485,7 @@ const Proposal = () => { display: "block", fontWeight: 600, color: "$white", - ml: "auto", + marginLeft: "auto", }} size="2" > @@ -466,10 +495,10 @@ const Proposal = () => { ) : ( <> - + Custom: - + Target: @@ -499,7 +528,7 @@ const Proposal = () => { - + Value: @@ -508,7 +537,7 @@ const Proposal = () => { display: "block", fontWeight: 600, color: "$white", - ml: "auto", + marginLeft: "auto", }} size="2" > @@ -517,7 +546,7 @@ const Proposal = () => { {action.functionName ? ( - + Function: @@ -526,7 +555,7 @@ const Proposal = () => { display: "block", fontWeight: 600, color: "$white", - ml: "auto", + marginLeft: "auto", maxWidth: "50%", textAlign: "right", }} @@ -538,7 +567,7 @@ const Proposal = () => { ) : ( <> - + Calldata: @@ -547,7 +576,7 @@ const Proposal = () => { display: "block", fontWeight: 600, color: "$white", - ml: "auto", + marginLeft: "auto", maxWidth: "50%", wordBreak: "break-all", textAlign: "right", @@ -566,9 +595,9 @@ const Proposal = () => { { {proposal.description} + + setVotesOpen(!votesOpen)} +> + + + + + {votesLoading ? "Loading votes…" : `View Votes (${votes.length})`} + + + + + + {votesOpen ? "–" : "+"} + + + + + {votesOpen && {votesContent()}} + + + - {width > 1200 ? ( + {isDesktop ? ( { position: "sticky", alignSelf: "flex-start", top: "$9", - mt: "$6", + marginTop: "$6", width: "25%", display: "flex", }, diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index e75873dd..f1289189 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -1,4 +1,4 @@ -import VotingWidget from "@components/VotingWidget"; +import VotingWidget from "@components/Votes/VotingWidget"; import { getLayout, LAYOUT_MAX_WIDTH } from "@layouts/main"; import { useRouter } from "next/router"; import MarkdownRenderer from "@components/MarkdownRenderer"; diff --git a/queries/proposalsByIds.graphql b/queries/proposalsByIds.graphql new file mode 100644 index 00000000..353c92ee --- /dev/null +++ b/queries/proposalsByIds.graphql @@ -0,0 +1,8 @@ +query getProposalsByIds($ids: [ID!]!) { + treasuryProposals(where: { id_in: $ids }) { + id + description + voteStart + voteEnd + } +} \ No newline at end of file diff --git a/utils/formatAddress.ts b/utils/formatAddress.ts new file mode 100644 index 00000000..c0fadef5 --- /dev/null +++ b/utils/formatAddress.ts @@ -0,0 +1,10 @@ +export const formatAddress = (addr: string): string => { + if (!addr) return ""; + if (addr.endsWith(".xyz")) { + return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; + } + if (addr.endsWith(".eth") && addr.length < 21) { + return addr; + } + return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; + }; \ No newline at end of file diff --git a/utils/voting.ts b/utils/voting.ts new file mode 100644 index 00000000..2c78cd09 --- /dev/null +++ b/utils/voting.ts @@ -0,0 +1,53 @@ +import { PollExtended } from "@lib/api/polls"; +import { fromWei } from "@lib/utils"; +import { AccountQuery, PollChoice } from "apollo"; +import numeral from "numeral"; +export type VotingResponse = { + poll: PollExtended; + delegateVote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + vote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + myAccount: AccountQuery; +}; + + + +export const formatPercent = (percent: number) => numeral(percent).format("0.0000%"); + + +export function getVotingPower( + accountAddress: string, + myAccount: VotingResponse["myAccount"], + vote: VotingResponse["vote"], + pendingStake?: string + ) { + // if account is a delegate its voting power is its total stake minus its delegators' vote stake (nonVoteStake) + if (accountAddress === myAccount?.delegator?.delegate?.id) { + if (vote?.voteStake) { + return +vote.voteStake - +vote?.nonVoteStake; + } + return ( + +myAccount?.delegator?.delegate?.totalStake - + (vote?.nonVoteStake ? +vote?.nonVoteStake : 0) + ); + } + + return fromWei(pendingStake ? pendingStake : "0"); + } + + \ No newline at end of file