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 {
|
return {
|
||||||
connections,
|
connections,
|
||||||
activeConnection,
|
activeConnection,
|
||||||
|
|
@ -221,5 +253,7 @@ export function useNWCInternal() {
|
||||||
setActiveConnection,
|
setActiveConnection,
|
||||||
getActiveConnection,
|
getActiveConnection,
|
||||||
sendPayment,
|
sendPayment,
|
||||||
|
getBalance,
|
||||||
|
makeInvoice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { CashuWalletCard } from '@/components/wallet/CashuWalletCard';
|
import { CashuWalletCard } from '@/components/wallet/CashuWalletCard';
|
||||||
|
import { NwcWalletCard } from '@/components/wallet/NwcWalletCard';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Zap,
|
Zap,
|
||||||
|
|
@ -248,7 +249,10 @@ export default function WalletPage() {
|
||||||
{/* Status bar + manage — compact single row */}
|
{/* Status bar + manage — compact single row */}
|
||||||
<WalletStatusBar />
|
<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 />
|
<CashuWalletCard />
|
||||||
|
|
||||||
{/* Get a wallet — collapsed by default */}
|
{/* Get a wallet — collapsed by default */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue