249 lines
9.3 KiB
TypeScript
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]);
|
|
}
|