+ 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 (
+
+ );
+}
diff --git a/src/hooks/useNwc.ts b/src/hooks/useNwc.ts
new file mode 100644
index 0000000..74c91f8
--- /dev/null
+++ b/src/hooks/useNwc.ts
@@ -0,0 +1,46 @@
+import { useCallback } from 'react';
+import { useNostr } from '@nostrify/react';
+import { useMutation } from '@tanstack/react-query';
+import { useCurrentUser } from './useCurrentUser';
+import { useLocalStorage } from './useLocalStorage';
+import { parseNwcUri, payInvoiceViaNwc, type NwcConnection } from '@/lib/nwc';
+
+function nwcStorageKey(pubkey: string | undefined) {
+ return `nostr:nwc:connection:${pubkey ?? 'anonymous'}`;
+}
+
+/**
+ * The signed-in user's optional Lightning wallet connection (NIP-47). Stored
+ * only in this browser, scoped to the signed-in pubkey — it is never
+ * published to a relay or sent anywhere but the wallet's own relay when
+ * paying an invoice.
+ */
+export function useNwcConnection() {
+ const { user } = useCurrentUser();
+ const [connection, setConnection] = useLocalStorage(
+ nwcStorageKey(user?.pubkey),
+ null,
+ );
+
+ const connect = useCallback(
+ (uri: string) => {
+ const parsed = parseNwcUri(uri);
+ setConnection(parsed);
+ return parsed;
+ },
+ [setConnection],
+ );
+
+ const disconnect = useCallback(() => setConnection(null), [setConnection]);
+
+ return { connection: user ? connection : null, connect, disconnect };
+}
+
+export function usePayWithNwc() {
+ const { nostr } = useNostr();
+
+ return useMutation({
+ mutationFn: ({ connection, invoice }: { connection: NwcConnection; invoice: string }) =>
+ payInvoiceViaNwc(nostr, connection, invoice),
+ });
+}
diff --git a/src/hooks/useZaps.test.ts b/src/hooks/useZaps.test.ts
new file mode 100644
index 0000000..45ba95a
--- /dev/null
+++ b/src/hooks/useZaps.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+import { finalizeEvent, generateSecretKey, getPublicKey, nip57 } from 'nostr-tools';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { summarizeZapReceipts, hasValidReceiptForInvoice, formatSats } from './useZaps';
+
+const targetId = 'a'.repeat(64);
+// A syntactically valid-enough bolt11 for getSatoshisAmountFromBolt11: "lnbc"
+// + amount "210" + unit "n" (nano-BTC, i.e. 21 sats) + the "1" data separator,
+// padded past the function's 50-character minimum with characters that don't
+// contain another "1" (which would shift where it splits the human-readable
+// part from the data part).
+const FAKE_BOLT11 = `lnbc210n1${'p'.repeat(45)}`;
+
+function signedZapRequest(amountMsats: number): { json: string; pubkey: string } {
+ const secretKey = generateSecretKey();
+ const pubkey = getPublicKey(secretKey);
+ const template = nip57.makeZapRequest({
+ event: { id: targetId, pubkey: 'b'.repeat(64), kind: 1, content: '', tags: [], created_at: 0, sig: '' },
+ amount: amountMsats,
+ comment: '',
+ relays: ['wss://relay.example.com'],
+ });
+ const signed = finalizeEvent(template, secretKey);
+ return { json: JSON.stringify(signed), pubkey };
+}
+
+function receipt(overrides: Partial = {}, description = signedZapRequest(21_000).json): NostrEvent {
+ return {
+ id: overrides.id ?? Math.random().toString(36),
+ pubkey: 'zapper-service',
+ created_at: 0,
+ kind: 9735,
+ content: '',
+ sig: '',
+ tags: [
+ ['e', targetId],
+ ['bolt11', FAKE_BOLT11],
+ ['description', description],
+ ],
+ ...overrides,
+ };
+}
+
+describe('formatSats', () => {
+ it('formats small numbers plainly', () => {
+ expect(formatSats(21)).toBe('21');
+ expect(formatSats(0)).toBe('0');
+ });
+
+ it('formats large numbers without throwing', () => {
+ // Exact compact-notation output ("1.2K" vs "1200") depends on the ICU
+ // data available at runtime, so only the type/no-throw contract is
+ // checked here — the notation itself is exercised in a real browser.
+ expect(typeof formatSats(1_234_567)).toBe('string');
+ });
+});
+
+describe('summarizeZapReceipts', () => {
+ it('returns zero for no receipts', () => {
+ expect(summarizeZapReceipts(undefined)).toEqual({ totalSats: 0, count: 0 });
+ });
+
+ it('sums valid receipts by their bolt11 amount', () => {
+ // FAKE_BOLT11 encodes 210n, which getSatoshisAmountFromBolt11 reads as 21 sats.
+ const events = [receipt({ id: '1' }), receipt({ id: '2' })];
+ const summary = summarizeZapReceipts(events);
+ expect(summary.count).toBe(2);
+ expect(summary.totalSats).toBe(42);
+ });
+
+ it('drops receipts with a malformed description', () => {
+ const events = [receipt({ id: '1' }, 'not json'), receipt({ id: '2' }, JSON.stringify({ not: 'an event' }))];
+ expect(summarizeZapReceipts(events)).toEqual({ totalSats: 0, count: 0 });
+ });
+
+ it('drops receipts missing bolt11 or description', () => {
+ const missingBolt11: NostrEvent = {
+ ...receipt({ id: '1' }),
+ tags: [['e', targetId], ['description', signedZapRequest(1000).json]],
+ };
+ expect(summarizeZapReceipts([missingBolt11]).count).toBe(0);
+ });
+
+ it('deduplicates by receipt id', () => {
+ const single = receipt({ id: 'dup' });
+ const summary = summarizeZapReceipts([single, single]);
+ expect(summary.count).toBe(1);
+ });
+});
+
+describe('hasValidReceiptForInvoice', () => {
+ const MY_INVOICE = `lnbc210n1${'q'.repeat(45)}`;
+
+ it('returns false for no receipts', () => {
+ expect(hasValidReceiptForInvoice(undefined, MY_INVOICE)).toBe(false);
+ });
+
+ it('matches a valid receipt paying the exact invoice', () => {
+ const mine = receipt({ id: '1', tags: [['e', targetId], ['bolt11', MY_INVOICE], ['description', signedZapRequest(21_000).json]] });
+ expect(hasValidReceiptForInvoice([mine], MY_INVOICE)).toBe(true);
+ });
+
+ it('ignores a receipt for a different invoice — someone else zapping the same note must not confirm this payment', () => {
+ const someoneElses = receipt({ id: '1' }); // uses the default FAKE_BOLT11, not MY_INVOICE
+ expect(hasValidReceiptForInvoice([someoneElses], MY_INVOICE)).toBe(false);
+ });
+
+ it('ignores a receipt matching the invoice but with an invalid description', () => {
+ const forged = receipt({ id: '1', tags: [['e', targetId], ['bolt11', MY_INVOICE], ['description', 'not json']] });
+ expect(hasValidReceiptForInvoice([forged], MY_INVOICE)).toBe(false);
+ });
+});
diff --git a/src/hooks/useZaps.ts b/src/hooks/useZaps.ts
new file mode 100644
index 0000000..45a7c94
--- /dev/null
+++ b/src/hooks/useZaps.ts
@@ -0,0 +1,162 @@
+import { useNostr } from '@nostrify/react';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { nip57 } from 'nostr-tools';
+import { tagValue } from '@/lib/nostrUtils';
+import { useCurrentUser } from './useCurrentUser';
+import { useAppContext } from './useAppContext';
+
+const ZAP_RECEIPT_KIND = 9735;
+
+function zapReceiptsQueryKey(eventId: string) {
+ return ['nostr', 'zap-receipts', eventId] as const;
+}
+
+/**
+ * NIP-57 zap receipts (kind 9735) referencing `eventId`. Pass `enabled: false`
+ * until the caller actually needs the total — a note list mounts one of these
+ * per row, and firing all of them unconditionally turns a page of notes into
+ * a page of relay queries (an N+1 pattern) before anyone's looked at any of
+ * them.
+ */
+export function useZapReceipts(eventId: string | undefined, opts?: { refetchInterval?: number | false; enabled?: boolean }) {
+ const { nostr } = useNostr();
+
+ return useQuery({
+ queryKey: zapReceiptsQueryKey(eventId ?? ''),
+ enabled: Boolean(eventId) && (opts?.enabled ?? true),
+ queryFn: async ({ signal }) => {
+ const events = await nostr.query(
+ [{ kinds: [ZAP_RECEIPT_KIND], '#e': [eventId!], limit: 500 }],
+ { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
+ );
+ return events;
+ },
+ staleTime: 30_000,
+ refetchInterval: opts?.refetchInterval,
+ });
+}
+
+export interface ZapSummary {
+ totalSats: number;
+ count: number;
+}
+
+/**
+ * Sums zap receipts defensively: a receipt only counts if it carries a
+ * `bolt11` invoice and a `description` whose embedded zap request is a
+ * well-formed, signature-valid Nostr event. That rules out garbage a relay
+ * happens to serve back — malformed data, or a request signature that
+ * doesn't check out — but it is not proof any payment actually happened.
+ * NIP-57 receipts are published by the recipient's own LNURL server, so
+ * trusting that a receipt means "paid" is inherent to the protocol; this
+ * validation only keeps structurally-invalid noise out of the total.
+ */
+export function summarizeZapReceipts(events: NostrEvent[] | undefined): ZapSummary {
+ const seen = new Set();
+ let totalSats = 0;
+ let count = 0;
+
+ for (const receipt of events ?? []) {
+ if (seen.has(receipt.id)) continue;
+ seen.add(receipt.id);
+
+ const bolt11 = tagValue(receipt, 'bolt11');
+ const description = tagValue(receipt, 'description');
+ if (!bolt11 || !description) continue;
+ if (nip57.validateZapRequest(description) !== null) continue;
+
+ const sats = nip57.getSatoshisAmountFromBolt11(bolt11);
+ if (sats <= 0) continue;
+
+ totalSats += sats;
+ count++;
+ }
+
+ return { totalSats, count };
+}
+
+/**
+ * True if any signature-valid receipt among `events` pays exactly `invoice`.
+ * Used to confirm a specific manual payment — checking whether the note's
+ * receipt *count* went up is not enough, since anyone else zapping the same
+ * note while the payer is waiting would also bump the count and falsely
+ * confirm a still-unpaid invoice.
+ */
+export function hasValidReceiptForInvoice(events: NostrEvent[] | undefined, invoice: string): boolean {
+ return (events ?? []).some((receipt) => {
+ const bolt11 = tagValue(receipt, 'bolt11');
+ const description = tagValue(receipt, 'description');
+ return bolt11 === invoice && Boolean(description) && nip57.validateZapRequest(description!) === null;
+ });
+}
+
+/** "1.2K" for 1234 — compact, locale-aware, and never wraps a note's action row. */
+export function formatSats(sats: number): string {
+ return new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(sats);
+}
+
+export interface CreateZapInvoiceInput {
+ /** The note or reply being zapped. */
+ target: NostrEvent;
+ /** The recipient's kind-0 metadata event — needed to resolve their LNURL/lud16 zap endpoint. */
+ recipientMetadata: NostrEvent;
+ amountSats: number;
+ comment?: string;
+}
+
+export interface ZapInvoice {
+ invoice: string;
+ amountSats: number;
+}
+
+/**
+ * Builds and signs a NIP-57 zap request, then asks the recipient's LNURL
+ * callback for an invoice. This never touches a wallet — it only produces a
+ * bolt11 for the caller to pay, manually or via NWC.
+ */
+export function useCreateZapInvoice() {
+ const { user } = useCurrentUser();
+ const { config } = useAppContext();
+
+ return useMutation({
+ mutationFn: async ({ target, recipientMetadata, amountSats, comment }) => {
+ if (!user) throw new Error('Sign in to zap');
+ if (!Number.isFinite(amountSats) || amountSats <= 0) {
+ throw new Error('Enter an amount greater than zero.');
+ }
+
+ const endpoint = await nip57.getZapEndpoint(recipientMetadata);
+ if (!endpoint) {
+ throw new Error('This person has not set up zaps on their Nostr profile.');
+ }
+ if (!endpoint.startsWith('https://')) {
+ throw new Error('This person\'s zap endpoint is not secure.');
+ }
+
+ const relays = config.relayMetadata.relays.filter((relay) => relay.read).map((relay) => relay.url);
+ const amountMsats = Math.round(amountSats * 1000);
+
+ const template = nip57.makeZapRequest({
+ event: target,
+ amount: amountMsats,
+ comment: comment ?? '',
+ relays: relays.length > 0 ? relays : config.relayMetadata.relays.map((relay) => relay.url),
+ });
+ const signed = await user.signer.signEvent(template);
+
+ const url = new URL(endpoint);
+ url.searchParams.set('amount', String(amountMsats));
+ url.searchParams.set('nostr', JSON.stringify(signed));
+
+ const response = await fetch(url.toString(), { signal: AbortSignal.timeout(10_000) });
+ const body: { status?: string; reason?: string; pr?: string } = await response.json().catch(() => ({}));
+
+ if (!response.ok || body.status === 'ERROR' || !body.pr) {
+ throw new Error(body.reason || 'The recipient\'s Lightning wallet declined the request.');
+ }
+
+ return { invoice: body.pr, amountSats };
+ },
+ });
+}
diff --git a/src/lib/nwc.test.ts b/src/lib/nwc.test.ts
new file mode 100644
index 0000000..108dd3f
--- /dev/null
+++ b/src/lib/nwc.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest';
+import { parseNwcUri } from './nwc';
+
+const PUBKEY = 'a'.repeat(64);
+const SECRET = 'b'.repeat(64);
+
+describe('parseNwcUri', () => {
+ it('parses a well-formed nostr+walletconnect:// link', () => {
+ const uri = `nostr+walletconnect://${PUBKEY}?relay=wss%3A%2F%2Frelay.example.com&secret=${SECRET}`;
+ const connection = parseNwcUri(uri);
+ expect(connection).toEqual({
+ pubkey: PUBKEY,
+ relay: 'wss://relay.example.com',
+ secret: SECRET,
+ });
+ });
+
+ it('accepts the legacy nostrwalletconnect:// scheme', () => {
+ const uri = `nostrwalletconnect://${PUBKEY}?relay=wss%3A%2F%2Frelay.example.com&secret=${SECRET}`;
+ expect(() => parseNwcUri(uri)).not.toThrow();
+ });
+
+ it('rejects a non-Wallet-Connect URI', () => {
+ expect(() => parseNwcUri('https://example.com')).toThrow();
+ });
+
+ it('rejects a missing secret', () => {
+ const uri = `nostr+walletconnect://${PUBKEY}?relay=wss%3A%2F%2Frelay.example.com`;
+ expect(() => parseNwcUri(uri)).toThrow();
+ });
+
+ it('rejects a non-hex pubkey', () => {
+ const uri = `nostr+walletconnect://not-hex?relay=wss%3A%2F%2Frelay.example.com&secret=${SECRET}`;
+ expect(() => parseNwcUri(uri)).toThrow();
+ });
+
+ it('rejects a relay that is not a websocket URL', () => {
+ const uri = `nostr+walletconnect://${PUBKEY}?relay=https%3A%2F%2Frelay.example.com&secret=${SECRET}`;
+ expect(() => parseNwcUri(uri)).toThrow();
+ });
+
+ it('rejects a plaintext ws:// relay', () => {
+ const uri = `nostr+walletconnect://${PUBKEY}?relay=ws%3A%2F%2Frelay.example.com&secret=${SECRET}`;
+ expect(() => parseNwcUri(uri)).toThrow();
+ });
+
+ it('lowercases hex fields', () => {
+ const uri = `nostr+walletconnect://${PUBKEY.toUpperCase()}?relay=wss%3A%2F%2Frelay.example.com&secret=${SECRET.toUpperCase()}`;
+ const connection = parseNwcUri(uri);
+ expect(connection.pubkey).toBe(PUBKEY);
+ expect(connection.secret).toBe(SECRET);
+ });
+});
diff --git a/src/lib/nwc.ts b/src/lib/nwc.ts
new file mode 100644
index 0000000..0c88d3c
--- /dev/null
+++ b/src/lib/nwc.ts
@@ -0,0 +1,111 @@
+import { nip04, nip47, utils } from 'nostr-tools';
+import type { NostrEvent, NPool } from '@nostrify/nostrify';
+
+const HEX_64 = /^[0-9a-f]{64}$/i;
+const NWC_WALLET_RESPONSE_KIND = 23195;
+
+/**
+ * A parsed Nostr Wallet Connect link. The `secret` is a private key that
+ * authorizes payments from the user's wallet — treat it exactly like any
+ * other private key: never log it, render it, publish it, or send it
+ * anywhere but the wallet's own relay.
+ */
+export interface NwcConnection {
+ pubkey: string;
+ relay: string;
+ secret: string;
+}
+
+/**
+ * Parses a `nostr+walletconnect://` (or legacy `nostrwalletconnect://`) URI.
+ * Throws a message safe to show the user rather than leaking parser
+ * internals; never includes the secret in that message.
+ */
+export function parseNwcUri(uri: string): NwcConnection {
+ const trimmed = uri.trim();
+ if (!/^nostr\+?walletconnect:\/\//i.test(trimmed)) {
+ throw new Error('That doesn\'t look like a Nostr Wallet Connect link.');
+ }
+
+ let parsed: { pubkey: string; relay: string; secret: string };
+ try {
+ parsed = nip47.parseConnectionString(trimmed);
+ } catch {
+ throw new Error('Could not read that Wallet Connect link.');
+ }
+
+ if (!HEX_64.test(parsed.pubkey) || !HEX_64.test(parsed.secret)) {
+ throw new Error('That Wallet Connect link is missing a valid key.');
+ }
+ // wss:// only: every request to the wallet is signed by `secret` (a private
+ // key) and, even though the payload is encrypted, an unencrypted `ws://`
+ // transport still leaks metadata about the connection (who you're paying,
+ // when, how often) to anyone on the network path, and admits tampering.
+ if (!parsed.relay.startsWith('wss://')) {
+ throw new Error('That Wallet Connect link must use a wss:// relay.');
+ }
+
+ return { pubkey: parsed.pubkey.toLowerCase(), relay: parsed.relay, secret: parsed.secret.toLowerCase() };
+}
+
+interface NwcPayResult {
+ result_type?: string;
+ error?: { code?: string; message?: string };
+ result?: { preimage?: string };
+}
+
+/**
+ * Pays a bolt11 invoice through a NIP-47 wallet: signs and publishes an
+ * encrypted `pay_invoice` request to the wallet's own relay, then waits for
+ * its encrypted response. Resolves with the payment preimage — proof of
+ * payment — and never resolves on anything less; a `result_type` mismatch or
+ * missing preimage is treated as a failure, not a success.
+ */
+export async function payInvoiceViaNwc(
+ nostr: NPool,
+ connection: NwcConnection,
+ invoice: string,
+ opts?: { timeoutMs?: number },
+): Promise {
+ const secretKey = utils.hexToBytes(connection.secret);
+ const requestEvent = await nip47.makeNwcRequestEvent(connection.pubkey, secretKey, invoice);
+ const signal = AbortSignal.timeout(opts?.timeoutMs ?? 60_000);
+
+ const waitForResponse = (async (): Promise => {
+ try {
+ for await (const msg of nostr.req(
+ [{ kinds: [NWC_WALLET_RESPONSE_KIND], authors: [connection.pubkey], '#e': [requestEvent.id], limit: 1 }],
+ { relays: [connection.relay], signal },
+ )) {
+ if (msg[0] === 'EVENT') return msg[2];
+ }
+ } catch {
+ // Aborted or the relay dropped — fall through to the timeout error below.
+ }
+ return undefined;
+ })();
+
+ await nostr.event(requestEvent, { relays: [connection.relay], signal });
+ const response = await waitForResponse;
+
+ if (!response) {
+ throw new Error('Your wallet did not respond in time.');
+ }
+
+ let payload: NwcPayResult;
+ try {
+ const decrypted = nip04.decrypt(secretKey, connection.pubkey, response.content);
+ payload = JSON.parse(decrypted);
+ } catch {
+ throw new Error('Could not read your wallet\'s response.');
+ }
+
+ if (payload.error) {
+ throw new Error(payload.error.message || 'Your wallet declined the payment.');
+ }
+ if (payload.result_type !== 'pay_invoice' || !payload.result?.preimage) {
+ throw new Error('Your wallet did not confirm the payment.');
+ }
+
+ return payload.result.preimage;
+}