Skip to content

Commit ef464f3

Browse files
sktbrdxSatoriclaude
authored
feat(rounds): gate voting by delegated Gnars votes at a fixed snapshot (#294)
* Gate round voting by delegated Gnars votes * feat(rounds): gate voting by delegated Gnars votes at a fixed snapshot Builds on xSatori's #115 (all UI/route/service structure is theirs): rounds voting requires delegated Gnars voting power, enforced server- side at vote submission. One change on top: the power is read with getPastVotes at the round's voting-open instant instead of live getVotes. Live votes let the same Gnars vote once per re-delegation hop (A delegates to B, B votes, A re-delegates to C, C votes — usage is tracked per wallet, so every hop arrives fresh; on fixed_per_wallet each hop gets the FULL per-wallet allotment). The checkpoint at votingStartsAt is immutable, so those Gnars sat in exactly one wallet at that instant — the same snapshot model the onchain governor uses, and the policy that comes with it: delegating transfers the vote. The Gnars token runs timestamp clock mode (ERC-6372), so the timepoint is the round's votingStartsAt directly — no block derivation. A future timepoint returns 0 without touching the chain (ERC-5805 would revert, and an unopened round has no snapshot). Co-authored-by: xSatori <99294685+xSatori@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: xSatori <99294685+xSatori@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2294d26 commit ef464f3

7 files changed

Lines changed: 430 additions & 19 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { getAddress } from "viem";
3+
import type { RoundWithSubmissions } from "@/features/rounds/types";
4+
5+
vi.mock("@/services/rounds", () => ({
6+
getPublicRoundBySlug: vi.fn(),
7+
getRoundVoteUsage: vi.fn(),
8+
getRoundVotingPower: vi.fn(),
9+
}));
10+
11+
import { getPublicRoundBySlug, getRoundVoteUsage, getRoundVotingPower } from "@/services/rounds";
12+
import { GET } from "./route";
13+
14+
const walletAddress = "0x39a7b6fa1597bb6657fe84e64e3b836c37d6f75d";
15+
16+
const round: RoundWithSubmissions = {
17+
id: "round-1",
18+
slug: "clip-round",
19+
title: "Clip Round",
20+
description: "Round description",
21+
content: "Round content",
22+
image: "",
23+
startsAt: "2026-06-01T00:00:00.000Z",
24+
submissionsOpenAt: "2026-06-02T00:00:00.000Z",
25+
votingStartsAt: "2026-06-03T00:00:00.000Z",
26+
votingEndsAt: "2026-06-04T00:00:00.000Z",
27+
endsAt: "2026-06-04T00:00:00.000Z",
28+
active: true,
29+
featured: false,
30+
status: "published",
31+
votingStrategy: "fixed_per_wallet",
32+
votesPerWallet: 5,
33+
winnerCount: 1,
34+
maxSubmissionsPerWallet: 1,
35+
createdAt: "2026-06-01T00:00:00.000Z",
36+
updatedAt: "2026-06-01T00:00:00.000Z",
37+
deletedAt: null,
38+
submissions: [],
39+
voteActivity: [],
40+
};
41+
42+
describe("GET /api/rounds/[slug]/voting-power", () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks();
45+
});
46+
47+
it("returns votingPower, usedVotes, and remainingVotes", async () => {
48+
vi.mocked(getPublicRoundBySlug).mockResolvedValueOnce(round);
49+
vi.mocked(getRoundVotingPower).mockResolvedValueOnce(5);
50+
vi.mocked(getRoundVoteUsage).mockResolvedValueOnce(2);
51+
52+
const response = await GET(
53+
new Request(`https://gnars.com/api/rounds/clip-round/voting-power?wallet=${walletAddress}`),
54+
{ params: Promise.resolve({ slug: "clip-round" }) },
55+
);
56+
57+
await expect(response.json()).resolves.toEqual({
58+
walletAddress: getAddress(walletAddress),
59+
votingPower: 5,
60+
usedVotes: 2,
61+
remainingVotes: 3,
62+
});
63+
});
64+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { NextResponse } from "next/server";
2+
import { getAddress, isAddress } from "viem";
3+
import { getPublicRoundBySlug, getRoundVoteUsage, getRoundVotingPower } from "@/services/rounds";
4+
5+
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
6+
const { slug } = await params;
7+
const wallet = new URL(request.url).searchParams.get("wallet");
8+
9+
try {
10+
if (!isValidRoundSlug(slug)) {
11+
return NextResponse.json({ error: "Use a valid round slug." }, { status: 400 });
12+
}
13+
14+
if (!wallet || !isAddress(wallet)) {
15+
return NextResponse.json({ error: "Use a valid wallet address." }, { status: 400 });
16+
}
17+
18+
const walletAddress = getAddress(wallet);
19+
const round = await getPublicRoundBySlug(slug);
20+
if (!round) return NextResponse.json({ error: "Round not found." }, { status: 404 });
21+
22+
const [votingPower, usedVotes] = await Promise.all([
23+
getRoundVotingPower(round, walletAddress),
24+
getRoundVoteUsage(round.id, walletAddress),
25+
]);
26+
27+
return NextResponse.json({
28+
walletAddress,
29+
votingPower,
30+
usedVotes,
31+
remainingVotes: Math.max(votingPower - usedVotes, 0),
32+
});
33+
} catch (error) {
34+
console.error("[rounds] voting power lookup failed", error);
35+
return NextResponse.json({ error: "Unable to load voting power." }, { status: 500 });
36+
}
37+
}
38+
39+
function isValidRoundSlug(slug: string) {
40+
return /^[a-z0-9-]+$/.test(slug);
41+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("thirdweb/react", () => ({
4+
useActiveAccount: () => null,
5+
}));
6+
7+
vi.mock("@/hooks/use-user-address", () => ({
8+
useUserAddress: () => ({ address: undefined, isConnected: false }),
9+
}));
10+
11+
vi.mock("@/i18n/navigation", () => ({
12+
Link: "a",
13+
useRouter: () => ({ refresh: vi.fn() }),
14+
}));
15+
16+
import { canShowRoundVotingControls } from "./RoundDetailView";
17+
18+
describe("RoundDetailView voting controls", () => {
19+
it("does not enable voting controls when votingPower is 0", () => {
20+
expect(canShowRoundVotingControls({ state: "voting_open", votingPower: 0 })).toBe(false);
21+
});
22+
});

src/components/rounds/RoundDetailView.tsx

Lines changed: 120 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
"use client";
22

3-
import { useMemo, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useState } from "react";
44
import { ArrowLeft, ExternalLink, Minus, Plus } from "lucide-react";
55
import { useActiveAccount } from "thirdweb/react";
66
import { Button } from "@/components/ui/button";
77
import { createRoundActionMessage } from "@/features/rounds/signature";
88
import { getRoundState, getRoundStateLabel } from "@/features/rounds/state";
9-
import type { RoundSubmission, RoundWithSubmissions } from "@/features/rounds/types";
9+
import type { RoundState, RoundSubmission, RoundWithSubmissions } from "@/features/rounds/types";
1010
import { useUserAddress } from "@/hooks/use-user-address";
11-
import { Link } from "@/i18n/navigation";
11+
import { Link, useRouter } from "@/i18n/navigation";
1212
import { cn } from "@/lib/utils";
1313
import { RoundStatusPill } from "./RoundStatusPill";
1414
import { RoundTimeline } from "./RoundTimeline";
1515

16+
type RoundVotingPower = {
17+
walletAddress: string;
18+
votingPower: number;
19+
usedVotes: number;
20+
remainingVotes: number;
21+
};
22+
1623
export function RoundDetailView({
1724
round,
1825
databaseConfigured,
@@ -23,16 +30,70 @@ export function RoundDetailView({
2330
const state = getRoundState(round);
2431
const { address, isConnected } = useUserAddress();
2532
const account = useActiveAccount();
33+
const router = useRouter();
2634
const [allocations, setAllocations] = useState<Record<string, number>>({});
2735
const [message, setMessage] = useState("");
2836
const [isVoting, setIsVoting] = useState(false);
29-
const votingPower = state === "voting_open" && isConnected ? round.votesPerWallet : 0;
37+
const [votingPowerStatus, setVotingPowerStatus] = useState<RoundVotingPower | null>(null);
38+
const [isLoadingVotingPower, setIsLoadingVotingPower] = useState(false);
39+
const [votingPowerError, setVotingPowerError] = useState("");
40+
const votingPower = state === "voting_open" && isConnected ? votingPowerStatus?.votingPower || 0 : 0;
41+
const remainingVotes =
42+
state === "voting_open" && isConnected ? votingPowerStatus?.remainingVotes || 0 : 0;
3043
const allocatedVotes = useMemo(
3144
() => Object.values(allocations).reduce((total, count) => total + count, 0),
3245
[allocations],
3346
);
47+
const availableVotes = Math.max(remainingVotes - allocatedVotes, 0);
48+
const showVotingControls = canShowRoundVotingControls({ state, votingPower });
49+
const votingStatusMessage = getVotingStatusMessage({
50+
isConnected,
51+
isLoadingVotingPower,
52+
votingPowerError,
53+
votingPower,
54+
availableVotes,
55+
remainingVotes,
56+
});
3457
const winners = state === "ended" ? round.submissions.slice(0, round.winnerCount) : [];
3558

59+
const fetchVotingPower = useCallback(async () => {
60+
if (state !== "voting_open" || !isConnected || !address) {
61+
setVotingPowerStatus(null);
62+
setVotingPowerError("");
63+
return;
64+
}
65+
66+
setIsLoadingVotingPower(true);
67+
setVotingPowerError("");
68+
69+
try {
70+
const response = await fetch(
71+
`/api/rounds/${round.slug}/voting-power?wallet=${encodeURIComponent(address)}`,
72+
);
73+
const result = await response.json();
74+
if (!response.ok) throw new Error(result.error || "Unable to load voting power.");
75+
76+
setVotingPowerStatus(result as RoundVotingPower);
77+
} catch (error) {
78+
setVotingPowerStatus(null);
79+
setVotingPowerError(error instanceof Error ? error.message : "Unable to load voting power.");
80+
} finally {
81+
setIsLoadingVotingPower(false);
82+
}
83+
}, [
84+
address,
85+
isConnected,
86+
round.slug,
87+
setIsLoadingVotingPower,
88+
setVotingPowerError,
89+
setVotingPowerStatus,
90+
state,
91+
]);
92+
93+
useEffect(() => {
94+
void fetchVotingPower();
95+
}, [fetchVotingPower]);
96+
3697
const updateAllocation = (submissionId: string, delta: number) => {
3798
setMessage("");
3899
setAllocations((current) => {
@@ -44,13 +105,13 @@ export function RoundDetailView({
44105
);
45106
return {
46107
...current,
47-
[submissionId]: Math.min(next, Math.max(votingPower - totalWithoutCurrent, 0)),
108+
[submissionId]: Math.min(next, Math.max(remainingVotes - totalWithoutCurrent, 0)),
48109
};
49110
});
50111
};
51112

52113
const submitVotes = async () => {
53-
if (!address || !account || allocatedVotes <= 0) return;
114+
if (!address || !account || allocatedVotes <= 0 || allocatedVotes > remainingVotes) return;
54115

55116
setIsVoting(true);
56117
setMessage("");
@@ -83,7 +144,9 @@ export function RoundDetailView({
83144
if (!response.ok) throw new Error(result.error || "Unable to submit votes.");
84145

85146
setAllocations({});
86-
setMessage("Votes submitted. Refreshing will show the updated totals.");
147+
setMessage("Votes submitted.");
148+
await fetchVotingPower();
149+
router.refresh();
87150
} catch (error) {
88151
setMessage(error instanceof Error ? error.message : "Unable to submit votes.");
89152
} finally {
@@ -190,15 +253,19 @@ export function RoundDetailView({
190253
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
191254
<div>
192255
<h2 className="text-xl font-semibold tracking-tight">Voting</h2>
193-
<p className="mt-1 text-sm text-muted-foreground">
194-
{isConnected
195-
? `${Math.max(votingPower - allocatedVotes, 0)} of ${votingPower} votes remaining`
196-
: "Connect your wallet to vote."}
197-
</p>
256+
<p className="mt-1 text-sm text-muted-foreground">{votingStatusMessage}</p>
198257
</div>
199258
<Button
200259
onClick={submitVotes}
201-
disabled={!databaseConfigured || !isConnected || allocatedVotes <= 0 || isVoting}
260+
disabled={
261+
!databaseConfigured ||
262+
!isConnected ||
263+
!showVotingControls ||
264+
allocatedVotes <= 0 ||
265+
allocatedVotes > remainingVotes ||
266+
isVoting ||
267+
isLoadingVotingPower
268+
}
202269
>
203270
{isVoting ? "Submitting..." : "Submit votes"}
204271
</Button>
@@ -221,10 +288,11 @@ export function RoundDetailView({
221288
key={submission.id}
222289
submission={submission}
223290
rank={index + 1}
224-
showVoting={state === "voting_open" && votingPower > 0}
291+
showVoting={showVotingControls}
225292
allocation={allocations[submission.id] || 0}
226293
onMinus={() => updateAllocation(submission.id, -1)}
227294
onPlus={() => updateAllocation(submission.id, 1)}
295+
disablePlus={availableVotes <= 0}
228296
/>
229297
))}
230298
</div>
@@ -285,13 +353,15 @@ function SubmissionCard({
285353
allocation,
286354
onMinus,
287355
onPlus,
356+
disablePlus,
288357
}: {
289358
submission: RoundSubmission;
290359
rank: number;
291360
showVoting: boolean;
292361
allocation: number;
293362
onMinus: () => void;
294363
onPlus: () => void;
364+
disablePlus?: boolean;
295365
}) {
296366
return (
297367
<article
@@ -357,7 +427,7 @@ function SubmissionCard({
357427
<span className="w-6 text-center text-sm font-semibold tabular-nums">
358428
{allocation}
359429
</span>
360-
<Button size="icon-sm" variant="outline" onClick={onPlus}>
430+
<Button size="icon-sm" variant="outline" onClick={onPlus} disabled={disablePlus}>
361431
<Plus className="size-3.5" />
362432
</Button>
363433
</div>
@@ -373,3 +443,38 @@ function getVotingStrategyLabel(round: RoundWithSubmissions) {
373443
if (round.votingStrategy === "one_per_nft") return "1 vote per Gnars NFT";
374444
return `${round.votesPerWallet} votes per wallet`;
375445
}
446+
447+
export function canShowRoundVotingControls({
448+
state,
449+
votingPower,
450+
}: {
451+
state: RoundState;
452+
votingPower: number;
453+
}) {
454+
return state === "voting_open" && votingPower > 0;
455+
}
456+
457+
function getVotingStatusMessage({
458+
isConnected,
459+
isLoadingVotingPower,
460+
votingPowerError,
461+
votingPower,
462+
availableVotes,
463+
remainingVotes,
464+
}: {
465+
isConnected: boolean;
466+
isLoadingVotingPower: boolean;
467+
votingPowerError: string;
468+
votingPower: number;
469+
availableVotes: number;
470+
remainingVotes: number;
471+
}) {
472+
if (!isConnected) return "Connect your wallet to vote.";
473+
if (isLoadingVotingPower) return "Checking delegated Gnars voting power...";
474+
if (votingPowerError) return votingPowerError;
475+
if (votingPower <= 0) {
476+
return "This wallet needs at least 1 delegated Gnars DAO vote to vote in this round.";
477+
}
478+
479+
return `${availableVotes} of ${remainingVotes} votes remaining`;
480+
}

0 commit comments

Comments
 (0)