Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions constants/manualPolls.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
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.
* 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
Expand All @@ -27,6 +34,7 @@ export const MANUAL_POLLS: SubgraphPoll[] = [
quota: "500000",
tally: null,
votes: [],
startBlockL2: 485384564,
},
];

Expand All @@ -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). */
Expand All @@ -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)}`,
})),
};
};
17 changes: 17 additions & 0 deletions hooks/useSwr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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<PollTally>(
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
) => {
Expand Down
262 changes: 262 additions & 0 deletions lib/api/pollTally.ts
Original file line number Diff line number Diff line change
@@ -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<string, PollTallyVote["choice"]> = { 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<ReturnType<typeof getRangeLogs>>) =>
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;
}
};
Comment thread
rickstaa marked this conversation as resolved.

/**
* 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<Address, Choice> }
>();

/** 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<Address, Choice>();
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<PollTally> => {
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<Address, bigint>();
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,
};
};
Loading
Loading