/** * useSasquatchPro — is the logged-in user a current Pro supporter? * ───────────────────────────────────────────────────────────────────────────── * Pro access is granted ONLY by cryptographically-verifiable proof of payment: * * 1. OWNER: platform owners always have Pro. * * 2. ZAP RECEIPT (NIP-57, kind 9735) — the ONLY network-trusted source. To * count, a receipt must ALL of: * • be AUTHORED by the platform's own Lightning provider pubkey * (resolved from the Pro Lightning address), so a user can't publish a * fake receipt themselves; * • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment → * identity) that tags the platform (`p`); * • encode a paid amount of at least the smallest Pro tier. * Expiry is read from the user-signed request's `pro-expiry` tag. * * SECURITY: the self-signed kind-32671 receipt is NObodyy's proof of payment — * any user can sign one with an arbitrary `expiry`, which previously let anyone * grant themselves permanent Pro. It is therefore treated as ADVISORY ONLY and * never grants access on its own. Genuine buyers are covered by the zap receipt * (or by owner status); the fallback existed only for providers that emit no zap * receipt, which is not worth a self-grant bypass. */ 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 { getZapProviderPubkey } from '@/lib/lightningAddress'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; import { PRO_LIGHTNING_ADDRESS, PRO_TIERS, DAY_SECONDS } from '@/lib/sasquatchPro'; import type { NostrEvent } from '@nostrify/nostrify'; const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; /** Smallest purchasable tier — the minimum a valid Pro zap must pay. */ const MIN_PRO_SATS = Math.min(...PRO_TIERS.map((t) => t.sats)); 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' | null; isLoading: boolean; /** The zap receipt that granted access — sent to the AI proxy as proof. */ receiptEvent?: NostrEvent | null; } function tagVal(tags: string[][], name: string): string | undefined { return tags.find((t) => t[0] === name)?.[1]; } /** * Extract expiry from a zap receipt, but ONLY if it's a genuine, provider-issued * proof of payment for THIS user to the platform. Returns null unless every * check passes: * • receipt authored by the platform's Lightning provider pubkey; * • embedded zap request signed by the user (payment↔identity binding); * • zap request tags the platform pubkey (`p`); * • paid amount (from the bolt11 tag, else the request `amount` tag) ≥ minimum. */ function expiryFromZapReceipt( receipt: NostrEvent, userPubkey: string, providerPubkey: string, ): number | null { // The receipt itself MUST be authored by the platform's Lightning provider. if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; const desc = tagVal(receipt.tags, 'description'); if (!desc) return null; let zapReq: NostrEvent; try { zapReq = JSON.parse(desc) as NostrEvent; } catch { return null; } // Zap request must be signed by the user and tag the platform. if (zapReq.pubkey !== userPubkey) return null; const tagsPlatform = zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY); if (!tagsPlatform) return null; // Verify a real amount was paid: prefer the receipt's bolt11, else the // request's amount tag (millisats). Must meet the smallest tier. let paidSats = 0; const bolt11 = tagVal(receipt.tags, 'bolt11'); if (bolt11) { try { paidSats = getSatoshisFromBolt11(bolt11); } catch { paidSats = 0; } } if (paidSats === 0) { const amountMsats = Number(tagVal(zapReq.tags, 'amount')); if (Number.isFinite(amountMsats) && amountMsats > 0) { paidSats = Math.floor(amountMsats / 1000); } } if (paidSats < MIN_PRO_SATS) return null; // Enforce the tier from what was ACTUALLY paid. Never trust the user-signed // `pro-expiry` tag — that let a 7-day payment self-assert a year. Pick the // largest tier the payment covers and run the pass from when the receipt was // issued (so paying later genuinely extends access). const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); if (!tier) return null; return receipt.created_at + tier.days * DAY_SECONDS; } 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']; receipt?: NostrEvent; }> => { if (!pubkey) return { expiresAt: null, source: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); // The ONLY network-trusted source is a zap receipt authored by the // platform's Lightning provider. Resolve that provider pubkey first; if we // can't, we cannot verify any receipt and grant no access (owners are // handled separately above). const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null); if (!providerPubkey) return { expiresAt: null, source: null }; // Zap receipts (9735) tagging the user as the zapper (#P), authored by the // provider (#authors) — verified thoroughly in expiryFromZapReceipt. const events = await nostr.query( [{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 50 }], { signal }, ); let best = 0; let bestReceipt: NostrEvent | undefined; for (const ev of events) { const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey); if (exp && exp > best) { best = exp; bestReceipt = ev; } } return { expiresAt: best || null, source: best ? ('zap' as const) : null, receipt: bestReceipt, }; }, }); return useMemo(() => { if (isOwner) { return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, receiptEvent: null }; } 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, receiptEvent: isPro ? data?.receipt ?? null : null, }; }, [isOwner, data, isLoading]); }