Merge remote-tracking branch 'origin/main' into copilot/rename-package-and-establish-version

# Conflicts:
#	README.md

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-07 15:18:56 +00:00
committed by GitHub
12 changed files with 971 additions and 227 deletions

312
README.md
View File

@@ -1,242 +1,124 @@
# LAYER.systems
**The Complete Framework for Building Nostr Clients with AI**
LAYER.systems is a Nostr client designed like a desktop operating system. Instead of
navigating between pages, you open apps in windows that can be moved, resized, stacked,
minimized, and kept open side by side. This makes it possible to read a thread, inspect a
profile, and keep your feed available at the same time.
LAYER.systems is an AI-powered framework for building Nostr applications with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify. Build powerful Nostr applications with AI-first development - from social feeds to private messaging, LAYER.systems provides everything you need to create decentralized apps on the Nostr protocol.
The app is for people who use Nostr and want a focused, multitasking-friendly interface,
as well as contributors interested in building Nostr applications with React and
TypeScript.
## 🚀 Quick Start
## What you can do
Build your Nostr app in 3 simple steps:
- Browse a Following or Global feed, publish notes, and open notes with their replies.
- View profiles, follow or unfollow people, and search notes, replies, hashtags, and users.
- Read NIP-23 long-form articles, save notes and articles, and create web bookmarks.
- Discover picture posts and view live events with their NIP-53 chat.
- Browse Nostr calendar events by month.
- Manage relay connections, inspect connection latency, and configure relays and Blossom
media servers.
- Sign in with a Nostr account, switch between accounts, and use NIP-19 identifiers such
as `npub`, `note`, `nevent`, `nprofile`, and `naddr` as links.
- Save and share reusable Nostr queries with Spells.
### 1. Install & Create
```bash
npm install -g @getstacks/stacks
stacks layer-systems
```
LAYER.systems reads and writes Nostr events through relays. It does not provide a
centralized social database; your relays and signer remain the source of your Nostr data.
Some features require a signed-in account, and availability of content depends on the
relays you use.
### 2. Build with AI
```bash
stacks agent
# Tell Dork AI what you want: "Build a group chat application"
```
## Run locally
### 3. Deploy Instantly
```bash
npm run deploy
# ✅ App deployed to NostrDeploy.com!
```
### Requirements
## ✨ What Makes LAYER.systems Special
- Node.js 22 or newer
- npm
- **🤖 AI-First Development**: Build complete Nostr apps with just one prompt using Dork AI agent
- **⚡ 8 Minutes Average**: From idea to deployed application in minutes, not months
- **🔗 50+ NIPs Supported**: Comprehensive Nostr protocol implementation
- **🎨 Beautiful UI**: 48+ shadcn/ui components with light/dark theme support
- **🔐 Built-in Security**: NIP-07 browser signing, NIP-44 encryption, event validation
- **💰 Payments Ready**: Lightning zaps (NIP-57), Cashu wallets (NIP-60), Wallet Connect (NIP-47)
- **📱 Production Ready**: TypeScript, testing, deployment, and responsive design included
## 🛠 Technology Stack
- **React 18.x**: Stable version with hooks, concurrent rendering, and improved performance
- **TailwindCSS 3.x**: Utility-first CSS framework for styling
- **Vite**: Fast build tool and development server
- **shadcn/ui**: 48+ unstyled, accessible UI components built with Radix UI
- **Nostrify**: Nostr protocol framework for Deno and web
- **React Router**: Client-side routing with BrowserRouter
- **TanStack Query**: Data fetching, caching, and state management
- **TypeScript**: Type-safe JavaScript development
## 🎯 Real-World Examples
### Built with One Prompt
Each of these applications was created with just a single prompt to Dork AI:
- **Group Chat Application**: `"Build me a group chat application"`
- [Live Demo](https://groupchat-74z9j26wq-mks-projects-1f1254c4.vercel.app/)
- **Decentralized Goodreads**: `"Build a decentralized goodreads alternative. Use OpenLibrary API for book data."`
- [Live Demo](https://bookstr123-87phkwjcy-mks-projects-1f1254c4.vercel.app/)
- **Chess Game**: `"Build a chess game with NIP 64"`
- [Live Demo](https://chess-l0d7ms7m3-mks-projects-1f1254c4.vercel.app/chess)
### Production Apps
Real Nostr applications built using LAYER.systems:
- **[Chorus](https://chorus.community/)**: Facebook-style groups on Nostr with built-in eCash wallet
- **[Blobbi](https://www.blobbi.pet/)**: Digital pet companions that live forever on the decentralized web
- **[Treasures](https://treasures.to/)**: Decentralized geocaching adventure powered by Nostr
[Browse more apps made with LAYER.systems →](https://nostrhub.io/apps/t/layer-systems/)
## 🔧 Core Features
### Authentication & Users
- `LoginArea` component with account switching
- `useCurrentUser` hook for authentication state
- `useAuthor` hook for fetching user profiles
- NIP-07 browser signing support
- Multi-account management
### Nostr Protocol Support
- **Social Features**: User profiles (NIP-01), follow lists (NIP-02), reactions (NIP-25), reposts (NIP-18)
- **Messaging**: Private DMs (NIP-17), public chat (NIP-28), group chat (NIP-29), encryption (NIP-44)
- **Payments**: Lightning zaps (NIP-57), Cashu wallets (NIP-60), Nutzaps (NIP-61), Wallet Connect (NIP-47)
- **Content**: Long-form articles (NIP-23), file metadata (NIP-94), live events (NIP-53), calendars (NIP-52)
### Data Management
- `useNostr` hook for querying and publishing
- `useNostrPublish` hook with automatic client tagging
- Event validation and filtering
- Infinite scroll with TanStack Query
- Multi-relay support
### UI Components
- 48+ shadcn/ui components (buttons, forms, dialogs, etc.)
- `NoteContent` component for rich text rendering
- `EditProfileForm` for profile management
- `RelaySelector` for relay switching
- `CommentsSection` for threaded discussions
- Light/dark theme system
### Media & Files
- `useUploadFile` hook with Blossom server integration
- NIP-94 compatible file metadata
- Image and video support
- File attachment to events
### Advanced Features
- NIP-19 identifier routing (`npub1`, `note1`, `nevent1`, `naddr1`)
- Cryptographic operations (encryption/decryption)
- Lightning payments and zaps
- Real-time event subscriptions
- Responsive design with mobile support
## 🤖 AI Development with Dork
LAYER.systems includes Dork, a built-in AI agent that understands your codebase and Nostr protocols:
### Supported AI Providers
Configure your AI provider with `stacks configure`:
- **OpenRouter** ([openrouter.ai](https://openrouter.ai/)): Enter your API key from settings
- **Routstr** ([routstr.com](https://www.routstr.com/)): Use Cashu tokens for payment
- **PayPerQ** ([ppq.ai](https://ppq.ai/)): OpenAI-compatible API
### How Dork Works
- **Context-Aware**: Understands your entire codebase and project structure
- **Nostr Expert**: Built-in knowledge of 50+ NIPs and best practices
- **Instant Implementation**: Makes changes directly to your code following React/TypeScript best practices
Example prompts:
```bash
"Add user profiles with avatars and bio"
"Implement NIP-17 private messaging"
"Add a dark mode toggle"
"Create a marketplace with NIP-15"
```
## 📁 Project Structure
```
src/
├── components/ # UI components
│ ├── ui/ # shadcn/ui components (48+ available)
│ ├── auth/ # Authentication components
│ └── comments/ # Comment system components
├── hooks/ # Custom React hooks
│ ├── useNostr # Core Nostr integration
│ ├── useAuthor # User profile data
│ ├── useCurrentUser # Authentication state
│ ├── useNostrPublish # Event publishing
│ ├── useUploadFile # File uploads
│ └── useZaps # Lightning payments
├── pages/ # Page components
├── lib/ # Utility functions
├── contexts/ # React context providers
└── test/ # Testing utilities
```
## 🎨 UI Components
LAYER.systems includes 48+ shadcn/ui components:
**Layout**: Card, Separator, Sheet, Sidebar, ScrollArea, Resizable
**Navigation**: Breadcrumb, NavigationMenu, Menubar, Tabs, Pagination
**Forms**: Button, Input, Textarea, Select, Checkbox, RadioGroup, Switch, Slider
**Feedback**: Alert, AlertDialog, Toast, Progress, Skeleton
**Overlay**: Dialog, Popover, HoverCard, Tooltip, ContextMenu, DropdownMenu
**Data Display**: Table, Avatar, Badge, Calendar, Chart, Carousel
**And many more...
## 🔐 Security & Best Practices
- **Never use `any` type**: Always use proper TypeScript types
- **Event validation**: Filter events through validator functions for custom kinds
- **Efficient queries**: Minimize separate queries to avoid rate limiting
- **Proper error handling**: Graceful handling of invalid NIP-19 identifiers
- **Secure authentication**: Use signer interface, never request private keys directly
## 📱 Responsive Design
- Mobile-first approach with Tailwind breakpoints
- `useIsMobile` hook for responsive behavior
- Touch-friendly interactions
- Optimized for all screen sizes
## 🧪 Testing
- Vitest with jsdom environment
- React Testing Library with jest-dom matchers
- `TestApp` component provides all necessary context providers
- Mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver)
## 🚀 Deployment
Built-in deployment to NostrDeploy.com:
Clone the repository, install dependencies, and start the Vite development server:
```bash
npm run deploy
git clone https://github.com/layer-systems/website.git
cd website
npm install
npm run dev
```
Your app goes live instantly with:
- Automatic builds
- CDN distribution
- HTTPS support
- Custom domains available
The development server listens on <http://localhost:8080>. The app has no required
environment variables; relay and media-server settings are managed in the app.
## 📚 Documentation
Useful commands:
For detailed documentation on building Nostr applications with LAYER.systems:
```bash
npm run test # Type-check, lint, run Vitest, and create a production build
npm run build # Create the production bundle in dist/
```
- [Tutorial](https://soapbox.pub/blog/layer-systems-tutorial)
- [Nostr Protocol Documentation](https://nostr.com)
- [shadcn/ui Components](https://ui.shadcn.com)
`npm run build` also creates `dist/404.html`, which supports client-side routes when the
build is deployed as a static site.
## 🤝 Contributing
## Production deployment
LAYER.systems is open source and welcomes contributions. The framework is designed to be:
The repository includes a GitHub Actions workflow in
[`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml). It runs on pushes to
`main` (or manually from the Actions tab), builds the app with Node.js 22, and deploys
`dist/` to GitHub Pages.
- **Extensible**: Easy to add new NIPs and features
- **Maintainable**: Clean architecture with TypeScript
- **Testable**: Comprehensive testing setup included
- **Documented**: Clear patterns and examples
For another static host, run `npm run build` and publish the generated `dist/` directory.
Configure the host to serve `dist/404.html` for unknown paths so direct NIP-19 links
continue to load the client application.
## 📄 License
## Technology
LAYER.systems is dedicated to the **public domain**.
- [React 19](https://react.dev/) and [TypeScript](https://www.typescriptlang.org/)
- [Vite](https://vite.dev/) for development and production builds
- [Tailwind CSS](https://tailwindcss.com/) and [shadcn/ui](https://ui.shadcn.com/) for UI
- [Nostrify](https://www.npmjs.com/package/@nostrify/nostrify) and
[nostr-tools](https://github.com/nbd-wtf/nostr-tools) for Nostr integration
- [React Router](https://reactrouter.com/) for the root and NIP-19 routes
- [TanStack Query](https://tanstack.com/query) for fetching and caching
- [Vitest](https://vitest.dev/) and
[React Testing Library](https://testing-library.com/docs/react-testing-library/intro/)
for tests
To the extent possible under law, the authors have waived all copyright and related or neighboring rights to LAYER.systems. You are free to copy, modify, distribute, and use this software for any purpose, commercial or non-commercial, without asking permission and without attribution.
The application is organized into three main layers:
Build amazing Nostr applications and help grow the decentralized web!
```text
src/os/ Window manager, persistence, layout, and keyboard shortcuts
src/components/os/ Desktop shell, menu bar, windows, and mobile app shell
src/apps/ Nostr and system apps rendered inside windows
src/components/ Shared UI, authentication, and Nostr components
src/hooks/ Data access and application hooks
```
---
Apps are registered in [`src/os/registry.ts`](./src/os/registry.ts). Each app is lazy
loaded and receives a small window-oriented contract, so adding an app normally does not
require changing the router. Read [the app guide](./docs/apps.md) before adding one.
**"Vibed with LAYER.systems"** - [Learn more about LAYER.systems](https://soapbox.pub/layer-systems)
## Documentation
*Build your Nostr app in minutes, not months. Start with AI, deploy instantly.*
- [Documentation index](./docs/README.md)
- [Window manager](./docs/window-manager.md)
- [App development](./docs/apps.md)
- [Nostr data access and safety](./docs/nostr.md)
- [Style guide](./docs/styleguide.md)
- [Project architecture and design decisions](./PLAN.md)
- [Versioning and release workflow](./docs/versioning.md)
For protocol reference, see the [Nostr protocol documentation](https://nostr.com/).
## Contributing
1. Create a branch for your change.
2. Install dependencies with `npm install`.
3. Make the smallest focused change that fits the existing architecture.
4. Run `npm run test`.
5. Open a pull request describing the behavior you changed and how you verified it.
When working with Nostr content, treat events, URLs, and profile metadata as untrusted
input. Follow the validation and URL-sanitization patterns in
[`docs/nostr.md`](./docs/nostr.md), and do not use `any` in TypeScript.
## License
LAYER.systems is dedicated to the public domain. You are free to copy, modify,
distribute, and use it for any purpose, commercial or non-commercial.

11
package-lock.json generated
View File

@@ -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": [

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>

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

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