Merge pull request #10941 from bitromortac/2604-bolt12-1c
Some checks are pending
Vulnerability scan / Scan release binaries (push) Waiting to run
CI / Static Checks (push) Waiting to run
CI / Check commits (push) Waiting to run
CI / Lint code (push) Waiting to run
CI / Cross compilation (push) Waiting to run
CI / Cross compilation-1 (push) Waiting to run
CI / Cross compilation-2 (push) Waiting to run
CI / Run unit tests (push) Waiting to run
CI / Run unit tests-1 (push) Waiting to run
CI / Run unit tests-2 (push) Waiting to run
CI / Run unit tests-3 (push) Waiting to run
CI / Run unit tests-4 (push) Waiting to run
CI / Run unit tests-5 (push) Waiting to run
CI / Run unit tests-6 (push) Waiting to run
CI / Run unit tests-7 (push) Waiting to run
CI / Run unit tests-8 (push) Waiting to run
CI / Run unit tests-9 (push) Waiting to run
CI / Run basic itests (push) Waiting to run
CI / Run basic itests-1 (push) Waiting to run
CI / Run basic itests-2 (push) Waiting to run
CI / Run basic itests-3 (push) Waiting to run
CI / Run basic itests-4 (push) Waiting to run
CI / Run itests (push) Waiting to run
CI / Run itests-1 (push) Waiting to run
CI / Run itests-2 (push) Waiting to run
CI / Run itests-3 (push) Waiting to run
CI / Run itests-4 (push) Waiting to run
CI / Run itests-5 (push) Waiting to run
CI / Run itests-6 (push) Waiting to run
CI / Run itests-7 (push) Waiting to run
CI / Run windows itest (push) Waiting to run
CI / Run macOS itest (push) Waiting to run
CI / Check pinned dependencies (push) Waiting to run
CI / Check pinned dependencies-1 (push) Waiting to run
CI / Check release notes updated (push) Waiting to run
CI / Backwards compatibility test (push) Waiting to run
CI / Cache Cleanup (push) Waiting to run
CI / Send coverage report (push) Blocked by required conditions

bolt12: add Invoice codec and structural validators
This commit is contained in:
ziggieXXX 2026-07-23 14:23:38 -03:00 committed by GitHub
commit dcacdfe0e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 3122 additions and 21 deletions

440
bolt12/invoice.go Normal file
View file

@ -0,0 +1,440 @@
package bolt12
import (
"bytes"
"fmt"
"maps"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)
// Invoice represents a BOLT 12 invoice message. It mirrors all non-signature
// invoice_request fields (types 0-91) and adds invoice-specific fields (types
// 160-176) plus a Schnorr signature (type 240).
//
// An invoice in response to a request should be constructed from that request
// (e.g., using NewInvoiceFromRequest) to mirror its fields. The caller then
// populates the invoice-specific fields and signs it.
type Invoice struct {
// Fields in the 0-91 range are mirrored verbatim from the
// invoice_request (which carries the offer's fields); the byte-for-byte
// match is enforced by ValidateInvoiceAgainstRequest.
// InvreqMetadata is the payer metadata.
InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob]
// OfferChains are the chains the offer is valid for.
OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]
// OfferMetadata is the offer metadata.
OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob]
// OfferCurrency is the offer currency.
OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob]
// OfferAmount is the offer amount.
OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64]
// OfferDescription is the offer description.
OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob]
// OfferFeatures are the offer features.
OfferFeatures tlv.OptionalRecordT[
tlv.TlvType12, lnwire.RawFeatureVector,
]
// OfferAbsoluteExpiry is the offer's absolute expiry.
OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64]
// OfferPaths are the offer's blinded paths.
OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths]
// OfferIssuer is the offer issuer name.
OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob]
// OfferQuantityMax is the offer's maximum quantity.
OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64]
// OfferIssuerID is the offer issuer's public key.
OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey]
// InvreqChain is the requested chain.
InvreqChain tlv.OptionalRecordT[tlv.TlvType80, [32]byte]
// InvreqAmount is the amount the payer offered.
InvreqAmount tlv.OptionalRecordT[tlv.TlvType82, TUint64]
// InvreqFeatures are the payer's features.
InvreqFeatures tlv.OptionalRecordT[
tlv.TlvType84, lnwire.RawFeatureVector,
]
// InvreqQuantity is the requested quantity.
InvreqQuantity tlv.OptionalRecordT[tlv.TlvType86, TUint64]
// InvreqPayerID is the payer's signing public key.
InvreqPayerID tlv.OptionalRecordT[tlv.TlvType88, *btcec.PublicKey]
// InvreqPayerNote is an optional payer note.
InvreqPayerNote tlv.OptionalRecordT[tlv.TlvType89, tlv.Blob]
// InvreqPaths are the payer's blinded paths to send the invoice to.
InvreqPaths tlv.OptionalRecordT[tlv.TlvType90, lnwire.BlindedPaths]
// InvreqBip353Name is the payer's BIP 353 name.
InvreqBip353Name tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob]
// Fields from type 160 on are invoice-specific.
// InvoicePaths are the blinded paths to the recipient node.
InvoicePaths tlv.OptionalRecordT[tlv.TlvType160, lnwire.BlindedPaths]
// InvoiceBlindedPay carries one blinded_payinfo per invoice_paths
// entry, in order.
InvoiceBlindedPay tlv.OptionalRecordT[tlv.TlvType162, BlindedPayInfos]
// InvoiceCreatedAt is the creation time in seconds since the Unix
// epoch.
InvoiceCreatedAt tlv.OptionalRecordT[tlv.TlvType164, TUint64]
// InvoiceRelativeExp is the expiry in seconds after creation. When
// absent the spec default of 7200 seconds applies.
InvoiceRelativeExp tlv.OptionalRecordT[tlv.TlvType166, TUint32]
// InvoicePaymentHash is the SHA256 hash of the payment preimage.
InvoicePaymentHash tlv.OptionalRecordT[tlv.TlvType168, [32]byte]
// InvoiceAmount is the minimum amount the payee will accept, in the
// minimal payable unit of invreq_chain.
InvoiceAmount tlv.OptionalRecordT[tlv.TlvType170, TUint64]
// InvoiceFallbacks are optional on-chain fallback addresses.
InvoiceFallbacks tlv.OptionalRecordT[
tlv.TlvType172, FallbackAddresses,
]
// InvoiceFeatures are the features of the invoice.
InvoiceFeatures tlv.OptionalRecordT[
tlv.TlvType174, lnwire.RawFeatureVector,
]
// InvoiceNodeID is the public key of the recipient node, used to verify
// the signature.
InvoiceNodeID tlv.OptionalRecordT[tlv.TlvType176, *btcec.PublicKey]
// Signature is a BIP-340 Schnorr signature covering all fields.
Signature tlv.OptionalRecordT[tlv.TlvType240, [64]byte]
// decodedTLVs is the canonical TypeMap produced by the typed-stream
// pass that decoded this invoice. See Offer.decodedTLVs for the design
// rationale.
decodedTLVs tlv.TypeMap
}
// AllRecords returns the canonical sorted record list for this invoice, merging
// the typed records with any extra signed-range fields that the decoder
// preserved.
//
// NOTE: this is part of the tlv.PureTLVMessage interface.
func (inv *Invoice) AllRecords() []tlv.Record {
return allRecordsFromTypeMap(
inv.allRecordProducers(), inv.decodedTLVs,
)
}
var _ lnwire.PureTLVMessage = (*Invoice)(nil)
const (
// maxWitnessVersion is the highest segwit witness version a usable
// fallback address may carry; the BOLT 12 reader ignores anything
// above it.
maxWitnessVersion = 16
// minWitnessProgramLen and maxWitnessProgramLen bound the witness
// program length, in bytes, of a usable fallback address.
minWitnessProgramLen = 2
maxWitnessProgramLen = 40
)
// UsableFallbackAddresses returns the invoice_fallbacks entries a payer may use
// after applying the BOLT 12 reader's MUST-ignore rules for the bitcoin chain.
func (inv *Invoice) UsableFallbackAddresses() []FallbackAddress {
// Unwrap the optional up front so the filtering loop stays flat; a nil
// Addrs slice ranges as empty.
fallbacks := inv.InvoiceFallbacks.ValOpt().UnwrapOr(FallbackAddresses{})
var addrs []FallbackAddress
for _, a := range fallbacks.Addrs {
// MUST ignore any fallback_address for which version is greater
// than 16.
if a.Version > maxWitnessVersion {
continue
}
// MUST ignore any fallback_address for which address is less
// than 2 or greater than 40 bytes.
if len(a.Address) < minWitnessProgramLen ||
len(a.Address) > maxWitnessProgramLen {
continue
}
// MUST ignore any fallback_address for which address does not
// meet known requirements for the given version. NOT enforced
// here: the per-version witness-program check needs on-chain
// address rules above this codec, so a caller dispatching
// on-chain MUST apply it.
addrs = append(addrs, a)
}
return addrs
}
// UsablePath pairs a blinded path with its payment parameters, as returned by
// UsablePaths after the BOLT 12 reader's feature filter has been applied.
type UsablePath struct {
// Path is the blinded path to the recipient.
Path lnwire.BlindedPath
// PayInfo is the blinded_payinfo for Path.
PayInfo BlindedPayInfo
}
// UsablePaths returns the invoice_paths entries a payer may use, each paired
// with its blinded_payinfo, after applying the BOLT 12 reader rule that a path
// MUST NOT be used when its payinfo.features has unknown required (even) bits
// set. knownBlindedFeatures names the feature bits the reader understands.
//
// The result is empty when invoice_paths or invoice_blindedpay is absent, or
// when the two lists differ in length; ValidateInvoiceRead rejects those cases
// separately, so a caller that validates first can treat an empty result as
// "no usable paths".
func (inv *Invoice) UsablePaths(
knownBlindedFeatures map[lnwire.FeatureBit]string) []UsablePath {
paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{})
bp := inv.InvoiceBlindedPay.ValOpt().UnwrapOr(BlindedPayInfos{})
// Entries pair by index; a length mismatch is rejected upstream by
// ValidateInvoiceRead, so guard here to stay in bounds.
if len(paths.Paths) != len(bp.Infos) {
return nil
}
var usable []UsablePath
for i := range bp.Infos {
// MUST NOT use the path if payinfo.features has any unknown
// even bits set.
fv := bp.Infos[i].Features
wrapped := lnwire.NewFeatureVector(&fv, knownBlindedFeatures)
if len(wrapped.UnknownRequiredFeatures()) > 0 {
continue
}
usable = append(usable, UsablePath{
Path: paths.Paths[i],
PayInfo: bp.Infos[i],
})
}
return usable
}
// allRecordProducers returns record producers for all set fields.
func (inv *Invoice) allRecordProducers() []tlv.RecordProducer {
var p []tlv.RecordProducer
// Invreq mirrored fields.
lnwire.AddOpt(&p, inv.InvreqMetadata)
lnwire.AddOpt(&p, inv.OfferChains)
lnwire.AddOpt(&p, inv.OfferMetadata)
lnwire.AddOpt(&p, inv.OfferCurrency)
lnwire.AddOpt(&p, inv.OfferAmount)
lnwire.AddOpt(&p, inv.OfferDescription)
lnwire.AddOpt(&p, inv.OfferFeatures)
lnwire.AddOpt(&p, inv.OfferAbsoluteExpiry)
lnwire.AddOpt(&p, inv.OfferPaths)
lnwire.AddOpt(&p, inv.OfferIssuer)
lnwire.AddOpt(&p, inv.OfferQuantityMax)
lnwire.AddOpt(&p, inv.OfferIssuerID)
lnwire.AddOpt(&p, inv.InvreqChain)
lnwire.AddOpt(&p, inv.InvreqAmount)
lnwire.AddOpt(&p, inv.InvreqFeatures)
lnwire.AddOpt(&p, inv.InvreqQuantity)
lnwire.AddOpt(&p, inv.InvreqPayerID)
lnwire.AddOpt(&p, inv.InvreqPayerNote)
lnwire.AddOpt(&p, inv.InvreqPaths)
lnwire.AddOpt(&p, inv.InvreqBip353Name)
// Invoice-specific fields.
lnwire.AddOpt(&p, inv.InvoicePaths)
lnwire.AddOpt(&p, inv.InvoiceBlindedPay)
lnwire.AddOpt(&p, inv.InvoiceCreatedAt)
lnwire.AddOpt(&p, inv.InvoiceRelativeExp)
lnwire.AddOpt(&p, inv.InvoicePaymentHash)
lnwire.AddOpt(&p, inv.InvoiceAmount)
lnwire.AddOpt(&p, inv.InvoiceFallbacks)
lnwire.AddOpt(&p, inv.InvoiceFeatures)
lnwire.AddOpt(&p, inv.InvoiceNodeID)
lnwire.AddOpt(&p, inv.Signature)
return p
}
// Encode validates the invoice per writer requirements and serialises it via
// the PureTLVMessage shape.
func (inv *Invoice) Encode() ([]byte, error) {
if err := ValidateInvoiceWrite(inv); err != nil {
return nil, fmt.Errorf("validate invoice: %w", err)
}
var buf bytes.Buffer
if err := lnwire.EncodePureTLVMessage(inv, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// DecodeInvoice deserializes an invoice from a TLV byte stream. Decoding is
// permissive: callers that need spec compliance must run ValidateInvoiceRead.
func DecodeInvoice(data []byte) (*Invoice, error) {
var inv Invoice
invreqMetadata := tlv.ZeroRecordT[tlv.TlvType0, tlv.Blob]()
chains := tlv.ZeroRecordT[tlv.TlvType2, ChainsRecord]()
offerMeta := tlv.ZeroRecordT[tlv.TlvType4, tlv.Blob]()
currency := tlv.ZeroRecordT[tlv.TlvType6, tlv.Blob]()
offerAmt := tlv.ZeroRecordT[tlv.TlvType8, TUint64]()
desc := tlv.ZeroRecordT[tlv.TlvType10, tlv.Blob]()
offerFeat := tlv.ZeroRecordT[tlv.TlvType12, lnwire.RawFeatureVector]()
expiry := tlv.ZeroRecordT[tlv.TlvType14, TUint64]()
offerPaths := tlv.ZeroRecordT[tlv.TlvType16, lnwire.BlindedPaths]()
issuer := tlv.ZeroRecordT[tlv.TlvType18, tlv.Blob]()
qtyMax := tlv.ZeroRecordT[tlv.TlvType20, TUint64]()
issuerID := tlv.ZeroRecordT[tlv.TlvType22, *btcec.PublicKey]()
invreqChain := tlv.ZeroRecordT[tlv.TlvType80, [32]byte]()
invreqAmt := tlv.ZeroRecordT[tlv.TlvType82, TUint64]()
invreqFeat := tlv.ZeroRecordT[tlv.TlvType84, lnwire.RawFeatureVector]()
invreqQty := tlv.ZeroRecordT[tlv.TlvType86, TUint64]()
payerID := tlv.ZeroRecordT[tlv.TlvType88, *btcec.PublicKey]()
payerNote := tlv.ZeroRecordT[tlv.TlvType89, tlv.Blob]()
invreqPaths := tlv.ZeroRecordT[tlv.TlvType90, lnwire.BlindedPaths]()
bip353 := tlv.ZeroRecordT[tlv.TlvType91, tlv.Blob]()
invPaths := tlv.ZeroRecordT[tlv.TlvType160, lnwire.BlindedPaths]()
blindedPay := tlv.ZeroRecordT[tlv.TlvType162, BlindedPayInfos]()
createdAt := tlv.ZeroRecordT[tlv.TlvType164, TUint64]()
relExp := tlv.ZeroRecordT[tlv.TlvType166, TUint32]()
payHash := tlv.ZeroRecordT[tlv.TlvType168, [32]byte]()
invAmt := tlv.ZeroRecordT[tlv.TlvType170, TUint64]()
fallbacks := tlv.ZeroRecordT[tlv.TlvType172, FallbackAddresses]()
invFeat := tlv.ZeroRecordT[tlv.TlvType174, lnwire.RawFeatureVector]()
nodeID := tlv.ZeroRecordT[tlv.TlvType176, *btcec.PublicKey]()
sig := tlv.ZeroRecordT[tlv.TlvType240, [64]byte]()
tm, err := decodeStream(
data,
invreqMetadata.Record(), chains.Record(), offerMeta.Record(),
currency.Record(), offerAmt.Record(), desc.Record(),
offerFeat.Record(), expiry.Record(), offerPaths.Record(),
issuer.Record(), qtyMax.Record(), issuerID.Record(),
invreqChain.Record(), invreqAmt.Record(), invreqFeat.Record(),
invreqQty.Record(), payerID.Record(), payerNote.Record(),
invreqPaths.Record(), bip353.Record(), invPaths.Record(),
blindedPay.Record(), createdAt.Record(), relExp.Record(),
payHash.Record(), invAmt.Record(), fallbacks.Record(),
invFeat.Record(), nodeID.Record(), sig.Record(),
)
if err != nil {
return nil, fmt.Errorf("decode invoice: %w", err)
}
lnwire.SetOptFromMap(tm, &inv.InvreqMetadata, invreqMetadata)
lnwire.SetOptFromMap(tm, &inv.OfferChains, chains)
lnwire.SetOptFromMap(tm, &inv.OfferMetadata, offerMeta)
lnwire.SetOptFromMap(tm, &inv.OfferCurrency, currency)
lnwire.SetOptFromMap(tm, &inv.OfferAmount, offerAmt)
lnwire.SetOptFromMap(tm, &inv.OfferDescription, desc)
lnwire.SetOptFromMap(tm, &inv.OfferFeatures, offerFeat)
lnwire.SetOptFromMap(tm, &inv.OfferAbsoluteExpiry, expiry)
lnwire.SetOptFromMap(tm, &inv.OfferPaths, offerPaths)
lnwire.SetOptFromMap(tm, &inv.OfferIssuer, issuer)
lnwire.SetOptFromMap(tm, &inv.OfferQuantityMax, qtyMax)
lnwire.SetOptFromMap(tm, &inv.OfferIssuerID, issuerID)
lnwire.SetOptFromMap(tm, &inv.InvreqChain, invreqChain)
lnwire.SetOptFromMap(tm, &inv.InvreqAmount, invreqAmt)
lnwire.SetOptFromMap(tm, &inv.InvreqFeatures, invreqFeat)
lnwire.SetOptFromMap(tm, &inv.InvreqQuantity, invreqQty)
lnwire.SetOptFromMap(tm, &inv.InvreqPayerID, payerID)
lnwire.SetOptFromMap(tm, &inv.InvreqPayerNote, payerNote)
lnwire.SetOptFromMap(tm, &inv.InvreqPaths, invreqPaths)
lnwire.SetOptFromMap(tm, &inv.InvreqBip353Name, bip353)
lnwire.SetOptFromMap(tm, &inv.InvoicePaths, invPaths)
lnwire.SetOptFromMap(tm, &inv.InvoiceBlindedPay, blindedPay)
lnwire.SetOptFromMap(tm, &inv.InvoiceCreatedAt, createdAt)
lnwire.SetOptFromMap(tm, &inv.InvoiceRelativeExp, relExp)
lnwire.SetOptFromMap(tm, &inv.InvoicePaymentHash, payHash)
lnwire.SetOptFromMap(tm, &inv.InvoiceAmount, invAmt)
lnwire.SetOptFromMap(tm, &inv.InvoiceFallbacks, fallbacks)
lnwire.SetOptFromMap(tm, &inv.InvoiceFeatures, invFeat)
lnwire.SetOptFromMap(tm, &inv.InvoiceNodeID, nodeID)
lnwire.SetOptFromMap(tm, &inv.Signature, sig)
inv.decodedTLVs = tm
return &inv, nil
}
// NewInvoiceFromRequest constructs a new Invoice by copying (mirroring) all
// non-signature fields from the provided InvoiceRequest. When invreq_amount is
// present it is mirrored into invoice_amount per the writer requirement. The
// caller is responsible for populating the remaining invoice-specific fields
// (invoice_created_at, invoice_payment_hash, invoice_node_id, invoice_paths,
// invoice_blindedpay, ...) and signing the invoice.
func NewInvoiceFromRequest(req *InvoiceRequest) *Invoice {
inv := &Invoice{
InvreqMetadata: req.InvreqMetadata,
OfferChains: req.OfferChains,
OfferMetadata: req.OfferMetadata,
OfferCurrency: req.OfferCurrency,
OfferAmount: req.OfferAmount,
OfferDescription: req.OfferDescription,
OfferFeatures: req.OfferFeatures,
OfferAbsoluteExpiry: req.OfferAbsoluteExpiry,
OfferPaths: req.OfferPaths,
OfferIssuer: req.OfferIssuer,
OfferQuantityMax: req.OfferQuantityMax,
OfferIssuerID: req.OfferIssuerID,
InvreqChain: req.InvreqChain,
InvreqAmount: req.InvreqAmount,
InvreqFeatures: req.InvreqFeatures,
InvreqQuantity: req.InvreqQuantity,
InvreqPayerID: req.InvreqPayerID,
InvreqPayerNote: req.InvreqPayerNote,
InvreqPaths: req.InvreqPaths,
InvreqBip353Name: req.InvreqBip353Name,
// Carry the request's unknown signed-range TLVs. Known invreq
// types appear in the map with nil values and are skipped when
// the sidecar is merged, so this re-emits only the unknowns and
// never duplicates the typed fields copied above. Any
// signature-range entries (240-1000) cloned here are inert:
// allRecordsFromTypeMap drops them via bolt12InUnsignedRange,
// so the request's signature never leaks into the invoice.
decodedTLVs: maps.Clone(req.decodedTLVs),
}
// Writer rule: if invreq_amount is present, invoice_amount MUST be set
// to it. When absent, the caller sets the expected amount.
req.InvreqAmount.WhenSome(
func(r tlv.RecordT[tlv.TlvType82, TUint64]) {
inv.InvoiceAmount = tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType170, TUint64](r.Val),
)
},
)
return inv
}

