messy, nwc support +_wallet fixes

This commit is contained in:
Chad Curtis 2025-07-13 17:53:30 +00:00
parent 11ac73776c
commit 45ff37e752
10 changed files with 594 additions and 284 deletions

54
package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "mkstack", "name": "mkstack",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1", "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5", "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",
@ -993,6 +995,45 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/inter": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.6.tgz",
"integrity": "sha512-jks/bficUPQ9nn7GvXvHtlQIPudW7Wx8CrlZoY8bhxgeobNxlQan8DclUJuYF2loYRrGpfrhCIZZspXYysiVGg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@getalby/lightning-tools": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz",
"integrity": "sha512-8kBvENBTMh541VjGKhw3I29+549/C02gLSh3AQaMfoMNSZaMxfQW+7dcMcc7vbFaCKEcEe18ST5bUveTRBuXCQ==",
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "lightning",
"url": "lightning:hello@getalby.com"
}
},
"node_modules/@getalby/sdk": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-5.1.1.tgz",
"integrity": "sha512-t/kg2ljPx86qRYKqEVc5VYhDICFKtVPRlQKIz5cI/AqOLYVguLJz1AkQlDBaiOz2PW5FxoyGlLkTGmX7ONHH/Q==",
"license": "MIT",
"dependencies": {
"@getalby/lightning-tools": "^5.1.2",
"nostr-tools": "2.15.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "lightning",
"url": "lightning:hello@getalby.com"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@ -6147,9 +6188,9 @@
} }
}, },
"node_modules/nostr-tools": { "node_modules/nostr-tools": {
"version": "2.13.0", "version": "2.15.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.0.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.0.tgz",
"integrity": "sha512-A1arGsvpULqVK0NmZQqK1imwaCiPm8gcG/lo+cTax2NbNqBEYsuplbqAFdVqcGHEopmkByYbTwF76x25+oEbew==", "integrity": "sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",
@ -6157,9 +6198,7 @@
"@noble/hashes": "1.3.1", "@noble/hashes": "1.3.1",
"@scure/base": "1.1.1", "@scure/base": "1.1.1",
"@scure/bip32": "1.3.1", "@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1",
},
"optionalDependencies": {
"nostr-wasm": "0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -6175,8 +6214,7 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/nwsapi": { "node_modules/nwsapi": {
"version": "2.2.20", "version": "2.2.20",

View File

@ -10,6 +10,8 @@
"deploy": "npm run build && npx -y nostr-deploy-cli deploy --skip-setup" "deploy": "npm run build && npx -y nostr-deploy-cli deploy --skip-setup"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1", "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5", "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Wallet, Plus, Trash2, Zap, Globe, Settings, CheckCircle } from 'lucide-react'; import { Wallet, Plus, Trash2, Zap, Globe, Settings, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -15,7 +15,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useNWC } from '@/hooks/useNWC'; import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet'; import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@ -40,9 +40,23 @@ export function WalletModal({ children, className }: WalletModalProps) {
setActiveConnection setActiveConnection
} = useNWC(); } = useNWC();
const { hasWebLN, hasNWC, isDetecting } = useWallet(); const { hasWebLN, isDetecting } = useWallet();
// Calculate hasNWC directly from connections to ensure reactivity
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast(); const { toast } = useToast();
// Debug logging for wallet modal status
useEffect(() => {
console.debug('WalletModal status:', {
hasWebLN,
hasNWC,
connectionsCount: connections.length,
connectionsDetails: connections.map(c => ({ alias: c.alias, isConnected: c.isConnected })),
isDetecting
});
}, [hasWebLN, hasNWC, connections, isDetecting]);
const handleAddConnection = async () => { const handleAddConnection = async () => {
if (!connectionUri.trim()) { if (!connectionUri.trim()) {
toast({ toast({
@ -53,33 +67,61 @@ export function WalletModal({ children, className }: WalletModalProps) {
return; return;
} }
console.debug('WalletModal: Before adding connection', {
currentConnections: connections.length,
hasNWC
});
setIsConnecting(true); setIsConnecting(true);
try { try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined); const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) { if (success) {
console.debug('WalletModal: Connection added successfully', {
newConnections: connections.length,
hasNWC
});
setConnectionUri(''); setConnectionUri('');
setAlias(''); setAlias('');
setAddDialogOpen(false); setAddDialogOpen(false);
// Force a small delay to check state after React updates
setTimeout(() => {
console.debug('WalletModal: Post-add state check', {
connectionsLength: connections.length,
hasNWC
});
}, 100);
} }
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
}; };
const handleRemoveConnection = (walletPubkey: string) => { const handleRemoveConnection = (connectionString: string) => {
removeConnection(walletPubkey); console.debug('WalletModal: Before removing connection', {
currentConnections: connections.length,
hasNWC
});
removeConnection(connectionString);
// Force a small delay to check state after React updates
setTimeout(() => {
console.debug('WalletModal: Post-remove state check', {
connectionsLength: connections.length,
hasNWC
});
}, 100);
}; };
const handleSetActive = (walletPubkey: string) => { const handleSetActive = (connectionString: string) => {
setActiveConnection(walletPubkey); setActiveConnection(connectionString);
toast({ toast({
title: 'Active wallet changed', title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.', description: 'The selected wallet is now active for zaps.',
}); });
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@ -208,11 +250,11 @@ export function WalletModal({ children, className }: WalletModalProps) {
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{connections.map((connection) => { {connections.map((connection) => {
const info = connectionInfo[connection.walletPubkey]; const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.walletPubkey; const isActive = activeConnection === connection.connectionString;
return ( return (
<div key={connection.walletPubkey} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary' : ''}`}> <div key={connection.connectionString} className={`flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary' : ''}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Settings className="h-4 w-4 text-muted-foreground" /> <Settings className="h-4 w-4 text-muted-foreground" />
<div> <div>
@ -220,7 +262,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
{connection.alias || info?.alias || 'Lightning Wallet'} {connection.alias || info?.alias || 'Lightning Wallet'}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{connection.walletPubkey.slice(0, 16)}... NWC Connection
</p> </p>
</div> </div>
</div> </div>
@ -230,7 +272,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleSetActive(connection.walletPubkey)} onClick={() => handleSetActive(connection.connectionString)}
> >
<Zap className="h-3 w-3" /> <Zap className="h-3 w-3" />
</Button> </Button>
@ -238,7 +280,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => handleRemoveConnection(connection.walletPubkey)} onClick={() => handleRemoveConnection(connection.connectionString)}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>

View File

@ -0,0 +1,47 @@
import { ZapDialog } from '@/components/ZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import type { Event } from 'nostr-tools';
interface ZapButtonProps {
target: Event;
className?: string;
showCount?: boolean;
}
export function ZapButton({ target, className = "text-xs ml-1", showCount = true }: ZapButtonProps) {
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { webln, activeNWC } = useWallet();
const { zaps } = useZaps(target, webln, activeNWC);
// Don't show zap button if user is not logged in, is the author, or author has no lightning address
if (!user || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) {
return null;
}
const zapCount = zaps?.length || 0;
const totalSats = zaps?.reduce((total, zap) => {
// Extract amount from amount tag
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) {
return total + Math.floor(parseInt(amountTag) / 1000); // Convert millisats to sats
}
// If no amount tag, don't count towards total
return total;
}, 0) || 0;
return (
<ZapDialog target={target}>
{showCount && zapCount > 0 && (
<span className={className}>
{totalSats > 0 ? `${totalSats.toLocaleString()}` : zapCount}
</span>
)}
</ZapDialog>
);
}

View File

@ -41,6 +41,23 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
const { data: author } = useAuthor(target.pubkey); const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast(); const { toast } = useToast();
const { webln, activeNWC, hasWebLN, hasNWC, detectWebLN } = useWallet(); const { webln, activeNWC, hasWebLN, hasNWC, detectWebLN } = useWallet();
// Debug logging
useEffect(() => {
console.debug('ZapDialog wallet status:', { hasWebLN, hasNWC, activeNWC: !!activeNWC });
}, [hasWebLN, hasNWC, activeNWC]);
// Additional debug logging when dialog opens
useEffect(() => {
if (open) {
console.debug('ZapDialog opened with wallet status:', {
hasWebLN,
hasNWC,
activeNWC: activeNWC ? { alias: activeNWC.alias, isConnected: activeNWC.isConnected } : null
});
}
}, [open, hasWebLN, hasNWC, activeNWC]);
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false)); const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState<number | string>(100); const [amount, setAmount] = useState<number | string>(100);
const [comment, setComment] = useState<string>(''); const [comment, setComment] = useState<string>('');

View File

@ -0,0 +1,8 @@
import { ReactNode } from 'react';
import { useNWCInternal as useNWCHook } from '@/hooks/useNWC';
import { NWCContext } from '@/hooks/useNWCContext';
export function NWCProvider({ children }: { children: ReactNode }) {
const nwc = useNWCHook();
return <NWCContext.Provider value={nwc}>{children}</NWCContext.Provider>;
}

View File

@ -1,16 +1,13 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { LN } from '@getalby/sdk';
import { nip04 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
export interface NWCConnection { export interface NWCConnection {
walletPubkey: string; connectionString: string;
secret: string;
relayUrls: string[];
lud16?: string;
alias?: string; alias?: string;
isConnected: boolean;
client?: LN;
} }
interface NWCInfo { interface NWCInfo {
@ -22,46 +19,37 @@ interface NWCInfo {
notifications?: string[]; notifications?: string[];
} }
export function useNWC() { export function useNWCInternal() {
const { nostr } = useNostr();
const { toast } = useToast(); const { toast } = useToast();
const { user } = useCurrentUser();
const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []); const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []);
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null); const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null);
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({}); const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
// Parse NWC URI // Use connections directly - no filtering needed
const parseNWCUri = (uri: string): NWCConnection | null => {
// Parse and validate NWC URI
const parseNWCUri = (uri: string): { connectionString: string } | null => {
try { try {
const url = new URL(uri); console.debug('Parsing NWC URI:', { uri: uri.substring(0, 50) + '...' });
if (url.protocol !== 'nostr+walletconnect:') {
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
return null; return null;
} }
const walletPubkey = url.pathname.replace('//', ''); // Basic validation - let the SDK handle the detailed parsing
const secret = url.searchParams.get('secret'); console.debug('NWC URI parsing successful');
const relayParam = url.searchParams.getAll('relay'); return { connectionString: uri };
const lud16 = url.searchParams.get('lud16') || undefined; } catch (error) {
console.error('Failed to parse NWC URI:', error);
if (!walletPubkey || !secret || relayParam.length === 0) {
return null;
}
return {
walletPubkey,
secret,
relayUrls: relayParam,
lud16,
};
} catch {
return null; return null;
} }
}; };
// Add new connection // Add new connection
const addConnection = async (uri: string, alias?: string): Promise<boolean> => { const addConnection = async (uri: string, alias?: string): Promise<boolean> => {
const connection = parseNWCUri(uri); const parsed = parseNWCUri(uri);
if (!connection) { if (!parsed) {
toast({ toast({
title: 'Invalid NWC URI', title: 'Invalid NWC URI',
description: 'Please check the connection string and try again.', description: 'Please check the connection string and try again.',
@ -71,7 +59,7 @@ export function useNWC() {
} }
// Check if connection already exists // Check if connection already exists
const existingConnection = connections.find(c => c.walletPubkey === connection.walletPubkey); const existingConnection = connections.find(c => c.connectionString === parsed.connectionString);
if (existingConnection) { if (existingConnection) {
toast({ toast({
title: 'Connection already exists', title: 'Connection already exists',
@ -81,31 +69,85 @@ export function useNWC() {
return false; return false;
} }
if (alias) {
connection.alias = alias;
}
try { try {
// Test connection by fetching info console.debug('Testing NWC connection:', { uri: uri.substring(0, 50) + '...' });
await fetchWalletInfo(connection);
setConnections(prev => [...prev, connection]); // Test the connection by creating an LN client with timeout
const testPromise = new Promise((resolve, reject) => {
try {
const client = new LN(parsed.connectionString);
resolve(client);
} catch (error) {
reject(error);
}
});
// Set as active if it's the first connection const timeoutPromise = new Promise((_, reject) => {
if (connections.length === 0) { setTimeout(() => reject(new Error('Connection test timeout')), 10000);
setActiveConnection(connection.walletPubkey); });
const _client = await Promise.race([testPromise, timeoutPromise]) as LN;
const connection: NWCConnection = {
connectionString: parsed.connectionString,
alias: alias || 'NWC Wallet',
isConnected: true,
// Don't store the client, create fresh ones for each payment
};
// Store basic connection info
setConnectionInfo(prev => ({
...prev,
[parsed.connectionString]: {
alias: connection.alias,
methods: ['pay_invoice'], // Assume basic payment capability
},
}));
const newConnections = [...connections, connection];
setConnections(newConnections);
console.debug('NWC connection added:', {
alias: connection.alias,
totalConnections: newConnections.length,
connectionString: parsed.connectionString.substring(0, 50) + '...',
isConnected: connection.isConnected
});
// Set as active if it's the first connection or no active connection is set
if (connections.length === 0 || !activeConnection) {
console.debug('Setting as active connection:', {
alias: connection.alias,
connectionString: parsed.connectionString.substring(0, 50) + '...',
previousActiveConnection: activeConnection
});
setActiveConnection(parsed.connectionString);
console.debug('Active connection set to:', parsed.connectionString.substring(0, 50) + '...');
} }
console.debug('NWC connection successful');
// Force a small delay to ensure state updates are processed
setTimeout(() => {
console.debug('Post-connection state check:', {
connectionsLength: connections.length + 1, // +1 because we just added one
newConnectionAlias: connection.alias
});
}, 100);
toast({ toast({
title: 'Wallet connected', title: 'Wallet connected',
description: `Successfully connected to ${alias || 'wallet'}.`, description: `Successfully connected to ${connection.alias}.`,
}); });
return true; return true;
} catch { } catch (error) {
console.error('NWC connection failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
toast({ toast({
title: 'Connection failed', title: 'Connection failed',
description: 'Could not connect to the wallet. Please check your connection.', description: `Could not connect to the wallet: ${errorMessage}`,
variant: 'destructive', variant: 'destructive',
}); });
return false; return false;
@ -113,17 +155,23 @@ export function useNWC() {
}; };
// Remove connection // Remove connection
const removeConnection = (walletPubkey: string) => { const removeConnection = (connectionString: string) => {
setConnections(prev => prev.filter(c => c.walletPubkey !== walletPubkey)); const filtered = connections.filter(c => c.connectionString !== connectionString);
setConnections(filtered);
if (activeConnection === walletPubkey) { console.debug('NWC connection removed:', {
const remaining = connections.filter(c => c.walletPubkey !== walletPubkey); remainingConnections: filtered.length
setActiveConnection(remaining.length > 0 ? remaining[0].walletPubkey : null); });
if (activeConnection === connectionString) {
const newActive = filtered.length > 0 ? filtered[0].connectionString : null;
setActiveConnection(newActive);
console.debug('Active connection changed:', { newActive });
} }
setConnectionInfo(prev => { setConnectionInfo(prev => {
const newInfo = { ...prev }; const newInfo = { ...prev };
delete newInfo[walletPubkey]; delete newInfo[connectionString];
return newInfo; return newInfo;
}); });
@ -134,156 +182,130 @@ export function useNWC() {
}; };
// Get active connection // Get active connection
const getActiveConnection = (): NWCConnection | null => { const getActiveConnection = useCallback((): NWCConnection | null => {
if (!activeConnection) return null; console.debug('getActiveConnection called:', {
return connections.find(c => c.walletPubkey === activeConnection) || null; activeConnection,
}; connectionsLength: connections.length,
connections: connections.map(c => ({ alias: c.alias, connectionString: c.connectionString.substring(0, 50) + '...' }))
});
// Send NWC request // If no active connection is set but we have connections, set the first one as active
const sendNWCRequest = useCallback(async ( if (!activeConnection && connections.length > 0) {
console.debug('Setting first connection as active:', connections[0].alias);
setActiveConnection(connections[0].connectionString);
return connections[0];
}
if (!activeConnection) {
console.debug('No active connection and no connections');
return null;
}
const found = connections.find(c => c.connectionString === activeConnection);
console.debug('Found active connection:', found ? found.alias : 'null');
return found || null;
}, [activeConnection, connections, setActiveConnection]);
// Send payment using the SDK
const sendPayment = useCallback(async (
connection: NWCConnection, connection: NWCConnection,
request: { method: string; params: Record<string, unknown> } invoice: string
): Promise<{ result_type: string; error?: { code: string; message: string }; result?: unknown }> => { ): Promise<{ preimage: string }> => {
if (!user?.signer) { if (!connection.connectionString) {
throw new Error('User not logged in or signer not available'); throw new Error('Invalid connection: missing connection string');
} }
// Create request event // Always create a fresh client for each payment to avoid stale connections
const requestEvent = { let client: LN;
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', connection.walletPubkey]],
content: await nip04.encrypt(connection.secret, connection.walletPubkey, JSON.stringify(request)),
};
// Sign and publish request
const signedRequest = await user.signer.signEvent(requestEvent);
if (!signedRequest) {
throw new Error('Failed to sign NWC request');
}
// Publish to NWC relays
try { try {
await nostr.event(signedRequest, { console.debug('Creating fresh NWC client for payment...');
signal: AbortSignal.timeout(10000), client = new LN(connection.connectionString);
relays: connection.relayUrls
});
} catch (error) { } catch (error) {
console.warn('Failed to publish NWC request:', error); console.error('Failed to create NWC client:', error);
throw new Error('Failed to publish NWC request'); throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
// Listen for response try {
return new Promise((resolve, reject) => { console.debug('Sending payment via NWC SDK:', {
const timeout = setTimeout(() => { invoice: invoice.substring(0, 50) + '...',
reject(new Error('NWC request timeout')); connectionAlias: connection.alias
}, 30000); // 30 second timeout });
// Query for response events // Add timeout to prevent hanging
const checkForResponse = async () => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Payment timeout after 30 seconds')), 30000);
});
const paymentPromise = client.pay(invoice);
const response = await Promise.race([paymentPromise, timeoutPromise]) as { preimage: string };
console.debug('Payment successful:', { preimage: response.preimage });
return response;
} catch (error) {
console.error('NWC payment failed:', error);
// Provide more specific error messages
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');
}
}, []);
// Get wallet info (simplified since SDK doesn't expose getInfo)
const getWalletInfo = useCallback(async (connection: NWCConnection): Promise<NWCInfo> => {
// Return stored info or basic fallback
const info = connectionInfo[connection.connectionString] || {
alias: connection.alias,
methods: ['pay_invoice'],
};
return info;
}, [connectionInfo]);
// Test NWC connection
const testNWCConnection = useCallback(async (connection: NWCConnection): Promise<boolean> => {
if (!connection.connectionString) {
console.error('NWC connection test failed: missing connection string');
return false;
}
try {
console.debug('Testing NWC connection...', { alias: connection.alias });
// Create a fresh client for testing
const testPromise = new Promise((resolve, reject) => {
try { try {
const responseEvents = await nostr.query([ const client = new LN(connection.connectionString);
{ resolve(client);
kinds: [23195],
authors: [connection.walletPubkey],
'#p': [user.pubkey],
'#e': [signedRequest.id],
since: Math.floor(Date.now() / 1000) - 60,
},
], { signal: AbortSignal.timeout(30000) });
for (const event of responseEvents) {
try {
const decrypted = await nip04.decrypt(
connection.secret,
connection.walletPubkey,
event.content
);
const response = JSON.parse(decrypted);
clearTimeout(timeout);
resolve(response);
return;
} catch (error) {
console.error('Failed to decrypt NWC response:', error);
}
}
// If no response found, wait and try again
setTimeout(checkForResponse, 2000);
} catch (error) { } catch (error) {
clearTimeout(timeout);
reject(error); reject(error);
} }
}; });
// Start checking for responses const timeoutPromise = new Promise((_, reject) => {
setTimeout(checkForResponse, 1000); // Wait 1 second before first check setTimeout(() => reject(new Error('Connection test timeout')), 5000);
}); });
}, [nostr, user]);
// Fetch wallet info await Promise.race([testPromise, timeoutPromise]);
const fetchWalletInfo = useCallback(async (connection: NWCConnection): Promise<NWCInfo> => {
// First, try to get the info event (kind 13194)
try {
const infoEvents = await nostr.query([
{
kinds: [13194],
authors: [connection.walletPubkey],
limit: 1,
}
], { signal: AbortSignal.timeout(5000) });
if (infoEvents.length > 0) { console.debug('NWC connection test successful');
const infoEvent = infoEvents[0]; return true;
const capabilities = infoEvent.content.split(' ');
const notificationsTag = infoEvent.tags.find(tag => tag[0] === 'notifications');
const notifications = notificationsTag ? notificationsTag[1].split(' ') : [];
const info: NWCInfo = {
methods: capabilities,
notifications,
};
setConnectionInfo(prev => ({
...prev,
[connection.walletPubkey]: info,
}));
return info;
}
} catch (error) { } catch (error) {
console.warn('Failed to fetch NWC info event:', error); console.error('NWC connection test failed:', error);
return false;
} }
}, []);
// Fallback: try to send a get_info request
try {
const response = await sendNWCRequest(connection, { method: 'get_info', params: {} });
if (response.error) {
throw new Error(response.error.message);
}
const info = response.result as NWCInfo;
setConnectionInfo(prev => ({
...prev,
[connection.walletPubkey]: info,
}));
return info;
} catch (error) {
console.error('Failed to fetch wallet info:', error);
throw error;
}
}, [nostr, sendNWCRequest]);
// Fetch info for all connections on mount
useEffect(() => {
connections.forEach(connection => {
if (!connectionInfo[connection.walletPubkey]) {
fetchWalletInfo(connection).catch(console.error);
}
});
}, [connections, connectionInfo, fetchWalletInfo]);
return { return {
connections, connections,
@ -293,8 +315,9 @@ export function useNWC() {
removeConnection, removeConnection,
setActiveConnection, setActiveConnection,
getActiveConnection, getActiveConnection,
fetchWalletInfo, sendPayment,
sendNWCRequest, getWalletInfo,
parseNWCUri, parseNWCUri,
testNWCConnection,
}; };
} }

View File

@ -0,0 +1,15 @@
import { useContext } from 'react';
import { createContext } from 'react';
import { useNWCInternal } from '@/hooks/useNWC';
type NWCContextType = ReturnType<typeof useNWCInternal>;
export const NWCContext = createContext<NWCContextType | null>(null);
export function useNWC(): NWCContextType {
const context = useContext(NWCContext);
if (!context) {
throw new Error('useNWC must be used within a NWCProvider');
}
return context;
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNWC } from '@/hooks/useNWC'; import { useNWC } from '@/hooks/useNWCContext';
import type { WebLNProvider } from 'webln'; import type { WebLNProvider } from 'webln';
import { requestProvider } from 'webln'; import { requestProvider } from 'webln';
@ -15,37 +15,46 @@ export interface WalletStatus {
export function useWallet() { export function useWallet() {
const [webln, setWebln] = useState<WebLNProvider | null>(null); const [webln, setWebln] = useState<WebLNProvider | null>(null);
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const { getActiveConnection } = useNWC(); const [hasAttemptedDetection, setHasAttemptedDetection] = useState(false);
const { connections, getActiveConnection } = useNWC();
// Get the active connection directly - no memoization to avoid stale state
const activeNWC = getActiveConnection(); const activeNWC = getActiveConnection();
// Detect WebLN // Detect WebLN
const detectWebLN = useCallback(async () => { const detectWebLN = useCallback(async () => {
if (webln || isDetecting) return webln; if (webln || isDetecting) return webln;
setIsDetecting(true); setIsDetecting(true);
try { try {
const provider = await requestProvider(); const provider = await requestProvider();
setWebln(provider); setWebln(provider);
setHasAttemptedDetection(true);
return provider; return provider;
} catch (error) { } catch (error) {
console.warn('WebLN not available:', error); // Only log the error if it's not the common "no provider" error
if (error instanceof Error && !error.message.includes('no WebLN provider')) {
console.warn('WebLN detection error:', error);
}
setWebln(null); setWebln(null);
setHasAttemptedDetection(true);
return null; return null;
} finally { } finally {
setIsDetecting(false); setIsDetecting(false);
} }
}, [webln, isDetecting]); }, [webln, isDetecting]);
// Auto-detect on mount // Only auto-detect once on mount, don't spam detection
useEffect(() => { useEffect(() => {
detectWebLN(); if (!hasAttemptedDetection) {
}, [detectWebLN]); detectWebLN();
}
}, [detectWebLN, hasAttemptedDetection]);
// Test WebLN connection // Test WebLN connection
const testWebLN = useCallback(async (): Promise<boolean> => { const testWebLN = useCallback(async (): Promise<boolean> => {
if (!webln) return false; if (!webln) return false;
try { try {
await webln.enable(); await webln.enable();
return true; return true;
@ -55,24 +64,41 @@ export function useWallet() {
} }
}, [webln]); }, [webln]);
// Calculate status values reactively
const hasNWC = useMemo(() => {
return connections.length > 0 && connections.some(c => c.isConnected);
}, [connections]);
// Determine preferred payment method // Determine preferred payment method
const preferredMethod: WalletStatus['preferredMethod'] = activeNWC const preferredMethod: WalletStatus['preferredMethod'] = activeNWC
? 'nwc' ? 'nwc'
: webln : webln
? 'webln' ? 'webln'
: 'manual'; : 'manual';
const status: WalletStatus = { const status: WalletStatus = {
hasWebLN: !!webln, hasWebLN: !!webln,
hasNWC: !!activeNWC, hasNWC,
webln, webln,
activeNWC, activeNWC,
isDetecting, isDetecting,
preferredMethod, preferredMethod,
}; };
// Debug logging for wallet status changes
useEffect(() => {
console.debug('Wallet status updated:', {
hasWebLN: status.hasWebLN,
hasNWC: status.hasNWC,
connectionsCount: connections.length,
activeNWC: !!status.activeNWC,
preferredMethod: status.preferredMethod
});
}, [status.hasWebLN, status.hasNWC, connections.length, status.activeNWC, status.preferredMethod]);
return { return {
...status, ...status,
hasAttemptedDetection,
detectWebLN, detectWebLN,
testWebLN, testWebLN,
}; };

View File

@ -3,12 +3,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWC'; import { useNWC } from '@/hooks/useNWCContext';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import type { NWCConnection } from '@/hooks/useNWC'; import type { NWCConnection } from '@/hooks/useNWC';
import { nip57, nip19 } from 'nostr-tools'; import { nip57, nip19 } from 'nostr-tools';
import type { Event } from 'nostr-tools'; import type { Event } from 'nostr-tools';
import type { WebLNProvider } from 'webln'; import type { WebLNProvider } from 'webln';
import { LNURL } from '@nostrify/nostrify/ln';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
@ -24,30 +24,30 @@ function parseNWCUri(uri: string): NWCConnection | null {
const walletPubkey = url.pathname.replace('//', ''); const walletPubkey = url.pathname.replace('//', '');
const secret = url.searchParams.get('secret'); const secret = url.searchParams.get('secret');
const relayParam = url.searchParams.getAll('relay'); const relayParam = url.searchParams.getAll('relay');
const lud16 = url.searchParams.get('lud16') || undefined; const _lud16 = url.searchParams.get('lud16') || undefined;
if (!walletPubkey || !secret || relayParam.length === 0) { if (!walletPubkey || !secret || relayParam.length === 0) {
return null; return null;
} }
return { return {
walletPubkey, connectionString: uri,
secret, alias: 'Parsed NWC',
relayUrls: relayParam, isConnected: false,
lud16,
}; };
} catch { } catch {
return null; return null;
} }
} }
export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnection: NWCConnection | null, onZapSuccess?: () => void) { export function useZaps(target: Event, webln: WebLNProvider | null, _nwcConnection: NWCConnection | null, onZapSuccess?: () => void) {
const { nostr } = useNostr(); const { nostr } = useNostr();
const { toast } = useToast(); const { toast } = useToast();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { config } = useAppContext(); const { config } = useAppContext();
const { mutate: publishEvent } = useNostrPublish();
const author = useAuthor(target?.pubkey); const author = useAuthor(target?.pubkey);
const { sendNWCRequest } = useNWC(); const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false); const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null); const [invoice, setInvoice] = useState<string | null>(null);
@ -104,6 +104,7 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio
} }
setIsZapping(true); setIsZapping(true);
setInvoice(null); // Clear any previous invoice at the start
if (!user) { if (!user) {
toast({ toast({
@ -126,74 +127,166 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio
return; return;
} }
const { lud06, lud16 } = author.data.metadata; const { lud16 } = author.data.metadata;
if (!lud16 && !lud06) { if (!lud16) {
toast({ toast({
title: 'Lightning address not found', title: 'Lightning address not found',
description: 'The author does not have a lightning address (lud16 or lud06) configured.', description: 'The author does not have a lightning address configured.',
variant: 'destructive', variant: 'destructive',
}); });
setIsZapping(false); setIsZapping(false);
return; return;
} }
const lnurl = lud06 ? LNURL.fromString(lud06) : LNURL.fromLightningAddress(lud16!); // Get zap endpoint using the old reliable method
const zapAmount = amount * 1000; // convert to millisats const zapEndpoint = await nip57.getZapEndpoint(author.data.event as Event);
const zapRequest = await user.signer.signEvent(nip57.makeZapRequest({ if (!zapEndpoint) {
profile: target.pubkey, toast({
event: target, title: 'Zap endpoint not found',
amount: zapAmount, description: 'Could not find a zap endpoint for the author.',
relays: [config.relayUrl], variant: 'destructive',
comment: comment, });
})); setIsZapping(false);
return;
}
const { pr: newInvoice } = await lnurl.getInvoice({ const zapAmount = amount * 1000; // convert to millisats
const relays = [config.relayUrl];
// Create zap request (unsigned, like the old implementation)
const zapRequest = nip57.makeZapRequest({
profile: target.pubkey,
event: target.id,
amount: zapAmount, amount: zapAmount,
nostr: zapRequest, relays,
comment: comment,
}); });
// Try NWC first if available // Handle addressable events (restored from old implementation)
if (nwcConnection) { if (naddr) {
try { const decoded = nip19.decode(naddr).data as nip19.AddressPointer;
const response = await sendNWCRequest(nwcConnection, { zapRequest.tags.push(["a", `${decoded.kind}:${decoded.pubkey}:${decoded.identifier}`]);
method: 'pay_invoice', zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e');
params: { }
invoice: newInvoice,
amount: zapAmount,
},
});
if (response.error) { // Sign and publish the zap request
throw new Error(`NWC Error: ${response.error.message}`); publishEvent(zapRequest, {
onSuccess: async (event) => {
try {
// Use the old fetch method - more reliable than LNURL validation
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(event))}`);
const responseData = await res.json();
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
}
const newInvoice = responseData.pr;
if (!newInvoice || typeof newInvoice !== 'string') {
throw new Error('Lightning service did not return a valid invoice');
}
// Get the current active NWC connection dynamically
const currentNWCConnection = getActiveConnection();
console.debug('Zap payment - detailed state check:', {
// Raw state
connectionsLength: connections.length,
activeConnectionString: activeConnection ? activeConnection.substring(0, 50) + '...' : null,
// Connection details
connections: connections.map(c => ({
alias: c.alias,
isConnected: c.isConnected,
connectionString: c.connectionString.substring(0, 50) + '...'
})),
// getActiveConnection result
currentNWCConnection: currentNWCConnection ? {
alias: currentNWCConnection.alias,
isConnected: currentNWCConnection.isConnected,
connectionString: currentNWCConnection.connectionString.substring(0, 50) + '...'
} : null
});
// Try NWC first if available and properly connected
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
try {
console.debug('Attempting NWC payment...', {
amount,
alias: currentNWCConnection.alias,
invoiceLength: newInvoice.length
});
const response = await sendPayment(currentNWCConnection, newInvoice);
console.debug('NWC payment successful:', { preimage: response.preimage });
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats via NWC to the author.`,
});
// Close dialog last to ensure clean state
onZapSuccess?.();
return;
} catch (nwcError) {
console.error('NWC payment failed, falling back:', nwcError);
// Show specific NWC error to user for debugging
const errorMessage = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error';
toast({
title: 'NWC payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
}
}
// Fallback to WebLN or manual payment
if (webln) {
await webln.sendPayment(newInvoice);
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats to the author.`,
});
// Close dialog last to ensure clean state
onZapSuccess?.();
} else {
setInvoice(newInvoice);
setIsZapping(false);
}
} catch (err) {
console.error('Zap error:', err);
toast({
title: 'Zap failed',
description: (err as Error).message,
variant: 'destructive',
});
} finally {
setIsZapping(false);
} }
},
onError: (err) => {
console.error('Failed to publish zap request:', err);
toast({ toast({
title: 'Zap successful!', title: 'Zap failed',
description: `You sent ${amount} sats via NWC to the author.`, description: 'Failed to create zap request',
});
onZapSuccess?.();
return;
} catch (nwcError) {
console.error('NWC payment failed, falling back:', nwcError);
toast({
title: 'NWC payment failed',
description: 'Falling back to manual payment...',
variant: 'destructive', variant: 'destructive',
}); });
} setIsZapping(false);
} },
});
// Fallback to WebLN or manual payment
if (webln) {
await webln.sendPayment(newInvoice);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats to the author.`,
});
onZapSuccess?.();
} else {
setInvoice(newInvoice);
}
} catch (err) { } catch (err) {
console.error('Zap error:', err); console.error('Zap error:', err);
toast({ toast({
@ -201,7 +294,6 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio
description: (err as Error).message, description: (err as Error).message,
variant: 'destructive', variant: 'destructive',
}); });
} finally {
setIsZapping(false); setIsZapping(false);
} }
}; };