nova-carnivore/bolt12-php

Modern PHP 8.3+ BOLT 12 Lightning Network offer/invoice encoder/decoder with BIP-340 Schnorr signatures

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/nova-carnivore/bolt12-php

v0.1.0 2026-02-15 17:33 UTC

This package is auto-updated.

Last update: 2026-02-15 17:42:28 UTC


README

PHP Version License CI PHPStan Level Latest Version

Modern PHP 8.3+ BOLT 12 Lightning Network offer/invoice encoder/decoder with BIP-340 Schnorr signatures. Full spec compliance, production-ready.

Features

  • Full BOLT 12 spec compliance — Offers, Invoice Requests, Invoices, Invoice Errors
  • 🔐 BIP-340 Schnorr signatures — Pure PHP implementation with Merkle tree construction
  • 🏗️ Modern PHP 8.3+ — Enums, readonly classes, named arguments, match expressions
  • 🔍 PHPStan level 9 — Maximum static analysis strictness
  • 🌐 All message types — Offer (lno), Invoice Request (lnr), Invoice (lni), Invoice Error
  • 🏷️ All TLV fields — chains, metadata, currency, amounts, paths, features, BIP-353, and more
  • 🔄 Round-trip safe — Encode → decode preserves all data
  • 📏 PSR-12 code style — Enforced with PHP-CS-Fixer
  • 🔒 Constant-time crypto — Uses GMP for big integer operations
  • 🔗 Blinded paths — Full support for multi-hop blinded payment paths

Installation

composer require nova-carnivore/bolt12-php

Requirements

  • PHP 8.3 or higher
  • ext-gmp (required for big integers and BIP-340 crypto)

Quick Start

Decode (Auto-detect Type)

use Nova\Bitcoin\Bolt12\Decoder;

$offer = Decoder::decode('lno1...');      // Returns Offer object
$invReq = Decoder::decode('lnr1...');     // Returns InvoiceRequest object
$invoice = Decoder::decode('lni1...');    // Returns Invoice object

Encode an Offer

use Nova\Bitcoin\Bolt12\Encoder;
use Nova\Bitcoin\Bolt12\Signer;

$encoded = Encoder::encodeOffer(
    issuerId: $myPublicKeyHex,
    description: 'Buy a coffee',
    amountMsat: gmp_init(100000),
    issuer: 'CoffeeShop',
);
// Returns: 'lno1...'

Encode an Invoice Request (auto-signed)

$invReq = Encoder::encodeInvoiceRequest(
    invreqMetadata: bin2hex(random_bytes(32)),
    payerId: $myPublicKeyHex,
    payerPrivateKey: $myPrivateKeyHex,
    offerDescription: 'Buy a coffee',
    offerIssuerId: $merchantPubkey,
    offerAmountMsat: gmp_init(100000),
);
// Returns: 'lnr1...' (signed with BIP-340 Schnorr)

Encode an Invoice (auto-signed)

use Nova\Bitcoin\Bolt12\BlindedPath;
use Nova\Bitcoin\Bolt12\BlindedPayInfo;
use Nova\Bitcoin\Bolt12\OnionMessageHop;

$invoice = Encoder::encodeInvoice(
    nodeId: $myNodePubkey,
    nodePrivateKey: $myNodePrivkey,
    createdAt: gmp_init(time()),
    paymentHash: hash('sha256', $preimage),
    amountMsat: gmp_init(100000),
    invoicePaths: [
        new BlindedPath($blindingKey, [
            new OnionMessageHop($nodeId, $encryptedData),
        ]),
    ],
    blindedPayInfo: [
        new BlindedPayInfo(
            feeBaseMsat: 1000,
            feeProportionalMillionths: 100,
            cltvExpiryDelta: 144,
            htlcMinimumMsat: gmp_init(1000),
            htlcMaximumMsat: gmp_init(1000000000),
        ),
    ],
);
// Returns: 'lni1...' (signed with BIP-340 Schnorr)

Verify Signatures

