SkateSpots/src/hooks/useNWC.ts
SkateSpots 78324605c1 feat(wallet): Lightning wallet over NWC (balance, receive, send)
The Wallet page was Cashu-only and said 'App only' on web, so anyone with an
NWC wallet connected saw nothing. Adds:
- useNWC.getBalance() (millisats -> sats) and makeInvoice() via NWCClient
- NwcWalletCard: live balance + refresh, Receive (create invoice, QR, copy),
  Send (paste invoice, decoded amount shown, pay via the connected wallet)
Renders only when an NWC connection is active.
2026-09-26 20:01:59 +07:00

259 lines
No EOL
8.4 KiB
TypeScript

import { useState, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast';
import type { LN } from '@getalby/sdk';
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 { toast } = useToast();
const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []);
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null);
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
// Add new connection
const addConnection = async (uri: string, alias?: string): Promise<boolean> => {
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: 'Invalid NWC URI',
description: 'Please check the connection string and try again.',
variant: 'destructive',
});
return false;
}
const existingConnection = connections.find(c => c.connectionString === parsed.connectionString);
if (existingConnection) {
toast({
title: 'Connection already exists',
description: 'This wallet is already connected.',
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<never>((_, 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: 'Wallet connected',
description: `Successfully connected to ${connection.alias}.`,
});
return true;
} catch (error) {
console.error('NWC connection failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
toast({
title: 'Connection failed',
description: `Could not connect to the wallet: ${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: 'Wallet disconnected',
description: 'The wallet connection has been removed.',
});
};
// 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<never>((_, 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')) {
throw new Error('Payment timed out. Please try again.');
} 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<number> => {
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<string> => {
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,
};
}