Claude-skill-registry lightning-address
Use when implementing Lightning address functionality - provides complete patterns for resolving Lightning addresses to invoices, generating invoices from addresses, displaying Lightning addresses in UI, and integrating with QR codes
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/lightning-address" ~/.claude/skills/majiayu000-claude-skill-registry-lightning-address && rm -rf "$T"
skills/data/lightning-address/SKILL.mdLightning Address Implementation
Overview
Complete implementation guide for Lightning addresses (LNURL-pay protocol). Supports resolving Lightning addresses to invoices, generating invoices with amount and optional comments, displaying Lightning addresses in UI, and integrating with QR code functionality.
Core Capabilities:
- Resolve Lightning addresses to invoices via LNURL-pay protocol
- Generate invoices from Lightning addresses with amount validation
- Display Lightning addresses in wallet UI with copy/QR functionality
- Handle min/max amount constraints from providers
- Support optional comments in invoice requests
- Comprehensive error handling and validation
- QR code integration for Lightning addresses
Prerequisites
No external packages required - uses native
fetch API for HTTP requests.
Optional dependencies:
- QR code generation (see
skill)qr-code-generator - Optional user feedback (console.log, toast notifications, or silent)
Implementation Checklist
- Implement Lightning address validation
- Implement LNURL-pay endpoint resolution
- Implement invoice generation from Lightning address
- Add amount validation (min/max bounds)
- Add optional comment support
- Create React hook wrapper
- Add UI components for Lightning address display
- Integrate with QR code generation
- Add error handling and user feedback
Part 1: Understanding Lightning Addresses
Lightning Address Format
Lightning addresses follow email-like format:
username@domain.com
Examples:
alice@strike.mesatoshi@getalby.comuser@npubx.cash
LNURL-pay Protocol Flow
Lightning addresses use the LNURL-pay protocol (LNURL specification):
-
Resolve Address → Fetch LNURL-pay endpoint
- URL:
https://domain.com/.well-known/lnurlp/username - Returns: LNURL-pay metadata (min/max amounts, callback URL)
- URL:
-
Request Invoice → Call callback URL with amount
- URL:
{callback}?amount={millisats}&comment={optional} - Returns: BOLT11 invoice
- URL:
LNURL-pay Response Structure
interface LNURLPayResponse { status: 'OK' | 'ERROR'; tag: 'payRequest'; commentAllowed?: number; // Max comment length in characters minSendable: number; // Minimum amount in millisats maxSendable: number; // Maximum amount in millisats metadata: string; // JSON string with payment metadata callback: string; // URL to request invoice }
Invoice Response Structure
interface LNURLInvoiceResponse { status: 'OK' | 'ERROR'; reason?: string; // Error reason if status is ERROR pr?: string; // BOLT11 invoice (payment request) }
Part 2: Core Implementation
Lightning Address Validation
CRITICAL: Always validate Lightning address format before attempting resolution.
// lib/lightningAddress.ts /** * Validate if a string is a valid Lightning address * @param address - The string to validate * @returns true if the string appears to be a valid Lightning address */ export function isValidLightningAddress(address: string): boolean { if (!address || typeof address !== 'string') { return false; } const trimmed = address.trim(); // Lightning addresses follow email format: username@domain.com // Must contain exactly one @ symbol const parts = trimmed.split('@'); if (parts.length !== 2) { return false; } const [username, domain] = parts; // Username must be non-empty if (!username || username.length === 0) { return false; } // Domain must be valid (contains at least one dot, valid TLD) if (!domain || !domain.includes('.')) { return false; } // Basic domain validation (must have TLD) const domainParts = domain.split('.'); if (domainParts.length < 2 || domainParts[domainParts.length - 1].length < 2) { return false; } return true; }
Usage:
if (isValidLightningAddress(address)) { // Safe to resolve const invoice = await getInvoiceFromLightningAddress(address, 1000); } else { // Invalid address format console.error('Invalid Lightning address format'); }
LNURL-pay Endpoint Resolution
Resolve Lightning address to LNURL-pay endpoint:
// lib/lightningAddress.ts /** * Resolve Lightning address to LNURL-pay endpoint * @param lightningAddress - Lightning address (username@domain.com) * @returns LNURL-pay response with metadata */ export async function resolveLightningAddress( lightningAddress: string ): Promise<LNURLPayResponse> { // Validate input if (!isValidLightningAddress(lightningAddress)) { throw new Error('Invalid Lightning address format. Expected: username@domain.com'); } // Parse Lightning address const [username, domain] = lightningAddress.split('@'); // Construct LNURL-pay endpoint const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`; // Fetch LNURL-pay metadata const response = await fetch(lnurlEndpoint, { headers: { 'Accept': 'application/json' }, }); if (!response.ok) { throw new Error( `Failed to resolve Lightning address: ${response.status} ${response.statusText}` ); } const data: LNURLPayResponse = await response.json(); // Validate response if (data.status && data.status !== 'OK') { throw new Error('LNURL endpoint returned error status'); } if (data.tag !== 'payRequest') { throw new Error(`Unexpected LNURL tag: ${data.tag}`); } return data; }
Invoice Generation
Generate invoice from Lightning address:
// lib/lightningAddress.ts interface GetInvoiceParams { lightningAddress: string; amountSats: number; comment?: string; } /** * Get BOLT11 invoice from Lightning address * @param params - Lightning address, amount in sats, and optional comment * @returns BOLT11 invoice string * @throws Error if resolution fails */ export async function getInvoiceFromLightningAddress( params: GetInvoiceParams ): Promise<string> { const { lightningAddress, amountSats, comment } = params; // Validate inputs if (!isValidLightningAddress(lightningAddress)) { throw new Error('Invalid Lightning address format. Expected: username@domain.com'); } if (amountSats <= 0) { throw new Error('Amount must be greater than 0'); } // Step 1: Resolve LNURL-pay endpoint const lnurlData = await resolveLightningAddress(lightningAddress); // Step 2: Validate amount against min/max bounds const minSats = lnurlData.minSendable / 1000; const maxSats = lnurlData.maxSendable / 1000; if (amountSats < minSats) { throw new Error(`Amount ${amountSats} sats is below minimum ${minSats} sats`); } if (amountSats > maxSats) { throw new Error(`Amount ${amountSats} sats exceeds maximum ${maxSats} sats`); } // Step 3: Request invoice from callback URL const amountMsats = amountSats * 1000; const callbackUrl = new URL(lnurlData.callback); callbackUrl.searchParams.set('amount', amountMsats.toString()); // Add comment if provided and allowed if (comment && lnurlData.commentAllowed) { if (comment.length > lnurlData.commentAllowed) { throw new Error( `Comment length ${comment.length} exceeds maximum ${lnurlData.commentAllowed} characters` ); } callbackUrl.searchParams.set('comment', comment); } // Fetch invoice const invoiceResponse = await fetch(callbackUrl.toString(), { headers: { 'Accept': 'application/json' }, }); if (!invoiceResponse.ok) { throw new Error( `Failed to get invoice: ${invoiceResponse.status} ${invoiceResponse.statusText}` ); } const invoiceData: LNURLInvoiceResponse = await invoiceResponse.json(); // Check if we have a valid invoice if (invoiceData.pr) { return invoiceData.pr; } // If no invoice but has status field, check for errors if (invoiceData.status && invoiceData.status !== 'OK') { const errorReason = invoiceData.reason || 'Invoice generation failed'; throw new Error(`Invoice generation failed: ${errorReason}`); } // If we get here, no invoice was provided throw new Error('No invoice returned from provider'); }
Usage:
try { const invoice = await getInvoiceFromLightningAddress({ lightningAddress: 'alice@strike.me', amountSats: 1000, comment: 'Thanks for the coffee!' }); // Use invoice for payment } catch (error) { console.error('Failed to get invoice:', error); }
Part 3: React Hook Implementation
Custom Hook
Create a React hook for Lightning address operations:
// hooks/useLightningAddress.ts import { useState, useCallback } from 'react'; import { getInvoiceFromLightningAddress, isValidLightningAddress } from '@/lib/lightningAddress'; import type { GetInvoiceParams } from '@/lib/lightningAddress'; interface UseLightningAddressReturn { getInvoice: (params: GetInvoiceParams) => Promise<string>; isLoading: boolean; error: string | null; } /** * Hook for resolving Lightning addresses to invoices * * @example * ```tsx * const { getInvoice, isLoading, error } = useLightningAddress(); * * const invoice = await getInvoice({ * lightningAddress: 'alice@strike.me', * amountSats: 1000, * comment: 'Thanks for the coffee!' * }); * ``` */ export function useLightningAddress(): UseLightningAddressReturn { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); /** * Fetches a Lightning invoice for the given address and amount * * @param params - Lightning address, amount in sats, and optional comment * @returns BOLT11 invoice string * @throws Error if resolution fails */ const getInvoice = useCallback(async (params: GetInvoiceParams): Promise<string> => { setIsLoading(true); setError(null); try { const invoice = await getInvoiceFromLightningAddress(params); setIsLoading(false); return invoice; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; setError(errorMessage); setIsLoading(false); throw err; } }, []); return { getInvoice, isLoading, error, }; }
Usage in components:
function PaymentForm() { const { getInvoice, isLoading, error } = useLightningAddress(); const [lightningAddress, setLightningAddress] = useState(''); const [amount, setAmount] = useState(0); const handlePay = async () => { try { const invoice = await getInvoice({ lightningAddress, amountSats: amount, }); // Process payment with invoice } catch (error) { // Error already set in hook } }; return ( <form onSubmit={handlePay}> <input value={lightningAddress} onChange={(e) => setLightningAddress(e.target.value)} placeholder="alice@strike.me" /> <input type="number" value={amount} onChange={(e) => setAmount(Number(e.target.value))} placeholder="Amount in sats" /> {error && <div className="error">{error}</div>} <button type="submit" disabled={isLoading}> {isLoading ? 'Loading...' : 'Get Invoice'} </button> </form> ); }
Part 4: UI Components
Lightning Address Display Component
Display Lightning address with copy and QR functionality:
// components/LightningAddressDisplay.tsx import { useState } from 'react'; import { Copy, Check, QrCode } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useQRCode } from '@/hooks/useQRCode'; import { QRModal } from '@/components/ui/qr-modal'; interface LightningAddressDisplayProps { lightningAddress: string; className?: string; } export function LightningAddressDisplay({ lightningAddress, className, }: LightningAddressDisplayProps) { const [copied, setCopied] = useState(false); const [showQR, setShowQR] = useState(false); const [qrCodeUrl, setQrCodeUrl] = useState<string>(''); const { copy } = useCopyToClipboard(); const { generateQRCode } = useQRCode(); const handleCopy = async () => { await copy(lightningAddress); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const handleShowQR = async () => { const qr = await generateQRCode(lightningAddress); setQrCodeUrl(qr); setShowQR(true); }; // Parse address for display const [username, domain] = lightningAddress.split('@'); return ( <> <div className={`flex gap-2 items-center ${className}`}> <button onClick={handleCopy} className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-muted/80 rounded-md border text-base font-mono transition-colors flex-1 h-[40px] min-w-0" title="Click to copy Lightning address" > <div className="min-w-0 flex-1 flex items-center"> <span className="truncate min-w-0 flex-1">{username}</span> <span className="flex-shrink-0 text-muted-foreground">@{domain}</span> </div> {copied ? ( <Check className="h-4 w-4 text-green-600 flex-shrink-0" /> ) : ( <Copy className="h-4 w-4 text-muted-foreground flex-shrink-0" /> )} </button> <button onClick={handleShowQR} className="flex items-center justify-center px-2 py-1.5 hover:bg-muted rounded-md transition-colors h-[40px] w-[42px] flex-shrink-0" title="Show QR Code" > <QrCode className="h-4 w-4" /> </button> </div> <QRModal isOpen={showQR} onClose={() => setShowQR(false)} title="Lightning Address" description="Scan this QR code to send a payment" qrCodeUrl={qrCodeUrl} content={lightningAddress} icon="zap" /> </> ); }
QR Modal Component
Reusable QR code modal with expiry support:
// components/ui/qr-modal.tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { X, Copy, Check, QrCode, Zap, Clock } from 'lucide-react'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useEffect, useState } from 'react'; interface QRModalProps { isOpen: boolean; onClose: () => void; title: string; description: string; qrCodeUrl: string; content: string; icon?: 'qr' | 'zap'; expiryTimestamp?: number; onExpiry?: () => void; } export function QRModal({ isOpen, onClose, title, description, qrCodeUrl, content, icon = 'qr', expiryTimestamp, onExpiry, }: QRModalProps) { const { copy, copied } = useCopyToClipboard(); const [timeRemaining, setTimeRemaining] = useState<number | null>(null); const handleCopy = () => { copy(content); }; // Countdown timer effect useEffect(() => { if (!isOpen || !expiryTimestamp) return; const updateTimer = () => { const now = Math.floor(Date.now() / 1000); const remaining = expiryTimestamp - now; if (remaining <= 0) { setTimeRemaining(0); if (onExpiry) { onExpiry(); } onClose(); } else { setTimeRemaining(remaining); } }; updateTimer(); const interval = setInterval(updateTimer, 1000); return () => clearInterval(interval); }, [isOpen, expiryTimestamp, onExpiry, onClose]); // Format time remaining as MM:SS const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; }; if (!isOpen) return null; const IconComponent = icon === 'zap' ? Zap : QrCode; return ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose} > <Card className="w-full max-w-md border-0 shadow-lg bg-background" onClick={(e) => e.stopPropagation()} > <CardHeader className="relative"> <Button onClick={onClose} className="absolute top-2 right-2" size="sm" variant="ghost" > <X className="h-4 w-4" /> </Button> <CardTitle className="flex items-center pr-8"> <IconComponent className="h-5 w-5 mr-2" /> {title} </CardTitle> <CardDescription> {description} </CardDescription> </CardHeader> <CardContent className="space-y-4"> {expiryTimestamp && timeRemaining !== null && timeRemaining <= 3600 && ( <div className="flex items-center justify-center gap-2 text-sm"> <Clock className="h-4 w-4" /> <span className={timeRemaining < 60 ? "text-destructive font-semibold" : "text-muted-foreground"}> Expires in {formatTime(timeRemaining)} </span> </div> )} <div className="flex justify-center"> <div className="bg-white p-4 rounded-lg border shadow-sm"> <img src={qrCodeUrl} alt={`${title} QR Code`} className="w-64 h-64 object-contain" /> </div> </div> <div className="text-center"> <div className="bg-muted p-3 rounded-lg border font-mono text-sm cursor-pointer hover:bg-muted/80 transition-colors relative group" onClick={handleCopy} title="Click to copy full content" > {content.length > 33 ? `${content.substring(0, 15)}...${content.substring(content.length - 15)}` : content} <div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"> {copied ? ( <Check className="h-4 w-4 text-green-600" /> ) : ( <Copy className="h-4 w-4 text-muted-foreground" /> )} </div> </div> </div> </CardContent> </Card> </div> ); }
Integration in Wallet Header
Example integration showing Lightning address in wallet header:
// components/wallet/WalletHeader.tsx (excerpt) import { LightningAddressDisplay } from '@/components/LightningAddressDisplay'; export function WalletHeader({ userPubkey, npubCashUsername, mode, // ... other props }: WalletHeaderProps) { return ( <div className="flex flex-col items-center pt-10 space-y-2"> {/* ... balance display ... */} {/* Lightning Address Row */} {userPubkey && mode === 'lightning' && npubCashUsername && ( <div className="mt-3 px-4 w-full max-w-[400px] mx-auto"> <LightningAddressDisplay lightningAddress={`${npubCashUsername}@npubx.cash`} /> </div> )} </div> ); }
Part 5: Error Handling & Validation
Comprehensive Error Handling
Handle all error cases gracefully:
// lib/lightningAddress.ts export class LightningAddressError extends Error { constructor( message: string, public code: 'INVALID_FORMAT' | 'RESOLUTION_FAILED' | 'AMOUNT_INVALID' | 'INVOICE_FAILED', public originalError?: Error ) { super(message); this.name = 'LightningAddressError'; } } export async function getInvoiceFromLightningAddress( params: GetInvoiceParams ): Promise<string> { try { // Validate format if (!isValidLightningAddress(params.lightningAddress)) { throw new LightningAddressError( 'Invalid Lightning address format. Expected: username@domain.com', 'INVALID_FORMAT' ); } // Validate amount if (params.amountSats <= 0) { throw new LightningAddressError( 'Amount must be greater than 0', 'AMOUNT_INVALID' ); } // Resolve and get invoice const lnurlData = await resolveLightningAddress(params.lightningAddress); // ... rest of implementation with proper error handling } catch (error) { if (error instanceof LightningAddressError) { throw error; } // Wrap unknown errors throw new LightningAddressError( error instanceof Error ? error.message : 'Unknown error occurred', 'INVOICE_FAILED', error instanceof Error ? error : undefined ); } }
User-Friendly Error Messages
Provide helpful error messages in UI:
// components/LightningAddressForm.tsx function LightningAddressForm() { const { getInvoice, isLoading, error } = useLightningAddress(); // Optional: User feedback notifications // Option 1: Console logging // const logMessage = (message: string) => console.log(message); // Option 2: Toast notifications (if useToast hook is available) // const { toast } = useToast(); // Option 3: No notification handler const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { const invoice = await getInvoice({ lightningAddress: formData.address, amountSats: formData.amount, }); // Process invoice } catch (error) { let message = 'Failed to get invoice'; if (error instanceof LightningAddressError) { switch (error.code) { case 'INVALID_FORMAT': message = 'Invalid Lightning address. Use format: username@domain.com'; break; case 'AMOUNT_INVALID': message = error.message; break; case 'RESOLUTION_FAILED': message = 'Could not resolve Lightning address. Check your connection.'; break; case 'INVOICE_FAILED': message = 'Failed to generate invoice. Please try again.'; break; } } // Optional: User feedback - choose one: // Option 1: Console logging // console.error('Failed to get invoice:', message); // Option 2: Toast notification (if toast is available) // toast({ variant: 'destructive', title: 'Error', description: message }); // Option 3: No notification (silent failure) } }; // ... rest of component }
Part 6: Advanced Features
Amount Validation with Min/Max Display
Show min/max amounts to users:
// hooks/useLightningAddressInfo.ts import { useState, useCallback } from 'react'; import { resolveLightningAddress } from '@/lib/lightningAddress'; interface LightningAddressInfo { minSats: number; maxSats: number; commentAllowed?: number; } export function useLightningAddressInfo() { const [info, setInfo] = useState<LightningAddressInfo | null>(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); const fetchInfo = useCallback(async (lightningAddress: string) => { setIsLoading(true); setError(null); try { const lnurlData = await resolveLightningAddress(lightningAddress); setInfo({ minSats: lnurlData.minSendable / 1000, maxSats: lnurlData.maxSendable / 1000, commentAllowed: lnurlData.commentAllowed, }); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch info'); } finally { setIsLoading(false); } }, []); return { info, fetchInfo, isLoading, error }; }
Usage:
function PaymentForm({ lightningAddress }: { lightningAddress: string }) { const { info, fetchInfo, isLoading } = useLightningAddressInfo(); const [amount, setAmount] = useState(0); useEffect(() => { fetchInfo(lightningAddress); }, [lightningAddress, fetchInfo]); return ( <form> {info && ( <div className="text-sm text-muted-foreground"> Amount range: {info.minSats.toLocaleString()} - {info.maxSats.toLocaleString()} sats </div> )} <input type="number" value={amount} onChange={(e) => setAmount(Number(e.target.value))} min={info?.minSats} max={info?.maxSats} /> </form> ); }
Comment Input with Length Validation
Add comment input with character limit:
// components/LightningAddressPaymentForm.tsx function LightningAddressPaymentForm({ lightningAddress }: { lightningAddress: string }) { const { info, fetchInfo } = useLightningAddressInfo(); const { getInvoice } = useLightningAddress(); const [comment, setComment] = useState(''); useEffect(() => { fetchInfo(lightningAddress); }, [lightningAddress, fetchInfo]); const maxCommentLength = info?.commentAllowed || 0; const canAddComment = maxCommentLength > 0; return ( <form> {/* Amount input */} {canAddComment && ( <div> <label> Comment (optional) {maxCommentLength > 0 && ( <span className="text-sm text-muted-foreground"> {' '}(max {maxCommentLength} characters) </span> )} </label> <textarea value={comment} onChange={(e) => setComment(e.target.value)} maxLength={maxCommentLength} rows={2} /> <div className="text-xs text-muted-foreground text-right"> {comment.length} / {maxCommentLength} </div> </div> )} </form> ); }
Part 7: Testing
Unit Tests
Test Lightning address validation and resolution:
// lib/__tests__/lightningAddress.test.ts import { describe, it, expect, vi } from 'vitest'; import { isValidLightningAddress, getInvoiceFromLightningAddress } from '../lightningAddress'; describe('isValidLightningAddress', () => { it('validates valid addresses', () => { expect(isValidLightningAddress('alice@strike.me')).toBe(true); expect(isValidLightningAddress('user@domain.com')).toBe(true); }); it('rejects invalid formats', () => { expect(isValidLightningAddress('invalid')).toBe(false); expect(isValidLightningAddress('@domain.com')).toBe(false); expect(isValidLightningAddress('user@')).toBe(false); expect(isValidLightningAddress('user@domain')).toBe(false); }); }); describe('getInvoiceFromLightningAddress', () => { it('resolves address and gets invoice', async () => { // Mock fetch responses global.fetch = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ status: 'OK', tag: 'payRequest', minSendable: 1000, maxSendable: 1000000000, callback: 'https://strike.me/api/invoice', metadata: '[]', }), }) .mockResolvedValueOnce({ ok: true, json: async () => ({ status: 'OK', pr: 'lnbc100n1p...', }), }); const invoice = await getInvoiceFromLightningAddress({ lightningAddress: 'alice@strike.me', amountSats: 1000, }); expect(invoice).toBe('lnbc100n1p...'); }); it('validates amount against min/max', async () => { global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ status: 'OK', tag: 'payRequest', minSendable: 10000, // 10 sats maxSendable: 1000000, // 1000 sats callback: 'https://strike.me/api/invoice', metadata: '[]', }), }); await expect( getInvoiceFromLightningAddress({ lightningAddress: 'alice@strike.me', amountSats: 5, // Below minimum }) ).rejects.toThrow('below minimum'); }); });
Part 8: Common Pitfalls
1. ❌ Not validating address format
Problem: Attempting to resolve invalid addresses causes unnecessary API calls.
Solution: Always validate format first:
if (!isValidLightningAddress(address)) { throw new Error('Invalid Lightning address format'); }
2. ❌ Not checking amount bounds
Problem: Requesting invoices with amounts outside provider limits causes errors.
Solution: Always validate against min/max from LNURL-pay response:
if (amountSats < minSats || amountSats > maxSats) { throw new Error('Amount out of range'); }
3. ❌ Not handling comment length limits
Problem: Sending comments longer than allowed causes invoice generation to fail.
Solution: Check
commentAllowed and validate length:
if (comment && lnurlData.commentAllowed && comment.length > lnurlData.commentAllowed) { throw new Error('Comment too long'); }
4. ❌ Not handling HTTP errors
Problem: Network failures or provider errors cause unhandled exceptions.
Solution: Always check response status and handle errors:
if (!response.ok) { throw new Error(`Failed: ${response.status} ${response.statusText}`); }
5. ❌ Not converting sats to millisats
Problem: LNURL-pay uses millisats, but amounts are often in sats.
Solution: Always convert when calling callback URL:
const amountMsats = amountSats * 1000; callbackUrl.searchParams.set('amount', amountMsats.toString());
6. ❌ Not handling optional invoice fields
Problem: Some providers return invoices without
status field, causing validation to fail.
Solution: Check for invoice first, then status:
if (invoiceData.pr) { return invoiceData.pr; // Invoice present, return it } if (invoiceData.status && invoiceData.status !== 'OK') { throw new Error(invoiceData.reason || 'Invoice generation failed'); }
Security Considerations
- Validate all inputs - Don't trust user-provided addresses or amounts
- Sanitize comments - Some providers may have restrictions on comment content
- Handle errors gracefully - Don't expose internal errors to users
- Rate limiting - Consider rate limiting invoice requests to prevent abuse
- HTTPS only - Always use HTTPS for LNURL-pay endpoints
Verification Checklist
- Lightning address validation works correctly
- LNURL-pay endpoint resolution handles errors
- Amount validation against min/max bounds
- Comment support with length validation
- Invoice generation from callback URL
- Error handling for all failure cases
- UI components display addresses correctly
- QR code integration works
- Copy to clipboard functionality
- Loading states displayed during resolution
- User-friendly error messages
Summary
To implement Lightning address functionality:
- Validate format - Use
before resolutionisValidLightningAddress() - Resolve endpoint - Fetch LNURL-pay metadata from
endpoint.well-known/lnurlp/ - Validate amount - Check against min/max bounds from provider
- Request invoice - Call callback URL with amount (and optional comment)
- Handle errors - Provide user-friendly error messages for all failure cases
- Display in UI - Show address with copy/QR functionality
- Test thoroughly - Test validation, resolution, and error cases
Key principle: Lightning addresses use the LNURL-pay protocol - always resolve the endpoint first to get metadata (min/max amounts, callback URL) before requesting invoices.