import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; import { useToast } from './useToast'; import { useTranslation } from '@/contexts/language'; import type { NostrEvent } from '@nostrify/nostrify'; // NIP-61 Kinds export const NUTZAP_INFO_KIND = 10019; // User's nutzap receiving preferences export const NUTZAP_KIND = 9321; // Nutzap event export const NUTZAP_HISTORY_KIND = 7376; // Nutzap redemption history export interface NutzapInfo { pubkey: string; relays: string[]; mints: Array<{ url: string; units: string[] }>; p2pkPubkey?: string; } export interface Nutzap { id: string; pubkey: string; // sender recipientPubkey: string; eventId?: string; mintUrl: string; amount: number; unit: string; comment?: string; proofs: string[]; createdAt: number; } // Parse a kind:10019 nutzap info event function parseNutzapInfo(event: NostrEvent): NutzapInfo | null { const relays: string[] = []; const mints: Array<{ url: string; units: string[] }> = []; let p2pkPubkey: string | undefined; for (const tag of event.tags) { if (tag[0] === 'relay' && tag[1]) { relays.push(tag[1]); } else if (tag[0] === 'mint' && tag[1]) { const units = tag.slice(2); mints.push({ url: tag[1], units: units.length > 0 ? units : ['sat'] }); } else if (tag[0] === 'pubkey' && tag[1]) { p2pkPubkey = tag[1]; } } if (relays.length === 0 && mints.length === 0) { return null; } return { pubkey: event.pubkey, relays, mints, p2pkPubkey, }; } // Parse a kind:9321 nutzap event function parseNutzap(event: NostrEvent): Nutzap | null { const proofs: string[] = []; let mintUrl = ''; let unit = 'sat'; let recipientPubkey = ''; let eventId: string | undefined; let amount = 0; for (const tag of event.tags) { if (tag[0] === 'proof' && tag[1]) { proofs.push(tag[1]); // Try to extract amount from proof try { const proof = JSON.parse(tag[1]); if (proof.amount) { amount += proof.amount; } } catch { // Ignore parse errors } } else if (tag[0] === 'u' && tag[1]) { mintUrl = tag[1]; } else if (tag[0] === 'unit' && tag[1]) { unit = tag[1]; } else if (tag[0] === 'p' && tag[1]) { recipientPubkey = tag[1]; } else if (tag[0] === 'e' && tag[1]) { eventId = tag[1]; } } if (!mintUrl || proofs.length === 0 || !recipientPubkey) { return null; } return { id: event.id, pubkey: event.pubkey, recipientPubkey, eventId, mintUrl, amount, unit, comment: event.content || undefined, proofs, createdAt: event.created_at, }; } /** * Get a user's nutzap info (kind:10019) */ export function useNutzapInfo(pubkey: string | undefined) { const { nostr } = useNostr(); return useQuery({ queryKey: ['nutzap-info', pubkey], queryFn: async (c) => { if (!pubkey) return null; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const events = await nostr.query([ { kinds: [NUTZAP_INFO_KIND], authors: [pubkey], limit: 1, }, ], { signal }); if (events.length === 0) return null; return parseNutzapInfo(events[0]); }, enabled: !!pubkey, staleTime: 1000 * 60 * 5, // 5 minutes }); } /** * Get nutzaps received by a user or for a specific event */ export function useNutzaps(params: { pubkey?: string; eventId?: string }) { const { nostr } = useNostr(); return useQuery({ queryKey: ['nutzaps', params.pubkey, params.eventId], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const filters: Parameters[0] = []; if (params.eventId) { filters.push({ kinds: [NUTZAP_KIND], '#e': [params.eventId], limit: 100, }); } else if (params.pubkey) { filters.push({ kinds: [NUTZAP_KIND], '#p': [params.pubkey], limit: 100, }); } if (filters.length === 0) return []; const events = await nostr.query(filters, { signal }); const nutzaps: Nutzap[] = []; for (const event of events) { const nutzap = parseNutzap(event); if (nutzap) { nutzaps.push(nutzap); } } return nutzaps.sort((a, b) => b.createdAt - a.createdAt); }, enabled: !!(params.pubkey || params.eventId), staleTime: 1000 * 30, // 30 seconds }); } /** * Check if the current user can receive nutzaps */ export function useCanReceiveNutzaps() { const { user } = useCurrentUser(); const { data: nutzapInfo } = useNutzapInfo(user?.pubkey); return { canReceive: !!(nutzapInfo && nutzapInfo.mints.length > 0 && nutzapInfo.p2pkPubkey), nutzapInfo, }; } /** * Setup nutzap receiving (publish kind:10019) */ export function useSetupNutzapReceiving() { const { t } = useTranslation(); const { user } = useCurrentUser(); const { mutateAsync: publishEvent } = useNostrPublish(); const { toast } = useToast(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ mints, relays, p2pkPubkey, }: { mints: Array<{ url: string; units?: string[] }>; relays: string[]; p2pkPubkey: string; }) => { if (!user) throw new Error('Must be logged in'); const tags: string[][] = []; // Add relays for (const relay of relays) { tags.push(['relay', relay]); } // Add mints with units for (const mint of mints) { const mintTag = ['mint', mint.url, ...(mint.units || ['sat'])]; tags.push(mintTag); } // Add P2PK pubkey (MUST be different from main Nostr pubkey) tags.push(['pubkey', p2pkPubkey]); await publishEvent({ kind: NUTZAP_INFO_KIND, content: '', tags, }); return { mints, relays, p2pkPubkey }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['nutzap-info'] }); toast({ title: t('toastsUi.nutzapEnabled'), description: t('toastsUi.nutzapEnabledDesc'), }); }, onError: (error) => { toast({ title: t('toastsUi.nutzapSetupFailed'), description: error instanceof Error ? error.message : 'Failed to setup nutzap receiving', variant: 'destructive', }); }, }); } /** * Whether in-app nutzap SENDING is available. * * Nutzap sending (NIP-61) requires minting Cashu proofs that are P2PK-locked to * the recipient's `p2pkPubkey` plus DLEQ proofs. Getting P2PK locking wrong would * lock funds to the wrong key — i.e. lost sats — so sending is intentionally * DISABLED until it can be implemented and audited against the wallet's P2PK * capabilities. Nutzap RECEIVING setup (kind 10019) and reading received nutzaps * work today. * * UI should gate any "send nutzap" affordance behind this flag and fall back to * regular Lightning zaps (which support paying from the Cashu balance anyway). */ export const NUTZAP_SENDING_ENABLED = false; /** * Send a nutzap to someone. * * NOT YET IMPLEMENTED — see {@link NUTZAP_SENDING_ENABLED}. This mutation always * rejects with a clear message so nothing silently appears to succeed. Do not * wire a visible "send" button to this until sending is implemented; use the * regular Lightning zap flow (ZapDialog), which can pay from the Cashu balance. * * Full implementation would require: * 1. Check for tokens on one of the recipient's mints (swap in if needed) * 2. Mint/swap proofs P2PK-locked to recipient.p2pkPubkey * 3. Include DLEQ proofs * 4. Publish kind:9321 with `proof`, `u` (mint), `p` (recipient), optional `e` */ export function useSendNutzap() { const { t } = useTranslation(); const { user } = useCurrentUser(); const { toast } = useToast(); const queryClient = useQueryClient(); return useMutation({ mutationFn: async (_params: { recipientPubkey: string; recipientNutzapInfo: NutzapInfo; eventId?: string; eventKind?: number; amount: number; comment?: string; }) => { if (!user) throw new Error('Must be logged in'); // Guarded off to avoid locking funds to the wrong key. See NUTZAP_SENDING_ENABLED. throw new Error( 'Nutzap sending is not available yet — use a regular Lightning zap (you can pay it straight from your Cashu balance).' ); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['nutzaps'] }); }, onError: (error) => { toast({ title: t('toastsUi.nutzapUnavailable'), description: error instanceof Error ? error.message : 'Use a Lightning zap instead', variant: 'destructive', }); }, }); } /** * Get total nutzaps received for an event */ export function useNutzapTotal(eventId: string | undefined) { const { data: nutzaps } = useNutzaps({ eventId }); if (!nutzaps) { return { count: 0, totalAmount: 0 }; } return { count: nutzaps.length, totalAmount: nutzaps.reduce((sum, nz) => sum + nz.amount, 0), }; }