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.
This commit is contained in:
parent
b1540f0608
commit
78324605c1
3 changed files with 262 additions and 1 deletions
223
src/components/wallet/NwcWalletCard.tsx
Normal file
223
src/components/wallet/NwcWalletCard.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useNWC } from '@/hooks/useNWCContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Check,
|
||||
Copy,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Lightning wallet over Nostr Wallet Connect (NIP-47).
|
||||
*
|
||||
* The Wallet page was Cashu-only and showed "App only" on web, so anyone who
|
||||
* connected an NWC wallet (Alby, Coinos, …) saw no balance, send or receive.
|
||||
* This card surfaces the connected wallet: balance, make-invoice (receive) and
|
||||
* pay-invoice (send). It renders nothing when no NWC connection is active.
|
||||
*/
|
||||
export function NwcWalletCard() {
|
||||
const { getActiveConnection, getBalance, makeInvoice, sendPayment } = useNWC();
|
||||
const { toast } = useToast();
|
||||
const connection = getActiveConnection();
|
||||
|
||||
const [balance, setBalance] = useState<number | null>(null);
|
||||
const [loadingBalance, setLoadingBalance] = useState(false);
|
||||
|
||||
const [receiveAmount, setReceiveAmount] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [invoice, setInvoice] = useState<string | null>(null);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [payInput, setPayInput] = useState('');
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const refreshBalance = useCallback(async () => {
|
||||
if (!connection) return;
|
||||
setLoadingBalance(true);
|
||||
try {
|
||||
setBalance(await getBalance(connection));
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not read balance',
|
||||
description: e instanceof Error ? e.message : 'Your wallet did not respond.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoadingBalance(false);
|
||||
}
|
||||
}, [connection, getBalance, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshBalance();
|
||||
}, [refreshBalance]);
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
if (!connection) return;
|
||||
const sats = Number(receiveAmount);
|
||||
if (!Number.isFinite(sats) || sats <= 0) {
|
||||
toast({ title: 'Enter an amount in sats', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const inv = await makeInvoice(connection, sats, 'SkateSpots');
|
||||
setInvoice(inv);
|
||||
const { default: QRCode } = await import('qrcode');
|
||||
setQr(await QRCode.toDataURL(inv.toUpperCase(), { width: 256, margin: 1 }));
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not create invoice',
|
||||
description: e instanceof Error ? e.message : 'Try again in a moment.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!connection) return;
|
||||
const inv = payInput.trim();
|
||||
if (!inv) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
await sendPayment(connection, inv);
|
||||
const sats = (() => { try { return getSatoshisFromBolt11(inv); } catch { return 0; } })();
|
||||
toast({ title: 'Payment sent', description: sats > 0 ? `Paid ${sats.toLocaleString()} sats.` : 'Paid.' });
|
||||
setPayInput('');
|
||||
void refreshBalance();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Payment failed',
|
||||
description: e instanceof Error ? e.message : 'The wallet rejected the payment.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyInvoice = async () => {
|
||||
if (!invoice) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
if (!connection) return null;
|
||||
|
||||
const paySats = (() => {
|
||||
try { return payInput.trim() ? getSatoshisFromBolt11(payInput.trim()) : 0; } catch { return 0; }
|
||||
})();
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-[#ff6600]/30 bg-black/90">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-[#ff6600]">
|
||||
<Zap className="h-5 w-5" />
|
||||
Lightning Wallet
|
||||
</CardTitle>
|
||||
<p className="text-xs text-gray-500">
|
||||
{connection.alias || 'NWC wallet'} · NIP-47
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Balance */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500">Balance</p>
|
||||
<p className="text-2xl font-black text-white">
|
||||
{loadingBalance && balance === null ? '…' : balance === null ? '—' : balance.toLocaleString()}
|
||||
<span className="ml-1 text-sm font-bold text-gray-400">sats</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Refresh balance"
|
||||
onClick={() => refreshBalance()}
|
||||
disabled={loadingBalance}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loadingBalance && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Receive */}
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
<ArrowDownToLine className="h-3.5 w-3.5 text-[#ccff00]" /> Receive
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={receiveAmount}
|
||||
onChange={(e) => setReceiveAmount(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="Amount in sats"
|
||||
className="h-11 border-gray-700 bg-gray-900 text-white"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateInvoice}
|
||||
disabled={creating}
|
||||
className="h-11 shrink-0 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
{invoice && (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
|
||||
{qr && <img src={qr} alt="Lightning invoice QR code" className="mx-auto h-48 w-48 rounded-lg bg-white p-2" />}
|
||||
<p className="break-all font-mono text-[11px] text-gray-400">{invoice}</p>
|
||||
<Button variant="outline" onClick={copyInvoice} className="w-full gap-2 border-white/15">
|
||||
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copied' : 'Copy invoice'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send */}
|
||||
<div className="space-y-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-gray-400">
|
||||
<ArrowUpFromLine className="h-3.5 w-3.5 text-[#ff6600]" /> Send
|
||||
</p>
|
||||
<Textarea
|
||||
value={payInput}
|
||||
onChange={(e) => setPayInput(e.target.value)}
|
||||
placeholder="Paste a Lightning invoice (lnbc…)"
|
||||
rows={2}
|
||||
className="resize-none border-gray-700 bg-gray-900 font-mono text-xs text-white"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{paySats > 0 ? `Amount: ${paySats.toLocaleString()} sats` : 'Paste an invoice to see the amount'}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handlePay}
|
||||
disabled={!payInput.trim() || paying}
|
||||
className="ml-auto h-10 gap-2 bg-[#ff6600] font-bold text-black hover:bg-[#ff6600]/90"
|
||||
>
|
||||
{paying ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
||||
Pay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -212,6 +212,38 @@ export function useNWCInternal() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
/** 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,
|
||||
|
|
@ -221,5 +253,7 @@ export function useNWCInternal() {
|
|||
setActiveConnection,
|
||||
getActiveConnection,
|
||||
sendPayment,
|
||||
getBalance,
|
||||
makeInvoice,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CashuWalletCard } from '@/components/wallet/CashuWalletCard';
|
||||
import { NwcWalletCard } from '@/components/wallet/NwcWalletCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Zap,
|
||||
|
|
@ -248,7 +249,10 @@ export default function WalletPage() {
|
|||
{/* Status bar + manage — compact single row */}
|
||||
<WalletStatusBar />
|
||||
|
||||
{/* Cashu wallet — the one and only balance + send/receive UI */}
|
||||
{/* Connected Lightning wallet (NIP-47) — balance / receive / send */}
|
||||
<NwcWalletCard />
|
||||
|
||||
{/* Cashu wallet — native app, self-custodial ecash */}
|
||||
<CashuWalletCard />
|
||||
|
||||
{/* Get a wallet — collapsed by default */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue