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",
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5",
@ -993,6 +995,45 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"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": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@ -6147,9 +6188,9 @@
}
},
"node_modules/nostr-tools": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.0.tgz",
"integrity": "sha512-A1arGsvpULqVK0NmZQqK1imwaCiPm8gcG/lo+cTax2NbNqBEYsuplbqAFdVqcGHEopmkByYbTwF76x25+oEbew==",
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.0.tgz",
"integrity": "sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
@ -6157,9 +6198,7 @@
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
@ -6175,8 +6214,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.20",

View File

@ -10,6 +10,8 @@
"deploy": "npm run build && npx -y nostr-deploy-cli deploy --skip-setup"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1",
"@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 { Button } from '@/components/ui/button';
import {
@ -15,7 +15,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useNWC } from '@/hooks/useNWC';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useToast } from '@/hooks/useToast';
@ -40,9 +40,23 @@ export function WalletModal({ children, className }: WalletModalProps) {
setActiveConnection
} = 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();
// 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 () => {
if (!connectionUri.trim()) {
toast({
@ -53,33 +67,61 @@ export function WalletModal({ children, className }: WalletModalProps) {
return;
}
console.debug('WalletModal: Before adding connection', {
currentConnections: connections.length,
hasNWC
});
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
console.debug('WalletModal: Connection added successfully', {
newConnections: connections.length,
hasNWC
});
setConnectionUri('');
setAlias('');
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 {
setIsConnecting(false);
}
};
const handleRemoveConnection = (walletPubkey: string) => {
removeConnection(walletPubkey);
const handleRemoveConnection = (connectionString: string) => {
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) => {
setActiveConnection(walletPubkey);
const handleSetActive = (connectionString: string) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@ -208,11 +250,11 @@ export function WalletModal({ children, className }: WalletModalProps) {
) : (
<div className="space-y-2">
{connections.map((connection) => {
const info = connectionInfo[connection.walletPubkey];
const isActive = activeConnection === connection.walletPubkey;
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
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">
<Settings className="h-4 w-4 text-muted-foreground" />
<div>
@ -220,7 +262,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
{connection.alias || info?.alias || 'Lightning Wallet'}
</p>
<p className="text-xs text-muted-foreground">
{connection.walletPubkey.slice(0, 16)}...
NWC Connection
</p>
</div>
</div>
@ -230,7 +272,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
<Button
size="sm"
variant="ghost"
onClick={() => handleSetActive(connection.walletPubkey)}
onClick={() => handleSetActive(connection.connectionString)}
>
<Zap className="h-3 w-3" />
</Button>
@ -238,7 +280,7 @@ export function WalletModal({ children, className }: WalletModalProps) {
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveConnection(connection.walletPubkey)}
onClick={() => handleRemoveConnection(connection.connectionString)}
>
<Trash2 className="h-3 w-3" />
</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 { toast } = useToast();
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 [amount, setAmount] = useState<number | string>(100);
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 { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { nip04 } from 'nostr-tools';
import { useNostr } from '@nostrify/react';
import { LN } from '@getalby/sdk';
export interface NWCConnection {
walletPubkey: string;
secret: string;
relayUrls: string[];
lud16?: string;
connectionString: string;
alias?: string;
isConnected: boolean;
client?: LN;
}
interface NWCInfo {
@ -22,46 +19,37 @@ interface NWCInfo {
notifications?: string[];
}
export function useNWC() {
const { nostr } = useNostr();
export function useNWCInternal() {
const { toast } = useToast();
const { user } = useCurrentUser();
const [connections, setConnections] = useLocalStorage<NWCConnection[]>('nwc-connections', []);
const [activeConnection, setActiveConnection] = useLocalStorage<string | null>('nwc-active-connection', null);
const [connectionInfo, setConnectionInfo] = useState<Record<string, NWCInfo>>({});
// Parse NWC URI
const parseNWCUri = (uri: string): NWCConnection | null => {
// Use connections directly - no filtering needed
// Parse and validate NWC URI
const parseNWCUri = (uri: string): { connectionString: string } | null => {
try {
const url = new URL(uri);
if (url.protocol !== 'nostr+walletconnect:') {
console.debug('Parsing NWC URI:', { uri: uri.substring(0, 50) + '...' });
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
return null;
}
const walletPubkey = url.pathname.replace('//', '');
const secret = url.searchParams.get('secret');
const relayParam = url.searchParams.getAll('relay');
const lud16 = url.searchParams.get('lud16') || undefined;
if (!walletPubkey || !secret || relayParam.length === 0) {
return null;
}
return {
walletPubkey,
secret,
relayUrls: relayParam,
lud16,
};
} catch {
// Basic validation - let the SDK handle the detailed parsing
console.debug('NWC URI parsing successful');
return { connectionString: uri };
} catch (error) {
console.error('Failed to parse NWC URI:', error);
return null;
}
};
// Add new connection
const addConnection = async (uri: string, alias?: string): Promise<boolean> => {
const connection = parseNWCUri(uri);
if (!connection) {
const parsed = parseNWCUri(uri);
if (!parsed) {
toast({
title: 'Invalid NWC URI',
description: 'Please check the connection string and try again.',
@ -71,7 +59,7 @@ export function useNWC() {
}
// 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) {
toast({
title: 'Connection already exists',
@ -81,31 +69,85 @@ export function useNWC() {
return false;
}
if (alias) {
connection.alias = alias;
}
try {
// Test connection by fetching info
await fetchWalletInfo(connection);
console.debug('Testing NWC connection:', { uri: uri.substring(0, 50) + '...' });
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
if (connections.length === 0) {
setActiveConnection(connection.walletPubkey);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Connection test timeout')), 10000);
});
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({
title: 'Wallet connected',
description: `Successfully connected to ${alias || 'wallet'}.`,
description: `Successfully connected to ${connection.alias}.`,
});
return true;
} catch {
} 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. Please check your connection.',
description: `Could not connect to the wallet: ${errorMessage}`,
variant: 'destructive',
});
return false;
@ -113,17 +155,23 @@ export function useNWC() {
};
// Remove connection
const removeConnection = (walletPubkey: string) => {
setConnections(prev => prev.filter(c => c.walletPubkey !== walletPubkey));
const removeConnection = (connectionString: string) => {
const filtered = connections.filter(c => c.connectionString !== connectionString);
setConnections(filtered);
if (activeConnection === walletPubkey) {
const remaining = connections.filter(c => c.walletPubkey !== walletPubkey);
setActiveConnection(remaining.length > 0 ? remaining[0].walletPubkey : null);
console.debug('NWC connection removed:', {
remainingConnections: filtered.length
});
if (activeConnection === connectionString) {
const newActive = filtered.length > 0 ? filtered[0].connectionString : null;
setActiveConnection(newActive);
console.debug('Active connection changed:', { newActive });
}
setConnectionInfo(prev => {
const newInfo = { ...prev };
delete newInfo[walletPubkey];
delete newInfo[connectionString];
return newInfo;
});
@ -134,156 +182,130 @@ export function useNWC() {
};
// Get active connection
const getActiveConnection = (): NWCConnection | null => {
if (!activeConnection) return null;
return connections.find(c => c.walletPubkey === activeConnection) || null;
};
const getActiveConnection = useCallback((): NWCConnection | null => {
console.debug('getActiveConnection called:', {
activeConnection,
connectionsLength: connections.length,
connections: connections.map(c => ({ alias: c.alias, connectionString: c.connectionString.substring(0, 50) + '...' }))
});
// Send NWC request
const sendNWCRequest = useCallback(async (
// If no active connection is set but we have connections, set the first one as active
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,
request: { method: string; params: Record<string, unknown> }
): Promise<{ result_type: string; error?: { code: string; message: string }; result?: unknown }> => {
if (!user?.signer) {
throw new Error('User not logged in or signer not available');
invoice: string
): Promise<{ preimage: string }> => {
if (!connection.connectionString) {
throw new Error('Invalid connection: missing connection string');
}
// Create request event
const requestEvent = {
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
// Always create a fresh client for each payment to avoid stale connections
let client: LN;
try {
await nostr.event(signedRequest, {
signal: AbortSignal.timeout(10000),
relays: connection.relayUrls
});
console.debug('Creating fresh NWC client for payment...');
client = new LN(connection.connectionString);
} catch (error) {
console.warn('Failed to publish NWC request:', error);
throw new Error('Failed to publish NWC request');
console.error('Failed to create NWC client:', error);
throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Listen for response
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('NWC request timeout'));
}, 30000); // 30 second timeout
try {
console.debug('Sending payment via NWC SDK:', {
invoice: invoice.substring(0, 50) + '...',
connectionAlias: connection.alias
});
// Query for response events
const checkForResponse = async () => {
// Add timeout to prevent hanging
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 {
const responseEvents = await nostr.query([
{
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);
const client = new LN(connection.connectionString);
resolve(client);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
};
});
// Start checking for responses
setTimeout(checkForResponse, 1000); // Wait 1 second before first check
});
}, [nostr, user]);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Connection test timeout')), 5000);
});
// Fetch wallet info
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) });
await Promise.race([testPromise, timeoutPromise]);
if (infoEvents.length > 0) {
const infoEvent = infoEvents[0];
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;
}
console.debug('NWC connection test successful');
return true;
} 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 {
connections,
@ -293,8 +315,9 @@ export function useNWC() {
removeConnection,
setActiveConnection,
getActiveConnection,
fetchWalletInfo,
sendNWCRequest,
sendPayment,
getWalletInfo,
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 { useNWC } from '@/hooks/useNWC';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNWC } from '@/hooks/useNWCContext';
import type { WebLNProvider } from 'webln';
import { requestProvider } from 'webln';
@ -15,8 +15,10 @@ export interface WalletStatus {
export function useWallet() {
const [webln, setWebln] = useState<WebLNProvider | null>(null);
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();
// Detect WebLN
@ -27,20 +29,27 @@ export function useWallet() {
try {
const provider = await requestProvider();
setWebln(provider);
setHasAttemptedDetection(true);
return provider;
} 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);
setHasAttemptedDetection(true);
return null;
} finally {
setIsDetecting(false);
}
}, [webln, isDetecting]);
// Auto-detect on mount
// Only auto-detect once on mount, don't spam detection
useEffect(() => {
detectWebLN();
}, [detectWebLN]);
if (!hasAttemptedDetection) {
detectWebLN();
}
}, [detectWebLN, hasAttemptedDetection]);
// Test WebLN connection
const testWebLN = useCallback(async (): Promise<boolean> => {
@ -55,6 +64,11 @@ export function useWallet() {
}
}, [webln]);
// Calculate status values reactively
const hasNWC = useMemo(() => {
return connections.length > 0 && connections.some(c => c.isConnected);
}, [connections]);
// Determine preferred payment method
const preferredMethod: WalletStatus['preferredMethod'] = activeNWC
? 'nwc'
@ -64,15 +78,27 @@ export function useWallet() {
const status: WalletStatus = {
hasWebLN: !!webln,
hasNWC: !!activeNWC,
hasNWC,
webln,
activeNWC,
isDetecting,
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 {
...status,
hasAttemptedDetection,
detectWebLN,
testWebLN,
};

View File

@ -3,12 +3,12 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
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 { nip57, nip19 } from 'nostr-tools';
import type { Event } from 'nostr-tools';
import type { WebLNProvider } from 'webln';
import { LNURL } from '@nostrify/nostrify/ln';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
@ -24,30 +24,30 @@ function parseNWCUri(uri: string): NWCConnection | null {
const walletPubkey = url.pathname.replace('//', '');
const secret = url.searchParams.get('secret');
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) {
return null;
}
return {
walletPubkey,
secret,
relayUrls: relayParam,
lud16,
connectionString: uri,
alias: 'Parsed NWC',
isConnected: false,
};
} catch {
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 { toast } = useToast();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutate: publishEvent } = useNostrPublish();
const author = useAuthor(target?.pubkey);
const { sendNWCRequest } = useNWC();
const { sendPayment, getActiveConnection, connections, activeConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState<string | null>(null);
@ -104,6 +104,7 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio
}
setIsZapping(true);
setInvoice(null); // Clear any previous invoice at the start
if (!user) {
toast({
@ -126,74 +127,166 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio
return;
}
const { lud06, lud16 } = author.data.metadata;
if (!lud16 && !lud06) {
const { lud16 } = author.data.metadata;
if (!lud16) {
toast({
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',
});
setIsZapping(false);
return;
}
const lnurl = lud06 ? LNURL.fromString(lud06) : LNURL.fromLightningAddress(lud16!);
const zapAmount = amount * 1000; // convert to millisats
const zapRequest = await user.signer.signEvent(nip57.makeZapRequest({
profile: target.pubkey,
event: target,
amount: zapAmount,
relays: [config.relayUrl],
comment: comment,
}));
// Get zap endpoint using the old reliable method
const zapEndpoint = await nip57.getZapEndpoint(author.data.event as Event);
if (!zapEndpoint) {
toast({
title: 'Zap endpoint not found',
description: 'Could not find a zap endpoint for the author.',
variant: 'destructive',
});
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,
nostr: zapRequest,
relays,
comment: comment,
});
// Try NWC first if available
if (nwcConnection) {
try {
const response = await sendNWCRequest(nwcConnection, {
method: 'pay_invoice',
params: {
invoice: newInvoice,
amount: zapAmount,
},
});
// Handle addressable events (restored from old implementation)
if (naddr) {
const decoded = nip19.decode(naddr).data as nip19.AddressPointer;
zapRequest.tags.push(["a", `${decoded.kind}:${decoded.pubkey}:${decoded.identifier}`]);
zapRequest.tags = zapRequest.tags.filter(t => t[0] !== 'e');
}
if (response.error) {
throw new Error(`NWC Error: ${response.error.message}`);
// Sign and publish the zap request
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({
title: 'Zap successful!',
description: `You sent ${amount} sats via NWC to the author.`,
});
onZapSuccess?.();
return;
} catch (nwcError) {
console.error('NWC payment failed, falling back:', nwcError);
toast({
title: 'NWC payment failed',
description: 'Falling back to manual payment...',
title: 'Zap failed',
description: 'Failed to create zap request',
variant: 'destructive',
});
}
}
// 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);
}
setIsZapping(false);
},
});
} catch (err) {
console.error('Zap error:', err);
toast({
@ -201,7 +294,6 @@ export function useZaps(target: Event, webln: WebLNProvider | null, nwcConnectio
description: (err as Error).message,
variant: 'destructive',
});
} finally {
setIsZapping(false);
}
};