diff --git a/app/api/lnurl-invoice/route.ts b/app/api/lnurl-invoice/route.ts
new file mode 100644
index 0000000..32f9b5c
--- /dev/null
+++ b/app/api/lnurl-invoice/route.ts
@@ -0,0 +1,140 @@
+import { NextResponse } from "next/server";
+
+type InvoiceRequestBody = {
+ endpoint?: unknown;
+ amount?: unknown;
+ nostr?: unknown;
+};
+
+function isAllowedEndpoint(endpoint: string): boolean {
+ try {
+ const url = new URL(endpoint);
+ if (url.protocol !== "https:") return false;
+ const hostname = url.hostname.toLowerCase();
+ if (
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname === "::1" ||
+ hostname.endsWith(".local")
+ ) {
+ return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function resolveInvoiceCallback(endpoint: string): Promise {
+ const url = new URL(endpoint);
+ if (!url.pathname.toLowerCase().includes("/.well-known/lnurlp/")) {
+ return endpoint;
+ }
+
+ const metadataRes = await fetch(endpoint, {
+ cache: "no-store",
+ headers: { accept: "application/json" },
+ });
+ const metadata = (await metadataRes.json().catch(() => ({}))) as {
+ callback?: unknown;
+ status?: unknown;
+ reason?: unknown;
+ };
+ if (!metadataRes.ok || metadata.status === "ERROR") {
+ throw new Error(
+ typeof metadata.reason === "string"
+ ? metadata.reason
+ : "No se pudo resolver la lightning address.",
+ );
+ }
+ if (typeof metadata.callback !== "string" || !isAllowedEndpoint(metadata.callback)) {
+ throw new Error("La lightning address no devolvió un callback válido.");
+ }
+ return metadata.callback;
+}
+
+export async function POST(req: Request) {
+ let body: InvoiceRequestBody;
+ try {
+ body = (await req.json()) as InvoiceRequestBody;
+ } catch {
+ return NextResponse.json(
+ { ok: false, reason: "Body JSON inválido." },
+ { status: 400 },
+ );
+ }
+
+ const endpoint = typeof body.endpoint === "string" ? body.endpoint : "";
+ const amount = typeof body.amount === "string" ? body.amount : "";
+ const nostr = typeof body.nostr === "string" ? body.nostr : "";
+
+ if (!isAllowedEndpoint(endpoint)) {
+ return NextResponse.json(
+ { ok: false, reason: "Endpoint LNURL inválido." },
+ { status: 400 },
+ );
+ }
+ if (!/^\d+$/.test(amount) || !nostr) {
+ return NextResponse.json(
+ { ok: false, reason: "Faltan amount o nostr." },
+ { status: 400 },
+ );
+ }
+
+ let callback: string;
+ try {
+ callback = await resolveInvoiceCallback(endpoint);
+ } catch (error) {
+ return NextResponse.json(
+ {
+ ok: false,
+ reason: error instanceof Error ? error.message : String(error),
+ },
+ { status: 502 },
+ );
+ }
+
+ const url = new URL(callback);
+ url.searchParams.set("amount", amount);
+ url.searchParams.set("nostr", nostr);
+
+ try {
+ const invoiceRes = await fetch(url.toString(), {
+ cache: "no-store",
+ headers: {
+ accept: "application/json",
+ },
+ });
+ const text = await invoiceRes.text();
+ let payload: unknown = {};
+ try {
+ payload = text ? JSON.parse(text) : {};
+ } catch {
+ payload = { reason: text };
+ }
+
+ if (!invoiceRes.ok) {
+ const reason =
+ typeof payload === "object" &&
+ payload !== null &&
+ "reason" in payload &&
+ typeof payload.reason === "string"
+ ? payload.reason
+ : `LNURL respondió HTTP ${invoiceRes.status}`;
+ return NextResponse.json(
+ { ok: false, reason },
+ { status: invoiceRes.status },
+ );
+ }
+
+ return NextResponse.json(payload);
+ } catch (error) {
+ return NextResponse.json(
+ {
+ ok: false,
+ reason: error instanceof Error ? error.message : String(error),
+ },
+ { status: 502 },
+ );
+ }
+}
diff --git a/app/hackathons/[id]/HackathonProjectsList.tsx b/app/hackathons/[id]/HackathonProjectsList.tsx
index b3a1b62..00bb959 100644
--- a/app/hackathons/[id]/HackathonProjectsList.tsx
+++ b/app/hackathons/[id]/HackathonProjectsList.tsx
@@ -33,6 +33,7 @@ import { formatSats } from "@/lib/hackathons";
import { useHackathonResults, type WinnerEntry } from "@/lib/nostrReports";
import { GithubIcon } from "@/components/BrandIcons";
import { cn } from "@/lib/cn";
+import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks";
function medal(position: number | null | undefined) {
if (position === 1) return "🥇";
@@ -330,6 +331,7 @@ function ProjectRow({
const authorDisplayName = isNostr
? displayNameForNostrProject(project)
: null;
+ const team = dedupeSoldierProfileMembers(project.team);
const Wrapper: React.ElementType = Link;
const wrapperProps = { href };
@@ -413,9 +415,9 @@ function ProjectRow({
)}
- {project.team.length > 0 && (
+ {team.length > 0 && (
- {project.team.map((t) => t.name).join(" · ")}
+ {team.map((t) => t.name).join(" · ")}
)}
{project.repo && (
diff --git a/app/hackathons/[id]/PrizeZapButton.tsx b/app/hackathons/[id]/PrizeZapButton.tsx
new file mode 100644
index 0000000..8b24bec
--- /dev/null
+++ b/app/hackathons/[id]/PrizeZapButton.tsx
@@ -0,0 +1,1519 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import {
+ CheckCircle2,
+ ClipboardList,
+ Copy,
+ ExternalLink,
+ FileJson,
+ Loader2,
+ ReceiptText,
+ ScrollText,
+ Trophy,
+ X,
+ Zap,
+} from "lucide-react";
+import { useAuth } from "@/lib/auth";
+import { getSigner } from "@/lib/nostrSigner";
+import type { SignedEvent } from "@/lib/nostrSigner";
+import {
+ findPrizeZapReceipt,
+ getLocalPrizeZapReceipt,
+ getLocalPrizeZap,
+ payPrizeZap,
+ type PrizeZapRecipientPaymentInfo,
+ type PrizeZapProgress,
+ type PrizeZapProgressStep,
+ type PrizeZapRecord,
+ type PrizeZapTarget,
+} from "@/lib/prizeZaps";
+import { formatSats } from "@/lib/hackathons";
+import { resolveLacryptaPubkey } from "@/lib/nostrReports";
+import { useToast } from "@/components/Toast";
+import { cn } from "@/lib/cn";
+
+export default function PrizeZapButton({ target }: { target: PrizeZapTarget }) {
+ const { auth, signerReady } = useAuth();
+ const { push } = useToast();
+ const [adminPubkey, setAdminPubkey] = useState
(null);
+ const [checking, setChecking] = useState(false);
+ const [paying, setPaying] = useState(false);
+ const [paid, setPaid] = useState(false);
+ const [receipt, setReceipt] = useState(null);
+ const [localRecord, setLocalRecord] = useState(null);
+ const [proofOpen, setProofOpen] = useState(false);
+ const [progressOpen, setProgressOpen] = useState(false);
+ const [progressLog, setProgressLog] = useState([]);
+ const [progressError, setProgressError] = useState(null);
+ const [recipientPaymentInfo, setRecipientPaymentInfo] =
+ useState(
+ target.recipientLightningAddress
+ ? {
+ lightningAddress: target.recipientLightningAddress,
+ zapEndpoint: target.recipientZapEndpoint ?? null,
+ source: "lud16",
+ }
+ : target.recipientZapEndpoint
+ ? {
+ lightningAddress: null,
+ zapEndpoint: target.recipientZapEndpoint,
+ source: "zap-endpoint",
+ }
+ : null,
+ );
+
+ const isAdmin = !!auth && !!adminPubkey && auth.pubkey === adminPubkey;
+ const disabled = checking || paying || (!paid && !signerReady);
+
+ const label = useMemo(() => {
+ if (paid) return "Pagado";
+ if (paying) return "Pagando";
+ if (checking) return "Chequeando";
+ return `Pagar ${formatSats(target.sats)}`;
+ }, [checking, paid, paying, target.sats]);
+
+ useEffect(() => {
+ let cancelled = false;
+ resolveLacryptaPubkey().then((pk) => {
+ if (!cancelled) setAdminPubkey(pk || null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!adminPubkey) return;
+ const cachedReceipt = getLocalPrizeZapReceipt(target, adminPubkey);
+ if (cachedReceipt) {
+ setReceipt(cachedReceipt);
+ setPaid(true);
+ return;
+ }
+ const local = isAdmin && auth ? getLocalPrizeZap(target, auth.pubkey) : null;
+ if (local) {
+ setLocalRecord(local);
+ setPaid(true);
+ return;
+ }
+ let cancelled = false;
+ setChecking(true);
+ findPrizeZapReceipt(target, adminPubkey)
+ .then((receipt) => {
+ if (!cancelled && receipt) {
+ setReceipt(receipt);
+ setPaid(true);
+ }
+ })
+ .catch(() => {
+ /* keep payable if receipt check fails */
+ })
+ .finally(() => {
+ if (!cancelled) setChecking(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [adminPubkey, auth, isAdmin, target]);
+
+ useEffect(() => {
+ setRecipientPaymentInfo(
+ target.recipientLightningAddress
+ ? {
+ lightningAddress: target.recipientLightningAddress,
+ zapEndpoint: target.recipientZapEndpoint ?? null,
+ source: "lud16",
+ }
+ : target.recipientZapEndpoint
+ ? {
+ lightningAddress: null,
+ zapEndpoint: target.recipientZapEndpoint,
+ source: "zap-endpoint",
+ }
+ : null,
+ );
+ }, [target.recipientLightningAddress, target.recipientZapEndpoint]);
+
+ async function handlePay(e: React.MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (paid) {
+ setProofOpen(true);
+ return;
+ }
+ if (!auth || disabled) return;
+ setPaying(true);
+ setProgressOpen(true);
+ setProgressError(null);
+ setProgressLog([]);
+ try {
+ const signer = await getSigner(auth);
+ const record = await payPrizeZap(target, signer, (progress) => {
+ setProgressLog((prev) => [...prev, progress]);
+ });
+ setLocalRecord(record);
+ setProgressLog((prev) => [
+ ...prev,
+ {
+ step: "recording",
+ status: "active",
+ detail: "Buscando recibo NIP-57",
+ },
+ ]);
+ const newReceipt = await findPrizeZapReceipt(target, signer.pubkey);
+ if (newReceipt) {
+ setReceipt(newReceipt);
+ setProgressLog((prev) => [
+ ...prev,
+ {
+ step: "recording",
+ status: "done",
+ detail: "Recibo NIP-57 encontrado",
+ },
+ ]);
+ } else {
+ setProgressLog((prev) => [
+ ...prev,
+ {
+ step: "recording",
+ status: "done",
+ detail: "Pago guardado; recibo pendiente de indexar",
+ },
+ ]);
+ }
+ setPaid(true);
+ push({
+ kind: "success",
+ title: "Premio pagado",
+ description: `${target.projectName} recibió ${formatSats(target.sats)} sats.`,
+ });
+ } catch (err) {
+ push({
+ kind: "error",
+ title: "No se pudo pagar",
+ description: err instanceof Error ? err.message : String(err),
+ duration: 12000,
+ });
+ setProgressError(err instanceof Error ? err.message : String(err));
+ setProgressLog((prev) => [
+ ...prev,
+ {
+ step: "done",
+ status: "error",
+ detail: err instanceof Error ? err.message : String(err),
+ },
+ ]);
+ } finally {
+ setPaying(false);
+ }
+ }
+
+ if (!isAdmin && !paid) return null;
+
+ return (
+ <>
+
+ {proofOpen && (
+ setProofOpen(false)}
+ />
+ )}
+ {progressOpen && (
+ setProgressOpen(false)}
+ onOpenProof={() => {
+ setProgressOpen(false);
+ setProofOpen(true);
+ }}
+ />
+ )}
+ >
+ );
+}
+
+const PRIZE_ZAP_STEPS: Array<{
+ id: PrizeZapProgressStep;
+ label: string;
+ description: string;
+}> = [
+ {
+ id: "checking",
+ label: "Pago previo",
+ description: "Busca registros locales y recibos NIP-57 existentes.",
+ },
+ {
+ id: "endpoint",
+ label: "Endpoint",
+ description: "Resuelve el callback LNURL/NIP-57 del ganador.",
+ },
+ {
+ id: "signing",
+ label: "Firma",
+ description: "Firma el zap request 9734 con el admin.",
+ },
+ {
+ id: "invoice",
+ label: "Invoice",
+ description: "Pide la invoice al servicio de zaps.",
+ },
+ {
+ id: "paying",
+ label: "Pago",
+ description: "Detecta WebLN o muestra la invoice para pago externo.",
+ },
+ {
+ id: "recording",
+ label: "Registro",
+ description: "Guarda el pago y busca el recibo 9735.",
+ },
+];
+
+const PRIZE_ZAP_VISIBLE_STEPS = PRIZE_ZAP_STEPS.filter(
+ (step, index, steps) =>
+ steps.findIndex((candidate) => candidate.id === step.id) === index,
+);
+
+function latestProgress(
+ log: PrizeZapProgress[],
+ step: PrizeZapProgressStep,
+): PrizeZapProgress | null {
+ for (let i = log.length - 1; i >= 0; i--) {
+ if (log[i].step === step) return log[i];
+ }
+ return null;
+}
+
+function latestProgressInvoice(log: PrizeZapProgress[]): string {
+ for (let i = log.length - 1; i >= 0; i--) {
+ const invoice = log[i].invoice;
+ if (invoice) return invoice;
+ }
+ return "";
+}
+
+function latestProgressEndpoint(log: PrizeZapProgress[]): string {
+ for (let i = log.length - 1; i >= 0; i--) {
+ const endpoint = log[i].zapEndpoint;
+ if (endpoint) return endpoint;
+ }
+ return "";
+}
+
+function lightningAddressFromEndpoint(endpoint: string): string | null {
+ try {
+ const url = new URL(endpoint);
+ const parts = url.pathname.split("/").filter(Boolean);
+ const lnurlpIndex = parts.findIndex((part) => part.toLowerCase() === "lnurlp");
+ const username = lnurlpIndex >= 0 ? parts[lnurlpIndex + 1] : null;
+ if (!username) return null;
+ return `${decodeURIComponent(username)}@${url.hostname}`;
+ } catch {
+ return null;
+ }
+}
+
+function ManualInvoicePanel({ invoice }: { invoice: string }) {
+ const [qrDataUrl, setQrDataUrl] = useState("");
+
+ useEffect(() => {
+ let cancelled = false;
+ import("qrcode")
+ .then((qr) =>
+ qr.toDataURL(invoice, {
+ margin: 1,
+ width: 220,
+ color: {
+ dark: "#0b0f1a",
+ light: "#ffffff",
+ },
+ }),
+ )
+ .then((dataUrl) => {
+ if (!cancelled) setQrDataUrl(dataUrl);
+ })
+ .catch(() => {
+ if (!cancelled) setQrDataUrl("");
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [invoice]);
+
+ return (
+
+
+
+ Invoice para pagar
+
+
+
+ {qrDataUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ )}
+
+
+
+ {invoice}
+
+
+
+
+
+ Abrir wallet
+
+
+
+
+
+ );
+}
+
+function PrizeZapProgressModal({
+ target,
+ recipientPaymentInfo,
+ progressLog,
+ error,
+ done,
+ onClose,
+ onOpenProof,
+}: {
+ target: PrizeZapTarget;
+ recipientPaymentInfo: PrizeZapRecipientPaymentInfo | null;
+ progressLog: PrizeZapProgress[];
+ error: string | null;
+ done: boolean;
+ onClose: () => void;
+ onOpenProof: () => void;
+}) {
+ const stepState = (step: PrizeZapProgressStep) => {
+ if (step === "paying") {
+ return (
+ latestProgress(progressLog, "paying") ??
+ latestProgress(progressLog, "webln")
+ );
+ }
+ return latestProgress(progressLog, step);
+ };
+ const completed = PRIZE_ZAP_VISIBLE_STEPS.filter(
+ (step) => stepState(step.id)?.status === "done",
+ ).length;
+ const activeStep =
+ PRIZE_ZAP_VISIBLE_STEPS.find(
+ (step) => stepState(step.id)?.status === "active",
+ ) ??
+ PRIZE_ZAP_VISIBLE_STEPS[
+ Math.min(completed, PRIZE_ZAP_VISIBLE_STEPS.length - 1)
+ ];
+ const progress = error
+ ? Math.max(8, (completed / PRIZE_ZAP_VISIBLE_STEPS.length) * 100)
+ : done
+ ? 100
+ : Math.max(8, (completed / PRIZE_ZAP_VISIBLE_STEPS.length) * 100);
+ const destination = recipientPaymentInfo
+ ? recipientPaymentInfo.lightningAddress || "Sin lightning address pública"
+ : "Resolviendo lightning address…";
+ const manualInvoice = latestProgressInvoice(progressLog);
+ const progressEndpoint = latestProgressEndpoint(progressLog);
+ const progressLightningAddress = progressEndpoint
+ ? lightningAddressFromEndpoint(progressEndpoint)
+ : null;
+ const displayedDestination =
+ recipientPaymentInfo?.lightningAddress ??
+ progressLightningAddress ??
+ progressEndpoint ??
+ destination;
+ const destinationLabel =
+ recipientPaymentInfo?.lightningAddress || progressLightningAddress
+ ? "Lightning address"
+ : progressEndpoint
+ ? "Endpoint"
+ : "Destino";
+
+ return (
+
+
+
+
+
+
+ {error ? (
+
+ ) : done ? (
+
+ ) : (
+
+ )}
+ Zap de premio
+
+
+ {target.projectName}
+
+
+ #{target.position} · {formatSats(target.sats)} sats
+
+
+
+
+ {destinationLabel}
+
+
+ {displayedDestination}
+
+
+
+
+
+
+
+
+
+
+ {error
+ ? "Proceso detenido"
+ : done
+ ? "Pago completado"
+ : activeStep?.label}
+
+
+ {Math.round(progress)}%
+
+
+
+
+
+
+ {PRIZE_ZAP_VISIBLE_STEPS.map((step, index) => {
+ const state = stepState(step.id);
+ const isActive = state?.status === "active";
+ const isDone = state?.status === "done";
+ const isError = state?.status === "error";
+ return (
+
+
+
+ {isDone ? (
+
+ ) : isActive ? (
+
+ ) : isError ? (
+
+ ) : (
+
+ {index + 1}
+
+ )}
+
+
+
+ {step.label}
+
+
+ {state?.detail || step.description}
+
+
+
+
+ );
+ })}
+
+
+ {manualInvoice && (error || latestProgress(progressLog, "webln")?.status === "error") && (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {(done || error) && (
+
+ )}
+ {done && (
+
+ )}
+
+
+
+
+ );
+}
+
+function eventTag(event: SignedEvent | null, name: string): string | null {
+ return event?.tags.find((tag) => tag[0] === name)?.[1] ?? null;
+}
+
+function parseZapRequest(receipt: SignedEvent | null): SignedEvent | null {
+ const description = eventTag(receipt, "description");
+ if (!description) return null;
+ try {
+ return JSON.parse(description) as SignedEvent;
+ } catch {
+ return null;
+ }
+}
+
+type RelayPublicationStatus =
+ | {
+ relay: string;
+ status: "checking" | "published" | "missing" | "publishing";
+ }
+ | {
+ relay: string;
+ status: "error";
+ error: string;
+ };
+
+function zapRequestRelays(zapRequest: SignedEvent | null): string[] {
+ const relays = zapRequest?.tags.find((tag) => tag[0] === "relays")?.slice(1) ?? [];
+ return [...new Set(relays.map((relay) => relay.trim()).filter(Boolean))];
+}
+
+async function checkRelayForEvent(
+ relay: string,
+ eventId: string,
+): Promise {
+ const { SimplePool } = await import("nostr-tools/pool");
+ const pool = new SimplePool();
+
+ return new Promise((resolve) => {
+ let settled = false;
+ let closer: { close: () => void } | null = null;
+ const finish = (status: RelayPublicationStatus) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ try {
+ closer?.close();
+ pool.close([relay]);
+ } catch {
+ /* noop */
+ }
+ resolve(status);
+ };
+ const timeout = window.setTimeout(() => {
+ finish({ relay, status: "missing" });
+ }, 3500);
+
+ try {
+ closer = pool.subscribe(
+ [relay],
+ { ids: [eventId], limit: 1 },
+ {
+ onevent(ev) {
+ if ((ev as SignedEvent).id === eventId) {
+ finish({ relay, status: "published" });
+ }
+ },
+ oneose() {
+ finish({ relay, status: "missing" });
+ },
+ onclose(reason) {
+ finish({
+ relay,
+ status: "error",
+ error: Array.isArray(reason)
+ ? reason.filter(Boolean).join(", ") || "Conexión cerrada"
+ : reason || "Conexión cerrada",
+ });
+ },
+ },
+ );
+ } catch (error) {
+ finish({
+ relay,
+ status: "error",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ });
+}
+
+async function checkRelayPublication(
+ relays: string[],
+ eventId: string,
+ onStatus: (status: RelayPublicationStatus) => void,
+) {
+ await Promise.all(
+ relays.map(async (relay) => {
+ const status = await checkRelayForEvent(relay, eventId);
+ onStatus(status);
+ }),
+ );
+}
+
+async function publishEventToRelay(
+ relay: string,
+ event: SignedEvent,
+): Promise {
+ const { SimplePool } = await import("nostr-tools/pool");
+ const pool = new SimplePool();
+ try {
+ const [published] = pool.publish([relay], event);
+ await Promise.race([
+ published,
+ new Promise((_, reject) => {
+ window.setTimeout(() => reject(new Error("Timeout publicando")), 5000);
+ }),
+ ]);
+ return { relay, status: "published" };
+ } catch (error) {
+ return {
+ relay,
+ status: "error",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ } finally {
+ try {
+ pool.close([relay]);
+ } catch {
+ /* noop */
+ }
+ }
+}
+
+function short(value?: string | null, head = 12, tail = 8): string {
+ if (!value) return "—";
+ if (value.length <= head + tail + 1) return value;
+ return `${value.slice(0, head)}…${value.slice(-tail)}`;
+}
+
+function CopyButton({ value }: { value?: string | null }) {
+ if (!value) return null;
+ return (
+
+ );
+}
+
+function ProofRow({
+ label,
+ value,
+ copyValue,
+ href,
+}: {
+ label: string;
+ value?: string | number | null;
+ copyValue?: string | null;
+ href?: string | null;
+}) {
+ const text = value === undefined || value === null || value === "" ? "—" : String(value);
+ return (
+
+
+ {label}
+
+ {href ? (
+
+ {text}
+
+
+ ) : (
+
+ {text}
+
+ )}
+
+
+ );
+}
+
+type ProofTab = "summary" | "invoice" | "request" | "receipt";
+
+function SectionTitle({
+ icon,
+ title,
+}: {
+ icon: React.ReactNode;
+ title: string;
+}) {
+ return (
+
+ {icon}
+ {title}
+
+ );
+}
+
+function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) {
+ if (value === null) {
+ return null;
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ return [];
+ }
+ return (
+
+ {value.map((item, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (typeof value === "object") {
+ const entries = Object.entries(value as Record);
+ if (entries.length === 0) {
+ return {"{}"};
+ }
+ return (
+
+ {entries.map(([key, nested]) => (
+
+ ))}
+
+ );
+ }
+
+ if (typeof value === "string") {
+ return {value};
+ }
+
+ if (typeof value === "number") {
+ return {value};
+ }
+
+ if (typeof value === "boolean") {
+ return {String(value)};
+ }
+
+ return {String(value)};
+}
+
+function JsonViewer({ value }: { value: unknown }) {
+ return (
+
+
+
+ );
+}
+
+function RelayPublicationList({
+ relays,
+ statuses,
+ receipt,
+ onStatus,
+}: {
+ relays: string[];
+ statuses: RelayPublicationStatus[];
+ receipt: SignedEvent | null;
+ onStatus: (status: RelayPublicationStatus) => void;
+}) {
+ const statusByRelay = new Map(statuses.map((status) => [status.relay, status]));
+
+ async function handlePublish(relay: string) {
+ if (!receipt) return;
+ onStatus({ relay, status: "publishing" });
+ const status = await publishEventToRelay(relay, receipt);
+ onStatus(status);
+ }
+
+ if (!relays.length) {
+ return (
+
+ El zap request no declara relays.
+
+ );
+ }
+
+ return (
+
+ {relays.map((relay) => {
+ const status = statusByRelay.get(relay) ?? { relay, status: "checking" };
+ const isPublished = status.status === "published";
+ const isMissing = status.status === "missing";
+ const isError = status.status === "error";
+ const isPublishing = status.status === "publishing";
+ const canPublish = !!receipt && (isMissing || isError);
+ const label = isPublished
+ ? "Publicado"
+ : isMissing
+ ? "No encontrado"
+ : isError
+ ? "Error"
+ : isPublishing
+ ? "Publicando"
+ : "Chequeando";
+ const stateClass = cn(
+ "inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[9px] font-mono font-bold uppercase tracking-widest transition-colors",
+ isPublished && "border-success/30 bg-success/10 text-success",
+ isMissing && "border-foreground-subtle/30 bg-white/5 text-foreground-subtle",
+ isError && "border-danger/30 bg-danger/10 text-danger",
+ (status.status === "checking" || isPublishing) &&
+ "border-bitcoin/30 bg-bitcoin/10 text-bitcoin",
+ );
+
+ return (
+
+
+
+ {relay}
+
+ {isError && (
+
+ {status.error}
+
+ )}
+
+ {canPublish ? (
+
+ ) : (
+
+ {isPublished ? (
+
+ ) : isError || isMissing ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+type DecodedBolt11 = {
+ network: string;
+ amountMsats: string | null;
+ amountSats: string | null;
+ timestamp: number;
+ paymentHash?: string;
+ description?: string;
+ descriptionHash?: string;
+ paymentSecret?: string;
+ payeePubkey?: string;
+ expirySeconds?: number;
+ minFinalCltvExpiry?: number;
+};
+
+const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+
+function wordsToInt(words: number[]): number {
+ return words.reduce((acc, word) => acc * 32 + word, 0);
+}
+
+function wordsToBytes(words: number[]): Uint8Array {
+ const bytes: number[] = [];
+ let value = 0;
+ let bits = 0;
+ for (const word of words) {
+ value = (value << 5) | word;
+ bits += 5;
+ while (bits >= 8) {
+ bits -= 8;
+ bytes.push((value >> bits) & 0xff);
+ }
+ }
+ return new Uint8Array(bytes);
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
+}
+
+function amountToMsats(amount: string): string | null {
+ if (!amount) return null;
+ const unit = amount.at(-1);
+ const numberPart = /[munp]/.test(unit ?? "") ? amount.slice(0, -1) : amount;
+ if (!/^\d+$/.test(numberPart)) return null;
+ const n = BigInt(numberPart);
+ const msats =
+ unit === "m"
+ ? n * BigInt("100000000")
+ : unit === "u"
+ ? n * BigInt("100000")
+ : unit === "n"
+ ? n * BigInt("100")
+ : unit === "p"
+ ? n / BigInt("10")
+ : n * BigInt("100000000000");
+ return msats.toString();
+}
+
+function decodeBolt11(invoice: string): DecodedBolt11 | null {
+ const lower = invoice.trim().toLowerCase();
+ const separator = lower.lastIndexOf("1");
+ if (separator <= 0) return null;
+
+ const hrp = lower.slice(0, separator);
+ if (!hrp.startsWith("ln")) return null;
+
+ const rawData = lower.slice(separator + 1);
+ const allWords = [...rawData].map((char) => BECH32_CHARSET.indexOf(char));
+ if (allWords.some((word) => word < 0) || allWords.length < 7 + 104 + 6) {
+ return null;
+ }
+
+ const words = allWords.slice(0, -6);
+ const timestamp = wordsToInt(words.slice(0, 7));
+ const signatureWords = 104;
+ const tagWords = words.slice(7, Math.max(7, words.length - signatureWords));
+ const amount = hrp.slice(4);
+ const amountMsats = amountToMsats(amount);
+ const decoded: DecodedBolt11 = {
+ network: hrp.slice(2, 4) || "bc",
+ amountMsats,
+ amountSats: amountMsats
+ ? (BigInt(amountMsats) / BigInt("1000")).toString()
+ : null,
+ timestamp,
+ };
+
+ for (let i = 0; i + 3 <= tagWords.length; ) {
+ const tag = BECH32_CHARSET[tagWords[i]];
+ const length = wordsToInt(tagWords.slice(i + 1, i + 3));
+ const data = tagWords.slice(i + 3, i + 3 + length);
+ if (data.length !== length) break;
+ const bytes = wordsToBytes(data);
+
+ if (tag === "p") decoded.paymentHash = bytesToHex(bytes);
+ if (tag === "d") decoded.description = new TextDecoder().decode(bytes);
+ if (tag === "h") decoded.descriptionHash = bytesToHex(bytes);
+ if (tag === "s") decoded.paymentSecret = bytesToHex(bytes);
+ if (tag === "n") decoded.payeePubkey = bytesToHex(bytes);
+ if (tag === "x") decoded.expirySeconds = wordsToInt(data);
+ if (tag === "c") decoded.minFinalCltvExpiry = wordsToInt(data);
+
+ i += 3 + length;
+ }
+
+ return decoded;
+}
+
+function Bolt11Decode({ invoice }: { invoice: string }) {
+ const decoded = invoice ? decodeBolt11(invoice) : null;
+ if (!decoded) {
+ return (
+
+ No pude decodificar esta invoice.
+
+ );
+ }
+
+ const expiresAt =
+ decoded.expirySeconds !== undefined
+ ? decoded.timestamp + decoded.expirySeconds
+ : null;
+
+ return (
+
+ );
+}
+
+function PrizeZapProofModal({
+ target,
+ receipt,
+ localRecord,
+ adminPubkey,
+ onClose,
+}: {
+ target: PrizeZapTarget;
+ receipt: SignedEvent | null;
+ localRecord: PrizeZapRecord | null;
+ adminPubkey: string | null;
+ onClose: () => void;
+}) {
+ const zapRequest = useMemo(() => parseZapRequest(receipt), [receipt]);
+ const bolt11 = eventTag(receipt, "bolt11") ?? localRecord?.invoice ?? "";
+ const preimage = eventTag(receipt, "preimage") ?? localRecord?.preimage ?? "";
+ const paidAt = receipt?.created_at ?? localRecord?.paidAt ?? null;
+ const amountMsats =
+ zapRequest?.tags.find((tag) => tag[0] === "amount")?.[1] ??
+ String(target.sats * 1000);
+ const [tab, setTab] = useState("summary");
+ const relayList = useMemo(() => zapRequestRelays(zapRequest), [zapRequest]);
+ const [relayStatuses, setRelayStatuses] = useState([]);
+ const updateRelayStatus = (status: RelayPublicationStatus) => {
+ setRelayStatuses((prev) => {
+ if (prev.some((item) => item.relay === status.relay)) {
+ return prev.map((item) => (item.relay === status.relay ? status : item));
+ }
+ return [...prev, status];
+ });
+ };
+
+ useEffect(() => {
+ if (!receipt?.id || !relayList.length) {
+ setRelayStatuses([]);
+ return;
+ }
+
+ let cancelled = false;
+ setRelayStatuses(
+ relayList.map((relay) => ({
+ relay,
+ status: "checking",
+ })),
+ );
+ checkRelayPublication(relayList, receipt.id, (status) => {
+ if (cancelled) return;
+ updateRelayStatus(status);
+ }).catch(() => {
+ /* per-relay status handles visible errors */
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [receipt?.id, relayList]);
+
+ const tabs: Array<{
+ id: ProofTab;
+ label: string;
+ icon: React.ReactNode;
+ disabled?: boolean;
+ }> = [
+ {
+ id: "summary",
+ label: "Resumen",
+ icon: ,
+ },
+ {
+ id: "invoice",
+ label: "Invoice",
+ icon: ,
+ disabled: !bolt11,
+ },
+ {
+ id: "request",
+ label: "Request",
+ icon: ,
+ disabled: !zapRequest,
+ },
+ {
+ id: "receipt",
+ label: "Receipt",
+ icon: ,
+ disabled: !receipt,
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+ Premio pagado
+
+
+ {target.projectName}
+
+
+ #{target.position} · {formatSats(target.sats)} sats · {receipt ? "Recibo NIP-57" : "Registro local"}
+
+
+
+
+
+
+
+ {tabs.map((item) => (
+
+ ))}
+
+
+
+
+ {tab === "summary" && (
+
+
+
+
+
+ Puesto
+
+
+ #{target.position}
+
+
+
+
+
+ Premio
+
+
+ {formatSats(target.sats)}
+
+
+
+
+
+ Estado
+
+
+ Pagado
+
+
+
+
+
+
}
+ title="Datos del pago"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {tab === "invoice" && (
+
+
}
+ title="Invoice bolt11"
+ />
+
+ {bolt11 || "—"}
+
+
+ }
+ title="Decode BOLT11"
+ />
+
+
+
+ )}
+
+ {tab === "request" && zapRequest && (
+
+ }
+ title="Zap request 9734"
+ />
+
+
+ )}
+
+ {tab === "receipt" && receipt && (
+
+
+ }
+ title="Publicación en relays"
+ />
+
+
+
+ }
+ title="Zap receipt 9735"
+ />
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx b/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx
index ddab9a6..f94ff74 100644
--- a/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx
+++ b/app/hackathons/[id]/[projectId]/NostrProjectPageClient.tsx
@@ -39,7 +39,10 @@ import { getSigner } from "@/lib/nostrSigner";
import { useToast } from "@/components/Toast";
import { GithubIcon } from "@/components/BrandIcons";
import { cn } from "@/lib/cn";
-import { soldierProfileHref } from "@/lib/soldierProfileLinks";
+import {
+ dedupeSoldierProfileMembers,
+ soldierProfileHref,
+} from "@/lib/soldierProfileLinks";
import { mergeDataRelays } from "@/lib/nostrRelayConfig";
import { Trophy, Lightbulb, AlertTriangle } from "lucide-react";
import NewProjectModal from "@/components/NewProjectModal";
@@ -451,6 +454,8 @@ export default function NostrProjectPage({
);
}
+ const team = dedupeSoldierProfileMembers(project.team);
+
return (
- {project.team.length === 0 ? (
+ {team.length === 0 ? (
Sin equipo cargado.
) : (
- {project.team.map((m, i) => {
+ {team.map((m, i) => {
const pic =
i === 0 ? (authorPicture ?? m.picture) : m.picture;
const displayName = m.name || m.nip05 || "Anónimo";
diff --git a/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx b/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx
index 6f18ebd..76731df 100644
--- a/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx
+++ b/app/hackathons/[id]/[projectId]/NostrProjectServer.tsx
@@ -1,6 +1,7 @@
import { getNostrProject } from "@/lib/nostrCache";
import { getHackathon } from "@/lib/hackathons";
import { breadcrumbLd, jsonLdScript } from "@/lib/jsonld";
+import { dedupeSoldierProfileMembers } from "@/lib/soldierProfileLinks";
import NostrProjectPageClient from "./NostrProjectPageClient";
/**
@@ -36,6 +37,7 @@ export default async function NostrProjectServer({
const hackathon = getHackathon(hackathonId);
const url = `https://lacrypta.dev/hackathons/${hackathonId}/${projectId}`;
+ const team = dedupeSoldierProfileMembers(project.team);
const projectLd = {
"@context": "https://schema.org",
@@ -55,7 +57,7 @@ export default async function NostrProjectServer({
url: `https://lacrypta.dev/hackathons/${hackathon.id}`,
}
: undefined,
- author: project.team.map((m) => ({
+ author: team.map((m) => ({
"@type": "Person",
name: m.name || m.nip05 || "Anonymous",
url: m.github ? `https://github.com/${m.github}` : undefined,
@@ -94,11 +96,11 @@ export default async function NostrProjectServer({
)}
Estado: {project.status}.
- {project.team.length > 0 && (
+ {team.length > 0 && (
<>
Equipo