Skip to content

fix: temporarily compute the LIP-118 tally on-chain while the subgraph resyncs#749

Merged
rickstaa merged 5 commits into
mainfrom
fix/lip118-onchain-poll-tally
Jul 21, 2026
Merged

fix: temporarily compute the LIP-118 tally on-chain while the subgraph resyncs#749
rickstaa merged 5 commits into
mainfrom
fix/lip118-onchain-poll-tally

Conversation

@rickstaa

@rickstaa rickstaa commented Jul 21, 2026

Copy link
Copy Markdown
Member

Warning

Bandaid — revert once the subgraph has reindexed LIP-118.
Only exists because the subgraph is resyncing from scratch
(livepeer/subgraph#247) and cannot serve poll tallies. Revert this and #734
together; neither is meant to stay.

Problem

The voting UI sources its stake-weighted counts from the subgraph. The subgraph is resyncing from scratch (livepeer/subgraph#247, currently ~block 5.8M of ~486M), so the manually surfaced LIP-118 poll (#734) is reachable and votable but shows 0% support / 0% participation — including for people who already voted, whose sidebar reads "My Vote: N/A".

Change

Adds GET /api/polls/tally/[poll], which recomputes the tally from chain state.

The Poll contract stores nothing — vote() only emits Vote(voter, choiceID) — so the endpoint reads the vote logs and weights each voter through BondingManager, following the rules the subgraph documents in src/mappings/poll.ts:

  • a registered transcoder votes with its total stake (own + delegated)
  • weight is clamped at zero — transcoderTotalStake reads the transcoder pool and returns 0 outside it, which would otherwise make a delegate's weight negative
  • a delegator that votes itself is subtracted from its delegate's weight (nonVoteStake), so stake is never double counted
  • only Yes/No count, latest vote per address wins

The route is restricted to MANUAL_POLLS, so it can't be pointed at arbitrary contracts.

Also:

  • getTotalStake falls back to BondingManager.getTotalBonded() when the subgraph is unavailable. The protocol entity is currently null, which would otherwise leave participation at 0% even with a tally present.
  • The poll page recovers "My Vote" from the computed tally, so a voter isn't shown N/A and prompted to vote (and pay gas) twice.

RPC cost

Infura caps eth_getLogs at 10k blocks on Arbitrum, so scans are chunked under that limit, 10 in flight. A scan cursor advances so only new blocks are read after the first pass, and results are memoised for 60s in-process as well as via Cache-Control at the CDN.

requests
cold scan (once per process) ~92 today, ~320 by poll end — measured 0.52s
steady state 1 getLogs + one batched round of contract reads, max once/60s

Verification

Run against Arbitrum with the production Infura key:

GET /api/polls/tally/0x74479927117d4158b32c03d5400c14bdd7e6a46a → 200
{"tally":{"yes":"15950.236512152106011868","no":"0"}, "totalStake":"29313650.734432784536262179", ...}

Matches an independent cast computation to the wei. Page renders 100% support / 15.95K LPT for / 0.05% participation. Guards checked: unknown poll → 404, malformed address → 400, POST → 405.

Known limitation

Stake is read as of now. That matches the subgraph while a poll is open, but the real result freezes when voting closes whereas this keeps moving as stake is bonded, unbonded, or earns rewards — enough to flip the derived passed/rejected status.

Pinning the reads to the poll's end block was implemented and tested, then dropped: it needs NodeInterface.l2BlockRangeForL1 to map the L1 end block onto L2, and non-archive Arbitrum nodes stop resolving that mapping past roughly 67-74 days (measured identically on Infura, arb1 and publicnode). It traded a small drift for a hard failure. The same boundary explains the existing getL2BlockRangeForL1 TODO in lib/api/polls.ts: block 18328665 is a 2023 L1 block, well outside the window. Documented in the module comment instead.

Voting closes in ~6 days and the subgraph needs ~2, so this window is not expected to open. If it does, treat the numbers as indicative and confirm from the vote events at the end block.

Revert

This PR is a stopgap and should be reverted, not maintained.

Revert when the subgraph has reindexed LIP-118 (polls and pollTally populated
for 0x74479927117d4158b32c03d5400c14bdd7e6a46a):

  1. Revert this PR via GitHub's Revert button, matching the precedent of
    Revert "refactor: temporarily hide minute usage data with warning overlay (#452)" (#470).
  2. Revert fix: manually surface LIP-118 poll while subgraph indexing is behind #734 as well, which added constants/manualPolls.ts for the same outage.

Reverting removes lib/api/pollTally.ts, GET /api/polls/tally/[poll], the
"My Vote" recovery on the poll page, and the getTotalBonded fallback in
getTotalStake.

Nothing depends on it staying: the UI prefers subgraph data wherever it exists,
so a reindexed poll drops the manual entry and the computed tally on its own,
even before the revert lands. The only cost of leaving it in place is carrying
governance-critical code that duplicates the subgraph's rules and can drift
from them.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added on-chain, stake-weighted tallying for supported manual polls.
    • Poll results now include detailed vote information and refresh approximately every minute.
    • Voting pages can identify whether you have already voted, even when indexed data is delayed.
  • Bug Fixes

    • Added a fallback to on-chain totals when subgraph data is unavailable or outdated.
    • Improved reliability when retrieving poll results across larger block ranges.

The voting UI takes its stake-weighted counts from the subgraph, so the
manually surfaced LIP-118 poll (#734) renders 0% support and 0%
participation — the poll is reachable and votable, but shows no results.

Add /api/polls/tally/[poll], which recomputes a poll's tally from chain
state: read the Poll contract's Vote logs, then weight each voter through
BondingManager, mirroring the subgraph's rules (a registered transcoder
votes with its total stake; a delegator that votes itself is subtracted
from its delegate's weight; latest vote per address wins). The route is
restricted to MANUAL_POLLS so it can't be pointed at arbitrary contracts.

Kept cheap on the RPC: log scans are chunked under Infura's 10k block
range cap, the scan cursor advances so only new blocks are read after the
first pass, and results are memoised for 60s in-process as well as at the
CDN. Steady state is one getLogs plus a batched round of contract reads
per minute.

getTotalStake now falls back to BondingManager.getTotalBonded() when the
subgraph is unavailable — the protocol entity is currently null, which
would otherwise leave participation at 0%. The poll page also recovers
"My Vote" from the computed tally, so a voter isn't told N/A and prompted
to vote a second time.

Tallies read stake as it is now, which matches the subgraph while a poll
is open but drifts once one closes; pinning reads to the end block needs
NodeInterface.l2BlockRangeForL1, which reverts intermittently for the same
input, so that is documented rather than attempted.

Revert once the subgraph has reindexed the poll.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Copilot AI review requested due to automatic review settings July 21, 2026 15:09
@rickstaa
rickstaa requested a review from ECWireless as a code owner July 21, 2026 15:09
@vercel

vercel Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
explorer-arbitrum-one Ready Ready Preview, Comment Jul 21, 2026 5:29pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Manual polls now support on-chain stake-weighted tally computation through a cached API endpoint. The voting page refreshes these tallies periodically, merges them with poll data, and uses on-chain vote information when subgraph data is unavailable.

Changes

Manual poll tally fallback

Layer / File(s) Summary
Manual tally contracts and poll types
constants/manualPolls.ts, lib/api/types/get-poll-tally.ts
Manual polls include startBlockL2; shared types and helpers represent and merge on-chain tally data.
On-chain vote and stake computation
lib/api/pollTally.ts, lib/api/polls.ts
Vote logs are scanned incrementally, voter choices are cached across a reorg buffer, and bonding-manager state provides stake-weighted totals with a subgraph fallback for total stake.
Cached manual tally API
pages/api/polls/tally/[poll].ts
A GET-only route validates manual polls, computes tallies from configured start blocks, caches results for one minute, and retries failed computations.
Voting-page manual tally integration
hooks/useSwr.tsx, pages/voting/[poll].tsx
The voting page fetches manual tallies, merges them into poll data, derives fallback vote status, and passes it to desktop and mobile widgets.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VotingPage
  participant useManualPollTally
  participant TallyAPI
  participant getPollTally
  participant OnChainContracts
  VotingPage->>useManualPollTally: request tally for manual poll
  useManualPollTally->>TallyAPI: GET /polls/tally/{poll}
  TallyAPI->>getPollTally: compute tally from startBlockL2
  getPollTally->>OnChainContracts: read votes, rounds, and stake data
  OnChainContracts-->>getPollTally: return on-chain state
  getPollTally-->>TallyAPI: return PollTally
  TallyAPI-->>useManualPollTally: return cached tally
  useManualPollTally-->>VotingPage: update poll totals and vote status
Loading

Possibly related PRs

  • livepeer/explorer#734: Updates manual poll handling and voting-page behavior while the subgraph is behind.

Suggested reviewers: ecwireless

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: a temporary on-chain tally fallback for LIP-118 while the subgraph resyncs.
Description check ✅ Passed It covers the problem, implementation, verification, risk, and rollback; only the template headings/checkbox sections are omitted.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/lip118-onchain-poll-tally

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an on-chain fallback path to compute governance poll tallies while the subgraph is still reindexing, so the voting UI can show stake-weighted support/participation and recover “My Vote” for manually surfaced polls.

Changes:

  • Add GET /api/polls/tally/[poll] to compute and cache a poll tally from on-chain vote logs + BondingManager stake reads (restricted to MANUAL_POLLS).
  • Add useManualPollTally + UI wiring to fill missing tallies for manual polls and recover the user’s vote from the computed tally.
  • Update getTotalStake to fall back to on-chain BondingManager.getTotalBonded() when the subgraph query fails.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pages/voting/[poll].tsx Uses the on-chain tally for manual polls and falls back to it for “My Vote”.
pages/api/polls/tally/[poll].ts New API route that serves a cached on-chain-computed tally for manual polls only.
lib/api/types/get-poll-tally.ts Adds types for the on-chain-computed tally payload.
lib/api/pollTally.ts Implements log scanning + stake weighting to compute tallies from chain state.
lib/api/polls.ts Adds on-chain fallback for total stake used in participation calculations.
hooks/useSwr.tsx Adds useManualPollTally SWR hook to poll the new endpoint.
constants/manualPolls.ts Extends manual poll entries with startBlockL2 and adds withManualTally helper.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/api/polls.ts
Comment thread lib/api/pollTally.ts Outdated
Comment thread lib/api/pollTally.ts Outdated
Comment thread pages/api/polls/tally/[poll].ts
A reindexing subgraph answers successfully with an empty protocol entity
rather than failing, so getTotalStake returned undefined and left
participation at 0% — the symptom the on-chain fallback was added to fix.
Split the subgraph read out and branch on the value, so the fallback also
covers a successful response with nothing in it.

Drop totalStake, updatedAt and registeredTranscoder from the tally
response: nothing reads them, and totalStake in particular is misleading
since participation takes its denominator from getTotalStake. Removing it
also drops the getTotalBonded read that fed it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pages/api/polls/tally/[poll].ts (1)

1-90: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add maxDuration to pages/api/polls/tally/[poll].ts

  • Problem: This route can spend tens of seconds on a cold tally, but it doesn’t export config.maxDuration.
  • Why it matters: The first request per poll per instance can hit serverless timeouts before the result is cached, causing repeated failures on the same slow path.
  • Suggested fix: Export a route config with a maxDuration high enough for the worst-case cold scan.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/api/polls/tally/`[poll].ts around lines 1 - 90, Export a Next.js route
config alongside the default handler in this file, setting config.maxDuration
high enough to cover the worst-case cold getCachedPollTally scan before the
result is cached.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/api/pollTally.ts`:
- Around line 66-101: Add unit tests covering getVoteLogs and getPollTally,
including chunked range fallback, reorg-buffer rescans, self-delegation versus
registration, and delegator exclusion. Mock the range-log and delegation
dependencies to verify stake-weighted totals and poll outcomes across these edge
cases.
- Around line 210-243: Align the `voteStake` selection in the `voters.map` block
with the `excluded` calculation in the `tallied.map` block by using the same
registration/self-delegation condition. Ensure unregistered self-delegated
addresses cannot retain delegated stake that is also counted through
`nonVoteStake`, preventing double-counting while preserving registered
transcoder weighting.

---

Outside diff comments:
In `@pages/api/polls/tally/`[poll].ts:
- Around line 1-90: Export a Next.js route config alongside the default handler
in this file, setting config.maxDuration high enough to cover the worst-case
cold getCachedPollTally scan before the result is cached.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d5bdd7c8-fc56-497b-87e3-b880dfdb4774

📥 Commits

Reviewing files that changed from the base of the PR and between 6f2a4a1 and 8116b80.

📒 Files selected for processing (7)
  • constants/manualPolls.ts
  • hooks/useSwr.tsx
  • lib/api/pollTally.ts
  • lib/api/polls.ts
  • lib/api/types/get-poll-tally.ts
  • pages/api/polls/tally/[poll].ts
  • pages/voting/[poll].tsx

Comment thread lib/api/pollTally.ts
Comment thread lib/api/pollTally.ts Outdated
rickstaa and others added 2 commits July 21, 2026 19:12
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The documented rule gives a registered transcoder its total delegated stake and
everyone else their own pending stake. Selecting on self-delegation matches that
only because isRegisteredTranscoder is defined as self-delegated with a non-zero
bonded amount.

Where the two diverge is an orchestrator that unbonded its own stake while
delegators remain: still self-delegated, no longer registered, and holding
delegated stake it would otherwise vote with.

Self-delegation still decides whether a vote is subtracted from a delegate's
weight, which is a question about delegating elsewhere rather than registration.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Co-Authored-By: Copilot <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
transcoderTotalStake reads the transcoder pool, which returns zero for an
orchestrator outside it, while its delegators' pending stake is still
subtracted. The resulting negative weight was added to the yes or no total,
reducing it by the stake of everyone who voted.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Co-Authored-By: Copilot <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
@rickstaa rickstaa changed the title fix: compute LIP-118 vote tally on-chain while the subgraph is behind fix: temporarily compute the LIP-118 tally on-chain while the subgraph resyncs Jul 21, 2026
@rickstaa
rickstaa merged commit 98f5694 into main Jul 21, 2026
8 of 9 checks passed
@rickstaa
rickstaa deleted the fix/lip118-onchain-poll-tally branch July 21, 2026 17:32
moudi-network pushed a commit to moudi-network/explorer that referenced this pull request Jul 24, 2026
…h resyncs (livepeer#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 livepeer#734 once the subgraph has reindexed the poll.
---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Copilot <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
moudi-network pushed a commit to moudi-network/explorer that referenced this pull request Jul 24, 2026
…h resyncs (livepeer#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 livepeer#734 once the subgraph has reindexed the poll.
---------

Co-authored-by: Copilot <copilot-pull-request-reviewer[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants