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:
SkateSpots 2026-09-26 20:01:59 +07:00
parent b1540f0608
commit 78324605c1
3 changed files with 262 additions and 1 deletions

View 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>
);
}

View file

@ -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,
};
}

View file

@ -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 */}