mirror of
https://github.com/lightningnetwork/lnd.git
synced 2026-08-13 12:32:48 +02:00
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
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:
commit
dcacdfe0e1
8 changed files with 3122 additions and 21 deletions
440
bolt12/invoice.go
Normal file
440
bolt12/invoice.go
Normal 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
359
bolt12/invoice_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue