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 && (