import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { MARKETPLACE_ITEM_KIND, SKATE_SPOT_KIND, SKATE_RELAY } from '@/lib/types'; import { verifyNip05 } from '@/lib/nip05'; // Platform fee destination (single source of truth: src/lib/platform.ts) import { PLATFORM_LIGHTNING_ADDRESS } from '@/lib/platform'; export { PLATFORM_LIGHTNING_ADDRESS }; // Platform owner pubkeys - always trusted, never pay fees export const PLATFORM_OWNER_PUBKEYS = [ '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21', // npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx 'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', // npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l ]; // Trust levels based on activity export type TrustLevel = 'unknown' | 'newcomer' | 'active' | 'trusted'; // Listing limits per trust level (per 30-day period) export const LISTING_LIMITS: Record = { unknown: 0, // No NIP-05 = can't list at all newcomer: 3, // Has NIP-05 but no activity yet - REQUIRES FEE active: 10, // Has spots or 2+ listings - NO FEE trusted: 50, // Established member (spots + listings) - NO FEE }; // Cooldown in milliseconds (24 hours for unknown users) export const COOLDOWN_MS = 24 * 60 * 60 * 1000; // Fee in sats for unknown users export const LISTING_FEE_SATS = 1000; interface ListingRestrictions { trustLevel: TrustLevel; listingLimit: number; listingsThisMonth: number; remainingListings: number; canCreateListing: boolean; requiresFee: boolean; feeRecipient: string | null; // Lightning address - spot owner or platform feeRecipientName: string | null; // Name for display cooldownEndsAt: number | null; isInCooldown: boolean; timeUntilCooldownEnds: number | null; // milliseconds hasNip05: boolean; isPlatformOwner: boolean; // Platform owners bypass all restrictions hasSpotsCreated: boolean; hasListingsCreated: boolean; isLoading: boolean; } interface LastListingRecord { timestamp: number; spotOwnerPubkey?: string; } /** * Hook to manage listing restrictions based on user trust level */ export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestrictions { const { nostr } = useNostr(); const { user, metadata } = useCurrentUser(); const [lastListingRecord] = useLocalStorage( 'last-listing-record', null ); // SECURITY: NIP-05 must be VERIFIED against the user's own key, not merely // present in their self-authored kind-0. Otherwise anyone can type a fake // handle and skip the new-account listing fee / limit. const { data: nip05Status } = useQuery({ queryKey: ['nip05-verify', user?.pubkey, metadata?.nip05], enabled: !!user?.pubkey && !!metadata?.nip05, staleTime: 10 * 60 * 1000, queryFn: async (c) => { if (!user?.pubkey || !metadata?.nip05) return { status: 'none' as const }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); return verifyNip05(metadata.nip05, user.pubkey, signal).catch(() => ({ status: 'unreachable' as const, })); }, }); const hasNip05 = nip05Status?.status === 'verified'; // Check if user is a platform owner (bypasses all restrictions) const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey); // Query user's spots const { data: userSpots, isLoading: spotsLoading } = useQuery({ queryKey: ['user-spots', user?.pubkey], queryFn: async (c) => { if (!user?.pubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const relay = nostr.relay(SKATE_RELAY); const events = await relay.query([{ kinds: [SKATE_SPOT_KIND], authors: [user.pubkey], limit: 50, }], { signal }); return events; }, enabled: !!user?.pubkey, staleTime: 60000, }); // Query user's listings (last 30 days) // Day-granular + memoized: computing this from Date.now() each render changed // the query key continuously and caused a refetch storm. const thirtyDaysAgo = useMemo( () => Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000), [], ); const { data: userListings, isLoading: listingsLoading } = useQuery({ queryKey: ['user-listings', user?.pubkey, thirtyDaysAgo], queryFn: async (c) => { if (!user?.pubkey) return []; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const relay = nostr.relay(SKATE_RELAY); const events = await relay.query([{ kinds: [MARKETPLACE_ITEM_KIND], authors: [user.pubkey], since: thirtyDaysAgo, limit: 100, }], { signal }); return events; }, enabled: !!user?.pubkey, staleTime: 30000, }); // Query spot owner's metadata if we have a linked spot const { data: spotOwnerData } = useQuery({ queryKey: ['spot-owner', linkedSpotPubkey], queryFn: async (c) => { if (!linkedSpotPubkey) return null; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const events = await nostr.query([{ kinds: [0], authors: [linkedSpotPubkey], limit: 1, }], { signal }); if (events.length === 0) return null; try { const metadata = JSON.parse(events[0].content); return { pubkey: linkedSpotPubkey, lud16: metadata.lud16 || null, name: metadata.name || metadata.display_name || null, }; } catch { return null; } }, enabled: !!linkedSpotPubkey, staleTime: 300000, }); const isLoading = spotsLoading || listingsLoading; // Calculate trust level const trustLevel = useMemo((): TrustLevel => { if (!user) return 'unknown'; // Platform owners are ALWAYS trusted - no exceptions, no other checks needed // This must be checked FIRST before any other conditions if (PLATFORM_OWNER_PUBKEYS.includes(user.pubkey)) { return 'trusted'; } // For non-platform-owners, NIP-05 is required if (!hasNip05) return 'unknown'; // While loading, assume user is at least active if they have NIP-05 // This prevents showing the fee payment UI while data loads // Better to temporarily give benefit of the doubt than show fee to trusted users if (isLoading && hasNip05) { return 'active'; } const spotsCreated = userSpots?.length || 0; const listingsCreated = userListings?.length || 0; // Trusted: Has created spots AND has successful listings if (spotsCreated >= 1 && listingsCreated >= 3) { return 'trusted'; } // Active: Has created spots OR has multiple listings if (spotsCreated >= 1 || listingsCreated >= 2) { return 'active'; } // Newcomer: Has NIP-05 but limited activity if (hasNip05) { return 'newcomer'; } return 'unknown'; }, [user, hasNip05, userSpots, userListings, isLoading]); // Calculate cooldown status (only for unknown users) const cooldownStatus = useMemo(() => { if (trustLevel !== 'unknown' || !lastListingRecord) { return { isInCooldown: false, cooldownEndsAt: null, timeUntilCooldownEnds: null }; } const cooldownEndsAt = lastListingRecord.timestamp + COOLDOWN_MS; const now = Date.now(); const isInCooldown = now < cooldownEndsAt; const timeUntilCooldownEnds = isInCooldown ? cooldownEndsAt - now : null; return { isInCooldown, cooldownEndsAt, timeUntilCooldownEnds }; }, [trustLevel, lastListingRecord]); // Calculate listings this month const listingsThisMonth = userListings?.length || 0; const listingLimit = LISTING_LIMITS[trustLevel]; const remainingListings = Math.max(0, listingLimit - listingsThisMonth); // Determine if fee is required - only for newcomers (NIP-05 but no activity yet) // Users without NIP-05 can't list at all, so we don't need to handle that case here const requiresFee = trustLevel === 'newcomer'; // Determine fee recipient const feeRecipient = useMemo(() => { if (!requiresFee) return null; // If spot has owner with lightning address, pay them if (spotOwnerData?.lud16) { return spotOwnerData.lud16; } // Otherwise pay platform return PLATFORM_LIGHTNING_ADDRESS; }, [requiresFee, spotOwnerData]); const feeRecipientName = useMemo(() => { if (!requiresFee) return null; if (spotOwnerData?.lud16) { return spotOwnerData.name || 'Spot Owner'; } return 'SkateSpots'; }, [requiresFee, spotOwnerData]); // Can create listing? const canCreateListing = useMemo(() => { if (!user) return false; // Platform owners can always create listings if (isPlatformOwner) return true; if (!hasNip05) return false; if (remainingListings <= 0) return false; if (cooldownStatus.isInCooldown) return false; return true; }, [user, isPlatformOwner, hasNip05, remainingListings, cooldownStatus.isInCooldown]); return { trustLevel, listingLimit, listingsThisMonth, remainingListings, canCreateListing, requiresFee, feeRecipient, feeRecipientName, cooldownEndsAt: cooldownStatus.cooldownEndsAt, isInCooldown: cooldownStatus.isInCooldown, timeUntilCooldownEnds: cooldownStatus.timeUntilCooldownEnds, hasNip05, isPlatformOwner, hasSpotsCreated: (userSpots?.length || 0) > 0, hasListingsCreated: (userListings?.length || 0) > 0, isLoading, }; } /** * Record a listing creation for cooldown tracking */ export function recordListingCreation( setLastListingRecord: (record: LastListingRecord) => void, spotOwnerPubkey?: string ) { setLastListingRecord({ timestamp: Date.now(), spotOwnerPubkey, }); }