From 1a93ae86f74b1d202181af5764f8ffc347b59e6d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 17:08:36 +0200 Subject: [PATCH 1/5] fix: compute LIP-118 vote tally on-chain while the subgraph is behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voting UI takes its stake-weighted counts from the subgraph, so the manually surfaced LIP-118 poll (#734) renders 0% support and 0% participation — the poll is reachable and votable, but shows no results. Add /api/polls/tally/[poll], which recomputes a poll's tally from chain state: read the Poll contract's Vote logs, then weight each voter through BondingManager, mirroring the subgraph's rules (a registered transcoder votes with its total stake; a delegator that votes itself is subtracted from its delegate's weight; latest vote per address wins). The route is restricted to MANUAL_POLLS so it can't be pointed at arbitrary contracts. Kept cheap on the RPC: log scans are chunked under Infura's 10k block range cap, the scan cursor advances so only new blocks are read after the first pass, and results are memoised for 60s in-process as well as at the CDN. Steady state is one getLogs plus a batched round of contract reads per minute. getTotalStake now falls back to BondingManager.getTotalBonded() when the subgraph is unavailable — the protocol entity is currently null, which would otherwise leave participation at 0%. The poll page also recovers "My Vote" from the computed tally, so a voter isn't told N/A and prompted to vote a second time. Tallies read stake as it is now, which matches the subgraph while a poll is open but drifts once one closes; pinning reads to the end block needs NodeInterface.l2BlockRangeForL1, which reverts intermittently for the same input, so that is documented rather than attempted. Revert once the subgraph has reindexed the poll. Co-Authored-By: Claude Opus 4.8 (1M context) --- constants/manualPolls.ts | 41 ++++- hooks/useSwr.tsx | 17 ++ lib/api/pollTally.ts | 270 ++++++++++++++++++++++++++++++++ lib/api/polls.ts | 40 ++++- lib/api/types/get-poll-tally.ts | 25 +++ pages/api/polls/tally/[poll].ts | 89 +++++++++++ pages/voting/[poll].tsx | 46 ++++-- 7 files changed, 506 insertions(+), 22 deletions(-) create mode 100644 lib/api/pollTally.ts create mode 100644 lib/api/types/get-poll-tally.ts create mode 100644 pages/api/polls/tally/[poll].ts diff --git a/constants/manualPolls.ts b/constants/manualPolls.ts index 5585421b..edbcce92 100644 --- a/constants/manualPolls.ts +++ b/constants/manualPolls.ts @@ -1,3 +1,4 @@ +import { PollTally } from "@lib/api/types/get-poll-tally"; import { PollsQuery } from "apollo"; /** @@ -7,15 +8,20 @@ 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. */ 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 +33,7 @@ export const MANUAL_POLLS: SubgraphPoll[] = [ quota: "500000", tally: null, votes: [], + startBlockL2: 485384564, }, ]; @@ -35,7 +42,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 +63,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..1a53a2f9 --- /dev/null +++ b/lib/api/pollTally.ts @@ -0,0 +1,270 @@ +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, totalBonded, choiceByVoter] = await Promise.all([ + l2PublicClient.readContract({ + address: roundsManagerAddress, + abi: roundsManager, + functionName: "currentRound", + }), + l2PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "getTotalBonded", + }), + 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 = isSelfDelegated + ? 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; + + if (choice === "Yes") yes += weight; + else no += weight; + + return { + voter: voter.toLowerCase(), + choice, + voteStake: formatEther(voteStake), + nonVoteStake: formatEther(excluded), + registeredTranscoder, + }; + } + ); + + return { + poll: pollAddress.toLowerCase(), + tally: { yes: formatEther(yes), no: formatEther(no) }, + totalStake: formatEther(totalBonded), + votes, + updatedAt: Math.floor(Date.now() / 1000), + }; +}; diff --git a/lib/api/polls.ts b/lib/api/polls.ts index 12f6bb48..fce50ffb 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; @@ -190,12 +197,31 @@ 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. + // The subgraph can be unavailable (e.g. an indexing halt). Fall back to the + // current total bonded stake on-chain — the same value 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. const detail = err instanceof Error ? err.message : String(err); - console.warn(`Could not fetch total stake from subgraph (${detail})`); - return undefined; + console.warn( + `Could not fetch total stake from subgraph, falling back to chain (${detail})` + ); + + try { + const bondingManagerAddress = await getBondingManagerAddress(); + + const totalBonded = await l2PublicClient.readContract({ + address: bondingManagerAddress, + abi: bondingManager, + functionName: "getTotalBonded", + }); + + return formatEther(totalBonded); + } catch (chainErr) { + const chainDetail = + chainErr instanceof Error ? chainErr.message : String(chainErr); + console.warn(`Could not fetch total bonded stake (${chainDetail})`); + return undefined; + } } }; diff --git a/lib/api/types/get-poll-tally.ts b/lib/api/types/get-poll-tally.ts new file mode 100644 index 00000000..e87f32ef --- /dev/null +++ b/lib/api/types/get-poll-tally.ts @@ -0,0 +1,25 @@ +/** 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; + registeredTranscoder: boolean; +}; + +/** + * 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 }; + /** Total bonded stake used for participation, in LPT. */ + totalStake: string; + votes: PollTallyVote[]; + /** Unix seconds the tally was computed at. */ + updatedAt: number; +}; diff --git a/pages/api/polls/tally/[poll].ts b/pages/api/polls/tally/[poll].ts new file mode 100644 index 00000000..b0bde31d --- /dev/null +++ b/pages/api/polls/tally/[poll].ts @@ -0,0 +1,89 @@ +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"; + +/** + * 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. + */ + +/** 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..bece8f09 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; From 8116b803b3c73ae9d71b241987a6a34ecae1241d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 18:41:02 +0200 Subject: [PATCH 2/5] fix: fall back to on-chain stake when the subgraph returns no protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reindexing subgraph answers successfully with an empty protocol entity rather than failing, so getTotalStake returned undefined and left participation at 0% — the symptom the on-chain fallback was added to fix. Split the subgraph read out and branch on the value, so the fallback also covers a successful response with nothing in it. Drop totalStake, updatedAt and registeredTranscoder from the tally response: nothing reads them, and totalStake in particular is misleading since participation takes its denominator from getTotalStake. Removing it also drops the getTotalBonded read that fed it. Co-Authored-By: Claude Opus 4.8 (1M context) --- constants/manualPolls.ts | 7 +++-- lib/api/pollTally.ts | 10 +----- lib/api/polls.ts | 56 +++++++++++++++++++-------------- lib/api/types/get-poll-tally.ts | 5 --- pages/api/polls/tally/[poll].ts | 14 ++++----- 5 files changed, 44 insertions(+), 48 deletions(-) diff --git a/constants/manualPolls.ts b/constants/manualPolls.ts index edbcce92..67e119b8 100644 --- a/constants/manualPolls.ts +++ b/constants/manualPolls.ts @@ -1,6 +1,3 @@ -import { PollTally } from "@lib/api/types/get-poll-tally"; -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 @@ -14,6 +11,10 @@ import { PollsQuery } from "apollo"; * * 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 & { diff --git a/lib/api/pollTally.ts b/lib/api/pollTally.ts index 1a53a2f9..fe12461d 100644 --- a/lib/api/pollTally.ts +++ b/lib/api/pollTally.ts @@ -149,17 +149,12 @@ export const getPollTally = async ( l2PublicClient.getBlockNumber(), ]); - const [currentRound, totalBonded, choiceByVoter] = await Promise.all([ + const [currentRound, choiceByVoter] = await Promise.all([ l2PublicClient.readContract({ address: roundsManagerAddress, abi: roundsManager, functionName: "currentRound", }), - l2PublicClient.readContract({ - address: bondingManagerAddress, - abi: bondingManager, - functionName: "getTotalBonded", - }), getChoiceByVoter(pollAddress, BigInt(startBlock), latestBlock), ]); @@ -255,7 +250,6 @@ export const getPollTally = async ( choice, voteStake: formatEther(voteStake), nonVoteStake: formatEther(excluded), - registeredTranscoder, }; } ); @@ -263,8 +257,6 @@ export const getPollTally = async ( return { poll: pollAddress.toLowerCase(), tally: { yes: formatEther(yes), no: formatEther(no) }, - totalStake: formatEther(totalBonded), votes, - updatedAt: Math.floor(Date.now() / 1000), }; }; diff --git a/lib/api/polls.ts b/lib/api/polls.ts index fce50ffb..c5aaf419 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -176,7 +176,9 @@ const getEstimatedEndTimeByBlockNumber = async ( .unix(); }; -const getTotalStake = async (l2BlockNumber?: number | undefined) => { +const getTotalStakeFromSubgraph = async ( + l2BlockNumber?: number | undefined +) => { try { const client = getApollo(); @@ -197,31 +199,37 @@ const getTotalStake = async (l2BlockNumber?: number | undefined) => { return protocolResponse?.data?.protocol?.totalActiveStake; } catch (err) { - // The subgraph can be unavailable (e.g. an indexing halt). Fall back to the - // current total bonded stake on-chain — the same value 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. const detail = err instanceof Error ? err.message : String(err); - console.warn( - `Could not fetch total stake from subgraph, falling back to chain (${detail})` - ); + console.warn(`Could not fetch total stake from subgraph (${detail})`); + return undefined; + } +}; - try { - const bondingManagerAddress = await getBondingManagerAddress(); - - const totalBonded = await l2PublicClient.readContract({ - address: bondingManagerAddress, - abi: bondingManager, - functionName: "getTotalBonded", - }); - - return formatEther(totalBonded); - } catch (chainErr) { - const chainDetail = - chainErr instanceof Error ? chainErr.message : String(chainErr); - console.warn(`Could not fetch total bonded stake (${chainDetail})`); - 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; } }; diff --git a/lib/api/types/get-poll-tally.ts b/lib/api/types/get-poll-tally.ts index e87f32ef..ca3c8403 100644 --- a/lib/api/types/get-poll-tally.ts +++ b/lib/api/types/get-poll-tally.ts @@ -6,7 +6,6 @@ export type PollTallyVote = { voteStake: string; /** Stake of this voter's delegators that voted themselves, in LPT. */ nonVoteStake: string; - registeredTranscoder: boolean; }; /** @@ -17,9 +16,5 @@ export type PollTally = { poll: string; /** Stake-weighted totals, in LPT (decimal strings). */ tally: { yes: string; no: string }; - /** Total bonded stake used for participation, in LPT. */ - totalStake: string; votes: PollTallyVote[]; - /** Unix seconds the tally was computed at. */ - updatedAt: number; }; diff --git a/pages/api/polls/tally/[poll].ts b/pages/api/polls/tally/[poll].ts index b0bde31d..c6d0da37 100644 --- a/pages/api/polls/tally/[poll].ts +++ b/pages/api/polls/tally/[poll].ts @@ -1,3 +1,10 @@ +/** + * 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, @@ -11,13 +18,6 @@ import { getManualPoll } from "constants/manualPolls"; import { NextApiRequest, NextApiResponse } from "next"; import { Address, getAddress, isAddress } from "viem"; -/** - * 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. - */ - /** Recompute at most once a minute per poll, CDN in front of us or not. */ const MEMO_TTL_MS = 60_000; From 21b158d205ffd4363f63c42a7887ce8041fdbcc4 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 19:12:55 +0200 Subject: [PATCH 3/5] style: run prettier on the poll page Co-Authored-By: Claude Opus 4.8 (1M context) --- pages/voting/[poll].tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index bece8f09..1b25e344 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -254,9 +254,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. + 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. )} From ffb174b875683b0e94766521f1b0bd40bfea3f6f Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 19:13:05 +0200 Subject: [PATCH 4/5] fix: weight votes by transcoder registration, not self-delegation The documented rule gives a registered transcoder its total delegated stake and everyone else their own pending stake. Selecting on self-delegation matches that only because isRegisteredTranscoder is defined as self-delegated with a non-zero bonded amount. Where the two diverge is an orchestrator that unbonded its own stake while delegators remain: still self-delegated, no longer registered, and holding delegated stake it would otherwise vote with. Self-delegation still decides whether a vote is subtracted from a delegate's weight, which is a question about delegating elsewhere rather than registration. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Copilot --- lib/api/pollTally.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/pollTally.ts b/lib/api/pollTally.ts index fe12461d..bc6d8951 100644 --- a/lib/api/pollTally.ts +++ b/lib/api/pollTally.ts @@ -211,7 +211,7 @@ export const getPollTally = async ( const registeredTranscoder = registrations[index]; const delegate = getAddress(delegators[index][2]); const isSelfDelegated = delegate === voter; - const voteStake = isSelfDelegated + const voteStake = registeredTranscoder ? transcoderStakes[index] : pendingStakes[index]; From e7c753b2760f6497e715d5cbf68e29ff5ca65010 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 19:26:20 +0200 Subject: [PATCH 5/5] fix: clamp vote weight at zero transcoderTotalStake reads the transcoder pool, which returns zero for an orchestrator outside it, while its delegators' pending stake is still subtracted. The resulting negative weight was added to the yes or no total, reducing it by the stake of everyone who voted. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Copilot --- lib/api/pollTally.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/pollTally.ts b/lib/api/pollTally.ts index bc6d8951..b7dcbf1c 100644 --- a/lib/api/pollTally.ts +++ b/lib/api/pollTally.ts @@ -240,7 +240,7 @@ export const getPollTally = async ( const excluded = registeredTranscoder ? nonVoteStake.get(voter) ?? 0n : 0n; - const weight = voteStake - excluded; + const weight = voteStake > excluded ? voteStake - excluded : 0n; if (choice === "Yes") yes += weight; else no += weight;