Agent Skills: 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

UncategorizedID: PerceptLabs/nostril/lightning-invoice-decoder

Install this agent skill to your local

pnpm dlx add-skill https://github.com/PerceptLabs/nostril/tree/HEAD/skills/lightning-invoice-decoder

Skill Files

Browse the full folder contents for lightning-invoice-decoder.

Download Skill

Loading file tree…

skills/lightning-invoice-decoder/SKILL.md

Skill Metadata

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

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-decoder project)

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 */;
}