use Nova\Bitcoin\Bolt12\Signer;

$invReq = Decoder::decode('lnr1...');
$valid = Signer::verifyInvoiceRequest($invReq);  // Returns bool

$invoice = Decoder::decode('lni1...');
$valid = Signer::verifyInvoice($invoice);  // Returns bool

Invoice Errors

// Encode
$errorBytes = Encoder::encodeInvoiceError(
    error: 'Amount too low',
    erroneousField: gmp_init(82),  // invreq_amount
    suggestedValue: '0186a0',      // 100000 as tu64 hex
);

// Decode
$invoiceError = Decoder::decodeInvoiceError($errorBytes);
echo $invoiceError->error;  // 'Amount too low'

Full Payment Flow Example

use Nova\Bitcoin\Bolt12\{Decoder, Encoder, Signer, BlindedPath, BlindedPayInfo, OnionMessageHop};

// 1. Merchant creates an offer
$offer = Encoder::encodeOffer(
    issuerId: $merchantPubkey,
    description: 'Buy a coffee',
    amountMsat: gmp_init(100000),
);
$decoded = Decoder::decode($offer);

// 2. Payer creates invoice request (mirrors offer fields)
$invReq = Encoder::encodeInvoiceRequest(
    invreqMetadata: bin2hex(random_bytes(32)),
    payerId: $payerPubkey,
    payerPrivateKey: $payerPrivkey,
    offerDescription: $decoded->description,
    offerIssuerId: $decoded->issuerId,
    offerAmountMsat: $decoded->amountMsat,
);

// 3. Merchant creates and signs invoice
$invoice = Encoder::encodeInvoice(
    nodeId: $merchantPubkey,
    nodePrivateKey: $merchantPrivkey,
    createdAt: gmp_init(time()),
    paymentHash: hash('sha256', $preimage),
    amountMsat: gmp_init(100000),
    invoicePaths: [$blindedPath],
    blindedPayInfo: [$payInfo],
);

// 4. Payer verifies invoice signature
$inv = Decoder::decode($invoice);
assert(Signer::verifyInvoice($inv));

API Reference

Decoder::decode(string $bolt12String): Offer|InvoiceRequest|Invoice

Decodes any BOLT 12 bech32 string (auto-detects type by prefix).

Decoder::decodeInvoiceError(array $bytes): InvoiceError

Decodes a BOLT 12 Invoice Error from raw TLV bytes.

Encoder::encodeOffer(...): string

Encodes a BOLT 12 Offer (lno1...). Not signed per spec.

Encoder::encodeInvoiceRequest(...): string

Encodes and signs a BOLT 12 Invoice Request (lnr1...).

Encoder::encodeInvoice(...): string

Encodes and signs a BOLT 12 Invoice (lni1...).

Encoder::encodeInvoiceError(...): array

Encodes a BOLT 12 Invoice Error as raw TLV bytes.

Signer::verifyInvoiceRequest(InvoiceRequest $invReq): bool

Verifies a BIP-340 Schnorr signature on an invoice request.

Signer::verifyInvoice(Invoice $invoice): bool

Verifies a BIP-340 Schnorr signature on an invoice.

Signer::getPublicKey(string $privateKeyHex): string

Derives a compressed public key from a private key.

Data Classes

Class Description
Offer Decoded offer with all fields as readonly properties
InvoiceRequest Decoded invoice request with signature
Invoice Decoded invoice with paths, payinfo, and signature
InvoiceError Decoded invoice error
BlindedPath Blinded path with hops
BlindedPayInfo Payment info for blinded paths
FallbackAddress On-chain fallback address
OnionMessageHop Single hop in a blinded path
Bip353Name BIP-353 name (user@domain)
TlvEntry Raw TLV type-value pair

BOLT 12 Spec Coverage

Message Types

Type Prefix Encode Decode Sign Verify
Offer lno ✅* ✅*
Invoice Request lnr
Invoice lni
Invoice Error N/A N/A

