feat(voting): community voting for hackathon projects via Nostr#36
feat(voting): community voting for hackathon projects via Nostr#36agustinkassis wants to merge 1 commit into
Conversation
Adds a Nostr-native community voting system embedded on each hackathon page. Winners are chosen by the community: anyone who participated in any hackathon and has a linked Nostr pubkey can vote for the current hackathon's projects, with 1 vote per hackathon participated, freely allocatable across projects (no self-votes). Two kind-30078 (NIP-78 replaceable) event roles, both tagged ["client","La Crypta Dev"]: - Voting period event (server-signed with LACRYPTA_NSEC, d=lacrypta.dev: voting:<id>): open/closed status, frozen eligibility snapshot, votable project list, and the canonical signed final tally once closed. - Ballot event (voter-signed, d=lacrypta.dev:vote:<id>): replaceable, one ballot per voter per hackathon. Admin opens/closes voting from the page via a kind-27235 auth request, reusing the soldiers-ranking publish pattern. While open the tally is computed live from relay ballots; after close clients render the signed embedded results verbatim (freeze rule). New: lib/voting.ts (shared contract), lib/votingCache.ts (cached server read), lib/votingClient.ts (publish + live subscriptions), app/api/hackathons/[id]/voting/route.ts, app/hackathons/[id]/ VotingSection.tsx, and a dev-only /dev/voting test harness. Test isolation via NEXT_PUBLIC_VOTING_NS=test (namespaced d-tags) + VOTING_TEST_EXTRA_VOTERS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR introduces a complete Nostr-based community voting system for hackathons. It implements shared ballot validation and tallying logic, server-side period caching from Nostr relays, an authenticated admin API for opening/closing votes, client-side subscriptions for live updates, and a production voting UI with real-time tally rendering plus a development identity lab. ChangesCommunity voting system
Sequence DiagramsequenceDiagram
participant User
participant Admin as Admin
participant VotingUI as VotingSection UI
participant Backend as Voting API
participant Relays as Nostr Relays
Admin->>Backend: POST open-voting (signed)
Backend->>Relays: Fetch soldiers, projects
Backend->>Backend: Build VotingPeriod, apply eligibility
Backend->>Relays: Publish period event
Backend-->>Admin: success + relay status
User->>VotingUI: Load hackathon page
VotingUI->>Backend: GET /voting (cached)
VotingUI->>Relays: Subscribe to period updates
VotingUI->>Relays: Subscribe to ballots
Relays-->>VotingUI: Period event + ballot stream
User->>VotingUI: Allocate votes to projects
VotingUI->>VotingUI: Validate budget, mark dirty
User->>VotingUI: Publish ballot
VotingUI->>Relays: Sign and send ballot event
Relays-->>VotingUI: Ballot received
VotingUI->>VotingUI: Dedupe, retally, update display
Admin->>Backend: POST close-voting (signed)
Backend->>Relays: Fetch all ballots for hackathon
Backend->>Backend: Tally against open snapshot
Backend->>Relays: Publish closed period event
VotingUI->>VotingUI: Receive closed period, freeze results
VotingUI->>User: Show TallyBoard with final ranking
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (2)
lib/votingCache.ts (1)
9-17: 📐 Maintainability & Code Quality | ⚡ Quick winUse
@/*path alias for imports.Per coding guidelines, imports should use the
@/*path alias resolving to the repo root.Suggested fix
import { cacheLife, cacheTag } from "next/cache"; -import { DEFAULT_RELAYS } from "./nostrRelayConfig"; -import { nostrVotingTag } from "./nostrCacheTags"; +import { DEFAULT_RELAYS } from "`@/lib/nostrRelayConfig`"; +import { nostrVotingTag } from "`@/lib/nostrCacheTags`"; import { VOTING_KIND, parseVotingPeriod, votingPeriodDTag, type VotingPeriod, -} from "./voting"; +} from "`@/lib/voting`";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/votingCache.ts` around lines 9 - 17, The imports in lib/votingCache.ts use relative paths; update them to use the project path alias (`@/`*) per guidelines — replace "./nostrRelayConfig" with "`@/nostrRelayConfig`", "./nostrCacheTags" with "`@/nostrCacheTags`", and "./voting" with "`@/voting`" while keeping the existing named imports (DEFAULT_RELAYS, nostrVotingTag, VOTING_KIND, parseVotingPeriod, votingPeriodDTag, VotingPeriod) and leaving the next/cache import unchanged so symbols like DEFAULT_RELAYS, nostrVotingTag, parseVotingPeriod, and VOTING_KIND continue to resolve via the alias.Source: Coding guidelines
lib/votingClient.ts (1)
9-20: 📐 Maintainability & Code Quality | ⚡ Quick winUse the repo alias here instead of relative imports.
Lines 9-20 are still importing through
./…, which breaks the repo-wide TS import convention and makes future moves noisier.♻️ Suggested cleanup
-import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "./nostrRelayConfig"; -import type { SignedEvent, UserSigner } from "./nostrSigner"; +import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "`@/lib/nostrRelayConfig`"; +import type { SignedEvent, UserSigner } from "`@/lib/nostrSigner`"; import { VOTE_T_TAG, VOTING_KIND, VOTING_SCHEMA_VERSION, parseVotingPeriod, voteDTag, votingPeriodDTag, type BallotContent, type VotingPeriod, -} from "./voting"; +} from "`@/lib/voting`";As per coding guidelines,
**/*.{ts,tsx}: Use the@/* path alias for imports, resolving to the repo root.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/votingClient.ts` around lines 9 - 20, The imports in this file use relative paths (e.g., "./nostrRelayConfig", "./nostrSigner", "./voting") which violate the repo TS alias convention; update the import statements to use the repository path alias (@"...") instead, keeping the same exported symbols (FAST_USER_RELAYS, DEFAULT_RELAYS, SignedEvent, UserSigner, VOTE_T_TAG, VOTING_KIND, VOTING_SCHEMA_VERSION, parseVotingPeriod, voteDTag, votingPeriodDTag, BallotContent, VotingPeriod) and ensure the new paths resolve from the repo root so the module names remain identical while switching to the `@/` alias.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/dev/voting/DevVotingClient.tsx`:
- Around line 257-260: En el mapeo de HACKATHONS (HACKATHONS.map) y en la
función hackathonStatus cambia los estados mostrados al usuario de inglés a
español (por ejemplo active → activo, upcoming → próximo, closed → cerrado) y en
cualquier etiqueta de votación que muestre open/closed usa abierto/cerrado;
además reemplaza la cadena visible "NAMESPACE TEST" por su equivalente en
español ("NAMESPACE DE PRUEBA" o "NOMBRE DE ESPACIO DE PRUEBA") para mantener
consistencia de idioma en la UI; actualiza solo los textos mostrados al usuario
dejando identificadores y comentarios en inglés.
- Around line 100-104: Al eliminar la identidad en la función removeIdentity
(que usa identities, setIdentities y saveIdentities) también detectá si la
pubkey eliminada coincide con la identidad actualmente autenticada y, si es así,
cerrá la sesión limpiando el estado de autenticación (por ejemplo llamando a
logout(), setActiveIdentity(null), setSession(null) o setAuthenticated(false)
según cómo esté implementado) y persistí ese cambio (por ejemplo
saveSession/guardarEstado) para evitar estado inconsistente; añadí esa
comprobación justo después de saveIdentities dentro de removeIdentity.
In `@app/dev/voting/page.tsx`:
- Line 4: Replace the relative import in page.tsx for DevVotingClient with the
repo-root alias form: change the import of DevVotingClient (currently
"./DevVotingClient") to the equivalent path using the `@/` alias that points to
the repository root (e.g., import DevVotingClient from
"`@/app/dev/voting/DevVotingClient`"), ensuring the module specifier matches the
file's location from the repo root.
In `@app/hackathons/`[id]/VotingSection.tsx:
- Around line 57-58: The ballots Map in VotingSection (state variables ballots /
setBallots) is never cleared and retains entries across hackathon or
VotingPeriod changes; add a useEffect that watches the current hackathon id and
period (period state / setPeriod) and resets ballots by calling setBallots(new
Map()) whenever either changes (also clear any related derived state like
liveTally and ownBallotEvent in the same effect or by calling their setters) so
stale ballots do not contaminate liveTally, ownBallotEvent, or the editor
“already voted” state; apply the same reset logic to the other ballots-related
state blocks referenced around lines 121-141.
- Around line 496-507: The allocations state can contain keys for projects that
were removed from the current project list, causing stale allocations to count
toward used and be republished; update the sync and publish paths to prune
allocations to only include IDs present in the current projects list. When
setting allocations from initialAllocations in the useEffect (and any other
place where initial allocations are applied, e.g., relay updates), filter the
object to remove keys not in the current project IDs before calling
setAllocations; likewise, before toggling publishing / inside the publish
handler (where setPublishing is used), compute a sanitizedAllocations object by
keeping only keys that exist in the current project list and use that for the
publish payload and setAllocations, so hidden/removed projects are never counted
or republished (reference allocations, setAllocations, dirty, the useEffect that
consumes initialAllocations, and the publish handler where publishing is set).
In `@lib/voting.ts`:
- Around line 357-361: The code incorrectly sets existing.maxVotes using
Math.max(existing.maxVotes, hackathons.size), which undercounts when the same
pubkey appears in multiple roster entries; instead, track and union the actual
hackathon sets per pubkey and set existing.maxVotes to the size of that union.
Update the merge logic where existing is found (references: existing, maxVotes,
hackathons, blocked) to compute a unioned set of hackathon IDs across the
existing record and the incoming hackathons, assign existing.maxVotes =
union.size, and retain the existing.blocked union behavior; if no per-pubkey
hackathon set exists yet, create one when first inserting the pubkey so future
merges can union correctly.
In `@lib/votingClient.ts`:
- Around line 111-117: The live ballot bootstrap is being truncated by the fixed
limit in the subscribe filter; in the call to pool.subscribe (the subscription
that uses VOTING_KIND and "`#d`": [dTag]) remove the hardcoded limit: 500 (or set
it to undefined/omit the property) so the initial snapshot returns all matching
events and the liveTally/editor hydration is accurate for contests with >500
voters.
- Around line 165-185: In subscribeToVotingPeriod's onevent handler add a
signature verification guard: call verifyEvent(event) (or the project's
verifyEvent/verifySignedEvent utility) as the very first check inside onevent
and return immediately if it fails, before accessing event.pubkey or parsing
event.content; this ensures events are cryptographically validated before the
existing checks (the handler in subscribeToVotingPeriod, the SignedEvent usage,
and parseVotingPeriod calls).
---
Nitpick comments:
In `@lib/votingCache.ts`:
- Around line 9-17: The imports in lib/votingCache.ts use relative paths; update
them to use the project path alias (`@/`*) per guidelines — replace
"./nostrRelayConfig" with "`@/nostrRelayConfig`", "./nostrCacheTags" with
"`@/nostrCacheTags`", and "./voting" with "`@/voting`" while keeping the existing
named imports (DEFAULT_RELAYS, nostrVotingTag, VOTING_KIND, parseVotingPeriod,
votingPeriodDTag, VotingPeriod) and leaving the next/cache import unchanged so
symbols like DEFAULT_RELAYS, nostrVotingTag, parseVotingPeriod, and VOTING_KIND
continue to resolve via the alias.
In `@lib/votingClient.ts`:
- Around line 9-20: The imports in this file use relative paths (e.g.,
"./nostrRelayConfig", "./nostrSigner", "./voting") which violate the repo TS
alias convention; update the import statements to use the repository path alias
(@"...") instead, keeping the same exported symbols (FAST_USER_RELAYS,
DEFAULT_RELAYS, SignedEvent, UserSigner, VOTE_T_TAG, VOTING_KIND,
VOTING_SCHEMA_VERSION, parseVotingPeriod, voteDTag, votingPeriodDTag,
BallotContent, VotingPeriod) and ensure the new paths resolve from the repo root
so the module names remain identical while switching to the `@/` alias.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 853724bf-9f90-4319-8e16-bb08ccdcf637
📒 Files selected for processing (9)
app/api/hackathons/[id]/voting/route.tsapp/dev/voting/DevVotingClient.tsxapp/dev/voting/page.tsxapp/hackathons/[id]/VotingSection.tsxapp/hackathons/[id]/page.tsxlib/nostrCacheTags.tslib/voting.tslib/votingCache.tslib/votingClient.ts
| function removeIdentity(pubkey: string) { | ||
| const next = identities.filter((i) => i.pubkey !== pubkey); | ||
| setIdentities(next); | ||
| saveIdentities(next); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Al borrar la identidad activa, cerrá también la sesión actual.
Si en Line 100-104 se elimina la identidad actualmente autenticada, la sesión queda vigente aunque ya no exista en la lista local, dejando estado inconsistente en el laboratorio.
💡 Propuesta de ajuste
function removeIdentity(pubkey: string) {
+ const wasActive = auth?.pubkey === pubkey;
const next = identities.filter((i) => i.pubkey !== pubkey);
setIdentities(next);
saveIdentities(next);
+ if (wasActive) clearAuth("user");
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/dev/voting/DevVotingClient.tsx` around lines 100 - 104, Al eliminar la
identidad en la función removeIdentity (que usa identities, setIdentities y
saveIdentities) también detectá si la pubkey eliminada coincide con la identidad
actualmente autenticada y, si es así, cerrá la sesión limpiando el estado de
autenticación (por ejemplo llamando a logout(), setActiveIdentity(null),
setSession(null) o setAuthenticated(false) según cómo esté implementado) y
persistí ese cambio (por ejemplo saveSession/guardarEstado) para evitar estado
inconsistente; añadí esa comprobación justo después de saveIdentities dentro de
removeIdentity.
| {HACKATHONS.map((h) => ( | ||
| <option key={h.id} value={h.id}> | ||
| {h.name} ({h.id}) — {hackathonStatus(h)} | ||
| </option> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Traducí los estados visibles al usuario a español.
En Line 259 y Line 267 se muestran estados en inglés (active/upcoming/closed, open/closed), y en Line 278 aparece NAMESPACE TEST. Esto rompe la consistencia de idioma en UI.
💡 Propuesta de ajuste
+ const hackathonStatusLabel: Record<"upcoming" | "active" | "closed", string> = {
+ upcoming: "próximo",
+ active: "activo",
+ closed: "cerrado",
+ };
+ const votingStatusLabel: Record<"open" | "closed", string> = {
+ open: "abierta",
+ closed: "cerrada",
+ };
...
- {h.name} ({h.id}) — {hackathonStatus(h)}
+ {h.name} ({h.id}) — {hackathonStatusLabel[hackathonStatus(h)]}
...
- ? `Estado: ${period.status} · ${period.eligible.length} votantes · ${period.projects.length} proyectos`
+ ? `Estado: ${votingStatusLabel[period.status]} · ${period.eligible.length} votantes · ${period.projects.length} proyectos`
...
- {testNamespace ? "NAMESPACE TEST" : "NAMESPACE PRODUCCIÓN"}
+ {testNamespace ? "NAMESPACE DE PRUEBA" : "NAMESPACE DE PRODUCCIÓN"}As per coding guidelines, "User-facing copy must be in Spanish (lang="es", locale es_AR); identifiers and code comments are English; error messages shown to users must remain Spanish".
Also applies to: 264-268, 278-278
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/dev/voting/DevVotingClient.tsx` around lines 257 - 260, En el mapeo de
HACKATHONS (HACKATHONS.map) y en la función hackathonStatus cambia los estados
mostrados al usuario de inglés a español (por ejemplo active → activo, upcoming
→ próximo, closed → cerrado) y en cualquier etiqueta de votación que muestre
open/closed usa abierto/cerrado; además reemplaza la cadena visible "NAMESPACE
TEST" por su equivalente en español ("NAMESPACE DE PRUEBA" o "NOMBRE DE ESPACIO
DE PRUEBA") para mantener consistencia de idioma en la UI; actualiza solo los
textos mostrados al usuario dejando identificadores y comentarios en inglés.
Source: Coding guidelines
| import type { Metadata } from "next"; | ||
| import { notFound } from "next/navigation"; | ||
| import { isVotingTestNamespace } from "@/lib/voting"; | ||
| import DevVotingClient from "./DevVotingClient"; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Usá el alias @/* en lugar de import relativo.
En Line 4, ./DevVotingClient incumple la convención de imports del repo para TS/TSX.
💡 Propuesta de ajuste
-import DevVotingClient from "./DevVotingClient";
+import DevVotingClient from "`@/app/dev/voting/DevVotingClient`";As per coding guidelines, "Use the @/* path alias for imports, resolving to the repo root (e.g., @/lib/hackathons, @/components/ui/PageHero)".
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import DevVotingClient from "./DevVotingClient"; | |
| import DevVotingClient from "`@/app/dev/voting/DevVotingClient`"; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/dev/voting/page.tsx` at line 4, Replace the relative import in page.tsx
for DevVotingClient with the repo-root alias form: change the import of
DevVotingClient (currently "./DevVotingClient") to the equivalent path using the
`@/` alias that points to the repository root (e.g., import DevVotingClient from
"`@/app/dev/voting/DevVotingClient`"), ensuring the module specifier matches the
file's location from the repo root.
Source: Coding guidelines
| const [period, setPeriod] = useState<VotingPeriod | null>(initialPeriod); | ||
| const [ballots, setBallots] = useState<Map<string, SignedEvent>>(new Map()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reset the ballot cache when the hackathon or voting period changes.
This Map only ever grows. If the user client-navigates from one hackathon page to another, or the admin reopens a fresh period, the old ballots stay in memory until relays happen to replace them. That contaminates liveTally, ownBallotEvent, and the editor’s “already voted” state with stale data.
🧹 Suggested reset point
const [period, setPeriod] = useState<VotingPeriod | null>(initialPeriod);
const [ballots, setBallots] = useState<Map<string, SignedEvent>>(new Map());
+
+ useEffect(() => {
+ setBallots(new Map());
+ }, [hackathonId, period?.openedAt]);
// Live ballots while voting is open.
const votingOpen = period?.status === "open";Also applies to: 121-141
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 57 - 58, The ballots Map
in VotingSection (state variables ballots / setBallots) is never cleared and
retains entries across hackathon or VotingPeriod changes; add a useEffect that
watches the current hackathon id and period (period state / setPeriod) and
resets ballots by calling setBallots(new Map()) whenever either changes (also
clear any related derived state like liveTally and ownBallotEvent in the same
effect or by calling their setters) so stale ballots do not contaminate
liveTally, ownBallotEvent, or the editor “already voted” state; apply the same
reset logic to the other ballots-related state blocks referenced around lines
121-141.
| const [allocations, setAllocations] = useState<Record<string, number>>( | ||
| initialAllocations ?? {}, | ||
| ); | ||
| const [publishing, setPublishing] = useState(false); | ||
| // Refresh steppers when our relay ballot arrives, but never clobber edits. | ||
| const dirty = useRef(false); | ||
| useEffect(() => { | ||
| if (!dirty.current && initialAllocations) { | ||
| setAllocations(initialAllocations); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [JSON.stringify(initialAllocations)]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Prune allocations against the current project list before publish.
The admin flow can republish the roster/project list while voting remains open. If a project disappears after this editor mounts, its old allocation stays hidden in local state, still counts toward used, and gets republished even though the user can no longer remove it from the UI.
🛠️ Suggested sanitization
+ const allowedProjectIds = useMemo(
+ () => new Set(period.projects.map((project) => project.id)),
+ [period.projects],
+ );
+
useEffect(() => {
if (!dirty.current && initialAllocations) {
- setAllocations(initialAllocations);
+ setAllocations(
+ Object.fromEntries(
+ Object.entries(initialAllocations).filter(([projectId]) =>
+ allowedProjectIds.has(projectId),
+ ),
+ ),
+ );
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [JSON.stringify(initialAllocations)]);
+ }, [JSON.stringify(initialAllocations), allowedProjectIds]);
async function handlePublish() {
if (!auth || publishing || used === 0 || used > maxVotes) return;
setPublishing(true);
try {
const signer = await getSigner(auth);
+ const sanitizedAllocations = Object.fromEntries(
+ Object.entries(allocations).filter(([projectId]) =>
+ allowedProjectIds.has(projectId),
+ ),
+ );
const ev = await publishBallot(
signer,
hackathonId,
- allocations,
+ sanitizedAllocations,
prevBallotCreatedAt,
);Also applies to: 509-540
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/hackathons/`[id]/VotingSection.tsx around lines 496 - 507, The
allocations state can contain keys for projects that were removed from the
current project list, causing stale allocations to count toward used and be
republished; update the sync and publish paths to prune allocations to only
include IDs present in the current projects list. When setting allocations from
initialAllocations in the useEffect (and any other place where initial
allocations are applied, e.g., relay updates), filter the object to remove keys
not in the current project IDs before calling setAllocations; likewise, before
toggling publishing / inside the publish handler (where setPublishing is used),
compute a sanitizedAllocations object by keeping only keys that exist in the
current project list and use that for the publish payload and setAllocations, so
hidden/removed projects are never counted or republished (reference allocations,
setAllocations, dirty, the useEffect that consumes initialAllocations, and the
publish handler where publishing is set).
| if (existing) { | ||
| // Same pubkey reachable from two roster entries — keep the larger budget | ||
| // and union the blocked lists. | ||
| existing.maxVotes = Math.max(existing.maxVotes, hackathons.size); | ||
| existing.blocked = [...new Set([...existing.blocked, ...blocked])]; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Incorrect maxVotes when same pubkey appears in multiple roster entries.
Taking Math.max(existing.maxVotes, hackathons.size) compares individual entry counts rather than counting the union of hackathons across all entries for that pubkey. If a voter appears in multiple roster entries (e.g., same Nostr key, different display names) with disjoint hackathon participation, their vote budget will be undercounted.
Example: Entry 1 has hackathons {A, B}, Entry 2 has hackathons {C, D}. Current code yields max(2, 2) = 2, but correct budget is 4 distinct hackathons.
Proposed fix: track hackathon sets per pubkey
export function buildEligibleVoters(
soldiers: Soldier[],
hackathonId: string,
): VotingEligibleVoter[] {
- const byPubkey = new Map<string, VotingEligibleVoter>();
+ const hackathonsByPubkey = new Map<string, Set<string>>();
+ const blockedByPubkey = new Map<string, Set<string>>();
+ const nameByPubkey = new Map<string, string>();
+
for (const s of soldiers) {
if (!s.pubkey) continue;
- const hackathons = new Set(
- s.projects.map((p) => p.hackathonId).filter(Boolean),
- );
- if (hackathons.size === 0) continue;
- const blocked = [
- ...new Set(
- s.projects
- .filter((p) => p.hackathonId === hackathonId)
- .map((p) => p.projectId),
- ),
- ];
const pubkey = s.pubkey.toLowerCase();
- const existing = byPubkey.get(pubkey);
- if (existing) {
- // Same pubkey reachable from two roster entries — keep the larger budget
- // and union the blocked lists.
- existing.maxVotes = Math.max(existing.maxVotes, hackathons.size);
- existing.blocked = [...new Set([...existing.blocked, ...blocked])];
- } else {
- byPubkey.set(pubkey, {
- pubkey,
- name: s.name,
- maxVotes: hackathons.size,
- blocked,
- });
+
+ // Accumulate hackathons for this pubkey
+ let hackathons = hackathonsByPubkey.get(pubkey);
+ if (!hackathons) {
+ hackathons = new Set();
+ hackathonsByPubkey.set(pubkey, hackathons);
}
+ for (const p of s.projects) {
+ if (p.hackathonId) hackathons.add(p.hackathonId);
+ }
+
+ // Accumulate blocked projects for this pubkey
+ let blocked = blockedByPubkey.get(pubkey);
+ if (!blocked) {
+ blocked = new Set();
+ blockedByPubkey.set(pubkey, blocked);
+ }
+ for (const p of s.projects) {
+ if (p.hackathonId === hackathonId) blocked.add(p.projectId);
+ }
+
+ // Keep first name encountered
+ if (!nameByPubkey.has(pubkey)) {
+ nameByPubkey.set(pubkey, s.name);
+ }
+ }
+
+ const result: VotingEligibleVoter[] = [];
+ for (const [pubkey, hackathons] of hackathonsByPubkey) {
+ if (hackathons.size === 0) continue;
+ result.push({
+ pubkey,
+ name: nameByPubkey.get(pubkey) ?? "",
+ maxVotes: hackathons.size,
+ blocked: [...(blockedByPubkey.get(pubkey) ?? [])],
+ });
}
- return [...byPubkey.values()];
+ return result;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/voting.ts` around lines 357 - 361, The code incorrectly sets
existing.maxVotes using Math.max(existing.maxVotes, hackathons.size), which
undercounts when the same pubkey appears in multiple roster entries; instead,
track and union the actual hackathon sets per pubkey and set existing.maxVotes
to the size of that union. Update the merge logic where existing is found
(references: existing, maxVotes, hackathons, blocked) to compute a unioned set
of hackathon IDs across the existing record and the incoming hackathons, assign
existing.maxVotes = union.size, and retain the existing.blocked union behavior;
if no per-pubkey hackathon set exists yet, create one when first inserting the
pubkey so future merges can union correctly.
| const closer = pool.subscribe( | ||
| relays, | ||
| { | ||
| kinds: [VOTING_KIND], | ||
| "#d": [dTag], | ||
| limit: 500, | ||
| }, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Drop the 500-event cap from the live ballot bootstrap.
Line 116 silently truncates the initial ballot snapshot. Once a hackathon has more than 500 current voters, the open-period UI undercounts liveTally, and some users' own prior ballots may never hydrate into the editor state.
💡 Minimal fix
const closer = pool.subscribe(
relays,
{
kinds: [VOTING_KIND],
"`#d`": [dTag],
- limit: 500,
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| "#d": [dTag], | |
| limit: 500, | |
| }, | |
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| "`#d`": [dTag], | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/votingClient.ts` around lines 111 - 117, The live ballot bootstrap is
being truncated by the fixed limit in the subscribe filter; in the call to
pool.subscribe (the subscription that uses VOTING_KIND and "`#d`": [dTag]) remove
the hardcoded limit: 500 (or set it to undefined/omit the property) so the
initial snapshot returns all matching events and the liveTally/editor hydration
is accurate for contests with >500 voters.
| const { SimplePool } = await import("nostr-tools/pool"); | ||
| if (closed) return; | ||
| const pool = new SimplePool(); | ||
| const closer = pool.subscribe( | ||
| relays, | ||
| { | ||
| kinds: [VOTING_KIND], | ||
| authors: [publisherPubkey], | ||
| "#d": [dTag], | ||
| }, | ||
| { | ||
| onevent(ev) { | ||
| const event = ev as SignedEvent; | ||
| const d = event.tags.find((t) => t[0] === "d")?.[1]; | ||
| if (d !== dTag) return; | ||
| if (event.pubkey !== publisherPubkey) return; | ||
| if (event.created_at <= freshest) return; | ||
| const period = parseVotingPeriod(event.content); | ||
| if (!period || period.hackathonId !== hackathonId) return; | ||
| freshest = event.created_at; | ||
| onPeriod(period, event.created_at); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major
Verify Nostr event signature in subscribeToVotingPeriod before using pubkey/content.
lib/votingClient.ts onevent filters on event.pubkey and parses event.content without checking the event signature; a malicious relay can feed spoofed/invalid events that still match these filters. Add a verifyEvent guard right at the start of onevent.
🔐 Suggested guard
+import { verifyEvent } from "nostr-tools/pure";
+
onevent(ev) {
const event = ev as SignedEvent;
+ if (!verifyEvent(event)) return;
const d = event.tags.find((t) => t[0] === "d")?.[1];
if (d !== dTag) return;
if (event.pubkey !== publisherPubkey) return;
if (event.created_at <= freshest) return;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { SimplePool } = await import("nostr-tools/pool"); | |
| if (closed) return; | |
| const pool = new SimplePool(); | |
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| authors: [publisherPubkey], | |
| "#d": [dTag], | |
| }, | |
| { | |
| onevent(ev) { | |
| const event = ev as SignedEvent; | |
| const d = event.tags.find((t) => t[0] === "d")?.[1]; | |
| if (d !== dTag) return; | |
| if (event.pubkey !== publisherPubkey) return; | |
| if (event.created_at <= freshest) return; | |
| const period = parseVotingPeriod(event.content); | |
| if (!period || period.hackathonId !== hackathonId) return; | |
| freshest = event.created_at; | |
| onPeriod(period, event.created_at); | |
| import { verifyEvent } from "nostr-tools/pure"; | |
| const { SimplePool } = await import("nostr-tools/pool"); | |
| if (closed) return; | |
| const pool = new SimplePool(); | |
| const closer = pool.subscribe( | |
| relays, | |
| { | |
| kinds: [VOTING_KIND], | |
| authors: [publisherPubkey], | |
| "`#d`": [dTag], | |
| }, | |
| { | |
| onevent(ev) { | |
| const event = ev as SignedEvent; | |
| if (!verifyEvent(event)) return; | |
| const d = event.tags.find((t) => t[0] === "d")?.[1]; | |
| if (d !== dTag) return; | |
| if (event.pubkey !== publisherPubkey) return; | |
| if (event.created_at <= freshest) return; | |
| const period = parseVotingPeriod(event.content); | |
| if (!period || period.hackathonId !== hackathonId) return; | |
| freshest = event.created_at; | |
| onPeriod(period, event.created_at); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/votingClient.ts` around lines 165 - 185, In subscribeToVotingPeriod's
onevent handler add a signature verification guard: call verifyEvent(event) (or
the project's verifyEvent/verifySignedEvent utility) as the very first check
inside onevent and return immediately if it fails, before accessing event.pubkey
or parsing event.content; this ensures events are cryptographically validated
before the existing checks (the handler in subscribeToVotingPeriod, the
SignedEvent usage, and parseVotingPeriod calls).
Context
Hackathon winners should be chosen by the community, not only by judges. This adds a Nostr-native community voting system embedded on each hackathon page (
/hackathons/[id]).Rules (as specified):
How it works
Two kind-30078 (NIP-78 parameterized replaceable) event roles, both carrying
["client","La Crypta Dev"]:LACRYPTA_NSEC,d = lacrypta.dev:voting:<hackathonId>. Carries the open/closed status, a frozen eligibility snapshot (voter pubkeys, budgets, blocked projects), the votable project list, and — once closed — the canonical final tally signed by La Crypta.d = lacrypta.dev:vote:<hackathonId>. Replaceable: one ballot per voter per hackathon; re-voting replaces it.Admin open/close goes through a kind-27235 auth request, reusing the existing soldiers-ranking publish pattern (
verifyEvent→ admin pubkey match → action tag → server-signs → publish →revalidateTag). While open, the tally is computed live from relay ballots; once aclosedperiod arrives, clients render the embedded signed results verbatim — the freeze rule, since relays can't freeze and late/forged-timestamp ballots can't change a signed result.Files
New
lib/voting.ts— pure shared contract: schemas,validateBallot,tallyBallots,buildEligibleVoters, namespaced d-tags.lib/votingCache.ts— server-only cached period read (mirrorsnostrSoldiersCache).lib/votingClient.ts—publishBallot, live ballot + period subscriptions.app/api/hackathons/[id]/voting/route.ts— GET state / POST open-close.app/hackathons/[id]/VotingSection.tsx— admin controls, ballot editor, live tally, frozen results.app/dev/voting/{page,DevVotingClient}.tsx— dev-only test harness (404s in prod).Modified
app/hackathons/[id]/page.tsx— page-levelcacheTag(nostrVotingTag(id))+ Suspense-wrapped<VotingSection>.lib/nostrCacheTags.ts—nostrVotingTag().Test environment
/dev/voting(dev-only) generates throwaway identities, logs in as any of them, and embeds the realVotingSection. Isolation viaNEXT_PUBLIC_VOTING_NS=test(moves all d-tags tolacrypta.dev:test:, invisible to production reads) plus a server-onlyVOTING_TEST_EXTRA_VOTERSallowlist. Production is unaffected when the var is unset.Verification
Verified end-to-end against real relays in the test namespace:
/hackathons/zapssaw the live tally + login prompt; ineligible users saw the explanation.pnpm buildpasses.🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes