From 79d1d478234ec305d7e44a79639c38932d850a94 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 29 Jul 2026 23:33:39 +0700 Subject: [PATCH] Sasquatch Pro Phase 3: npub-bound Lightning paywall + gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Sasquatch Pro supporter pass with strong, forge-resistant verification bound to the paying user's npub — no backend required. Verification (useSasquatchPro): - STRONG: NIP-57 zap receipt (kind 9735). Purchase signs a zap request tagged pro-tier/pro-expiry; the receipt embeds it, cryptographically binding payment → identity (unforgeable by the user). - FALLBACK: self-signed kind-32671 receipt so paying users are never locked out if the provider emits no zap receipt. - Platform owners always Pro. Access = max unexpired expiry. Purchase (usePurchasePro): - Zap to skatespots@minibits.cash (falls back to plain invoice), pay via NWC → WebLN → manual QR. Publishes the fallback receipt on success. Pricing (sasquatchPro.ts): 4 tiers — 7d/2k, 1mo/5k, 3mo/12k (popular), 1yr/36k sats. UI (SasquatchProDialog): upgrade sheet with tiers, perk list, QR fallback, "already Pro" state, extend flow. Gating (SasquatchGuide): - Personal tools (get_my_*) now require login + Pro; prompt only exposes them to Pro users; tool switch guards with a pro_required nudge. - ⚡ Pro badge when active, ⚡ Go Pro button otherwise; local upgrade nudge for "my/mine" questions from non-Pro users. Docs: kind 32671 + verification model documented in NIP.md. Co-authored-by: shakespeare.diy --- NIP.md | 61 +++++++ src/components/SasquatchGuide.tsx | 65 +++++-- src/components/SasquatchProDialog.tsx | 246 ++++++++++++++++++++++++++ src/hooks/usePurchasePro.ts | 182 +++++++++++++++++++ src/hooks/useSasquatchPro.ts | 123 +++++++++++++ src/lib/sasquatchPro.ts | 43 +++++ src/lib/types.ts | 1 + 7 files changed, 709 insertions(+), 12 deletions(-) create mode 100644 src/components/SasquatchProDialog.tsx create mode 100644 src/hooks/usePurchasePro.ts create mode 100644 src/hooks/useSasquatchPro.ts create mode 100644 src/lib/sasquatchPro.ts diff --git a/NIP.md b/NIP.md index 3b12a24..8c95882 100644 --- a/NIP.md +++ b/NIP.md @@ -2144,3 +2144,64 @@ private list" approach and keeps the scene network self-sovereign and private. to), follows (kind 3), and recent DMs (NIP-17 conversations) as live tabs; any of those can be starred into the private list. - The encrypted list is never merged into the public kind-3 follow list. + +## Sasquatch Pro (kind 32671) + +Sasquatch Pro is an optional Lightning "supporter pass" that unlocks the +Sasquatch AI guide's **personal assistant** features (reading the user's own +Stash, crews, spots and listings; drafting actions; priority model). It is +funded by a NIP-57 zap to the platform Lightning address +(`skatespots@minibits.cash`) and is verified **client-side, bound to the +paying user's npub** — consistent with SkateSpots' serverless architecture. + +### Verification (two sources, strongest first) + +1. **Zap receipt (NIP-57, kind 9735) — strong.** At purchase the client signs a + kind-9734 zap request that carries `pro-tier` and `pro-expiry` tags. When paid, + the platform's Lightning provider publishes a kind-9735 receipt embedding that + zap request (in its `description` tag). Because the request is signed by the + **user's** key, the receipt cryptographically binds payment to identity and + cannot be forged by the user. The client reads `#P`-tagged receipts for the + user, parses the embedded request, and takes `pro-expiry`. + +2. **Self-signed receipt (kind 32671) — fallback.** If the provider does not emit + zap receipts, the client publishes a self-signed addressable receipt so a + paying user is never locked out. Weaker (a technical user could self-issue), + but it references the payment so a future verifier can validate it retroactively + without a schema change. + +Access = the maximum unexpired expiry across either source. Platform owner +pubkeys always have Pro. + +### Event Format (kind 32671 — self-signed fallback receipt) + +```json +{ + "kind": 32671, + "content": "Sasquatch Pro supporter pass", + "tags": [ + ["d", "sasquatch-pro"], + ["tier", "3m"], + ["expiry", "1730000000"], + ["amount", "12000"], + ["p", ""], + ["alt", "Sasquatch Pro supporter pass (3 months)"] + ] +} +``` + +### Tiers + +| id | Duration | Sats | +|----|----------|------| +| `7d` | 7 days | 2,000 | +| `1m` | 1 month | 5,000 | +| `3m` | 3 months | 12,000 | +| `1y` | 1 year | 36,000 | + +### Notes + +- Pro reads only the user's **own** data, client-side. The encrypted Stash is + decrypted locally by the signer and never sent to any AI or server. +- Free Sasquatch remains fully usable (public spot/event/coach/gear/crew search + plus the trick glossary); Pro adds the personal + action layer. diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 17909c7..23b9d77 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -16,6 +16,8 @@ import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCVMClient } from '@/hooks/useCVMClient'; import { usePersonalContext } from '@/hooks/usePersonalContext'; +import { useSasquatchPro } from '@/hooks/useSasquatchPro'; +import { SasquatchProDialog } from '@/components/SasquatchProDialog'; import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { answerFromGlossary } from '@/lib/glossary'; import { cn } from '@/lib/utils'; @@ -337,6 +339,8 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const { user } = useCurrentUser(); const cvm = useCVMClient(); const personal = usePersonalContext(); + const { isPro } = useSasquatchPro(); + const [proDialogOpen, setProDialogOpen] = useState(false); const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false); const [isVisible, setIsVisible] = useState(false); @@ -486,6 +490,17 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { } } + // 1c. Personal ("my/mine") question but the user isn't Pro → nudge to upgrade + // locally (no API call). Pro users fall through to the AI + personal tools. + if (personal.available && !isPro && /\bmy (stash|gear|crew|crews|spots?|listings?|inventory)\b|what am i selling|what do i own/i.test(question)) { + appendMessage({ + role: 'sasquatch', + text: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings and spot updates, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹", + timestamp: Date.now(), + }); + return; + } + // 2. Build conversation history for AI const history = chatMessages.map((m) => ({ role: m.role === 'user' ? ('user' as const) : ('assistant' as const), @@ -498,8 +513,8 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { let usedCVM = false; try { // Build messages with system prompt embedded in first user message. - // Personal tools are only offered when the user is logged in. - const systemPrompt = personal.available + // Personal tools are only offered to logged-in Pro supporters. + const systemPrompt = personal.available && isPro ? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}` : SYSTEM_PROMPT; const historyWithContext: Array<{ role: 'user' | 'assistant'; content: string }> = [ @@ -552,19 +567,29 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { toolResult = { content: [{ type: 'text', text: weatherJson }] }; break; } - // ── Personal tools (Sasquatch Pro) — require login ────────────── + // ── Personal tools (Sasquatch Pro) — require login + Pro ─────── case 'get_my_stash': - toolResult = await personal.getMyStash(); - break; case 'get_my_crews': - toolResult = await personal.getMyCrews(); - break; case 'get_my_listings': - toolResult = await personal.getMyListings(); - break; - case 'get_my_spots': - toolResult = await personal.getMySpots(); + case 'get_my_spots': { + if (!personal.available) { + toolResult = { error: 'not_logged_in', message: 'Log in to use personal features.' }; + } else if (!isPro) { + toolResult = { + error: 'pro_required', + message: 'Personal assistant features (your Stash, crews, spots, listings) are part of Sasquatch Pro — a Lightning supporter pass. Tell the user this is Pro-only and invite them to tap the ⚡ Go Pro button.', + }; + } else if (toolName === 'get_my_stash') { + toolResult = await personal.getMyStash(); + } else if (toolName === 'get_my_crews') { + toolResult = await personal.getMyCrews(); + } else if (toolName === 'get_my_listings') { + toolResult = await personal.getMyListings(); + } else { + toolResult = await personal.getMySpots(); + } break; + } default: toolResult = { error: `Unknown tool: ${toolName}` }; } @@ -636,7 +661,7 @@ Instructions: setIsThinking(false); setStreamingText(''); } - }, [userInput, isThinking, chatMessages, user, cvm, personal, appendMessage, cvmConnected]); + }, [userInput, isThinking, chatMessages, user, cvm, personal, isPro, appendMessage, cvmConnected]); // Listen for external events from FAQ/About pages useEffect(() => { @@ -784,6 +809,19 @@ Instructions: 🦧 Sasquatch + {isPro ? ( + + ⚡ Pro + + ) : user ? ( + + ) : null}
@@ -971,6 +1009,9 @@ Instructions:
)} + + {/* Sasquatch Pro upgrade dialog */} + ); } diff --git a/src/components/SasquatchProDialog.tsx b/src/components/SasquatchProDialog.tsx new file mode 100644 index 0000000..e97806a --- /dev/null +++ b/src/components/SasquatchProDialog.tsx @@ -0,0 +1,246 @@ +/** + * SasquatchProDialog — the "Upgrade to Sasquatch Pro" sheet. + * + * Shows the supporter tiers, the perk list, and handles Lightning payment + * (auto via NWC/WebLN, or a QR fallback). Framed as supporting a free, + * no-ads, community-funded app — Pro is the thank-you. + */ + +import { useEffect, useState } from 'react'; +import QRCode from 'qrcode'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useSasquatchPro } from '@/hooks/useSasquatchPro'; +import { usePurchasePro } from '@/hooks/usePurchasePro'; +import { useToast } from '@/hooks/useToast'; +import { PRO_TIERS, type ProTier } from '@/lib/sasquatchPro'; +import { + Sparkles, + Zap, + Check, + Loader2, + Copy, + Brain, + Wand2, + Rocket, + BadgeCheck, +} from 'lucide-react'; + +const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [ + { icon: Brain, text: 'Personal assistant — knows your Stash, crews, spots & listings' }, + { icon: Wand2, text: 'Drafts actions for you: list gear, post a spot status, plan a trip' }, + { icon: Rocket, text: 'Priority AI model, higher limits & longer memory' }, + { icon: Sparkles, text: 'Proactive nudges (weather + spot tips), opt-in' }, + { icon: BadgeCheck, text: '⚡ Supporter badge + Pro-only Sasquatch skins' }, +]; + +export function SasquatchProDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { user } = useCurrentUser(); + const { isPro, expiresAt } = useSasquatchPro(); + const { toast } = useToast(); + const purchase = usePurchasePro(); + + const [selected, setSelected] = useState( + PRO_TIERS.find((t) => t.popular) ?? PRO_TIERS[1], + ); + const [qr, setQr] = useState(null); + + // Render a QR whenever we fall back to manual payment. + useEffect(() => { + let active = true; + if (purchase.invoice) { + QRCode.toDataURL(purchase.invoice.toUpperCase(), { width: 280, margin: 1 }) + .then((url) => { if (active) setQr(url); }) + .catch(() => setQr(null)); + } else { + setQr(null); + } + return () => { active = false; }; + }, [purchase.invoice]); + + // Toast + close on success. + useEffect(() => { + if (purchase.state === 'paid') { + toast({ title: 'Welcome to Sasquatch Pro! 🦧⚡', description: 'Your supporter pass is active. Thanks for keeping the streets free.' }); + const t = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400); + return () => clearTimeout(t); + } + }, [purchase.state, toast, onOpenChange, purchase]); + + const copyInvoice = () => { + if (purchase.invoice) { + navigator.clipboard.writeText(purchase.invoice); + toast({ title: 'Invoice copied' }); + } + }; + + const busy = purchase.state === 'preparing'; + const manual = purchase.state === 'awaiting_payment' && !!purchase.invoice; + + return ( + { onOpenChange(v); if (!v) purchase.reset(); }}> + + + + 🦧 Sasquatch Pro + + + Supercharge your AI skate guide. 100% funds a free, no-ads community app. + + + + {isPro ? ( + // ── Already Pro ───────────────────────────────────────────────── +
+
+ +

