What changed, and why it matters
This commit adds validation checks for BOLT 12 invoices in the LND Lightning node software. It ensures invoices contain required fields (creation time, amount, payment hash, node ID, payment paths), match their originating invoice requests, and aren't expired or malformed before being encoded or accepted. The change is defensive: it rejects invalid invoices rather than letting them propagate, which helps prevent payment failures, confusion, or minor abuse. Signature verification is explicitly left for a future patch, so this is not a complete security fix on its own.
Review the deferred TODO for Schnorr signature verification and identity-path binding to ensure those protections land promptly; otherwise an attacker could still craft structurally valid but unsigned or wrongly-signed invoices. Confirm callers actually invoke ValidateInvoiceRead, ValidateInvoiceExpiry, and ValidateInvoiceAgainstRequest in the receive/payment flow, since the codec library does not call them automatically. Consider whether the zero-amount policy extension is acceptable for all BOLT 12 use cases or needs to be configurable.
Security signals we found
New validation gate added to Invoice.Encode() to reject malformed invoices before serialization
Reader rejects unknown even invoice TLV types and unknown even feature bits
Reader enforces chain compatibility against activeChain
Reader requires at least one usable blinded path after filtering on known features
Writer and reader both reject zero invoice_amount as a policy extension
Expiry validator guards against uint64 overflow when adding created_at + relative_expiry
Invoice/request validator performs byte-for-byte field mirroring and amount equality
Signature verification is explicitly deferred to a future change (TODO)
Evidence from the diff
The patch introduces four new validators in bolt12/validate.go: ValidateInvoiceWrite, ValidateInvoiceRead, ValidateInvoiceExpiry, and ValidateInvoiceAgainstRequest. Encode() now calls ValidateInvoiceWrite before serialization. Writer checks enforce presence of invoice_created_at, invoice_amount (with a non-zero policy extension), invoice_payment_hash, invoice_node_id, invoice_paths, invoice_blindedpay, and 1:1 correspondence between paths and blinded pay infos. Reader checks add chain matching, unknown-even TLV rejection, feature-bit validation via injected catalogues, usable-path filtering, and offer_issuer_id/invoice_node_id matching. ValidateInvoiceExpiry uses a strict ‘>’ comparison with a 7200-second default and guards against uint64 overflow. ValidateInvoiceAgainstRequest performs a byte-for-byte mirror-field comparison over ranges 0-159 and 1000000000-2999999999, plus explicit invreq_amount/invoice_amount equality and an offer_amount*quantity lower bound. Schnorr signature verification is deferred (TODO), mirroring the existing invoice_request validator.
Changed components
bolt12/invoice.gobolt12/validate.gobolt12/invoice_test.gobolt12/validate_test.goInspect captured patch +1642 / −7
diff --git a/bolt12/invoice.go b/bolt12/invoice.go
index eecbf0f..6a04c01 100644
--- a/bolt12/invoice.go
+++ b/bolt12/invoice.go
@@ -286,6 +286,10 @@ func (inv *Invoice) allRecordProducers() []tlv.RecordProducer {
// 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
diff --git a/bolt12/invoice_test.go b/bolt12/invoice_test.go
index b24be7a..f15e1a7 100644
--- a/bolt12/invoice_test.go
+++ b/bolt12/invoice_test.go
@@ -196,6 +196,10 @@ func TestInvoiceRoundTripPreservesAllTypes(t *testing.T) {
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()
@@ -339,3 +343,17 @@ func TestNewInvoiceFromRequestMirrorsUnknownFields(t *testing.T) {
"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)
+}
diff --git a/bolt12/validate.go b/bolt12/validate.go
index 1900930..86c7c7d 100644
--- a/bolt12/validate.go
+++ b/bolt12/validate.go
@@ -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,13 +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. Since the writer lacks a catalogue in scope, we
- // pass nil for the catalogue, treating all even feature bits as
- // unknown.
- if err := checkFeatures(ir.InvreqFeatures, nil); 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")
@@ -1116,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
+}
diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go
index e6240d6..c4736fc 100644
--- a/bolt12/validate_test.go
+++ b/bolt12/validate_test.go
@@ -1,6 +1,7 @@
package bolt12
import (
+ "math"
"testing"
"time"
@@ -1721,3 +1722,982 @@ func TestCheckBip353Name(t *testing.T) {
})
}
}
+
+// encodeIRBypassValidate serialises an InvoiceRequest skipping the
+// validate-on-encode gate.
+func encodeIRBypassValidate(ir *InvoiceRequest) ([]byte, error) {
+ records := lnwire.ProduceRecordsSorted(ir.allRecordProducers()...)
+ return lnwire.EncodeRecords(records)
+}
+
+// encodeInvBypassValidate is the Invoice analogue for encodeIRBypassValidate.
+func encodeInvBypassValidate(inv *Invoice) ([]byte, error) {
+ records := lnwire.ProduceRecordsSorted(inv.allRecordProducers()...)
+ return lnwire.EncodeRecords(records)
+}
+
+// TestValidateInvoiceRead table-drives every reader-side rejection in
+// ValidateInvoiceRead.
+func TestValidateInvoiceRead(t *testing.T) {
+ t.Parallel()
+
+ _, intro := aliceKey()
+ introNode, err := lnwire.NewPubkeyIntro(intro)
+ require.NoError(t, err)
+
+ baseline := func() *Invoice {
+ return validInvoice(t)
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*Invoice)
+ wantErr error
+ }{
+ {
+ name: "missing amount",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceAmount = tlv.OptionalRecordT[
+ tlv.TlvType170, TUint64,
+ ]{}
+ },
+ wantErr: ErrMissingAmount,
+ },
+ {
+ name: "zero amount",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[
+ tlv.TlvType170, TUint64,
+ ](TUint64(0)),
+ )
+ },
+ wantErr: ErrZeroInvoiceAmount,
+ },
+ {
+ name: "missing created_at",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceCreatedAt = tlv.OptionalRecordT[
+ tlv.TlvType164, TUint64,
+ ]{}
+ },
+ wantErr: ErrMissingCreatedAt,
+ },
+ {
+ name: "missing payment_hash",
+ mutate: func(inv *Invoice) {
+ inv.InvoicePaymentHash = tlv.OptionalRecordT[
+ tlv.TlvType168, [32]byte,
+ ]{}
+ },
+ wantErr: ErrMissingPaymentHash,
+ },
+ {
+ name: "missing node_id",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceNodeID = tlv.OptionalRecordT[
+ tlv.TlvType176, *btcec.PublicKey,
+ ]{}
+ },
+ wantErr: ErrMissingNodeID,
+ },
+ {
+ name: "missing paths",
+ mutate: func(inv *Invoice) {
+ inv.InvoicePaths = tlv.OptionalRecordT[
+ tlv.TlvType160, lnwire.BlindedPaths,
+ ]{}
+ },
+ wantErr: ErrMissingPaths,
+ },
+ {
+ name: "missing blinded_pay",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceBlindedPay = tlv.OptionalRecordT[
+ tlv.TlvType162, BlindedPayInfos,
+ ]{}
+ },
+ wantErr: ErrMissingBlindedPay,
+ },
+ {
+ name: "paths count exceeds blinded_pay count",
+ mutate: func(inv *Invoice) {
+ path := lnwire.BlindedPath{
+ IntroductionNode: introNode,
+ Hops: []lnwire.BlindedHop{
+ {},
+ },
+ }
+ paths := lnwire.BlindedPaths{
+ Paths: []lnwire.BlindedPath{path, path},
+ }
+ inv.InvoicePaths = tlv.SomeRecordT(
+ tlv.NewRecordT[
+ tlv.TlvType160,
+ lnwire.BlindedPaths,
+ ](paths),
+ )
+ },
+ wantErr: ErrBlindedPayMismatch,
+ },
+ {
+ // The invoice reader defines no out-of-range type
+ // rejection, so an unknown odd type outside every known
+ // range is ignored rather than rejected: validation
+ // proceeds to the final signature check.
+ name: "unknown odd out-of-range type ignored",
+ mutate: func(inv *Invoice) {
+ inv.decodedTLVs = tlv.TypeMap{2001: nil}
+ },
+ wantErr: ErrMissingSignature,
+ },
+ {
+ name: "unknown even type",
+ mutate: func(inv *Invoice) {
+ inv.decodedTLVs = tlv.TypeMap{200: nil}
+ },
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ // Baseline invoice_node_id is bob; an offer_issuer_id
+ // of alice must be rejected as a mismatch.
+ name: "node_id does not match offer_issuer_id",
+ mutate: func(inv *Invoice) {
+ _, alice := aliceKey()
+ inv.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ alice,
+ ),
+ )
+ },
+ wantErr: ErrInvoiceNodeIDMismatch,
+ },
+ {
+ // A present-but-nil invoice_node_id passes IsSome but
+ // would panic the codec on encode, so it must be
+ // rejected as ErrNilPublicKey rather than treated as a
+ // missing or mismatched field.
+ name: "present-but-nil node_id",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceNodeID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType176](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ wantErr: ErrNilPublicKey,
+ },
+ {
+ name: "unsupported chain",
+ mutate: func(inv *Invoice) {
+ inv.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ [32]byte{0x01},
+ ),
+ )
+ },
+ wantErr: ErrUnsupportedChain,
+ },
+ {
+ name: "unknown even feature",
+ mutate: func(inv *Invoice) {
+ fv := *lnwire.NewRawFeatureVector(0)
+ inv.InvoiceFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType174](fv),
+ )
+ },
+ wantErr: ErrUnknownEvenFeature,
+ },
+ {
+ name: "empty invoice_paths",
+ mutate: func(inv *Invoice) {
+ paths := lnwire.BlindedPaths{Paths: nil}
+ inv.InvoicePaths = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType160](paths),
+ )
+ },
+ wantErr: ErrEmptyBlindedPaths,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ inv := baseline()
+ tc.mutate(inv)
+
+ err := ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{},
+ )
+ require.ErrorIs(t, err, tc.wantErr)
+ })
+ }
+}
+
+// TestValidateInvoiceReadAcceptsSignatureRange pins the rule that an unknown
+// odd TLV anywhere in the signature range (240-1000) is ignored rather than
+// rejected.
+func TestValidateInvoiceReadAcceptsSignatureRange(t *testing.T) {
+ t.Parallel()
+
+ _, pub := bobKey()
+
+ _, intro := aliceKey()
+ _, blinding := bobKey()
+ _, hopPub := aliceKey()
+ introNode, err := lnwire.NewPubkeyIntro(intro)
+ require.NoError(t, err)
+
+ inv := &Invoice{
+ InvoiceCreatedAt: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType164, TUint64](TUint64(123)),
+ ),
+ InvoiceAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170, TUint64](TUint64(1000)),
+ ),
+ InvoicePaymentHash: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType168, [32]byte](
+ [32]byte{},
+ ),
+ ),
+ 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{{}}},
+ ),
+ ),
+ Signature: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240, [64]byte](
+ [64]byte{},
+ ),
+ ),
+ }
+
+ // An unknown odd type at 241 sits inside the signature range and must
+ // be ignored, not rejected as out-of-range or unknown-even.
+ inv.decodedTLVs = tlv.TypeMap{241: nil}
+
+ err = ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{},
+ )
+ require.NoError(t, err)
+}
+
+// TestValidateInvoiceExpiry covers the relative-expiry default, an explicit
+// relative expiry, the expired/not-expired boundary, and the overflow guard
+// that keeps an absurd created_at from wrapping into a spurious expiry.
+func TestValidateInvoiceExpiry(t *testing.T) {
+ t.Parallel()
+
+ invoice := func(createdAt uint64, relExp *uint32) *Invoice {
+ inv := &Invoice{
+ InvoiceCreatedAt: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType164, TUint64](
+ TUint64(createdAt),
+ ),
+ ),
+ }
+ if relExp != nil {
+ inv.InvoiceRelativeExp = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType166, TUint32](
+ TUint32(*relExp),
+ ),
+ )
+ }
+
+ return inv
+ }
+
+ relExp := func(v uint32) *uint32 { return &v }
+
+ tests := []struct {
+ name string
+ inv *Invoice
+ now int64
+ wantErr error
+ }{
+ {
+ name: "missing created_at",
+ inv: &Invoice{},
+ now: 1000,
+ wantErr: ErrMissingCreatedAt,
+ },
+ {
+ name: "within default expiry",
+ inv: invoice(1000, nil),
+ now: 1000 + 7199,
+ },
+ {
+ // The boundary second itself is still valid: the spec
+ // rejects only when now is strictly greater than
+ // created_at + expiry.
+ name: "at default expiry boundary",
+ inv: invoice(1000, nil),
+ now: 1000 + 7200,
+ },
+ {
+ name: "past default expiry",
+ inv: invoice(1000, nil),
+ now: 1000 + 7201,
+ wantErr: ErrInvoiceExpired,
+ },
+ {
+ name: "within explicit expiry",
+ inv: invoice(1000, relExp(100)),
+ now: 1099,
+ },
+ {
+ // The exact expiry second is still valid (strict ">").
+ name: "at explicit expiry boundary",
+ inv: invoice(1000, relExp(100)),
+ now: 1100,
+ },
+ {
+ name: "past explicit expiry",
+ inv: invoice(1000, relExp(100)),
+ now: 1101,
+ wantErr: ErrInvoiceExpired,
+ },
+ {
+ name: "overflow is not expired",
+ inv: invoice(math.MaxUint64, relExp(100)),
+ now: 9223372036854775807,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ err := ValidateInvoiceExpiry(
+ tc.inv, time.Unix(tc.now, 0),
+ )
+ if tc.wantErr != nil {
+ require.ErrorIs(t, err, tc.wantErr)
+
+ return
+ }
+ require.NoError(t, err)
+ })
+ }
+}
+
+// TestValidateInvoiceAgainstRequest table-drives the mirror-field comparison
+// between an invoice and the request it is responding to.
+func TestValidateInvoiceAgainstRequest(t *testing.T) {
+ t.Parallel()
+
+ // The request whose mirrored fields every invoice below is compared
+ // against: payer metadata plus offer_amount.
+ ir := &InvoiceRequest{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](1000),
+ ),
+ }
+
+ irEncoded, err := encodeIRBypassValidate(ir)
+ require.NoError(t, err)
+ irDecoded, err := DecodeInvoiceRequest(irEncoded)
+ require.NoError(t, err)
+
+ // baseline mirrors the request's fields exactly. Invoice-specific
+ // fields >= 160 (here invoice_amount) are excluded from the mirror
+ // comparison, so the baseline validates cleanly.
+ baseline := func() *Invoice {
+ return &Invoice{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](1000),
+ ),
+ InvoiceAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170, TUint64](1000),
+ ),
+ }
+ }
+
+ tests := []struct {
+ name string
+ mutate func(*Invoice)
+ wantErr error
+ errContains string
+ }{
+ {
+ name: "matching mirrored fields",
+ mutate: func(inv *Invoice) {},
+ },
+ {
+ name: "missing mirrored field",
+ mutate: func(inv *Invoice) {
+ inv.OfferAmount = tlv.OptionalRecordT[
+ tlv.TlvType8, TUint64,
+ ]{}
+ },
+ wantErr: ErrInvoiceMismatch,
+ errContains: "missing 1 fields",
+ },
+ {
+ name: "extra mirrored field",
+ mutate: func(inv *Invoice) {
+ inv.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ []byte("extra"),
+ ),
+ )
+ },
+ wantErr: ErrInvoiceMismatch,
+ errContains: "unexpected field 10",
+ },
+ {
+ name: "mismatched field data",
+ mutate: func(inv *Invoice) {
+ inv.InvreqMetadata = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("different"),
+ ),
+ )
+ },
+ wantErr: ErrInvoiceMismatch,
+ errContains: "data mismatch",
+ },
+ {
+ name: "equal length byte difference",
+ mutate: func(inv *Invoice) {
+ inv.InvreqMetadata = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadatA"),
+ ),
+ )
+ },
+ wantErr: ErrInvoiceMismatch,
+ errContains: "data mismatch",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ inv := baseline()
+ tc.mutate(inv)
+
+ invEncoded, err := encodeInvBypassValidate(inv)
+ require.NoError(t, err)
+ invDecoded, err := DecodeInvoice(invEncoded)
+ require.NoError(t, err)
+
+ err = ValidateInvoiceAgainstRequest(
+ invDecoded, irDecoded,
+ )
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+
+ return
+ }
+ require.ErrorIs(t, err, tc.wantErr)
+ require.Contains(t, err.Error(), tc.errContains)
+ })
+ }
+}
+
+// TestValidateInvoiceAgainstRequestAmountMirror covers the cross-field
+// invreq_amount (82) vs invoice_amount (170) equality rule.
+func TestValidateInvoiceAgainstRequestAmountMirror(t *testing.T) {
+ t.Parallel()
+
+ ir := &InvoiceRequest{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](2500),
+ ),
+ }
+ irEncoded, err := encodeIRBypassValidate(ir)
+ require.NoError(t, err)
+ irDecoded, err := DecodeInvoiceRequest(irEncoded)
+ require.NoError(t, err)
+
+ build := func(invAmt uint64) *Invoice {
+ return &Invoice{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](2500),
+ ),
+ InvoiceAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170, TUint64](
+ TUint64(invAmt),
+ ),
+ ),
+ }
+ }
+
+ // Equal amounts pass.
+ matchEnc, _ := encodeInvBypassValidate(build(2500))
+ matchDec, _ := DecodeInvoice(matchEnc)
+ require.NoError(t, ValidateInvoiceAgainstRequest(matchDec, irDecoded))
+
+ // Mismatched amounts fail.
+ missEnc, _ := encodeInvBypassValidate(build(2501))
+ missDec, _ := DecodeInvoice(missEnc)
+ err = ValidateInvoiceAgainstRequest(missDec, irDecoded)
+ require.ErrorIs(t, err, ErrInvoiceMismatch)
+ require.Contains(t, err.Error(), "invoice_amount")
+}
+
+// TestValidateInvoiceAgainstRequestOfferAmount pins the offer-amount lower
+// bound applied when invreq_amount is absent: the payee MUST NOT charge less
+// than offer_amount * invreq_quantity for the native (non-offer_currency) case,
+// while the offer_currency case is delegated to the caller.
+func TestValidateInvoiceAgainstRequestOfferAmount(t *testing.T) {
+ t.Parallel()
+
+ // build constructs a mirrored (invoice, request) pair carrying a fixed
+ // offer_amount and optional quantity/currency, with no invreq_amount so
+ // the offer-amount bound is what gets exercised.
+ build := func(offerAmt uint64, qty *uint64, currency []byte,
+ invAmt uint64) (*Invoice, *InvoiceRequest) {
+
+ ir := &InvoiceRequest{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](
+ TUint64(offerAmt),
+ ),
+ ),
+ }
+ inv := &Invoice{
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](
+ TUint64(offerAmt),
+ ),
+ ),
+ InvoiceAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170, TUint64](
+ TUint64(invAmt),
+ ),
+ ),
+ }
+ if qty != nil {
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86, TUint64](
+ TUint64(*qty),
+ ),
+ )
+ inv.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86, TUint64](
+ TUint64(*qty),
+ ),
+ )
+ }
+ if currency != nil {
+ ir.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](currency),
+ )
+ inv.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](currency),
+ )
+ }
+
+ return inv, ir
+ }
+
+ // roundtrip encodes and decodes both sides so the comparison runs over
+ // canonical wire bytes, mirroring the production flow.
+ roundtrip := func(inv *Invoice, ir *InvoiceRequest) error {
+ irEnc, err := encodeIRBypassValidate(ir)
+ require.NoError(t, err)
+ irDec, err := DecodeInvoiceRequest(irEnc)
+ require.NoError(t, err)
+
+ invEnc, err := encodeInvBypassValidate(inv)
+ require.NoError(t, err)
+ invDec, err := DecodeInvoice(invEnc)
+ require.NoError(t, err)
+
+ return ValidateInvoiceAgainstRequest(invDec, irDec)
+ }
+
+ qty := func(v uint64) *uint64 { return &v }
+
+ t.Run("at offer amount passes", func(t *testing.T) {
+ t.Parallel()
+
+ inv, ir := build(1000, nil, nil, 1000)
+ require.NoError(t, roundtrip(inv, ir))
+ })
+
+ t.Run("above offer amount passes", func(t *testing.T) {
+ t.Parallel()
+
+ inv, ir := build(1000, nil, nil, 2000)
+ require.NoError(t, roundtrip(inv, ir))
+ })
+
+ t.Run("below offer amount rejected", func(t *testing.T) {
+ t.Parallel()
+
+ inv, ir := build(1000, nil, nil, 999)
+ require.ErrorIs(t, roundtrip(inv, ir), ErrAmountBelowExpected)
+ })
+
+ t.Run("quantity scales the bound", func(t *testing.T) {
+ t.Parallel()
+
+ // 1000 * 3 = 3000 expected; 2999 is below, 3000 at the bound.
+ inv, ir := build(1000, qty(3), nil, 2999)
+ require.ErrorIs(t, roundtrip(inv, ir), ErrAmountBelowExpected)
+
+ inv, ir = build(1000, qty(3), nil, 3000)
+ require.NoError(t, roundtrip(inv, ir))
+ })
+
+ t.Run("offer_currency bound delegated", func(t *testing.T) {
+ t.Parallel()
+
+ // With offer_currency present the bitcoin-unit bound does not
+ // apply, so an invoice_amount below offer_amount still passes
+ // this validator; the caller applies the exchange-rate check.
+ inv, ir := build(1000, nil, []byte("USD"), 1)
+ require.NoError(t, roundtrip(inv, ir))
+ })
+}
+
+// TestValidateInvoiceWrite table-drives the writer-side checks of
+// ValidateInvoiceWrite by clearing required fields on a valid baseline invoice.
+func TestValidateInvoiceWrite(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ mutate func(*Invoice)
+ wantErr error
+ }{
+ {
+ name: "valid baseline invoice",
+ mutate: func(inv *Invoice) {},
+ wantErr: nil,
+ },
+ {
+ name: "missing created_at",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceCreatedAt = tlv.OptionalRecordT[
+ tlv.TlvType164, TUint64,
+ ]{}
+ },
+ wantErr: ErrMissingCreatedAt,
+ },
+ {
+ name: "missing amount",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceAmount = tlv.OptionalRecordT[
+ tlv.TlvType170, TUint64,
+ ]{}
+ },
+ wantErr: ErrMissingAmount,
+ },
+ {
+ name: "missing payment_hash",
+ mutate: func(inv *Invoice) {
+ inv.InvoicePaymentHash = tlv.OptionalRecordT[
+ tlv.TlvType168, [32]byte,
+ ]{}
+ },
+ wantErr: ErrMissingPaymentHash,
+ },
+ {
+ name: "missing node_id",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceNodeID = tlv.OptionalRecordT[
+ tlv.TlvType176, *btcec.PublicKey,
+ ]{}
+ },
+ wantErr: ErrMissingNodeID,
+ },
+ {
+ name: "missing paths",
+ mutate: func(inv *Invoice) {
+ inv.InvoicePaths = tlv.OptionalRecordT[
+ tlv.TlvType160, lnwire.BlindedPaths,
+ ]{}
+ },
+ wantErr: ErrMissingPaths,
+ },
+ {
+ name: "missing blinded_pay",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceBlindedPay = tlv.OptionalRecordT[
+ tlv.TlvType162, BlindedPayInfos,
+ ]{}
+ },
+ wantErr: ErrMissingBlindedPay,
+ },
+ {
+ name: "present non-nil payer_id and matching " +
+ "non-nil offer_issuer_id",
+ mutate: func(inv *Invoice) {
+ _, payerID := bobKey()
+ _, issuerID := aliceKey()
+ inv.InvreqPayerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ payerID,
+ ),
+ )
+ inv.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ issuerID,
+ ),
+ )
+ inv.InvoiceNodeID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType176](
+ issuerID,
+ ),
+ )
+ },
+ wantErr: nil,
+ },
+ {
+ name: "mismatched node_id and offer_issuer_id",
+ mutate: func(inv *Invoice) {
+ _, issuerID := aliceKey()
+ inv.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ issuerID,
+ ),
+ )
+ },
+ wantErr: ErrInvoiceNodeIDMismatch,
+ },
+ {
+ name: "zero invoice_amount",
+ mutate: func(inv *Invoice) {
+ inv.InvoiceAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType170](
+ TUint64(0),
+ ),
+ )
+ },
+ wantErr: ErrZeroInvoiceAmount,
+ },
+ {
+ name: "blinded pay info mismatch",
+ mutate: func(inv *Invoice) {
+ infos := BlindedPayInfos{
+ Infos: []BlindedPayInfo{{}, {}},
+ }
+ inv.InvoiceBlindedPay = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType162](infos),
+ )
+ },
+ wantErr: ErrBlindedPayMismatch,
+ },
+ {
+ name: "empty invoice_paths",
+ mutate: func(inv *Invoice) {
+ paths := lnwire.BlindedPaths{Paths: nil}
+ inv.InvoicePaths = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType160](paths),
+ )
+ },
+ wantErr: ErrEmptyBlindedPaths,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ tc.mutate(inv)
+
+ err := ValidateInvoiceWrite(inv)
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+ } else {
+ require.ErrorIs(t, err, tc.wantErr)
+ }
+ })
+ }
+}
+
+// TestValidateFeaturesWithCatalogue verifies that both Role 1 endpoint features
+// and Role 2 routing path features are correctly validated using injected
+// catalogues.
+func TestValidateFeaturesWithCatalogue(t *testing.T) {
+ t.Parallel()
+
+ // Role 1 validation verifies endpoint features on ValidateInvoiceRead.
+ t.Run("endpoint features (Role 1)", func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240](
+ [64]byte{},
+ ),
+ )
+
+ // Set MPP required (bit 16, even/required)
+ fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired)
+ inv.InvoiceFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType174](fv),
+ )
+
+ // An unknown required bit must be rejected.
+ err := ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{},
+ )
+ require.ErrorIs(t, err, ErrUnknownEvenFeature)
+
+ // A known required bit must pass.
+ known := map[lnwire.FeatureBit]string{
+ lnwire.MPPRequired: "mpp",
+ }
+ err = ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{Invoice: known},
+ )
+ require.NoError(t, err)
+ })
+
+ // Role 2 validation verifies routing path features on
+ // ValidateInvoiceRead.
+ t.Run("routing path features (Role 2)", func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ inv.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240](
+ [64]byte{},
+ ),
+ )
+
+ // Set an even required feature bit on the path's features (e.g.
+ // bit 16).
+ fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired)
+ inv.InvoiceBlindedPay = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType162](BlindedPayInfos{
+ Infos: []BlindedPayInfo{{
+ Features: fv,
+ }},
+ }),
+ )
+
+ // If there are no known features in the catalogue, there are
+ // zero usable paths and we expect ErrNoUsablePaths.
+ err := ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{},
+ )
+ require.ErrorIs(t, err, ErrNoUsablePaths)
+
+ // A known features catalogue for blinded pay results in at
+ // least one usable path, which must pass.
+ knownBlinded := map[lnwire.FeatureBit]string{
+ lnwire.MPPRequired: "mpp",
+ }
+ err = ValidateInvoiceRead(
+ inv, bitcoinMainnetGenesisHash,
+ InvoiceFeatureCatalogues{Blinded: knownBlinded},
+ )
+ require.NoError(t, err)
+ })
+
+ // Writer side ignores features, as we set the features.
+ t.Run("writer side ignores features", func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ fv := *lnwire.NewRawFeatureVector(lnwire.MPPRequired)
+ inv.InvoiceFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType174](fv),
+ )
+
+ require.NoError(t, ValidateInvoiceWrite(inv))
+ })
+}
+
+// TestValidateInvoiceWriteRejectsNilPubkeys verifies the writer rejects a
+// present-but-nil mirrored pubkey field, which would otherwise panic the codec
+// on encode. Symmetric with ValidateInvoiceRequestWrite.
+func TestValidateInvoiceWriteRejectsNilPubkeys(t *testing.T) {
+ t.Parallel()
+
+ t.Run("present-but-nil payer_id", func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ inv.InvreqPayerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey)
+ })
+
+ t.Run("present-but-nil offer_issuer_id", func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ inv.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey)
+ })
+
+ t.Run("present-but-nil node_id", func(t *testing.T) {
+ t.Parallel()
+
+ inv := validInvoice(t)
+ inv.InvoiceNodeID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType176](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ require.ErrorIs(t, ValidateInvoiceWrite(inv), ErrNilPublicKey)
+ })
+}
Why this scored 36/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.