diff --git a/constants/manualPolls.ts b/constants/manualPolls.ts index 67e119b8..5585421b 100644 --- a/constants/manualPolls.ts +++ b/constants/manualPolls.ts @@ -1,3 +1,5 @@ +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 @@ -5,24 +7,15 @@ * 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. * - * 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. + * 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. * * 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]; -type ManualPoll = SubgraphPoll & { - /** L2 block the poll was created in — lower bound for on-chain vote lookups. */ - startBlockL2: number; -}; - -export const MANUAL_POLLS: ManualPoll[] = [ +export const MANUAL_POLLS: SubgraphPoll[] = [ { // LIP-118 "Delegated Reward Calling" — created during the subgraph indexing halt. // tx: 0xb8ab2a824b4a5b16b76be99f1313a8238f35acc2259b139562277e1df8ba02e4 @@ -34,7 +27,6 @@ export const MANUAL_POLLS: ManualPoll[] = [ quota: "500000", tally: null, votes: [], - startBlockL2: 485384564, }, ]; @@ -43,7 +35,7 @@ const norm = (id: string) => id.toLowerCase(); /** The manually-listed poll for `id`, if any. */ export const getManualPoll = ( id: string | undefined | null -): ManualPoll | undefined => +): SubgraphPoll | 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). */ @@ -64,27 +56,3 @@ 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 c90c95af..720b6e91 100644 --- a/hooks/useSwr.tsx +++ b/hooks/useSwr.tsx @@ -14,7 +14,6 @@ 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"; @@ -25,7 +24,6 @@ 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"; @@ -129,21 +127,6 @@ 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 deleted file mode 100644 index b7dcbf1c..00000000 --- a/lib/api/pollTally.ts +++ /dev/null @@ -1,262 +0,0 @@ -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 c5aaf419..12f6bb48 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -1,9 +1,4 @@ -import { - AVERAGE_L1_BLOCK_TIME, - CHAIN_INFO, - l2Provider, - l2PublicClient, -} from "@lib/chains"; +import { AVERAGE_L1_BLOCK_TIME, CHAIN_INFO, l2Provider } from "@lib/chains"; import dayjs from "@lib/dayjs"; import { getApollo, @@ -15,11 +10,9 @@ import { import { ethers } from "ethers"; import fm from "front-matter"; import { catIpfsJson, IpfsPoll } from "utils/ipfs"; -import { Address, formatEther } from "viem"; +import { Address } from "viem"; import { nodeInterface } from "./abis/bridge/NodeInterface"; -import { bondingManager } from "./abis/main/BondingManager"; -import { getBondingManagerAddress } from "./contracts"; export type Fm = { title: string; @@ -176,9 +169,7 @@ const getEstimatedEndTimeByBlockNumber = async ( .unix(); }; -const getTotalStakeFromSubgraph = async ( - l2BlockNumber?: number | undefined -) => { +const getTotalStake = async (l2BlockNumber?: number | undefined) => { try { const client = getApollo(); @@ -199,40 +190,15 @@ const getTotalStakeFromSubgraph = async ( 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 deleted file mode 100644 index ca3c8403..00000000 --- a/lib/api/types/get-poll-tally.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** 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 deleted file mode 100644 index c6d0da37..00000000 --- a/pages/api/polls/tally/[poll].ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * 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 1b25e344..ce06159a 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -28,21 +28,16 @@ import { useVoteQuery, } from "apollo"; import { sentenceCase } from "change-case"; -import { - getManualPoll, - isManualPoll, - withManualTally, -} from "constants/manualPolls"; +import { getManualPoll, isManualPoll } from "constants/manualPolls"; import Head from "next/head"; import { useRouter } from "next/router"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useWindowSize } from "react-use"; import { useAccountAddress, useCurrentRoundData, useExplorerStore, - useManualPollTally, } from "../../hooks"; import FourZeroFour from "../404"; @@ -94,42 +89,21 @@ 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 manualPoll = getManualPoll(pollId); - const source = data?.poll ?? manualPoll; + const source = data?.poll ?? getManualPoll(pollId); if (source && currentRound?.currentL1Block) { const response = await getPollExtended( - withManualTally(source, manualTally), + source, currentRound.currentL1Block ); setPollData(response); } }; init(); - }, [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]); + }, [data, pollId, currentRound?.currentL1Block]); // Only 404 on a genuine subgraph error with no manual fallback for this poll. if (pollError && !getManualPoll(pollId)) { @@ -254,9 +228,9 @@ const Poll = () => { }} > - 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. + 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. )} @@ -426,7 +400,7 @@ const Poll = () => { } | undefined | null, - vote: (voteData?.vote ?? manualVote) as + vote: voteData?.vote as | { __typename: "Vote"; choiceID?: PollChoice; @@ -453,7 +427,7 @@ const Poll = () => { } | undefined | null, - vote: (voteData?.vote ?? manualVote) as + vote: voteData?.vote as | { __typename: "Vote"; choiceID?: PollChoice;