Skip to content

Commit a85831e

Browse files
sktbrdxSatoriclaude
committed
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>
1 parent 35f16ec commit a85831e

3 files changed

Lines changed: 72 additions & 12 deletions

File tree

src/services/round-voting-power.test.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import type { Round } from "@/features/rounds/types";
3+
import { serverPublicClient } from "@/lib/rpc";
4+
import { getRoundVotingPower } from "./rounds";
35

46
vi.mock("server-only", () => ({}));
57

@@ -9,9 +11,6 @@ vi.mock("@/lib/rpc", () => ({
911
},
1012
}));
1113

12-
import { serverPublicClient } from "@/lib/rpc";
13-
import { getRoundVotingPower } from "./rounds";
14-
1514
const readContract = vi.mocked(serverPublicClient.readContract);
1615

1716
const walletAddress = "0x39a7b6fa1597bb6657fe84e64e3b836c37d6f75d";
@@ -63,14 +62,43 @@ describe("getRoundVotingPower", () => {
6362
it("returns votesPerWallet for fixed_per_wallet when delegated Gnars voting power is greater than 0", async () => {
6463
readContract.mockResolvedValueOnce(7n);
6564

66-
await expect(getRoundVotingPower(baseRound("fixed_per_wallet", 5), walletAddress)).resolves.toBe(
67-
5,
68-
);
65+
await expect(
66+
getRoundVotingPower(baseRound("fixed_per_wallet", 5), walletAddress),
67+
).resolves.toBe(5);
6968
});
7069

7170
it("returns delegated Gnars voting power for one_per_nft", async () => {
7271
readContract.mockResolvedValueOnce(7n);
7372

7473
await expect(getRoundVotingPower(baseRound("one_per_nft"), walletAddress)).resolves.toBe(7);
7574
});
75+
76+
it("reads votes at the round's voting-open SNAPSHOT, not live", async () => {
77+
// Live getVotes would let the same Gnars vote once per re-delegation hop
78+
// (A→B votes, A re-delegates→C votes). The checkpoint at votingStartsAt is
79+
// immutable, so the power must be read there.
80+
readContract.mockResolvedValueOnce(7n);
81+
82+
await getRoundVotingPower(baseRound("one_per_nft"), walletAddress);
83+
84+
expect(readContract).toHaveBeenCalledWith(
85+
expect.objectContaining({
86+
functionName: "getPastVotes",
87+
args: [
88+
expect.any(String),
89+
BigInt(Math.floor(Date.parse("2026-06-03T00:00:00.000Z") / 1000)),
90+
],
91+
}),
92+
);
93+
});
94+
95+
it("returns 0 without touching the chain when voting has not opened yet", async () => {
96+
// ERC-5805 reverts on a future timepoint; a round that has not opened has
97+
// no snapshot and nobody can vote in it — 0 is the true answer.
98+
const future = baseRound("one_per_nft");
99+
future.votingStartsAt = new Date(Date.now() + 86_400_000).toISOString();
100+
101+
await expect(getRoundVotingPower(future, walletAddress)).resolves.toBe(0);
102+
expect(readContract).not.toHaveBeenCalled();
103+
});
76104
});

src/services/round-voting-power.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,60 @@ import { serverPublicClient } from "@/lib/rpc";
66
const gnarsVotesAbi = [
77
{
88
type: "function",
9-
name: "getVotes",
9+
name: "getPastVotes",
1010
stateMutability: "view",
11-
inputs: [{ name: "account", type: "address" }],
11+
inputs: [
12+
{ name: "account", type: "address" },
13+
// Named "timepoint" per ERC-5805; the Gnars token runs timestamp clock
14+
// mode (ERC-6372), so this is a UNIX TIMESTAMP, not a block number —
15+
// the same fact useCastVote.ts documents for onchain proposal voting.
16+
{ name: "timepoint", type: "uint256" },
17+
],
1218
outputs: [{ name: "", type: "uint256" }],
1319
},
1420
] as const;
1521

16-
export async function getDelegatedGnarsVotingPower(walletAddress?: string | null) {
22+
/**
23+
* Delegated Gnars voting power AT A FIXED SNAPSHOT, not live.
24+
*
25+
* Live `getVotes` would let the same Gnars vote in one round N times:
26+
* A delegates to B, B votes, A re-delegates to C, C votes — vote usage is
27+
* tracked per wallet, so every hop arrives with fresh usage and full power.
28+
* Delegation is free, so 10 Gnars plus N empty wallets would be N ballots.
29+
*
30+
* Reading `getPastVotes` at the round's voting-open instant closes that: the
31+
* checkpoint is immutable, and those 10 Gnars sat in exactly ONE wallet's
32+
* checkpoint at that timestamp. Re-delegating afterwards moves nothing. This
33+
* is the same snapshot model the DAO's onchain governor uses for proposals,
34+
* and the policy that comes with it — delegating transfers the vote; a holder
35+
* who delegated does not vote, their delegate does.
36+
*/
37+
export async function getDelegatedGnarsVotingPower(
38+
walletAddress: string | null | undefined,
39+
snapshotTimestamp: number,
40+
) {
1741
if (!walletAddress || !isAddress(walletAddress)) return 0;
42+
// A future timepoint makes ERC-5805 revert (FutureLookup); a round whose
43+
// voting has not opened has no snapshot yet, and nobody can vote in it
44+
// anyway — 0 is the true answer, not a degraded one.
45+
if (!Number.isFinite(snapshotTimestamp) || snapshotTimestamp * 1000 > Date.now()) return 0;
1846

1947
const normalizedWallet = getAddress(walletAddress);
2048

2149
try {
2250
const votes = await serverPublicClient.readContract({
2351
address: DAO_ADDRESSES.token,
2452
abi: gnarsVotesAbi,
25-
functionName: "getVotes",
26-
args: [normalizedWallet],
53+
functionName: "getPastVotes",
54+
args: [normalizedWallet, BigInt(snapshotTimestamp)],
2755
});
2856

2957
return toSafeInteger(votes);
3058
} catch (error) {
3159
console.error("[rounds] failed to read delegated Gnars voting power", {
3260
walletAddress: normalizedWallet,
3361
tokenAddress: DAO_ADDRESSES.token,
62+
snapshotTimestamp,
3463
error,
3564
});
3665
return 0;

src/services/rounds.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,10 @@ export async function getPublicRoundBySlug(slug: string): Promise<RoundWithSubmi
463463

464464
export async function getRoundVotingPower(round: Round, walletAddress?: string | null) {
465465
if (!walletAddress || !isAddress(walletAddress)) return 0;
466-
const delegatedVotingPower = await getDelegatedGnarsVotingPower(walletAddress);
466+
// Snapshot at the instant voting opened — see getDelegatedGnarsVotingPower
467+
// for why live votes would let the same Gnars vote once per re-delegation.
468+
const snapshotTimestamp = Math.floor(Date.parse(round.votingStartsAt) / 1000);
469+
const delegatedVotingPower = await getDelegatedGnarsVotingPower(walletAddress, snapshotTimestamp);
467470
if (delegatedVotingPower <= 0) return 0;
468471

469472
if (round.votingStrategy === "fixed_per_wallet") return round.votesPerWallet;

0 commit comments

Comments
 (0)