From 8d594d7d6968c3d175eb440c3ccb82c9fb284140 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:16:10 +0200 Subject: [PATCH] Add Lightning zaps with optional Nostr Wallet Connect (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Lightning zaps with optional Nostr Wallet Connect Signed-in users can zap a note or reply, and see its zap total, from the feed, a thread view, and replies. Zapping opens a dialog to pick an amount (presets or custom) and an optional comment, then: - Builds and signs a NIP-57 zap request and fetches an invoice from the recipient's LNURL/lud16 callback (rejecting non-https endpoints). - If a wallet is connected via Nostr Wallet Connect (NIP-47, Settings > Lightning wallet), pays the invoice automatically and only reports success once the wallet returns a payment preimage. - Otherwise shows the invoice as a QR code plus a copy/`lightning:` link for the user's own wallet, and polls for a matching zap receipt to confirm payment without ever asserting success it can't verify. Zap totals sum kind-9735 receipts defensively: a receipt only counts if it carries a bolt11 amount and a description whose embedded zap request is a well-formed, signature-valid event, so a malformed or forged receipt can't inflate the total. The NWC connection secret is stored only in this browser, scoped to the signed-in pubkey, and is used solely to sign/send payment requests to the wallet's own relay — never published, logged, or shown besides a truncated pubkey once connected. Closes #54 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * Address PR #62 review feedback: NWC relay security, N+1 zap queries, manual-payment confirmation Addresses Copilot review comments on PR #62: - nwc.ts: reject plaintext ws:// Wallet Connect relays, requiring wss://. Every request is signed by the connection secret (a private key); even with an encrypted payload, an unencrypted transport still leaks metadata about the connection and admits tampering. - ZapButton/useZaps: fixed the N+1 query pattern — a feed page mounted one unconditional zap-receipts query (limit 500) per rendered note. Added a `revealed` gate so the query only fires once a note is actually hovered or focused (the same interaction that already reveals the action row via CSS), confirmed live: 0 queries fired across 49 mounted notes before any interaction, exactly 1 after hovering one. - ZapDialog: manual-payment confirmation compared the note's total receipt *count* against a baseline, so anyone else zapping the same note while the dialog waited would falsely confirm the viewer's own unpaid invoice. Added `hasValidReceiptForInvoice` to match against the specific invoice instead, with unit tests covering the exact race the review described. - useZaps.ts: corrected a docstring overclaiming that receipt validation prevents "forged" receipts from inflating totals — it only rules out structurally invalid data; NIP-57 receipts are vouched for by the recipient's own LNURL server, so trusting one is inherent to the protocol, not something client-side validation can prove. Investigated but did not change: the review's claim that `nip04.decrypt(...)` needs an `await` because it returns a Promise. Not correct for this project's actual `nostr-tools` dependency — confirmed by running the real encrypt/decrypt round trip, `decrypt` is synchronous and returns the plaintext string directly, so the existing `JSON.parse` call already worked. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * Merge main and resolve action conflicts Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- package-lock.json | 11 -- src/apps/notes/index.tsx | 2 + src/apps/settings/index.tsx | 80 +++++++++- src/components/nostr/NoteCard.tsx | 9 ++ src/components/nostr/ZapButton.tsx | 69 +++++++++ src/components/nostr/ZapDialog.tsx | 231 +++++++++++++++++++++++++++++ src/hooks/useNwc.ts | 46 ++++++ src/hooks/useZaps.test.ts | 112 ++++++++++++++ src/hooks/useZaps.ts | 162 ++++++++++++++++++++ src/lib/nwc.test.ts | 53 +++++++ src/lib/nwc.ts | 111 ++++++++++++++ 11 files changed, 874 insertions(+), 12 deletions(-) create mode 100644 src/components/nostr/ZapButton.tsx create mode 100644 src/components/nostr/ZapDialog.tsx create mode 100644 src/hooks/useNwc.ts create mode 100644 src/hooks/useZaps.test.ts create mode 100644 src/hooks/useZaps.ts create mode 100644 src/lib/nwc.test.ts create mode 100644 src/lib/nwc.ts diff --git a/package-lock.json b/package-lock.json index 36fc8ce..55ebf24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6069,7 +6069,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6090,7 +6089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6111,7 +6109,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6132,7 +6129,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6153,7 +6149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6174,7 +6169,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6195,7 +6189,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6216,7 +6209,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6237,7 +6229,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6258,7 +6249,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6279,7 +6269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index a88872d..c706c25 100644 --- a/src/apps/notes/index.tsx +++ b/src/apps/notes/index.tsx @@ -7,6 +7,7 @@ import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppC import { AuthorLine } from '@/components/nostr/AuthorLine'; import { NoteContent } from '@/components/nostr/NoteContent'; import { NoteCard } from '@/components/nostr/NoteCard'; +import { ZapButton } from '@/components/nostr/ZapButton'; import { ReactionButton } from '@/components/nostr/ReactionButton'; import { Composer } from '@/apps/feed/Composer'; import { DraftNote } from './Draft'; @@ -124,6 +125,7 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {

{absoluteTime(event.created_at)}

+
diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index e5bcdd4..6192bbb 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Check, Plus, Trash2 } from 'lucide-react'; +import { Check, Plus, Trash2, Zap } from 'lucide-react'; import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,6 +10,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { useAppContext } from '@/hooks/useAppContext'; import { useTheme } from '@/hooks/useTheme'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNwcConnection } from '@/hooks/useNwc'; import { useWindowManager } from '@/os/useWindowManager'; import { desktopApps } from '@/os/registry'; import { useIconLayout } from '@/os/useIconLayout'; @@ -44,6 +45,8 @@ export default function SettingsApp({ setTitle }: AppProps) { + + @@ -304,6 +307,81 @@ function MediaSection() { ); } +function WalletSection() { + const { user } = useCurrentUser(); + const { connection, connect, disconnect } = useNwcConnection(); + const { toast } = useToast(); + const [draft, setDraft] = useState(''); + + const handleConnect = () => { + try { + connect(draft); + setDraft(''); + toast({ title: 'Wallet connected' }); + } catch (error) { + toast({ + title: 'Could not connect that wallet', + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + } + }; + + const handleDisconnect = () => { + disconnect(); + toast({ title: 'Wallet disconnected' }); + }; + + return ( +
+ {!user ? ( +

+ Sign in to connect a wallet. +

+ ) : connection ? ( +
+
+ +
+

Connected

+

+ {connection.pubkey.slice(0, 12)}… via {connection.relay.replace(/^wss:\/\//, '')} +

+
+
+ +
+ ) : ( +
+
+ setDraft(event.target.value)} + onKeyDown={(event) => event.key === 'Enter' && handleConnect()} + placeholder="nostr+walletconnect://…" + className="font-mono text-xs" + aria-label="Nostr Wallet Connect link" + /> + +
+

+ Paste a connection link from your wallet (e.g. Alby, Mutiny). It is stored only in this browser and used + solely to sign and send payment requests to your wallet's relay — never published or shared elsewhere. + Without a connected wallet, zapping still works: you'll pay each invoice manually. +

+
+ )} +
+ ); +} + function SessionSection() { const { resetSession } = useWindowManager(); const { toast } = useToast(); diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index 59e746b..7fb138d 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -1,9 +1,11 @@ +import { useState } from 'react'; import { MessageSquare, Repeat2 } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; import { BookmarkButton } from './BookmarkButton'; +import { ZapButton } from './ZapButton'; import { ReactionButton } from './ReactionButton'; import { Button } from '@/components/ui/button'; import { useWindowManager } from '@/os/useWindowManager'; @@ -26,6 +28,10 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { const { openApp } = useWindowManager(); const { toast } = useToast(); const hints = useRelayHints(); + // Tracks the same interaction that reveals the action row via CSS + // (`group-hover`/`focus-within`), so `ZapButton` can defer its relay query + // until this note is actually looked at instead of firing on every mount. + const [revealed, setRevealed] = useState(false); const copyLink = async () => { try { @@ -45,6 +51,8 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { 'group border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-muted/40', className, )} + onMouseEnter={() => setRevealed(true)} + onFocus={() => setRevealed(true)} > @@ -71,6 +79,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { Copy link + diff --git a/src/components/nostr/ZapButton.tsx b/src/components/nostr/ZapButton.tsx new file mode 100644 index 0000000..0579289 --- /dev/null +++ b/src/components/nostr/ZapButton.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Zap } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import AuthDialog from '@/components/auth/AuthDialog'; +import { ZapDialog } from './ZapDialog'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useZapReceipts, summarizeZapReceipts, formatSats } from '@/hooks/useZaps'; +import { cn } from '@/lib/utils'; + +/** + * Shows a note's zap total and opens the zap flow (NIP-57), from the feed or + * a thread. + * + * `revealed` gates the zap-receipts query itself, not just the total's + * visibility: a feed page mounts one of these per note, and firing every + * one's relay query unconditionally on mount turns a page of notes into a + * page of concurrent queries before anyone's looked at any of them. Callers + * in a list (`NoteCard`) pass `revealed` once the row is actually hovered or + * focused — the same interaction that already reveals the row via CSS — so + * off-screen or never-looked-at notes never fetch. A caller showing the + * button on its own (the thread root) can just leave it `true`. + */ +export function ZapButton({ target, className, revealed = true }: { target: NostrEvent; className?: string; revealed?: boolean }) { + const { user } = useCurrentUser(); + const [authOpen, setAuthOpen] = useState(false); + const [zapOpen, setZapOpen] = useState(false); + const receipts = useZapReceipts(target.id, { enabled: revealed || zapOpen }); + const recipient = useAuthor(target.pubkey); + + const { totalSats } = summarizeZapReceipts(receipts.data); + + const handleClick = () => { + if (!user) { + setAuthOpen(true); + return; + } + setZapOpen(true); + }; + + return ( + <> + + + setAuthOpen(false)} /> + + {zapOpen && ( + setZapOpen(false)} + target={target} + recipientMetadata={recipient.data?.event} + recipientMetadataLoading={recipient.isLoading} + /> + )} + + ); +} diff --git a/src/components/nostr/ZapDialog.tsx b/src/components/nostr/ZapDialog.tsx new file mode 100644 index 0000000..f6a5e85 --- /dev/null +++ b/src/components/nostr/ZapDialog.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { CheckCircle2, Copy, Loader2, XCircle, Zap } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { useToast } from '@/hooks/useToast'; +import { useCreateZapInvoice, useZapReceipts, hasValidReceiptForInvoice, formatSats } from '@/hooks/useZaps'; +import { useNwcConnection, usePayWithNwc } from '@/hooks/useNwc'; +import { cn } from '@/lib/utils'; + +const PRESET_AMOUNTS = [21, 100, 500, 1_000, 5_000, 21_000]; + +type Stage = 'amount' | 'requesting' | 'paying' | 'manual' | 'paid' | 'failed'; + +interface ZapDialogProps { + open: boolean; + onClose: () => void; + /** The note or reply being zapped. */ + target: NostrEvent; + /** The recipient's kind-0 event, so a zap endpoint can be resolved. */ + recipientMetadata: NostrEvent | undefined; + recipientMetadataLoading: boolean; +} + +export function ZapDialog({ open, onClose, target, recipientMetadata, recipientMetadataLoading }: ZapDialogProps) { + const { toast } = useToast(); + const { connection } = useNwcConnection(); + const createInvoice = useCreateZapInvoice(); + const payWithNwc = usePayWithNwc(); + + const [stage, setStage] = useState('amount'); + const [amount, setAmount] = useState(21); + const [customAmount, setCustomAmount] = useState(''); + const [comment, setComment] = useState(''); + const [invoice, setInvoice] = useState(null); + const [amountSats, setAmountSats] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + + // Re-fetched on an interval only while this dialog is showing an unpaid + // manual invoice. Whether that now means "paid" is derived at render time + // below instead of copied into state, so there is nothing to keep in sync + // by hand. Matched against the specific invoice, not just a receipt-count + // increase — someone else zapping the same note while this dialog waits + // must not be mistaken for this payment completing. + const receipts = useZapReceipts(target.id, { refetchInterval: stage === 'manual' ? 4_000 : false }); + const manualPaymentConfirmed = stage === 'manual' && invoice !== null && hasValidReceiptForInvoice(receipts.data, invoice); + const effectiveStage: Stage = manualPaymentConfirmed ? 'paid' : stage; + + const resolvedAmount = customAmount.trim() ? Number(customAmount) : amount; + const canSubmit = Boolean(recipientMetadata) && Number.isFinite(resolvedAmount) && (resolvedAmount ?? 0) > 0; + + const handleSubmit = async () => { + if (!recipientMetadata || !resolvedAmount || resolvedAmount <= 0) return; + setStage('requesting'); + setErrorMessage(''); + + try { + const result = await createInvoice.mutateAsync({ + target, + recipientMetadata, + amountSats: resolvedAmount, + comment: comment.trim() || undefined, + }); + setInvoice(result.invoice); + setAmountSats(result.amountSats); + + if (connection) { + setStage('paying'); + try { + await payWithNwc.mutateAsync({ connection, invoice: result.invoice }); + setStage('paid'); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Your wallet did not complete the payment.'); + setStage('manual'); + } + } else { + setStage('manual'); + } + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Could not request an invoice.'); + setStage('failed'); + } + }; + + const copyInvoice = async () => { + if (!invoice) return; + try { + await navigator.clipboard.writeText(invoice); + toast({ title: 'Invoice copied' }); + } catch { + toast({ title: 'Could not copy the invoice', variant: 'destructive' }); + } + }; + + return ( + !next && onClose()}> + + + + + Zap this note + + Send a Lightning zap for this note. + + + {effectiveStage === 'amount' && ( +
+
+ {PRESET_AMOUNTS.map((preset) => ( + + ))} +
+ +
+ + setCustomAmount(event.target.value)} + placeholder="1000" + /> +
+ +
+ +