diff --git a/constants/manualPolls.ts b/constants/manualPolls.ts index 5585421b..67e119b8 100644 --- a/constants/manualPolls.ts +++ b/constants/manualPolls.ts @@ -1,5 +1,3 @@ -import { PollsQuery } from "apollo"; - /** * TEMPORARY stopgap: manually surface governance polls that were created while * the subgraph was behind on indexing, so voting stays functional during the @@ -7,15 +5,24 @@ import { PollsQuery } from "apollo"; * voting list/detail pages, deduped by poll address — once the subgraph * reindexes a poll, its real (tallied) copy wins and the manual entry drops out. * - * These carry no vote tally (the subgraph computes the stake-weighted counts), - * so the UI shows a "counts not loaded" note for them; voting itself is on-chain - * and works regardless. + * The subgraph normally computes the stake-weighted counts, so these carry no + * tally of their own: the UI fills it in from `/api/polls/tally/[poll]`, which + * recomputes it from chain state. Voting itself is on-chain and works regardless. * * Remove entries (or delete this file and its imports) once indexing recovers. */ + +import { PollTally } from "@lib/api/types/get-poll-tally"; +import { PollsQuery } from "apollo"; + type SubgraphPoll = PollsQuery["polls"][number]; -export const MANUAL_POLLS: SubgraphPoll[] = [ +type ManualPoll = SubgraphPoll & { + /** L2 block the poll was created in — lower bound for on-chain vote lookups. */ + startBlockL2: number; +}; + +export const MANUAL_POLLS: ManualPoll[] = [ { // LIP-118 "Delegated Reward Calling" — created during the subgraph indexing halt. // tx: 0xb8ab2a824b4a5b16b76be99f1313a8238f35acc2259b139562277e1df8ba02e4 @@ -27,6 +34,7 @@ export const MANUAL_POLLS: SubgraphPoll[] = [ quota: "500000", tally: null, votes: [], + startBlockL2: 485384564, }, ]; @@ -35,7 +43,7 @@ const norm = (id: string) => id.toLowerCase(); /** The manually-listed poll for `id`, if any. */ export const getManualPoll = ( id: string | undefined | null -): SubgraphPoll | undefined => +): ManualPoll | undefined => id ? MANUAL_POLLS.find((p) => norm(p.id) === norm(id)) : undefined; /** Whether `id` is a manually-listed poll (i.e. not sourced from the subgraph). */ @@ -56,3 +64,27 @@ export const mergeManualPolls = ( } return list; }; + +/** + * Fill a manual poll's missing tally with the one computed on-chain by + * `/api/polls/tally/[poll]`. Subgraph-sourced polls are returned untouched, so + * this becomes a no-op as soon as indexing catches up. + */ +export const withManualTally = ( + poll: SubgraphPoll, + tally: PollTally | undefined | null +): SubgraphPoll => { + if (!tally || norm(tally.poll) !== norm(poll.id) || !isManualPoll(poll.id)) { + return poll; + } + + return { + ...poll, + tally: { __typename: "PollTally", ...tally.tally }, + // The list/widget show a vote count, which the subgraph would supply. + votes: tally.votes.map((vote) => ({ + __typename: "Vote" as const, + id: `${vote.voter}-${norm(poll.id)}`, + })), + }; +}; diff --git a/hooks/useSwr.tsx b/hooks/useSwr.tsx index 720b6e91..c90c95af 100644 --- a/hooks/useSwr.tsx +++ b/hooks/useSwr.tsx @@ -14,6 +14,7 @@ import { AllPerformanceMetrics, PerformanceMetrics, } from "@lib/api/types/get-performance"; +import { PollTally } from "@lib/api/types/get-poll-tally"; import { ProtocolDayData } from "@lib/api/types/get-protocol-day-data"; import { Regions } from "@lib/api/types/get-regions"; import { SupplyChangeData } from "@lib/api/types/get-supply-change"; @@ -24,6 +25,7 @@ import { VotingPower, } from "@lib/api/types/get-treasury-proposal"; import { formatAddress } from "@utils/web3"; +import { isManualPoll } from "constants/manualPolls"; import useSWR from "swr"; import { Address } from "viem"; @@ -127,6 +129,21 @@ export const useCurrentRoundData = () => { return data ?? null; }; +/** + * TEMPORARY stopgap: on-chain tally for a poll the subgraph hasn't indexed yet. + * Remove together with `constants/manualPolls` once indexing recovers. + */ +export const useManualPollTally = (id: string | undefined | null) => { + const { data } = useSWR( + id && isManualPoll(id) ? `/polls/tally/${id.toLowerCase()}` : null, + // Matches the endpoint's cache window — polling faster just re-reads the + // same cached object. + { refreshInterval: 60000 } + ); + + return data ?? null; +}; + export const usePendingFeesAndStakeData = ( address: string | undefined | null ) => { diff --git a/lib/api/pollTally.ts b/lib/api/pollTally.ts new file mode 100644 index 00000000..b7dcbf1c --- /dev/null +++ b/lib/api/pollTally.ts @@ -0,0 +1,262 @@ +import { l2PublicClient } from "@lib/chains"; +import { Address, formatEther, getAbiItem, getAddress } from "viem"; + +import { bondingManager } from "./abis/main/BondingManager"; +import { poll as pollAbi } from "./abis/main/Poll"; +import { roundsManager } from "./abis/main/RoundsManager"; +import { getBondingManagerAddress, getRoundsManagerAddress } from "./contracts"; +import { PollTally, PollTallyVote } from "./types/get-poll-tally"; + +/** + * TEMPORARY stopgap: compute a poll's stake-weighted tally from chain state so + * results stay visible while the subgraph is behind on indexing. + * + * This mirrors the subgraph's tally rules (see livepeer/subgraph + * `src/mappings/poll.ts`): + * - a vote's weight is the voter's stake at the current round, + * - a registered transcoder votes with its *total* stake (own + delegated), + * - a delegator that votes itself is subtracted from its delegate's weight + * (`nonVoteStake`), so stake is never counted twice, + * - only `Yes` (0) and `No` (1) choices count, and the latest vote per address + * wins. + * + * CAVEAT: this reads stake as it is *now*, not as it was at the poll's end + * block. While a poll is open that matches what the subgraph reports. Once it + * closes the real result freezes and this one keeps moving as stake is bonded, + * unbonded, or earns rewards — enough to flip the derived passed/rejected + * status. That is left unhandled on purpose: the poll is expected to still be + * open for the few days this stopgap exists, and pinning the reads to the end + * block needs `NodeInterface.l2BlockRangeForL1` to map it onto L2, which reverts + * intermittently for the same input (the RPC's own flakiness, also the likely + * cause of the `getL2BlockRangeForL1` TODO in ./polls.ts). Pinning through it + * traded a small drift for an intermittent hard failure. If voting closes while + * the subgraph is still behind, treat these numbers as indicative and confirm + * the outcome from the vote events at the end block. + * + * Remove together with the manual poll list once indexing recovers. + */ + +const VOTE_EVENT = getAbiItem({ abi: pollAbi, name: "Vote" }); + +/** Choice IDs the contract accepts; anything else is ignored by the tally. */ +const CHOICES: Record = { 0: "Yes", 1: "No" }; + +/** Infura caps `eth_getLogs` at 10k blocks on Arbitrum; stay under it. */ +const MAX_LOG_RANGE = 9_000n; + +/** Blocks re-scanned on every poll, so a reorg near the tip can't drop a vote. */ +const REORG_BUFFER = 200n; + +/** How many range chunks are in flight at once during a cold scan. */ +const CHUNK_CONCURRENCY = 10; + +type Choice = PollTallyVote["choice"]; + +const decode = (logs: Awaited>) => + logs.flatMap((log) => { + const choice = CHOICES[String(log.args.choiceID)]; + return choice && log.args.voter + ? [{ voter: getAddress(log.args.voter), choice }] + : []; + }); + +const getRangeLogs = (address: Address, fromBlock: bigint, toBlock: bigint) => + l2PublicClient.getLogs({ address, event: VOTE_EVENT, fromBlock, toBlock }); + +/** + * Read `Vote` logs across a block span. Tries the span in one request — cheap on + * providers that allow it — and falls back to fixed-size chunks for the ones + * that cap the range (Infura), a few at a time so we don't burst the rate limit. + */ +const getVoteLogs = async ( + address: Address, + fromBlock: bigint, + toBlock: bigint +): Promise<{ voter: Address; choice: Choice }[]> => { + if (fromBlock > toBlock) return []; + + try { + return decode(await getRangeLogs(address, fromBlock, toBlock)); + } catch (err) { + if (toBlock - fromBlock < MAX_LOG_RANGE) throw err; + + const ranges: [bigint, bigint][] = []; + for (let start = fromBlock; start <= toBlock; start += MAX_LOG_RANGE) { + const end = start + MAX_LOG_RANGE - 1n; + ranges.push([start, end > toBlock ? toBlock : end]); + } + + const votes: { voter: Address; choice: Choice }[] = []; + for (let i = 0; i < ranges.length; i += CHUNK_CONCURRENCY) { + const batch = await Promise.all( + ranges + .slice(i, i + CHUNK_CONCURRENCY) + .map(([start, end]) => getRangeLogs(address, start, end)) + ); + for (const logs of batch) votes.push(...decode(logs)); + } + + return votes; + } +}; + +/** + * Votes seen so far per poll, so only blocks appended since the last call are + * scanned. A poll's history is ~830k blocks and grows by ~345k a day, which is + * ~90 chunked requests to walk — worth paying once per process, not per request. + */ +const scanned = new Map< + Address, + { toBlock: bigint; choices: Map } +>(); + +/** Latest vote per address, matching the subgraph's overwrite on re-vote. */ +const getChoiceByVoter = async ( + address: Address, + startBlock: bigint, + latestBlock: bigint +) => { + const previous = scanned.get(address); + const choices = previous?.choices ?? new Map(); + const fromBlock = previous + ? previous.toBlock > REORG_BUFFER + ? previous.toBlock - REORG_BUFFER + : startBlock + : startBlock; + + // Re-scanning the buffer is idempotent: replaying a vote sets the same choice. + for (const { voter, choice } of await getVoteLogs( + address, + fromBlock, + latestBlock + )) { + choices.set(voter, choice); + } + + scanned.set(address, { toBlock: latestBlock, choices }); + + return choices; +}; + +export const getPollTally = async ( + pollAddress: Address, + startBlock: number +): Promise => { + const [bondingManagerAddress, roundsManagerAddress, latestBlock] = + await Promise.all([ + getBondingManagerAddress(), + getRoundsManagerAddress(), + l2PublicClient.getBlockNumber(), + ]); + + const [currentRound, choiceByVoter] = await Promise.all([ + l2PublicClient.readContract({ + address: roundsManagerAddress, + abi: roundsManager, + functionName: "currentRound", + }), + getChoiceByVoter(pollAddress, BigInt(startBlock), latestBlock), + ]); + + const voters = [...choiceByVoter.keys()]; + + // `l2PublicClient` aggregates concurrent reads into multicall3 batches, so + // these are a handful of requests no matter how many addresses voted. + const read = { address: bondingManagerAddress, abi: bondingManager } as const; + + const [delegators, registrations, transcoderStakes, pendingStakes] = + await Promise.all([ + Promise.all( + voters.map((voter) => + l2PublicClient.readContract({ + ...read, + functionName: "getDelegator", + args: [voter], + }) + ) + ), + Promise.all( + voters.map((voter) => + l2PublicClient.readContract({ + ...read, + functionName: "isRegisteredTranscoder", + args: [voter], + }) + ) + ), + Promise.all( + voters.map((voter) => + l2PublicClient.readContract({ + ...read, + functionName: "transcoderTotalStake", + args: [voter], + }) + ) + ), + Promise.all( + voters.map((voter) => + l2PublicClient.readContract({ + ...read, + functionName: "pendingStake", + args: [voter, currentRound], + }) + ) + ), + ]); + + // Delegators that voted themselves are subtracted from their delegate's + // weight, whether or not the delegate ended up voting. + const nonVoteStake = new Map(); + const tallied = voters.map((voter, index) => { + const registeredTranscoder = registrations[index]; + const delegate = getAddress(delegators[index][2]); + const isSelfDelegated = delegate === voter; + const voteStake = registeredTranscoder + ? transcoderStakes[index] + : pendingStakes[index]; + + if (!isSelfDelegated) { + nonVoteStake.set( + delegate, + (nonVoteStake.get(delegate) ?? 0n) + voteStake + ); + } + + return { + voter, + choice: choiceByVoter.get(voter) as Choice, + registeredTranscoder, + voteStake, + }; + }); + + let yes = 0n; + let no = 0n; + + const votes = tallied.map( + ({ voter, choice, registeredTranscoder, voteStake }) => { + // Only a *registered* transcoder's weight is reduced by its delegators' + // own votes — an unregistered delegate votes with its personal stake only. + const excluded = registeredTranscoder + ? nonVoteStake.get(voter) ?? 0n + : 0n; + const weight = voteStake > excluded ? voteStake - excluded : 0n; + + if (choice === "Yes") yes += weight; + else no += weight; + + return { + voter: voter.toLowerCase(), + choice, + voteStake: formatEther(voteStake), + nonVoteStake: formatEther(excluded), + }; + } + ); + + return { + poll: pollAddress.toLowerCase(), + tally: { yes: formatEther(yes), no: formatEther(no) }, + votes, + }; +}; diff --git a/lib/api/polls.ts b/lib/api/polls.ts index 12f6bb48..c5aaf419 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -1,4 +1,9 @@ -import { AVERAGE_L1_BLOCK_TIME, CHAIN_INFO, l2Provider } from "@lib/chains"; +import { + AVERAGE_L1_BLOCK_TIME, + CHAIN_INFO, + l2Provider, + l2PublicClient, +} from "@lib/chains"; import dayjs from "@lib/dayjs"; import { getApollo, @@ -10,9 +15,11 @@ import { import { ethers } from "ethers"; import fm from "front-matter"; import { catIpfsJson, IpfsPoll } from "utils/ipfs"; -import { Address } from "viem"; +import { Address, formatEther } from "viem"; import { nodeInterface } from "./abis/bridge/NodeInterface"; +import { bondingManager } from "./abis/main/BondingManager"; +import { getBondingManagerAddress } from "./contracts"; export type Fm = { title: string; @@ -169,7 +176,9 @@ const getEstimatedEndTimeByBlockNumber = async ( .unix(); }; -const getTotalStake = async (l2BlockNumber?: number | undefined) => { +const getTotalStakeFromSubgraph = async ( + l2BlockNumber?: number | undefined +) => { try { const client = getApollo(); @@ -190,15 +199,40 @@ const getTotalStake = async (l2BlockNumber?: number | undefined) => { return protocolResponse?.data?.protocol?.totalActiveStake; } catch (err) { - // The subgraph can be unavailable (e.g. an indexing halt). Total stake only - // feeds participation percentages, so degrade gracefully instead of failing - // the whole poll render — this keeps manually-listed polls viewable/votable. const detail = err instanceof Error ? err.message : String(err); console.warn(`Could not fetch total stake from subgraph (${detail})`); return undefined; } }; +const getTotalStake = async (l2BlockNumber?: number | undefined) => { + const totalActiveStake = await getTotalStakeFromSubgraph(l2BlockNumber); + + // A subgraph that is reindexing answers successfully with an empty protocol + // entity rather than failing, so fall back on a missing value too — not just + // on a thrown error. Total bonded stake is the same figure the subgraph + // tracks, except it can't be read at a past block, so participation for an + // ended poll is measured against today's stake rather than the stake at its + // end. + if (totalActiveStake) return totalActiveStake; + + try { + const bondingManagerAddress = await getBondingManagerAddress(); + + const totalBonded = await l2PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "getTotalBonded", + }); + + return formatEther(totalBonded); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + console.warn(`Could not fetch total bonded stake (${detail})`); + return undefined; + } +}; + const getL2BlockRangeForL1 = async (l1BlockNumber: number) => { try { const contract = new ethers.Contract( diff --git a/lib/api/types/get-poll-tally.ts b/lib/api/types/get-poll-tally.ts new file mode 100644 index 00000000..ca3c8403 --- /dev/null +++ b/lib/api/types/get-poll-tally.ts @@ -0,0 +1,20 @@ +/** A single voter's contribution to an on-chain computed poll tally. */ +export type PollTallyVote = { + voter: string; + choice: "Yes" | "No"; + /** Stake behind the vote, in LPT (decimal string). */ + voteStake: string; + /** Stake of this voter's delegators that voted themselves, in LPT. */ + nonVoteStake: string; +}; + +/** + * Stake-weighted poll results computed directly from chain state, mirroring the + * subgraph's tally so it can stand in for `poll.tally` while indexing is behind. + */ +export type PollTally = { + poll: string; + /** Stake-weighted totals, in LPT (decimal strings). */ + tally: { yes: string; no: string }; + votes: PollTallyVote[]; +}; diff --git a/pages/api/polls/tally/[poll].ts b/pages/api/polls/tally/[poll].ts new file mode 100644 index 00000000..c6d0da37 --- /dev/null +++ b/pages/api/polls/tally/[poll].ts @@ -0,0 +1,89 @@ +/** + * TEMPORARY stopgap: serve a poll's stake-weighted tally computed from chain + * state, for polls the subgraph has not indexed yet. Restricted to the manually + * listed polls so it can't be used to scan arbitrary contracts — remove along + * with `constants/manualPolls` once indexing recovers. + */ + +import { getCacheControlHeader } from "@lib/api"; +import { + badRequest, + internalError, + methodNotAllowed, + notFound, +} from "@lib/api/errors"; +import { getPollTally } from "@lib/api/pollTally"; +import { PollTally } from "@lib/api/types/get-poll-tally"; +import { getManualPoll } from "constants/manualPolls"; +import { NextApiRequest, NextApiResponse } from "next"; +import { Address, getAddress, isAddress } from "viem"; + +/** Recompute at most once a minute per poll, CDN in front of us or not. */ +const MEMO_TTL_MS = 60_000; + +const memo = new Map< + Address, + { expiresAt: number; tally: Promise } +>(); + +const getCachedPollTally = (pollAddress: Address, startBlock: number) => { + const cached = memo.get(pollAddress); + + if (cached && cached.expiresAt > Date.now()) { + return cached.tally; + } + + const tally = getPollTally(pollAddress, startBlock).catch((err) => { + // Don't cache failures — the next request should retry. + memo.delete(pollAddress); + throw err; + }); + + memo.set(pollAddress, { expiresAt: Date.now() + MEMO_TTL_MS, tally }); + + return tally; +}; +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + try { + const method = req.method; + + if (method !== "GET") { + methodNotAllowed(res, method ?? "unknown", ["GET"]); + return; + } + + const { poll } = req.query; + + if (!poll || Array.isArray(poll) || !isAddress(poll)) { + badRequest(res, "Invalid poll address format"); + return; + } + + const manualPoll = getManualPoll(poll); + + if (!manualPoll) { + notFound( + res, + "No manually listed poll for this address", + "Tallies are only computed on-chain for polls missing from the subgraph" + ); + return; + } + + res.setHeader("Cache-Control", getCacheControlHeader("minute")); + + const tally = await getCachedPollTally( + getAddress(poll), + manualPoll.startBlockL2 + ); + + res.status(200).json(tally); + } catch (err) { + internalError(res, err); + } +}; + +export default handler; diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index ce06159a..1b25e344 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -28,16 +28,21 @@ import { useVoteQuery, } from "apollo"; import { sentenceCase } from "change-case"; -import { getManualPoll, isManualPoll } from "constants/manualPolls"; +import { + getManualPoll, + isManualPoll, + withManualTally, +} from "constants/manualPolls"; import Head from "next/head"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; import { useAccountAddress, useCurrentRoundData, useExplorerStore, + useManualPollTally, } from "../../hooks"; import FourZeroFour from "../404"; @@ -89,21 +94,42 @@ const Poll = () => { const currentRound = useCurrentRoundData(); + // Tally computed on-chain for polls the subgraph hasn't indexed yet. + const manualTally = useManualPollTally(pollId); + useEffect(() => { const init = async () => { // Fall back to a manually-listed poll when the subgraph doesn't have it // (e.g. created while indexing was down), so it stays viewable/votable. - const source = data?.poll ?? getManualPoll(pollId); + const manualPoll = getManualPoll(pollId); + const source = data?.poll ?? manualPoll; if (source && currentRound?.currentL1Block) { const response = await getPollExtended( - source, + withManualTally(source, manualTally), currentRound.currentL1Block ); setPollData(response); } }; init(); - }, [data, pollId, currentRound?.currentL1Block]); + }, [data, pollId, currentRound?.currentL1Block, manualTally]); + + // The subgraph also backs "did I already vote?" — recover it from the + // on-chain tally while indexing is behind. + const manualVote = useMemo(() => { + const vote = manualTally?.votes.find( + (v) => v.voter === accountAddress?.toLowerCase() + ); + + return vote + ? { + __typename: "Vote" as const, + choiceID: vote.choice as PollChoice, + voteStake: vote.voteStake, + nonVoteStake: vote.nonVoteStake, + } + : undefined; + }, [manualTally, accountAddress]); // Only 404 on a genuine subgraph error with no manual fallback for this poll. if (pollError && !getManualPoll(pollId)) { @@ -228,9 +254,9 @@ const Poll = () => { }} > - Live vote counts aren't available for this poll yet — - the subgraph is still indexing it. The totals below may read - 0 until indexing catches up. Voting works normally. + The subgraph is still indexing this poll, so the totals + below are computed directly from chain state and refresh + about once a minute. Voting works normally. )} @@ -400,7 +426,7 @@ const Poll = () => { } | undefined | null, - vote: voteData?.vote as + vote: (voteData?.vote ?? manualVote) as | { __typename: "Vote"; choiceID?: PollChoice; @@ -427,7 +453,7 @@ const Poll = () => { } | undefined | null, - vote: voteData?.vote as + vote: (voteData?.vote ?? manualVote) as | { __typename: "Vote"; choiceID?: PollChoice;