diff --git a/.env.example b/.env.example index d79f24a0..3e132cff 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,4 @@ NEXT_PUBLIC_SUBGRAPH_ID=FE63YgkzcpVocxdCEyEYbvjYqEf2kb1A6daMYRxmejYC NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= NEXT_PUBLIC_METRICS_SERVER_URL=https://livepeer-leaderboard-serverless.vercel.app NEXT_PUBLIC_AI_METRICS_SERVER_URL=https://leaderboard-api.livepeer.cloud +NEXT_PUBLIC_SNAPSHOT_SPACE=livepeer.eth diff --git a/README.md b/README.md index a06f75b2..9311630f 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ To run the application in production mode, follow these steps: | `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID` | WalletConnect (Reown) Cloud Project ID. Used to enhance wallet UX for users of Explorer. | | `NEXT_PUBLIC_METRICS_SERVER_URL` | The Transcoding performance API server used by Explorer. | | `NEXT_PUBLIC_AI_METRICS_SERVER_URL` | The AI performance API server used by Explorer. | +| `NEXT_PUBLIC_SNAPSHOT_SPACE` | The Snapshot space ID for community governance (e.g., `livepeer.eth`). Used to fetch and display community proposals via Snapshot. | ## Developing on Arbitrum Rinkeby @@ -112,4 +113,25 @@ To test Livepeer Improvement Proposals (LIPs), follow these steps: With the namespace and your forked repository, you can now test LIPs locally within the application. +## Community Governance via Snapshot + +The Explorer integrates with [Snapshot](https://snapshot.org) to enable community governance voting on proposals outside of LIPs and treasury funding. This allows orchestrators and delegators to express support or opposition to smaller governance changes. + +### Features + +- **Browse Proposals**: View all community proposals from the Livepeer Snapshot space at `/governance` +- **Vote on Proposals**: Cast votes directly using your connected wallet +- **Voting Power**: Your voting power is calculated based on your stake at the proposal's snapshot block +- **Real-time Results**: See current vote distributions and participation rates + +### Configuration + +To use a custom Snapshot space for testing: + +```env +NEXT_PUBLIC_SNAPSHOT_SPACE=your-space.eth +``` + +The default is `livepeer.eth` which is the official Livepeer community space. + --- diff --git a/components/SnapshotProposalRow/index.tsx b/components/SnapshotProposalRow/index.tsx new file mode 100644 index 00000000..bccbba7e --- /dev/null +++ b/components/SnapshotProposalRow/index.tsx @@ -0,0 +1,90 @@ +import { Badge, Box, Card, Flex, Heading, Link as A } from "@livepeer/design-system"; +import Link from "next/link"; +import { SnapshotProposal } from "@lib/api/snapshot"; +import { sentenceCase } from "change-case"; +import dayjs from "@lib/dayjs"; + +export const BadgeVariantByState = { + active: "blue", + pending: "lime", + closed: "gray", +} as const; + +type Props = { + key: string; + proposal: SnapshotProposal; +}; + +const SnapshotProposalRow = ({ key, proposal, ...props }: Props) => { + const startDate = dayjs.unix(proposal.start); + const endDate = dayjs.unix(proposal.end); + const now = dayjs(); + + const isActive = now.isAfter(startDate) && now.isBefore(endDate); + const isPending = now.isBefore(startDate); + + return ( + + + + + + {proposal.title} + + + {proposal.state === "closed" ? ( + Voting ended on {endDate.format("MMM D, YYYY")} + ) : isActive ? ( + Voting will end on ~{endDate.format("MMM D, YYYY")} + ) : isPending ? ( + Voting starts on {startDate.format("MMM D, YYYY")} + ) : ( + Voting ended on {endDate.format("MMM D, YYYY")} + )} + + + + {sentenceCase(proposal.state)} + + + + + ); +}; + +export default SnapshotProposalRow; diff --git a/components/SnapshotVoteButton/index.tsx b/components/SnapshotVoteButton/index.tsx new file mode 100644 index 00000000..ca4058da --- /dev/null +++ b/components/SnapshotVoteButton/index.tsx @@ -0,0 +1,75 @@ +import { Button } from "@livepeer/design-system"; +import { useAccountAddress } from "hooks"; +import { useState } from "react"; +import { castSnapshotVote } from "@lib/api/snapshot"; +import { useWalletClient } from "wagmi"; + +type Props = React.ComponentProps & { + proposalId: string; + choice: number; + reason?: string; +}; + +const SnapshotVoteButton = ({ + proposalId, + choice, + reason, + children, + ...props +}: Props) => { + const accountAddress = useAccountAddress(); + const { data: walletClient } = useWalletClient(); + const [isVoting, setIsVoting] = useState(false); + const [error, setError] = useState(null); + + const handleVote = async () => { + if (!walletClient || !accountAddress) { + setError("Please connect your wallet"); + return; + } + + setIsVoting(true); + setError(null); + try { + const provider = walletClient; + await castSnapshotVote( + provider, + accountAddress, + proposalId, + choice, + reason + ); + + // Refresh the page to show updated vote + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (err) { + console.error("Error casting vote:", err); + setError( + err instanceof Error ? err.message : "Failed to cast vote" + ); + } finally { + setIsVoting(false); + } + }; + + if (!accountAddress) { + return null; + } + + return ( + <> + + {error && ( + + {error} + + )} + + ); +}; + +export default SnapshotVoteButton; diff --git a/components/SnapshotVotingWidget/index.tsx b/components/SnapshotVotingWidget/index.tsx new file mode 100644 index 00000000..bb657985 --- /dev/null +++ b/components/SnapshotVotingWidget/index.tsx @@ -0,0 +1,220 @@ +import { Box, Button, Flex, Heading, Text } from "@livepeer/design-system"; +import dayjs from "@lib/dayjs"; +import { useAccountAddress, useSnapshotHasVoted, useSnapshotVotingPower } from "hooks"; +import numeral from "numeral"; +import { useState } from "react"; +import { abbreviateNumber } from "@lib/utils"; +import SnapshotVoteButton from "@components/SnapshotVoteButton"; +import { SnapshotProposal } from "@lib/api/snapshot"; +import TreasuryVotingReason from "@components/TreasuryVotingReason"; + +type Props = { + proposal: SnapshotProposal; +}; + +const formatPercent = (percent: number) => numeral(percent).format("0.0000%"); + +const SnapshotVotingWidget = ({ proposal, ...props }: Props) => { + const accountAddress = useAccountAddress(); + const endDate = dayjs.unix(proposal.end); + const now = dayjs(); + const isActive = proposal.state === "active"; + + const { data: votingPower } = useSnapshotVotingPower( + accountAddress, + proposal.id, + proposal.snapshot, + proposal.strategies + ); + + const { data: hasVoted } = useSnapshotHasVoted(accountAddress, proposal.id); + + const [reason, setReason] = useState(""); + + // Calculate vote percentages + const totalVotes = proposal.scores_total || 0; + const choicePercentages = proposal.scores?.map((score) => + totalVotes > 0 ? score / totalVotes : 0 + ) || []; + + return ( + + + + + Do you support this proposal? + + + + + {proposal.choices.map((choice, index) => ( + + + + {choice} + + + {formatPercent(choicePercentages[index] || 0)} + + + ))} + + + {abbreviateNumber(totalVotes, 4)} votes ·{" "} + {!isActive + ? "Final Results" + : dayjs.duration(endDate.diff(now)).humanize() + " left"} + + + + {accountAddress ? ( + <> + + + + You ( + {accountAddress.replace(accountAddress.slice(5, 39), "…")}) + {hasVoted ? " already voted" : " haven't voted yet"} + + + {votingPower > 0 ? abbreviateNumber(votingPower, 4) : "0"} VP + + + {!hasVoted && isActive && ( + + + My Voting Power + + + {abbreviateNumber(votingPower, 4)} VP + + + )} + + + {isActive && !hasVoted && ( + + {proposal.choices.map((choice, index) => ( + + {choice} + + ))} + + + + )} + + ) : ( + + + + Connect your wallet to vote. + + + )} + + + + ); +}; + +export default SnapshotVotingWidget; diff --git a/hooks/useSwr.tsx b/hooks/useSwr.tsx index 0dd87b72..1ba418d6 100644 --- a/hooks/useSwr.tsx +++ b/hooks/useSwr.tsx @@ -15,6 +15,13 @@ import { RegisteredToVote, VotingPower, } from "@lib/api/types/get-treasury-proposal"; +import { + SnapshotProposal, + getSnapshotProposals, + getSnapshotProposal, + getVotingPower, + hasVoted, +} from "@lib/api/snapshot"; import useSWR from "swr"; import { Address } from "viem"; @@ -172,3 +179,79 @@ export const useContractInfoData = ( } ); }; + +export const useSnapshotProposals = ( + state: "all" | "active" | "pending" | "closed" = "all" +) => { + const { data, error, isValidating } = useSWR( + `snapshot-proposals-${state}`, + () => getSnapshotProposals(state), + { + refreshInterval: 20000, + } + ); + + return { + data: data ?? [], + error, + isLoading: isValidating && !data, + }; +}; + +export const useSnapshotProposal = (proposalId: string | undefined) => { + const { data, error, isValidating } = useSWR( + proposalId ? `snapshot-proposal-${proposalId}` : null, + () => (proposalId ? getSnapshotProposal(proposalId) : null), + { + refreshInterval: 20000, + } + ); + + return { + data: data ?? null, + error, + isLoading: isValidating && !data, + }; +}; + +export const useSnapshotVotingPower = ( + address: string | undefined | null, + proposalId: string | undefined, + snapshot: string | undefined, + strategies: any[] | undefined +) => { + const { data, error, isValidating } = useSWR( + address && proposalId && snapshot && strategies + ? `snapshot-voting-power-${proposalId}-${address}` + : null, + () => + address && proposalId && snapshot && strategies + ? getVotingPower(address, proposalId, snapshot, strategies) + : 0 + ); + + return { + data: data ?? 0, + error, + isLoading: isValidating && !data, + }; +}; + +export const useSnapshotHasVoted = ( + address: string | undefined | null, + proposalId: string | undefined +) => { + const { data, error, isValidating } = useSWR( + address && proposalId ? `snapshot-has-voted-${proposalId}-${address}` : null, + () => (address && proposalId ? hasVoted(address, proposalId) : false), + { + refreshInterval: 20000, + } + ); + + return { + data: data ?? false, + error, + isLoading: isValidating && !data, + }; +}; diff --git a/layouts/main.tsx b/layouts/main.tsx index 1944719b..ec4ecec3 100644 --- a/layouts/main.tsx +++ b/layouts/main.tsx @@ -231,6 +231,13 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => { icon: Ballot, className: "treasury", }, + { + name: "Snapshots", + href: "/snapshots", + as: "/snapshots", + icon: Ballot, + className: "snapshots", + }, ]; const onDrawerOpen = () => { @@ -532,6 +539,29 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => { )} + + + {accountAddress && ( + )} + + + + + + Description + + {proposal.body} + + + + + Information + + + + Voting System + + + {sentenceCase(proposal.type)} + + + + + Start Date + + + {formatDateTime(proposal.start)} + + + + + End Date + + + {formatDateTime(proposal.end)} + + + + + Snapshot + + + + {proposal.snapshot} + + + + + + + + {width > 1200 ? ( + + + + ) : ( + + + + )} + + + + ); +}; + +Proposal.getLayout = getLayout; + +export default Proposal; diff --git a/pages/snapshots/create-proposal.tsx b/pages/snapshots/create-proposal.tsx new file mode 100644 index 00000000..7fbe12ed --- /dev/null +++ b/pages/snapshots/create-proposal.tsx @@ -0,0 +1,317 @@ +import { + Box, + Button, + Container, + Flex, + Heading, + Text, + TextField, + TextArea, + Card, +} from "@livepeer/design-system"; +import { getLayout, LAYOUT_MAX_WIDTH } from "layouts/main"; +import Head from "next/head"; +import { useState } from "react"; +import { useAccountAddress } from "hooks"; +import { useWalletClient } from "wagmi"; +import { createSnapshotProposal } from "@lib/api/snapshot"; + +const CreateSnapshot = () => { + const accountAddress = useAccountAddress(); + const { data: walletClient } = useWalletClient(); + + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [choices, setChoices] = useState("For\nAgainst\nAbstain"); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!walletClient || !accountAddress) { + setError("Please connect your wallet"); + return; + } + + if (!title || !body || !startDate || !endDate) { + setError("Please fill in all required fields"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const choicesArray = choices.split("\n").filter(c => c.trim()); + + const startTimestamp = Math.floor(new Date(startDate).getTime() / 1000); + const endTimestamp = Math.floor(new Date(endDate).getTime() / 1000); + + const receipt = await createSnapshotProposal( + walletClient, + accountAddress, + { + title, + body, + choices: choicesArray, + start: startTimestamp, + end: endTimestamp, + } + ); + + console.log("Proposal created:", receipt); + setSuccess(true); + + // Redirect to snapshots page after a delay + setTimeout(() => { + window.location.href = "/snapshots"; + }, 2000); + } catch (err) { + console.error("Error creating snapshot:", err); + setError(err instanceof Error ? err.message : "Failed to create snapshot"); + } finally { + setIsSubmitting(false); + } + }; + + if (!accountAddress) { + return ( + <> + + Livepeer Explorer - Create Snapshot + + + + + Create Snapshot + + + Please connect your wallet to create a snapshot proposal. + + + + + ); + } + + if (success) { + return ( + <> + + Livepeer Explorer - Create Snapshot + + + + + Snapshot Created Successfully! + + + Redirecting to snapshots page... + + + + + ); + } + + return ( + <> + + Livepeer Explorer - Create Snapshot + + + + Create Snapshot + + +
+ + + + Title * + + setTitle(e.target.value)} + required + css={{ width: "100%" }} + /> + + + + + Description * + +