359
bolt12/invoice_test.go Normal file
View file

@ -0,0 +1,359 @@
package bolt12
import (
"bytes"
"testing"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
)
// validInvoice returns an Invoice populated with the minimum set of fields
// required to satisfy ValidateInvoiceWrite.
func validInvoice(t *testing.T) *Invoice {
t.Helper()
_, pub := bobKey()
var payHash [32]byte
for i := range payHash {
payHash[i] = byte(i)
}
_, intro := aliceKey()
_, blinding := bobKey()
_, hopPub := aliceKey()
introNode, err := lnwire.NewPubkeyIntro(intro)
require.NoError(t, err)
return &Invoice{
InvoiceCreatedAt: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType164, TUint64](
TUint64(1234567890),
),
),
InvoiceAmount: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType170, TUint64](
TUint64(100_000),
),
),
InvoicePaymentHash: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType168, [32]byte](
payHash,
),
),
InvoiceNodeID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType176](pub),
),
InvoicePaths: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths](
lnwire.BlindedPaths{
Paths: []lnwire.BlindedPath{{
IntroductionNode: introNode,
BlindingPoint: blinding,
Hops: []lnwire.BlindedHop{{
BlindedNodeID: hopPub,
}},
}},
},
),
),
InvoiceBlindedPay: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos](
BlindedPayInfos{Infos: []BlindedPayInfo{{}}},
),
),
}
}
// TestUsableFallbackAddresses pins the BOLT 12 ignore semantics for
// invoice_fallbacks.
func TestUsableFallbackAddresses(t *testing.T) {
t.Parallel()
addrs := []FallbackAddress{
// Valid: version 0, 2 bytes.
{Version: 0, Address: []byte{0x01, 0x02}},
// Invalid: version 17, 2 bytes. Version is not supported.
{Version: 17, Address: []byte{0x01, 0x02}},
// Invalid: version 0, 1 byte. Address is too short.
{Version: 0, Address: []byte{0x01}},
// Invalid: version 0, 41 bytes. Address is too long.
{Version: 0, Address: make([]byte, 41)},
// Valid: version 16, 40 bytes.
{Version: 16, Address: make([]byte, 40)},
}
inv := &Invoice{
InvoiceFallbacks: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType172, FallbackAddresses](
FallbackAddresses{Addrs: addrs},
),
),
}
got := inv.UsableFallbackAddresses()
require.Len(t, got, 2)
require.Equal(t, byte(0), got[0].Version)
require.Equal(t, byte(16), got[1].Version)
require.Len(t, got[1].Address, 40)
}
// TestUsablePaths pins the BOLT 12 reader filter that excludes any blinded path
// whose payinfo.features carries an unknown required (even) bit, and confirms
// each surviving entry is paired with its own payinfo by index.
func TestUsablePaths(t *testing.T) {
t.Parallel()
_, blinding := bobKey()
_, hopPub := aliceKey()
_, intro := aliceKey()
introNode, err := lnwire.NewPubkeyIntro(intro)
require.NoError(t, err)
// hop builds a minimal single-hop blinded path; two of these populate
// invoice_paths so the by-index pairing with payinfos can be observed.
hop := lnwire.BlindedPath{
IntroductionNode: introNode,
BlindingPoint: blinding,
Hops: []lnwire.BlindedHop{{BlindedNodeID: hopPub}},
}
pathsRecord := func(n int) tlv.OptionalRecordT[
tlv.TlvType160, lnwire.BlindedPaths,
] {
paths := make([]lnwire.BlindedPath, n)
for i := range paths {
paths[i] = hop
}
return tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType160, lnwire.BlindedPaths](
lnwire.BlindedPaths{Paths: paths},
),
)
}
payRecord := func(infos ...BlindedPayInfo) tlv.OptionalRecordT[
tlv.TlvType162, BlindedPayInfos,
] {
return tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType162, BlindedPayInfos](
BlindedPayInfos{Infos: infos},
),
)
}
// The first payinfo carries an unknown required feature bit
// (MPPRequired); the second is featureless.
required := *lnwire.NewRawFeatureVector(lnwire.MPPRequired)
inv := &Invoice{
InvoicePaths: pathsRecord(2),
InvoiceBlindedPay: payRecord(
BlindedPayInfo{FeeBaseMsat: 1, Features: required},
BlindedPayInfo{FeeBaseMsat: 2},
),
}
// Empty catalogue: the MPPRequired bit is unknown, so path 0 is
// filtered out and only path 1 (fee_base 2) survives.
got := inv.UsablePaths(nil)
require.Len(t, got, 1)
require.Equal(t, uint32(2), got[0].PayInfo.FeeBaseMsat)
// Once the bit is known, both paths become usable and stay paired with
// their own payinfo in order.
known := map[lnwire.FeatureBit]string{lnwire.MPPRequired: "mpp"}
got = inv.UsablePaths(known)
require.Len(t, got, 2)
require.Equal(t, uint32(1), got[0].PayInfo.FeeBaseMsat)
require.Equal(t, uint32(2), got[1].PayInfo.FeeBaseMsat)
// A length mismatch between paths and payinfos yields no usable paths
// (rejected upstream by ValidateInvoiceRead).
inv.InvoiceBlindedPay = payRecord(BlindedPayInfo{})
require.Empty(t, inv.UsablePaths(known))
}
// TestInvoiceRoundTripPreservesAllTypes encodes a fully populated invoice then
// decodes it back, asserting every field is preserved byte-for-byte. The codec
// promises bijection on the message level, and any drift (dropped record,
// re-ordered output) breaks downstream signature verification because the
// Merkle root depends on the exact raw TLV stream.
func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) {
t.Parallel()
inv := validInvoice(t)
inv.Signature = tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte]([64]byte{}),
)
encoded, err := inv.Encode()
require.NoError(t, err)
require.NotEmpty(t, encoded)
decoded, err := DecodeInvoice(encoded)
require.NoError(t, err)
err = ValidateInvoiceRead(decoded, bitcoinMainnetGenesisHash,
InvoiceFeatureCatalogues{})
require.NoError(t, err)
// Re-encode the decoded copy and confirm canonicality.
// decode(encode(decode(encode(x)))) must equal decode(encode(x)).
encoded2, err := decoded.Encode()
require.NoError(t, err)
require.Equal(t, encoded, encoded2)
}
// TestDecodeInvoiceRejectsTruncated locks in that DecodeInvoice surfaces an
// error when fed a truncated TLV stream rather than returning a partial
// Invoice. A silent partial-decode would let validation see fields that weren't
// actually on the wire.
func TestDecodeInvoiceRejectsTruncated(t *testing.T) {
t.Parallel()
inv := validInvoice(t)
encoded, err := inv.Encode()
require.NoError(t, err)
// Chop off the last byte. The truncation lands in the middle of the
// final blinded_pay record's variable-length payload.
truncated := encoded[:len(encoded)-1]
_, err = DecodeInvoice(truncated)
require.Error(t, err)
}
// TestNewInvoiceFromRequest verifies the constructor mirrors all non-signature
// invoice_request fields into the invoice, applies the invreq_amount ->
// invoice_amount writer rule, and does not copy the request's signature.
func TestNewInvoiceFromRequest(t *testing.T) {
t.Parallel()
_, bobPub := bobKey()
_, alicePub := aliceKey()
req := &InvoiceRequest{
OfferDescription: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType10](
tlv.Blob("description"),
),
),
OfferIssuerID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType22](alicePub),
),
InvreqMetadata: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType0](
tlv.Blob("payer-metadata"),
),
),
InvreqPayerID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub),
),
InvreqAmount: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType82, TUint64](2500),
),
Signature: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}),
),
}
inv := NewInvoiceFromRequest(req)
require.NotNil(t, inv)
// Non-signature request fields are mirrored exactly.
require.Equal(t, req.OfferDescription, inv.OfferDescription)
require.Equal(t, req.OfferIssuerID, inv.OfferIssuerID)
require.Equal(t, req.InvreqMetadata, inv.InvreqMetadata)
require.Equal(t, req.InvreqPayerID, inv.InvreqPayerID)
require.Equal(t, req.InvreqAmount, inv.InvreqAmount)
// invreq_amount is mirrored into invoice_amount per the writer rule.
require.Equal(t, TUint64(2500), inv.InvoiceAmount.UnwrapOrFailV(t))
// The request's signature is not copied. The invoice signs its own.
require.True(t, inv.Signature.IsNone())
}
// TestNewInvoiceFromRequestMirrorsUnknownFields verifies the writer requirement
// "MUST copy all non-signature fields from the invoice request (including
// unknown fields)": an unknown odd TLV in the request's signed range must
// survive into the constructed invoice's canonical record set so it is signed.
func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) {
t.Parallel()
_, bobPub := bobKey()
// Build a minimal valid spontaneous request and encode it.
req := &InvoiceRequest{
OfferDescription: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("desc")),
),
InvreqMetadata: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("meta")),
),
InvreqPayerID: tlv.SomeRecordT(
tlv.NewPrimitiveRecord[tlv.TlvType88](bobPub),
),
InvreqAmount: tlv.SomeRecordT(
tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
),
}
encoded, err := req.Encode()
require.NoError(t, err)
// Fill in an unknown odd TLV (type 93, within the invreq signed range
// and above the request's existing types) so the spliced stream stays
// canonically sorted and the unknown lands in the decoded request's
// decodedTLVs sidecar.
const unknownType = 93
unknownVal := []byte("xyz")
var extra bytes.Buffer
require.NoError(t, tlv.WriteVarInt(&extra, unknownType, &[8]byte{}))
require.NoError(t, tlv.WriteVarInt(
&extra, uint64(len(unknownVal)), &[8]byte{},
))
extra.Write(unknownVal)
spliced := append(append([]byte{}, encoded...), extra.Bytes()...)
decodedReq, err := DecodeInvoiceRequest(spliced)
require.NoError(t, err)
inv := NewInvoiceFromRequest(decodedReq)
// The unknown field must appear in the invoice's canonical record set
// with its value preserved, not just its type.
var (
found bool
gotVal bytes.Buffer
)
for _, r := range inv.AllRecords() {
if r.Type() != unknownType {
continue
}
found = true
require.NoError(t, r.Encode(&gotVal))
}
require.True(t, found, "unknown request TLV not mirrored into invoice")
require.Equal(
t, unknownVal, gotVal.Bytes(),
"unknown request TLV value not preserved",
)
}
// TestInvoiceEncodeValidationGate verifies that Encode runs
// ValidateInvoiceWrite and rejects invalid invoices.
func TestInvoiceEncodeValidationGate(t *testing.T) {
t.Parallel()
inv := validInvoice(t)
inv.InvoiceCreatedAt = tlv.OptionalRecordT[
tlv.TlvType164, TUint64,
]{}
_, err := inv.Encode()
require.ErrorIs(t, err, ErrMissingCreatedAt)
}

