/** * useSasquatchPro — is the logged-in user a current Pro supporter? * ───────────────────────────────────────────────────────────────────────────── * Verifies Pro access two ways, strongest first: * * 1. ZAP RECEIPT (NIP-57, kind 9735): the platform's Lightning provider * publishes a receipt when the user zaps the platform. The receipt's zap * request (kind 9734) is signed by the USER's npub and tags the SkateSpots * Pro address, so it cryptographically binds payment → identity. A user * cannot forge this. We read the tier/expiry from the zap request's * `pro-tier` / `pro-expiry` tags (added at purchase time). * * 2. SELF-SIGNED RECEIPT (kind 32671): fallback for providers that don't emit * zap receipts. Signed by the user, references the payment. Weaker (a * technical user could self-issue), but never locks a paying user out. * * Access = the max unexpired expiry found across either source. */ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { SASQUATCH_PRO_KIND } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; export interface ProStatus { /** True if the user currently has active Pro access. */ isPro: boolean; /** Unix seconds when access expires (max across sources), or null. */ expiresAt: number | null; /** How access was granted. */ source: 'owner' | 'zap' | 'self' | null; isLoading: boolean; } function tagVal(tags: string[][], name: string): string | undefined { return tags.find((t) => t[0] === name)?.[1]; } /** Extract an expiry (unix seconds) from a Pro-bearing event, if valid & ours. */ function expiryFromSelfReceipt(event: NostrEvent, userPubkey: string): number | null { if (event.pubkey !== userPubkey) return null; const exp = Number(tagVal(event.tags, 'expiry')); return Number.isFinite(exp) && exp > 0 ? exp : null; } /** Extract expiry from a zap receipt whose zap-request is signed by the user. */ function expiryFromZapReceipt(receipt: NostrEvent, userPubkey: string): number | null { // The 9735 receipt embeds the original 9734 zap request as a `description` tag. const desc = tagVal(receipt.tags, 'description'); if (!desc) return null; try { const zapReq = JSON.parse(desc) as NostrEvent; if (zapReq.pubkey !== userPubkey) return null; // must be signed by the user const exp = Number(tagVal(zapReq.tags, 'pro-expiry')); return Number.isFinite(exp) && exp > 0 ? exp : null; } catch { return null; } } export function useSasquatchPro(): ProStatus { const { nostr } = useNostr(); const { user } = useCurrentUser(); const pubkey = user?.pubkey; // Platform owners always have Pro. const isOwner = !!pubkey && PLATFORM_OWNER_PUBKEYS.includes(pubkey); const { data, isLoading } = useQuery({ queryKey: ['sasquatch-pro', pubkey], enabled: !!pubkey && !isOwner, staleTime: 60_000, queryFn: async (c): Promise<{ expiresAt: number | null; source: ProStatus['source'] }> => { if (!pubkey) return { expiresAt: null, source: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); // Fetch both sources in one round-trip: // • self-signed receipts authored by the user (kind 32671) // • zap receipts (9735) tagging the user as the zapper (#P) const events = await nostr.query( [ { kinds: [SASQUATCH_PRO_KIND], authors: [pubkey], limit: 10 }, { kinds: [9735], '#P': [pubkey], limit: 50 }, ], { signal }, ); let best = 0; let source: ProStatus['source'] = null; for (const ev of events) { if (ev.kind === SASQUATCH_PRO_KIND) { const exp = expiryFromSelfReceipt(ev, pubkey); if (exp && exp > best) { best = exp; source = 'self'; } } else if (ev.kind === 9735) { const exp = expiryFromZapReceipt(ev, pubkey); // Zap receipts are the stronger source — prefer them on ties. if (exp && exp >= best) { best = exp; source = 'zap'; } } } return { expiresAt: best || null, source: best ? source : null }; }, }); return useMemo(() => { if (isOwner) { return { isPro: true, expiresAt: null, source: 'owner', isLoading: false }; } const now = Math.floor(Date.now() / 1000); const expiresAt = data?.expiresAt ?? null; const isPro = !!expiresAt && expiresAt > now; return { isPro, expiresAt, source: isPro ? data?.source ?? null : null, isLoading, }; }, [isOwner, data, isLoading]); }