import { useState, useCallback } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import type { LN } from '@getalby/sdk'; /** Marks a payment whose final status could NOT be confirmed after a timeout. */ export const PAYMENT_STATUS_UNKNOWN = 'PAYMENT_STATUS_UNKNOWN'; /** * Ask the wallet whether an invoice has settled. Used after a `pay()` timeout so * we never report "failed" (and let the caller retry on another rail) when the * payment may in fact have gone through. */ async function lookupInvoiceSettled( connectionString: string, invoice: string, ): Promise<{ settled: boolean; preimage?: string }> { try { const { nwc } = await import('@getalby/sdk'); const client = new nwc.NWCClient({ nostrWalletConnectUrl: connectionString }); try { const res = (await client.lookupInvoice({ invoice })) as | { state?: string; settled?: boolean; preimage?: string } | undefined; const state = String(res?.state ?? '').toLowerCase(); const settled = res?.settled === true || state === 'settled' || state === 'paid'; return { settled, preimage: res?.preimage }; } finally { try { client.close(); } catch { /* ignore */ } } } catch { return { settled: false }; } } export interface NWCConnection { connectionString: string; alias?: string; isConnected: boolean; client?: LN; } export interface NWCInfo { alias?: string; color?: string; pubkey?: string; network?: string; methods?: string[]; notifications?: string[]; } export function useNWCInternal() { const { t } = useTranslation(); const { toast } = useToast(); const [connections, setConnections] = useLocalStorage('nwc-connections', []); const [activeConnection, setActiveConnection] = useLocalStorage('nwc-active-connection', null); const [connectionInfo, setConnectionInfo] = useState>({}); // Add new connection const addConnection = async (uri: string, alias?: string): Promise => { const parseNWCUri = (uri: string): { connectionString: string } | null => { try { if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) { console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] }); return null; } return { connectionString: uri }; } catch (error) { console.error('Failed to parse NWC URI:', error); return null; } }; const parsed = parseNWCUri(uri); if (!parsed) { toast({ title: t('toastsUi.nwcInvalidUri'), description: t('toastsUi.nwcInvalidUriDesc'), variant: 'destructive', }); return false; } const existingConnection = connections.find(c => c.connectionString === parsed.connectionString); if (existingConnection) { toast({ title: t('toastsUi.nwcExists'), description: t('toastsUi.nwcExistsDesc'), variant: 'destructive', }); return false; } try { // Load the Getalby SDK on demand so it stays out of the initial bundle. const { LN } = await import('@getalby/sdk'); let timeoutId: NodeJS.Timeout | undefined; const testPromise = new Promise((resolve, reject) => { try { const client = new LN(parsed.connectionString); resolve(client); } catch (error) { reject(error); } }); const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Connection test timeout')), 10000); }); try { await Promise.race([testPromise, timeoutPromise]) as LN; if (timeoutId) clearTimeout(timeoutId); } catch (error) { if (timeoutId) clearTimeout(timeoutId); throw error; } const connection: NWCConnection = { connectionString: parsed.connectionString, alias: alias || 'NWC Wallet', isConnected: true, }; setConnectionInfo(prev => ({ ...prev, [parsed.connectionString]: { alias: connection.alias, methods: ['pay_invoice'], }, })); const newConnections = [...connections, connection]; setConnections(newConnections); if (connections.length === 0 || !activeConnection) setActiveConnection(parsed.connectionString); toast({ title: t('toastsUi.nwcConnected'), description: t('toastsUi.nwcConnectedDesc', { alias: connection.alias ?? '' }), }); return true; } catch (error) { console.error('NWC connection failed:', error); const errorMessage = error instanceof Error ? error.message : t('toastsUi.nwcUnknownError'); toast({ title: t('toastsUi.nwcFailed'), description: t('toastsUi.nwcFailedDesc', { error: errorMessage }), variant: 'destructive', }); return false; } }; // Remove connection const removeConnection = (connectionString: string) => { const filtered = connections.filter(c => c.connectionString !== connectionString); setConnections(filtered); if (activeConnection === connectionString) { const newActive = filtered.length > 0 ? filtered[0].connectionString : null; setActiveConnection(newActive); } setConnectionInfo(prev => { const newInfo = { ...prev }; delete newInfo[connectionString]; return newInfo; }); toast({ title: t('toastsUi.nwcDisconnected'), description: t('toastsUi.nwcDisconnectedDesc'), }); }; // Get active connection const getActiveConnection = useCallback((): NWCConnection | null => { if (!activeConnection && connections.length > 0) { setActiveConnection(connections[0].connectionString); return connections[0]; } if (!activeConnection) return null; const found = connections.find(c => c.connectionString === activeConnection); return found || null; }, [activeConnection, connections, setActiveConnection]); // Send payment using the SDK const sendPayment = useCallback(async ( connection: NWCConnection, invoice: string ): Promise<{ preimage: string }> => { if (!connection.connectionString) { throw new Error('Invalid connection: missing connection string'); } // Load the Getalby SDK on demand so it stays out of the initial bundle. const { LN } = await import('@getalby/sdk'); let client: LN; try { client = new LN(connection.connectionString); } catch (error) { console.error('Failed to create NWC client:', error); throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`); } try { let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15000); }); const paymentPromise = client.pay(invoice); try { const response = await Promise.race([paymentPromise, timeoutPromise]) as { preimage: string }; if (timeoutId) clearTimeout(timeoutId); return response; } catch (error) { if (timeoutId) clearTimeout(timeoutId); throw error; } } catch (error) { console.error('NWC payment failed:', error); if (error instanceof Error) { if (error.message.includes('timeout')) { // The wallet may still settle it after our 15s cap. Confirm before // reporting failure, so the caller never pays again on another rail. for (let attempt = 0; attempt < 3; attempt++) { const status = await lookupInvoiceSettled(connection.connectionString, invoice); if (status.settled) return { preimage: status.preimage ?? '' }; if (attempt < 2) await new Promise((r) => setTimeout(r, 3000)); } const unknown = new Error( 'Payment status unknown — it may have gone through. Check your wallet before trying again.', ) as Error & { code?: string }; unknown.code = PAYMENT_STATUS_UNKNOWN; throw unknown; } else if (error.message.includes('insufficient')) { throw new Error('Insufficient balance in connected wallet.'); } else if (error.message.includes('invalid')) { throw new Error('Invalid invoice or connection. Please check your wallet.'); } else { throw new Error(`Payment failed: ${error.message}`); } } throw new Error('Payment failed with unknown error'); } }, []); /** Read the connected wallet's balance, in sats (NWC reports millisats). */ const getBalance = useCallback(async (connection: NWCConnection): Promise => { if (!connection?.connectionString) throw new Error('Invalid connection: missing connection string'); const { nwc } = await import('@getalby/sdk'); const client = new nwc.NWCClient({ nostrWalletConnectUrl: connection.connectionString }); try { const res = await client.getBalance(); return Math.floor((res?.balance ?? 0) / 1000); } finally { try { client.close(); } catch { /* ignore */ } } }, []); /** Create a Lightning invoice with the connected wallet. Returns the bolt11. */ const makeInvoice = useCallback(async ( connection: NWCConnection, amountSats: number, description?: string, ): Promise => { if (!connection?.connectionString) throw new Error('Invalid connection: missing connection string'); if (!Number.isFinite(amountSats) || amountSats <= 0) throw new Error('Enter an amount greater than zero.'); const { nwc } = await import('@getalby/sdk'); const client = new nwc.NWCClient({ nostrWalletConnectUrl: connection.connectionString }); try { const tx = await client.makeInvoice({ amount: Math.round(amountSats * 1000), description }); if (!tx?.invoice) throw new Error('Wallet did not return an invoice.'); return tx.invoice; } finally { try { client.close(); } catch { /* ignore */ } } }, []); return { connections, activeConnection, connectionInfo, addConnection, removeConnection, setActiveConnection, getActiveConnection, sendPayment, getBalance, makeInvoice, }; }