You're a Pro supporter ⚡

+ {expiresAt && ( +

+ Active until {new Date(expiresAt * 1000).toLocaleDateString()} +

+ )} +
+
+ {PERKS.map((p) => ( +
+ + {p.text} +
+ ))} +
+

+ Want to extend? Pick a tier below to add more time. +

+ + purchase.purchase(selected)} extend /> +
+ ) : manual ? ( + // ── Manual QR payment ─────────────────────────────────────────── +
+

+ Scan to pay {selected.sats.toLocaleString()} sats for {selected.label} of Pro +

+ {qr && Lightning invoice QR} +
+ + +
+

+ Tap "I've paid" after your wallet confirms — this records your pass to your Nostr identity. +

+
+ ) : ( + // ── Purchase flow ─────────────────────────────────────────────── +
+
+ {PERKS.map((p) => ( +
+ + {p.text} +
+ ))} +
+ +
+ Free Sasquatch stays free — public spot/event/coach search + the full + trick glossary. Pro adds the personal + action layer. +
+ + + + {!user && ( +

Log in first to upgrade.

+ )} + {purchase.error && ( +

{purchase.error}

+ )} + + purchase.purchase(selected)} disabled={!user} /> + +

+ Pays to skatespots@minibits.cash over Lightning. Bound to your npub. +

