diff --git a/app/api/hackathons/[id]/voting/route.ts b/app/api/hackathons/[id]/voting/route.ts new file mode 100644 index 0000000..500b972 --- /dev/null +++ b/app/api/hackathons/[id]/voting/route.ts @@ -0,0 +1,357 @@ +import { NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; +import type { SignedEvent } from "@/lib/nostrSigner"; +import { getSoldiers } from "@/lib/soldiers"; +import { + getHackathon, + mergeWithSubmissions, + type HackathonSubmission, +} from "@/lib/hackathons"; +import { getNostrHackathonSubmissions } from "@/lib/nostrCache"; +import { DEFAULT_RELAYS } from "@/lib/nostrRelayConfig"; +import { nostrVotingTag } from "@/lib/nostrCacheTags"; +import { + fetchVotingPeriodFromRelays, + getCachedVotingPeriod, +} from "@/lib/votingCache"; +import { + VOTING_KIND, + VOTING_SCHEMA_VERSION, + VOTING_T_TAG, + buildEligibleVoters, + isVotingTestNamespace, + serializeVotingPeriod, + tallyBallots, + voteDTag, + votingPeriodDTag, + type VotingEligibleVoter, + type VotingPeriod, + type VotingProjectRef, +} from "@/lib/voting"; + +const OPEN_ACTION = "open-voting"; +const CLOSE_ACTION = "close-voting"; + +function jsonError(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +async function getBackendSecret(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) throw new Error("Falta LACRYPTA_NSEC."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") throw new Error("LACRYPTA_NSEC invalido."); + return decoded.data as Uint8Array; +} + +async function getAdminPubkey(): Promise { + const npub = + process.env.NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB || + process.env.NEXT_PUBLIC_LACRYPTA_NPUB; + if (!npub) throw new Error("Falta NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB."); + const { decode } = await import("nostr-tools/nip19"); + const decoded = decode(npub); + if (decoded.type !== "npub") { + throw new Error("NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB invalido."); + } + return decoded.data as string; +} + +function requestTagValue(request: SignedEvent, name: string): string | null { + return request.tags.find((tag) => tag[0] === name)?.[1] ?? null; +} + +async function publishToRelays( + signed: SignedEvent, + relays: string[], + perRelayTimeoutMs = 8000, +): Promise<{ relay: string; ok: boolean; error?: string }[]> { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const promises = pool.publish(relays, signed); + const results = await Promise.all( + relays.map(async (relay, i) => { + try { + await Promise.race([ + promises[i], + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), perRelayTimeoutMs), + ), + ]); + return { relay, ok: true }; + } catch (error) { + return { + relay, + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + try { + pool.close(relays); + } catch { + /* noop */ + } + return results; +} + +/** Collects all ballot events for the hackathon (no `authors` filter — + * eligibility is enforced by `tallyBallots` against the frozen snapshot). */ +async function fetchBallotEvents( + hackathonId: string, + timeoutMs = 5000, +): Promise { + const dTag = voteDTag(hackathonId); + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: SignedEvent[] = []; + + const closer = pool.subscribe( + DEFAULT_RELAYS, + { kinds: [VOTING_KIND], "#d": [dTag], limit: 1000 }, + { + onevent(ev) { + const event = ev as SignedEvent; + const d = event.tags.find((t) => t[0] === "d")?.[1]; + if (d === dTag) events.push(event); + }, + oneose() { + /* timeout-driven */ + }, + }, + ); + + await new Promise((r) => setTimeout(r, timeoutMs)); + try { + closer.close(); + } catch { + /* noop */ + } + try { + pool.close(DEFAULT_RELAYS); + } catch { + /* noop */ + } + return events; +} + +/** Test-only extra voters (`hexpk:budget,hexpk:budget`) — hard-gated to the + * test namespace so it can never widen production eligibility. */ +function testExtraVoters(): VotingEligibleVoter[] { + if (!isVotingTestNamespace()) return []; + const raw = process.env.VOTING_TEST_EXTRA_VOTERS; + if (!raw) return []; + const out: VotingEligibleVoter[] = []; + for (const entry of raw.split(",")) { + const [pubkey, budget] = entry.trim().split(":"); + if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) continue; + const maxVotes = Math.max(1, Number.parseInt(budget ?? "1", 10) || 1); + out.push({ + pubkey: pubkey.toLowerCase(), + name: `Tester ${pubkey.slice(0, 8)}`, + maxVotes, + blocked: [], + }); + } + return out; +} + +/** Adds current-hackathon team pubkeys (and Nostr authors) to those voters' + * blocked lists — covers projects whose members carry explicit pubkeys. */ +function applyTeamPubkeyBlocks( + eligible: VotingEligibleVoter[], + projects: HackathonSubmission[], +) { + const byPubkey = new Map(eligible.map((v) => [v.pubkey, v])); + for (const project of projects) { + const memberPubkeys = new Set(); + for (const m of project.team) { + if (m.pubkey) memberPubkeys.add(m.pubkey.toLowerCase()); + } + if (project.nostrAuthor) memberPubkeys.add(project.nostrAuthor.toLowerCase()); + for (const pubkey of memberPubkeys) { + const voter = byPubkey.get(pubkey); + if (voter && !voter.blocked.includes(project.id)) { + voter.blocked.push(project.id); + } + } + } +} + +async function votableProjects( + hackathonId: string, +): Promise { + const nostr = (await getNostrHackathonSubmissions(hackathonId)).map((p) => ({ + ...p, + nostrAuthor: p.author, + nostrEventId: p.eventId, + nostrCreatedAt: p.eventCreatedAt, + })); + return mergeWithSubmissions(hackathonId, nostr); +} + +export async function GET( + _req: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + if (!getHackathon(id)) return jsonError("Hackatón desconocido.", 404); + try { + const period = await getCachedVotingPeriod(id); + return NextResponse.json({ period }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : "No se pudo leer la votación.", + 500, + ); + } +} + +export async function POST( + req: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + const hackathon = getHackathon(id); + if (!hackathon) return jsonError("Hackatón desconocido.", 404); + + let body: { request?: SignedEvent }; + try { + body = (await req.json()) as { request?: SignedEvent }; + } catch { + return jsonError("Body JSON invalido."); + } + const request = body.request; + if (!request) return jsonError("Falta request firmado."); + + try { + const { finalizeEvent, verifyEvent } = await import("nostr-tools/pure"); + const secret = await getBackendSecret(); + const adminPubkey = await getAdminPubkey(); + + if (!verifyEvent(request)) return jsonError("Request Nostr invalido.", 401); + if (request.pubkey !== adminPubkey) { + return jsonError( + "El usuario logueado debe coincidir con NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB.", + 403, + ); + } + if (Math.abs(Math.floor(Date.now() / 1000) - request.created_at) > 10 * 60) { + return jsonError("Request expirado.", 401); + } + const action = requestTagValue(request, "action"); + if (action !== OPEN_ACTION && action !== CLOSE_ACTION) { + return jsonError("Request no autorizado para administrar la votación.", 401); + } + if (requestTagValue(request, "h") !== id) { + return jsonError("El request no corresponde a este hackatón.", 401); + } + + const existing = await fetchVotingPeriodFromRelays(id); + const now = Math.floor(Date.now() / 1000); + // NIP-01 replaceable tie-break keeps the LOWEST id on equal created_at — + // always publish strictly after the event we're replacing. + const eventCreatedAt = Math.max(now, (existing?.eventCreatedAt ?? 0) + 1); + + let period: VotingPeriod; + + if (action === OPEN_ACTION) { + if ( + existing?.period.status === "closed" && + requestTagValue(request, "force") !== "1" + ) { + return jsonError( + "La votación ya fue cerrada. Reabrir requiere forzar.", + 409, + ); + } + + const projects = await votableProjects(id); + const projectRefs: VotingProjectRef[] = projects.map((p) => ({ + id: p.id, + name: p.name, + })); + const soldiers = await getSoldiers().catch(() => []); + const eligible = buildEligibleVoters(soldiers, id); + for (const extra of testExtraVoters()) { + if (!eligible.some((v) => v.pubkey === extra.pubkey)) { + eligible.push(extra); + } + } + applyTeamPubkeyBlocks(eligible, projects); + + period = { + version: VOTING_SCHEMA_VERSION, + hackathonId: id, + status: "open", + // Re-publishing an already-open period refreshes the snapshot + // (projects/eligibility) without restarting the window. + openedAt: + existing?.period.status === "open" ? existing.period.openedAt : now, + closedAt: null, + projects: projectRefs, + eligible, + results: null, + }; + } else { + if (!existing || existing.period.status !== "open") { + return jsonError("No hay una votación abierta para cerrar.", 409); + } + const ballots = await fetchBallotEvents(id); + const { results } = tallyBallots(ballots, existing.period, now); + period = { + ...existing.period, + status: "closed", + closedAt: now, + results, + }; + } + + const signed = finalizeEvent( + { + kind: VOTING_KIND, + created_at: eventCreatedAt, + content: serializeVotingPeriod(period), + tags: [ + ["d", votingPeriodDTag(id)], + ["t", VOTING_T_TAG], + ["h", id], + ["status", period.status], + ["client", "La Crypta Dev"], + ], + }, + secret, + ) as SignedEvent; + + const relayResults = await publishToRelays(signed, DEFAULT_RELAYS); + if (!relayResults.some((r) => r.ok)) { + return NextResponse.json( + { error: "Ningún relay aceptó el evento de votación.", relays: relayResults }, + { status: 502 }, + ); + } + + // Bust the server cache so SSR reads the freshly-published period. + revalidateTag(nostrVotingTag(id), { expire: 0 }); + + return NextResponse.json({ + ok: true, + eventId: signed.id, + status: period.status, + eligibleCount: period.eligible.length, + projectCount: period.projects.length, + results: period.results, + relays: relayResults, + }); + } catch (error) { + console.error("[api/hackathons/voting] failed", error); + return jsonError( + error instanceof Error + ? error.message + : "No se pudo administrar la votación.", + 500, + ); + } +} diff --git a/app/dev/voting/DevVotingClient.tsx b/app/dev/voting/DevVotingClient.tsx new file mode 100644 index 0000000..f1c37ed --- /dev/null +++ b/app/dev/voting/DevVotingClient.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Copy, KeyRound, Plus, Trash2, UserCheck } from "lucide-react"; +import { HACKATHONS, hackathonStatus } from "@/lib/hackathons"; +import { useAuth, setAuth, clearAuth } from "@/lib/auth"; +import { useToast } from "@/components/Toast"; +import { cn } from "@/lib/cn"; +import type { VotingPeriod } from "@/lib/voting"; +import VotingSection from "@/app/hackathons/[id]/VotingSection"; + +type DevIdentity = { + label: string; + pubkey: string; + npub: string; + /** 32-byte secret as plain array (same encoding as Auth.localSecret). */ + secret: number[]; +}; + +const STORAGE_KEY = "labs:dev:voting-voters:v1"; + +function loadIdentities(): DevIdentity[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as DevIdentity[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function saveIdentities(identities: DevIdentity[]) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(identities)); + } catch { + /* quota */ + } +} + +export default function DevVotingClient({ + testNamespace, +}: { + testNamespace: boolean; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + + const activeHackathon = useMemo( + () => HACKATHONS.find((h) => hackathonStatus(h) === "active") ?? HACKATHONS[0], + [], + ); + const [hackathonId, setHackathonId] = useState(activeHackathon.id); + const [identities, setIdentities] = useState([]); + const [period, setPeriod] = useState(null); + const [loadingPeriod, setLoadingPeriod] = useState(false); + + useEffect(() => { + setIdentities(loadIdentities()); + }, []); + + useEffect(() => { + let cancelled = false; + setLoadingPeriod(true); + fetch(`/api/hackathons/${hackathonId}/voting`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { period?: VotingPeriod | null } | null) => { + if (!cancelled) setPeriod(data?.period ?? null); + }) + .catch(() => { + if (!cancelled) setPeriod(null); + }) + .finally(() => { + if (!cancelled) setLoadingPeriod(false); + }); + return () => { + cancelled = true; + }; + }, [hackathonId]); + + async function generateIdentity() { + const { generateSecretKey, getPublicKey } = await import( + "nostr-tools/pure" + ); + const { npubEncode } = await import("nostr-tools/nip19"); + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + const identity: DevIdentity = { + label: `Identidad ${identities.length + 1}`, + pubkey, + npub: npubEncode(pubkey), + secret: Array.from(secret), + }; + const next = [...identities, identity]; + setIdentities(next); + saveIdentities(next); + } + + function removeIdentity(pubkey: string) { + const next = identities.filter((i) => i.pubkey !== pubkey); + setIdentities(next); + saveIdentities(next); + } + + function loginAs(identity: DevIdentity) { + setAuth({ + method: "local", + pubkey: identity.pubkey, + localSecret: identity.secret, + }); + push({ + kind: "success", + title: `Sesión iniciada como ${identity.label}`, + description: identity.npub.slice(0, 20) + "…", + }); + } + + async function copy(text: string, what: string) { + try { + await navigator.clipboard.writeText(text); + push({ kind: "info", title: `${what} copiado` }); + } catch { + push({ kind: "error", title: "No se pudo copiar" }); + } + } + + return ( +
+ {/* ── Identity lab ── */} +
+
+
+ +

+ Identidades de prueba +

+
+ +
+ +

+ Para actuar como admin: copiá el npub de una + identidad a NEXT_PUBLIC_LACRYPTA_ADMIN_NPUB. + Para habilitar votantes: copiá sus pubkeys hex a{" "} + VOTING_TEST_EXTRA_VOTERS (formato{" "} + hexpk:presupuesto,hexpk:presupuesto). + Reiniciá el server tras cambiar el .env.local. +

+ + {identities.length === 0 ? ( +

+ Sin identidades todavía — generá un par para empezar. +

+ ) : ( +
    + {identities.map((identity) => { + const active = auth?.pubkey === identity.pubkey; + return ( +
  • + {identity.label} + {active && ( + + + ACTIVA + + )} + + {identity.npub} + + + + + + + +
  • + ); + })} +
+ )} + + {auth && ( +
+ + Sesión actual: {auth.method} · {auth.pubkey.slice(0, 16)}… + + +
+ )} +
+ + {/* ── Period status + embedded production component ── */} +
+
+ + + + {loadingPeriod + ? "Cargando estado…" + : period + ? `Estado: ${period.status} · ${period.eligible.length} votantes · ${period.projects.length} proyectos` + : "Sin votación publicada"} + + + {testNamespace ? "NAMESPACE TEST" : "NAMESPACE PRODUCCIÓN"} + +
+ + {/* The real production component — what you test here is what ships. */} + h.id === hackathonId)?.name ?? hackathonId + } + initialPeriod={period} + /> +
+
+ ); +} diff --git a/app/dev/voting/page.tsx b/app/dev/voting/page.tsx new file mode 100644 index 0000000..57d1626 --- /dev/null +++ b/app/dev/voting/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { isVotingTestNamespace } from "@/lib/voting"; +import DevVotingClient from "./DevVotingClient"; + +export const metadata: Metadata = { + title: "Dev · Votación", + robots: { index: false, follow: false }, +}; + +/** + * Dev-only harness to exercise the community voting flow end to end: generate + * throwaway identities, act as admin (open/close) and as several voters, and + * watch the live tally — all against the `lacrypta.dev:test:` d-tag namespace + * so production voting data is never touched. 404s in production builds. + */ +export default function DevVotingPage() { + if (process.env.NODE_ENV === "production") notFound(); + + return ( +
+
+

+ Laboratorio de votación +

+

+ Entorno de prueba para la votación comunitaria. Generá identidades + descartables, usalas como admin o votantes y probá el flujo completo + sin tocar datos de producción. +

+ {!isVotingTestNamespace() && ( +
+ ⚠ Namespace de producción. Agregá{" "} + NEXT_PUBLIC_VOTING_NS=test a tu{" "} + .env.local y reiniciá el server — + si no, los eventos de prueba van al namespace real. +
+ )} + +
+
+ ); +} diff --git a/app/hackathons/[id]/VotingSection.tsx b/app/hackathons/[id]/VotingSection.tsx new file mode 100644 index 0000000..f32db51 --- /dev/null +++ b/app/hackathons/[id]/VotingSection.tsx @@ -0,0 +1,722 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + CheckCircle2, + Loader2, + Lock, + Megaphone, + Minus, + Plus, + Trophy, + Vote, + X, +} from "lucide-react"; +import { useAuth } from "@/lib/auth"; +import { getSigner, type SignedEvent } from "@/lib/nostrSigner"; +import { useToast } from "@/components/Toast"; +import { useScrollLock } from "@/lib/useScrollLock"; +import { cn } from "@/lib/cn"; +import { + isVotingTestNamespace, + tallyBallots, + type VotingPeriod, + type VotingResults, +} from "@/lib/voting"; +import { + publishBallot, + subscribeToBallots, + subscribeToVotingPeriod, +} from "@/lib/votingClient"; + +type Pubkeys = { adminPubkey: string | null; publisherPubkey: string | null }; + +/** + * Community voting for the hackathon's projects. Eligibility, vote budgets and + * the votable project list come frozen inside the period event La Crypta + * publishes when the admin opens the voting; ballots are replaceable Nostr + * events signed by each voter. While open the tally is computed live from + * relay ballots; once closed the embedded official results are rendered + * verbatim (the freeze rule — late ballots can't change a signed result). + */ +export default function VotingSection({ + hackathonId, + hackathonName, + initialPeriod, +}: { + hackathonId: string; + hackathonName: string; + initialPeriod: VotingPeriod | null; +}) { + const { auth, ready } = useAuth(); + + const [pubkeys, setPubkeys] = useState({ + adminPubkey: null, + publisherPubkey: null, + }); + const [period, setPeriod] = useState(initialPeriod); + const [ballots, setBallots] = useState>(new Map()); + + useEffect(() => { + let cancelled = false; + fetch("/api/lacrypta-pubkeys") + .then((res) => (res.ok ? res.json() : null)) + .then((data: Pubkeys | null) => { + if (!cancelled && data) { + setPubkeys({ + adminPubkey: data.adminPubkey ?? null, + publisherPubkey: data.publisherPubkey ?? null, + }); + } + }) + .catch(() => { + /* section degrades to read-only */ + }); + return () => { + cancelled = true; + }; + }, []); + + // Authoritative period read on mount — the SSR'd page is cached and may + // predate the latest open/close, and relays can be slow to answer the + // subscription below. + useEffect(() => { + let cancelled = false; + fetch(`/api/hackathons/${hackathonId}/voting`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { period?: VotingPeriod | null } | null) => { + if (cancelled || !data?.period) return; + setPeriod((prev) => { + // Never downgrade a closed period back to open with stale data. + if (prev?.status === "closed" && data.period!.status === "open") { + return prev; + } + return data.period!; + }); + }) + .catch(() => { + /* relay subscription still covers us */ + }); + return () => { + cancelled = true; + }; + }, [hackathonId]); + + // Live period flips (open/close) — what makes admin actions visible + // everywhere without a reload. + useEffect(() => { + if (!pubkeys.publisherPubkey) return; + let freshest = 0; + return subscribeToVotingPeriod( + hackathonId, + pubkeys.publisherPubkey, + (next, createdAt) => { + if (createdAt <= freshest) return; + freshest = createdAt; + setPeriod(next); + }, + ); + }, [hackathonId, pubkeys.publisherPubkey]); + + // Live ballots while voting is open. + const votingOpen = period?.status === "open"; + useEffect(() => { + if (!votingOpen) return; + return subscribeToBallots(hackathonId, (ev) => { + setBallots((prev) => { + const key = ev.pubkey.toLowerCase(); + const existing = prev.get(key); + if ( + existing && + (existing.created_at > ev.created_at || + (existing.created_at === ev.created_at && existing.id <= ev.id)) + ) { + return prev; + } + const next = new Map(prev); + next.set(key, ev); + return next; + }); + }); + }, [hackathonId, votingOpen]); + + const isAdmin = + !!auth?.pubkey && + !!pubkeys.adminPubkey && + auth.pubkey === pubkeys.adminPubkey; + + const liveTally = useMemo(() => { + if (!period) return null; + return tallyBallots([...ballots.values()], period); + }, [ballots, period]); + + // Nothing to show before the first opening (admins see the open button). + if (!period && !isAdmin) return null; + + const results: VotingResults | null = + period?.status === "closed" + ? period.results + : (liveTally?.results ?? null); + + const voter = + period && auth?.pubkey + ? (period.eligible.find( + (v) => v.pubkey === auth.pubkey.toLowerCase(), + ) ?? null) + : null; + + const ownBallotEvent = auth?.pubkey + ? (ballots.get(auth.pubkey.toLowerCase()) ?? null) + : null; + const ownAllocations = + voter && auth?.pubkey + ? (liveTally?.byVoter.get(auth.pubkey.toLowerCase()) ?? null) + : null; + + return ( +
+
+
+
+
+ +

+ Votación comunitaria +

+ {isVotingTestNamespace() && ( + + MODO TEST + + )} + {period && ( + + {period.status === "open" ? "ABIERTA" : "CERRADA"} + + )} +
+ {isAdmin && ( + + )} +
+ + {!period ? ( +

+ La votación comunitaria de {hackathonName} todavía no fue abierta. +

+ ) : ( + <> +

+ {period.status === "open" + ? "La comunidad elige a los ganadores. Vota cualquiera que haya participado de algún hackatón y tenga su identidad Nostr vinculada — 1 voto por hackatón participado, repartidos como quieras." + : "La votación está cerrada. Estos son los resultados oficiales."} +

+ + {period.status === "open" && ready && ( +
+ {!auth ? ( +

+ Iniciá sesión con Nostr para votar. +

+ ) : voter ? ( + { + setBallots((prev) => { + const next = new Map(prev); + next.set(ev.pubkey.toLowerCase(), ev); + return next; + }); + }} + /> + ) : ( +

+ Solo pueden votar quienes participaron de algún hackatón y + tienen su identidad Nostr vinculada. +

+ )} +
+ )} + + {results && ( + + )} + + )} +
+
+
+ ); +} + +/* ───────────────────────── Admin controls ───────────────────────── */ + +type AdminStep = "idle" | "signing" | "publishing"; + +function AdminVotingControls({ + hackathonId, + period, + onPeriod, +}: { + hackathonId: string; + period: VotingPeriod | null; + onPeriod: (period: VotingPeriod) => void; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + const [step, setStep] = useState("idle"); + const [confirmClose, setConfirmClose] = useState(false); + useScrollLock(confirmClose); + + const busy = step !== "idle"; + + const runAction = useCallback( + async (action: "open-voting" | "close-voting", force = false) => { + if (!auth || busy) return; + setStep("signing"); + try { + const signer = await getSigner(auth); + const tags: string[][] = [ + ["u", `/api/hackathons/${hackathonId}/voting`], + ["method", "POST"], + ["action", action], + ["h", hackathonId], + ]; + if (force) tags.push(["force", "1"]); + const request = await signer.signEvent({ + kind: 27235, + pubkey: signer.pubkey, + created_at: Math.floor(Date.now() / 1000), + content: + action === "open-voting" + ? "Abrir votación comunitaria" + : "Cerrar votación comunitaria", + tags, + }); + + setStep("publishing"); + const res = await fetch(`/api/hackathons/${hackathonId}/voting`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request }), + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + status?: "open" | "closed"; + eligibleCount?: number; + error?: string; + }; + if (!res.ok || !data.ok) { + throw new Error(data.error || "No se pudo actualizar la votación."); + } + + // Optimistic refresh — the relay subscription will confirm shortly. + const fresh = await fetch( + `/api/hackathons/${hackathonId}/voting`, + ).then((r) => (r.ok ? r.json() : null)); + if (fresh?.period) onPeriod(fresh.period as VotingPeriod); + + push({ + kind: "success", + title: + data.status === "open" ? "Votación abierta" : "Votación cerrada", + description: + data.status === "open" + ? `${data.eligibleCount ?? 0} votantes habilitados.` + : "Los resultados quedaron congelados y publicados en Nostr.", + }); + } catch (error) { + push({ + kind: "error", + title: "Error de votación", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setStep("idle"); + setConfirmClose(false); + } + }, + [auth, busy, hackathonId, onPeriod, push], + ); + + const label = + step === "signing" + ? "Firmando…" + : step === "publishing" + ? "Publicando…" + : null; + + return ( + <> +
+ {(!period || period.status === "closed") && ( + + )} + {period?.status === "open" && ( + <> + + + + )} +
+ + {confirmClose && ( +
!busy && setConfirmClose(false)} + > +
e.stopPropagation()} + > +
+

+ ¿Cerrar la votación? +

+ +
+

+ Se calculará el resultado final con los votos recibidos hasta + ahora y se publicará firmado por La Crypta. Después del cierre + los votos nuevos no cuentan. +

+
+ + +
+
+
+ )} + + ); +} + +/* ───────────────────────── Ballot editor ───────────────────────── */ + +function BallotEditor({ + hackathonId, + period, + voterPubkey, + maxVotes, + blocked, + initialAllocations, + prevBallotCreatedAt, + onPublished, +}: { + hackathonId: string; + period: VotingPeriod; + voterPubkey: string; + maxVotes: number; + blocked: string[]; + initialAllocations: Record | null; + prevBallotCreatedAt: number; + onPublished: (ev: SignedEvent) => void; +}) { + const { auth } = useAuth(); + const { push } = useToast(); + const [allocations, setAllocations] = useState>( + 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)]); + + const used = Object.values(allocations).reduce((sum, n) => sum + n, 0); + const remaining = maxVotes - used; + const hasPrev = prevBallotCreatedAt > 0 || !!initialAllocations; + + function adjust(projectId: string, delta: number) { + dirty.current = true; + setAllocations((prev) => { + const current = prev[projectId] ?? 0; + const next = current + delta; + if (next < 0) return prev; + // Compute against `prev`, not the rendered `remaining` — rapid clicks + // batched into one render would otherwise overshoot the budget. + const prevUsed = Object.values(prev).reduce((sum, n) => sum + n, 0); + if (delta > 0 && prevUsed >= maxVotes) return prev; + const out = { ...prev }; + if (next === 0) delete out[projectId]; + else out[projectId] = next; + return out; + }); + } + + async function handlePublish() { + if (!auth || publishing || used === 0 || used > maxVotes) return; + setPublishing(true); + try { + const signer = await getSigner(auth); + const ev = await publishBallot( + signer, + hackathonId, + allocations, + prevBallotCreatedAt, + ); + dirty.current = false; + onPublished(ev); + push({ + kind: "success", + title: hasPrev ? "Votos actualizados" : "Votos publicados", + description: `Repartiste ${used} ${used === 1 ? "voto" : "votos"} firmados con tu clave Nostr.`, + }); + } catch (error) { + push({ + kind: "error", + title: "No se pudo publicar tu voto", + description: + error instanceof Error ? error.message : "Error desconocido.", + }); + } finally { + setPublishing(false); + } + } + + return ( +
+
+ + Tenés {maxVotes} {maxVotes === 1 ? "voto" : "votos"} ·{" "} + + te {remaining === 1 ? "queda" : "quedan"} {remaining} + + + {voterPubkey && hasPrev && ( + + + Ya votaste — podés cambiar tu voto + + )} +
+ +
    + {period.projects.map((p) => { + const isBlocked = blocked.includes(p.id); + const count = allocations[p.id] ?? 0; + return ( +
  • 0 + ? "border-nostr/40 bg-nostr/5" + : "border-border bg-white/[0.02]", + )} + > + + {p.name} + + {isBlocked ? ( + + Tu proyecto + + ) : ( + + + 0 ? "text-nostr" : "text-foreground-subtle", + )} + > + {count} + + + + )} +
  • + ); + })} +