View file

@ -1,10 +1,13 @@
package bolt12
import (
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)
@ -12,6 +15,28 @@ import (
// maxOfferChains.
var ErrTooManyChains = errors.New("offer_chains exceeds maxOfferChains")
// ErrNonMinimalFeatures is returned when a decoded feature vector is not
// canonically (minimally) encoded.
var ErrNonMinimalFeatures = errors.New("non-minimal feature vector encoding")
// ErrTooManyBlindedPayInfos is returned when decoded blinded_payinfo entries
// exceed maxBlindedPayInfos.
var ErrTooManyBlindedPayInfos = errors.New(
"invoice_blindedpay exceeds maxBlindedPayInfos",
)
// ErrInvalidHtlcRange is returned when a decoded blinded_payinfo entry carries
// an htlc_minimum_msat greater than its htlc_maximum_msat.
var ErrInvalidHtlcRange = errors.New(
"blinded_payinfo htlc_minimum_msat exceeds htlc_maximum_msat",
)
// ErrTooManyFallbackAddrs is returned when decoded fallback_address entries
// exceed maxFallbackAddrs.
var ErrTooManyFallbackAddrs = errors.New(
"invoice_fallbacks exceeds maxFallbackAddrs",
)
const (
// chainHashLen is the length of a chain hash (32 bytes).
chainHashLen = 32
@ -20,6 +45,19 @@ const (
// check to prevent excessive memory allocation and is not a protocol
// limit but a local implementation choice.
maxOfferChains = 32
// maxBlindedPayInfos caps decoded blinded_payinfo entries to prevent
// excessive allocation and validation cost.
maxBlindedPayInfos = 32
// maxFallbackAddrs caps decoded fallback_address entries to prevent
// excessive allocation and validation cost.
maxFallbackAddrs = 32
// maxFallbackAddrLen bounds the address bytes in a single fallback
// entry. The spec encodes the length as a uint16, so 65535 is the
// format's ceiling.
maxFallbackAddrLen = math.MaxUint16
)
// ChainsRecord holds one or more chain hashes for the offer_chains field.
@ -85,3 +123,326 @@ func decodeChainsRecord(r io.Reader, val any, _ *[8]byte, l uint64) error {
return nil
}
// BlindedPayInfo holds the payment parameters for a blinded path, corresponding
// to the blinded_payinfo subtype.
type BlindedPayInfo struct {
// FeeBaseMsat is the base fee, in millisatoshis, charged for relaying a
// payment over this blinded path.
FeeBaseMsat uint32
// FeeProportionalMillionths is the proportional fee, in millionths of a
// satoshi per relayed satoshi, charged over this blinded path.
FeeProportionalMillionths uint32
// CltvExpiryDelta is the CLTV expiry delta the path requires.
CltvExpiryDelta uint16
// HtlcMinimumMsat is the smallest HTLC, in millisatoshis, the path
// accepts.
HtlcMinimumMsat uint64
// HtlcMaximumMsat is the largest HTLC, in millisatoshis, the path
// accepts.
HtlcMaximumMsat uint64
// Features is the relay feature bitmap for this blinded path, typed for
// consistency with the other BOLT 12 feature fields.
//
// WARNING: RawFeatureVector re-encodes to minimal length, so setting
// non-minimal feature bytes (trailing zeros) yields different wire
// bytes than were read and invalidates the invoice signature.
Features lnwire.RawFeatureVector
}
// BlindedPayInfos holds a list of BlindedPayInfo entries for the
// invoice_blindedpay field.
type BlindedPayInfos struct {
Infos []BlindedPayInfo
}
// Record returns a TLV record for BlindedPayInfos.
//
// NOTE: This implements the tlv.RecordProducer interface.
func (bp *BlindedPayInfos) Record() tlv.Record {
return tlv.MakeDynamicRecord(
0, bp,
func() uint64 {
return blindedPayInfosSize(bp)
},
encodeBlindedPayInfos, decodeBlindedPayInfos,
)
}
// blindedPayInfosSize returns the encoded byte length of all blinded_payinfo
// entries, used to size the dynamic TLV record.
func blindedPayInfosSize(bp *BlindedPayInfos) uint64 {
var size uint64
for _, info := range bp.Infos {
// fee_base(4) + fee_prop(4) + cltv(2) + htlc_min(8) +
// htlc_max(8) + flen(2) + features.
size += 4 + 4 + 2 + 8 + 8 + 2 +
uint64(info.Features.SerializeSize())
}
return size
}
// encodeBlindedPayInfos writes each blinded_payinfo entry in sequence: the
// fixed fee, cltv and htlc fields followed by a u16-length-prefixed feature
// vector. Entries are concatenated without a count prefix; the count is
// recovered on decode from the surrounding invoice_paths length.
func encodeBlindedPayInfos(
w io.Writer, val interface{}, buf *[8]byte) error {
bp, ok := val.(*BlindedPayInfos)
if !ok {
return fmt.Errorf("expected *BlindedPayInfos, got %T", val)
}
for _, info := range bp.Infos {
binary.BigEndian.PutUint32(buf[:4], info.FeeBaseMsat)
if _, err := w.Write(buf[:4]); err != nil {
return err
}
binary.BigEndian.PutUint32(
buf[:4], info.FeeProportionalMillionths,
)
if _, err := w.Write(buf[:4]); err != nil {
return err
}
binary.BigEndian.PutUint16(buf[:2], info.CltvExpiryDelta)
if _, err := w.Write(buf[:2]); err != nil {
return err
}
binary.BigEndian.PutUint64(buf[:8], info.HtlcMinimumMsat)
if _, err := w.Write(buf[:8]); err != nil {
return err
}
binary.BigEndian.PutUint64(buf[:8], info.HtlcMaximumMsat)
if _, err := w.Write(buf[:8]); err != nil {
return err
}
// flen is a u16, so guard the cast before framing the minimal
// feature bytes, mirroring encodeFallbackAddrs.
flen := info.Features.SerializeSize()
if flen > math.MaxUint16 {
return fmt.Errorf("features %d exceed limit %d",
flen, math.MaxUint16)
}
binary.BigEndian.PutUint16(buf[:2], uint16(flen))
if _, err := w.Write(buf[:2]); err != nil {
return err
}
if err := info.Features.EncodeBase256(w); err != nil {
return err
}
}
return nil
}
// decodeBlindedPayInfos reads blinded_payinfo entries until the record bytes
// are exhausted. The entry count is capped at maxBlindedPayInfos to prevent
// excessive memory allocation and validation cost.
func decodeBlindedPayInfos(
r io.Reader, val interface{}, buf *[8]byte, l uint64) error {
bp, ok := val.(*BlindedPayInfos)
if !ok {
return fmt.Errorf("expected *BlindedPayInfos, got %T", val)
}
lr := &io.LimitedReader{R: r, N: int64(l)}
for lr.N > 0 {
if len(bp.Infos) >= maxBlindedPayInfos {
return ErrTooManyBlindedPayInfos
}
var info BlindedPayInfo
if _, err := io.ReadFull(lr, buf[:4]); err != nil {
return fmt.Errorf("read fee_base: %w", err)
}
info.FeeBaseMsat = binary.BigEndian.Uint32(buf[:4])
if _, err := io.ReadFull(lr, buf[:4]); err != nil {
return fmt.Errorf("read fee_prop: %w", err)
}
info.FeeProportionalMillionths = binary.BigEndian.Uint32(
buf[:4],
)
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
return fmt.Errorf("read cltv_delta: %w", err)
}
info.CltvExpiryDelta = binary.BigEndian.Uint16(buf[:2])
if _, err := io.ReadFull(lr, buf[:8]); err != nil {
return fmt.Errorf("read htlc_min: %w", err)
}
info.HtlcMinimumMsat = binary.BigEndian.Uint64(buf[:8])
if _, err := io.ReadFull(lr, buf[:8]); err != nil {
return fmt.Errorf("read htlc_max: %w", err)
}
info.HtlcMaximumMsat = binary.BigEndian.Uint64(buf[:8])
// Defense-in-depth decode check, mirroring the
// ErrNonMinimalFeatures guard below: reject an inverted HTLC
// range so the htlc_min <= htlc_max invariant holds for every
// downstream consumer instead of being re-derived per caller.
if info.HtlcMinimumMsat > info.HtlcMaximumMsat {
return ErrInvalidHtlcRange
}
// flen then features, mirroring decodeFallbackAddrs: reject a
// length that overruns the remaining bytes before allocating.
// Decode into a constructed vector so its map is initialised.
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
return fmt.Errorf("read flen: %w", err)
}
flen := binary.BigEndian.Uint16(buf[:2])
if int64(flen) > lr.N {
return fmt.Errorf("flen %d exceeds remaining %d",
flen, lr.N)
}
fv := lnwire.NewRawFeatureVector()
if err := fv.DecodeBase256(lr, int(flen)); err != nil {
return fmt.Errorf("read features: %w", err)
}
if fv.SerializeSize() != int(flen) {
return ErrNonMinimalFeatures
}
info.Features = *fv
bp.Infos = append(bp.Infos, info)
}
return nil
}
// FallbackAddress represents an on-chain fallback address.
type FallbackAddress struct {
Version byte
Address []byte
}
// FallbackAddresses holds a list of fallback addresses for the
// invoice_fallbacks field.
type FallbackAddresses struct {
Addrs []FallbackAddress
}
// Record returns a TLV record for FallbackAddresses.
//
// NOTE: This implements the tlv.RecordProducer interface.
func (fa *FallbackAddresses) Record() tlv.Record {
return tlv.MakeDynamicRecord(
0, fa,
func() uint64 {
return fallbackAddrsSize(fa)
},
encodeFallbackAddrs, decodeFallbackAddrs,
)
}
// fallbackAddrsSize returns the encoded byte length of all fallback_address
// entries, used to size the dynamic TLV record.
func fallbackAddrsSize(fa *FallbackAddresses) uint64 {
var size uint64
for _, a := range fa.Addrs {
// version(1) + len(2) + address
size += 1 + 2 + uint64(len(a.Address))
}
return size
}
// encodeFallbackAddrs writes each fallback_address entry as a version byte, a
// u16 address length and the raw address bytes, concatenated without a count
// prefix.
func encodeFallbackAddrs(
w io.Writer, val interface{}, buf *[8]byte) error {
fa, ok := val.(*FallbackAddresses)
if !ok {
return fmt.Errorf("expected *FallbackAddresses, got %T", val)
}
for i, a := range fa.Addrs {
if len(a.Address) > maxFallbackAddrLen {
return fmt.Errorf("fallback %d: address %d exceeds "+
"limit %d", i, len(a.Address),
maxFallbackAddrLen)
}
buf[0] = a.Version
if _, err := w.Write(buf[:1]); err != nil {
return err
}
binary.BigEndian.PutUint16(buf[:2], uint16(len(a.Address)))
if _, err := w.Write(buf[:2]); err != nil {
return err
}
if _, err := w.Write(a.Address); err != nil {
return err
}
}
return nil
}
// decodeFallbackAddrs reads fallback_address entries until the record bytes are
// exhausted. The entry count is capped at maxFallbackAddrs to prevent
// excessive memory allocation and validation cost.
func decodeFallbackAddrs(
r io.Reader, val interface{}, buf *[8]byte, l uint64) error {
fa, ok := val.(*FallbackAddresses)
if !ok {
return fmt.Errorf("expected *FallbackAddresses, got %T", val)
}
lr := &io.LimitedReader{R: r, N: int64(l)}
for lr.N > 0 {
if len(fa.Addrs) >= maxFallbackAddrs {
return ErrTooManyFallbackAddrs
}
var a FallbackAddress
if _, err := io.ReadFull(lr, buf[:1]); err != nil {
return fmt.Errorf("read version: %w", err)
}
a.Version = buf[0]
if _, err := io.ReadFull(lr, buf[:2]); err != nil {
return fmt.Errorf("read addrlen: %w", err)
}
addrLen := binary.BigEndian.Uint16(buf[:2])
if int64(addrLen) > lr.N {
return fmt.Errorf("addrlen %d exceeds remaining %d",
addrLen, lr.N)
}
a.Address = make([]byte, addrLen)
if _, err := io.ReadFull(lr, a.Address); err != nil {
return fmt.Errorf("read address: %w", err)
}
fa.Addrs = append(fa.Addrs, a)
}
return nil
}

