302 lines
No EOL
10 KiB
TypeScript
302 lines
No EOL
10 KiB
TypeScript
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<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: 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<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: 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<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')) {
|
|
// 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<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,
|
|
};
|
|
} |