/** * 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 { useSendDirectMessage } from '@/hooks/useDirectMessages'; 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 { mutateAsync: sendDM } = useSendDirectMessage(); const [result, setResult] = useState({ invoice: null, state: 'idle', error: null }); /** * Send the buyer an encrypted (NIP-17) confirmation to their own inbox — a * durable, private receipt of what they bought. Best-effort: a failure here * never blocks the purchase (they already paid + got the on-relay receipt). */ const sendConfirmationDM = useCallback( async (tier: ProTier, expiry: number) => { if (!user?.pubkey || !user.signer?.nip44) return; const until = new Date(expiry * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', }); const content = [ `🦧⚡ Sasquatch Pro — you're in!`, ``, `Pass: ${tier.label} (${tier.sats.toLocaleString()} sats)`, `Active until: ${until}`, ``, `What you unlocked:`, `• Personal assistant — I read your Stash, crews, spots & listings`, `• Action drafts — list gear, post a spot status, plan trips (you confirm)`, `• Priority AI model, higher limits & longer memory`, `• Opt-in weather + spot nudges`, `• ⚡ Supporter badge + Pro-only skins`, ``, `Thanks for keeping SkateSpots free & ad-free. Now go shred. 🛹`, ].join('\n'); try { await sendDM({ recipients: user.pubkey, content, subject: 'Sasquatch Pro receipt' }); } catch { /* best-effort — don't fail the purchase over a receipt DM */ } }, [user, sendDM], ); /** 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); await sendConfirmationDM(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); await sendConfirmationDM(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, sendConfirmationDM], ); /** 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); await sendConfirmationDM(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, sendConfirmationDM], ); const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []); return { ...result, purchase, confirmManual, reset }; }