* Offers are optionally signed per spec

TLV Fields

Type Field Offer InvReq Invoice
0 invreq_metadata
2 offer_chains
4 offer_metadata
6 offer_currency
8 offer_amount
10 offer_description
12 offer_features
14 offer_absolute_expiry
16 offer_paths
18 offer_issuer
20 offer_quantity_max
22 offer_issuer_id
80 invreq_chain
82 invreq_amount
84 invreq_features
86 invreq_quantity
88 invreq_payer_id
89 invreq_payer_note
90 invreq_paths
91 invreq_bip_353_name
160 invoice_paths
162 invoice_blindedpay
164 invoice_created_at
166 invoice_relative_expiry
168 invoice_payment_hash
170 invoice_amount
172 invoice_fallbacks
174 invoice_features
176 invoice_node_id
240 signature ✅*

Encoding

  • Bech32 without checksum — per BOLT 12 spec
  • BigSize TLV type/length encoding
  • tu64 truncated unsigned 64-bit values
  • + concatenation support for long strings

Cryptography

  • BIP-340 Schnorr signatures (64-byte raw, not DER)
  • Tagged hashesSHA256(SHA256(tag) || SHA256(tag) || msg)
  • Merkle tree for multi-TLV signatures with nonce leaves
  • secp256k1 elliptic curve arithmetic

Security

  • BIP-340 Schnorr signatures use pure PHP with GMP for big integer arithmetic
  • All elliptic curve operations use constant-time modular arithmetic via GMP
  • The Merkle tree construction includes nonce leaves to prevent revealing adjacent fields
  • Private keys are only used in signing operations and never stored

Exception Handling

use Nova\Bitcoin\Bolt12\Exception\{
    Bolt12Exception,       // Base exception
    DecodeException,       // Malformed input
    EncodeException,       // Missing/invalid fields
    SignatureException,    // Signature issues
};

try {
    $offer = Decoder::decode($bolt12String);
} catch (DecodeException $e) {
    // Malformed string or TLV data
} catch (Bolt12Exception $e) {
    // Any BOLT 12 error
}

Development

# Install dependencies
composer install

# Run tests
vendor/bin/phpunit

# Static analysis
vendor/bin/phpstan analyse

# Code style check
vendor/bin/php-cs-fixer fix --dry-run --diff

# Fix code style
vendor/bin/php-cs-fixer fix

# Run all CI checks
composer ci

Architecture

src/
├── Decoder.php          # Main decoder (auto-detect type)
├── Encoder.php          # Main encoder (all message types)
├── Signer.php           # BIP-340 Schnorr + Merkle tree
├── Bech32.php           # Bech32 WITHOUT checksum (per BOLT 12)
├── TlvStream.php        # BigSize TLV encoding/decoding
├── TlvEntry.php         # TLV entry value object
├── Offer.php            # Offer value object (readonly)
├── InvoiceRequest.php   # Invoice request value object (readonly)
├── Invoice.php          # Invoice value object (readonly)
├── InvoiceError.php     # Invoice error value object (readonly)
├── BlindedPath.php      # Blinded path with encoding/decoding
├── BlindedPayInfo.php   # Blinded payment info
├── FallbackAddress.php  # On-chain fallback address
├── OnionMessageHop.php  # Blinded path hop
├── Bip353Name.php       # BIP-353 name resolution
├── Helpers.php          # Byte/hex/string utilities
└── Exception/
    ├── Bolt12Exception.php
    ├── DecodeException.php
    ├── EncodeException.php
    └── SignatureException.php

Key Differences from BOLT 11

Feature BOLT 11 BOLT 12
Encoding Bech32 with checksum Bech32 without checksum
Crypto ECDSA recovery BIP-340 Schnorr
Signatures Simple message hash Merkle tree construction
TLV encoding 5-bit continuation BigSize encoding
Message types 1 (invoice only) 4 (offer, invreq, invoice, error)
Privacy Direct node ID Blinded paths

License

MIT — see LICENSE.