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>
123 lines
4.8 KiB
TypeScript
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]);
|
|
}
|