From e7e522f255bc86dca177d36ca6c965a405d96bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Jan 2026 21:23:47 +0000 Subject: [PATCH] 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 --- STUDY-LNURL-LIGHTNING.md | 713 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 713 insertions(+) create mode 100644 STUDY-LNURL-LIGHTNING.md diff --git a/STUDY-LNURL-LIGHTNING.md b/STUDY-LNURL-LIGHTNING.md new file mode 100644 index 0000000..2ba4beb --- /dev/null +++ b/STUDY-LNURL-LIGHTNING.md @@ -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` + +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; // 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 ( +
+

{zap.sender.name} zapped {zap.amount / 1000} sats

+ {zappedEvent && } +
+ ); +} +``` + +### 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` - 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
{zaps.length} zaps - {totalSats} sats total
; +} +``` + +**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
Sent {sentZaps.length} zaps
; +} +``` + +### 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
Received {totalReceived} sats
; +} +``` + +## 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[]` + +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 ( + + ); +} +``` + +## 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
Invalid zap
; + } + + // 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 ( +
+
+ ⚡ {senderProfile?.name || 'Anonymous'} zapped {sats.toLocaleString()} sats +
+ {comment &&

{comment}

} + {zappedEvent && } +
+ ); +} + +// 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 ( + +); +``` + +## 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!