diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index bb48808..f9a9fc7 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,7 +1,5 @@ -import Search from "@/components/container/Search"; -import SearchSkeleton from "@/components/container/SearchSkeleton"; -import Hero from "@components/section/display/Hero"; -import { Suspense } from "react"; +import { HomeView } from "@/components/container/HomeView"; +import ProposalBanner from "@components/section/display/ProposalBanner"; export const metadata = { title: "ArbitrumDAO Governance", @@ -9,13 +7,21 @@ export const metadata = { export default async function IndexPage() { return ( - <> - -
- }> - - +
+ {/* Starfield backdrop from the Figma frame, behind the banner and table. */} + ); } diff --git a/app/api/delegate-count/route.test.ts b/app/api/delegate-count/route.test.ts new file mode 100644 index 0000000..c2ab81a --- /dev/null +++ b/app/api/delegate-count/route.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DELEGATE_MIN_VOTING_POWER_ARB, + DELEGATE_MIN_VOTING_POWER_WEI, + EXCLUDED_DELEGATE_ADDRESSES, +} from "@/config/delegates"; + +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[]) { + return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ + delegates, + totalVotingPower: "0", + totalSupply: "0", + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); +} + +describe("delegate count route", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("returns 503 when the indexer URL is not configured", async () => { + vi.stubEnv("GOVERNANCE_INDEXER_URL", ""); + + const response = await GET(); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Governance indexer is not configured.", + }); + }); + + it("asks the indexer for the eligible population with an explicit row cap", async () => { + vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test/"); + const fetchMock = mockIndexerList([]); + + 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" + ); + 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); + }); + + it("counts only delegates above the threshold, excluding the governance address", 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"), + ]); + + const response = await GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + count: 2, + minVotingPowerArb: DELEGATE_MIN_VOTING_POWER_ARB, + minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI, + }); + expect(response.headers.get("cache-control")).toBe( + "public, s-maxage=300, stale-while-revalidate=3600" + ); + }); + + it("returns 502 when the indexer answers with an error status", async () => { + vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response("nope", { status: 500 }) + ); + + const response = await GET(); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toEqual({ + error: "Indexer upstream error.", + }); + }); + + it("returns 502 when the indexer is unreachable", async () => { + vi.stubEnv("GOVERNANCE_INDEXER_URL", "https://indexer.example.test"); + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("offline")); + + const response = await GET(); + + expect(response.status).toBe(502); + }); +}); diff --git a/app/api/delegate-count/route.ts b/app/api/delegate-count/route.ts new file mode 100644 index 0000000..c156e6a --- /dev/null +++ b/app/api/delegate-count/route.ts @@ -0,0 +1,79 @@ +import { + DELEGATE_LIST_MAX_ROWS, + DELEGATE_MIN_VOTING_POWER_ARB, + DELEGATE_MIN_VOTING_POWER_WEI, + countEligibleDelegates, +} from "@/config/delegates"; +import type { TallyDelegateListResult } from "@/lib/tally-data/types"; + +export const dynamic = "force-dynamic"; + +const FETCH_TIMEOUT_MS = 15_000; + +function getIndexerUrl(): string | null { + // eslint-disable-next-line no-process-env + const value = process.env.GOVERNANCE_INDEXER_URL?.trim(); + return value ? value.replace(/\/+$/, "") : 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. + */ +export async function GET(): Promise { + const indexerUrl = getIndexerUrl(); + if (!indexerUrl) { + return Response.json( + { error: "Governance indexer is not configured." }, + { status: 503 } + ); + } + + const search = new URLSearchParams({ + minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI, + limit: String(DELEGATE_LIST_MAX_ROWS), + }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const upstream = await fetch( + `${indexerUrl}/api/tally/delegates?${search}`, + { + signal: controller.signal, + headers: { accept: "application/json" }, + } + ); + if (!upstream.ok) { + throw new Error(`Indexer request failed: ${upstream.status}`); + } + + const { delegates } = (await upstream.json()) as TallyDelegateListResult; + + 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. + headers.set( + "cache-control", + "public, s-maxage=300, stale-while-revalidate=3600" + ); + + return new Response( + JSON.stringify({ + count: countEligibleDelegates(delegates ?? []), + minVotingPowerArb: DELEGATE_MIN_VOTING_POWER_ARB, + minVotingPower: DELEGATE_MIN_VOTING_POWER_WEI, + }), + { status: 200, headers } + ); + } catch { + return Response.json({ error: "Indexer upstream error." }, { status: 502 }); + } finally { + clearTimeout(timer); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index eb98885..61e972c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -69,23 +69,21 @@ export default function RootLayout({ children }: RootLayoutProps) { - + -
-
-
-
- - -
+
+
+
+ +
diff --git a/app/proposals/page.tsx b/app/proposals/page.tsx index 67dbf1b..f9887d8 100644 --- a/app/proposals/page.tsx +++ b/app/proposals/page.tsx @@ -1,6 +1,4 @@ -import Search from "@/components/container/Search"; -import SearchSkeleton from "@/components/container/SearchSkeleton"; -import { Suspense } from "react"; +import { ProposalsView } from "@/components/container/ProposalsView"; export const metadata = { title: "Arbitrum Governance", @@ -8,11 +6,28 @@ export const metadata = { export default function IndexPage() { return ( -
-
- }> - - +
+ {/* Decorative background from the Figma frame: starfield + illustration. */} +