View file

@ -3,8 +3,10 @@ package bolt12
import (
"bytes"
"encoding/hex"
"math"
"testing"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/stretchr/testify/require"
)
@ -152,3 +154,262 @@ func TestChainsRecordRoundTrip(t *testing.T) {
})
}
}
// TestFallbackAddressesRoundTrip encodes a list of fallback addresses
// covering BIP-141 v0, BIP-350 v1, a forward-compatible v2 entry, and
// a v17 entry that the spec mandates a *reader* ignore but the codec
// layer must still round-trip faithfully (the ignore policy lives at
// the invoice-consumer layer, not at the codec). The fallback list is
// on-chain payment data: a wrong version byte or mis-framed length
// translates into funds going to an unintended script, so encode/
// decode must be a faithful bijection across the entire version
// range.
func TestFallbackAddressesRoundTrip(t *testing.T) {
t.Parallel()
addrs := &FallbackAddresses{
Addrs: []FallbackAddress{
{
Version: 0,
Address: bytes.Repeat([]byte{0xab}, 20),
},
{
Version: 1,
Address: bytes.Repeat([]byte{0xcd}, 32),
},
{
Version: 2,
Address: bytes.Repeat([]byte{0xef}, 64),
},
{
Version: 17,
Address: bytes.Repeat([]byte{0x99}, 20),
},
},
}
var buf bytes.Buffer
require.NoError(t, encodeFallbackAddrs(&buf, addrs, new([8]byte)))
encoded := buf.Bytes()
expectedSize := fallbackAddrsSize(addrs)
require.Equal(t, expectedSize, uint64(len(encoded)))
var decoded FallbackAddresses
err := decodeFallbackAddrs(
bytes.NewReader(encoded), &decoded, new([8]byte),
uint64(len(encoded)),
)
require.NoError(t, err)
require.Equal(t, addrs.Addrs, decoded.Addrs)
}
// TestBlindedPayInfosRoundTrip encodes a list of blinded_payinfo entries and
// asserts decode reproduces them exactly.
func TestBlindedPayInfosRoundTrip(t *testing.T) {
t.Parallel()
noFeats := *lnwire.NewRawFeatureVector()
someFeats := *lnwire.NewRawFeatureVector(8, 15)
infos := &BlindedPayInfos{
Infos: []BlindedPayInfo{
{
FeeBaseMsat: 1000,
FeeProportionalMillionths: 250,
CltvExpiryDelta: 144,
HtlcMinimumMsat: 1,
HtlcMaximumMsat: 1_000_000,
Features: noFeats,
},
{
FeeBaseMsat: 0,
FeeProportionalMillionths: 0,
CltvExpiryDelta: 40,
HtlcMinimumMsat: 0,
HtlcMaximumMsat: math.MaxUint64,
Features: someFeats,
},
},
}
var buf bytes.Buffer
require.NoError(t, encodeBlindedPayInfos(&buf, infos, new([8]byte)))
encoded := buf.Bytes()
require.Equal(t, blindedPayInfosSize(infos), uint64(len(encoded)))
var decoded BlindedPayInfos
err := decodeBlindedPayInfos(
bytes.NewReader(encoded), &decoded,
new([8]byte), uint64(len(encoded)),
)
require.NoError(t, err)
require.Equal(t, infos.Infos, decoded.Infos)
}
// TestDecodeBlindedPayInfosRejectsTruncated covers truncation before the fixed
// fields and before the declared features payload. Each must fail rather than
// yield a partial BlindedPayInfos with corrupt entries.
func TestDecodeBlindedPayInfosRejectsTruncated(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data []byte
declLen uint64
errSubstr string
}{
{
name: "missing fee_base",
data: nil,
declLen: 4,
errSubstr: "read fee_base",
},
{
name: "features length exceeds remaining",
// fee_base(4) fee_prop(4) cltv(2) htlc_min(8)
// htlc_max(8) then flen=0xffff with no payload.
data: append(
make([]byte, 26), []byte{0xff, 0xff}...,
),
declLen: 28,
errSubstr: "exceeds remaining",
},
{
name: "exceeds cap",
data: make([]byte, (maxBlindedPayInfos+1)*28),
declLen: (maxBlindedPayInfos + 1) * 28,
errSubstr: "exceeds maxBlindedPayInfos",
},
{
name: "non-minimal features",
// fee_base(4) + fee_prop(4) + cltv(2) + htlc_min(8) +
// htlc_max(8) followed by flen = 1, and 1 non-minimal
// feature byte (trailing zero).
data: append(
make([]byte, 26), []byte{0x00, 0x01, 0x00}...,
),
declLen: 29,
errSubstr: "non-minimal",
},
{
name: "inverted htlc range",
// htlc_min at bytes [10:18] = 1000, htlc_max at bytes
// [18:26] = 500, so min > max must be rejected before
// the flen/features are ever read.
data: func() []byte {
b := make([]byte, 26)
b[16], b[17] = 0x03, 0xe8 // htlc_min = 1000
b[24], b[25] = 0x01, 0xf4 // htlc_max = 500
return b
}(),
declLen: 26,
errSubstr: "htlc_minimum_msat exceeds",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var bp BlindedPayInfos
err := decodeBlindedPayInfos(
bytes.NewReader(tc.data), &bp, new([8]byte),
tc.declLen,
)
require.Error(t, err)
require.Contains(t, err.Error(), tc.errSubstr)
})
}
}
// TestEncodeFallbackAddrsRejectsOversize asserts the maxFallbackAddrLen cap is
// enforced before any bytes hit the writer.
func TestEncodeFallbackAddrsRejectsOversize(t *testing.T) {
t.Parallel()
addrs := &FallbackAddresses{
Addrs: []FallbackAddress{{
Version: 0,
Address: make([]byte, maxFallbackAddrLen+1),
}},
}
var buf bytes.Buffer
err := encodeFallbackAddrs(&buf, addrs, new([8]byte))
require.Error(t, err)
require.Contains(t, err.Error(), "exceeds limit")
require.Zero(t, buf.Len(),
"no bytes should be written when validation fails")
}
// TestDecodeFallbackAddrsRejectsTruncated covers the three truncation points in
// decodeFallbackAddrs: stream ends before the version byte, before the 16-bit
// length, and before the address payload of the declared size. Each must fail
// with an error rather than yielding a partial FallbackAddresses with corrupt
// entries.
func TestDecodeFallbackAddrsRejectsTruncated(t *testing.T) {
t.Parallel()
// Each case declares a TLV-record length that overshoots the bytes
// actually present, simulating a malformed wire payload that promises
// more data than it delivers.
tests := []struct {
name string
data []byte
declLen uint64
errSubstr string
}{
{
name: "missing version byte",
data: nil,
declLen: 1,
errSubstr: "read version",
},
{
name: "missing length bytes",
data: []byte{0x00},
declLen: 3,
errSubstr: "read addrlen",
},
{
name: "truncated address payload",
data: []byte{
0x00, 0x00, 0x05, 0xab, 0xab,
},
declLen: 8,
errSubstr: "read address",
},
{
// addrlen > remaining trips the guard before
// allocation; without it a hostile addrlen would force
// a huge make([]byte, addrLen).
name: "addrlen exceeds remaining",
data: []byte{0x00, 0xff, 0xff, 0xab},
declLen: 4,
errSubstr: "exceeds remaining",
},
{
name: "exceeds cap",
data: make([]byte, (maxFallbackAddrs+1)*3),
declLen: (maxFallbackAddrs + 1) * 3,
errSubstr: "exceeds maxFallbackAddrs",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var fa FallbackAddresses
err := decodeFallbackAddrs(
bytes.NewReader(tc.data), &fa, new([8]byte),
tc.declLen,
)
require.Error(t, err)
require.Contains(t, err.Error(), tc.errSubstr)
})
}
}

View file

@ -20,3 +20,20 @@ func (t *TUint64) Record() tlv.Record {
tlv.ETUint64, tlv.DTUint64,
)
}
// TUint32 is a uint32 that serializes using truncated encoding (tu32) as
// required by BOLT 12. Leading zero bytes are omitted.
type TUint32 uint32
// Record returns a TLV record using truncated uint32 encoding.
//
// NOTE: This implements the tlv.RecordProducer interface.
func (t *TUint32) Record() tlv.Record {
return tlv.MakeDynamicRecord(
0, (*uint32)(t),
func() uint64 {
return tlv.SizeTUint32(uint32(*t))
},
tlv.ETUint32, tlv.DTUint32,
)
}

View file

@ -1,6 +1,7 @@
package bolt12
import (
"bytes"
"errors"
"fmt"
"math/bits"
@ -144,6 +145,61 @@ var (
ErrOfferFieldsOnSpontaneous = errors.New(
"offer fields present on non-offer response",
)
// ErrMissingCreatedAt is returned when invoice_created_at is absent.
ErrMissingCreatedAt = errors.New("missing invoice_created_at")
// ErrMissingPaymentHash is returned when invoice_payment_hash is
// absent.
ErrMissingPaymentHash = errors.New("missing invoice_payment_hash")
// ErrMissingNodeID is returned when invoice_node_id is absent.
ErrMissingNodeID = errors.New("missing invoice_node_id")
// ErrMissingBlindedPay is returned when invoice_blindedpay is absent.
ErrMissingBlindedPay = errors.New("missing invoice_blindedpay")
// ErrBlindedPayMismatch is returned when invoice_blindedpay does not
// correspond 1:1 with invoice_paths.
ErrBlindedPayMismatch = errors.New(
"invoice_blindedpay count does not match invoice_paths",
)
// ErrMissingPaths is returned when invoice_paths is absent.
ErrMissingPaths = errors.New("missing invoice_paths")
// ErrNoUsablePaths is returned by ValidateInvoiceRead when every
// blinded path in invoice_paths carries unknown required features in
// payinfo.
ErrNoUsablePaths = errors.New(
"no blinded paths with known required features",
)
// ErrInvoiceExpired is returned by ValidateInvoiceExpiry when the
// caller's clock is past invoice_created_at + invoice_relative_expiry
// (default 7200 seconds when relative expiry is absent).
ErrInvoiceExpired = errors.New("invoice has expired")
// ErrInvoiceMismatch is returned when an invoice field does not match
// the invoice request.
ErrInvoiceMismatch = errors.New(
"invoice field mismatch with request",
)
// ErrInvoiceNodeIDMismatch is returned when offer_issuer_id is present
// but invoice_node_id does not equal it. The spec requires the invoice
// to be signed by the offer's issuer in this case.
ErrInvoiceNodeIDMismatch = errors.New(
"invoice_node_id does not match offer_issuer_id",
)
// ErrZeroInvoiceAmount is returned when invoice_amount is present but
// set to zero. The spec permits a zero "minimum amount", but a
// zero-amount HTLC cannot settle past the channel-layer dust limit, so
// the codec rejects it with a typed sentinel a spec-strict caller can
// distinguish from a missing-field violation.
ErrZeroInvoiceAmount = errors.New("invoice_amount must be greater " +
"than zero")
)
const (
@ -171,6 +227,17 @@ const (
invreqPathsType tlv.Type = 90
invreqBip353NameType tlv.Type = 91
signatureTLVType tlv.Type = 240
// Invoice TLV types.
invoicePathsType tlv.Type = 160
invoiceBlindedPayType tlv.Type = 162
invoiceCreatedAtType tlv.Type = 164
invoiceRelativeExpiryType tlv.Type = 166
invoicePaymentHashType tlv.Type = 168
invoiceAmountType tlv.Type = 170
invoiceFallbacksType tlv.Type = 172
invoiceFeaturesType tlv.Type = 174
invoiceNodeIDType tlv.Type = 176
)
// isKnownInvreqTLVType determines if a TLV type is defined in the
@ -358,11 +425,10 @@ func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error {
// - if it supports bolt12 invoice request features:
// - MUST set invreq_features.features to the bitmap of features.
// We only reject unknown even bits here; advertising a feature is the
// caller's decision.
if err := checkFeatures(ir.InvreqFeatures); err != nil {
return err
}
// We rely on the writer to set feature bits correctly as those are
// mostly static and the reader will also verify the features. This is
// done to not having to pass in the known feature vector for writer
// validation, similar to other write validation in this file.
// check UTF-8 constraints and BIP 353
err := checkUTF8(ir.InvreqPayerNote, "invreq_payer_note")
@ -461,7 +527,8 @@ func getInvreqChain(ir *InvoiceRequest) [32]byte {
// Invoice message (see the TODO at the end of this function). Until then, a
// caller wiring this into a handler MUST verify the signature itself.
func ValidateInvoiceRequestRead(ir *InvoiceRequest,
activeChain [32]byte) error {
activeChain [32]byte,
knownFeatures map[lnwire.FeatureBit]string) error {
// A present-but-nil pubkey passes IsSome but would panic the codec on
// encode, so reject both pubkey fields.
@ -506,7 +573,7 @@ func ValidateInvoiceRequestRead(ir *InvoiceRequest,
// - if invreq_features contains unknown *even* bits that are non-zero:
// - MUST reject the invoice request.
if err := checkFeatures(ir.InvreqFeatures); err != nil {
if err := checkFeatures(ir.InvreqFeatures, knownFeatures); err != nil {
return err
}
@ -798,7 +865,9 @@ func isKnownOfferTLVType(typ tlv.Type) bool {
// defaults to Bitcoin mainnet, and the reader must reject offers that do not
// list a chain it operates on. Pass the genesis hash of the chain the receiver
// is willing to settle on.
func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte,
knownFeatures map[lnwire.FeatureBit]string) error {
// A present-but-nil offer_issuer_id passes IsSome but would panic the
// codec on encode, so reject it here.
if err := checkPubKeyNotNil(
@ -806,7 +875,6 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
); err != nil {
return err
}
// Check TLV types are in allowed range and that unknown even types are
// rejected (even = must-understand).
for _, t := range sortedTypes(o.decodedTLVs) {
@ -820,7 +888,7 @@ func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
}
// Check for unknown even feature bits.
if err := checkFeatures(o.OfferFeatures); err != nil {
if err := checkFeatures(o.OfferFeatures, knownFeatures); err != nil {
return err
}
@ -1029,15 +1097,13 @@ func checkISO4217[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob]) error {
// checkFeatures rejects any unknown even (must-understand) feature bit.
func checkFeatures[T tlv.TlvType](
opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector]) error {
opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector],
known map[lnwire.FeatureBit]string) error {
return fn.MapOptionZ(
opt.ValOpt(),
func(fv lnwire.RawFeatureVector) error {
// nil catalogue: BOLT 12 defines no feature bits yet,
// so every set even bit is "unknown". Swap in a
// Bolt12Features map once the spec assigns bits.
wrapped := lnwire.NewFeatureVector(&fv, nil)
wrapped := lnwire.NewFeatureVector(&fv, known)
unknown := wrapped.UnknownRequiredFeatures()
if len(unknown) == 0 {
return nil
@ -1114,3 +1180,572 @@ func checkPubKeyNotNil[T tlv.TlvType](
return nil
})
}
// checkInvoiceNodeID enforces the spec rule that, when offer_issuer_id is
// present, invoice_node_id MUST equal it. Both fields live on the invoice, so
// this is verifiable without the originating offer. The offer_paths branch
// (invoice_node_id equals the final blinded_node_id on the arrival path) needs
// caller context and is not checked here. A present-but-nil offer_issuer_id or
// invoice_node_id is rejected separately as ErrNilPublicKey, so a nil here is
// treated as absent.
func checkInvoiceNodeID(inv *Invoice) error {
// A present-but-nil offer_issuer_id is rejected separately as
// ErrNilPublicKey, so a nil here means absent and there is nothing to
// check.
issuerID := inv.OfferIssuerID.ValOpt().UnwrapOr(nil)
if issuerID == nil {
return nil
}
// invoice_node_id is likewise guarded against present-but-nil by
// checkPubKeyNotNil, so a nil here means absent; its required presence
// is enforced separately as ErrMissingNodeID.
nodeID := inv.InvoiceNodeID.ValOpt().UnwrapOr(nil)
if nodeID == nil || !nodeID.IsEqual(issuerID) {
return ErrInvoiceNodeIDMismatch
}
return nil
}
// ValidateInvoiceWrite validates an invoice per the BOLT 12 invoice writer
// requirements. The checks follow the spec's writer section in order.
// Requirements that depend on context this codec layer does not have
// (signing, the payment preimage, the offer or path the request arrived on)
// are noted inline as deferred to the caller or to a paired validator.
func ValidateInvoiceWrite(inv *Invoice) error {
// - MUST set invoice_created_at to the number of seconds since Midnight
// 1 January 1970, UTC when the invoice was created.
if !inv.InvoiceCreatedAt.IsSome() {
return ErrMissingCreatedAt
}
// - MUST set invoice_amount to the minimum amount it will accept, in
// units of the minimal lightning-payable unit (e.g. milli-satoshis
// for bitcoin) for invreq_chain.
if !inv.InvoiceAmount.IsSome() {
return ErrMissingAmount
}
// Policy extension: reject zero invoice_amount. The spec permits it
// ("minimum amount it will accept"), but a zero-amount HTLC cannot
// settle past the channel-layer dust limit. The typed
// ErrZeroInvoiceAmount lets a spec-strict caller distinguish this from
// a missing-field violation. Symmetric with ValidateInvoiceRead.
if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 {
return ErrZeroInvoiceAmount
}
// - if the invoice is in response to an invoice_request:
// - MUST copy all non-signature fields from the invoice request
// (including unknown fields).
// - if invreq_amount is present: MUST set invoice_amount to
// invreq_amount.
// - otherwise: MUST set invoice_amount to the expected amount.
// NOT CHECKED HERE: the copy is performed by NewInvoiceFromRequest and
// this validator runs on the assembled struct. The invoice_amount ==
// invreq_amount equality and the byte-for-byte field mirror are
// enforced when the invoice is paired with its request in
// ValidateInvoiceAgainstRequest. The offer_currency "expected amount"
// needs a live exchange rate the codec cannot compute.
// - MUST set invoice_payment_hash to the SHA256 hash of the
// payment_preimage that will be given in return for payment.
// NOT CHECKED HERE beyond presence: relating the hash to the preimage
// needs the preimage, which lives with the caller's logic.
if !inv.InvoicePaymentHash.IsSome() {
return ErrMissingPaymentHash
}
// - if offer_issuer_id is present: MUST set invoice_node_id to
// offer_issuer_id.
// - otherwise, if offer_paths is present: MUST set invoice_node_id to
// the final blinded_node_id on the path the request arrived on.
// The offer_issuer_id case is enforced by checkInvoiceNodeID since both
// fields live on the invoice. The offer_paths case needs the blinded
// arrival path, which is caller context, so only presence is checked
// for it.
//
// A present-but-nil pubkey passes IsSome but would panic the codec on
// encode, so reject it before the presence check.
if err := checkPubKeyNotNil(
inv.InvoiceNodeID, "invoice_node_id",
); err != nil {
return err
}
if !inv.InvoiceNodeID.IsSome() {
return ErrMissingNodeID
}
if err := checkInvoiceNodeID(inv); err != nil {
return err
}
// - MUST specify exactly one signature TLV element: signature.
// - MUST set sig to the signature using invoice_node_id as described
// in Signature Calculation.
// NOT CHECKED HERE: signing happens after this validator runs. The
// string-codec layer rejects an unsigned invoice, mirroring
// ValidateInvoiceRequestWrite.
// - if the expiry for accepting payment is not 7200 seconds after
// invoice_created_at: MUST set invoice_relative_expiry.
// seconds_from_creation to the number of seconds after
// invoice_created_at that payment should not be attempted.
// NOT CHECKED HERE: the writer chooses the expiry, so there is no rule
// to enforce on the encoded value. The time comparison needs a clock
// (see ValidateInvoiceExpiry).
// - if it accepts onchain payments:
// - MAY specify invoice_fallbacks.
// - SHOULD specify invoice_fallbacks in order of most-preferred to
// least-preferred if it has a preference.
// - for the bitcoin chain, it MUST set each fallback_address with
// version as a valid witness version and address as a valid witness
// program.
// NOT CHECKED HERE: the codec stays permissive so callers can inspect
// raw fallbacks. The spec's ignore semantics are applied on the read
// side by UsableFallbackAddresses.
// - MUST include invoice_paths containing one or more paths to the
// node.
// - MUST specify invoice_paths in order of most-preferred to
// least-preferred if it has a preference.
if !inv.InvoicePaths.IsSome() {
return ErrMissingPaths
}
// Writer mirror of the reader rule rejecting a blinded_path with zero
// hops.
if err := checkBlindedPaths(inv.InvoicePaths); err != nil {
return err
}
// - MUST include invoice_blindedpay with exactly one blinded_payinfo
// for each blinded_path in paths, in order.
// - MUST set features in each blinded_payinfo to match
// encrypted_data_tlv.allowed_features (or empty, if no
// allowed_features).
// NOT CHECKED HERE: matching each payinfo.features to its path's
// encrypted_data_tlv allowed_features needs the decrypted path, which
// is caller context. Only the 1:1 count is enforced below.
bp, err := inv.InvoiceBlindedPay.ValOpt().UnwrapOrErr(
ErrMissingBlindedPay,
)
if err != nil {
return err
}
// invoice_paths presence is enforced above, so the default is never the
// value used; UnwrapOr just avoids a second WhenSome.
paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{})
if len(paths.Paths) != len(bp.Infos) {
return ErrBlindedPayMismatch
}
// A present-but-nil pubkey passes IsSome but would panic the codec on
// encode, so reject the mirrored pubkey fields. Symmetric with
// ValidateInvoiceRequestWrite.
if err := fn.MapOptionZ(inv.InvreqPayerID.ValOpt(),
func(pk *btcec.PublicKey) error {
if pk == nil {
return fmt.Errorf("%w: invreq_payer_id",
ErrNilPublicKey)
}
return nil
}); err != nil {
return err
}
if err := fn.MapOptionZ(inv.OfferIssuerID.ValOpt(),
func(pk *btcec.PublicKey) error {
if pk == nil {
return fmt.Errorf("%w: offer_issuer_id",
ErrNilPublicKey)
}
return nil
}); err != nil {
return err
}
return nil
}
// defaultInvoiceRelativeExpiry is the spec-defined fallback when an invoice
// omits invoice_relative_expiry: two hours from creation.
const defaultInvoiceRelativeExpiry uint32 = 7200
// ValidateInvoiceExpiry rejects an invoice whose effective expiry is strictly
// before now. The effective expiry is invoice_created_at +
// invoice_relative_expiry, falling back to a 7200-second default per spec when
// relative expiry is absent. Per the BOLT 12 reader the invoice is rejected
// only when the current time is greater than the expiry, so the boundary second
// itself is still valid; this matches the strict comparison ValidateOfferRead
// uses for offer_absolute_expiry. Callers must invoke this separately after
// decoding. ValidateInvoiceRead covers the structural reader requirements, but
// the time check needs a clock the codec library doesn't supply.
func ValidateInvoiceExpiry(inv *Invoice, now time.Time) error {
createdAt, err := inv.InvoiceCreatedAt.ValOpt().UnwrapOrErr(
ErrMissingCreatedAt,
)
if err != nil {
return err
}
relExpiry := inv.InvoiceRelativeExp.ValOpt().UnwrapOr(
TUint32(defaultInvoiceRelativeExpiry),
)
// invoice_created_at + the relative expiry can overflow uint64 for an
// absurd timestamp. The true sum then exceeds any real clock, so the
// invoice is not expired: detect the carry rather than wrapping to a
// small value that would spuriously read as expired.
expiry, carry := bits.Add64(uint64(createdAt), uint64(relExpiry), 0)
if carry == 0 && uint64(now.Unix()) > expiry {
return ErrInvoiceExpired
}
return nil
}
// mirroredRecordBytes encodes the records in the invreq mirror range to their
// canonical per-record bytes, keyed by TLV type. This is the view the
// byte-for-byte invreq->invoice comparison operates on.
func mirroredRecordBytes(records []tlv.Record) (map[tlv.Type][]byte, error) {
out := make(map[tlv.Type][]byte)
for i := range records {
r := records[i]
if !invreqAllowedRange(r.Type()) {
continue
}
buf, err := lnwire.EncodeRecords([]tlv.Record{r})
if err != nil {
return nil, fmt.Errorf(
"encode record (type %d): %w", r.Type(), err,
)
}
out[r.Type()] = buf
}
return out, nil
}
// ValidateInvoiceAgainstRequest performs a byte-for-byte comparison of the
// fields in ranges 0-159 and 1000000000-2999999999 between an invoice and its
// original request, as required by the BOLT 12 invoice reader specification.
// Callers must invoke this after pairing the invoice with its originating
// request. The codec library cannot reach across that pairing on its own.
//
// The comparison runs against the canonical per-record encoding from each
// side's AllRecords output. Two structs that decode to the same typed fields
// and the same ExtraSignedFields entries produce byte-identical encodings for
// any matching type. That is the byte-mirror invariant the spec demands.
//
// The amount cross-check enforces the spec's authorized-range rule: when
// invreq_amount is present, invoice_amount MUST equal it; otherwise the payer
// relied on the offer's fixed amount, so invoice_amount MUST be at least
// offer_amount * invreq_quantity for the native (bitcoin) case. The
// offer_currency case needs a caller-supplied exchange rate and is delegated to
// the caller.
func ValidateInvoiceAgainstRequest(inv *Invoice, req *InvoiceRequest) error {
reqFields, err := mirroredRecordBytes(req.AllRecords())
if err != nil {
return fmt.Errorf("encode request fields: %w", err)
}
invFields, err := mirroredRecordBytes(inv.AllRecords())
if err != nil {
return fmt.Errorf("encode invoice fields: %w", err)
}
for typ, invBytes := range invFields {
reqBytes, ok := reqFields[typ]
if !ok {
return fmt.Errorf("%w: invoice contains unexpected "+
"field %d", ErrInvoiceMismatch, typ)
}
if !bytes.Equal(invBytes, reqBytes) {
return fmt.Errorf("%w: field %d data mismatch",
ErrInvoiceMismatch, typ)
}
delete(reqFields, typ)
}
if len(reqFields) > 0 {
return fmt.Errorf("%w: invoice is missing %d fields from "+
"request", ErrInvoiceMismatch, len(reqFields))
}
// Spec MUST: if invreq_amount (type 82) is present, invoice_amount
// (type 170) must equal it. The byte-mirror loop cannot relate fields
// with differing type numbers, so this cross-type equality is checked
// explicitly.
if req.InvreqAmount.IsSome() {
invreqAmt := req.InvreqAmount.ValOpt().UnwrapOr(0)
invAmt := inv.InvoiceAmount.ValOpt().UnwrapOr(0)
if invAmt != invreqAmt {
return fmt.Errorf("%w: invoice_amount %d != "+
"invreq_amount %d", ErrInvoiceMismatch, invAmt,
invreqAmt)
}
return nil
}
// Spec SHOULD: with invreq_amount absent the payer relied on the
// offer's fixed amount, so confirm invoice_amount is within the
// authorized range. For the native (non-offer_currency) case that range
// is bounded below by offer_amount * invreq_quantity, computable here
// from the mirrored offer fields. The offer_currency case is delegated
// to the caller (see checkInvoiceAmountMeetsOffer).
return checkInvoiceAmountMeetsOffer(inv)
}
// checkInvoiceAmountMeetsOffer confirms invoice_amount is at least the offer's
// authorized amount for the native (bitcoin) case, where the expected amount is
// offer_amount * invreq_quantity. It is a no-op when offer_amount is absent
// (there is nothing to bound against) or when offer_currency is present (the
// conversion into the invreq_chain currency needs a caller-supplied exchange
// rate, so the bound is delegated). This mirrors the request-side
// checkInvreqAmountMeetsOffer and is only meaningful when invreq_amount is
// absent, since a present invreq_amount pins invoice_amount by exact equality.
func checkInvoiceAmountMeetsOffer(inv *Invoice) error {
if !inv.OfferAmount.IsSome() {
return nil
}
// NOT CHECKED HERE: the offer_currency (non-bitcoin) case. Caller MUST
// convert offer_amount to the invreq_chain currency and compare.
if inv.OfferCurrency.IsSome() {
return nil
}
offerAmt := uint64(inv.OfferAmount.ValOpt().UnwrapOr(0))
qty := uint64(inv.InvreqQuantity.ValOpt().UnwrapOr(1))
invAmt := uint64(inv.InvoiceAmount.ValOpt().UnwrapOr(0))
// Guard against overflow of offer_amount * quantity.
hi, expectedAmt := bits.Mul64(offerAmt, qty)
if hi != 0 {
return fmt.Errorf("%w: offer_amount %d * quantity %d "+
"overflows uint64", ErrAmountBelowExpected, offerAmt,
qty)
}
if invAmt < expectedAmt {
return fmt.Errorf("%w: invoice_amount %d below expected %d",
ErrAmountBelowExpected, invAmt, expectedAmt)
}
return nil
}
// isKnownInvoiceTLVType returns true for TLV types that are defined in the
// invoice spec.
func isKnownInvoiceTLVType(typ tlv.Type) bool {
if isKnownInvreqTLVType(typ) {
return true
}
switch typ {
case invoicePathsType, invoiceBlindedPayType, invoiceCreatedAtType,
invoiceRelativeExpiryType, invoicePaymentHashType,
invoiceAmountType, invoiceFallbacksType, invoiceFeaturesType,
invoiceNodeIDType:
return true
default:
return false
}
}
// InvoiceFeatureCatalogues names the two feature-bit catalogues the invoice
// reader validates against. They are grouped in a struct rather than passed as
// two positional map[lnwire.FeatureBit]string arguments because the identical
// types would otherwise let a caller transpose them silently: validating
// invoice_features against the blinded-path catalogue and vice versa compiles
// cleanly but misvalidates. Named fields make the swap impossible.
type InvoiceFeatureCatalogues struct {
// Invoice names the feature bits the reader understands for the
// top-level invoice_features field.
Invoice map[lnwire.FeatureBit]string
// Blinded names the feature bits the reader understands for each
// blinded_payinfo.features field carried in invoice_blindedpay.
Blinded map[lnwire.FeatureBit]string
}
// ValidateInvoiceRead validates an invoice against the BOLT 12 reader
// requirements, running the stateless structural checks against activeChain
// (the chain the reader supports).
//
// Note: This only performs stateless structural checks. Cryptographic Schnorr
// signature verification and identity-path binding are deferred to the caller
// (see the TODO at the end of this function). Additionally, while it verifies
// that at least one usable path is present, downstream callers must re-apply
// the same features.Blinded filter at path selection time (via
// Invoice.UsablePaths) to avoid selecting paths with unknown required features.
func ValidateInvoiceRead(inv *Invoice, activeChain [32]byte,
features InvoiceFeatureCatalogues) error {
// - MUST reject the invoice if invoice_amount is not present.
if !inv.InvoiceAmount.IsSome() {
return ErrMissingAmount
}
// Policy extension. See ValidateInvoiceWrite.
if inv.InvoiceAmount.ValOpt().UnwrapOr(0) == 0 {
return ErrZeroInvoiceAmount
}
// - MUST reject the invoice if invoice_created_at is not present.
if !inv.InvoiceCreatedAt.IsSome() {
return ErrMissingCreatedAt
}
// - MUST reject the invoice if invoice_payment_hash is not present.
if !inv.InvoicePaymentHash.IsSome() {
return ErrMissingPaymentHash
}
// - MUST reject the invoice if invoice_node_id is not present. A
// present-but-nil pubkey passes IsSome but would panic the codec, so
// reject it before the presence check.
if err := checkPubKeyNotNil(
inv.InvoiceNodeID, "invoice_node_id",
); err != nil {
return err
}
if !inv.InvoiceNodeID.IsSome() {
return ErrMissingNodeID
}
// - if invreq_chain is not present:
// - MUST reject the invoice if bitcoin is not a supported chain.
// - otherwise:
// - MUST reject the invoice if invreq_chain.chain is not a supported
// chain.
// invreq_chain defaults to bitcoin mainnet when absent. activeChain is
// the chain the reader supports.
chain := inv.InvreqChain.ValOpt().UnwrapOr(bitcoinMainnetGenesisHash)
if chain != activeChain {
return ErrUnsupportedChain
}
// - if invoice_features contains unknown odd bits that are non-zero:
// - MUST ignore the bit.
// - if invoice_features contains unknown even bits that are non-zero:
// - MUST reject the invoice.
// checkFeatures enforces those invoice_features bit rules below.
//
// Separately, BOLT 1 makes unknown even TLV types must-understand, so
// reject those here over the decoded type set. Unlike the
// invoice_request reader, the invoice reader defines no out-of-range
// type rejection, so unknown odd types are simply ignored ("it's ok to
// be odd"). The signature range (240-1000) is exempt for the same
// reason, matching the invoice_request reader and the Merkle path.
for _, t := range sortedTypes(inv.decodedTLVs) {
if bolt12InUnsignedRange(t) {
continue
}
if !isKnownInvoiceTLVType(t) && t%2 == 0 {
return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t)
}
}
err := checkFeatures(inv.InvoiceFeatures, features.Invoice)
if err != nil {
return err
}
// - if invoice_relative_expiry is present:
// - MUST reject the invoice if the current time since 1970-01-01 UTC
// is greater than invoice_created_at plus seconds_from_creation.
// - otherwise:
// - MUST reject the invoice if the current time since 1970-01-01 UTC
// is greater than invoice_created_at plus 7200.
// NOT CHECKED HERE: the comparison needs a clock the codec doesn't
// supply. Callers run ValidateInvoiceExpiry separately.
// - MUST reject the invoice if invoice_paths is not present or is
// empty.
if !inv.InvoicePaths.IsSome() {
return ErrMissingPaths
}
// - MUST reject the invoice if num_hops is 0 in any blinded_path in
// invoice_paths (checkBlindedPaths also rejects an empty path list).
if err := checkBlindedPaths(inv.InvoicePaths); err != nil {
return err
}
// - MUST reject the invoice if invoice_blindedpay is not present.
bp, err := inv.InvoiceBlindedPay.ValOpt().UnwrapOrErr(
ErrMissingBlindedPay,
)
if err != nil {
return err
}
// - MUST reject the invoice if invoice_blindedpay does not contain
// exactly one blinded_payinfo per invoice_paths.blinded_path.
paths := inv.InvoicePaths.ValOpt().UnwrapOr(lnwire.BlindedPaths{})
if len(paths.Paths) != len(bp.Infos) {
return ErrBlindedPayMismatch
}
// - For each invoice_blindedpay.payinfo:
// - MUST NOT use the corresponding invoice_paths.path if
// payinfo.features has any unknown even bits set.
// - MUST reject the invoice if this leaves no usable paths.
// UsablePaths applies that filter; a caller selecting a path downstream
// should use it rather than the unfiltered invoice_paths.
if len(inv.UsablePaths(features.Blinded)) == 0 {
return ErrNoUsablePaths
}
// - if the invoice is a response to an invoice_request:
// - MUST reject the invoice if all fields in ranges 0 to 159 and
// 1000000000 to 2999999999 (inclusive) do not exactly match the
// invoice request.
// - if offer_issuer_id is present: MUST reject the invoice if
// invoice_node_id is not equal to offer_issuer_id.
// - otherwise, if offer_paths is present: MUST reject the invoice if
// invoice_node_id is not equal to the final blinded_node_id it sent
// the invoice request to.
// The offer_issuer_id case is checked here by checkInvoiceNodeID (both
// fields live on the invoice). NOT CHECKED HERE: the byte-for-byte
// field mirror and the invreq_amount == invoice_amount rule are
// enforced by ValidateInvoiceAgainstRequest once the invoice is paired
// with its request; the offer_paths blinded_node_id case needs the
// arrival path and stays with the caller.
if err := checkInvoiceNodeID(inv); err != nil {
return err
}
// - MUST reject the invoice if signature is not a valid signature using
// invoice_node_id as described in Signature Calculation.
// TODO(bolt12): implement signature verification. For now only
// presence is enforced, mirroring ValidateInvoiceRequestRead.
if !inv.Signature.IsSome() {
return ErrMissingSignature
}
// - SHOULD prefer to use earlier invoice_paths over later ones if it
// has no other reason for preference.
// - if invoice_features contains the MPP/compulsory bit: MUST pay
// via multiple separate blinded paths; the MPP/optional bit MAY,
// otherwise MUST NOT use multiple parts.
// - if invreq_amount is present: MUST reject the invoice if
// invoice_amount is not equal to invreq_amount (otherwise SHOULD
// confirm invoice_amount.msat is within the authorized range).
// - for the bitcoin chain, if the invoice specifies invoice_fallbacks:
// - MUST ignore any fallback_address with version greater than 16,
// address shorter than 2 or longer than 40 bytes, or an address that
// does not meet known requirements for the given version.
// - the invreq_paths / blinded-path / reply_path arrival rules.
// NOT CHECKED HERE: these are payment-time or transport concerns
// handled outside this codec. invreq_amount equality is enforced by
// ValidateInvoiceAgainstRequest; the fallback ignore rules by
// UsableFallbackAddresses.
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -123,6 +123,13 @@
in `SubscribeOnionMessages`, ensuring a nil reply path remains nil in the
RPC response rather than being emitted as an empty struct.
* [BOLT 12 invoice
codec](https://github.com/lightningnetwork/lnd/pull/10941): add the
`invoice` TLV message to the `bolt12/` package with structural
reader/writer validation. Schnorr signature verification is not yet
performed; callers must verify the signature independently until the
Merkle and signing primitives land.
## Testing
## Database