--- name: lightning-invoice-decoder description: Use when implementing BOLT11 Lightning invoice decoding - provides complete patterns for decoding invoice amounts, validating invoice format, and extracting satoshi values from Lightning invoices when_to_use: When adding Lightning invoice parsing, validating BOLT11 invoices, extracting payment amounts from invoices, or implementing Lightning payment flows --- # Lightning Invoice Decoder ## Overview Complete implementation guide for decoding BOLT11 Lightning invoices and extracting payment amounts. Supports all BOLT11 denominations (m, u, n, p) and handles amountless invoices gracefully. **Core Capabilities:** - Decode BOLT11 invoice amounts to satoshis - Validate BOLT11 invoice format - Handle all denomination suffixes (m, u, n, p) - Support amountless invoices - Invoice expiry calculation and validation - Error handling and validation ## Prerequisites **No external packages required** - pure TypeScript/JavaScript implementation. ## Implementation Checklist - [ ] Implement invoice validation function - [ ] Implement amount decoding function - [ ] Handle all denomination suffixes - [ ] Support amountless invoices - [ ] Add error handling - [ ] Create React hook wrapper (optional) - [ ] Add UI components for invoice display (optional) ## Part 1: Core Decoder Functions ### Invoice Validation **CRITICAL:** Always validate invoice format before attempting to decode. ```typescript // lib/bolt11Decoder.ts /** * Validate if a string is a valid BOLT11 invoice * @param invoice - The string to validate * @returns true if the string appears to be a valid BOLT11 invoice */ export function isValidBolt11Invoice(invoice: string): boolean { if (!invoice || typeof invoice !== 'string') { return false; } const normalized = invoice.toLowerCase().trim(); // BOLT11 invoices start with 'lnbc' and contain '1' followed by characters (the bech32 separator) // They also need to be long enough to contain the required components return ( normalized.startsWith('lnbc') && /1[a-z0-9]/.test(normalized) && normalized.length > 20 // Minimum reasonable length for a BOLT11 invoice ); } ``` **Usage:** ```typescript if (isValidBolt11Invoice(invoiceString)) { // Safe to decode const amount = decodeBolt11Amount(invoiceString); } else { // Invalid invoice format console.error('Invalid BOLT11 invoice'); } ``` ### Amount Decoding **Decode invoice amount to satoshis:** ```typescript // lib/bolt11Decoder.ts /** * Decode a BOLT11 invoice and extract the amount in satoshis * @param invoice - The BOLT11 invoice string * @returns The amount in satoshis, or null if amount cannot be determined */ export function decodeBolt11Amount(invoice: string): number | null { try { // Validate and prepare invoice if (!invoice || typeof invoice !== 'string') { return null; } const normalizedInvoice = invoice.toLowerCase().trim(); // Check if it's a valid BOLT11 invoice if (!isValidBolt11Invoice(normalizedInvoice)) { return null; } // Parse the prefix to extract amount // Format: lnbc[amount][suffix] where suffix can be m, u, n, p const prefixMatch = normalizedInvoice.match(/^lnbc(\d*)([munp]?)/); if (!prefixMatch) { return null; } const [, amountStr, suffix] = prefixMatch; // If no amount specified, return null (amountless invoice) if (!amountStr) { return null; } const amount = parseInt(amountStr, 10); if (isNaN(amount)) { return null; } // Define multipliers for different denominations const multipliers: Record = { m: 0.001, // milli-bitcoin u: 0.000001, // micro-bitcoin n: 0.000000001, // nano-bitcoin p: 0.000000000001 // pico-bitcoin }; // Apply multiplier if suffix exists const multiplier = multipliers[suffix] || 1; const amountInBitcoin = amount * multiplier; // Convert Bitcoin to satoshis (1 Bitcoin = 100,000,000 satoshis) const amountInSatoshis = Math.round(amountInBitcoin * 100_000_000); return amountInSatoshis; } catch (error) { console.error('Error decoding BOLT11 invoice:', error); return null; } } ``` **Usage:** ```typescript const invoice = 'lnbc100n1p...'; const amount = decodeBolt11Amount(invoice); if (amount === null) { console.log('Amountless invoice or invalid format'); } else { console.log(`Amount: ${amount} sats`); } ``` ## Part 2: Understanding BOLT11 Format ### Invoice Structure BOLT11 invoices follow this format: ``` lnbc[amount][suffix]1[bech32-encoded-data] ``` **Prefix breakdown:** - `lnbc` - Lightning Network Bitcoin mainnet prefix - `[amount]` - Optional numeric amount - `[suffix]` - Optional denomination suffix (m, u, n, p) - `1` - Bech32 separator character - `[bech32-encoded-data]` - Encoded invoice data ### Denomination Suffixes | Suffix | Multiplier | Example | Satoshis | |--------|-----------|---------|----------| | (none) | 1 BTC | `lnbc1001...` | 10,000,000,000 sats | | `m` | 0.001 BTC | `lnbc100m1...` | 10,000,000 sats | | `u` | 0.000001 BTC | `lnbc100u1...` | 10,000 sats | | `n` | 0.000000001 BTC | `lnbc100n1...` | 1,000 sats | | `p` | 0.000000000001 BTC | `lnbc100p1...` | 1 sat | ### Amountless Invoices Some invoices don't specify an amount - the payer chooses how much to send. The decoder returns `null` for these invoices: ```typescript const amountlessInvoice = 'lnbc1...'; const amount = decodeBolt11Amount(amountlessInvoice); // amount === null (amountless invoice) ``` **Handling amountless invoices:** ```typescript const amount = decodeBolt11Amount(invoice); if (amount === null) { // Show amount input field for user to specify return ; } else { // Display fixed amount return ; } ``` ## Part 2.5: Invoice Expiry ### Understanding Invoice Expiry BOLT11 invoices include an expiration time calculated from: - **Invoice timestamp**: When the invoice was created (Unix timestamp in seconds) - **Expire time tag**: Tag code 6 (`expire_time`) contains seconds until expiry - **Expiry calculation**: `expiry = timestamp + expire_time` - **Default expiry**: 3600 seconds (1 hour) if expire_time tag is missing ### Expiry Calculation **Important:** The current `decodeBolt11Amount` function only extracts the amount from the invoice prefix. To decode expiry information, you need to decode the full bech32 invoice and parse tags. **For full invoice decoding** (including expiry, description, payment hash, etc.), use a complete BOLT11 decoder library like: - `bolt11` (npm package) - `light-bolt11-decoder` (npm package) - Or implement full bech32 decoding (see `lightning-decoder` project) **Expiry calculation from decoded invoice:** ```typescript // If using a full decoder library that returns decoded invoice object: interface DecodedInvoice { timestamp: number; // Unix timestamp (seconds) timestampString: string; // ISO string timeExpireDate?: number; // Absolute expiry timestamp (seconds) timeExpireDateString?: string; // ISO string tags: Array<{ tagName: string; data: any; }>; } // Expiry is calculated as: // timeExpireDate = timestamp + expire_time (from tag 6) ``` ### Checking if Invoice is Expired **With full decoder:** ```typescript function isInvoiceExpired(decodedInvoice: DecodedInvoice): boolean { if (!decodedInvoice.timeExpireDate) { // No expiry specified, use default 1 hour const defaultExpiry = decodedInvoice.timestamp + 3600; return Date.now() / 1000 > defaultExpiry; } return Date.now() / 1000 > decodedInvoice.timeExpireDate; } ``` **Time remaining:** ```typescript function getTimeRemaining(decodedInvoice: DecodedInvoice): number | null { const expiry = decodedInvoice.timeExpireDate ?? decodedInvoice.timestamp + 3600; // Default 1 hour const remaining = expiry - (Date.now() / 1000); return remaining > 0 ? Math.floor(remaining) : 0; } ``` ### Expiry Display Component **Show expiry countdown in UI:** ```typescript // components/InvoiceExpiry.tsx import { useEffect, useState } from 'react'; import { Badge } from '@/components/ui/badge'; export function InvoiceExpiry({ expiryTimestamp }: { expiryTimestamp: number }) { const [timeRemaining, setTimeRemaining] = useState( expiryTimestamp - Math.floor(Date.now() / 1000) ); useEffect(() => { const interval = setInterval(() => { const remaining = expiryTimestamp - Math.floor(Date.now() / 1000); setTimeRemaining(remaining); if (remaining <= 0) clearInterval(interval); }, 1000); return () => clearInterval(interval); }, [expiryTimestamp]); if (timeRemaining <= 0) return Expired; const minutes = Math.floor(timeRemaining / 60); const seconds = timeRemaining % 60; return ( Expires in {minutes}m {seconds}s ); } ``` ### Expiry Validation Before Payment **Always check expiry before processing payment:** ```typescript function validateInvoiceBeforePayment(decodedInvoice: DecodedInvoice): { valid: boolean; error?: string; } { if (isInvoiceExpired(decodedInvoice)) { return { valid: false, error: 'This invoice has expired. Please request a new one.', }; } const timeRemaining = getTimeRemaining(decodedInvoice); if (timeRemaining !== null && timeRemaining < 60) { // Warn if less than 1 minute remaining return { valid: true, error: `Invoice expires in ${timeRemaining} seconds. Proceed quickly!`, }; } return { valid: true }; } ``` ## Part 3: React Hook Integration ### Custom Hook Wrapper **Optional:** Create a React hook for easier integration: ```typescript // hooks/useBolt11Decoder.ts import { useMemo } from 'react'; import { decodeBolt11Amount, isValidBolt11Invoice } from '@/lib/bolt11Decoder'; export function useBolt11Decoder(invoice: string | null | undefined) { return useMemo(() => { if (!invoice) { return { isValid: false, amount: null, error: null, }; } const isValid = isValidBolt11Invoice(invoice); if (!isValid) { return { isValid: false, amount: null, error: 'Invalid BOLT11 invoice format', }; } const amount = decodeBolt11Amount(invoice); return { isValid: true, amount, isAmountless: amount === null, error: null, }; }, [invoice]); } ``` **Usage:** ```typescript function PaymentForm({ invoice }: { invoice: string }) { const { isValid, amount, isAmountless, error } = useBolt11Decoder(invoice); if (!isValid) { return
Invalid invoice: {error}
; } if (isAmountless) { return ; } return ; } ``` ## Part 4: UI Components ### Invoice Display Component **Display decoded invoice information:** ```typescript // components/InvoiceDisplay.tsx import { decodeBolt11Amount, isValidBolt11Invoice } from '@/lib/bolt11Decoder'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; export function InvoiceDisplay({ invoice }: { invoice: string }) { const isValid = isValidBolt11Invoice(invoice); const amount = isValid ? decodeBolt11Amount(invoice) : null; if (!isValid) { return (

