docs: Add comprehensive LNURL and Lightning study guide

Complete reference for applesauce-common's LNURL and Lightning helpers:
- LNURL parsing (addresses, decode, invoice fetching)
- NIP-57 zap helpers (amount, sender, recipient, validation)
- Bolt11 invoice parsing
- Reactive Zap cast and models
- Zap splits operations
- Profile integration examples
- Complete zap flow examples
- Best practices and anti-patterns

Discovered helpers:
- parseLightningAddress(), decodeLNURL(), getInvoice()
- getZapAmount(), getZapSender(), getZapRecipient()
- getZapRequest(), getZapPayment(), isValidZap()
- EventZapsModel, SentZapsModel, ReceivedZapsModel
- Zap cast with reactive event$ observable
This commit is contained in:
Claude
2026-01-17 21:23:47 +00:00
parent c7cced2a9e
commit e7e522f255

713
STUDY-LNURL-LIGHTNING.md Normal file
View File

@@ -0,0 +1,713 @@
# Study: Applesauce LNURL and Lightning Helpers
This document provides a comprehensive overview of LNURL and Lightning-related functionality in applesauce v5.
## Overview
Applesauce v5's `applesauce-common` package provides comprehensive helpers for working with:
- **LNURL** - Lightning Network URL protocol for payments
- **Zaps** - NIP-57 Lightning tips with proof on Nostr
- **Bolt11** - Lightning invoice parsing
- **Zap Splits** - Multi-recipient zap distribution
## Package Structure
```
applesauce-common/
├── helpers/
│ ├── lnurl.js # LNURL parsing and invoice fetching
│ ├── zap.js # NIP-57 zap helpers
│ └── bolt11.js # Lightning invoice parsing
├── casts/
│ └── zap.js # Reactive Zap class
├── models/
│ └── zaps.js # Zap query models
└── operations/
└── zap-split.js # Zap split operations
```
## LNURL Helpers
Location: `applesauce-common/helpers/lnurl`
### Functions
#### `parseLightningAddress(address: string): URL | undefined`
Parses a Lightning Address (lud16) into a LNURL callback URL.
```typescript
import { parseLightningAddress } from 'applesauce-common/helpers/lnurl';
// Parse Lightning Address (user@domain.com)
const callbackUrl = parseLightningAddress('alice@getalby.com');
// Returns: URL { https://getalby.com/.well-known/lnurlp/alice }
```
**Format**: Lightning Address follows the email format: `name@domain.com`
**Conversion**: Transforms to `https://{domain}/.well-known/lnurlp/{name}`
#### `decodeLNURL(lnurl: string): URL | undefined`
Decodes a bech32-encoded LNURL string into a URL.
```typescript
import { decodeLNURL } from 'applesauce-common/helpers/lnurl';
// Decode LNURL (lnurl1...)
const url = decodeLNURL('lnurl1dp68gurn8ghj7ctsdyh85etzv4jx2efwd9hj7mrww4excup0dajx2mrv92x2um5v56kuvmhv9jnxc3k8qa5vtpexu');
// Returns: URL object with decoded callback
```
**Format**: LNURL is a bech32-encoded URL with prefix `lnurl`
#### `parseLNURLOrAddress(addressOrLNURL: string): URL | undefined`
Universal parser that handles both Lightning Addresses and LNURL strings.
```typescript
import { parseLNURLOrAddress } from 'applesauce-common/helpers/lnurl';
// Works with either format
const url1 = parseLNURLOrAddress('alice@getalby.com');
const url2 = parseLNURLOrAddress('lnurl1...');
```
**Use Case**: When you don't know which format the user will provide
#### `getInvoice(callback: URL): Promise<string>`
Requests a bolt11 invoice from a LNURL callback URL.
```typescript
import { parseLightningAddress, getInvoice } from 'applesauce-common/helpers/lnurl';
// Get invoice for a Lightning Address
const callbackUrl = parseLightningAddress('alice@getalby.com');
if (callbackUrl) {
const invoice = await getInvoice(callbackUrl);
// Returns: bolt11 invoice string (lnbc...)
}
```
**Flow**:
1. Parse Lightning Address or LNURL to get callback URL
2. Call `getInvoice()` with callback URL
3. Receive bolt11 invoice string
4. Pay invoice using Lightning wallet
## Bolt11 Helpers
Location: `applesauce-common/helpers/bolt11`
### Types
```typescript
type ParsedInvoice = {
paymentRequest: string; // Original invoice string
description: string; // Invoice description
amount?: number; // Amount in millisats (optional)
timestamp: number; // Creation timestamp
expiry: number; // Expiration time
paymentHash?: string; // Payment hash (optional)
}
```
### Functions
#### `parseBolt11(paymentRequest: string): ParsedInvoice`
Parses a Lightning bolt11 invoice into structured data.
```typescript
import { parseBolt11 } from 'applesauce-common/helpers/bolt11';
const invoice = parseBolt11('lnbc10n1...');
console.log(invoice.amount); // 10000 (msats)
console.log(invoice.description); // "Coffee"
console.log(invoice.expiry); // 3600 (seconds)
```
## Zap Helpers (NIP-57)
Location: `applesauce-common/helpers/zap`
### Core Concept
Zaps are Lightning payments with cryptographic proof on Nostr. The flow:
1. User wants to zap (tip) content or a profile
2. Get recipient's Lightning Address from their profile (`lud16` or `lud06`)
3. Create zap request event (kind 9734)
4. Get invoice from LNURL callback
5. Pay invoice via Lightning
6. LNURL provider publishes zap receipt (kind 9735) to Nostr relays
7. Zap receipt proves payment occurred
### Types
```typescript
type ZapEvent = KnownEvent<kinds.Zap>; // kind 9735
type ZapSplit = {
pubkey: string; // Recipient pubkey
percent: number; // Percentage of zap (calculated)
weight: number; // Relative weight
relay?: string; // Preferred relay
};
```
### Validation
#### `isValidZap(zap?: NostrEvent): zap is ZapEvent`
Checks if a zap event is valid (has required fields). Does NOT validate LNURL address.
```typescript
import { isValidZap } from 'applesauce-common/helpers/zap';
if (isValidZap(event)) {
// TypeScript now knows event is ZapEvent
const amount = getZapAmount(event); // Won't be undefined
}
```
**Note**: This only validates structure, not cryptographic signatures or LNURL
### Extraction Helpers
All helpers cache internally - no need for `useMemo`!
#### `getZapAmount(zap: NostrEvent): number | undefined`
Returns the zap amount in millisats.
```typescript
import { getZapAmount } from 'applesauce-common/helpers/zap';
// Returns amount in millisats (1 sat = 1000 msats)
const msats = getZapAmount(zapEvent);
const sats = msats ? Math.floor(msats / 1000) : 0;
```
#### `getZapSender(zap: NostrEvent): string | undefined`
Returns the sender's pubkey (who sent the zap).
```typescript
import { getZapSender } from 'applesauce-common/helpers/zap';
const senderPubkey = getZapSender(zapEvent);
```
#### `getZapRecipient(zap: NostrEvent): string | undefined`
Returns the recipient's pubkey (who received the zap).
```typescript
import { getZapRecipient } from 'applesauce-common/helpers/zap';
const recipientPubkey = getZapRecipient(zapEvent);
```
#### `getZapRequest(zap: NostrEvent): NostrEvent | undefined`
Returns the zap request event (kind 9734) embedded in the zap receipt.
```typescript
import { getZapRequest } from 'applesauce-common/helpers/zap';
const zapRequest = getZapRequest(zapReceipt);
if (zapRequest) {
const comment = zapRequest.content; // Zap comment/message
}
```
**Use Case**: Get the zap comment (message attached to zap)
#### `getZapPayment(zap: NostrEvent): ParsedInvoice | undefined`
Returns the parsed bolt11 invoice from the zap receipt.
```typescript
import { getZapPayment } from 'applesauce-common/helpers/zap';
const payment = getZapPayment(zapEvent);
if (payment) {
console.log(payment.paymentHash);
console.log(payment.amount);
}
```
#### `getZapPreimage(zap: NostrEvent): string | undefined`
Returns the payment preimage (proof of payment).
```typescript
import { getZapPreimage } from 'applesauce-common/helpers/zap';
const preimage = getZapPreimage(zapEvent);
```
#### `getZapEventPointer(zap: NostrEvent): EventPointer | null`
Gets the EventPointer for the event that was zapped (if zapping a specific event).
```typescript
import { getZapEventPointer } from 'applesauce-common/helpers/zap';
const pointer = getZapEventPointer(zapEvent);
if (pointer) {
// This zap was for a specific event
console.log(pointer.id); // Event ID
}
```
#### `getZapAddressPointer(zap: NostrEvent): AddressPointer | null`
Gets the AddressPointer for the replaceable event that was zapped.
```typescript
import { getZapAddressPointer } from 'applesauce-common/helpers/zap';
const pointer = getZapAddressPointer(zapEvent);
if (pointer) {
// This zap was for a replaceable event
console.log(pointer.kind);
console.log(pointer.identifier);
}
```
#### `getZapSplits(event: NostrEvent): ZapSplit[] | undefined`
Returns the zap splits configured on an event (for multi-recipient zaps).
```typescript
import { getZapSplits } from 'applesauce-common/helpers/zap';
const splits = getZapSplits(event);
if (splits) {
splits.forEach(split => {
console.log(`${split.pubkey}: ${split.percent}%`);
});
}
```
**Use Case**: Events can specify multiple recipients for zaps (value-for-value splits)
## Zap Cast (Reactive)
Location: `applesauce-common/casts/zap`
The `Zap` cast provides a reactive, object-oriented interface to zap events.
### Usage
```typescript
import { castEvent } from 'applesauce-common/casts';
import { Zap } from 'applesauce-common/casts/zap';
import { use$ } from 'applesauce-react/hooks';
function ZapComponent({ zapEvent }) {
const zap = castEvent(zapEvent, Zap, eventStore);
// Synchronous properties
console.log(zap.amount); // Amount in msats
console.log(zap.payment); // ParsedInvoice
console.log(zap.preimage); // Payment preimage
console.log(zap.request); // Zap request event
console.log(zap.sender); // User cast for sender
console.log(zap.recipient); // User cast for recipient
console.log(zap.eventPointer); // EventPointer | null
console.log(zap.addressPointer); // AddressPointer | null
// Reactive observable - zapped event
const zappedEvent = use$(zap.event$);
return (
<div>
<p>{zap.sender.name} zapped {zap.amount / 1000} sats</p>
{zappedEvent && <EventCard event={zappedEvent} />}
</div>
);
}
```
### Properties
- `sender: User` - User cast for the zap sender
- `recipient: User` - User cast for the zap recipient
- `payment: ParsedInvoice` - Parsed bolt11 invoice
- `amount: number` - Amount in millisats
- `preimage: string | undefined` - Payment preimage
- `request: NostrEvent` - Zap request event (kind 9734)
- `eventPointer: EventPointer | null` - Pointer to zapped event
- `addressPointer: AddressPointer | null` - Pointer to zapped address
- `event$: Observable<NostrEvent | undefined>` - Observable of zapped event
## Zap Models (Queries)
Location: `applesauce-common/models/zaps`
Models provide reactive queries for zap events.
### EventZapsModel
Gets all zaps for a specific event.
```typescript
import { EventZapsModel } from 'applesauce-common/models/zaps';
import { use$ } from 'applesauce-react/hooks';
function EventZaps({ event }) {
const zaps = use$(() => EventZapsModel(event), [event.id]);
const totalSats = zaps.reduce((sum, zap) =>
sum + (getZapAmount(zap) / 1000), 0
);
return <div>{zaps.length} zaps - {totalSats} sats total</div>;
}
```
**Accepts**: `string | EventPointer | AddressPointer | NostrEvent`
### SentZapsModel
Gets all zaps sent by a user.
```typescript
import { SentZapsModel } from 'applesauce-common/models/zaps';
import { use$ } from 'applesauce-react/hooks';
function UserSentZaps({ pubkey }) {
const sentZaps = use$(() => SentZapsModel(pubkey), [pubkey]);
return <div>Sent {sentZaps.length} zaps</div>;
}
```
### ReceivedZapsModel
Gets all zaps received by a user.
```typescript
import { ReceivedZapsModel } from 'applesauce-common/models/zaps';
import { use$ } from 'applesauce-react/hooks';
function UserReceivedZaps({ pubkey }) {
const receivedZaps = use$(() => ReceivedZapsModel(pubkey), [pubkey]);
const totalReceived = receivedZaps.reduce((sum, zap) =>
sum + (getZapAmount(zap) / 1000), 0
);
return <div>Received {totalReceived} sats</div>;
}
```
## Zap Operations (Event Creation)
Location: `applesauce-common/operations/zap-split`
Operations for creating events with zap splits.
### setZapSplitTags
Override the zap splits on an event.
```typescript
import { setZapSplitTags } from 'applesauce-common/operations/zap-split';
import { EventFactory } from 'applesauce-core/event-factory';
const factory = new EventFactory({ signer });
// Create event with zap splits
const draft = await factory.build(
NoteBlueprint({ content: 'Hello!' }),
setZapSplitTags([
{ pubkey: 'alice-pubkey', weight: 2 }, // Gets 66%
{ pubkey: 'bob-pubkey', weight: 1 }, // Gets 33%
])
);
```
**Type**: `Omit<ZapSplit, "percent" | "relay">[]`
Weights are converted to percentages automatically.
### setZapSplit
Creates the necessary operations for zap options.
```typescript
import { setZapSplit } from 'applesauce-common/operations/zap-split';
const draft = await factory.build(
NoteBlueprint({ content: 'Hello!' }),
setZapSplit({
splits: [
{ pubkey: 'alice-pubkey', weight: 1 },
{ pubkey: 'bob-pubkey', weight: 1 },
]
})
);
```
## Profile Integration
Lightning Addresses are stored in user profiles (kind 0 metadata).
```typescript
// Profile metadata structure
interface ProfileMetadata {
name?: string;
lud06?: string; // LNURL (deprecated, use lud16)
lud16?: string; // Lightning Address (preferred)
// ... other fields
}
```
### Getting Lightning Address from Profile
```typescript
import { useProfile } from '@/hooks/useProfile';
import { parseLightningAddress } from 'applesauce-common/helpers/lnurl';
function ZapButton({ pubkey }) {
const profile = useProfile(pubkey);
const lightningAddress = profile?.lud16 || profile?.lud06;
const handleZap = async () => {
if (!lightningAddress) {
alert('No Lightning Address');
return;
}
// Parse to callback URL
const callbackUrl = parseLightningAddress(lightningAddress);
if (!callbackUrl) return;
// Get invoice
const invoice = await getInvoice(callbackUrl);
// Pay with webln or show QR code
if (window.webln) {
await window.webln.sendPayment(invoice);
}
};
return (
<button onClick={handleZap} disabled={!lightningAddress}>
Zap
</button>
);
}
```
## Complete Zap Flow Example
Here's a complete example of creating and displaying zaps:
```typescript
import {
getZapAmount,
getZapSender,
getZapRequest,
isValidZap,
getZapEventPointer,
} from 'applesauce-common/helpers/zap';
import { parseLightningAddress, getInvoice } from 'applesauce-common/helpers/lnurl';
import { useNostrEvent } from '@/hooks/useNostrEvent';
import { useProfile } from '@/hooks/useProfile';
// Display a zap receipt
function ZapReceipt({ event }) {
// Validate
if (!isValidZap(event)) {
return <div>Invalid zap</div>;
}
// Get zap details (helpers cache internally - no useMemo needed!)
const zapSender = getZapSender(event);
const zapAmount = getZapAmount(event);
const zapRequest = getZapRequest(event);
const eventPointer = getZapEventPointer(event);
// Fetch sender profile
const senderProfile = useProfile(zapSender);
// Fetch zapped event
const zappedEvent = useNostrEvent(eventPointer || undefined);
// Get comment
const comment = zapRequest?.content || null;
// Convert to sats
const sats = Math.floor(zapAmount / 1000);
return (
<div>
<div>
{senderProfile?.name || 'Anonymous'} zapped {sats.toLocaleString()} sats
</div>
{comment && <p>{comment}</p>}
{zappedEvent && <EventCard event={zappedEvent} />}
</div>
);
}
// Send a zap
async function sendZap(recipientPubkey: string, amountSats: number, comment?: string) {
// 1. Get recipient's Lightning Address
const profile = await getProfile(recipientPubkey);
const lightningAddress = profile?.lud16 || profile?.lud06;
if (!lightningAddress) {
throw new Error('No Lightning Address found');
}
// 2. Parse Lightning Address
const callbackUrl = parseLightningAddress(lightningAddress);
if (!callbackUrl) {
throw new Error('Invalid Lightning Address');
}
// 3. Create zap request event (kind 9734)
const zapRequest = await factory.sign({
kind: 9734,
content: comment || '',
tags: [
['p', recipientPubkey],
['amount', String(amountSats * 1000)], // Convert to msats
['relays', 'wss://relay1.com', 'wss://relay2.com'],
],
created_at: Math.floor(Date.now() / 1000),
});
// 4. Add zap request to callback URL
const invoiceUrl = new URL(callbackUrl);
invoiceUrl.searchParams.set('amount', String(amountSats * 1000));
invoiceUrl.searchParams.set('nostr', JSON.stringify(zapRequest));
// 5. Get invoice
const invoice = await getInvoice(invoiceUrl);
// 6. Pay invoice (using WebLN or show QR)
if (window.webln) {
await window.webln.sendPayment(invoice);
return { success: true };
} else {
// Show QR code with invoice
return { invoice };
}
}
```
## Best Practices
### 1. No useMemo for Helpers
All applesauce helpers cache internally using symbols. Don't wrap them in `useMemo`:
```typescript
// ❌ WRONG - Unnecessary memoization
const amount = useMemo(() => getZapAmount(event), [event]);
const sender = useMemo(() => getZapSender(event), [event]);
// ✅ CORRECT - Helpers cache internally
const amount = getZapAmount(event);
const sender = getZapSender(event);
```
### 2. Validate Zaps Before Using
Always validate zap events before extracting data:
```typescript
if (isValidZap(zapEvent)) {
const amount = getZapAmount(zapEvent); // TypeScript knows this won't be undefined
}
```
### 3. Prefer lud16 over lud06
`lud16` (Lightning Address) is the modern format. Fall back to `lud06` (LNURL) for compatibility:
```typescript
const address = profile?.lud16 || profile?.lud06;
```
### 4. Use Models for Reactive Queries
For reactive lists of zaps, use the models:
```typescript
// ✅ CORRECT - Reactive, auto-updates
const zaps = use$(() => EventZapsModel(event), [event.id]);
// ❌ WRONG - Manual subscription management
const [zaps, setZaps] = useState([]);
useEffect(() => {
const sub = eventStore.timeline(filter).subscribe(setZaps);
return () => sub.unsubscribe();
}, []);
```
### 5. Handle Missing Lightning Addresses
Not all users have Lightning Addresses. Always check before attempting to zap:
```typescript
const canZap = !!(profile?.lud16 || profile?.lud06);
return (
<button disabled={!canZap}>
{canZap ? '⚡ Zap' : 'No Lightning Address'}
</button>
);
```
## Integration with Grimoire
Current usage in Grimoire:
1. **ProfileViewer** (`src/components/ProfileViewer.tsx:438-444`)
- Displays `lud16` Lightning Address in profile details
2. **ZapReceiptRenderer** (`src/components/nostr/kinds/ZapReceiptRenderer.tsx`)
- Renders kind 9735 zap receipt events
- Uses `getZapAmount`, `getZapSender`, `getZapRequest`, `getZapEventPointer`, `isValidZap`
- **Note**: Currently uses `useMemo` unnecessarily (could be removed per best practices)
3. **ProfileMetadata** (`src/types/profile.ts:9-10`)
- Defines `lud06` and `lud16` fields
## Related NIPs
- **NIP-57**: Lightning Zaps - https://github.com/nostr-protocol/nips/blob/master/57.md
- **NIP-47**: Nostr Wallet Connect - Remote wallet control
- **NIP-05**: NIP-05 Mapping - Often includes relay hints for zap receipts
## Resources
- LNURL Spec: https://github.com/lnurl/luds
- Lightning Network: https://lightning.network/
- WebLN: https://www.webln.guide/
## Summary
The applesauce LNURL and Lightning helpers provide:
**Complete LNURL support** - Parse addresses, decode LNURL, fetch invoices
**Comprehensive zap helpers** - Extract all data from zap receipts
**Bolt11 parsing** - Parse Lightning invoices
**Reactive models** - Query zaps with RxJS observables
**Zap splits** - Multi-recipient value distribution
**Type safety** - Full TypeScript definitions
**Internal caching** - No manual memoization needed
**NIP-57 compliance** - Full support for Lightning Zaps protocol
Use these helpers to integrate Lightning payments and tips into your Nostr application!