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>
This commit is contained in:
shakespeare.diy 2026-07-29 23:33:39 +07:00
parent a37224d8a2
commit 79d1d47823
7 changed files with 709 additions and 12 deletions

61
NIP.md
View file

@ -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", "<platform pubkey>"],
["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.

View file

@ -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:
<span className="font-black text-xs text-[#ccff00] uppercase tracking-wider" style={{ fontFamily: 'Impact, Arial Black, sans-serif' }}>
🦧 Sasquatch
</span>
{isPro ? (
<span className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]">
⚡ Pro
</span>
) : user ? (
<button
onClick={() => setProDialogOpen(true)}
className="flex items-center gap-0.5 rounded bg-red-500/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-red-400 hover:bg-red-500/30 transition-colors"
title="Upgrade to Sasquatch Pro"
>
⚡ Go Pro
</button>
) : null}
<CVMStatus connected={cvmConnected} />
</div>
<div className="text-[10px] text-[#ccff00]/50 leading-none mt-0.5">
@ -971,6 +1009,9 @@ Instructions:
</div>
</div>
)}
{/* Sasquatch Pro upgrade dialog */}
<SasquatchProDialog open={proDialogOpen} onOpenChange={setProDialogOpen} />
</>
);
}

View file

@ -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<ProTier>(
PRO_TIERS.find((t) => t.popular) ?? PRO_TIERS[1],
);
const [qr, setQr] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) purchase.reset(); }}>
<DialogContent className="max-w-md bg-black border-red-500/30 text-white max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl font-black">
<span className="text-2xl">🦧</span> Sasquatch <span className="text-red-500">Pro</span>
</DialogTitle>
<DialogDescription className="text-gray-400">
Supercharge your AI skate guide. 100% funds a free, no-ads community app.
</DialogDescription>
</DialogHeader>
{isPro ? (
// ── Already Pro ─────────────────────────────────────────────────
<div className="space-y-4 py-2">
<div className="rounded-2xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center">
<BadgeCheck className="mx-auto mb-2 h-8 w-8 text-[#ccff00]" />
<p className="font-black text-[#ccff00]">You're a Pro supporter ⚡</p>
{expiresAt && (
<p className="mt-1 text-xs text-gray-300">
Active until {new Date(expiresAt * 1000).toLocaleDateString()}
</p>
)}
</div>
<div className="space-y-2">
{PERKS.map((p) => (
<div key={p.text} className="flex items-start gap-2 text-sm text-gray-300">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ccff00]" />
{p.text}
</div>
))}
</div>
<p className="text-center text-xs text-gray-500">
Want to extend? Pick a tier below to add more time.
</p>
<TierPicker selected={selected} onSelect={setSelected} />
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} extend />
</div>
) : manual ? (
// ── Manual QR payment ───────────────────────────────────────────
<div className="space-y-4 py-2 text-center">
<p className="text-sm text-gray-300">
Scan to pay <span className="font-black text-white">{selected.sats.toLocaleString()} sats</span> for {selected.label} of Pro
</p>
{qr && <img src={qr} alt="Lightning invoice QR" className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />}
<div className="flex gap-2">
<Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={copyInvoice}>
<Copy className="h-4 w-4" /> Copy invoice
</Button>
<Button
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#d4ff1a] font-bold"
onClick={() => purchase.confirmManual(selected)}
>
<Check className="h-4 w-4" /> I've paid
</Button>
</div>
<p className="text-[11px] text-gray-500">
Tap "I've paid" after your wallet confirms — this records your pass to your Nostr identity.
</p>
</div>
) : (
// ── Purchase flow ───────────────────────────────────────────────
<div className="space-y-4 py-2">
<div className="space-y-2">
{PERKS.map((p) => (
<div key={p.text} className="flex items-start gap-2.5 text-sm text-gray-200">
<p.icon className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
{p.text}
</div>
))}
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
Free Sasquatch stays free — public spot/event/coach search + the full
trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
</div>
<TierPicker selected={selected} onSelect={setSelected} />
{!user && (
<p className="text-center text-xs text-amber-400">Log in first to upgrade.</p>
)}
{purchase.error && (
<p className="text-center text-xs text-red-400">{purchase.error}</p>
)}
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} disabled={!user} />
<p className="text-center text-[11px] text-gray-500">
Pays to skatespots@minibits.cash over Lightning. Bound to your npub.
</p>
</div>
)}
</DialogContent>
</Dialog>
);
}
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
return (
<div className="grid grid-cols-2 gap-2">
{PRO_TIERS.map((t) => {
const active = t.id === selected.id;
return (
<button
key={t.id}
onClick={() => onSelect(t)}
className={cn(
'relative rounded-xl border p-3 text-left transition-all',
active
? 'border-red-500 bg-red-500/15'
: 'border-white/10 bg-white/[0.02] hover:border-white/25',
)}
>
{t.popular && (
<span className="absolute -top-2 right-2 rounded-full bg-red-500 px-1.5 py-0.5 text-[8px] font-black uppercase text-white">
Popular
</span>
)}
<p className="text-sm font-black text-white">{t.label}</p>
<p className="text-lg font-black text-red-400">{t.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">sats</span></p>
<p className="text-[10px] text-gray-500">{t.usd}{t.badge ? ` · ${t.badge}` : ''}</p>
</button>
);
})}
</div>
);
}
function PayButton({
tier,
busy,
disabled,
extend,
onPay,
}: {
tier: ProTier;
busy: boolean;
disabled?: boolean;
extend?: boolean;
onPay: () => void;
}) {
return (
<Button
className="w-full gap-2 bg-red-500 hover:bg-red-600 font-black uppercase tracking-wide h-12"
onClick={onPay}
disabled={busy || disabled}
>
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <Zap className="h-5 w-5" />}
{extend ? 'Extend' : 'Go Pro'} · {tier.sats.toLocaleString()} sats
</Button>
);
}

182
src/hooks/usePurchasePro.ts Normal file
View file

@ -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<PurchaseResult>({ 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 };
}

View file

@ -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<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]);
}

43
src/lib/sasquatchPro.ts Normal file
View file

@ -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;

View file

@ -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.
/**