fix(pro): stack Pro proofs + owner-grant fallback

#1 Extend now actually extends. useSasquatchPro collected receipts and took the
max of (receipt.created_at + tierDays), so buying again before expiry
under-delivered. It now stacks every valid proof with renewal semantics
(expiry = max(expiry, proofTime) + days), client and server alike.

#2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32
kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely
dependent on the Lightning provider publishing zap receipts. New owner-only
GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook
reads via querySkateContent.

Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and
the Netlify function accept the new payload and a bare receipt (old APKs). Proxy
was deployed first and verified healthy before this commit.
This commit is contained in:
SkateSpots 2026-09-28 18:58:25 +07:00
parent 8d63efa18a
commit 124cac74fa
10 changed files with 435 additions and 88 deletions

View file

@ -195,6 +195,22 @@ session with **no other memory of this project**. So:
Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt, Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt,
and its **expiry is derived from the paid amount → tier**, never the and its **expiry is derived from the paid amount → tier**, never the
user-signed `pro-expiry` tag. user-signed `pro-expiry` tag.
- **Pro proofs STACK (fixed 2026-09-28).** `useSasquatchPro` now collects ALL
valid proofs and accumulates `expiry = max(expiry, proofTime) + tierDays`
(renewal semantics) — before, it took the **max** of
`receipt.created_at + days`, so buying again before expiry silently
under-delivered ("Extend" did not extend). The proof sent to the proxy is
now `{ receipts, grants }` (base64) instead of one receipt; **both servers
(`ai-proxy/server.ts` and `netlify/functions/ai.mjs`) accept the new payload
AND a bare receipt**, so installed APKs keep working. ⚠️ Deploy the **proxy
before the web client** (old proxy + new payload = 402).
- **Owner-grant fallback (NIP-32, same date):** `org.skatespots.pro` kind-1985
labels authored by an owner pubkey, tagged `p` = user + `expiry`, are a
trusted Pro source (`useSasquatchPro` + both servers honour them) — the
safety net if the LN provider ever stops emitting zap receipts. Published by
the owner-only **`GrantProCard`** (Settings → Advanced), written to
`getWriteRelays()` and read via `querySkateContent` (community relay + user
relays); the self-signed `32671` remains advisory-only.
`sasquatchBrain.ts` caps each request (15 s) and the whole attempt `sasquatchBrain.ts` caps each request (15 s) and the whole attempt
(35 s); the system prompt is a real `system` message; relay results are (35 s); the system prompt is a real `system` message; relay results are
signature-verified (`verifyEvent`) before use. The first pass **streams** signature-verified (`verifyEvent`) before use. The first pass **streams**

View file

@ -66,6 +66,10 @@ const PRO_TIERS = [
{ days: 365, sats: 36000 }, { days: 365, sats: 36000 },
]; ];
/** Owner-grant fallback (NIP-32 label). Keep in sync with src/lib/sasquatchPro.ts. */
const PRO_LABEL_NAMESPACE = 'org.skatespots.pro';
const PRO_LABEL_VALUE = 'pro';
let providerPubkeyCache: string | null | undefined; let providerPubkeyCache: string | null | undefined;
async function getProProviderPubkey(): Promise<string | null> { async function getProProviderPubkey(): Promise<string | null> {
if (providerPubkeyCache !== undefined) return providerPubkeyCache; if (providerPubkeyCache !== undefined) return providerPubkeyCache;
@ -117,21 +121,22 @@ function verifyNip98(authHeader: string | null): string | null {
} }
} }
/** Verify a Pro pass (provider-authored zap receipt bound to the caller). */ /** Tier days for a valid, provider-authored receipt bound to the user. */
async function verifyPro(receiptHeader: string | null, userPubkey: string): Promise<number | null> { function receiptTier(
if (!receiptHeader) return null; receipt: { pubkey: string; created_at: number; tags: string[][] },
const receipt = decodeB64Json(receiptHeader); userPubkey: string,
if (!receipt || receipt.kind !== 9735) return null; providerPubkey: string,
const providerPubkey = await getProProviderPubkey(); ): { at: number; days: number } | null {
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
try { try {
if (!verifyEvent(receipt)) return null; if (!verifyEvent(receipt as never)) return null;
} catch { } catch {
return null; return null;
} }
const desc = tagVal(receipt.tags, 'description'); const desc = tagVal(receipt.tags, 'description');
if (!desc) return null; if (!desc) return null;
let zapReq; let zapReq: { pubkey?: string; tags?: string[][] };
try { try {
zapReq = JSON.parse(desc); zapReq = JSON.parse(desc);
} catch { } catch {
@ -139,11 +144,11 @@ async function verifyPro(receiptHeader: string | null, userPubkey: string): Prom
} }
if (!zapReq || zapReq.pubkey !== userPubkey) return null; if (!zapReq || zapReq.pubkey !== userPubkey) return null;
try { try {
if (!verifyEvent(zapReq)) return null; if (!verifyEvent(zapReq as never)) return null;
} catch { } catch {
return null; return null;
} }
if (!zapReq.tags?.some((t: string[]) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null; if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;
let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11') ?? ''); let paidSats = satsFromBolt11(tagVal(receipt.tags, 'bolt11') ?? '');
if (!paidSats) { if (!paidSats) {
@ -152,7 +157,62 @@ async function verifyPro(receiptHeader: string | null, userPubkey: string): Prom
} }
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
if (!tier) return null; if (!tier) return null;
return receipt.created_at + tier.days * DAY_SECONDS; return { at: receipt.created_at, days: tier.days };
}
/** Expiry from an owner-issued kind-1985 Pro grant, or null. */
function grantExpiry(
grant: { kind?: number; pubkey?: string; tags?: string[][] },
userPubkey: string,
): number | null {
if (grant?.kind !== 1985 || !grant.pubkey) return null;
if (!PLATFORM_OWNER_PUBKEYS.has(grant.pubkey)) return null;
try {
if (!verifyEvent(grant as never)) return null;
} catch {
return null;
}
const tags = grant.tags ?? [];
const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]);
if (!ns.includes(PRO_LABEL_NAMESPACE)) return null;
const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]);
if (vals.length && !vals.includes(PRO_LABEL_VALUE)) return null;
if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null;
const expiry = Number(tagVal(tags, 'expiry'));
return Number.isFinite(expiry) && expiry > 0 ? expiry : null;
}
/**
* Verify a Pro proof (zap receipts + owner grants) → STACKED expiry, or null.
*
* Accepts both the new `{ receipts, grants }` payload and a bare receipt event
* (older installed clients), and adds proofs up with renewal semantics so a
* second purchase genuinely extends a pass.
*/
async function verifyPro(proofHeader: string | null, userPubkey: string): Promise<number | null> {
if (!proofHeader) return null;
const payload = decodeB64Json(proofHeader) as
| { kind?: number; receipts?: unknown[]; grants?: unknown[] }
| null;
if (!payload) return null;
const receipts = payload.kind === 9735 ? [payload] : Array.isArray(payload.receipts) ? payload.receipts : [];
const grants = Array.isArray(payload.grants) ? payload.grants : [];
let expiry = 0;
const providerPubkey = await getProProviderPubkey();
if (providerPubkey && receipts.length) {
const tiers = receipts
.map((r) => receiptTier(r as never, userPubkey, providerPubkey))
.filter((t): t is { at: number; days: number } => !!t)
.sort((a, b) => a.at - b.at);
for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS;
}
for (const g of grants) {
const e = grantExpiry(g as never, userPubkey);
if (e && e > expiry) expiry = e;
}
return expiry || null;
} }
// Origins allowed to call the proxy (web + local dev + Capacitor native). // Origins allowed to call the proxy (web + local dev + Capacitor native).

View file

@ -69,6 +69,10 @@ const PRO_TIERS = [
{ days: 365, sats: 36000 }, { days: 365, sats: 36000 },
]; ];
/** Owner-grant fallback (NIP-32 label). Keep in sync with src/lib/sasquatchPro.ts. */
const PRO_LABEL_NAMESPACE = 'org.skatespots.pro';
const PRO_LABEL_VALUE = 'pro';
let providerPubkeyCache; let providerPubkeyCache;
/** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */ /** Resolve the platform's LNURL provider pubkey (zap receipts must be theirs). */
async function getProProviderPubkey() { async function getProProviderPubkey() {
@ -124,13 +128,8 @@ function verifyNip98(authHeader) {
} }
} }
/** Verify a Pro pass (provider-authored zap receipt bound to the caller, paying /** Tier days for a valid, provider-authored receipt bound to the user. */
* the platform ≥ the smallest tier) → proUntil (unix sec), or null. */ function receiptTier(receipt, userPubkey, providerPubkey) {
async function verifyPro(receiptHeader, userPubkey) {
if (!receiptHeader) return null;
const receipt = decodeB64Json(receiptHeader);
if (!receipt || receipt.kind !== 9735) return null;
const providerPubkey = await getProProviderPubkey();
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
try { try {
if (!verifyEvent(receipt)) return null; if (!verifyEvent(receipt)) return null;
@ -161,7 +160,54 @@ async function verifyPro(receiptHeader, userPubkey) {
} }
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
if (!tier) return null; if (!tier) return null;
return receipt.created_at + tier.days * DAY_SECONDS; return { at: receipt.created_at, days: tier.days };
}
/** Expiry from an owner-issued kind-1985 Pro grant, or null. */
function grantExpiry(grant, userPubkey) {
if (grant?.kind !== 1985 || !grant.pubkey) return null;
if (!PLATFORM_OWNER_PUBKEYS.has(grant.pubkey)) return null;
try {
if (!verifyEvent(grant)) return null;
} catch {
return null;
}
const tags = grant.tags ?? [];
const ns = tags.filter((t) => t[0] === 'L').map((t) => t[1]);
if (!ns.includes(PRO_LABEL_NAMESPACE)) return null;
const vals = tags.filter((t) => t[0] === 'l').map((t) => t[1]);
if (vals.length && !vals.includes(PRO_LABEL_VALUE)) return null;
if (!tags.some((t) => t[0] === 'p' && t[1] === userPubkey)) return null;
const expiry = Number(tagVal(tags, 'expiry'));
return Number.isFinite(expiry) && expiry > 0 ? expiry : null;
}
/**
* Verify a Pro proof (zap receipts + owner grants) → STACKED expiry, or null.
* Accepts the new `{ receipts, grants }` payload and a bare receipt (old clients).
*/
async function verifyPro(proofHeader, userPubkey) {
if (!proofHeader) return null;
const payload = decodeB64Json(proofHeader);
if (!payload) return null;
const receipts = payload.kind === 9735 ? [payload] : Array.isArray(payload.receipts) ? payload.receipts : [];
const grants = Array.isArray(payload.grants) ? payload.grants : [];
let expiry = 0;
const providerPubkey = await getProProviderPubkey();
if (providerPubkey && receipts.length) {
const tiers = receipts
.map((r) => receiptTier(r, userPubkey, providerPubkey))
.filter(Boolean)
.sort((a, b) => a.at - b.at);
for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS;
}
for (const g of grants) {
const e = grantExpiry(g, userPubkey);
if (e && e > expiry) expiry = e;
}
return expiry || null;
} }
// Origins allowed to call the proxy (web + local dev + Capacitor native). // Origins allowed to call the proxy (web + local dev + Capacitor native).

View file

@ -0,0 +1,143 @@
/**
* GrantProCard — owner-only: hand out Sasquatch Pro manually.
*
* The SAFE fallback for when the Lightning provider publishes no zap receipt:
* the owner publishes a NIP-32 (kind 1985) label in `org.skatespots.pro` tagged
* to the user with an `expiry`. Only an owner key can author it, so a user can't
* forge it (unlike the self-signed kind-32671 receipt). `useSasquatchPro` and the
* AI proxy both honour it.
*/
import { useState } from 'react';
import { nip19 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { toast } from '@/hooks/useToast';
import { PRO_LABEL_NAMESPACE, PRO_LABEL_VALUE, DAY_SECONDS } from '@/lib/sasquatchPro';
import { BadgeCheck, Loader2 } from 'lucide-react';
function decodeTarget(value: string): string | null {
const v = value.trim();
if (/^[0-9a-f]{64}$/i.test(v)) return v.toLowerCase();
try {
const d = nip19.decode(v);
if (d.type === 'npub') return d.data;
if (d.type === 'nprofile') return d.data.pubkey;
} catch {
/* not bech32 */
}
return null;
}
export function GrantProCard() {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { getWriteRelays } = useSkateRelays();
const [target, setTarget] = useState('');
const [days, setDays] = useState('30');
const [busy, setBusy] = useState(false);
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
if (!isOwner) return null;
const grant = async () => {
const pubkey = decodeTarget(target);
if (!pubkey) {
toast({ title: 'Enter a valid npub (or hex pubkey)', variant: 'destructive' });
return;
}
const n = Number(days);
if (!Number.isFinite(n) || n <= 0) {
toast({ title: 'Days must be a positive number', variant: 'destructive' });
return;
}
if (!user?.signer) return;
setBusy(true);
try {
const now = Math.floor(Date.now() / 1000);
const expiry = now + Math.round(n) * DAY_SECONDS;
const event = await user.signer.signEvent({
kind: 1985,
content: `Sasquatch Pro grant — ${n} days`,
created_at: now,
tags: [
['L', PRO_LABEL_NAMESPACE],
['l', PRO_LABEL_VALUE, PRO_LABEL_NAMESPACE],
['p', pubkey],
['expiry', String(expiry)],
['alt', `SkateSpots: Sasquatch Pro grant until ${new Date(expiry * 1000).toISOString()}`],
],
});
// Publish where users READ (community relay + owner relays).
const results = await Promise.allSettled(
getWriteRelays().map((url) =>
nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }),
),
);
if (!results.some((r) => r.status === 'fulfilled')) throw new Error('No relay accepted the grant.');
toast({
title: 'Pro granted ⚡',
description: `${nip19.npubEncode(pubkey).slice(0, 14)}… until ${new Date(expiry * 1000).toLocaleDateString()}`,
});
setTarget('');
} catch (err) {
toast({
title: 'Grant failed',
description: err instanceof Error ? err.message : 'Try again.',
variant: 'destructive',
});
} finally {
setBusy(false);
}
};
return (
<Card className="border-white/10 bg-white/2">
<CardContent className="py-3.5 space-y-2.5">
<p className="flex items-center gap-2 text-sm font-bold text-white">
<BadgeCheck className="h-4 w-4 text-[#ccff00]" />
Grant Sasquatch Pro
<span className="rounded bg-[#ccff00]/10 px-1.5 py-0.5 text-[9px] font-black uppercase tracking-widest text-[#ccff00]/70">
Owner
</span>
</p>
<p className="text-xs text-muted-foreground">
Manual fallback for a verified payment the zap receipt missed. Publishes an owner-signed
grant; the user can't forge it.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="npub1… (or hex)"
className="h-9 flex-1 border-white/10 bg-black/40 text-sm text-white"
spellCheck={false}
disabled={busy}
/>
<Input
value={days}
onChange={(e) => setDays(e.target.value)}
type="number"
inputMode="numeric"
className="h-9 w-full border-white/10 bg-black/40 text-sm text-white sm:w-24"
disabled={busy}
/>
<Button
onClick={grant}
disabled={busy}
className="h-9 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <BadgeCheck className="h-4 w-4" />}
Grant
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -637,7 +637,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const cvm = useCVMClient(); const cvm = useCVMClient();
const personal = usePersonalContext(); const personal = usePersonalContext();
const { isPro, receiptEvent } = useSasquatchPro(); const { isPro, proof } = useSasquatchPro();
// Owner-only: show the AI (Routstr) balance on the panel — fetched only while // Owner-only: show the AI (Routstr) balance on the panel — fetched only while
// the panel is open, so it never prompts at startup. // the panel is open, so it never prompts at startup.
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey); const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
@ -978,7 +978,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const authHeaders = await buildProAuthHeaders({ const authHeaders = await buildProAuthHeaders({
pubkey: user?.pubkey, pubkey: user?.pubkey,
signEvent: user ? (e) => user.signer.signEvent(e) : undefined, signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
receiptEvent, proof,
}); });
// The system prompt goes in a real `system` message rather than being // The system prompt goes in a real `system` message rather than being
// smuggled into a user turn — the model then treats the relay/derived // smuggled into a user turn — the model then treats the relay/derived
@ -1376,7 +1376,7 @@ Instructions:
setIsThinking(false); setIsThinking(false);
setStreamingText(''); setStreamingText('');
} }
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, receiptEvent, user, appendMessage, runTripPlanner]); }, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner]);
// Listen for external events from FAQ/About pages // Listen for external events from FAQ/About pages
useEffect(() => { useEffect(() => {

View file

@ -170,7 +170,7 @@ Rules:
export function useCrewAgentChat(ctx: CrewContext) { export function useCrewAgentChat(ctx: CrewContext) {
const cvm = useCVMClient(); const cvm = useCVMClient();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { isPro, receiptEvent } = useSasquatchPro(); const { isPro, proof } = useSasquatchPro();
const [messages, setMessages] = useState<CrewAgentMessage[]>([]); const [messages, setMessages] = useState<CrewAgentMessage[]>([]);
const [isThinking, setIsThinking] = useState(false); const [isThinking, setIsThinking] = useState(false);
@ -217,7 +217,7 @@ export function useCrewAgentChat(ctx: CrewContext) {
? await buildProAuthHeaders({ ? await buildProAuthHeaders({
pubkey: user?.pubkey, pubkey: user?.pubkey,
signEvent: user ? (e) => user.signer.signEvent(e) : undefined, signEvent: user ? (e) => user.signer.signEvent(e) : undefined,
receiptEvent, proof,
}) })
: {}; : {};
const system = buildCrewSystemPrompt(ctx); const system = buildCrewSystemPrompt(ctx);
@ -277,7 +277,7 @@ ${JSON.stringify(toolResult, null, 2)}
setIsThinking(false); setIsThinking(false);
} }
}, },
[ctx, isThinking, append, runTool, isPro, receiptEvent, user], [ctx, isThinking, append, runTool, isPro, proof, user],
); );
return { messages, isThinking, send }; return { messages, isThinking, send };

View file

@ -5,7 +5,7 @@
* *
* 1. OWNER: platform owners always have Pro. * 1. OWNER: platform owners always have Pro.
* *
* 2. ZAP RECEIPT (NIP-57, kind 9735) — the ONLY network-trusted source. To * 2. ZAP RECEIPT (NIP-57, kind 9735) — the primary network-trusted source. To
* count, a receipt must ALL of: * count, a receipt must ALL of:
* • be AUTHORED by the platform's own Lightning provider pubkey * • be AUTHORED by the platform's own Lightning provider pubkey
* (resolved from the Pro Lightning address), so a user can't publish a * (resolved from the Pro Lightning address), so a user can't publish a
@ -13,24 +13,38 @@
* • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment → * • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment →
* identity) that tags the platform (`p`); * identity) that tags the platform (`p`);
* • encode a paid amount of at least the smallest Pro tier. * • encode a paid amount of at least the smallest Pro tier.
* Expiry is read from the user-signed request's `pro-expiry` tag.
* *
* SECURITY: the self-signed kind-32671 receipt is NObodyy's proof of payment — * 3. OWNER GRANT (NIP-32 kind 1985, `org.skatespots.pro`) — the SAFE fallback
* any user can sign one with an arbitrary `expiry`, which previously let anyone * when the provider publishes no receipt: only an owner pubkey can author
* grant themselves permanent Pro. It is therefore treated as ADVISORY ONLY and * it, and the user cannot forge it. Carries an `expiry` (unix seconds).
* never grants access on its own. Genuine buyers are covered by the zap receipt *
* (or by owner status); the fallback existed only for providers that emit no zap * STACKING: multiple receipts/grants ADD UP (renewal semantics), so buying again
* receipt, which is not worth a self-grant bypass. * before your pass lapses genuinely extends it. We accumulate
* `expiry = max(expiry, proofTime) + days`.
*
* SECURITY: the self-signed kind-32671 receipt is NObody's proof of payment — any
* user can sign one with an arbitrary `expiry`, which previously let anyone grant
* themselves permanent Pro. It is treated as ADVISORY ONLY and never grants
* access on its own.
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { verifyEvent } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { getZapProviderPubkey } from '@/lib/lightningAddress'; import { getZapProviderPubkey } from '@/lib/lightningAddress';
import { getSatoshisFromBolt11 } from '@/lib/bolt11'; import { getSatoshisFromBolt11 } from '@/lib/bolt11';
import { PRO_LIGHTNING_ADDRESS, PRO_TIERS, DAY_SECONDS } from '@/lib/sasquatchPro'; import {
PRO_LIGHTNING_ADDRESS,
PRO_TIERS,
DAY_SECONDS,
PRO_LABEL_NAMESPACE,
PRO_LABEL_VALUE,
type ProProof,
} from '@/lib/sasquatchPro';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
@ -38,36 +52,32 @@ const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
/** Smallest purchasable tier — the minimum a valid Pro zap must pay. */ /** Smallest purchasable tier — the minimum a valid Pro zap must pay. */
const MIN_PRO_SATS = Math.min(...PRO_TIERS.map((t) => t.sats)); const MIN_PRO_SATS = Math.min(...PRO_TIERS.map((t) => t.sats));
/** Proof of Pro sent to the AI proxy so its gate can re-verify server-side.
* (Defined in lib so proAuth can import it without a hook dependency.) */
export type { ProProof } from '@/lib/sasquatchPro';
export interface ProStatus { export interface ProStatus {
/** True if the user currently has active Pro access. */ /** True if the user currently has active Pro access. */
isPro: boolean; isPro: boolean;
/** Unix seconds when access expires (max across sources), or null. */ /** Unix seconds when access expires (stacked across proofs), or null. */
expiresAt: number | null; expiresAt: number | null;
/** How access was granted. */ /** How access was granted. */
source: 'owner' | 'zap' | null; source: 'owner' | 'zap' | 'grant' | null;
isLoading: boolean; isLoading: boolean;
/** The zap receipt that granted access — sent to the AI proxy as proof. */ /** Proof to send to the AI proxy (zap receipts + owner grants). */
receiptEvent?: NostrEvent | null; proof?: ProProof | null;
} }
function tagVal(tags: string[][], name: string): string | undefined { function tagVal(tags: string[][], name: string): string | undefined {
return tags.find((t) => t[0] === name)?.[1]; return tags.find((t) => t[0] === name)?.[1];
} }
/** /** Tier days for a valid receipt, or null if the receipt proves nothing. */
* Extract expiry from a zap receipt, but ONLY if it's a genuine, provider-issued function receiptTier(
* proof of payment for THIS user to the platform. Returns null unless every
* check passes:
* • receipt authored by the platform's Lightning provider pubkey;
* • embedded zap request signed by the user (payment↔identity binding);
* • zap request tags the platform pubkey (`p`);
* • paid amount (from the bolt11 tag, else the request `amount` tag) ≥ minimum.
*/
function expiryFromZapReceipt(
receipt: NostrEvent, receipt: NostrEvent,
userPubkey: string, userPubkey: string,
providerPubkey: string, providerPubkey: string,
): number | null { ): { at: number; days: number } | null {
// The receipt itself MUST be authored by the platform's Lightning provider. // The receipt itself MUST be authored by the platform's Lightning provider.
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null; if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
@ -83,8 +93,7 @@ function expiryFromZapReceipt(
// Zap request must be signed by the user and tag the platform. // Zap request must be signed by the user and tag the platform.
if (zapReq.pubkey !== userPubkey) return null; if (zapReq.pubkey !== userPubkey) return null;
const tagsPlatform = zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY); if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;
if (!tagsPlatform) return null;
// Verify a real amount was paid: prefer the receipt's bolt11, else the // Verify a real amount was paid: prefer the receipt's bolt11, else the
// request's amount tag (millisats). Must meet the smallest tier. // request's amount tag (millisats). Must meet the smallest tier.
@ -106,17 +115,36 @@ function expiryFromZapReceipt(
if (paidSats < MIN_PRO_SATS) return null; if (paidSats < MIN_PRO_SATS) return null;
// Enforce the tier from what was ACTUALLY paid. Never trust the user-signed // Enforce the tier from what was ACTUALLY paid. Never trust the user-signed
// `pro-expiry` tag — that let a 7-day payment self-assert a year. Pick the // `pro-expiry` tag — that let a 7-day payment self-assert a year.
// largest tier the payment covers and run the pass from when the receipt was
// issued (so paying later genuinely extends access).
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats); const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
if (!tier) return null; if (!tier) return null;
return receipt.created_at + tier.days * DAY_SECONDS;
return { at: receipt.created_at, days: tier.days };
}
/** Expiry from an owner-issued kind-1985 Pro grant, or null if not valid. */
function grantExpiry(grant: NostrEvent, userPubkey: string): number | null {
if (grant.kind !== 1985) return null;
if (!PLATFORM_OWNER_PUBKEYS.includes(grant.pubkey)) return null;
try {
if (!verifyEvent(grant)) return null;
} catch {
return null;
}
const namespace = grant.tags.filter(([n]) => n === 'L').map(([, v]) => v);
if (!namespace.includes(PRO_LABEL_NAMESPACE)) return null;
const value = grant.tags.filter(([n]) => n === 'l').map(([, v]) => v);
if (value.length && !value.includes(PRO_LABEL_VALUE)) return null;
const taggedUser = grant.tags.some(([n, v]) => n === 'p' && v === userPubkey);
if (!taggedUser) return null;
const expiry = Number(tagVal(grant.tags, 'expiry'));
return Number.isFinite(expiry) && expiry > 0 ? expiry : null;
} }
export function useSasquatchPro(): ProStatus { export function useSasquatchPro(): ProStatus {
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { querySkateContent } = useSkateRelays();
const pubkey = user?.pubkey; const pubkey = user?.pubkey;
// Platform owners always have Pro. // Platform owners always have Pro.
@ -128,49 +156,76 @@ export function useSasquatchPro(): ProStatus {
staleTime: 60_000, staleTime: 60_000,
queryFn: async ( queryFn: async (
c, c,
): Promise<{ ): Promise<{ expiresAt: number | null; source: ProStatus['source']; proof: ProProof | null }> => {
expiresAt: number | null; if (!pubkey) return { expiresAt: null, source: null, proof: null };
source: ProStatus['source'];
receipt?: NostrEvent;
}> => {
if (!pubkey) return { expiresAt: null, source: null };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
// The ONLY network-trusted source is a zap receipt authored by the // Owner grants (kind 1985) — the fallback path. Read via querySkateContent
// platform's Lightning provider. Resolve that provider pubkey first; if we // (community relay + the user's relays), which is where the owner publishes
// can't, we cannot verify any receipt and grant no access (owners are // them — the default pool would miss them for most users.
// handled separately above). const grantsPromise = querySkateContent(
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null); [
if (!providerPubkey) return { expiresAt: null, source: null }; {
kinds: [1985],
// Zap receipts (9735) tagging the user as the zapper (#P), authored by the authors: PLATFORM_OWNER_PUBKEYS,
// provider (#authors) — verified thoroughly in expiryFromZapReceipt. '#L': [PRO_LABEL_NAMESPACE],
const events = await nostr.query( '#p': [pubkey],
[{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 50 }], limit: 20,
},
],
{ signal }, { signal },
); ).catch(() => [] as NostrEvent[]);
let best = 0; // The primary source: zap receipts authored by the platform's Lightning
let bestReceipt: NostrEvent | undefined; // provider. Resolve that provider pubkey first; without it we can't verify
for (const ev of events) { // any receipt (owners are handled separately).
const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey); const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
if (exp && exp > best) { const receipts = providerPubkey
best = exp; ? await nostr
bestReceipt = ev; .query(
[{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 100 }],
{ signal },
)
.catch(() => [] as NostrEvent[])
: [];
const validReceipts = providerPubkey
? receipts.filter((r) => receiptTier(r, pubkey, providerPubkey))
: [];
const allGrants = await grantsPromise;
const validGrants = allGrants.filter((g) => grantExpiry(g, pubkey) !== null);
// Stack everything (renewal semantics): sort proofs by time and accumulate,
// so a second purchase genuinely extends the pass.
let expiry = 0;
if (providerPubkey) {
const tiers = validReceipts
.map((r) => receiptTier(r, pubkey, providerPubkey)!)
.sort((a, b) => a.at - b.at);
for (const t of tiers) expiry = Math.max(expiry, t.at) + t.days * DAY_SECONDS;
}
let fromReceipts = expiry > 0;
for (const g of validGrants) {
const e = grantExpiry(g, pubkey);
if (e && e > expiry) {
expiry = e;
fromReceipts = false;
} }
} }
if (!expiry) return { expiresAt: null, source: null, proof: null };
return { return {
expiresAt: best || null, expiresAt: expiry,
source: best ? ('zap' as const) : null, source: fromReceipts ? 'zap' : 'grant',
receipt: bestReceipt, proof: { receipts: validReceipts, grants: validGrants },
}; };
}, },
}); });
return useMemo<ProStatus>(() => { return useMemo<ProStatus>(() => {
if (isOwner) { if (isOwner) {
return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, receiptEvent: null }; return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, proof: null };
} }
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const expiresAt = data?.expiresAt ?? null; const expiresAt = data?.expiresAt ?? null;
@ -180,7 +235,7 @@ export function useSasquatchPro(): ProStatus {
expiresAt, expiresAt,
source: isPro ? data?.source ?? null : null, source: isPro ? data?.source ?? null : null,
isLoading, isLoading,
receiptEvent: isPro ? data?.receipt ?? null : null, proof: isPro ? data?.proof ?? null : null,
}; };
}, [isOwner, data, isLoading]); }, [isOwner, data, isLoading]);
} }

View file

@ -11,6 +11,7 @@
*/ */
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { sasquatchAiBase } from '@/lib/sasquatchAi'; import { sasquatchAiBase } from '@/lib/sasquatchAi';
import type { ProProof } from '@/lib/sasquatchPro';
type SignEvent = (event: { type SignEvent = (event: {
kind: number; kind: number;
@ -30,9 +31,10 @@ function toBase64(value: string): string {
export async function buildProAuthHeaders(opts: { export async function buildProAuthHeaders(opts: {
pubkey?: string; pubkey?: string;
signEvent?: SignEvent; signEvent?: SignEvent;
receiptEvent?: NostrEvent | null; /** Zap receipts + owner grants proving Pro (see useSasquatchPro). */
proof?: ProProof | null;
}): Promise<Record<string, string>> { }): Promise<Record<string, string>> {
const { pubkey, signEvent, receiptEvent } = opts; const { pubkey, signEvent, proof } = opts;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (!pubkey || !signEvent) return headers; if (!pubkey || !signEvent) return headers;
@ -51,6 +53,11 @@ export async function buildProAuthHeaders(opts: {
} }
headers['x-sasquatch-auth'] = cachedAuthHeader; headers['x-sasquatch-auth'] = cachedAuthHeader;
if (receiptEvent) headers['x-sasquatch-pro'] = toBase64(JSON.stringify(receiptEvent)); // Send ALL proof so the proxy can stack it the same way the client does.
if (proof && (proof.receipts.length > 0 || proof.grants.length > 0)) {
headers['x-sasquatch-pro'] = toBase64(
JSON.stringify({ receipts: proof.receipts, grants: proof.grants }),
);
}
return headers; return headers;
} }

View file

@ -12,9 +12,17 @@
* future verifier can validate it retroactively. * future verifier can validate it retroactively.
*/ */
import type { NostrEvent } from '@nostrify/nostrify';
/** Platform Lightning address that receives Pro payments (same as the fee address). */ /** Platform Lightning address that receives Pro payments (same as the fee address). */
export { PLATFORM_LIGHTNING_ADDRESS as PRO_LIGHTNING_ADDRESS } from '@/lib/platform'; export { PLATFORM_LIGHTNING_ADDRESS as PRO_LIGHTNING_ADDRESS } from '@/lib/platform';
/** Proof of Pro sent to the AI proxy so its gate can re-verify server-side. */
export interface ProProof {
receipts: NostrEvent[];
grants: NostrEvent[];
}
export interface ProTier { export interface ProTier {
id: string; id: string;
label: string; label: string;
@ -41,3 +49,13 @@ export function tierById(id: string): ProTier | undefined {
/** Seconds in a day. */ /** Seconds in a day. */
export const DAY_SECONDS = 24 * 60 * 60; export const DAY_SECONDS = 24 * 60 * 60;
/**
* Owner-grant fallback (NIP-32 label). If the Lightning provider ever stops
* publishing zap receipts, Pro can still be granted by the platform owner
* publishing a kind-1985 label in this namespace, tagged to the user with an
* `expiry` (unix seconds). Trusted because it is authored by an owner pubkey —
* and unforgeable by the user, unlike the self-signed kind-32671 receipt.
*/
export const PRO_LABEL_NAMESPACE = 'org.skatespots.pro';
export const PRO_LABEL_VALUE = 'pro';

View file

@ -9,6 +9,7 @@ import { SecuritySettings } from '@/components/SecuritySettings';
import { RelatrSettings } from '@/components/RelatrSettings'; import { RelatrSettings } from '@/components/RelatrSettings';
import { CVMHealthCheck } from '@/components/CVMHealthCheck'; import { CVMHealthCheck } from '@/components/CVMHealthCheck';
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard'; import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
import { GrantProCard } from '@/components/GrantProCard';
import { AdminReviewCard } from '@/components/AdminReviewCard'; import { AdminReviewCard } from '@/components/AdminReviewCard';
import { LanguageSelector } from '@/components/LanguageToggle'; import { LanguageSelector } from '@/components/LanguageToggle';
import { DesktopNavManager } from '@/components/DesktopNavManager'; import { DesktopNavManager } from '@/components/DesktopNavManager';
@ -622,6 +623,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
{/* Owner-only — render null for everyone except platform owners. */} {/* Owner-only — render null for everyone except platform owners. */}
<CVMHealthCheck /> <CVMHealthCheck />
<SasquatchAiStatusCard /> <SasquatchAiStatusCard />
<GrantProCard />
<AdminReviewCard /> <AdminReviewCard />
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2"> <div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
<div className="px-4 py-3.5 border-b border-white/5"> <div className="px-4 py-3.5 border-b border-white/5">