From 74b746495fe4b23642708d7d0205a3d93c31e38e Mon Sep 17 00:00:00 2001 From: Moudi Network Date: Tue, 30 Jun 2026 19:00:25 +0000 Subject: [PATCH 01/18] feat: add delegators view Adds a Delegators tab to the account layout listing an orchestrator's delegators, backed by a new orchestratorDelegators subgraph query. --- apollo/subgraph.ts | 138 ++++++++++ components/DelegatorList/index.tsx | 338 ++++++++++++++++++++++++ components/DelegatorsView/index.tsx | 32 +++ layouts/account.tsx | 19 +- pages/accounts/[account]/delegators.tsx | 100 +++++++ queries/orchestratorDelegators.graphql | 21 ++ 6 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 components/DelegatorList/index.tsx create mode 100644 components/DelegatorsView/index.tsx create mode 100644 pages/accounts/[account]/delegators.tsx create mode 100644 queries/orchestratorDelegators.graphql diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 74599ced..5bbf22d8 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -1648,6 +1648,23 @@ export enum LivepeerAccount_OrderBy { LastUpdatedTimestamp = 'lastUpdatedTimestamp' } +/** + * The severity level of a log entry. + * Log levels are ordered from most to least severe: CRITICAL > ERROR > WARNING > INFO > DEBUG + */ +export enum LogLevel { + /** Critical errors that require immediate attention */ + Critical = 'CRITICAL', + /** Detailed diagnostic information for debugging */ + Debug = 'DEBUG', + /** Error conditions that indicate a failure */ + Error = 'ERROR', + /** Informational messages about normal operations */ + Info = 'INFO', + /** Warning conditions that may require attention */ + Warning = 'WARNING' +} + /** MigrateDelegatorFinalizedEvent entities are created for every emitted WithdrawStake event. */ export type MigrateDelegatorFinalizedEvent = Event & { __typename: 'MigrateDelegatorFinalizedEvent'; @@ -3331,6 +3348,8 @@ export enum Protocol_OrderBy { export type Query = { __typename: 'Query'; + /** Query execution logs emitted by the subgraph during indexing. Results are sorted by timestamp in descending order (newest first). */ + _logs: Array<_Log_>; /** Access to subgraph metadata */ _meta?: Maybe<_Meta_>; bondEvent?: Maybe; @@ -3436,6 +3455,17 @@ export type Query = { }; +export type Query_LogsArgs = { + first?: InputMaybe; + from?: InputMaybe; + level?: InputMaybe; + orderDirection?: InputMaybe; + search?: InputMaybe; + skip?: InputMaybe; + to?: InputMaybe; +}; + + export type Query_MetaArgs = { block?: InputMaybe; }; @@ -9579,6 +9609,54 @@ export type _Block_ = { timestamp?: Maybe; }; +/** + * A key-value pair of additional data associated with a log entry. + * These correspond to arguments passed to the log function in the subgraph code. + */ +export type _LogArgument_ = { + __typename: '_LogArgument_'; + /** The parameter name */ + key: Scalars['String']; + /** The parameter value, serialized as a string */ + value: Scalars['String']; +}; + +/** + * Source code location metadata for a log entry. + * Indicates where in the subgraph's AssemblyScript code the log statement was executed. + */ +export type _LogMeta_ = { + __typename: '_LogMeta_'; + /** The column number in the source file */ + column: Scalars['Int']; + /** The line number in the source file */ + line: Scalars['Int']; + /** The module or file path where the log was emitted */ + module: Scalars['String']; +}; + +/** + * A log entry emitted by a subgraph during indexing. + * Logs can be generated by the subgraph's AssemblyScript code using the `log.*` functions. + */ +export type _Log_ = { + __typename: '_Log_'; + /** Additional structured data passed to the log function as key-value pairs */ + arguments: Array<_LogArgument_>; + /** Unique identifier for this log entry */ + id: Scalars['String']; + /** The severity level of the log entry */ + level: LogLevel; + /** Metadata about the source location in the subgraph code where the log was emitted */ + meta: _LogMeta_; + /** The deployment hash of the subgraph that emitted this log */ + subgraphId: Scalars['String']; + /** The log message text */ + text: Scalars['String']; + /** The timestamp when the log was emitted, in RFC3339 format (e.g., '2024-01-15T10:30:00Z') */ + timestamp: Scalars['String']; +}; + /** The type for the top-level _meta field */ export type _Meta_ = { __typename: '_Meta_'; @@ -9658,6 +9736,17 @@ export type MetaQueryVariables = Exact<{ [key: string]: never; }>; export type MetaQuery = { __typename: 'Query', _meta?: { __typename: '_Meta_', hasIndexingErrors: boolean } | null }; +export type OrchestratorDelegatorsQueryVariables = Exact<{ + id: Scalars['ID']; + first?: InputMaybe; + skip?: InputMaybe; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; +}>; + + +export type OrchestratorDelegatorsQuery = { __typename: 'Query', transcoder?: { __typename: 'Transcoder', id: string, delegators?: Array<{ __typename: 'Delegator', id: string, bondedAmount: string, startRound: string }> | null } | null }; + export type OrchestratorsQueryVariables = Exact<{ currentRound?: InputMaybe; currentRoundString?: InputMaybe; @@ -10341,6 +10430,55 @@ export function useMetaLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type MetaLazyQueryHookResult = ReturnType; export type MetaQueryResult = Apollo.QueryResult; +export const OrchestratorDelegatorsDocument = gql` + query orchestratorDelegators($id: ID!, $first: Int, $skip: Int, $orderBy: Delegator_orderBy, $orderDirection: OrderDirection) { + transcoder(id: $id) { + id + delegators( + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: $orderDirection + ) { + id + bondedAmount + startRound + } + } +} + `; + +/** + * __useOrchestratorDelegatorsQuery__ + * + * To run a query within a React component, call `useOrchestratorDelegatorsQuery` and pass it any options that fit your needs. + * When your component renders, `useOrchestratorDelegatorsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useOrchestratorDelegatorsQuery({ + * variables: { + * id: // value for 'id' + * first: // value for 'first' + * skip: // value for 'skip' + * orderBy: // value for 'orderBy' + * orderDirection: // value for 'orderDirection' + * }, + * }); + */ +export function useOrchestratorDelegatorsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(OrchestratorDelegatorsDocument, options); + } +export function useOrchestratorDelegatorsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(OrchestratorDelegatorsDocument, options); + } +export type OrchestratorDelegatorsQueryHookResult = ReturnType; +export type OrchestratorDelegatorsLazyQueryHookResult = ReturnType; +export type OrchestratorDelegatorsQueryResult = Apollo.QueryResult; export const OrchestratorsDocument = gql` query orchestrators($currentRound: BigInt, $currentRoundString: String, $where: Transcoder_filter, $first: Int, $skip: Int, $orderBy: Transcoder_orderBy, $orderDirection: OrderDirection) { transcoders( diff --git a/components/DelegatorList/index.tsx b/components/DelegatorList/index.tsx new file mode 100644 index 00000000..31ad08d2 --- /dev/null +++ b/components/DelegatorList/index.tsx @@ -0,0 +1,338 @@ +import { ExplorerTooltip } from "@components/ExplorerTooltip"; +import IdentityAvatar from "@components/IdentityAvatar"; +import PopoverLink from "@components/PopoverLink"; +import Table from "@components/Table"; +import { textTruncate } from "@lib/utils"; +import { + Badge, + Box, + Flex, + IconButton, + Link as A, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { DotsHorizontalIcon } from "@radix-ui/react-icons"; +import { formatStakeAmount } from "@utils/numberFormatters"; +import { OrchestratorDelegatorsQuery } from "apollo"; +import { useEnsData, usePendingFeesAndStakeData } from "hooks"; +import Link from "next/link"; +import { useMemo } from "react"; +import { Column, Row } from "react-table"; + +type Delegator = NonNullable< + NonNullable["delegators"] +>[number]; + +type DelegatorRow = Delegator & { + bondedAmountNumber: number; + startRoundNumber: number; +}; + +// Compare the already-numeric row values directly. react-table's built-in +// "number" sortType stringifies each value and strips non-numeric chars, which +// mangles values rendered in exponential notation (e.g. 9.4e-7 -> "9.47"), +// scattering sub-1e-6 "dust" amounts into the middle of the list. +const numericSort = ( + rowA: Row, + rowB: Row, + columnId: string +) => + (Number(rowA.values[columnId]) || 0) - (Number(rowB.values[columnId]) || 0); + +const DelegatorList = ({ + data, + pageSize = 10, +}: { + pageSize?: number; + data: + | NonNullable["delegators"] + | undefined; +}) => { + const rows: DelegatorRow[] = useMemo( + () => + (data ?? []).map((delegator) => ({ + ...delegator, + bondedAmountNumber: Number(delegator?.bondedAmount ?? 0), + startRoundNumber: Number(delegator?.startRound ?? 0), + })), + [data] + ); + + const columns: Column[] = useMemo( + () => [ + { + Header: ( + + An account that has delegated stake to this orchestrator. + + } + > + Delegator + + ), + accessor: "id", + Cell: ({ row, value }) => { + const address = value as string; + const identity = useEnsData(address); + const ensName = identity?.name; + const shortAddress = address.replace(address.slice(6, 38), "…"); + + return ( + + + + {row.index + 1} + + + + {ensName ? ( + + + {textTruncate(ensName, 20, "…")} + + + {address.substring(0, 6)} + + + ) : ( + {shortAddress} + )} + + + + ); + }, + }, + { + Header: ( + + The amount of stake this account has bonded to the orchestrator, + excluding rewards not yet claimed (see Pending Rewards). + + } + > + Stake + + ), + accessor: "bondedAmountNumber", + Cell: ({ value }) => ( + + {formatStakeAmount(Number(value ?? 0))} + + ), + sortType: numericSort, + }, + { + Header: ( + + Rewards earned from this orchestrator that have not been claimed + yet. Stake + Pending Rewards equals the account's current + total stake. + + } + > + Pending Rewards + + ), + // Live pendingStake is fetched per row (on-screen only), so this column + // has no row-model value and cannot be globally sorted. + id: "pendingRewards", + disableSortBy: true, + Cell: ({ row }) => { + const { id, bondedAmountNumber } = row.original; + const data = usePendingFeesAndStakeData(id); + // Pending rewards = live total stake − bonded amount (never negative). + const rewards = data + ? Math.max(0, Number(data.pendingStake) / 1e18 - bondedAmountNumber) + : undefined; + + return ( + + {rewards === undefined ? "—" : formatStakeAmount(rewards)} + + ); + }, + }, + { + Header: ( + The round this account became bonded.} + > + Start Round + + ), + accessor: "startRoundNumber", + Cell: ({ value }) => ( + + {value ? Number(value) : "N/A"} + + ), + sortType: numericSort, + }, + { + Header: <>, + id: "actions", + Cell: ({ row }) => ( + + { + e.stopPropagation(); + }} + asChild + > + + + + + + + { + e.stopPropagation(); + }} + > + + + Account Details + + + + Profile + + + History + + + + + ), + }, + ], + [] + ); + + if (!rows?.length) { + return ( + + No delegators found. + + ); + } + + return ( + + ); +}; + +export default DelegatorList; diff --git a/components/DelegatorsView/index.tsx b/components/DelegatorsView/index.tsx new file mode 100644 index 00000000..8fa865f4 --- /dev/null +++ b/components/DelegatorsView/index.tsx @@ -0,0 +1,32 @@ +import DelegatorList from "@components/DelegatorList"; +import { Box } from "@livepeer/design-system"; +import { + AccountQueryResult, + Delegator_OrderBy, + OrderDirection, + useOrchestratorDelegatorsQuery, +} from "apollo"; + +interface Props { + transcoder?: NonNullable["transcoder"]; +} + +const DelegatorsView = ({ transcoder }: Props) => { + const { data } = useOrchestratorDelegatorsQuery({ + variables: { + id: transcoder?.id ?? "", + first: 1000, + orderBy: Delegator_OrderBy.BondedAmount, + orderDirection: OrderDirection.Desc, + }, + skip: !transcoder?.id, + }); + + return ( + + + + ); +}; + +export default DelegatorsView; diff --git a/layouts/account.tsx b/layouts/account.tsx index 51774a37..0abcbcdc 100644 --- a/layouts/account.tsx +++ b/layouts/account.tsx @@ -1,5 +1,6 @@ import BottomDrawer from "@components/BottomDrawer"; import BroadcastingView from "@components/BroadcastingView"; +import DelegatorsView from "@components/DelegatorsView"; import HistoryView from "@components/HistoryView"; import HorizontalScrollContainer from "@components/HorizontalScrollContainer"; import OrchestratingView from "@components/OrchestratingView"; @@ -47,11 +48,17 @@ export interface TabType { isActive?: boolean; } -type TabTypeEnum = "delegating" | "orchestrating" | "history" | "broadcasting"; +type TabTypeEnum = + | "delegating" + | "orchestrating" + | "delegators" + | "history" + | "broadcasting"; const ACCOUNT_VIEWS: TabTypeEnum[] = [ "delegating", "orchestrating", + "delegators", "broadcasting", "history", ]; @@ -310,6 +317,9 @@ const AccountLayout = ({ transcoder={viewedAccount?.transcoder} /> )} + {view === "delegators" && ( + + )} {view === "delegating" && ( { + if (hadError) { + return ; + } + + return ( + + ); +}; + +Delegators.getLayout = getLayout; + +export const getStaticPaths = async () => { + const { sortedOrchestrators } = await getSortedOrchestrators(); + + return { + paths: + sortedOrchestrators?.data?.transcoders?.map((t) => ({ + params: { account: t.id }, + })) ?? [], + fallback: "blocking", + }; +}; + +export const getStaticProps = async (context: { + params: { account: string }; +}) => { + try { + const accountId = context.params?.account?.toString().toLowerCase(); + + // 404 only on malformed addresses; a valid address with no on-chain activity still + // renders the empty-state account page. + if (!accountId || !isAddress(accountId)) { + return { notFound: true }; + } + + const client = getApollo(); + const { account, fallback } = await getAccount(client, accountId); + + // If we couldn't fetch account data, treat it as a temporary error + if (!account.data) { + throw new Error("Failed to fetch account data"); + } + + const { sortedOrchestrators, fallback: sortedOrchestratorsFallback } = + await getSortedOrchestrators(client); + + // If we couldn't fetch orchestrators data, treat it as a temporary error + if (!sortedOrchestrators.data) { + throw new Error("Failed to fetch orchestrators data"); + } + + const props: PageProps = { + hadError: false, + account: account.data, + sortedOrchestrators: sortedOrchestrators.data, + fallback: { + ...sortedOrchestratorsFallback, + ...fallback, + }, + }; + + return { + props, + revalidate: 600, + }; + } catch (e) { + console.log(e); + return { + props: { + hadError: true, + }, + revalidate: 60, + }; + } +}; + +export default Delegators; diff --git a/queries/orchestratorDelegators.graphql b/queries/orchestratorDelegators.graphql new file mode 100644 index 00000000..c4def0fd --- /dev/null +++ b/queries/orchestratorDelegators.graphql @@ -0,0 +1,21 @@ +query orchestratorDelegators( + $id: ID! + $first: Int + $skip: Int + $orderBy: Delegator_orderBy + $orderDirection: OrderDirection +) { + transcoder(id: $id) { + id + delegators( + first: $first + skip: $skip + orderBy: $orderBy + orderDirection: $orderDirection + ) { + id + bondedAmount + startRound + } + } +} From c04812d623273361540d55e5d6be2b36e9e3f8bb Mon Sep 17 00:00:00 2001 From: Moudi Network Date: Wed, 1 Jul 2026 08:28:50 +0000 Subject: [PATCH 02/18] fix table width --- components/DelegatorList/index.tsx | 7 ++++--- components/Table/index.tsx | 4 +++- layouts/account.tsx | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/components/DelegatorList/index.tsx b/components/DelegatorList/index.tsx index 31ad08d2..1a962c7b 100644 --- a/components/DelegatorList/index.tsx +++ b/components/DelegatorList/index.tsx @@ -89,7 +89,7 @@ const DelegatorList = ({ href={`/accounts/${address}/delegating`} passHref css={{ - width: 350, + width: 300, display: "block", textDecoration: "none", "&:hover": { textDecoration: "none" }, @@ -185,7 +185,7 @@ const DelegatorList = ({ } > - Pending Rewards + Pending Rewards ), // Live pendingStake is fetched per row (on-screen only), so this column @@ -219,7 +219,7 @@ const DelegatorList = ({ multiline content={The round this account became bonded.} > - Start Round + Start Round ), accessor: "startRoundNumber", @@ -326,6 +326,7 @@ const DelegatorList = ({
({ data, columns, initialState = {}, + minWidth = 960, }: { heading?: ReactNode; input?: ReactNode; data: T[]; columns: Column[]; initialState: object; + minWidth?: number; }) { const { getTableProps, @@ -95,7 +97,7 @@ function DataTable({ css={{ borderCollapse: "collapse", tableLayout: "auto", - minWidth: 960, + minWidth, width: "100%", "@bp4": { width: "100%", diff --git a/layouts/account.tsx b/layouts/account.tsx index 0abcbcdc..89daac5e 100644 --- a/layouts/account.tsx +++ b/layouts/account.tsx @@ -205,6 +205,10 @@ const AccountLayout = ({ paddingRight: 0, paddingTop: "$4", width: "100%", + // Allow this column to shrink to its flex share instead of being + // held open by wide content (e.g. the delegators table's minWidth), + // which would otherwise squeeze the side widget. + minWidth: 0, "@bp3": { paddingTop: "$6", paddingRight: "$7", From 426f232c0844ce08324f5518b64d4fba8c42c383 Mon Sep 17 00:00:00 2001 From: Moudi Network Date: Wed, 1 Jul 2026 22:31:58 +0000 Subject: [PATCH 03/18] apply coderabbit pr review recommendations --- components/DelegatorsView/index.tsx | 93 +++++++++++++++++++++++-- pages/accounts/[account]/delegators.tsx | 6 ++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/components/DelegatorsView/index.tsx b/components/DelegatorsView/index.tsx index 8fa865f4..ba152c81 100644 --- a/components/DelegatorsView/index.tsx +++ b/components/DelegatorsView/index.tsx @@ -1,30 +1,115 @@ import DelegatorList from "@components/DelegatorList"; -import { Box } from "@livepeer/design-system"; +import Spinner from "@components/Spinner"; +import { Box, Flex, Text } from "@livepeer/design-system"; import { AccountQueryResult, Delegator_OrderBy, OrderDirection, useOrchestratorDelegatorsQuery, } from "apollo"; +import { useCallback, useEffect, useRef } from "react"; + +// The subgraph caps `delegators(first:)` at 1000 rows per request, so a single +// query silently drops delegators beyond the first 1000. Page through with +// `skip` until a short page comes back to load the complete list. +const PAGE_SIZE = 1000; interface Props { transcoder?: NonNullable["transcoder"]; } const DelegatorsView = ({ transcoder }: Props) => { - const { data } = useOrchestratorDelegatorsQuery({ + const { data, loading, error, fetchMore } = useOrchestratorDelegatorsQuery({ variables: { id: transcoder?.id ?? "", - first: 1000, + first: PAGE_SIZE, + skip: 0, orderBy: Delegator_OrderBy.BondedAmount, orderDirection: OrderDirection.Desc, }, skip: !transcoder?.id, + notifyOnNetworkStatusChange: true, }); + const delegators = data?.transcoder?.delegators; + + // Concurrency lock (ref so it's synchronous) to avoid overlapping page fetches. + const fetchingRef = useRef(false); + const fetchNext = useCallback( + async (skip: number) => { + if (fetchingRef.current) return; + fetchingRef.current = true; + try { + await fetchMore({ + variables: { skip }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const next = fetchMoreResult?.transcoder?.delegators; + if (!next?.length || !previousResult.transcoder) { + return previousResult; + } + return { + ...previousResult, + transcoder: { + ...previousResult.transcoder, + delegators: [ + ...(previousResult.transcoder.delegators ?? []), + ...next, + ], + }, + }; + }, + }); + } catch (e) { + console.error("Failed to fetch additional delegators:", e); + } finally { + fetchingRef.current = false; + } + }, + [fetchMore] + ); + + useEffect(() => { + // A full page implies there may be more; keep paging until a short page + // (or an exact multiple returning an empty page) ends the loop. + if (!delegators?.length || delegators.length % PAGE_SIZE !== 0) return; + fetchNext(delegators.length); + }, [delegators, fetchNext]); + + if (loading && !delegators) { + return ( + + + + ); + } + + if (error && !delegators) { + console.error(error); + return ( + + Unable to load delegators. Please try again later. + + ); + } + return ( - + ); }; diff --git a/pages/accounts/[account]/delegators.tsx b/pages/accounts/[account]/delegators.tsx index c6e288e9..31a2d38c 100644 --- a/pages/accounts/[account]/delegators.tsx +++ b/pages/accounts/[account]/delegators.tsx @@ -64,6 +64,12 @@ export const getStaticProps = async (context: { throw new Error("Failed to fetch account data"); } + // This route is orchestrator-only. Accounts without a transcoder can't have + // delegators, so 404 instead of rendering a misleading empty state. + if (!account.data.transcoder) { + return { notFound: true, revalidate: 600 }; + } + const { sortedOrchestrators, fallback: sortedOrchestratorsFallback } = await getSortedOrchestrators(client); From 6a3d5e92e6c34cbd3b35217b13a425309a3ebbf9 Mon Sep 17 00:00:00 2001 From: Moudi Network Date: Thu, 2 Jul 2026 16:03:47 +0000 Subject: [PATCH 04/18] apply coderabbit pr review recommendations --- components/DelegatorsView/index.tsx | 51 ++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/components/DelegatorsView/index.tsx b/components/DelegatorsView/index.tsx index ba152c81..153cde4c 100644 --- a/components/DelegatorsView/index.tsx +++ b/components/DelegatorsView/index.tsx @@ -1,13 +1,13 @@ import DelegatorList from "@components/DelegatorList"; import Spinner from "@components/Spinner"; -import { Box, Flex, Text } from "@livepeer/design-system"; +import { Box, Button, Flex, Text } from "@livepeer/design-system"; import { AccountQueryResult, Delegator_OrderBy, OrderDirection, useOrchestratorDelegatorsQuery, } from "apollo"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; // The subgraph caps `delegators(first:)` at 1000 rows per request, so a single // query silently drops delegators beyond the first 1000. Page through with @@ -33,12 +33,17 @@ const DelegatorsView = ({ transcoder }: Props) => { const delegators = data?.transcoder?.delegators; + // Set when a later page fails to load, so we can surface that the list is + // incomplete (and pause auto-paging until the user retries). + const [pageFetchFailed, setPageFetchFailed] = useState(false); + // Concurrency lock (ref so it's synchronous) to avoid overlapping page fetches. const fetchingRef = useRef(false); const fetchNext = useCallback( async (skip: number) => { if (fetchingRef.current) return; fetchingRef.current = true; + setPageFetchFailed(false); try { await fetchMore({ variables: { skip }, @@ -61,6 +66,7 @@ const DelegatorsView = ({ transcoder }: Props) => { }); } catch (e) { console.error("Failed to fetch additional delegators:", e); + setPageFetchFailed(true); } finally { fetchingRef.current = false; } @@ -70,10 +76,21 @@ const DelegatorsView = ({ transcoder }: Props) => { useEffect(() => { // A full page implies there may be more; keep paging until a short page - // (or an exact multiple returning an empty page) ends the loop. + // (or an exact multiple returning an empty page) ends the loop. Pause while + // a page fetch has failed so we don't hammer a failing endpoint; the retry + // action clears the flag and re-runs this effect. + if (pageFetchFailed) return; if (!delegators?.length || delegators.length % PAGE_SIZE !== 0) return; fetchNext(delegators.length); - }, [delegators, fetchNext]); + }, [delegators, fetchNext, pageFetchFailed]); + + useEffect(() => { + // Log the initial-query failure as an after-render side effect rather than + // during render (which can run twice or be discarded). + if (error && !delegators) { + console.error(error); + } + }, [error, delegators]); if (loading && !delegators) { return ( @@ -91,7 +108,6 @@ const DelegatorsView = ({ transcoder }: Props) => { } if (error && !delegators) { - console.error(error); return ( { return ( + {pageFetchFailed && ( + + + Couldn't load all delegators — the list may be incomplete. + + + + )} ); From bc916b0df89d531524ba3ee602857df9d7ecd2dd Mon Sep 17 00:00:00 2001 From: Moudi Network Date: Fri, 24 Jul 2026 11:31:00 +0200 Subject: [PATCH 05/18] fix: keep delegator table header labels on one line --- components/DelegatorList/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/DelegatorList/index.tsx b/components/DelegatorList/index.tsx index 1a962c7b..7a7357fa 100644 --- a/components/DelegatorList/index.tsx +++ b/components/DelegatorList/index.tsx @@ -185,7 +185,7 @@ const DelegatorList = ({ } > - Pending Rewards + Pending Rewards ), // Live pendingStake is fetched per row (on-screen only), so this column @@ -219,7 +219,7 @@ const DelegatorList = ({ multiline content={The round this account became bonded.} > - Start Round + Start Round ), accessor: "startRoundNumber", From 1053e78720557de1c992318acfbd0d4bdcea011c Mon Sep 17 00:00:00 2001 From: ECWireless <40322776+ECWireless@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:26:51 -0600 Subject: [PATCH 06/18] feat: preserve orch table state on back nav (#713) * feat: preserve orch table state on back nav * fix: address Copilot comments * feat: preserve state for all major tables * docs: add JSDoc to new persisted... hook * chore: move default sort outside of memo * chore: clean up persisted table state defaults --- components/GatewayList/index.tsx | 42 +- components/OrchestratorList/index.tsx | 633 ++++++++++++++----------- components/PerformanceList/index.tsx | 46 +- components/Table/index.tsx | 57 ++- components/TransactionsList/index.tsx | 48 +- hooks/index.tsx | 1 + hooks/useExplorerStore.tsx | 94 ++++ hooks/usePersistedExplorerListState.ts | 218 +++++++++ pages/gateways.tsx | 7 +- pages/index.tsx | 10 +- pages/orchestrators.tsx | 1 + pages/transactions.tsx | 2 + 12 files changed, 823 insertions(+), 336 deletions(-) create mode 100644 hooks/usePersistedExplorerListState.ts diff --git a/components/GatewayList/index.tsx b/components/GatewayList/index.tsx index 7eb8dc44..bbf3f1fe 100644 --- a/components/GatewayList/index.tsx +++ b/components/GatewayList/index.tsx @@ -16,10 +16,10 @@ import { } from "@livepeer/design-system"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { GatewaysQuery } from "apollo"; -import { useEnsData } from "hooks"; +import { useEnsData, usePersistedExplorerListState } from "hooks"; import Link from "next/link"; import { useMemo } from "react"; -import { Column } from "react-table"; +import { Column, SortingRule } from "react-table"; type GatewayRow = NonNullable[number] & { depositNumber: number; @@ -28,13 +28,26 @@ type GatewayRow = NonNullable[number] & { totalVolumeNumber: number; }; +const DEFAULT_SORT_BY: SortingRule[] = [ + { id: "ninetyDayVolumeNumber", desc: true }, +]; + const GatewayList = ({ data, + listKey, pageSize = 10, + routePath, }: { + listKey: string; pageSize?: number; + routePath: string; data: GatewaysQuery["gateways"] | undefined; }) => { + const { handleTableStateChange, persistedState, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey, + routePath, + }); const rows: GatewayRow[] = useMemo( () => (data ?? []).map((gateway) => ({ @@ -310,15 +323,22 @@ const GatewayList = ({ } return ( -
+ +
[]) + : DEFAULT_SORT_BY, + }} + /> + ); }; diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index 8db7ba85..73f302ae 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -47,10 +47,16 @@ import { PERCENTAGE_PRECISION_MILLION, } from "@utils/web3"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; -import { useEnsData } from "hooks"; +import { + OrchestratorListKey, + OrchestratorListState, + useEnsData, + useExplorerStore, + usePersistedExplorerListState, +} from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; import Link from "next/link"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { formatUnits } from "viem"; import { useReadContract } from "wagmi"; @@ -76,11 +82,16 @@ const formatFactors = (factors: ROIFactors) => ? `LPT Only` : `ETH Only`; +const getListPath = (listKey: OrchestratorListKey) => + listKey === "home" ? "/" : "/orchestrators"; + const OrchestratorList = ({ data, + listKey, protocolData, pageSize = 10, }: { + listKey: OrchestratorListKey; pageSize: number; protocolData: | NonNullable["protocol"] @@ -89,6 +100,25 @@ const OrchestratorList = ({ | NonNullable["transcoders"] | undefined; }) => { + const persistedState = useExplorerStore( + (state) => state.orchestratorLists[listKey] + ); + const setOrchestratorListState = useExplorerStore( + (state) => state.setOrchestratorListState + ); + const setPersistedListState = useCallback( + (value: Partial) => { + setOrchestratorListState(listKey, value); + }, + [listKey, setOrchestratorListState] + ); + const { handleTableStateChange, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey: `orchestrator-list:${listKey}`, + routePath: getListPath(listKey), + persistedState, + setPersistedState: setPersistedListState, + }); // Derive protocol inflation data const inflationRate = Number(protocolData?.inflation || 0) / PERCENTAGE_PRECISION_BILLION; @@ -108,16 +138,35 @@ const OrchestratorList = ({ [inflationRate, inflationChangeAmount] ); - const [principle, setPrinciple] = useState(150); - const [inflationChange, setInflationChange] = - useState("none"); - const [factors, setFactors] = useState("lpt+eth"); - const [timeHorizon, setTimeHorizon] = useState("one-year"); + const [principle, setPrinciple] = useState(persistedState.principle); + const [inflationChange, setInflationChange] = useState( + persistedState.inflationChange + ); + const [factors, setFactors] = useState(persistedState.factors); + const [timeHorizon, setTimeHorizon] = useState( + persistedState.timeHorizon + ); const maxSupplyTokens = useMemo( () => Math.floor(Number(protocolData?.totalSupply || 1e7)), [protocolData] ); + useEffect(() => { + setOrchestratorListState(listKey, { + factors, + inflationChange, + principle, + timeHorizon, + }); + }, [ + factors, + inflationChange, + listKey, + principle, + setOrchestratorListState, + timeHorizon, + ]); + const formattedPrinciple = formatLPT(Number(principle) || 150, { precision: 0, }); @@ -1015,215 +1064,38 @@ const OrchestratorList = ({ ); return ( -
- - - - - - {"Forecasted Yield Assumptions"} - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - - {"Time horizon:"} - - - {formatTimeHorizon(timeHorizon)} - - - - +
+ + + + + - - setTimeHorizon("half-year")} - > - {"6 months"} - - setTimeHorizon("one-year")} - > - {"1 year"} - - setTimeHorizon("two-years")} - > - {"2 years"} - - setTimeHorizon("three-years")} - > - {"3 years"} - - setTimeHorizon("four-years")} - > - {"4 years"} - - - - - - - { - e.stopPropagation(); - }} - asChild - > - - - - - - {"Delegation:"} - - - {formatLPT(principle, { precision: 1 })} - - - - - - - { - setPrinciple( - Number(e.target.value) > maxSupplyTokens - ? maxSupplyTokens - : Number(e.target.value) - ); - }} - min="1" - max={`${Number( - protocolData?.totalSupply || 1e7 - ).toFixed(0)}`} - /> - - LPT - - - - - - - + {"Forecasted Yield Assumptions"} + + + { @@ -1250,7 +1122,7 @@ const OrchestratorList = ({ marginRight: 3, }} > - {"Factors:"} + {"Time horizon:"} - {formatFactors(factors)} + {formatTimeHorizon(timeHorizon)} @@ -1278,113 +1150,296 @@ const OrchestratorList = ({ css={{ cursor: "pointer", }} - onSelect={() => setFactors("lpt+eth")} + onSelect={() => setTimeHorizon("half-year")} + > + {"6 months"} + + setTimeHorizon("one-year")} > - {formatFactors("lpt+eth")} + {"1 year"} setFactors("lpt")} + onSelect={() => setTimeHorizon("two-years")} > - {formatFactors("lpt")} + {"2 years"} setFactors("eth")} + onSelect={() => setTimeHorizon("three-years")} > - {formatFactors("eth")} + {"3 years"} + + setTimeHorizon("four-years")} + > + {"4 years"} - - - - { - e.stopPropagation(); - }} - asChild - > - + + { + e.stopPropagation(); }} + asChild > - - - - - - {"Inflation change:"} - - - {formatPercentChange(inflationChange)} - - - - - - + + + + {"Delegation:"} + + + {formatLPT(principle, { precision: 1 })} + + + + + setInflationChange("none")} > - {formatPercentChange("none")} - - + { + const nextValue = Number(e.target.value); + setPrinciple( + !Number.isFinite(nextValue) + ? 1 + : nextValue < 1 + ? 1 + : nextValue > maxSupplyTokens + ? maxSupplyTokens + : nextValue + ); + }} + min="1" + max={`${Number( + protocolData?.totalSupply || 1e7 + ).toFixed(0)}`} + /> + + LPT + + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + setInflationChange("positive")} > - {formatPercentChange("positive")} - - + + + + + {"Factors:"} + + + {formatFactors(factors)} + + + + + + setFactors("lpt+eth")} + > + {formatFactors("lpt+eth")} + + setFactors("lpt")} + > + {formatFactors("lpt")} + + setFactors("eth")} + > + {formatFactors("eth")} + + + + + + + + { + e.stopPropagation(); + }} + asChild + > + setInflationChange("negative")} > - {formatPercentChange("negative")} - - - - - - - - } - /> + + + + + + {"Inflation change:"} + + + {formatPercentChange(inflationChange)} + + + + + + setInflationChange("none")} + > + {formatPercentChange("none")} + + setInflationChange("positive")} + > + {formatPercentChange("positive")} + + setInflationChange("negative")} + > + {formatPercentChange("negative")} + + + + + + + + } + /> + ); }; diff --git a/components/PerformanceList/index.tsx b/components/PerformanceList/index.tsx index 4340438d..ec136bfa 100644 --- a/components/PerformanceList/index.tsx +++ b/components/PerformanceList/index.tsx @@ -10,13 +10,20 @@ import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import { formatNumber, formatPercent } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; import { OrchestratorsQueryResult } from "apollo"; -import { useEnsData } from "hooks"; +import { useEnsData, usePersistedExplorerListState } from "hooks"; import Link from "next/link"; import { useMemo } from "react"; import { Column } from "react-table"; const EmptyData = () => ; +const DEFAULT_SORT_BY = [ + { + id: "scores", + desc: true, + }, +]; + const PerformanceList = ({ orchestratorIds, pageSize = 20, @@ -41,15 +48,28 @@ const PerformanceList = ({ const scoreAccessor = `scores.${region}`; //total score const successRateAccessor = `successRates.${region}`; //success rate const roundTripScoreAccessor = `roundTripScores.${region}`; //latency score + const listKey = useMemo( + () => + [ + "leaderboard-performance", + region, + pipeline ?? "transcoding", + model ?? "default", + ].join(":"), + [model, pipeline, region] + ); + const { handleTableStateChange, persistedState, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey, + routePath: "/leaderboard", + }); const initialState = { + pageIndex: persistedState.pageIndex, pageSize: pageSize, - sortBy: [ - { - id: "scores", - desc: true, - }, - ], + sortBy: persistedState.sortBy.length + ? persistedState.sortBy + : DEFAULT_SORT_BY, hiddenColumns: [ "activationRound", "deactivationRound", @@ -320,7 +340,17 @@ const PerformanceList = ({ ] ); return ( -
+ +
+ ); }; diff --git a/components/Table/index.tsx b/components/Table/index.tsx index e9ad1e93..0c37a2df 100644 --- a/components/Table/index.tsx +++ b/components/Table/index.tsx @@ -9,20 +9,37 @@ import { Tr, } from "@livepeer/design-system"; import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; -import { ReactNode } from "react"; +import { ReactNode, useEffect } from "react"; import { Column, HeaderGroup, + SortingRule, TableInstance, + TableOptions, + TableState, usePagination, UsePaginationInstanceProps, + UsePaginationOptions, + UsePaginationState, useSortBy, UseSortByInstanceProps, + UseSortByOptions, + UseSortByState, useTable, } from "react-table"; import Pagination from "./Pagination"; +type TableInitialState = Partial> & + Partial> & + Partial>; + +type PersistedTableState = { + pageIndex: number; + pageSize: number; + sortBy: SortingRule[]; +}; + function DataTable({ heading = null, input = null, @@ -30,14 +47,30 @@ function DataTable({ columns, initialState = {}, minWidth = 960, + autoResetPage, + autoResetSortBy, + onStateChange, }: { heading?: ReactNode; input?: ReactNode; data: T[]; columns: Column[]; - initialState: object; + initialState: TableInitialState; minWidth?: number; + autoResetPage?: boolean; + autoResetSortBy?: boolean; + onStateChange?: (state: PersistedTableState) => void; }) { + const tableOptions: TableOptions & + UsePaginationOptions & + UseSortByOptions = { + columns, + data, + initialState, + autoResetPage, + autoResetSortBy, + }; + const { getTableProps, getTableBodyProps, @@ -49,18 +82,16 @@ function DataTable({ pageCount, nextPage, previousPage, - state: { pageIndex }, - } = useTable( - { - columns, - data, - initialState, - }, - useSortBy, - usePagination - ) as TableInstance & + state: { pageIndex, pageSize, sortBy }, + } = useTable(tableOptions, useSortBy, usePagination) as TableInstance & UsePaginationInstanceProps & - UseSortByInstanceProps & { state: { pageIndex: number } }; + UseSortByInstanceProps & { + state: UsePaginationState & UseSortByState; + }; + + useEffect(() => { + onStateChange?.({ pageIndex, pageSize, sortBy }); + }, [onStateChange, pageIndex, pageSize, sortBy]); return ( <> diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index 89a78df7..d5f6ee19 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -17,6 +17,7 @@ import { } from "@utils/web3"; import { EventsQueryResult, TreasuryProposal } from "apollo"; import { sentenceCase } from "change-case"; +import { usePersistedExplorerListState } from "hooks"; import { useCallback, useMemo } from "react"; export const FILTERED_EVENT_TYPENAMES = [ @@ -67,15 +68,33 @@ const renderEmoji = (emoji: string) => ( ); +const DEFAULT_SORT_BY = [ + { + id: "timestamp", + desc: true, + }, +]; + const TransactionsList = ({ events, + listKey, pageSize = 10, + routePath, }: { + listKey: string; pageSize: number; + routePath: string; events: NonNullable< EventsQueryResult["data"] >["transactions"][number]["events"]; }) => { + const { handleTableStateChange, persistedState, saveCurrentScroll } = + usePersistedExplorerListState({ + listKey, + routePath, + }); + const pageCount = Math.max(1, Math.ceil((events?.length ?? 0) / pageSize)); + const pageIndex = Math.min(persistedState.pageIndex, pageCount - 1); const getAccountForRow = useCallback( ( event: NonNullable< @@ -547,19 +566,22 @@ const TransactionsList = ({ ); return ( -
+ +
+ ); }; diff --git a/hooks/index.tsx b/hooks/index.tsx index 9d37f82c..a22571b6 100644 --- a/hooks/index.tsx +++ b/hooks/index.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; // DO NOT IMPORT useHandleTransaction due to @rainbow-me/rainbowkit issues with SSR export * from "./useExplorerStore"; +export * from "./usePersistedExplorerListState"; export * from "./useSwr"; export * from "./wallet"; diff --git a/hooks/useExplorerStore.tsx b/hooks/useExplorerStore.tsx index 980a1dc2..2d36084a 100644 --- a/hooks/useExplorerStore.tsx +++ b/hooks/useExplorerStore.tsx @@ -1,9 +1,26 @@ import { ProposalExtended } from "@lib/api/treasury"; +import { ROIFactors, ROIInflationChange, ROITimeHorizon } from "@lib/roi"; import { txMessages } from "lib/utils"; +import { SortingRule } from "react-table"; import { Address } from "viem"; import { create } from "zustand"; export type StakingAction = "undelegate" | "delegate" | null; +export type OrchestratorListKey = "home" | "orchestrators"; +export type OrchestratorListState = { + factors: ROIFactors; + inflationChange: ROIInflationChange; + pageIndex: number; + principle: number; + scrollY: number; + sortBy: SortingRule[]; + timeHorizon: ROITimeHorizon; +}; +export type ExplorerListState = { + pageIndex: number; + scrollY: number; + sortBy: SortingRule[]; +}; export type YieldResults = { roiFees: number; roiFeesLpt: number; @@ -48,12 +65,22 @@ export type ExplorerState = { bottomDrawerOpen: boolean; selectedStakingAction: StakingAction; yieldResults: YieldResults; + orchestratorLists: Record; + explorerLists: Record; latestTransaction: TransactionStatus | null; setWalletModalOpen: (v: boolean) => void; setBottomDrawerOpen: (v: boolean) => void; setSelectedStakingAction: (v: StakingAction) => void; setYieldResults: (v: YieldResults) => void; + setOrchestratorListState: ( + key: OrchestratorListKey, + value: Partial + ) => void; + setExplorerListState: ( + key: string, + value: Partial + ) => void; setLatestTransactionDetails: ( hash: string, @@ -66,6 +93,22 @@ export type ExplorerState = { clearLatestTransaction: () => void; }; +const defaultOrchestratorListState: OrchestratorListState = { + factors: "lpt+eth", + inflationChange: "none", + pageIndex: 0, + principle: 150, + scrollY: 0, + sortBy: [{ id: "earnings", desc: true }], + timeHorizon: "one-year", +}; + +export const defaultExplorerListState: ExplorerListState = { + pageIndex: 0, + scrollY: 0, + sortBy: [], +}; + export const useExplorerStore = create()((set) => ({ walletModalOpen: false, bottomDrawerOpen: false, @@ -76,6 +119,11 @@ export const useExplorerStore = create()((set) => ({ roiRewards: 0.0, principle: 0.0, }, + orchestratorLists: { + home: defaultOrchestratorListState, + orchestrators: defaultOrchestratorListState, + }, + explorerLists: {}, latestTransaction: null, setWalletModalOpen: (v: boolean) => set(() => ({ walletModalOpen: v })), @@ -83,6 +131,52 @@ export const useExplorerStore = create()((set) => ({ setSelectedStakingAction: (v: StakingAction) => set(() => ({ selectedStakingAction: v })), setYieldResults: (v: YieldResults) => set(() => ({ yieldResults: v })), + setOrchestratorListState: (key, value) => + set((state) => { + const { orchestratorLists } = state; + const current = orchestratorLists[key]; + const hasChanged = Object.entries(value).some( + ([field, nextValue]) => + current[field as keyof OrchestratorListState] !== nextValue + ); + + if (!hasChanged) { + return state; + } + + return { + orchestratorLists: { + ...orchestratorLists, + [key]: { + ...current, + ...value, + }, + }, + }; + }), + setExplorerListState: (key, value) => + set((state) => { + const { explorerLists } = state; + const current = explorerLists[key] ?? defaultExplorerListState; + const hasChanged = Object.entries(value).some( + ([field, nextValue]) => + current[field as keyof ExplorerListState] !== nextValue + ); + + if (!hasChanged) { + return state; + } + + return { + explorerLists: { + ...explorerLists, + [key]: { + ...current, + ...value, + }, + }, + }; + }), setLatestTransactionDetails: ( hash: string, diff --git a/hooks/usePersistedExplorerListState.ts b/hooks/usePersistedExplorerListState.ts new file mode 100644 index 00000000..82130f40 --- /dev/null +++ b/hooks/usePersistedExplorerListState.ts @@ -0,0 +1,218 @@ +import Router from "next/router"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { SortingRule } from "react-table"; + +import { + defaultExplorerListState, + ExplorerListState, + useExplorerStore, +} from "./useExplorerStore"; + +type PersistedListState = { + pageIndex: number; + scrollY: number; + sortBy: SortingRule[]; +}; + +type PersistedTableState = { + pageIndex: number; + sortBy: SortingRule[]; +}; + +type UsePersistedExplorerListStateOptions = { + listKey: string; + routePath: string; + persistedState?: PersistedListState; + setPersistedState?: (value: Partial) => void; +}; + +/** Builds the sessionStorage key used as a same-tab scroll backup for a list. */ +const getScrollStorageKey = (listKey: string) => + `livepeer:explorer-list:${listKey}:scrollY`; + +/** Reads the last saved scroll offset for a list, returning null when unavailable. */ +const readStoredScrollY = (listKey: string) => { + if (typeof window === "undefined") { + return null; + } + + try { + const value = window.sessionStorage.getItem(getScrollStorageKey(listKey)); + const scrollY = value ? Number(value) : NaN; + + return Number.isFinite(scrollY) ? scrollY : null; + } catch { + return null; + } +}; + +/** Writes a non-negative rounded scroll offset for a list into sessionStorage. */ +const writeStoredScrollY = (listKey: string, scrollY: number) => { + if (typeof window === "undefined") { + return; + } + + try { + window.sessionStorage.setItem( + getScrollStorageKey(listKey), + `${Math.max(0, Math.round(scrollY))}` + ); + } catch {} +}; + +/** + * Persists table page/sort state and restores window scroll for a browsable list. + * + * `listKey` scopes stored state per list instance, while `routePath` identifies + * the route that should trigger restoration after Back navigation. Callers can + * provide `persistedState` and `setPersistedState` to reuse an existing store + * slice; otherwise the hook uses the generic explorer list store. + * + * Returns `handleTableStateChange` for the shared Table callback, + * `persistedState` for Table initial state, and `saveCurrentScroll` for click + * capture before navigating from a list item. + */ +export const usePersistedExplorerListState = ({ + listKey, + routePath, + persistedState: externalPersistedState, + setPersistedState: externalSetPersistedState, +}: UsePersistedExplorerListStateOptions) => { + const storedPersistedState = + useExplorerStore((state) => state.explorerLists[listKey]) ?? + defaultExplorerListState; + const setExplorerListState = useExplorerStore( + (state) => state.setExplorerListState + ); + const persistedState = externalPersistedState ?? storedPersistedState; + const setPersistedState = useMemo( + () => + externalSetPersistedState ?? + ((value: Partial) => + setExplorerListState(listKey, value)), + [externalSetPersistedState, listKey, setExplorerListState] + ); + const activeRestoreCleanup = useRef<(() => void) | undefined>(undefined); + + const saveCurrentScroll = useCallback(() => { + writeStoredScrollY(listKey, window.scrollY); + setPersistedState({ scrollY: window.scrollY }); + }, [listKey, setPersistedState]); + + const restoreSavedScroll = useCallback(() => { + const scrollY = readStoredScrollY(listKey) ?? persistedState.scrollY; + + if (scrollY <= 0) { + return; + } + + const getMaxScrollY = () => + Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight + ) - window.innerHeight; + + const isAtSavedScroll = () => Math.abs(window.scrollY - scrollY) <= 2; + const isTallEnough = () => getMaxScrollY() >= scrollY; + + if (isTallEnough() && isAtSavedScroll()) { + return; + } + + const startedAt = Date.now(); + const maxRestoreMs = 2000; + let frameId: number | undefined; + let cancelled = false; + + const restoreScroll = () => { + if (cancelled) { + return; + } + + if (isTallEnough()) { + window.scrollTo(0, scrollY); + } + + if ( + (!isTallEnough() || !isAtSavedScroll()) && + Date.now() - startedAt < maxRestoreMs + ) { + frameId = requestAnimationFrame(restoreScroll); + } + }; + + frameId = requestAnimationFrame(restoreScroll); + + return () => { + cancelled = true; + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + } + }; + }, [listKey, persistedState.scrollY]); + + const startScrollRestore = useCallback(() => { + activeRestoreCleanup.current?.(); + activeRestoreCleanup.current = restoreSavedScroll(); + }, [restoreSavedScroll]); + + useEffect(() => { + startScrollRestore(); + + return () => { + activeRestoreCleanup.current?.(); + activeRestoreCleanup.current = undefined; + }; + }, [startScrollRestore]); + + useEffect(() => { + let frameId: number | null = null; + const saveScrollToStorage = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + writeStoredScrollY(listKey, window.scrollY); + }); + }; + + window.addEventListener("scroll", saveScrollToStorage, { passive: true }); + const restoreAfterRouteChange = (url: string) => { + const path = url.split("?")[0].split("#")[0]; + + if (path === routePath) { + startScrollRestore(); + } + }; + + Router.events.on("routeChangeStart", saveCurrentScroll); + Router.events.on("routeChangeComplete", restoreAfterRouteChange); + + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + window.removeEventListener("scroll", saveScrollToStorage); + Router.events.off("routeChangeStart", saveCurrentScroll); + Router.events.off("routeChangeComplete", restoreAfterRouteChange); + }; + }, [listKey, routePath, saveCurrentScroll, startScrollRestore]); + + const handleTableStateChange = useCallback( + ({ pageIndex, sortBy }: PersistedTableState) => { + setPersistedState({ + pageIndex, + sortBy: sortBy as SortingRule[], + }); + }, + [setPersistedState] + ); + + return { + handleTableStateChange, + persistedState, + saveCurrentScroll, + }; +}; diff --git a/pages/gateways.tsx b/pages/gateways.tsx index fefa2b0d..4eeaced4 100644 --- a/pages/gateways.tsx +++ b/pages/gateways.tsx @@ -46,7 +46,12 @@ const GatewaysPage = ({ gateways }: PageProps) => { the last 12 months. - + diff --git a/pages/index.tsx b/pages/index.tsx index 75628cc6..0d244974 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -492,6 +492,7 @@ const Home = ({ {showOrchList ? ( @@ -579,7 +580,12 @@ const Home = ({ ) : ( - + )} @@ -659,7 +665,9 @@ const Home = ({ EventsQueryResult["data"] >["transactions"][number]["events"] } + listKey="home-transactions" pageSize={10} + routePath="/" /> diff --git a/pages/orchestrators.tsx b/pages/orchestrators.tsx index ab291282..7e76aded 100644 --- a/pages/orchestrators.tsx +++ b/pages/orchestrators.tsx @@ -123,6 +123,7 @@ const OrchestratorsPage = ({ {showOrchList ? ( diff --git a/pages/transactions.tsx b/pages/transactions.tsx index d0ededf9..6f81a0e2 100644 --- a/pages/transactions.tsx +++ b/pages/transactions.tsx @@ -72,7 +72,9 @@ const TransactionsPage = ({ hadError, events }: PageProps) => { EventsQueryResult["data"] >["transactions"][number]["events"] } + listKey="transactions" pageSize={TRANSACTIONS_PER_PAGE} + routePath="/transactions" /> )} From 70946b722c75f03c9dac772ab6318decc0a14ed1 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 16 Jul 2026 11:07:53 +0200 Subject: [PATCH 07/18] fix: return partial score data when a metrics upstream is down (#729) One unreachable upstream failed the whole request with a 502. Fetch each independently and fall back to null for that field, failing only when all are down. Partial responses cache for a minute so recovery is not hidden, and the UI shows "Unavailable" rather than "N/A" so our outage is not read as the orchestrator having no score. --- components/OrchestratingView/index.tsx | 24 ++++-- lib/api/types/get-performance.ts | 11 +-- pages/api/score/[address].tsx | 105 +++++++++++++------------ 3 files changed, 76 insertions(+), 64 deletions(-) diff --git a/components/OrchestratingView/index.tsx b/components/OrchestratingView/index.tsx index e86cd4fb..b99a8aa2 100644 --- a/components/OrchestratingView/index.tsx +++ b/components/OrchestratingView/index.tsx @@ -97,7 +97,7 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { const maxScore = useMemo(() => { const topTransData = Object.keys(scores?.scores ?? {}).reduce( (prev, curr) => { - const score = scores?.scores[curr]; + const score = scores?.scores?.[curr]; const region = knownRegions?.regions?.find((r) => r.id === curr)?.name ?? "N/A"; if ( @@ -107,7 +107,7 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { ) { return { region: region, - score: scores?.scores[curr], + score, }; } return prev; @@ -128,8 +128,9 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { precision: 1, })} - ${maxScore.transcoding.region}` : ""; - return outputTrans ? transcodingInfo : "N/A"; - }, [maxScore]); + if (outputTrans) return transcodingInfo; + return scores?.scores === null ? "Unavailable" : "N/A"; + }, [maxScore, scores]); const maxAIScoreOutput = useMemo(() => { const outputAI = maxScore.ai?.value && maxScore.ai?.value > 0; @@ -148,8 +149,11 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { score: aiInfo, modelText: `. The pipeline and model for this Orchestrator was '${maxScore.ai?.pipeline}' and '${maxScore.ai?.model}'`, } - : { score: "N/A", modelText: "" }; - }, [knownRegions?.regions, maxScore]); + : { + score: scores?.topAIScore === null ? "Unavailable" : "N/A", + modelText: "", + }; + }, [knownRegions?.regions, maxScore, scores]); return ( { className="masonry-grid_item" label="Price / Pixel" tooltip="The most recent price for transcoding which the orchestrator is currently advertising off-chain to gateways. This may be different from on-chain pricing." - value={scores ? `${formatNumber(scores.pricePerPixel)} WEI` : "N/A"} + value={ + !scores + ? "N/A" + : scores.pricePerPixel === null + ? "Unavailable" + : `${formatNumber(scores.pricePerPixel)} WEI` + } /> {/* (url: string): Promise => { + try { + const response = await fetchWithRetry(url); + + if (!response.ok) { + console.error( + `Fetch error: ${url}`, + response.status, + (await response.text()).slice(0, 500) + ); + return null; + } + + return await response.json(); + } catch (err) { + console.error( + `Fetch error: ${url}`, + err instanceof Error ? err.message : err + ); + return null; + } +}; + const handler = async ( req: NextApiRequest, res: NextApiResponse @@ -63,8 +84,6 @@ const handler = async ( if (method === "GET") { const { address } = req.query; - res.setHeader("Cache-Control", getCacheControlHeader("hour")); - if (!!address && !Array.isArray(address) && isAddress(address)) { const transcoderId = address.toLowerCase(); @@ -72,54 +91,32 @@ const handler = async ( const metricsUrl = `${process.env.NEXT_PUBLIC_METRICS_SERVER_URL}/api/aggregated_stats?orchestrator=${transcoderId}`; const pricingUrl = `${CHAIN_INFO[DEFAULT_CHAIN_ID].pricingUrl}?excludeUnavailable=False`; - const [topScoreResponse, metricsResponse, priceResponse] = - await Promise.all([ - fetchWithRetry(topScoreUrl), - fetchWithRetry(metricsUrl), - fetchWithRetry(pricingUrl), - ]); - - if (!topScoreResponse.ok) { - const errorText = await topScoreResponse.text(); - console.error( - "Top AI score fetch error:", - topScoreResponse.status, - errorText - ); - return externalApiError(res, "AI metrics server"); - } + const [topAIScore, metrics, transcodersWithPrice] = await Promise.all([ + fetchJson(topScoreUrl), + fetchJson(metricsUrl), + fetchJson(pricingUrl), + ]); - if (!metricsResponse.ok) { - const errorText = await metricsResponse.text(); - console.error( - "Metrics fetch error:", - metricsResponse.status, - errorText - ); - return externalApiError(res, "metrics server"); + // Every upstream being down is never a legitimate empty state, so fail + // loudly rather than rendering a healthy-looking orchestrator with no data. + if (!topAIScore && !metrics && !transcodersWithPrice) { + return externalApiError(res, "all metrics servers"); } - if (!priceResponse.ok) { - const errorText = await priceResponse.text(); - console.error( - "Transcoder price fetch error:", - priceResponse.status, - errorText - ); - return externalApiError(res, "pricing server"); - } - - const topAIScore: ScoreResponse = await topScoreResponse.json(); - const metrics: MetricsResponse = await metricsResponse.json(); - const transcodersWithPrice: PriceResponse = await priceResponse.json(); + // Expire quickly so upstream recovery is not hidden for an hour. + const degraded = !topAIScore || !metrics || !transcodersWithPrice; + res.setHeader( + "Cache-Control", + getCacheControlHeader(degraded ? "minute" : "hour") + ); - const transcoderWithPrice = transcodersWithPrice.find((t) => + const transcoderWithPrice = transcodersWithPrice?.find((t) => checkAddressEquality(t.Address, transcoderId) ); const uniqueRegions = (() => { const keys = new Set(); - Object.values(metrics).forEach((metric) => { + Object.values(metrics ?? {}).forEach((metric) => { if (metric) { Object.keys(metric).forEach((key) => keys.add(key)); } @@ -130,8 +127,10 @@ const handler = async ( const createMetricsObject = ( metricKey: keyof Metric, transcoderId: string, - metrics: MetricsResponse - ): RegionalValues => { + metrics: MetricsResponse | null + ): RegionalValues | null => { + if (!metrics) return null; + const metricsObject: RegionalValues = uniqueRegions.reduce( (acc, metricsRegionKey) => { const value = @@ -153,7 +152,9 @@ const handler = async ( }; const combined: PerformanceMetrics = { - pricePerPixel: transcoderWithPrice?.PricePerPixel ?? 0, + pricePerPixel: transcodersWithPrice + ? transcoderWithPrice?.PricePerPixel ?? 0 + : null, successRates: createMetricsObject( "success_rate", transcoderId, From d4e72541a9d71b9a46b4864b5b46b05ce490ae34 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Sun, 19 Jul 2026 21:39:23 +0200 Subject: [PATCH 08/18] fix: source current round timing on-chain instead of the subgraph (#731) /current-round mixed sources: round id/startBlock/initialized came from the subgraph, but the current block from a live RPC call. When subgraph indexing stalls the stale round is compared against a live block, so the tracker shows a stale round number with counters running past the round length. Read the timing from the RoundsManager contract (currentRound, currentRoundStartBlock, currentRoundInitialized, blockNum) via l2PublicClient, keeping startBlock and the current block consistent and independent of subgraph health. Response shape is unchanged; aggregate stats still use the subgraph. --- pages/api/current-round.tsx | 48 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/pages/api/current-round.tsx b/pages/api/current-round.tsx index d0a64b58..d4ad704d 100644 --- a/pages/api/current-round.tsx +++ b/pages/api/current-round.tsx @@ -1,7 +1,9 @@ -import { getCacheControlHeader, getCurrentRound } from "@lib/api"; +import { getCacheControlHeader } from "@lib/api"; +import { roundsManager } from "@lib/api/abis/main/RoundsManager"; +import { getContractAddress } from "@lib/api/contracts"; import { internalError, methodNotAllowed } from "@lib/api/errors"; import { CurrentRoundInfo } from "@lib/api/types/get-current-round"; -import { l1PublicClient } from "@lib/chains"; +import { l2PublicClient } from "@lib/chains"; import { NextApiRequest, NextApiResponse } from "next"; const handler = async ( @@ -14,28 +16,38 @@ const handler = async ( if (method === "GET") { res.setHeader("Cache-Control", getCacheControlHeader("minute")); - const { - data: { protocol, _meta }, - } = await getCurrentRound(); - const currentRound = protocol?.currentRound; + const roundsManagerAddress = await getContractAddress("RoundsManager"); - if (!currentRound) { - throw new Error("No current round found"); - } + const contract = { + address: roundsManagerAddress, + abi: roundsManager, + } as const; - if (!_meta?.block) { - throw new Error("No block number found"); - } - - const { id, startBlock, initialized } = currentRound; - - const currentL2Block = _meta.block.number; - const currentL1Block = await l1PublicClient.getBlockNumber(); + const [id, startBlock, initialized, currentL1Block, currentL2Block] = + await Promise.all([ + l2PublicClient.readContract({ + ...contract, + functionName: "currentRound", + }), + l2PublicClient.readContract({ + ...contract, + functionName: "currentRoundStartBlock", + }), + l2PublicClient.readContract({ + ...contract, + functionName: "currentRoundInitialized", + }), + l2PublicClient.readContract({ + ...contract, + functionName: "blockNum", + }), + l2PublicClient.getBlockNumber(), + ]); const roundInfo: CurrentRoundInfo = { id: Number(id), startBlock: Number(startBlock), - initialized, + initialized: Boolean(initialized), currentL1Block: Number(currentL1Block), currentL2Block: Number(currentL2Block), }; From 919cf01270f2dfaaf5f28936f588bf226dfd58e6 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Mon, 20 Jul 2026 23:21:34 +0200 Subject: [PATCH 09/18] feat: warn when subgraph indexing falls behind (#733) Show a site-wide info banner when the subgraph is significantly behind the L2 chain head, signalling Explorer data may be temporarily stale while the protocol operates normally. --- apollo/subgraph.ts | 5 +++- hooks/index.tsx | 1 + hooks/useSubgraphHealth.ts | 16 ++++++++++++ layouts/main.tsx | 42 ++++++++++++++++++++++++++++++ pages/api/subgraph-health.tsx | 49 +++++++++++++++++++++++++++++++++++ queries/meta.graphql | 3 +++ 6 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 hooks/useSubgraphHealth.ts create mode 100644 pages/api/subgraph-health.tsx diff --git a/apollo/subgraph.ts b/apollo/subgraph.ts index 5bbf22d8..f8ff53e9 100644 --- a/apollo/subgraph.ts +++ b/apollo/subgraph.ts @@ -9734,7 +9734,7 @@ export type GatewaysQuery = { __typename: 'Query', gateways: Array<{ __typename: export type MetaQueryVariables = Exact<{ [key: string]: never; }>; -export type MetaQuery = { __typename: 'Query', _meta?: { __typename: '_Meta_', hasIndexingErrors: boolean } | null }; +export type MetaQuery = { __typename: 'Query', _meta?: { __typename: '_Meta_', hasIndexingErrors: boolean, block: { __typename: '_Block_', number: number } } | null }; export type OrchestratorDelegatorsQueryVariables = Exact<{ id: Scalars['ID']; @@ -10399,6 +10399,9 @@ export type GatewaysQueryResult = Apollo.QueryResult { + const { data } = useSWR<{ degraded: boolean }>("/subgraph-health", { + refreshInterval: HEALTH_POLL_INTERVAL, + }); + + return data?.degraded === true; +}; diff --git a/layouts/main.tsx b/layouts/main.tsx index 9c7659de..68a9627c 100644 --- a/layouts/main.tsx +++ b/layouts/main.tsx @@ -59,6 +59,7 @@ import React, { } from "react"; import { isMobile } from "react-device-detect"; import ReactGA from "react-ga"; +import { FiInfo } from "react-icons/fi"; import { LuRadioTower } from "react-icons/lu"; import { useWindowSize } from "react-use"; import { Chain } from "viem"; @@ -71,6 +72,7 @@ import { useExplorerStore, useOnClickOutside, usePendingFeesAndStakeData, + useSubgraphDegraded, } from "../hooks"; import Ballot from "../public/img/ballot.svg"; import DNS from "../public/img/dns.svg"; @@ -142,6 +144,7 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => { const currentRound = useCurrentRoundData(); const pendingFeesAndStake = usePendingFeesAndStakeData(accountAddress); const isBannerDisabledByQuery = query.disableUrlVerificationBanner === "true"; + const subgraphDegraded = useSubgraphDegraded(); const viewedAccountId = query?.account?.toString().toLowerCase(); const { data: viewedAccountData } = useAccountQuery({ @@ -413,6 +416,45 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => { {bannerActive && ( )} + {subgraphDegraded && ( + + + )} { + const method = req.method; + if (method !== "GET") { + res.setHeader("Allow", "GET"); + return res.status(405).end(`Method ${method} Not Allowed`); + } + + const [metaResult, chainHeadResult] = await Promise.allSettled([ + getApollo().query({ + query: MetaDocument, + // Bypass the shared InMemoryCache, which would pin a stale block. + fetchPolicy: "no-cache", + }), + l2PublicClient.getBlockNumber(), + ]); + + const servedBlock = + metaResult.status === "fulfilled" + ? metaResult.value.data._meta?.block?.number + : undefined; + const chainHead = + chainHeadResult.status === "fulfilled" + ? Number(chainHeadResult.value) + : undefined; + + const degraded = + servedBlock != null && chainHead != null + ? chainHead - servedBlock > MAX_BLOCK_DRIFT + : false; + + // Cache briefly so one server-side check is shared across clients. + res.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate=60"); + return res.status(200).json({ degraded }); +}; + +export default subgraphHealth; diff --git a/queries/meta.graphql b/queries/meta.graphql index aee53dd2..c907adec 100644 --- a/queries/meta.graphql +++ b/queries/meta.graphql @@ -1,5 +1,8 @@ query meta { _meta { + block { + number + } hasIndexingErrors } } From 00d4fd4f56e0c6f71216abb2fb16fbe6aa4cf59a Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 01:12:15 +0200 Subject: [PATCH 10/18] fix: manually surface LIP-118 poll while subgraph indexing is behind (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voting list/detail pages and the Governance nav badge source polls entirely from the subgraph, so a poll created during an indexing halt (LIP-118 "Delegated Reward Calling") doesn't appear and can't be reached for voting through the UI — even though voting itself is an on-chain action. Add a temporary MANUAL_POLLS constant (subgraph-shaped entries) merged into the voting list, the poll detail page, and the active-poll nav count, deduped by poll address so the real tallied copy wins and the manual entry drops out once indexing recovers. Manual polls carry no tally, so the detail page shows a "counts not loaded" note; casting votes works normally on-chain. getTotalStake now degrades gracefully if the subgraph is unavailable so manual polls still render. Revert once the subgraph has reindexed the poll. --- constants/manualPolls.ts | 58 ++++++++++++++++++++++++++++++++++++++++ layouts/main.tsx | 3 ++- lib/api/polls.ts | 45 ++++++++++++++++++------------- pages/voting/[poll].tsx | 30 ++++++++++++++++++--- pages/voting/index.tsx | 8 ++++-- 5 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 constants/manualPolls.ts diff --git a/constants/manualPolls.ts b/constants/manualPolls.ts new file mode 100644 index 00000000..5585421b --- /dev/null +++ b/constants/manualPolls.ts @@ -0,0 +1,58 @@ +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 + * outage. Entries are shaped like subgraph poll results and merged into the + * 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. + * + * Remove entries (or delete this file and its imports) once indexing recovers. + */ +type SubgraphPoll = PollsQuery["polls"][number]; + +export const MANUAL_POLLS: SubgraphPoll[] = [ + { + // LIP-118 "Delegated Reward Calling" — created during the subgraph indexing halt. + // tx: 0xb8ab2a824b4a5b16b76be99f1313a8238f35acc2259b139562277e1df8ba02e4 + __typename: "Poll", + id: "0x74479927117d4158b32c03d5400c14bdd7e6a46a", + proposal: "QmcYGZp3SipMEz699bpeZqocdouYWTE2zHG8TMdaSYqowF", + endBlock: "25622119", + quorum: "333300", + quota: "500000", + tally: null, + votes: [], + }, +]; + +const norm = (id: string) => id.toLowerCase(); + +/** The manually-listed poll for `id`, if any. */ +export const getManualPoll = ( + id: string | undefined | null +): 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). */ +export const isManualPoll = (id: string | undefined | null): boolean => + Boolean(getManualPoll(id)); + +/** + * Merge manual polls into a subgraph poll list, deduped by address. The subgraph + * copy takes precedence, so a poll that has since been indexed is not duplicated. + */ +export const mergeManualPolls = ( + polls: SubgraphPoll[] | undefined +): SubgraphPoll[] => { + const list = polls ? [...polls] : []; + const seen = new Set(list.map((p) => norm(p.id))); + for (const poll of MANUAL_POLLS) { + if (!seen.has(norm(poll.id))) list.push(poll); + } + return list; +}; diff --git a/layouts/main.tsx b/layouts/main.tsx index 68a9627c..f1700b5e 100644 --- a/layouts/main.tsx +++ b/layouts/main.tsx @@ -40,6 +40,7 @@ import { useTreasuryProposalsQuery, } from "apollo"; import { BRIDGE_LPT_URL, GET_LPT_URL } from "constants/links"; +import { mergeManualPolls } from "constants/manualPolls"; import { BigNumber } from "ethers"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; import dynamic from "next/dynamic"; @@ -178,7 +179,7 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => { const totalActivePolls = useMemo( () => - pollData?.polls.filter( + mergeManualPolls(pollData?.polls).filter( (p) => (currentRound?.currentL1Block ?? Number.MAX_VALUE) <= parseInt(p.endBlock) diff --git a/lib/api/polls.ts b/lib/api/polls.ts index 87192da4..12f6bb48 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -170,24 +170,33 @@ const getEstimatedEndTimeByBlockNumber = async ( }; const getTotalStake = async (l2BlockNumber?: number | undefined) => { - const client = getApollo(); - - const protocolResponse = await client.query< - ProtocolByBlockQuery, - ProtocolByBlockQueryVariables - >({ - query: ProtocolByBlockDocument, - variables: l2BlockNumber - ? { - block: { - number: l2BlockNumber, - }, - } - : undefined, - fetchPolicy: "network-only", - }); - - return protocolResponse?.data?.protocol?.totalActiveStake; + try { + const client = getApollo(); + + const protocolResponse = await client.query< + ProtocolByBlockQuery, + ProtocolByBlockQueryVariables + >({ + query: ProtocolByBlockDocument, + variables: l2BlockNumber + ? { + block: { + number: l2BlockNumber, + }, + } + : undefined, + fetchPolicy: "network-only", + }); + + 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 getL2BlockRangeForL1 = async (l1BlockNumber: number) => { diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index 5cc80eed..ce06159a 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -28,6 +28,7 @@ import { useVoteQuery, } from "apollo"; import { sentenceCase } from "change-case"; +import { getManualPoll, isManualPoll } from "constants/manualPolls"; import Head from "next/head"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -90,18 +91,22 @@ const Poll = () => { useEffect(() => { const init = async () => { - if (data && currentRound?.currentL1Block) { + // 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); + if (source && currentRound?.currentL1Block) { const response = await getPollExtended( - data.poll, + source, currentRound.currentL1Block ); setPollData(response); } }; init(); - }, [data, currentRound?.currentL1Block]); + }, [data, pollId, currentRound?.currentL1Block]); - if (pollError) { + // Only 404 on a genuine subgraph error with no manual fallback for this poll. + if (pollError && !getManualPoll(pollId)) { return ; } @@ -212,6 +217,23 @@ const Poll = () => { + {isManualPoll(pollData.id) && ( + + + 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. + + + )} { const currentRound = useCurrentRoundData(); useEffect(() => { - if (data && currentRound?.currentL1Block) { + // Render as soon as the (on-chain) current round is available, even if the + // subgraph query returned nothing, so manually-listed polls still show while + // indexing is down. mergeManualPolls tolerates `data` being undefined. + if (currentRound?.currentL1Block) { const init = async () => { setPolls( await Promise.all( - (data?.polls ?? []).map((p) => + mergeManualPolls(data?.polls).map((p) => getPollExtended(p, currentRound.currentL1Block) ) ) From e84d80e18cf5cc99ccd7b061586a843734c7f9ce Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 10:30:44 +0200 Subject: [PATCH 11/18] fix: source round length and lock state on-chain in the round tracker (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: source round length and lock state on-chain in the round tracker RoundStatus still mixed sources after #731: round timing came from the RoundsManager via /current-round, but roundLength and the lock state came from the subgraph (protocol.roundLength / protocol.lockPeriod). The lock state was re-derived client-side (blocksRemaining <= lockPeriod) because the subgraph, being event-driven, can't keep a per-block boolean fresh. Extend /api/current-round to also return roundLength and locked (from RoundsManager.roundLength and currentRoundLocked), and read both from useCurrentRoundData. The lock state now comes straight from currentRoundLocked — one authoritative snapshot instead of re-deriving it — and the round tracker (ring, blocks remaining, lock badge) is fully on-chain and consistent. The spinner now only waits on the on-chain round data, so the tracker renders even when the subgraph is fully down; the subgraph-only stats (fees, rewards, supply) are hidden in that case. * chore: drop redundant comment in RoundStatus * fix: show a loading skeleton for the round lock badge isRoundLocked falls back to false before currentRoundInfo resolves, so the header badge asserted "Initialized" while the round data was still loading. Render a skeleton until currentRoundInfo is available, matching the spinner in the content area below. --------- --- components/RoundStatus/index.tsx | 480 +++++++++++++++-------------- lib/api/types/get-current-round.ts | 2 + pages/api/current-round.tsx | 57 ++-- 3 files changed, 281 insertions(+), 258 deletions(-) diff --git a/components/RoundStatus/index.tsx b/components/RoundStatus/index.tsx index aeaba3b5..a8ab84d6 100644 --- a/components/RoundStatus/index.tsx +++ b/components/RoundStatus/index.tsx @@ -39,12 +39,12 @@ const Index = ({ const blocksRemaining = useMemo( () => - currentRoundInfo?.initialized && protocol - ? +protocol.roundLength - - (+Number(currentRoundInfo.currentL1Block) - - +Number(currentRoundInfo.startBlock)) + currentRoundInfo?.initialized + ? currentRoundInfo.roundLength - + (Number(currentRoundInfo.currentL1Block) - + Number(currentRoundInfo.startBlock)) : 0, - [protocol, currentRoundInfo] + [currentRoundInfo] ); const timeRemaining = useMemo( () => AVERAGE_L1_BLOCK_TIME * blocksRemaining, @@ -61,19 +61,13 @@ const Index = ({ const percentage = useMemo( () => - protocol - ? (blocksSinceCurrentRoundStart / +protocol.roundLength) * 100 + currentRoundInfo?.roundLength + ? (blocksSinceCurrentRoundStart / currentRoundInfo.roundLength) * 100 : 0, - [blocksSinceCurrentRoundStart, protocol] + [blocksSinceCurrentRoundStart, currentRoundInfo] ); - const isRoundLocked = useMemo( - () => - protocol && currentRoundInfo - ? blocksRemaining <= Number(protocol?.lockPeriod) - : false, - [protocol, blocksRemaining, currentRoundInfo] - ); + const isRoundLocked = currentRoundInfo?.locked ?? false; const rewardTokensClaimed = useMemo( () => @@ -126,50 +120,54 @@ const Index = ({ {currentRoundInfo?.id ? `#${currentRoundInfo.id}` : ""} - - {!isRoundLocked - ? "The current round is ongoing and orchestrators can currently update their parameters." - : "The current round is locked, which means that orchestrator parameters cannot be updated until the next round begins."} - - } - > - - - {!isRoundLocked ? "Initialized " : "Locked "} - - - {isRoundLocked ? ( - - ) : ( - + ) : ( + + {!isRoundLocked + ? "The current round is ongoing and orchestrators can currently update their parameters." + : "The current round is locked, which means that orchestrator parameters cannot be updated until the next round begins."} + + } + > + + - )} - - + > + {!isRoundLocked ? "Initialized " : "Locked "} + + + {isRoundLocked ? ( + + ) : ( + + )} + + + )} - {!currentRoundInfo || !protocol ? ( + {!currentRoundInfo ? ( - of {protocol.roundLength} blocks + of {currentRoundInfo.roundLength} blocks @@ -259,212 +257,218 @@ const Index = ({ begins. - - The amount of fees that have been paid out in the current - round. Equivalent to{" "} - {formatUSD(protocol?.currentRound?.volumeUSD, { - precision: 0, - abbreviate: true, - })}{" "} - at recent prices of ETH. - - } - > - - - - Fees - - - - - - - - {formatETH(protocol?.currentRound?.volumeETH, { - precision: 2, - })} - - - - - The amount of rewards which have been claimed by orchestrators - in the current round. - - } - > - - + + The amount of fees that have been paid out in the current + round. Equivalent to{" "} + {formatUSD(protocol?.currentRound?.volumeUSD, { + precision: 0, + abbreviate: true, + })}{" "} + at recent prices of ETH. + + } > - - Rewards - - - - - + + + Fees + + + + + - - {rewards} - - - - - The current total supply of LPT.} - > - + {formatETH(protocol?.currentRound?.volumeETH, { + precision: 2, + })} + + + + + The amount of rewards which have been claimed by + orchestrators in the current round. + + } > + + + Rewards + + + + + + - Total Supply + {rewards} - - - - - - {totalSupply !== null - ? formatLPT(totalSupply, { - precision: 0, - abbreviate: true, - }) - : "--"} - - - - Total supply change over the past 365 days.} - > - + - The current total supply of LPT.} > - - Supply Change (1Y) - - - - - + + + Total Supply + + + + + - + {totalSupply !== null + ? formatLPT(totalSupply, { + precision: 0, + abbreviate: true, + }) + : "--"} + + + + Total supply change over the past 365 days. + } > - {isSupplyChangeLoading ? ( - - ) : supplyChangeData?.supplyChange != null ? ( - formatPercent(supplyChangeData.supplyChange, { - precision: 2, - }) - ) : ( - "--" - )} - - - - + + + + Supply Change (1Y) + + + + + + + + {isSupplyChangeLoading ? ( + + ) : supplyChangeData?.supplyChange != null ? ( + formatPercent(supplyChangeData.supplyChange, { + precision: 2, + }) + ) : ( + "--" + )} + + + + + + )} ) : ( Date: Tue, 21 Jul 2026 11:04:12 +0200 Subject: [PATCH 12/18] fix: source the round on-chain in the pending-stake endpoint (#736) /api/pending-stake read the current round from the subgraph and passed it to on-chain pendingStake/pendingFees calls. When indexing falls behind, the stale round understates both values, and a subgraph error or empty response threw "No current round found" and returned a 500. Read currentRound from the RoundsManager via l2PublicClient instead, so the route is a pure contract read that no longer depends on subgraph health. Response shape is unchanged. This was the last caller of the subgraph-backed getCurrentRound() helper, so drop it. queries/currentRound.graphql stays in use by getOrchestrators(). --- lib/api/ssr.ts | 6 ------ pages/api/pending-stake/[address].tsx | 26 ++++++++++++++------------ 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/api/ssr.ts b/lib/api/ssr.ts index 061ad981..ac07ebce 100644 --- a/lib/api/ssr.ts +++ b/lib/api/ssr.ts @@ -75,12 +75,6 @@ export async function getGatewaySelfRedeem( return lastTimestamp >= cutoff; } -export async function getCurrentRound(client = getApollo()) { - return client.query({ - query: CurrentRoundDocument, - }); -} - export async function getOrchestrators(client = getApollo()) { const protocolResponse = await client.query< CurrentRoundQuery, diff --git a/pages/api/pending-stake/[address].tsx b/pages/api/pending-stake/[address].tsx index e5da3bef..1f3bb49e 100644 --- a/pages/api/pending-stake/[address].tsx +++ b/pages/api/pending-stake/[address].tsx @@ -1,6 +1,10 @@ -import { getCacheControlHeader, getCurrentRound } from "@lib/api"; +import { getCacheControlHeader } from "@lib/api"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; -import { getBondingManagerAddress } from "@lib/api/contracts"; +import { roundsManager } from "@lib/api/abis/main/RoundsManager"; +import { + getBondingManagerAddress, + getRoundsManagerAddress, +} from "@lib/api/contracts"; import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; import { PendingFeesAndStake } from "@lib/api/types/get-pending-stake"; import { l2PublicClient } from "@lib/chains"; @@ -20,17 +24,15 @@ const handler = async ( const { address } = req.query; if (!!address && !Array.isArray(address) && isAddress(address)) { - const bondingManagerAddress = await getBondingManagerAddress(); + const [bondingManagerAddress, roundsManagerAddress] = await Promise.all( + [getBondingManagerAddress(), getRoundsManagerAddress()] + ); - const { - data: { protocol }, - } = await getCurrentRound(); - const currentRoundString = protocol?.currentRound?.id; - - if (!currentRoundString) { - throw new Error("No current round found"); - } - const currentRound = BigInt(currentRoundString); + const currentRound = await l2PublicClient.readContract({ + address: roundsManagerAddress, + abi: roundsManager, + functionName: "currentRound", + }); const [pendingStake, pendingFees] = await l2PublicClient.multicall({ allowFailure: false, From d8e1168dda00c18a070fd35f695d8860e99e2abf Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 19:32:55 +0200 Subject: [PATCH 13/18] fix: temporarily compute the LIP-118 tally on-chain while the subgraph resyncs (#749) Stopgap while the subgraph resyncs from scratch (livepeer/subgraph#247), which leaves the manually surfaced LIP-118 poll showing 0% support and 0% participation even for people who already voted. Revert this and #734 once the subgraph has reindexed the poll. --------- Co-authored-by: Copilot --- constants/manualPolls.ts | 46 +++++- hooks/useSwr.tsx | 17 +++ lib/api/pollTally.ts | 262 ++++++++++++++++++++++++++++++++ lib/api/polls.ts | 46 +++++- lib/api/types/get-poll-tally.ts | 20 +++ pages/api/polls/tally/[poll].ts | 89 +++++++++++ pages/voting/[poll].tsx | 46 ++++-- 7 files changed, 503 insertions(+), 23 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..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; From 5b699433304122e6bc73fd3009c3eb7819f42c15 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 21 Jul 2026 21:33:45 +0200 Subject: [PATCH 14/18] fix: temporarily show unbonding locks the subgraph has not indexed (#748) Stopgap while the subgraph resyncs from scratch (livepeer/subgraph#247). Locks created after indexing stalled are missing from the withdrawal list, so an unbond appears to do nothing, and a matured lock stays invisible while being withdrawable on-chain. Lock ids are sequential and never reused, so anything the subgraph has not seen sits above its highest known id. Add /api/unbonding-locks/[address] to read that tail from the contract and append it to the subgraph's list rather than replacing it, with the round from /api/current-round since a stale round hides a matured lock's withdraw button. Only fetched when the subgraph reports degraded and on the user's own account, capped at 200 ids. Revert once the subgraph has reindexed. --------- Co-authored-by: CodeRabbit --- components/StakeTransactions/index.tsx | 70 ++++++++++++---- hooks/useSwr.tsx | 13 +++ lib/api/types/get-unbonding-locks.ts | 15 ++++ pages/api/unbonding-locks/[address].tsx | 102 ++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 lib/api/types/get-unbonding-locks.ts create mode 100644 pages/api/unbonding-locks/[address].tsx diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx index 1b4dd409..264c48c8 100644 --- a/components/StakeTransactions/index.tsx +++ b/components/StakeTransactions/index.tsx @@ -2,6 +2,11 @@ import { Box, Card, Flex, Heading, Text } from "@livepeer/design-system"; import { formatLPT } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; import { UnbondingLock } from "apollo"; +import { + useCurrentRoundData, + useSubgraphDegraded, + useUnbondingLocksData, +} from "hooks"; import { useMemo } from "react"; import { parseEther } from "viem"; @@ -13,20 +18,54 @@ import WithdrawStake from "../WithdrawStake"; const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { const isBonded = !!delegator.delegate; - const pendingStakeTransactions = useMemo(() => { - const roundId = parseInt(currentRound.id, 10); - return delegator.unbondingLocks.filter( - (item: UnbondingLock) => - item.withdrawRound && +item.withdrawRound > roundId - ); - }, [delegator.unbondingLocks, currentRound.id]); - const completedStakeTransactions = useMemo(() => { - const roundId = parseInt(currentRound.id, 10); - return delegator.unbondingLocks.filter( - (item: UnbondingLock) => - item.withdrawRound && +item.withdrawRound <= roundId - ); - }, [delegator.unbondingLocks, currentRound.id]); + // TEMPORARY, with the subgraph outage: locks created after indexing stopped + // are missing here, so an unbond looks like it never happened. Append the + // ones above the subgraph's highest known id, read from the chain. The round + // comes from the chain too, or a stale one leaves a matured lock stuck in + // "Pending" with no withdraw button. Remove once indexing has recovered. + const degraded = useSubgraphDegraded(); + + const nextLockId = useMemo( + () => + delegator.unbondingLocks.reduce( + (next: number, lock: UnbondingLock) => + Math.max(next, lock.unbondingLockId + 1), + 0 + ), + [delegator.unbondingLocks] + ); + const missing = useUnbondingLocksData( + degraded && isMyAccount ? delegator.id : null, + nextLockId + ); + + const locks = useMemo( + () => + missing?.locks.length + ? [...delegator.unbondingLocks, ...missing.locks] + : delegator.unbondingLocks, + [delegator.unbondingLocks, missing] + ); + + const onchainRound = useCurrentRoundData(); + const roundId = Number(onchainRound?.id ?? currentRound.id); + + const pendingStakeTransactions = useMemo( + () => + locks.filter( + (item: UnbondingLock) => + item.withdrawRound && +item.withdrawRound > roundId + ), + [locks, roundId] + ); + const completedStakeTransactions = useMemo( + () => + locks.filter( + (item: UnbondingLock) => + item.withdrawRound && +item.withdrawRound <= roundId + ), + [locks, roundId] + ); return ( @@ -75,8 +114,7 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { Tokens will be available for withdrawal in approximately{" "} - {+lock.withdrawRound - parseInt(currentRound.id, 10)}{" "} - days. + {+lock.withdrawRound - roundId} days. { return data ?? null; }; +export const useUnbondingLocksData = ( + address: string | undefined | null, + from: number +) => { + // `from` skips the ids the subgraph already has. See /api/unbonding-locks. + const { data } = useSWR( + address ? `/unbonding-locks/${address.toLowerCase()}?from=${from}` : null + ); + + return data ?? null; +}; + export const useL1DelegatorData = (address: string | undefined | null) => { const { data } = useSWR( address ? `/l1-delegator/${address.toLowerCase()}` : null diff --git a/lib/api/types/get-unbonding-locks.ts b/lib/api/types/get-unbonding-locks.ts new file mode 100644 index 00000000..d5d9cf38 --- /dev/null +++ b/lib/api/types/get-unbonding-locks.ts @@ -0,0 +1,15 @@ +export type UnbondingLockInfo = { + id: string; + unbondingLockId: number; + amount: string; + withdrawRound: string; + /** + * The contract does not record which orchestrator a lock was unbonded from, + * so this is the delegator's current delegate. + */ + delegate: { id: string }; +}; + +export type UnbondingLocks = { + locks: UnbondingLockInfo[]; +}; diff --git a/pages/api/unbonding-locks/[address].tsx b/pages/api/unbonding-locks/[address].tsx new file mode 100644 index 00000000..b00ae040 --- /dev/null +++ b/pages/api/unbonding-locks/[address].tsx @@ -0,0 +1,102 @@ +import { getCacheControlHeader } from "@lib/api"; +import { bondingManager } from "@lib/api/abis/main/BondingManager"; +import { getBondingManagerAddress } from "@lib/api/contracts"; +import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; +import { UnbondingLocks } from "@lib/api/types/get-unbonding-locks"; +import { l2PublicClient } from "@lib/chains"; +import { NextApiRequest, NextApiResponse } from "next"; +import { formatEther, isAddress } from "viem"; + +/** Lock ids read per request, so a caller cannot ask for an unbounded scan. */ +const MAX_SCAN = 200; + +/** + * TEMPORARY stopgap, paired with the subgraph outage: returns the delegator's + * unbonding locks from `from` onwards, so the UI can show locks created after + * the subgraph stopped indexing. Everything the subgraph already knows keeps + * coming from the subgraph. + * + * Remove this route along with its caller once indexing has recovered. + */ +const handler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + try { + const method = req.method; + + if (method === "GET") { + res.setHeader("Cache-Control", getCacheControlHeader("revalidate")); + + const { address, from } = req.query; + + if (!!address && !Array.isArray(address) && isAddress(address)) { + const bondingManagerAddress = await getBondingManagerAddress(); + + const contract = { + address: bondingManagerAddress, + abi: bondingManager, + } as const; + + // [bondedAmount, fees, delegateAddress, delegatedAmount, startRound, + // lastClaimRound, nextUnbondingLockId] + const delegator = await l2PublicClient.readContract({ + ...contract, + functionName: "getDelegator", + args: [address], + }); + const delegateAddress = delegator[2]; + const end = Number(delegator[6]); + + // Ids are sequential and never reused, so anything the subgraph has not + // seen sits above its highest known id. Always cover the newest ids, + // whatever `from` asks for, and never read more than MAX_SCAN of them. + const requested = Number(Array.isArray(from) ? from[0] : from); + const start = Math.max( + 0, + Number.isInteger(requested) ? requested : 0, + end - MAX_SCAN + ); + + const results = await l2PublicClient.multicall({ + allowFailure: false, + contracts: Array.from( + { length: Math.max(0, end - start) }, + (_, i) => ({ + ...contract, + functionName: "getDelegatorUnbondingLock" as const, + args: [address, BigInt(start + i)] as const, + }) + ), + }); + + // Withdrawing and rebonding both delete the lock, so a non-zero amount + // is what makes one open. + const locks = results + .map(([amount, withdrawRound], i) => ({ + amount, + withdrawRound, + id: start + i, + })) + .filter(({ amount }) => amount > 0n) + .map(({ amount, withdrawRound, id }) => ({ + id: `${address.toLowerCase()}-${id}`, + unbondingLockId: id, + amount: formatEther(amount), + withdrawRound: withdrawRound.toString(), + delegate: { id: delegateAddress.toLowerCase() }, + })); + + return res.status(200).json({ locks }); + } else { + return badRequest(res, "Invalid address format"); + } + } + + return methodNotAllowed(res, method ?? "unknown", ["GET"]); + } catch (err) { + return internalError(res, err); + } +}; + +export default handler; From 0f16ef53b4aeb350dc35eccca888d37c77b70256 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 22 Jul 2026 21:36:56 +0200 Subject: [PATCH 15/18] =?UTF-8?q?Revert=20"fix:=20temporarily=20show=20unb?= =?UTF-8?q?onding=20locks=20the=20subgraph=20has=20not=20indexed=20(#?= =?UTF-8?q?=E2=80=A6"=20(#752)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 081de4be4a4e21fc694dd018da4a938d029644d0 as the new subgraph has been indexed. --- components/StakeTransactions/index.tsx | 70 ++++------------ hooks/useSwr.tsx | 13 --- lib/api/types/get-unbonding-locks.ts | 15 ---- pages/api/unbonding-locks/[address].tsx | 102 ------------------------ 4 files changed, 16 insertions(+), 184 deletions(-) delete mode 100644 lib/api/types/get-unbonding-locks.ts delete mode 100644 pages/api/unbonding-locks/[address].tsx diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx index 264c48c8..1b4dd409 100644 --- a/components/StakeTransactions/index.tsx +++ b/components/StakeTransactions/index.tsx @@ -2,11 +2,6 @@ import { Box, Card, Flex, Heading, Text } from "@livepeer/design-system"; import { formatLPT } from "@utils/numberFormatters"; import { formatAddress } from "@utils/web3"; import { UnbondingLock } from "apollo"; -import { - useCurrentRoundData, - useSubgraphDegraded, - useUnbondingLocksData, -} from "hooks"; import { useMemo } from "react"; import { parseEther } from "viem"; @@ -18,54 +13,20 @@ import WithdrawStake from "../WithdrawStake"; const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { const isBonded = !!delegator.delegate; - // TEMPORARY, with the subgraph outage: locks created after indexing stopped - // are missing here, so an unbond looks like it never happened. Append the - // ones above the subgraph's highest known id, read from the chain. The round - // comes from the chain too, or a stale one leaves a matured lock stuck in - // "Pending" with no withdraw button. Remove once indexing has recovered. - const degraded = useSubgraphDegraded(); - - const nextLockId = useMemo( - () => - delegator.unbondingLocks.reduce( - (next: number, lock: UnbondingLock) => - Math.max(next, lock.unbondingLockId + 1), - 0 - ), - [delegator.unbondingLocks] - ); - const missing = useUnbondingLocksData( - degraded && isMyAccount ? delegator.id : null, - nextLockId - ); - - const locks = useMemo( - () => - missing?.locks.length - ? [...delegator.unbondingLocks, ...missing.locks] - : delegator.unbondingLocks, - [delegator.unbondingLocks, missing] - ); - - const onchainRound = useCurrentRoundData(); - const roundId = Number(onchainRound?.id ?? currentRound.id); - - const pendingStakeTransactions = useMemo( - () => - locks.filter( - (item: UnbondingLock) => - item.withdrawRound && +item.withdrawRound > roundId - ), - [locks, roundId] - ); - const completedStakeTransactions = useMemo( - () => - locks.filter( - (item: UnbondingLock) => - item.withdrawRound && +item.withdrawRound <= roundId - ), - [locks, roundId] - ); + const pendingStakeTransactions = useMemo(() => { + const roundId = parseInt(currentRound.id, 10); + return delegator.unbondingLocks.filter( + (item: UnbondingLock) => + item.withdrawRound && +item.withdrawRound > roundId + ); + }, [delegator.unbondingLocks, currentRound.id]); + const completedStakeTransactions = useMemo(() => { + const roundId = parseInt(currentRound.id, 10); + return delegator.unbondingLocks.filter( + (item: UnbondingLock) => + item.withdrawRound && +item.withdrawRound <= roundId + ); + }, [delegator.unbondingLocks, currentRound.id]); return ( @@ -114,7 +75,8 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { Tokens will be available for withdrawal in approximately{" "} - {+lock.withdrawRound - roundId} days. + {+lock.withdrawRound - parseInt(currentRound.id, 10)}{" "} + days. { return data ?? null; }; -export const useUnbondingLocksData = ( - address: string | undefined | null, - from: number -) => { - // `from` skips the ids the subgraph already has. See /api/unbonding-locks. - const { data } = useSWR( - address ? `/unbonding-locks/${address.toLowerCase()}?from=${from}` : null - ); - - return data ?? null; -}; - export const useL1DelegatorData = (address: string | undefined | null) => { const { data } = useSWR( address ? `/l1-delegator/${address.toLowerCase()}` : null diff --git a/lib/api/types/get-unbonding-locks.ts b/lib/api/types/get-unbonding-locks.ts deleted file mode 100644 index d5d9cf38..00000000 --- a/lib/api/types/get-unbonding-locks.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type UnbondingLockInfo = { - id: string; - unbondingLockId: number; - amount: string; - withdrawRound: string; - /** - * The contract does not record which orchestrator a lock was unbonded from, - * so this is the delegator's current delegate. - */ - delegate: { id: string }; -}; - -export type UnbondingLocks = { - locks: UnbondingLockInfo[]; -}; diff --git a/pages/api/unbonding-locks/[address].tsx b/pages/api/unbonding-locks/[address].tsx deleted file mode 100644 index b00ae040..00000000 --- a/pages/api/unbonding-locks/[address].tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { getCacheControlHeader } from "@lib/api"; -import { bondingManager } from "@lib/api/abis/main/BondingManager"; -import { getBondingManagerAddress } from "@lib/api/contracts"; -import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; -import { UnbondingLocks } from "@lib/api/types/get-unbonding-locks"; -import { l2PublicClient } from "@lib/chains"; -import { NextApiRequest, NextApiResponse } from "next"; -import { formatEther, isAddress } from "viem"; - -/** Lock ids read per request, so a caller cannot ask for an unbounded scan. */ -const MAX_SCAN = 200; - -/** - * TEMPORARY stopgap, paired with the subgraph outage: returns the delegator's - * unbonding locks from `from` onwards, so the UI can show locks created after - * the subgraph stopped indexing. Everything the subgraph already knows keeps - * coming from the subgraph. - * - * Remove this route along with its caller once indexing has recovered. - */ -const handler = async ( - req: NextApiRequest, - res: NextApiResponse -) => { - try { - const method = req.method; - - if (method === "GET") { - res.setHeader("Cache-Control", getCacheControlHeader("revalidate")); - - const { address, from } = req.query; - - if (!!address && !Array.isArray(address) && isAddress(address)) { - const bondingManagerAddress = await getBondingManagerAddress(); - - const contract = { - address: bondingManagerAddress, - abi: bondingManager, - } as const; - - // [bondedAmount, fees, delegateAddress, delegatedAmount, startRound, - // lastClaimRound, nextUnbondingLockId] - const delegator = await l2PublicClient.readContract({ - ...contract, - functionName: "getDelegator", - args: [address], - }); - const delegateAddress = delegator[2]; - const end = Number(delegator[6]); - - // Ids are sequential and never reused, so anything the subgraph has not - // seen sits above its highest known id. Always cover the newest ids, - // whatever `from` asks for, and never read more than MAX_SCAN of them. - const requested = Number(Array.isArray(from) ? from[0] : from); - const start = Math.max( - 0, - Number.isInteger(requested) ? requested : 0, - end - MAX_SCAN - ); - - const results = await l2PublicClient.multicall({ - allowFailure: false, - contracts: Array.from( - { length: Math.max(0, end - start) }, - (_, i) => ({ - ...contract, - functionName: "getDelegatorUnbondingLock" as const, - args: [address, BigInt(start + i)] as const, - }) - ), - }); - - // Withdrawing and rebonding both delete the lock, so a non-zero amount - // is what makes one open. - const locks = results - .map(([amount, withdrawRound], i) => ({ - amount, - withdrawRound, - id: start + i, - })) - .filter(({ amount }) => amount > 0n) - .map(({ amount, withdrawRound, id }) => ({ - id: `${address.toLowerCase()}-${id}`, - unbondingLockId: id, - amount: formatEther(amount), - withdrawRound: withdrawRound.toString(), - delegate: { id: delegateAddress.toLowerCase() }, - })); - - return res.status(200).json({ locks }); - } else { - return badRequest(res, "Invalid address format"); - } - } - - return methodNotAllowed(res, method ?? "unknown", ["GET"]); - } catch (err) { - return internalError(res, err); - } -}; - -export default handler; From d584361d9a054822dbd6bd4e71fade98fe05e16d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 22 Jul 2026 21:37:23 +0200 Subject: [PATCH 16/18] =?UTF-8?q?Revert=20"fix:=20temporarily=20compute=20?= =?UTF-8?q?the=20LIP-118=20tally=20on-chain=20while=20the=20subgrap?= =?UTF-8?q?=E2=80=A6"=20(#750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 98f56949a5c46098f5c1b127574b8134b225b47c as the new subgraph has been indexed. --- constants/manualPolls.ts | 46 +----- hooks/useSwr.tsx | 17 --- lib/api/pollTally.ts | 262 -------------------------------- lib/api/polls.ts | 46 +----- lib/api/types/get-poll-tally.ts | 20 --- pages/api/polls/tally/[poll].ts | 89 ----------- pages/voting/[poll].tsx | 46 ++---- 7 files changed, 23 insertions(+), 503 deletions(-) delete mode 100644 lib/api/pollTally.ts delete mode 100644 lib/api/types/get-poll-tally.ts delete mode 100644 pages/api/polls/tally/[poll].ts 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; From c8a15eb9249e1ec534bb877b1752024099ba39ef Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 22 Jul 2026 21:59:45 +0200 Subject: [PATCH 17/18] =?UTF-8?q?Revert=20"fix:=20manually=20surface=20LIP?= =?UTF-8?q?-118=20poll=20while=20subgraph=20indexing=20is=20behind=20?= =?UTF-8?q?=E2=80=A6"=20(#753)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 29fd3cc86fd2f1168ab7bd57fef02d8d7a8b3521 now the new subgraph is fully indexed. --- constants/manualPolls.ts | 58 ---------------------------------------- layouts/main.tsx | 3 +-- lib/api/polls.ts | 45 +++++++++++++------------------ pages/voting/[poll].tsx | 30 +++------------------ pages/voting/index.tsx | 8 ++---- 5 files changed, 25 insertions(+), 119 deletions(-) delete mode 100644 constants/manualPolls.ts diff --git a/constants/manualPolls.ts b/constants/manualPolls.ts deleted file mode 100644 index 5585421b..00000000 --- a/constants/manualPolls.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 - * outage. Entries are shaped like subgraph poll results and merged into the - * 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. - * - * Remove entries (or delete this file and its imports) once indexing recovers. - */ -type SubgraphPoll = PollsQuery["polls"][number]; - -export const MANUAL_POLLS: SubgraphPoll[] = [ - { - // LIP-118 "Delegated Reward Calling" — created during the subgraph indexing halt. - // tx: 0xb8ab2a824b4a5b16b76be99f1313a8238f35acc2259b139562277e1df8ba02e4 - __typename: "Poll", - id: "0x74479927117d4158b32c03d5400c14bdd7e6a46a", - proposal: "QmcYGZp3SipMEz699bpeZqocdouYWTE2zHG8TMdaSYqowF", - endBlock: "25622119", - quorum: "333300", - quota: "500000", - tally: null, - votes: [], - }, -]; - -const norm = (id: string) => id.toLowerCase(); - -/** The manually-listed poll for `id`, if any. */ -export const getManualPoll = ( - id: string | undefined | null -): 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). */ -export const isManualPoll = (id: string | undefined | null): boolean => - Boolean(getManualPoll(id)); - -/** - * Merge manual polls into a subgraph poll list, deduped by address. The subgraph - * copy takes precedence, so a poll that has since been indexed is not duplicated. - */ -export const mergeManualPolls = ( - polls: SubgraphPoll[] | undefined -): SubgraphPoll[] => { - const list = polls ? [...polls] : []; - const seen = new Set(list.map((p) => norm(p.id))); - for (const poll of MANUAL_POLLS) { - if (!seen.has(norm(poll.id))) list.push(poll); - } - return list; -}; diff --git a/layouts/main.tsx b/layouts/main.tsx index f1700b5e..68a9627c 100644 --- a/layouts/main.tsx +++ b/layouts/main.tsx @@ -40,7 +40,6 @@ import { useTreasuryProposalsQuery, } from "apollo"; import { BRIDGE_LPT_URL, GET_LPT_URL } from "constants/links"; -import { mergeManualPolls } from "constants/manualPolls"; import { BigNumber } from "ethers"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; import dynamic from "next/dynamic"; @@ -179,7 +178,7 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => { const totalActivePolls = useMemo( () => - mergeManualPolls(pollData?.polls).filter( + pollData?.polls.filter( (p) => (currentRound?.currentL1Block ?? Number.MAX_VALUE) <= parseInt(p.endBlock) diff --git a/lib/api/polls.ts b/lib/api/polls.ts index 12f6bb48..87192da4 100644 --- a/lib/api/polls.ts +++ b/lib/api/polls.ts @@ -170,33 +170,24 @@ const getEstimatedEndTimeByBlockNumber = async ( }; const getTotalStake = async (l2BlockNumber?: number | undefined) => { - try { - const client = getApollo(); - - const protocolResponse = await client.query< - ProtocolByBlockQuery, - ProtocolByBlockQueryVariables - >({ - query: ProtocolByBlockDocument, - variables: l2BlockNumber - ? { - block: { - number: l2BlockNumber, - }, - } - : undefined, - fetchPolicy: "network-only", - }); - - 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 client = getApollo(); + + const protocolResponse = await client.query< + ProtocolByBlockQuery, + ProtocolByBlockQueryVariables + >({ + query: ProtocolByBlockDocument, + variables: l2BlockNumber + ? { + block: { + number: l2BlockNumber, + }, + } + : undefined, + fetchPolicy: "network-only", + }); + + return protocolResponse?.data?.protocol?.totalActiveStake; }; const getL2BlockRangeForL1 = async (l1BlockNumber: number) => { diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index ce06159a..5cc80eed 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -28,7 +28,6 @@ import { useVoteQuery, } from "apollo"; import { sentenceCase } from "change-case"; -import { getManualPoll, isManualPoll } from "constants/manualPolls"; import Head from "next/head"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; @@ -91,22 +90,18 @@ const Poll = () => { 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); - if (source && currentRound?.currentL1Block) { + if (data && currentRound?.currentL1Block) { const response = await getPollExtended( - source, + data.poll, currentRound.currentL1Block ); setPollData(response); } }; init(); - }, [data, pollId, currentRound?.currentL1Block]); + }, [data, currentRound?.currentL1Block]); - // Only 404 on a genuine subgraph error with no manual fallback for this poll. - if (pollError && !getManualPoll(pollId)) { + if (pollError) { return ; } @@ -217,23 +212,6 @@ const Poll = () => { - {isManualPoll(pollData.id) && ( - - - 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. - - - )} { const currentRound = useCurrentRoundData(); useEffect(() => { - // Render as soon as the (on-chain) current round is available, even if the - // subgraph query returned nothing, so manually-listed polls still show while - // indexing is down. mergeManualPolls tolerates `data` being undefined. - if (currentRound?.currentL1Block) { + if (data && currentRound?.currentL1Block) { const init = async () => { setPolls( await Promise.all( - mergeManualPolls(data?.polls).map((p) => + (data?.polls ?? []).map((p) => getPollExtended(p, currentRound.currentL1Block) ) ) From dc8e5bcbdedbbebcc8eea4da73bf14ee1d96f08f Mon Sep 17 00:00:00 2001 From: Moudi Network Date: Fri, 24 Jul 2026 14:06:52 +0000 Subject: [PATCH 18/18] DelegatorList ordinal uses row.index not visible order --- components/DelegatorList/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/components/DelegatorList/index.tsx b/components/DelegatorList/index.tsx index 7a7357fa..0a65cf60 100644 --- a/components/DelegatorList/index.tsx +++ b/components/DelegatorList/index.tsx @@ -77,8 +77,12 @@ const DelegatorList = ({ ), accessor: "id", - Cell: ({ row, value }) => { + Cell: ({ row, rows, value }) => { const address = value as string; + // row.index is the position in the original data array; use the row's + // position in the sorted row model so the ordinal reflects the visible + // order and stays continuous across sorting and pagination. + const ordinal = rows.indexOf(row) + 1; const identity = useEnsData(address); const ensName = identity?.name; const shortAddress = address.replace(address.slice(6, 38), "…"); @@ -113,7 +117,7 @@ const DelegatorList = ({ alignItems: "center", }} > - {row.index + 1} + {ordinal}