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
28 changes: 17 additions & 11 deletions app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
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",
};

export default async function IndexPage() {
return (
<>
<Hero />
<div className="container flex flex-col gap-4 pb-8 md:pb-12">
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
<div className="relative isolate">
{/* Starfield backdrop from the Figma frame, behind the banner and table. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[822px] select-none overflow-hidden"
>
<div
className="absolute inset-0 bg-top bg-cover bg-no-repeat"
style={{ backgroundImage: "url(/proposals/stars-bg.svg)" }}
/>
</div>
</>

<div className="container flex flex-col gap-4 pb-8 pt-6 md:pb-12 md:pt-10">
<HomeView banner={<ProposalBanner />} />
</div>
</div>
);
}
123 changes: 123 additions & 0 deletions app/api/delegate-count/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof listItem>[]) {
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);
});
});
79 changes: 79 additions & 0 deletions app/api/delegate-count/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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);
}
}
16 changes: 7 additions & 9 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,23 +69,21 @@ export default function RootLayout({ children }: RootLayoutProps) {

<body
className={cn(
"min-h-screen font-sans antialiased bg-[#f0f8ff] dark:bg-[#040019] transition-colors duration-200 ease-in-out",
"min-h-screen font-sans antialiased bg-[#0b0c10] transition-colors duration-200 ease-in-out",
GeistSans.className
)}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider attribute="class" defaultTheme="dark" forcedTheme="dark">
<PostHogProvider>
<Web3ModalProvider>
<NerdModeProvider>
<DeepLinkProvider>
<SettingsSheetProvider>
<header className="sticky top-0 z-50 w-full">
<div className="mx-auto max-w-screen-2xl px-4 sm:px-6 lg:px-8 pt-3 sm:pt-4">
<div className="glass rounded-2xl px-4 sm:px-6 backdrop-blur-md">
<div className="flex h-14 sm:h-16 items-center justify-between gap-2">
<MainNav items={marketingConfig.mainNav} />
<ButtonNav />
</div>
<header className="sticky top-0 z-50 w-full backdrop-blur-md">
<div className="mx-auto max-w-screen-2xl px-4 sm:px-6 lg:px-8">
<div className="relative flex h-16 items-center justify-between gap-2">
<MainNav items={marketingConfig.mainNav} />
<ButtonNav />
</div>
</div>
</header>
Expand Down
31 changes: 23 additions & 8 deletions app/proposals/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
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",
};

export default function IndexPage() {
return (
<div className="space-y-6 pb-8 pt-6 md:pb-12 md:pt-10 lg:py-16">
<div className="container flex flex-col gap-4">
<Suspense fallback={<SearchSkeleton />}>
<Search />
</Suspense>
<div className="relative isolate">
{/* Decorative background from the Figma frame: starfield + illustration. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[822px] overflow-hidden select-none"
>
<div
className="absolute inset-0 bg-top bg-cover bg-no-repeat"
style={{ backgroundImage: "url(/proposals/stars-bg.svg)" }}
/>
<div className="container relative h-full">
<div
className="absolute right-0 top-2 h-[160px] w-[160px] bg-right-top bg-contain bg-no-repeat opacity-90 md:top-4 md:h-[220px] md:w-[220px] lg:h-[290px] lg:w-[290px]"
style={{ backgroundImage: "url(/proposals/illustration.png)" }}
/>
</div>
</div>

<div className="space-y-6 pb-8 pt-6 md:pb-12 md:pt-10 lg:py-16">
<div className="container flex flex-col gap-4">
<ProposalsView />
</div>
</div>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Loader2,
LucideIcon,
LucideProps,
Menu,
Moon,
MoreVertical,
PackageOpenIcon,
Expand Down Expand Up @@ -118,8 +119,25 @@ function LogoIcon({ className }: { className?: string } = { className: "" }) {
);
}

function WordmarkIcon(
{ className }: { className?: string } = { className: "" }
) {
return (
<Image
src="/logos/arbitrum-foundation.svg"
alt="Arbitrum Foundation"
className={className}
width={128}
height={33}
priority
/>
);
}

export const Icons = {
logo: LogoIcon,
wordmark: WordmarkIcon,
menu: Menu,
close: X,
spinner: Loader2,
chevronLeft: ChevronLeft,
Expand Down
Loading
Loading