/** * 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 { dateLocaleCode } from '@/lib/dateFnsI18n'; import { useCallback, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNWC } from '@/hooks/useNWCContext'; import { PAYMENT_STATUS_UNKNOWN } from '@/hooks/useNWC'; import { useWallet } from '@/hooks/useWallet'; import { useRelays } from '@/lib/relays'; import { useSendDirectMessage } from '@/hooks/useDirectMessages'; import { useTranslation } from '@/contexts/language'; 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'; import { assertInvoiceAmount } from '@/lib/verifyInvoice'; 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 { read, write } = useRelays(); const { mutateAsync: sendDM } = useSendDirectMessage(); const { t } = useTranslation(); 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(dateLocaleCode(), { year: 'numeric', month: 'long', day: 'numeric', }); const content = [ t('feesUi.proReceiptTitle'), ``, t('feesUi.proReceiptPass', { pass: t(tier.labelKey), sats: tier.sats.toLocaleString() }), t('feesUi.proReceiptActiveUntil', { until }), ``, t('feesUi.proReceiptUnlocked'), t('feesUi.proReceiptBullet1'), t('feesUi.proReceiptBullet2'), t('feesUi.proReceiptBullet3'), t('feesUi.proReceiptBullet4'), ``, t('feesUi.proReceiptThanks'), ].join('\n'); try { await sendDM({ recipients: user.pubkey, content, subject: t('feesUi.proReceiptSubject') }); } catch { /* best-effort — don't fail the purchase over a receipt DM */ } }, [user, sendDM, t], ); /** 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: t('feesUi.receiptContent', { tier: t(tier.labelKey) }), tags: [ ['d', 'sasquatch-pro'], ['tier', tier.id], ['expiry', String(expiry)], ['amount', String(tier.sats)], ['p', PLATFORM_PUBKEY], ['alt', t('feesUi.receiptAlt', { tier: t(tier.labelKey), until: new Date(expiry * 1000).toISOString() })], ], created_at: now, }); await nostr.event(event, { signal: AbortSignal.timeout(10000) }); }, [user, nostr, t], ); const purchase = useCallback( async (tier: ProTier) => { if (!user?.signer) { setResult({ invoice: null, state: 'error', error: t('feesUi.errLogin') }); 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 = [...new Set([...read, ...write])]; const comment = t('feesUi.purchaseComment', { tier: t(tier.labelKey) }); // 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) { // Build the NIP-57 zap request event directly (profile zap, no event // target). Constructed by hand rather than via nip57.makeZapRequest so // we're not coupled to that helper's changing type signature. const zapRequest = { kind: 9734, content: comment, created_at: Math.floor(Date.now() / 1000), tags: [ ['relays', ...relays], ['amount', String(amountMsats)], ['p', PLATFORM_PUBKEY], // Tag the purchase so verification can read tier + expiry from the receipt. ['pro-tier', tier.id], ['pro-expiry', String(expiry)], ] as string[][], }; 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, }); } // SECURITY: never auto-pay an invoice whose amount doesn't match the tier. assertInvoiceAmount(invoice, tier.sats); // 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 (err) { // Unknown status: the payment may have gone through — do NOT retry on // another rail. Surface the invoice so the user can verify. if ((err as { code?: string })?.code === PAYMENT_STATUS_UNKNOWN) { setResult({ invoice, state: 'awaiting_payment', error: t('feesUi.payStatusUnknown') }); return; } /* 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 : t('feesUi.errStart'), }); } }, [user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM, t], ); /** 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 : t('feesUi.errRecord'), }); } }, [publishReceipt, sendConfirmationDM, t], ); const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []); return { ...result, purchase, confirmManual, reset }; }