import { useState, useCallback } from 'react'; import { useNostr } from '@nostrify/react'; import { useNWC } from '@/hooks/useNWCContext'; import { useWallet } from '@/hooks/useWallet'; import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress'; import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import type { NostrMetadata } from '@nostrify/nostrify'; export type CrewZapStatus = 'pending' | 'resolving' | 'paying' | 'success' | 'skipped' | 'failed'; export interface CrewZapResult { pubkey: string; amount: number; status: CrewZapStatus; /** Lightning address used (if resolved). */ address?: string; /** Invoice to pay manually if auto-pay unavailable. */ invoice?: string; error?: string; } /** Split `total` sats as evenly as possible across `count` recipients. */ export function splitAmount(total: number, count: number): number[] { if (count <= 0) return []; const base = Math.floor(total / count); let remainder = total - base * count; return Array.from({ length: count }, () => { const extra = remainder > 0 ? 1 : 0; if (remainder > 0) remainder--; return base + extra; }); } /** * Zap a whole crew: split an amount across the given recipient pubkeys and pay * each recipient's Lightning address. Uses the connected wallet (NWC → WebLN). * Recipients without a Lightning address are skipped. Recipients that can't be * auto-paid surface a manual invoice. */ export function useCrewZap() { const { nostr } = useNostr(); const { sendPayment, getActiveConnection } = useNWC(); const { webln } = useWallet(); const [results, setResults] = useState([]); const [isRunning, setIsRunning] = useState(false); const reset = useCallback(() => setResults([]), []); const zapCrew = useCallback( async (recipients: string[], totalAmount: number, comment: string) => { if (recipients.length === 0 || totalAmount <= 0) return; setIsRunning(true); const shares = splitAmount(totalAmount, recipients.length); // Seed initial state const initial: CrewZapResult[] = recipients.map((pubkey, i) => ({ pubkey, amount: shares[i], status: 'pending', })); setResults(initial); const update = (pubkey: string, patch: Partial) => setResults((prev) => prev.map((r) => (r.pubkey === pubkey ? { ...r, ...patch } : r))); // Fetch metadata (kind 0) for all recipients in one query let metaByPubkey = new Map(); try { const signal = AbortSignal.timeout(6000); const events = await nostr.query([{ kinds: [0], authors: recipients }], { signal }); // Latest kind-0 per author const latest = new Map(); for (const ev of events) { const existing = latest.get(ev.pubkey); if (!existing || ev.created_at > existing.created_at) { latest.set(ev.pubkey, { created_at: ev.created_at, content: ev.content }); } } metaByPubkey = new Map( [...latest.entries()].map(([pk, { content }]) => { try { return [pk, JSON.parse(content) as NostrMetadata]; } catch { return [pk, {} as NostrMetadata]; } }) ); } catch { // Continue — recipients without resolvable metadata will be skipped } const activeNWC = getActiveConnection(); // Pay each recipient sequentially (keeps NWC/WebLN happy, clear progress) for (let i = 0; i < recipients.length; i++) { const pubkey = recipients[i]; const amount = shares[i]; if (amount <= 0) { update(pubkey, { status: 'skipped', error: 'Share rounds to 0 sats' }); continue; } const meta = metaByPubkey.get(pubkey); const address = meta?.lud16; if (!address) { update(pubkey, { status: 'skipped', error: 'No Lightning address' }); continue; } update(pubkey, { status: 'resolving', address }); let invoice: string; try { invoice = await getInvoiceFromLightningAddress({ lightningAddress: address, amountSats: amount, comment, }); // SECURITY: never auto-pay an invoice whose amount doesn't match this // recipient's share — a hostile address server could inflate it. assertInvoiceAmount(invoice, amount); } catch (err) { update(pubkey, { status: 'failed', error: err instanceof Error ? err.message : 'Invoice failed' }); continue; } // Try to auto-pay update(pubkey, { status: 'paying', invoice }); let paid = false; if (activeNWC && activeNWC.connectionString && activeNWC.isConnected) { try { await sendPayment(activeNWC, invoice); paid = true; } catch { /* fall through to WebLN / manual */ } } if (!paid && webln) { try { let provider = webln; if (webln.enable && typeof webln.enable === 'function') { const enabled = (await webln.enable()) as typeof webln | undefined; if (enabled) provider = enabled; } await provider.sendPayment(invoice); paid = true; } catch { /* fall through to manual */ } } if (paid) { update(pubkey, { status: 'success' }); } else { // Leave invoice attached for manual payment update(pubkey, { status: 'failed', error: 'Auto-pay unavailable — pay manually', invoice }); } } setIsRunning(false); }, [nostr, sendPayment, getActiveConnection, webln] ); return { zapCrew, results, isRunning, reset }; }