import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import type { NostrEvent } from '@nostrify/nostrify'; import { ZAP_GOAL_KIND } from '@/lib/types'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; export interface CrewGoal { id: string; pubkey: string; /** Human-readable goal description (content). */ description: string; /** Target amount in sats. */ targetSats: number; /** Total raised so far in sats (tallied from zap receipts). */ raisedSats: number; /** Number of contributions. */ contributions: number; /** Optional deadline (unix seconds). */ closedAt?: number; /** Percentage complete (0–100+, may exceed 100). */ percent: number; image?: string; summary?: string; event: NostrEvent; } /** * Sum sats from a set of zap receipts (kind 9735). * * SECURITY: we tally ONLY the amount encoded in each receipt's bolt11 invoice. * The free-form `amount` tag is trivially forgeable — anyone can publish a fake * 9735 with `["amount","1000000000"]` `#e`-tagging the goal to inflate "raised" * for fake funding/social proof. The bolt11 amount reflects a real invoice, so * a receipt with no (or unparseable) bolt11 contributes 0 and is not counted. */ function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; count: number } { let sats = 0; let count = 0; for (const z of receipts) { if (closedAt && z.created_at > closedAt) continue; // NIP-75: ignore late zaps const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1]; if (!bolt11) continue; // no invoice → unverifiable → ignore try { const invoiceSats = getSatoshisFromBolt11(bolt11); if (invoiceSats > 0) { sats += invoiceSats; count++; } } catch { // Unparseable invoice → ignore (do NOT fall back to the amount tag). } } return { sats, count }; } /** * Load a crew's NIP-75 funding goal (kind 9041) and tally its progress from * zap receipts (kind 9735) that reference the goal event. * * The goal is fully transparent & non-custodial: contributors zap the goal, and * funds settle to the goal author's own Lightning address. SkateSpots never * holds funds — it only reads the public tally. */ export function useCrewGoal(goalId?: string): UseQueryResult { const { nostr } = useNostr(); return useQuery({ queryKey: ['crew-goal', goalId], enabled: !!goalId, staleTime: 60 * 1000, refetchInterval: (q) => (q.getObserversCount() > 0 ? 90 * 1000 : false), queryFn: async (c): Promise => { if (!goalId) return null; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); // Fetch the goal event and its zap receipts in one round-trip. const [goalEvents, receipts] = await Promise.all([ nostr.query([{ kinds: [ZAP_GOAL_KIND], ids: [goalId] }], { signal }), nostr.query([{ kinds: [9735], '#e': [goalId], limit: 500 }], { signal }), ]); const goal = goalEvents[0]; if (!goal) return null; const amountTag = goal.tags.find(([n]) => n === 'amount')?.[1]; const targetMs = amountTag ? parseInt(amountTag, 10) : 0; const targetSats = Number.isNaN(targetMs) ? 0 : Math.floor(targetMs / 1000); const closedAtTag = goal.tags.find(([n]) => n === 'closed_at')?.[1]; const closedAt = closedAtTag ? parseInt(closedAtTag, 10) : undefined; const { sats: raisedSats, count } = tallyZaps(receipts, closedAt); const percent = targetSats > 0 ? Math.round((raisedSats / targetSats) * 100) : 0; return { id: goal.id, pubkey: goal.pubkey, description: goal.content, targetSats, raisedSats, contributions: count, closedAt, percent, image: goal.tags.find(([n]) => n === 'image')?.[1], summary: goal.tags.find(([n]) => n === 'summary')?.[1], event: goal, }; }, }); }