mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
Add Lightning zaps with optional Nostr Wallet Connect (#62)
* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <highperfocused@pm.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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": [
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<p className="text-xs text-muted-foreground">{absoluteTime(event.created_at)}</p>
|
||||
<ZapButton target={event} className="h-6" />
|
||||
<ReactionButton target={event} className="h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
<Separator />
|
||||
<MediaSection />
|
||||
<Separator />
|
||||
<WalletSection />
|
||||
<Separator />
|
||||
<IconLayoutSection />
|
||||
<Separator />
|
||||
<SessionSection />
|
||||
@@ -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 (
|
||||
<Section
|
||||
title="Lightning wallet"
|
||||
description="Optional. Connect a wallet with Nostr Wallet Connect (NIP-47) to pay zaps in one step, instead of scanning an invoice each time."
|
||||
>
|
||||
{!user ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground">
|
||||
Sign in to connect a wallet.
|
||||
</p>
|
||||
) : connection ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Zap className="size-4 shrink-0 text-amber-500" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">Connected</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{connection.pubkey.slice(0, 12)}… via {connection.relay.replace(/^wss:\/\//, '')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => event.key === 'Enter' && handleConnect()}
|
||||
placeholder="nostr+walletconnect://…"
|
||||
className="font-mono text-xs"
|
||||
aria-label="Nostr Wallet Connect link"
|
||||
/>
|
||||
<Button onClick={handleConnect} disabled={!draft.trim()}>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSection() {
|
||||
const { resetSession } = useWindowManager();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<AuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
|
||||
|
||||
@@ -71,6 +79,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) {
|
||||
<Repeat2 className="size-3.5" aria-hidden />
|
||||
Copy link
|
||||
</Button>
|
||||
<ZapButton target={event} revealed={revealed} />
|
||||
<ReactionButton target={event} />
|
||||
<BookmarkButton target={{ type: 'e', value: event.id }} />
|
||||
</div>
|
||||
|
||||
69
src/components/nostr/ZapButton.tsx
Normal file
69
src/components/nostr/ZapButton.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-7 gap-1.5 px-2 text-xs text-muted-foreground', className)}
|
||||
onClick={handleClick}
|
||||
aria-label={`Zap${totalSats > 0 ? ` — ${totalSats} sats received` : ''}`}
|
||||
>
|
||||
<Zap className="size-3.5" aria-hidden />
|
||||
<span aria-hidden>{totalSats > 0 ? formatSats(totalSats) : ''}</span>
|
||||
</Button>
|
||||
|
||||
<AuthDialog isOpen={authOpen} onClose={() => setAuthOpen(false)} />
|
||||
|
||||
{zapOpen && (
|
||||
<ZapDialog
|
||||
open={zapOpen}
|
||||
onClose={() => setZapOpen(false)}
|
||||
target={target}
|
||||
recipientMetadata={recipient.data?.event}
|
||||
recipientMetadataLoading={recipient.isLoading}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
231
src/components/nostr/ZapDialog.tsx
Normal file
231
src/components/nostr/ZapDialog.tsx
Normal file
@@ -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<Stage>('amount');
|
||||
const [amount, setAmount] = useState<number | null>(21);
|
||||
const [customAmount, setCustomAmount] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [invoice, setInvoice] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="size-4 text-amber-500" aria-hidden />
|
||||
Zap this note
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">Send a Lightning zap for this note.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{effectiveStage === 'amount' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PRESET_AMOUNTS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAmount(preset);
|
||||
setCustomAmount('');
|
||||
}}
|
||||
aria-pressed={amount === preset && !customAmount}
|
||||
className={cn(
|
||||
'rounded-lg border px-2 py-2 text-sm transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
amount === preset && !customAmount
|
||||
? 'border-primary bg-accent text-accent-foreground'
|
||||
: 'border-border hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{formatSats(preset)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="zap-custom-amount" className="text-xs text-muted-foreground">
|
||||
Custom amount (sats)
|
||||
</label>
|
||||
<Input
|
||||
id="zap-custom-amount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={customAmount}
|
||||
onChange={(event) => setCustomAmount(event.target.value)}
|
||||
placeholder="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="zap-comment" className="text-xs text-muted-foreground">
|
||||
Comment (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="zap-comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Great note!"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recipientMetadataLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading this person's zap settings…</p>
|
||||
) : !recipientMetadata ? (
|
||||
<p className="text-xs text-destructive">Could not load this person's profile.</p>
|
||||
) : null}
|
||||
|
||||
<Button className="w-full gap-1.5" onClick={handleSubmit} disabled={!canSubmit}>
|
||||
<Zap className="size-4" aria-hidden />
|
||||
Zap {resolvedAmount ? `${formatSats(resolvedAmount)} sats` : ''}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(effectiveStage === 'requesting' || effectiveStage === 'paying') && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{effectiveStage === 'requesting' ? 'Requesting an invoice…' : 'Waiting for your wallet to pay…'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveStage === 'manual' && invoice && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{errorMessage && (
|
||||
<p className="text-center text-xs text-destructive">{errorMessage} Pay the invoice below instead.</p>
|
||||
)}
|
||||
<div className="rounded-lg border border-border bg-white p-2">
|
||||
<QRCodeCanvas value={`lightning:${invoice}`} size={220} />
|
||||
</div>
|
||||
<p className="text-sm font-medium">{formatSats(amountSats)} sats</p>
|
||||
<div className="flex w-full gap-2">
|
||||
<Input readOnly value={invoice} className="font-mono text-xs" aria-label="Lightning invoice" />
|
||||
<Button type="button" variant="outline" size="icon" onClick={copyInvoice} aria-label="Copy invoice">
|
||||
<Copy className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={`lightning:${invoice}`}>Open in wallet</a>
|
||||
</Button>
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" aria-hidden />
|
||||
Waiting for payment confirmation…
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveStage === 'paid' && (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<CheckCircle2 className="size-10 text-emerald-500" aria-hidden />
|
||||
<p className="text-sm font-medium">Zap sent!</p>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveStage === 'failed' && (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<XCircle className="size-10 text-destructive" aria-hidden />
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
<Button variant="outline" onClick={() => setStage('amount')}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
46
src/hooks/useNwc.ts
Normal file
46
src/hooks/useNwc.ts
Normal file
@@ -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<NwcConnection | null>(
|
||||
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),
|
||||
});
|
||||
}
|
||||
112
src/hooks/useZaps.test.ts
Normal file
112
src/hooks/useZaps.test.ts
Normal file
@@ -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<NostrEvent> = {}, 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);
|
||||
});
|
||||
});
|
||||
162
src/hooks/useZaps.ts
Normal file
162
src/hooks/useZaps.ts
Normal file
@@ -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<NostrEvent[]>({
|
||||
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<string>();
|
||||
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<ZapInvoice, Error, CreateZapInvoiceInput>({
|
||||
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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
53
src/lib/nwc.test.ts
Normal file
53
src/lib/nwc.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
111
src/lib/nwc.ts
Normal file
111
src/lib/nwc.ts
Normal file
@@ -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<string> {
|
||||
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<NostrEvent | undefined> => {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user