/** * 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 primary 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. * * 3. OWNER GRANT (NIP-32 kind 1985, `org.skatespots.pro`) — the SAFE fallback * when the provider publishes no receipt: only an owner pubkey can author * it, and the user cannot forge it. Carries an `expiry` (unix seconds). * * STACKING: multiple receipts/grants ADD UP (renewal semantics), so buying again * before your pass lapses genuinely extends it. We accumulate * `expiry = max(expiry, proofTime) + days`. * * SECURITY: the self-signed kind-32671 receipt is NObody's proof of payment — any * user can sign one with an arbitrary `expiry`, which previously let anyone grant * themselves permanent Pro. It is treated as ADVISORY ONLY and never grants * access on its own. */ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { verifyEvent } from 'nostr-tools'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; 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, PRO_LABEL_NAMESPACE, PRO_LABEL_VALUE, type ProProof, } 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)); /** Proof of Pro sent to the AI proxy so its gate can re-verify server-side. * (Defined in lib so proAuth can import it without a hook dependency.) */ export type { ProProof } from '@/lib/sasquatchPro'; export interface ProStatus { /** True if the user currently has active Pro access. */ isPro: boolean; /** Unix seconds when access expires (stacked across proofs), or null. */ expiresAt: number | null; /** How access was granted. */ source: 'owner' | 'zap' | 'grant' | null; isLoading: boolean; /** Proof to send to the AI proxy (zap receipts + owner grants). */ proof?: ProProof | null; } function tagVal(tags: string[][], name: string): string | undefined { return tags.find((t) => t[0] === name)?.[1]; } /** Tier days for a valid receipt, or null if the receipt proves nothing. */ function receiptTier( receipt: NostrEvent, userPubkey: string, providerPubkey: string, ): { at: number; days: 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; if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) 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. const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); if (!tier) return null; return { at: receipt.created_at, days: tier.days }; } /** Expiry from an owner-issued kind-1985 Pro grant, or null if not valid. */ function grantExpiry(grant: NostrEvent, userPubkey: string): number | null { if (grant.kind !== 1985) return null; if (!PLATFORM_OWNER_PUBKEYS.includes(grant.pubkey)) return null; try { if (!verifyEvent(grant)) return null; } catch { return null; } const namespace = grant.tags.filter(([n]) => n === 'L').map(([, v]) => v); if (!namespace.includes(PRO_LABEL_NAMESPACE)) return null; const value = grant.tags.filter(([n]) => n === 'l').map(([, v]) => v); if (!value.includes(PRO_LABEL_VALUE)) return null; const taggedUser = grant.tags.some(([n, v]) => n === 'p' && v === userPubkey); if (!taggedUser) return null; const expiry = Number(tagVal(grant.tags, 'expiry')); return Number.isFinite(expiry) && expiry > 0 ? expiry : null; } export function useSasquatchPro(): ProStatus { const { nostr } = useNostr(); const { user } = useCurrentUser(); const { querySkateContent } = useSkateRelays(); 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']; proof: ProProof | null }> => { if (!pubkey) return { expiresAt: null, source: null, proof: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); // Owner grants (kind 1985) — the fallback path. Read via querySkateContent // (community relay + the user's relays), which is where the owner publishes // them — the default pool would miss them for most users. const grantsPromise = querySkateContent( [ { kinds: [1985], authors: PLATFORM_OWNER_PUBKEYS, '#L': [PRO_LABEL_NAMESPACE], '#p': [pubkey], limit: 20, }, ], { signal }, ).catch(() => [] as NostrEvent[]); // The primary source: zap receipts authored by the platform's Lightning // provider. Resolve that provider pubkey first; without it we can't verify // any receipt (owners are handled separately). const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null); const receipts = providerPubkey ? await nostr .query( [{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 100 }], { signal }, ) .catch(() => [] as NostrEvent[]) : []; const validReceipts = providerPubkey ? receipts.filter((r) => receiptTier(r, pubkey, providerPubkey)) : []; const allGrants = await grantsPromise; const validGrants = allGrants.filter((g) => grantExpiry(g, pubkey) !== null); // Stack everything (renewal semantics): sort proofs by time and accumulate, // so a second purchase genuinely extends the pass. let expiry = 0; if (providerPubkey) { const tiers = validReceipts .map((r) => receiptTier(r, pubkey, providerPubkey)!) .sort((a, b) => a.at - b.at); for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS; } let fromReceipts = expiry > 0; for (const g of validGrants) { const e = grantExpiry(g, pubkey); if (e && e > expiry) { expiry = e; fromReceipts = false; } } if (!expiry) return { expiresAt: null, source: null, proof: null }; return { expiresAt: expiry, source: fromReceipts ? 'zap' : 'grant', proof: { receipts: validReceipts, grants: validGrants }, }; }, }); return useMemo(() => { if (isOwner) { return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, proof: 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, proof: isPro ? data?.proof ?? null : null, }; }, [isOwner, data, isLoading]); }