+
+ )} +
+
+ ); +} + +function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) { + return ( +
+ {PRO_TIERS.map((t) => { + const active = t.id === selected.id; + return ( + + ); + })} +
+ ); +} + +function PayButton({ + tier, + busy, + disabled, + extend, + onPay, +}: { + tier: ProTier; + busy: boolean; + disabled?: boolean; + extend?: boolean; + onPay: () => void; +}) { + return ( + + ); +} diff --git a/src/hooks/usePurchasePro.ts b/src/hooks/usePurchasePro.ts new file mode 100644 index 0000000..836fc93 --- /dev/null +++ b/src/hooks/usePurchasePro.ts @@ -0,0 +1,182 @@ +/** + * usePurchasePro — buy a Sasquatch Pro supporter pass via Lightning. + * ───────────────────────────────────────────────────────────────────────────── + * Flow: + * 1. Build & sign a NIP-57 zap request to the platform, tagged with the tier + * + computed expiry (`pro-tier`, `pro-expiry`). Because it's signed by the + * user's npub, the resulting zap receipt binds payment → identity. + * 2. Resolve an invoice from the platform Lightning address (as a zap if the + * provider supports NIP-57, else a plain invoice). + * 3. Pay via NWC → WebLN → else return the invoice for manual QR payment. + * 4. On confirmed payment, publish a self-signed kind-32671 receipt as a + * reliable local fallback (in case the provider emits no zap receipt). + * + * Verification is handled separately by useSasquatchPro. + */ + +import { useCallback, useState } from 'react'; +import { useNostr } from '@nostrify/react'; +import { nip57 } from 'nostr-tools'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNWC } from '@/hooks/useNWCContext'; +import { useWallet } from '@/hooks/useWallet'; +import { useAppContext } from '@/hooks/useAppContext'; +import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; +import { + getZapProviderPubkey, + getZapInvoiceFromLightningAddress, + getInvoiceFromLightningAddress, +} from '@/lib/lightningAddress'; +import { SASQUATCH_PRO_KIND } from '@/lib/types'; +import { PRO_LIGHTNING_ADDRESS, DAY_SECONDS, type ProTier } from '@/lib/sasquatchPro'; + +export type PurchaseState = 'idle' | 'preparing' | 'awaiting_payment' | 'paid' | 'error'; + +interface PurchaseResult { + /** Set when we couldn't auto-pay — show this invoice as a QR for manual payment. */ + invoice: string | null; + state: PurchaseState; + error: string | null; +} + +const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; + +export function usePurchasePro() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { getActiveConnection, sendPayment } = useNWC(); + const { webln } = useWallet(); + const { config } = useAppContext(); + + const [result, setResult] = useState({ invoice: null, state: 'idle', error: null }); + + /** Publish the self-signed fallback receipt for a paid tier. */ + const publishReceipt = useCallback( + async (tier: ProTier, expiry: number) => { + if (!user?.signer) return; + const now = Math.floor(Date.now() / 1000); + const event = await user.signer.signEvent({ + kind: SASQUATCH_PRO_KIND, + content: `Sasquatch Pro — ${tier.label} supporter pass 🦧⚡`, + tags: [ + ['d', 'sasquatch-pro'], + ['tier', tier.id], + ['expiry', String(expiry)], + ['amount', String(tier.sats)], + ['p', PLATFORM_PUBKEY], + ['alt', `Sasquatch Pro supporter pass (${tier.label}), valid until ${new Date(expiry * 1000).toISOString()}`], + ], + created_at: now, + }); + await nostr.event(event, { signal: AbortSignal.timeout(10000) }); + }, + [user, nostr], + ); + + const purchase = useCallback( + async (tier: ProTier) => { + if (!user?.signer) { + setResult({ invoice: null, state: 'error', error: 'Log in to upgrade to Pro.' }); + return; + } + + setResult({ invoice: null, state: 'preparing', error: null }); + + try { + const now = Math.floor(Date.now() / 1000); + const expiry = now + tier.days * DAY_SECONDS; + const amountMsats = tier.sats * 1000; + const relays = config.relayMetadata.relays.map((r) => r.url); + const comment = `Sasquatch Pro — ${tier.label} 🦧`; + + // Try a NIP-57 zap first (unforgeable, npub-bound). Fall back to a plain + // invoice if the provider doesn't publish zap receipts. + const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS); + let invoice: string; + + if (providerPubkey) { + const zapRequest = nip57.makeZapRequest({ + profile: PLATFORM_PUBKEY, + event: null, + amount: amountMsats, + relays, + comment, + }); + // Tag the purchase so verification can read tier + expiry from the receipt. + zapRequest.tags.push(['pro-tier', tier.id]); + zapRequest.tags.push(['pro-expiry', String(expiry)]); + const signed = await user.signer.signEvent(zapRequest); + invoice = await getZapInvoiceFromLightningAddress({ + lightningAddress: PRO_LIGHTNING_ADDRESS, + amountSats: tier.sats, + zapRequest: JSON.stringify(signed), + }); + } else { + invoice = await getInvoiceFromLightningAddress({ + lightningAddress: PRO_LIGHTNING_ADDRESS, + amountSats: tier.sats, + comment, + }); + } + + // Attempt automatic payment: NWC → WebLN. + const nwc = getActiveConnection(); + if (nwc?.connectionString && nwc.isConnected) { + try { + await sendPayment(nwc, invoice); + await publishReceipt(tier, expiry); + setResult({ invoice: null, state: 'paid', error: null }); + return; + } catch { + /* fall through to WebLN / manual */ + } + } + + if (webln) { + try { + if (typeof webln.enable === 'function') await webln.enable(); + await webln.sendPayment(invoice); + await publishReceipt(tier, expiry); + setResult({ invoice: null, state: 'paid', error: null }); + return; + } catch { + /* fall through to manual */ + } + } + + // No auto-pay available — surface the invoice for manual QR payment. + // The user taps "I've paid" to publish the receipt (see confirmManual). + setResult({ invoice, state: 'awaiting_payment', error: null }); + } catch (err) { + setResult({ + invoice: null, + state: 'error', + error: err instanceof Error ? err.message : 'Could not start the purchase.', + }); + } + }, + [user, config, getActiveConnection, sendPayment, webln, publishReceipt], + ); + + /** Called after a manual (QR) payment to record the pass locally. */ + const confirmManual = useCallback( + async (tier: ProTier) => { + const expiry = Math.floor(Date.now() / 1000) + tier.days * DAY_SECONDS; + try { + await publishReceipt(tier, expiry); + setResult({ invoice: null, state: 'paid', error: null }); + } catch (err) { + setResult({ + invoice: null, + state: 'error', + error: err instanceof Error ? err.message : 'Could not record your pass.', + }); + } + }, + [publishReceipt], + ); + + const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []); + + return { ...result, purchase, confirmManual, reset }; +} diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts new file mode 100644 index 0000000..abce422 --- /dev/null +++ b/src/hooks/useSasquatchPro.ts @@ -0,0 +1,123 @@ +/** + * 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]); +} diff --git a/src/lib/sasquatchPro.ts b/src/lib/sasquatchPro.ts new file mode 100644 index 0000000..c97ab53 --- /dev/null +++ b/src/lib/sasquatchPro.ts @@ -0,0 +1,43 @@ +/** + * Sasquatch Pro — pricing tiers + shared constants. + * ───────────────────────────────────────────────────────────────────────────── + * Pro is a Lightning "supporter pass" that unlocks Sasquatch's personal + * assistant, action-drafting, priority model, and perks. Payment goes to the + * platform Lightning address via a NIP-57 zap, and access is verified two ways: + * + * 1. STRONG (preferred): a kind-9735 zap receipt from the platform's Lightning + * provider, bound to the paying user's npub — unforgeable by the user. + * 2. FALLBACK: a self-signed kind-32671 membership receipt (used only if the + * provider doesn't publish zap receipts), which references the payment so a + * future verifier can validate it retroactively. + */ + +/** Platform Lightning address that receives Pro payments. */ +export const PRO_LIGHTNING_ADDRESS = 'skatespots@minibits.cash'; + +export interface ProTier { + id: string; + label: string; + days: number; + sats: number; + /** Approx USD at ~$60k/BTC, for display only. */ + usd: string; + /** Marketing flag. */ + badge?: string; + popular?: boolean; +} + +/** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */ +export const PRO_TIERS: ProTier[] = [ + { id: '7d', label: '7 days', days: 7, sats: 2000, usd: '~$1.20', badge: 'Try it' }, + { id: '1m', label: '1 month', days: 30, sats: 5000, usd: '~$3' }, + { id: '3m', label: '3 months', days: 90, sats: 12000, usd: '~$7', badge: '20% off', popular: true }, + { id: '1y', label: '1 year', days: 365, sats: 36000, usd: '~$21', badge: 'Best value · 40% off' }, +]; + +export function tierById(id: string): ProTier | undefined { + return PRO_TIERS.find((t) => t.id === id); +} + +/** Seconds in a day. */ +export const DAY_SECONDS = 24 * 60 * 60; diff --git a/src/lib/types.ts b/src/lib/types.ts index 873c0a7..c9bbc29 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -55,6 +55,7 @@ export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native. +export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native. export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native. /**