+ +
+ +
+
+ ); +} + +/* ───────────────────────── Tally board ───────────────────────── */ + +function TallyBoard({ + results, + closed, +}: { + results: VotingResults; + closed: boolean; +}) { + const max = Math.max(1, ...results.tally.map((r) => r.votes)); + return ( +
+
+ + {closed ? "Resultados finales" : "Resultados en vivo"} + + + {results.ballotsCounted}{" "} + {results.ballotsCounted === 1 ? "votante" : "votantes"} ·{" "} + {results.totalVotesCast} votos + +
+
    + {results.tally.map((row, i) => { + const leader = closed && i === 0 && row.votes > 0; + return ( +
  1. +
    +
    +
    + {leader && } + + {row.name} + + + {row.votes} + +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/app/hackathons/[id]/page.tsx b/app/hackathons/[id]/page.tsx index 3e4191a..1f3e50b 100644 --- a/app/hackathons/[id]/page.tsx +++ b/app/hackathons/[id]/page.tsx @@ -46,7 +46,10 @@ import { NOSTR_SUBMISSIONS_TAG, } from "@/lib/nostrCache"; import { getCachedNostrProfile } from "@/lib/nostrProfileCache"; +import { getCachedVotingPeriod } from "@/lib/votingCache"; +import { nostrVotingTag } from "@/lib/nostrCacheTags"; import HackathonProjectsList from "./HackathonProjectsList"; +import VotingSection from "./VotingSection"; import HackathonResultsClient from "./HackathonResultsClient"; import PrizeBadgeButton, { type PrizeBadgeTask } from "./PrizeBadgeButton"; import PrizeZapButton from "./PrizeZapButton"; @@ -439,6 +442,10 @@ export default async function HackathonPage({ const { id } = await params; const hackathon = getHackathon(id); if (!hackathon) notFound(); + // Inner "use cache" tags don't bubble in Next 16 — register the voting tag + // at page level so open/close revalidations refresh this page too. + cacheTag(nostrVotingTag(id)); + const votingPeriod = await getCachedVotingPeriod(id); const status = hackathonStatus(hackathon); const statusMeta = STATUS_META[status]; const projects = rankedProjects(id); @@ -698,6 +705,15 @@ export default async function HackathonPage({ /> + {/* Community voting — same Suspense requirement as the projects list. */} + + + +
{/* Dates timeline */} diff --git a/lib/nostrCacheTags.ts b/lib/nostrCacheTags.ts index f7b75de..0d20dbd 100644 --- a/lib/nostrCacheTags.ts +++ b/lib/nostrCacheTags.ts @@ -18,6 +18,10 @@ export function nostrReportsTag(hackathonId: string) { return `nostr:reports:${hackathonId}`; } +export function nostrVotingTag(hackathonId: string) { + return `nostr:voting:${hackathonId}`; +} + export function nostrHackathonBadgesTag(hackathonId: string) { return `nostr:hackathon-badges:${hackathonId}`; } diff --git a/lib/voting.ts b/lib/voting.ts new file mode 100644 index 0000000..105df45 --- /dev/null +++ b/lib/voting.ts @@ -0,0 +1,372 @@ +/** + * Shared contract for the community voting system — two NIP-78 (kind 30078) + * parameterized replaceable event roles: + * + * 1. Voting period event — published by La Crypta (server-signed with + * LACRYPTA_NSEC), `d = lacrypta.dev:voting:`. Carries the + * open/closed status, a frozen eligibility snapshot and, once closed, the + * canonical final tally. + * 2. Ballot event — signed by the voter, `d = lacrypta.dev:vote:`. + * Replaceable: one ballot per voter per hackathon; re-voting replaces it. + * + * Pure module shared by the server cache reader (`lib/votingCache.ts`), the + * admin API route (`app/api/hackathons/[id]/voting/route.ts`) and the client + * (`lib/votingClient.ts`, `VotingSection`). No Nostr I/O, no `"use cache"`. + */ + +import type { Soldier } from "./soldiers"; + +export const VOTING_KIND = 30078; +export const VOTING_T_TAG = "lacrypta-dev-voting"; +export const VOTE_T_TAG = "lacrypta-dev-vote"; +export const VOTING_SCHEMA_VERSION = 1; + +/** + * Dev/test isolation: with `NEXT_PUBLIC_VOTING_NS=test` every d-tag moves to + * the `lacrypta.dev:test:` namespace, so test events on the public relays are + * invisible to production reads (which query the un-namespaced tags signed by + * the production publisher key). Build-time inlined on both server and client. + */ +export function isVotingTestNamespace(): boolean { + return process.env.NEXT_PUBLIC_VOTING_NS === "test"; +} + +function dTagPrefix(): string { + return isVotingTestNamespace() ? "lacrypta.dev:test" : "lacrypta.dev"; +} + +export function votingPeriodDTag(hackathonId: string): string { + return `${dTagPrefix()}:voting:${hackathonId}`; +} + +export function voteDTag(hackathonId: string): string { + return `${dTagPrefix()}:vote:${hackathonId}`; +} + +export type VotingEligibleVoter = { + pubkey: string; + name: string; + /** 1 vote per distinct hackathon the voter participated in. */ + maxVotes: number; + /** Project ids in the current hackathon the voter cannot vote for (own projects). */ + blocked: string[]; +}; + +export type VotingProjectRef = { + id: string; + name: string; +}; + +export type VotingTallyRow = { + projectId: string; + name: string; + votes: number; + /** Distinct voters that allocated at least one vote to this project. */ + voters: number; +}; + +export type VotingResults = { + tally: VotingTallyRow[]; + ballotsCounted: number; + ballotsRejected: number; + totalVotesCast: number; +}; + +export type VotingPeriod = { + version: number; + hackathonId: string; + status: "open" | "closed"; + /** Unix seconds the voting opened. */ + openedAt: number; + /** Unix seconds the voting closed; null while open. */ + closedAt: number | null; + projects: VotingProjectRef[]; + eligible: VotingEligibleVoter[]; + /** Canonical final tally — only present once status is "closed". */ + results: VotingResults | null; +}; + +export type BallotContent = { + version: number; + hackathonId: string; + /** projectId → votes allocated (positive integers). */ + allocations: Record; +}; + +/** Minimal event shape — matches `SignedEvent` without importing client code. */ +export type VotingEventLike = { + id: string; + pubkey: string; + kind: number; + created_at: number; + tags: string[][]; + content: string; +}; + +export function serializeVotingPeriod(period: VotingPeriod): string { + return JSON.stringify(period); +} + +/** Defensive parse — returns null for anything that isn't a valid period. */ +export function parseVotingPeriod(content: string): VotingPeriod | null { + try { + const parsed = JSON.parse(content) as Partial; + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.hackathonId !== "string" || + (parsed.status !== "open" && parsed.status !== "closed") || + typeof parsed.openedAt !== "number" || + !Array.isArray(parsed.projects) || + !Array.isArray(parsed.eligible) + ) { + return null; + } + return { + version: + typeof parsed.version === "number" + ? parsed.version + : VOTING_SCHEMA_VERSION, + hackathonId: parsed.hackathonId, + status: parsed.status, + openedAt: parsed.openedAt, + closedAt: typeof parsed.closedAt === "number" ? parsed.closedAt : null, + projects: parsed.projects.filter( + (p): p is VotingProjectRef => + !!p && typeof p.id === "string" && typeof p.name === "string", + ), + eligible: parsed.eligible + .filter( + (v): v is VotingEligibleVoter => + !!v && + typeof v.pubkey === "string" && + typeof v.maxVotes === "number" && + v.maxVotes > 0, + ) + .map((v) => ({ + pubkey: v.pubkey.toLowerCase(), + name: typeof v.name === "string" ? v.name : "", + maxVotes: Math.floor(v.maxVotes), + blocked: Array.isArray(v.blocked) + ? v.blocked.filter((b): b is string => typeof b === "string") + : [], + })), + results: + parsed.results && Array.isArray(parsed.results.tally) + ? (parsed.results as VotingResults) + : null, + }; + } catch { + return null; + } +} + +/** Defensive parse of a ballot's content — null on garbage. */ +export function parseBallotContent(content: string): BallotContent | null { + try { + const parsed = JSON.parse(content) as Partial; + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.hackathonId !== "string" || + !parsed.allocations || + typeof parsed.allocations !== "object" || + Array.isArray(parsed.allocations) + ) { + return null; + } + const allocations: Record = {}; + for (const [projectId, votes] of Object.entries(parsed.allocations)) { + if (typeof votes !== "number") return null; + allocations[projectId] = votes; + } + return { + version: + typeof parsed.version === "number" + ? parsed.version + : VOTING_SCHEMA_VERSION, + hackathonId: parsed.hackathonId, + allocations, + }; + } catch { + return null; + } +} + +function eventTagValue(ev: VotingEventLike, name: string): string | null { + return ev.tags.find((t) => t[0] === name)?.[1] ?? null; +} + +export type BallotValidation = + | { ok: true; allocations: Record } + | { ok: false; reason: string }; + +/** + * A ballot counts iff its `d` tag matches the hackathon, its author is in the + * frozen eligibility snapshot, it was created inside the voting window, every + * allocation targets a votable (non-blocked) project with a positive integer + * amount, and the total stays within the voter's budget. + */ +export function validateBallot( + ev: VotingEventLike, + period: VotingPeriod, + opts: { closedAt?: number | null } = {}, +): BallotValidation { + if (ev.kind !== VOTING_KIND) return { ok: false, reason: "kind" }; + if (eventTagValue(ev, "d") !== voteDTag(period.hackathonId)) { + return { ok: false, reason: "d-tag" }; + } + const voter = period.eligible.find( + (v) => v.pubkey === ev.pubkey.toLowerCase(), + ); + if (!voter) return { ok: false, reason: "not-eligible" }; + if (ev.created_at < period.openedAt) return { ok: false, reason: "too-early" }; + const closedAt = opts.closedAt ?? period.closedAt; + if (closedAt !== null && closedAt !== undefined && ev.created_at > closedAt) { + return { ok: false, reason: "too-late" }; + } + + const content = parseBallotContent(ev.content); + if (!content || content.hackathonId !== period.hackathonId) { + return { ok: false, reason: "content" }; + } + + const projectIds = new Set(period.projects.map((p) => p.id)); + let total = 0; + const allocations: Record = {}; + for (const [projectId, votes] of Object.entries(content.allocations)) { + if (!Number.isInteger(votes) || votes < 1) { + return { ok: false, reason: "invalid-amount" }; + } + if (!projectIds.has(projectId)) { + return { ok: false, reason: "unknown-project" }; + } + if (voter.blocked.includes(projectId)) { + return { ok: false, reason: "self-vote" }; + } + allocations[projectId] = votes; + total += votes; + } + if (total === 0) return { ok: false, reason: "empty" }; + if (total > voter.maxVotes) return { ok: false, reason: "over-budget" }; + + return { ok: true, allocations }; +} + +/** + * Latest-per-author dedupe for replaceable ballots: keep the highest + * `created_at`; on ties keep the lowest event id (NIP-01 — relays may return + * divergent versions of the same replaceable event). + */ +export function dedupeBallots(events: VotingEventLike[]): VotingEventLike[] { + const byAuthor = new Map(); + for (const ev of events) { + const key = ev.pubkey.toLowerCase(); + const prev = byAuthor.get(key); + if ( + !prev || + ev.created_at > prev.created_at || + (ev.created_at === prev.created_at && ev.id < prev.id) + ) { + byAuthor.set(key, ev); + } + } + return [...byAuthor.values()]; +} + +export function tallyBallots( + events: VotingEventLike[], + period: VotingPeriod, + closedAt?: number | null, +): { + results: VotingResults; + /** voter pubkey (lowercase hex) → their counted allocations. */ + byVoter: Map>; +} { + const deduped = dedupeBallots(events); + const byVoter = new Map>(); + let rejected = 0; + + for (const ev of deduped) { + const result = validateBallot(ev, period, { closedAt }); + if (result.ok) { + byVoter.set(ev.pubkey.toLowerCase(), result.allocations); + } else { + rejected++; + } + } + + const votesByProject = new Map(); + let totalVotesCast = 0; + for (const allocations of byVoter.values()) { + for (const [projectId, votes] of Object.entries(allocations)) { + const row = votesByProject.get(projectId) ?? { votes: 0, voters: 0 }; + row.votes += votes; + row.voters += 1; + votesByProject.set(projectId, row); + totalVotesCast += votes; + } + } + + const tally: VotingTallyRow[] = period.projects + .map((p) => ({ + projectId: p.id, + name: p.name, + votes: votesByProject.get(p.id)?.votes ?? 0, + voters: votesByProject.get(p.id)?.voters ?? 0, + })) + .sort((a, b) => b.votes - a.votes || a.name.localeCompare(b.name)); + + return { + results: { + tally, + ballotsCounted: byVoter.size, + ballotsRejected: rejected, + totalVotesCast, + }, + byVoter, + }; +} + +/** + * Builds the frozen eligibility snapshot from the soldiers roster: anyone with + * a Nostr pubkey who participated in at least one hackathon. Vote budget = 1 + * per distinct hackathon participated in; `blocked` = the voter's own projects + * in the hackathon being voted (no self-votes). + */ +export function buildEligibleVoters( + soldiers: Soldier[], + hackathonId: string, +): VotingEligibleVoter[] { + const byPubkey = new Map(); + 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, + }); + } + } + return [...byPubkey.values()]; +} diff --git a/lib/votingCache.ts b/lib/votingCache.ts new file mode 100644 index 0000000..7c79f91 --- /dev/null +++ b/lib/votingCache.ts @@ -0,0 +1,104 @@ +/** + * Server-only read of the voting period event — the kind-30078 replaceable + * event published by La Crypta (see `app/api/hackathons/[id]/voting`). + * Mirrors `lib/nostrSoldiersCache.ts`: one cached relay round-trip, freshest + * event wins, `null` on any miss. The `authors` filter (publisher pubkey + * derived from LACRYPTA_NSEC) is the trust anchor. + */ + +import { cacheLife, cacheTag } from "next/cache"; +import { DEFAULT_RELAYS } from "./nostrRelayConfig"; +import { nostrVotingTag } from "./nostrCacheTags"; +import { + VOTING_KIND, + parseVotingPeriod, + votingPeriodDTag, + type VotingPeriod, +} from "./voting"; + +type IncomingEvent = { + id: string; + pubkey: string; + content: string; + tags: string[][]; + created_at: number; +}; + +async function publisherPubkeyFromNsec(): Promise { + const nsec = process.env.LACRYPTA_NSEC; + if (!nsec) return ""; + const { decode } = await import("nostr-tools/nip19"); + const { getPublicKey } = await import("nostr-tools/pure"); + const decoded = decode(nsec); + if (decoded.type !== "nsec") return ""; + return getPublicKey(decoded.data as Uint8Array); +} + +/** Uncached relay fetch — used by the admin POST route to read current state. */ +export async function fetchVotingPeriodFromRelays( + hackathonId: string, + timeoutMs = 4500, +): Promise<{ period: VotingPeriod; eventCreatedAt: number } | null> { + const publisherPubkey = await publisherPubkeyFromNsec(); + if (!publisherPubkey) return null; + + const relays = DEFAULT_RELAYS; + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const events: IncomingEvent[] = []; + + const closer = pool.subscribe( + relays, + { + kinds: [VOTING_KIND], + authors: [publisherPubkey], + "#d": [votingPeriodDTag(hackathonId)], + }, + { + onevent(ev: IncomingEvent) { + events.push(ev); + }, + oneose() { + /* timeout-driven */ + }, + }, + ); + + await new Promise((r) => setTimeout(r, timeoutMs)); + try { + closer.close(); + } catch { + /* noop */ + } + try { + pool.close(relays); + } catch { + /* noop */ + } + + events.sort( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + ); + for (const ev of events) { + // Some relays match `#d` loosely — re-check the exact tag. + const d = ev.tags.find((t) => t[0] === "d")?.[1]; + if (d !== votingPeriodDTag(hackathonId)) continue; + const period = parseVotingPeriod(ev.content); + if (period) return { period, eventCreatedAt: ev.created_at }; + } + return null; +} + +export async function getCachedVotingPeriod( + hackathonId: string, +): Promise { + "use cache"; + cacheLife("hours"); + cacheTag(nostrVotingTag(hackathonId)); + try { + const found = await fetchVotingPeriodFromRelays(hackathonId); + return found?.period ?? null; + } catch { + return null; + } +} diff --git a/lib/votingClient.ts b/lib/votingClient.ts new file mode 100644 index 0000000..807f3a5 --- /dev/null +++ b/lib/votingClient.ts @@ -0,0 +1,207 @@ +"use client"; + +/** + * Client side of the voting system: publish the user's ballot and live-stream + * ballots / period flips from the relays. Server reads live in + * `lib/votingCache.ts` — never import this module from server code. + */ + +import { FAST_USER_RELAYS, DEFAULT_RELAYS } from "./nostrRelayConfig"; +import type { SignedEvent, UserSigner } from "./nostrSigner"; +import { + VOTE_T_TAG, + VOTING_KIND, + VOTING_SCHEMA_VERSION, + parseVotingPeriod, + voteDTag, + votingPeriodDTag, + type BallotContent, + type VotingPeriod, +} from "./voting"; + +async function publishToRelays( + signed: SignedEvent, + relays: string[], + perRelayTimeoutMs = 8000, +): Promise<{ relay: string; ok: boolean }[]> { + const { SimplePool } = await import("nostr-tools/pool"); + const pool = new SimplePool(); + const promises = pool.publish(relays, signed); + const results = await Promise.all( + relays.map(async (relay, i) => { + try { + await Promise.race([ + promises[i], + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), perRelayTimeoutMs), + ), + ]); + return { relay, ok: true }; + } catch { + return { relay, ok: false }; + } + }), + ); + try { + pool.close(relays); + } catch { + /* noop */ + } + return results; +} + +/** + * Signs and publishes the user's (replaceable) ballot. `createdAtFloor` should + * be the created_at of the user's previous ballot, if any — NIP-01 keeps the + * LOWEST id on created_at ties, so we bump past it to guarantee replacement. + */ +export async function publishBallot( + signer: UserSigner, + hackathonId: string, + allocations: Record, + createdAtFloor = 0, +): Promise { + const content: BallotContent = { + version: VOTING_SCHEMA_VERSION, + hackathonId, + allocations, + }; + const createdAt = Math.max( + Math.floor(Date.now() / 1000), + createdAtFloor + 1, + ); + const signed = await signer.signEvent({ + kind: VOTING_KIND, + pubkey: signer.pubkey, + created_at: createdAt, + content: JSON.stringify(content), + tags: [ + ["d", voteDTag(hackathonId)], + ["t", VOTE_T_TAG], + ["h", hackathonId], + ["client", "La Crypta Dev"], + ], + }); + + const results = await publishToRelays(signed, [...DEFAULT_RELAYS]); + if (!results.some((r) => r.ok)) { + throw new Error("Ningún relay aceptó tu voto. Probá de nuevo."); + } + return signed; +} + +/** + * Live-subscribe to ballot events for a hackathon (historical + new), keeping + * the relay subscription open until the returned cleanup function runs. + * Eligibility/validity is NOT enforced here — callers run `tallyBallots`. + */ +export function subscribeToBallots( + hackathonId: string, + onEvent: (ev: SignedEvent) => void, +): () => void { + let closed = false; + let teardown: (() => void) | null = null; + const dTag = voteDTag(hackathonId); + const relays = [...FAST_USER_RELAYS]; + + void (async () => { + const { SimplePool } = await import("nostr-tools/pool"); + if (closed) return; + const pool = new SimplePool(); + const closer = pool.subscribe( + relays, + { + kinds: [VOTING_KIND], + "#d": [dTag], + limit: 500, + }, + { + onevent(ev) { + const event = ev as SignedEvent; + // Relay-side `#d` filtering is not universal — re-check the tag. + const d = event.tags.find((t) => t[0] === "d")?.[1]; + if (d !== dTag) return; + onEvent(event); + }, + oneose() { + // Keep the subscription open for live ballots — do not close here. + }, + }, + ); + teardown = () => { + closer.close(); + try { + pool.close(relays); + } catch { + /* noop */ + } + }; + if (closed) teardown(); + })(); + + return () => { + closed = true; + teardown?.(); + }; +} + +/** + * Live-subscribe to the voting period event published by La Crypta. Calls + * `onPeriod` with the freshest valid period whenever one arrives, so open and + * close flips reach every viewer without a page reload. + */ +export function subscribeToVotingPeriod( + hackathonId: string, + publisherPubkey: string, + onPeriod: (period: VotingPeriod, eventCreatedAt: number) => void, +): () => void { + let closed = false; + let teardown: (() => void) | null = null; + let freshest = 0; + const dTag = votingPeriodDTag(hackathonId); + const relays = [...FAST_USER_RELAYS]; + + void (async () => { + 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); + }, + oneose() { + // Keep open for live open/close flips. + }, + }, + ); + teardown = () => { + closer.close(); + try { + pool.close(relays); + } catch { + /* noop */ + } + }; + if (closed) teardown(); + })(); + + return () => { + closed = true; + teardown?.(); + }; +}