Claude-skill-registry lightning-invoice-decoder
Use when implementing BOLT11 Lightning invoice decoding - provides complete patterns for decoding invoice amounts, validating invoice format, and extracting satoshi values from Lightning invoices
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-invoice-decoder" ~/.claude/skills/majiayu000-claude-skill-registry-lightning-invoice-decoder && rm -rf "$T"
skills/data/lightning-invoice-decoder/SKILL.mdLightning 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.
// 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:
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:
// 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<string, number> = { 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:
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:
- Lightning Network Bitcoin mainnet prefixlnbc
- Optional numeric amount[amount]
- Optional denomination suffix (m, u, n, p)[suffix]
- Bech32 separator character1
- Encoded invoice data[bech32-encoded-data]
Denomination Suffixes
| Suffix | Multiplier | Example | Satoshis |
|---|---|---|---|
| (none) | 1 BTC | | 10,000,000,000 sats |
| 0.001 BTC | | 10,000,000 sats |
| 0.000001 BTC | | 10,000 sats |
| 0.000000001 BTC | | 1,000 sats |
| 0.000000000001 BTC | | 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:
const amountlessInvoice = 'lnbc1...'; const amount = decodeBolt11Amount(amountlessInvoice); // amount === null (amountless invoice)
Handling amountless invoices:
const amount = decodeBolt11Amount(invoice); if (amount === null) { // Show amount input field for user to specify return <AmountInputForm invoice={invoice} />; } else { // Display fixed amount return <PaymentDisplay amount={amount} invoice={invoice} />; }
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 (
) contains seconds until expiryexpire_time - 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:
(npm package)bolt11
(npm package)light-bolt11-decoder- Or implement full bech32 decoding (see
project)lightning-decoder
Expiry calculation from decoded invoice:
// 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:
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:
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:
// 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 <Badge variant="destructive">Expired</Badge>; const minutes = Math.floor(timeRemaining / 60); const seconds = timeRemaining % 60; return ( <Badge variant={minutes < 5 ? "destructive" : "default"}> Expires in {minutes}m {seconds}s </Badge> ); }
Expiry Validation Before Payment
Always check expiry before processing payment:
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:
// 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:
function PaymentForm({ invoice }: { invoice: string }) { const { isValid, amount, isAmountless, error } = useBolt11Decoder(invoice); if (!isValid) { return <div>Invalid invoice: {error}</div>; } if (isAmountless) { return <AmountInputForm invoice={invoice} />; } return <PaymentDisplay amount={amount!} invoice={invoice} />; }
Part 4: UI Components
Invoice Display Component
Display decoded invoice information:
// 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 ( <Card> <CardContent className="pt-6"> <p className="text-destructive">Invalid BOLT11 invoice</p> </CardContent> </Card> ); } return ( <Card> <CardHeader> <CardTitle>Lightning Invoice</CardTitle> </CardHeader> <CardContent className="space-y-4"> <div> <p className="text-sm text-muted-foreground">Invoice</p> <p className="font-mono text-xs break-all">{invoice}</p> </div> {amount === null ? ( <div> <Badge variant="outline">Amountless Invoice</Badge> <p className="text-sm text-muted-foreground mt-2"> You can specify any amount to pay </p> </div> ) : ( <div> <p className="text-sm text-muted-foreground">Amount</p> <p className="text-2xl font-bold"> {amount.toLocaleString()} sats </p> </div> )} </CardContent> </Card> ); }
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:
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:
// 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:
// 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:
function isValidBolt11Invoice(invoice: string, network: 'mainnet' | 'testnet' = 'mainnet'): boolean { const prefix = network === 'mainnet' ? 'lnbc' : 'lntb'; return normalized.startsWith(prefix) && /* ... rest of validation */; }