SkateSpots/src/hooks/useSasquatchPro.ts
shakespeare.diy 79d1d47823 Sasquatch Pro Phase 3: npub-bound Lightning paywall + gating
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 <assistant@shakespeare.diy>
2026-07-29 23:33:39 +07:00

123 lines
4.8 KiB
TypeScript

/**
* 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<ProStatus>(() => {
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]);
}