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.
// 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:
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:
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 (
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-decoderproject)
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 */;
}