Skip to content

feat(voting): community voting for hackathon projects via Nostr#36

Open
agustinkassis wants to merge 1 commit into
mainfrom
claude/thirsty-galileo-a3303c
Open

feat(voting): community voting for hackathon projects via Nostr#36
agustinkassis wants to merge 1 commit into
mainfrom
claude/thirsty-galileo-a3303c

Conversation

@agustinkassis

@agustinkassis agustinkassis commented Jun 12, 2026

Copy link
Copy Markdown
Member

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):

  • Eligible voters = anyone who participated in any hackathon so far and has a linked Nostr pubkey (drawn from the soldiers roster).
  • Vote budget = number of distinct hackathons participated in (1 vote per hackathon).
  • Votes are freely allocatable across projects; no self-votes (your own projects are disabled).
  • Admin opens voting with a button and closes it manually (no countdown).
  • Votes are Nostr events signed by the voter's own key.

How it works

Two kind-30078 (NIP-78 parameterized replaceable) event roles, both carrying ["client","La Crypta Dev"]:

  1. Voting period event — server-signed with 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.
  2. Ballot event — voter-signed, 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 a closed period 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 (mirrors nostrSoldiersCache).
  • lib/votingClient.tspublishBallot, 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-level cacheTag(nostrVotingTag(id)) + Suspense-wrapped <VotingSection>.
  • lib/nostrCacheTags.tsnostrVotingTag().

Test environment

/dev/voting (dev-only) generates throwaway identities, logs in as any of them, and embeds the real VotingSection. Isolation via NEXT_PUBLIC_VOTING_NS=test (moves all d-tags to lacrypta.dev:test:, invisible to production reads) plus a server-only VOTING_TEST_EXTRA_VOTERS allowlist. Production is unaffected when the var is unset.

Verification

Verified end-to-end against real relays in the test namespace:

  • Admin opened voting for gaming → 13 projects, 17 eligible voters (15 roster + 2 test).
  • Voter B (budget 3) voted 2+1, re-voted 1/1/1 — tally moved, no double-counting. Voter C capped at budget 1.
  • Anonymous visitors on /hackathons/zaps saw the live tally + login prompt; ineligible users saw the explanation.
  • Admin closed via confirm modal → board flipped live to frozen "Resultados finales" (🏆 Zaptris 2; signed results: 2 ballots counted, 0 rejected, 4 votes).
  • Two bugs found & fixed during verification: batched-click budget overshoot, and a relay-latency case that could hide the section.
  • pnpm build passes.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added community voting system for hackathons with open/close periods managed by administrators.
    • Voters can submit ballots, allocate votes across projects, and view live tally results.
    • Admin controls enable opening, closing, and reopening voting periods.
    • Real-time vote tallying with distinct voter tracking and results display.

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>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lacrypta-dev Ready Ready Preview, Comment Jun 12, 2026 10:02pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Community voting system