Invalid BOLT11 invoice

); } return ( Lightning Invoice

Invoice

{invoice}

{amount === null ? (
Amountless Invoice

You can specify any amount to pay

) : (

Amount

{amount.toLocaleString()} sats

)}
); } ``` **Note:** For input components with validation, follow the same pattern: validate with `isValidBolt11Invoice()`, decode with `decodeBolt11Amount()`, and handle null amounts appropriately. ## Part 5: Error Handling & Integration ### Validation Pattern **Always validate before decoding:** ```typescript function processInvoice(invoice: string) { if (!isValidBolt11Invoice(invoice)) { throw new Error('Invalid BOLT11 invoice format'); } const amount = decodeBolt11Amount(invoice); // amount may be null for amountless invoices } ``` ### Integration Example **Complete payment flow with QR code and clipboard support:** ```typescript // hooks/useLightningPayment.ts import { useState } from 'react'; import { decodeBolt11Amount, isValidBolt11Invoice } from '@/lib/bolt11Decoder'; // optional import { useToast } from '@/hooks/useToast'; export function useLightningPayment() { // 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 [isProcessing, setIsProcessing] = useState(false); const processInvoice = async (invoiceString: string) => { if (!isValidBolt11Invoice(invoiceString)) { // Optional: User feedback - choose one: // Option 1: Console logging // console.error('Invalid Invoice'); // Option 2: Toast notification (if toast is available) // toast({ variant: 'destructive', title: 'Invalid Invoice' }); // Option 3: No notification (silent failure) return; } const amount = decodeBolt11Amount(invoiceString); if (amount === null) { // Optional: User feedback - choose one: // Option 1: Console logging // console.log('Amountless Invoice: Please specify amount'); // Option 2: Toast notification (if toast is available) // toast({ title: 'Amountless Invoice', description: 'Please specify amount' }); // Option 3: No notification (silent) return; } setIsProcessing(true); try { await payInvoice(invoiceString, amount); } catch (error) { // Optional: User feedback - choose one: // Option 1: Console logging // console.error('Payment Failed'); // Option 2: Toast notification (if toast is available) // toast({ variant: 'destructive', title: 'Payment Failed' }); // Option 3: No notification (silent failure) } finally { setIsProcessing(false); } }; // QR code scan handler const handleQRScan = (decodedText: string) => { if (isValidBolt11Invoice(decodedText)) { processInvoice(decodedText); } }; // Clipboard paste handler const handlePaste = async () => { const text = await navigator.clipboard.readText(); if (isValidBolt11Invoice(text)) { processInvoice(text); } }; return { processInvoice, handleQRScan, handlePaste, isProcessing }; } ``` ## Part 6: Testing **Unit tests for decoder functions:** ```typescript // lib/__tests__/bolt11Decoder.test.ts import { describe, it, expect } from 'vitest'; import { decodeBolt11Amount, isValidBolt11Invoice } from '../bolt11Decoder'; describe('isValidBolt11Invoice', () => { it('validates valid invoices', () => { expect(isValidBolt11Invoice('lnbc100n1p...')).toBe(true); }); it('rejects invalid formats', () => { expect(isValidBolt11Invoice('invalid')).toBe(false); }); }); describe('decodeBolt11Amount', () => { it('decodes amounts correctly', () => { expect(decodeBolt11Amount('lnbc100n1p...')).toBe(1000); // 100 nano = 1k sats expect(decodeBolt11Amount('lnbc500u1p...')).toBe(50000); // 500 micro = 50k sats }); it('returns null for amountless invoices', () => { expect(decodeBolt11Amount('lnbc1p...')).toBe(null); }); }); ``` ## Security & Troubleshooting ### Security Considerations - ✅ Always validate input before processing - ✅ Don't expose internal errors to users - ✅ Validate decoded amounts are reasonable ### Common Issues **Returns null for valid invoices:** Check invoice starts with `lnbc`, has sufficient length (>20 chars), contains bech32 separator `1` **Wrong amount decoded:** Verify denomination suffix (m, u, n, p) and multiplier calculation **Amountless invoice not detected:** Amountless invoices have no amount in prefix: `lnbc1...` ### Network Support **Add testnet support:** ```typescript function isValidBolt11Invoice(invoice: string, network: 'mainnet' | 'testnet' = 'mainnet'): boolean { const prefix = network === 'mainnet' ? 'lnbc' : 'lntb'; return normalized.startsWith(prefix) && /* ... rest of validation */; } ```