SkateSpots/src/hooks/useSasquatchPro.ts

249 lines
9.3 KiB
TypeScript

/**
* useSasquatchPro — is the logged-in user a current Pro supporter?
* ─────────────────────────────────────────────────────────────────────────────
* Pro access is granted ONLY by cryptographically-verifiable proof of payment:
*
* 1. OWNER: platform owners always have Pro.
*
* 2. ZAP RECEIPT (NIP-57, kind 9735) — the primary network-trusted source. To
* count, a receipt must ALL of:
* • be AUTHORED by the platform's own Lightning provider pubkey
* (resolved from the Pro Lightning address), so a user can't publish a
* fake receipt themselves;
* • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment →
* identity) that tags the platform (`p`);
* • encode a paid amount of at least the smallest Pro tier.
*
* 3. OWNER GRANT (NIP-32 kind 1985, `org.skatespots.pro`) — the SAFE fallback
* when the provider publishes no receipt: only an owner pubkey can author
* it, and the user cannot forge it. Carries an `expiry` (unix seconds).
*
* STACKING: multiple receipts/grants ADD UP (renewal semantics), so buying again
* 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 { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { verifyEvent } from 'nostr-tools';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { getZapProviderPubkey } from '@/lib/lightningAddress';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
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';
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
/** Smallest purchasable tier — the minimum a valid Pro zap must pay. */
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 {
/** True if the user currently has active Pro access. */
isPro: boolean;
/** Unix seconds when access expires (stacked across proofs), or null. */
expiresAt: number | null;
/** How access was granted. */
source: 'owner' | 'zap' | 'grant' | null;
isLoading: boolean;
/** Proof to send to the AI proxy (zap receipts + owner grants). */
proof?: ProProof | null;
}
function tagVal(tags: string[][], name: string): string | undefined {
return tags.find((t) => t[0] === name)?.[1];
}
/** Tier days for a valid receipt, or null if the receipt proves nothing. */
function receiptTier(
receipt: NostrEvent,
userPubkey: string,
providerPubkey: string,
): { at: number; days: number } | null {
// The receipt itself MUST be authored by the platform's Lightning provider.
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
const desc = tagVal(receipt.tags, 'description');
if (!desc) return null;
let zapReq: NostrEvent;
try {
zapReq = JSON.parse(desc) as NostrEvent;
} catch {
return null;
}
// The embedded zap request must carry a VALID signature — the receipt is
// provider-authored, but its `description` is attacker-controllable data.
try {
if (!verifyEvent(zapReq)) return null;
} catch {
return null;
}
// Zap request must be signed by the user and tag the platform.
if (zapReq.pubkey !== userPubkey) return null;
if (!zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY)) return null;
// Verify a real amount was paid: prefer the receipt's bolt11, else the
// request's amount tag (millisats). Must meet the smallest tier.
let paidSats = 0;
const bolt11 = tagVal(receipt.tags, 'bolt11');
if (bolt11) {
try {
paidSats = getSatoshisFromBolt11(bolt11);
} catch {
paidSats = 0;
}
}
if (paidSats === 0) {
const amountMsats = Number(tagVal(zapReq.tags, 'amount'));
if (Number.isFinite(amountMsats) && amountMsats > 0) {
paidSats = Math.floor(amountMsats / 1000);
}
}
if (paidSats < MIN_PRO_SATS) return null;
// 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.
const tier = [...PRO_TIERS].sort((a, b) => b.sats - a.sats).find((t) => paidSats >= t.sats);
if (!tier) return null;
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.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 {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { querySkateContent } = useSkateRelays();
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']; proof: ProProof | null }> => {
if (!pubkey) return { expiresAt: null, source: null, proof: null };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
// Owner grants (kind 1985) — the fallback path. Read via querySkateContent
// (community relay + the user's relays), which is where the owner publishes
// them — the default pool would miss them for most users.
const grantsPromise = querySkateContent(
[
{
kinds: [1985],
authors: PLATFORM_OWNER_PUBKEYS,
'#L': [PRO_LABEL_NAMESPACE],
'#p': [pubkey],
limit: 20,
},
],
{ signal },
).catch(() => [] as NostrEvent[]);
// The primary source: zap receipts authored by the platform's Lightning
// provider. Resolve that provider pubkey first; without it we can't verify
// any receipt (owners are handled separately).
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
const receipts = providerPubkey
? await nostr
.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 {
expiresAt: expiry,
source: fromReceipts ? 'zap' : 'grant',
proof: { receipts: validReceipts, grants: validGrants },
};
},
});
return useMemo<ProStatus>(() => {
if (isOwner) {
return { isPro: true, expiresAt: null, source: 'owner', isLoading: false, proof: null };
}
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,
proof: isPro ? data?.proof ?? null : null,
};
}, [isOwner, data, isLoading]);
}