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
48 changes: 13 additions & 35 deletions app/api/delegate-count/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,11 @@ import {

import { GET } from "./route";

function listItem(address: string, votingPower: string) {
return {
address,
votingPower,
lastChangeBlock: 100,
votesCount: votingPower,
delegatorsCount: 1,
isPrioritized: false,
ens: null,
name: null,
picture: null,
knownLabel: null,
displayName: null,
};
}

function mockIndexerList(delegates: ReturnType<typeof listItem>[]) {
function mockIndexerCount(totalCount: number) {
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response(
JSON.stringify({
delegates,
totalCount,
totalVotingPower: "0",
totalSupply: "0",
}),
Expand All @@ -54,36 +38,30 @@ describe("delegate count route", () => {
});
});

it("asks the indexer for the eligible population with an explicit row cap", async () => {
it("asks the count endpoint with the threshold and exclude list, no row cap", async () => {
vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test/");
const fetchMock = mockIndexerList([]);
const fetchMock = mockIndexerCount(0);

await GET();

const url = new URL(String(fetchMock.mock.calls[0][0]));
expect(url.origin + url.pathname).toBe(
"https://indexer.example.test/api/tally/delegates"
"https://indexer.example.test/api/tally/delegate-count"
);
expect(url.searchParams.get("minVotingPower")).toBe(
DELEGATE_MIN_VOTING_POWER_WEI
);
// Without this the indexer silently truncates at 1,000 rows.
expect(Number(url.searchParams.get("limit"))).toBeGreaterThan(1000);
// Exclusion is applied server-side now; the route forwards the exclude list.
expect(url.searchParams.get("exclude")).toBe(
EXCLUDED_DELEGATE_ADDRESSES.join(",")
);
// No whole-list read — the count endpoint takes no limit.
expect(url.searchParams.has("limit")).toBe(false);
});

it("counts only delegates above the threshold, excluding the governance address", async () => {
it("relays the server-computed count", async () => {
vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test");
const above = DELEGATE_MIN_VOTING_POWER_WEI;
const below = (
BigInt(DELEGATE_MIN_VOTING_POWER_WEI) - BigInt(1)
).toString();
mockIndexerList([
listItem("0x1111111111111111111111111111111111111111", above),
listItem("0x2222222222222222222222222222222222222222", above),
listItem("0x3333333333333333333333333333333333333333", below),
// The indexer includes the exclude address, and it always clears the bar.
listItem(EXCLUDED_DELEGATE_ADDRESSES[0], "5000000000000000000000000000"),
]);
mockIndexerCount(2);

const response = await GET();

Expand Down
23 changes: 11 additions & 12 deletions app/api/delegate-count/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import {
DELEGATE_LIST_MAX_ROWS,
DELEGATE_MIN_VOTING_POWER_ARB,
DELEGATE_MIN_VOTING_POWER_WEI,
countEligibleDelegates,
EXCLUDED_DELEGATE_ADDRESSES,
} from "@/config/delegates";
import type { TallyDelegateListResult } from "@/lib/tally-data/types";
import type { TallyDelegateCountResult } from "@/lib/tally-data/types";

export const dynamic = "force-dynamic";

Expand All @@ -19,9 +18,9 @@ function getIndexerUrl(): string | null {
/**
* Counts the delegates that clear the app-wide voting-power threshold.
*
* The indexer has no count endpoint, so the only way to get this number is to
* read the whole eligible list (~250 KB today). This route does that read
* server-side and hands the browser a single integer instead.
* The indexer's dedicated /delegate-count endpoint applies the same threshold
* and the governance exclude list server-side (via SQL COUNT), so this route
* just relays that number — no whole-list read.
*/
export async function GET(): Promise<Response> {
const indexerUrl = getIndexerUrl();
Expand All @@ -34,15 +33,15 @@ export async function GET(): Promise<Response> {

const search = new URLSearchParams({
minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI,
limit: String(DELEGATE_LIST_MAX_ROWS),
exclude: EXCLUDED_DELEGATE_ADDRESSES.join(","),
});

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);

try {
const upstream = await fetch(
`${indexerUrl}/api/tally/delegates?${search}`,
`${indexerUrl}/api/tally/delegate-count?${search}`,
{
signal: controller.signal,
headers: { accept: "application/json" },
Expand All @@ -52,20 +51,20 @@ export async function GET(): Promise<Response> {
throw new Error(`Indexer request failed: ${upstream.status}`);
}

const { delegates } = (await upstream.json()) as TallyDelegateListResult;
const { totalCount } = (await upstream.json()) as TallyDelegateCountResult;

const headers = new Headers();
headers.set("content-type", "application/json");
// The population moves slowly (delegations, not votes), and the upstream
// read is heavy, so cache it hard and refresh in the background.
// The population moves slowly (delegations, not votes), so cache it hard and
// refresh in the background.
headers.set(
"cache-control",
"public, s-maxage=300, stale-while-revalidate=3600"
);

return new Response(
JSON.stringify({
count: countEligibleDelegates(delegates ?? []),
count: totalCount,
minVotingPowerArb: DELEGATE_MIN_VOTING_POWER_ARB,
minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI,
}),
Expand Down
26 changes: 16 additions & 10 deletions components/container/DelegateSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import RpcStatus from "@/components/container/RpcStatus";
import {
DelegateStatsCards,
DelegatesTable,
SnapshotBlockNotice,
} from "@/components/container/delegate";
import {
DELEGATE_MIN_VOTING_POWER_ARB,
Expand Down Expand Up @@ -78,8 +77,14 @@ export default function DelegateSearch() {
totalSupply,
error,
isLoading,
cacheStats,
snapshotBlock,
pageIndex,
pageSize,
rowCount,
setPagination,
sorting,
setSorting,
sortOrder,
setSortOrder,
refreshVisibleDelegates,
refreshedAddresses,
} = useDelegateSearch({
Expand Down Expand Up @@ -128,13 +133,6 @@ export default function DelegateSearch() {
/>
)}

{snapshotBlock > 0 && cacheStats && (
<SnapshotBlockNotice
snapshotBlock={snapshotBlock}
cacheAge={cacheStats.age}
/>
)}

<DelegatesTable
delegates={delegates}
totalVotingPower={totalVotingPower}
Expand All @@ -143,6 +141,14 @@ export default function DelegateSearch() {
rpcHealthy={rpcHealthy}
minPowerFloor={DELEGATE_MIN_VOTING_POWER_ARB}
refreshedAddresses={refreshedAddresses}
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={rowCount}
onPaginationChange={setPagination}
sorting={sorting}
onSortingChange={setSorting}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
onSearchChange={setDelegateSearchFilter}
onMinPowerChange={setMinPowerFilter}
onVisibleRowsChange={handleVisibleRowsChange}
Expand Down
Loading
Loading