Layer / File(s) Summary
Voting domain contracts and types
lib/voting.ts (lines 1–105)
Defines Nostr kind 30078 constants, d-tag namespacing with test-namespace support, and all shared domain types: VotingPeriod, VotingEligibleVoter, VotingProjectRef, VotingTallyRow, VotingResults, and BallotContent.
Shared ballot validation, parsing, and tallying
lib/voting.ts (lines 106–372)
Implements defensive parsing for voting periods and ballots, validates ballots against eligibility snapshots/timing/budget constraints, deduplicates replaceable ballots per voter, tallies votes into project results with voter counts, and derives eligible voter snapshots from soldier rosters with per-voter vote budgets and project blocking.
Cache tag infrastructure
lib/nostrCacheTags.ts (lines 21–24)
Adds nostrVotingTag(hackathonId) helper for Next.js cache revalidation.
Server-side voting period caching
lib/votingCache.ts (all)
Fetches voting period events from Nostr relays filtered by La Crypta author and hackathon d-tag, validates and parses content with fallback to null, exposes uncached relay reads and cached application reads with cacheLife("hours") and per-hackathon revalidation.
Backend voting admin API
app/api/hackathons/[id]/voting/route.ts (all)
Implements GET (returns cached period) and POST (admin only). POST verifies signed Nostr event auth, computes replaceable-event timestamps, fetches projects/soldiers/ballots, injects test voters when in test namespace, applies team pubkey blocks to eligibility, constructs or tallies the period, publishes the signed event to relays, and revalidates the cache.
Client-side ballot publishing and subscriptions
lib/votingClient.ts (all)
Provides ballot publishing with replaceable event semantics and per-relay timeouts, live subscription to ballot events with d-tag validation and callback forwarding, and live subscription to voting period updates with freshness tracking.
Production voting UI
app/hackathons/[id]/VotingSection.tsx (all)
Manages voting state with pubkey fetching, period initialization and live updates, and ballot subscriptions. Renders conditional UI: open voting allows BallotEditor (with dirty tracking, budget enforcement, and ballot publishing), closed voting shows TallyBoard with ranked results, and admins see AdminVotingControls (signing and publishing period-change actions with modal confirmation and optimistic refresh).
Development voting lab
app/dev/voting/DevVotingClient.tsx, app/dev/voting/page.tsx (all)
Adds dev-only page with identity lab: localStorage persistence for test identities, keypair generation, identity login/logout with toast notifications, and Nostr pubkey copying. Hosts production VotingSection component with test-identity context. Page guards production access and warns when not in test namespace.
Hackathon page integration
app/hackathons/[id]/page.tsx (lines 49–52, 445–448, 708–716)
Registers voting cache tag, fetches cached voting period at page render, and renders VotingSection inside Suspense boundary with hackathon context and initial period.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • lacrypta/lacrypta-dev#27: Establishes the Nostr cache-tag infrastructure that this PR extends with voting-specific tags and revalidation for period updates.

Poem

🐰 A voting booth blooms in the Nostr night,
Where soldiers gather and cast their plight,
Smart contracts tally each ballot clean,
The fairest hackathon ever seen!
Test labs breed keypairs with glee,
Community decides—wild and free! 🗳️✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the primary change: adding community voting for hackathon projects via Nostr, which is exactly what this PR delivers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/thirsty-galileo-a3303c

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
lib/votingCache.ts (1)

9-17: 📐 Maintainability & Code Quality | ⚡ Quick win

Use @/* 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 win

Use 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

📥 Commits

Reviewing files that changed from the base of the PR and between c4b6e36 and e963e58.

📒 Files selected for processing (9)
  • app/api/hackathons/[id]/voting/route.ts
  • app/dev/voting/DevVotingClient.tsx
  • app/dev/voting/page.tsx
  • app/hackathons/[id]/VotingSection.tsx
  • app/hackathons/[id]/page.tsx
  • lib/nostrCacheTags.ts
  • lib/voting.ts
  • lib/votingCache.ts
  • lib/votingClient.ts

Comment on lines +100 to +104
function removeIdentity(pubkey: string) {
const next = identities.filter((i) => i.pubkey !== pubkey);
setIdentities(next);
saveIdentities(next);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +257 to +260
{HACKATHONS.map((h) => (
<option key={h.id} value={h.id}>
{h.name} ({h.id}) — {hackathonStatus(h)}
</option>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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

Comment thread app/dev/voting/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { isVotingTestNamespace } from "@/lib/voting";
import DevVotingClient from "./DevVotingClient";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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.

Suggested change
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

Comment on lines +57 to +58
const [period, setPeriod] = useState<VotingPeriod | null>(initialPeriod);
const [ballots, setBallots] = useState<Map<string, SignedEvent>>(new Map());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +496 to +507
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)]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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).

Comment thread lib/voting.ts
Comment on lines +357 to +361
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])];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment thread lib/votingClient.ts
Comment on lines +111 to +117
const closer = pool.subscribe(
relays,
{
kinds: [VOTING_KIND],
"#d": [dTag],
limit: 500,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment thread lib/votingClient.ts
Comment on lines +165 to +185
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.

Suggested change
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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant