bolt12: validate InvoiceRequest per BOLT 12 reader/writer requirements
What changed, and why it matters
This commit adds validation checks for BOLT 12 invoice requests in the LND Lightning node. It ensures that invoice requests follow protocol rules when being created (written) and received (read), rejecting malformed or non-compliant requests before they are encoded or processed. The change is defensive: it prevents invalid invoice requests from leaving the node or being accepted from peers, which could otherwise lead to payment confusion, incorrect amounts, or protocol incompatibility. Signature verification and full offer cross-checking are intentionally left for future commits.
Review the deferred signature and offer-matching checks to ensure they land promptly, as the current validators alone do not prevent spoofed or mismatched invoice requests. Confirm that callers of ValidateInvoiceRequestRead verify signatures before acting on the request, as the code comments require.
Security signals we found
New input validation functions added for protocol messages
Validation now runs before encoding, preventing malformed outbound messages
Overflow guard added for amount*quantity calculation
Unknown even TLV types are rejected to enforce must-understand semantics
Signature verification and offer cross-validation explicitly deferred to later commits
Evidence from the diff
The patch introduces ValidateInvoiceRequestWrite and ValidateInvoiceRequestRead in bolt12/validate.go and wires the writer validator into InvoiceRequest.Encode. The validators enforce BOLT 12 structural MUSTs: required fields (payer_id, metadata, amount depending on context), chain compatibility, quantity/quantity_max coupling, amount >= offer_amount*quantity with overflow guarding via bits.Mul64, UTF-8 constraints, BIP 353 name alphabet/layout, blinded path validity, feature-bit sanity, and allowed TLV type ranges with rejection of unknown even types. Signature correctness and exact offer matching are explicitly deferred. The change is accompanied by extensive unit tests covering happy paths and sentinel error cases.
Changed components
bolt12/invoice_request.gobolt12/validate.gobolt12/validate_test.goInspect captured patch +1703 / −5
diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go
index 2c7b2b9..0093e4a 100644
--- a/bolt12/invoice_request.go
+++ b/bolt12/invoice_request.go
@@ -150,11 +150,13 @@ func (ir *InvoiceRequest) allRecordProducers() []tlv.RecordProducer {
return p
}
-// Encode serialises the invoice request via the PureTLVMessage shape.
-// The per-record canonicalisation is pure: a struct mutated and
-// re-encoded reflects the new bytes without any sidecar rehydration
-// step.
+// Encode validates the invoice request per writer requirements and serialises
+// it via the PureTLVMessage shape.
func (ir *InvoiceRequest) Encode() ([]byte, error) {
+ if err := ValidateInvoiceRequestWrite(ir); err != nil {
+ return nil, fmt.Errorf("validate invoice request: %w", err)
+ }
+
var buf bytes.Buffer
if err := lnwire.EncodePureTLVMessage(ir, &buf); err != nil {
return nil, err
diff --git a/bolt12/validate.go b/bolt12/validate.go
index 8803d95..7b2e421 100644
--- a/bolt12/validate.go
+++ b/bolt12/validate.go
@@ -3,6 +3,7 @@ package bolt12
import (
"errors"
"fmt"
+ "math/bits"
"slices"
"time"
"unicode/utf8"
@@ -87,8 +88,680 @@ var (
// ErrInvalidCurrency is returned when offer_currency is not a valid ISO
// 4217 code.
ErrInvalidCurrency = errors.New("invalid offer_currency")
+
+ // ErrMissingAmount is returned when neither offer_amount nor
+ // invreq_amount is present.
+ ErrMissingAmount = errors.New("missing amount field")
+
+ // ErrAmountBelowExpected is returned when a present invreq_amount is
+ // less than the amount expected from offer_amount (times
+ // invreq_quantity). The spec states this from both sides: the writer
+ // MUST NOT set a lower amount and the reader MUST reject one.
+ ErrAmountBelowExpected = errors.New(
+ "invreq_amount below offer-expected amount",
+ )
+
+ // ErrQuantityZero is returned when invreq_quantity is present but set
+ // to 0 while offer_quantity_max is present. The spec distinguishes this
+ // from a missing field (see ErrQuantityMissing).
+ ErrQuantityZero = errors.New("invreq_quantity is zero")
+
+ // ErrQuantityMissing is returned when offer_quantity_max is present but
+ // the invreq omits invreq_quantity entirely. The spec states this as a
+ // distinct rejection ("MUST reject ... if there is no invreq_quantity
+ // field") from a present-but-zero quantity.
+ ErrQuantityMissing = errors.New(
+ "invreq_quantity missing but offer_quantity_max present",
+ )
+
+ // ErrQuantityExceedsMax is returned when invreq_quantity is greater
+ // than offer_quantity_max.
+ ErrQuantityExceedsMax = errors.New(
+ "invreq_quantity exceeds offer_quantity_max",
+ )
+
+ // ErrQuantityWithoutMax is returned when invreq_quantity is set but the
+ // mirrored offer_quantity_max is absent. The spec only permits a
+ // quantity when the offer advertises a maximum.
+ ErrQuantityWithoutMax = errors.New(
+ "invreq_quantity set without offer_quantity_max",
+ )
+
+ // ErrInvalidBip353Name is returned when invreq_bip_353_name is
+ // structurally malformed or contains a non-alphabet byte.
+ ErrInvalidBip353Name = errors.New("invalid invreq_bip_353_name")
+
+ // ErrMissingSignature is returned when a wire-form invoice or
+ // invoice_request is emitted without a populated signature TLV.
+ // Pre-sign Encode (used to compute the Merkle root) is permitted to run
+ // without a signature; the bech32 string-codec layer is where the
+ // signature becomes mandatory.
+ ErrMissingSignature = errors.New("missing signature")
+
+ // ErrOfferFieldsOnSpontaneous is returned when an invoice request
+ // is not responding to an offer but includes offer fields (e.g.
+ // offer_chains, offer_amount, etc.).
+ ErrOfferFieldsOnSpontaneous = errors.New(
+ "offer fields present on non-offer response",
+ )
)
+const (
+ // Offer TLV types.
+ offerChainsType tlv.Type = 2
+ offerMetadataType tlv.Type = 4
+ offerCurrencyType tlv.Type = 6
+ offerAmountType tlv.Type = 8
+ offerDescriptionType tlv.Type = 10
+ offerFeaturesType tlv.Type = 12
+ offerAbsoluteExpiryType tlv.Type = 14
+ offerPathsType tlv.Type = 16
+ offerIssuerType tlv.Type = 18
+ offerQuantityMaxType tlv.Type = 20
+ offerIssuerIDType tlv.Type = 22
+
+ // InvoiceRequest TLV types.
+ invreqMetadataType tlv.Type = 0
+ invreqChainType tlv.Type = 80
+ invreqAmountType tlv.Type = 82
+ invreqFeaturesType tlv.Type = 84
+ invreqQuantityType tlv.Type = 86
+ invreqPayerIDType tlv.Type = 88
+ invreqPayerNoteType tlv.Type = 89
+ invreqPathsType tlv.Type = 90
+ invreqBip353NameType tlv.Type = 91
+ signatureTLVType tlv.Type = 240
+)
+
+// isKnownInvreqTLVType determines if a TLV type is defined in the
+// invoice_request specification.
+func isKnownInvreqTLVType(typ tlv.Type) bool {
+ switch typ {
+ case invreqMetadataType,
+ invreqChainType,
+ invreqAmountType,
+ invreqFeaturesType,
+ invreqQuantityType,
+ invreqPayerIDType,
+ invreqPayerNoteType,
+ invreqPathsType,
+ invreqBip353NameType,
+ signatureTLVType:
+
+ return true
+
+ default:
+ return isKnownOfferTLVType(typ)
+ }
+}
+
+// ValidateInvoiceRequestWrite ensures an invoice request adheres to the BOLT 12
+// writer requirements.
+//
+// Note: This writer validation assumes that for requests responding to an
+// offer, the caller/constructor has already mirrored the offer's fields exactly
+// by using the NewInvoiceRequestFromOffer constructor, as an invoice request
+// can also be created without an offer.
+func ValidateInvoiceRequestWrite(ir *InvoiceRequest) error {
+ // A present-but-nil pubkey passes IsSome but would panic the codec on
+ // encode, so reject both pubkey fields.
+ if err := checkPubKeyNotNil(
+ ir.InvreqPayerID, "invreq_payer_id",
+ ); err != nil {
+ return err
+ }
+ if err := checkPubKeyNotNil(
+ ir.OfferIssuerID, "offer_issuer_id",
+ ); err != nil {
+ return err
+ }
+
+ // - if it is responding to an offer:
+ isResponse := ir.OfferIssuerID.IsSome() || ir.OfferPaths.IsSome()
+ //nolint:nestif
+ if isResponse {
+ // - if offer_chains is set:
+ // - MUST set invreq_chain to one of offer_chains unless that
+ // chain is bitcoin, in which case it SHOULD omit
+ // invreq_chain.
+ // - otherwise (no offer_chains):
+ // - if it sets invreq_chain it MUST set it to bitcoin.
+ chains := getInvoiceRequestOfferChains(ir)
+ chain := getInvreqChain(ir)
+ if !slices.Contains(chains, chain) {
+ return ErrUnsupportedChain
+ }
+
+ // - if offer_amount is not present:
+ // - MUST specify invreq_amount.
+ if !ir.OfferAmount.IsSome() && !ir.InvreqAmount.IsSome() {
+ return ErrMissingAmount
+ }
+
+ // - MUST set signature.sig using the invreq_payer_id.
+ // NOT CHECKED HERE: signing happens after this validator runs;
+ // the string encoder rejects an unsigned request and the reader
+ // verifies signature correctness.
+
+ // - MUST set invreq_payer_id to a transient public key.
+ // NOT CHECKED HERE: only presence is checked below; the caller
+ // MUST supply a fresh key per request and remember its secret.
+ if !ir.InvreqPayerID.IsSome() {
+ return ErrMissingPayerID
+ }
+
+ // - if offer_quantity_max is present:
+ // - MUST set invreq_quantity to greater than zero.
+ // - if offer_quantity_max is non-zero:
+ // - MUST set invreq_quantity less than or equal to
+ // offer_quantity_max.
+ // - otherwise:
+ // - MUST NOT set invreq_quantity
+ //
+ // Checked before the amount so the bounded quantity feeds the
+ // offer_amount*quantity product (reader uses the same order).
+ if err := checkInvreqQuantity(ir); err != nil {
+ return err
+ }
+
+ // - otherwise:
+ // - MAY omit invreq_amount.
+ // - if it sets invreq_amount:
+ // - MUST specify invreq_amount.msat as greater or equal
+ // to amount expected by offer_amount (and, if present,
+ // offer_currency and invreq_quantity).
+ if err := checkInvreqAmountMeetsOffer(ir); err != nil {
+ return err
+ }
+ } else {
+ // - otherwise (not responding to an offer):
+
+ // - MUST set invreq_payer_id (as it would set offer_issuer_id
+ // for an offer).
+ if !ir.InvreqPayerID.IsSome() {
+ return ErrMissingPayerID
+ }
+
+ // - MUST set invreq_paths as it would set (or not set)
+ // offer_paths for an offer.
+ if err := checkBlindedPaths(ir.InvreqPaths); err != nil {
+ return err
+ }
+
+ // - MUST set offer_description to a complete description of the
+ // purpose of the payment.
+ if !ir.OfferDescription.IsSome() {
+ return ErrMissingDescription
+ }
+
+ // - MUST NOT include signature, offer_metadata, offer_chains,
+ // offer_amount, offer_currency, offer_features,
+ // offer_quantity_max, offer_paths or offer_issuer_id.
+ //
+ // signature is intentionally omitted from this list: the spec's
+ // unsigned offerless variant conflicts with the unconditional
+ // reader signature check, and other implementations require a
+ // signature on every invoice_request. We always sign, so a
+ // present signature here is expected.
+ if ir.OfferMetadata.IsSome() || ir.OfferChains.IsSome() ||
+ ir.OfferAmount.IsSome() || ir.OfferCurrency.IsSome() ||
+ ir.OfferFeatures.IsSome() ||
+ ir.OfferQuantityMax.IsSome() ||
+ ir.OfferPaths.IsSome() || ir.OfferIssuerID.IsSome() {
+
+ return ErrOfferFieldsOnSpontaneous
+ }
+
+ // - if the chain for the invoice is not solely bitcoin:
+ // - MUST specify invreq_chain the offer is valid for.
+ // NOT CHECKED HERE: this validator has no chain context. The
+ // caller building the request MUST set invreq_chain for
+ // non-bitcoin chains; the reader enforces it via activeChain.
+
+ // - MUST NOT set invreq_quantity.
+ if err := checkInvreqQuantity(ir); err != nil {
+ return err
+ }
+
+ // - MUST set invreq_amount.
+ if !ir.InvreqAmount.IsSome() {
+ return ErrMissingAmount
+ }
+ }
+
+ // - MUST NOT set any non-signature TLV fields outside the inclusive
+ // ranges: 0 to 159 and 1000000000 to 2999999999
+ //
+ // The signature range (240-1000) is excluded: it carries signature TLV
+ // elements, which by design sit outside the message ranges.
+ for _, t := range sortedTypes(ir.decodedTLVs) {
+ if bolt12InUnsignedRange(t) {
+ continue
+ }
+ if !invreqAllowedRange(t) {
+ return fmt.Errorf("%w: type %d",
+ ErrOutOfRangeType, t)
+ }
+ }
+
+ // - MUST set invreq_metadata to an unpredictable series of bytes.
+ // NOT CHECKED HERE: only presence is verified; unpredictability is the
+ // caller's responsibility.
+ if !ir.InvreqMetadata.IsSome() {
+ return ErrMissingMetadata
+ }
+
+ // - if it sets invreq_amount: MUST set msat in multiples of the minimum
+ // payable unit.
+ // NOT CHECKED HERE: trivially satisfied for bitcoin (msat); a caller
+ // on a chain with a coarser unit MUST enforce it.
+
+ // - 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
+ }
+
+ // check UTF-8 constraints and BIP 353
+ err := checkUTF8(ir.InvreqPayerNote, "invreq_payer_note")
+ if err != nil {
+ return err
+ }
+
+ // - if it received the offer using BIP 353 resolution:
+ // - MUST include invreq_bip_353_name with name/domain from the HRN.
+ // NOT CHECKED HERE: whether resolution was used is caller context; we
+ // only validate the field's alphabet/layout when present.
+ if err := checkBip353Name(ir.InvreqBip353Name); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// invreqAllowedRange determines if the TLV type falls within the allowed
+// ranges for invoice request messages.
+func invreqAllowedRange(typ tlv.Type) bool {
+ return typ <= 159 ||
+ (typ >= 1000000000 && typ <= 2999999999)
+}
+
+// checkInvreqAmountMeetsOffer enforces that a present invreq_amount is at least
+// offer_amount times invreq_quantity. Shared by the writer and reader, which
+// state the same rule from each side.
+//
+// It covers only the native case (offer_currency absent, amounts in msat). When
+// offer_currency is present the expected amount needs a live exchange-rate
+// conversion the codec cannot do, so the caller MUST compare; it returns nil.
+func checkInvreqAmountMeetsOffer(ir *InvoiceRequest) error {
+ if !ir.OfferAmount.IsSome() || !ir.InvreqAmount.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 ir.OfferCurrency.IsSome() {
+ return nil
+ }
+
+ var offerAmt uint64
+ ir.OfferAmount.WhenSome(func(r tlv.RecordT[tlv.TlvType8, TUint64]) {
+ offerAmt = uint64(r.Val)
+ })
+
+ var qty uint64 = 1
+ ir.InvreqQuantity.WhenSome(func(r tlv.RecordT[tlv.TlvType86, TUint64]) {
+ qty = uint64(r.Val)
+ })
+
+ var invreqAmt uint64
+ ir.InvreqAmount.WhenSome(func(r tlv.RecordT[tlv.TlvType82, TUint64]) {
+ invreqAmt = uint64(r.Val)
+ })
+
+ // Guard against overflows.
+ hi, expectedAmt := bits.Mul64(offerAmt, qty)
+ if hi != 0 {
+ return fmt.Errorf("%w: offer_amount %d * quantity %d "+
+ "overflows uint64", ErrAmountBelowExpected,
+ offerAmt, qty)
+ }
+ if invreqAmt < expectedAmt {
+ return fmt.Errorf("%w: invreq_amount %d below expected %d",
+ ErrAmountBelowExpected, invreqAmt, expectedAmt)
+ }
+
+ return nil
+}
+
+// getInvreqChain returns the chain genesis hash an invoice request
+// targets, defaulting to Bitcoin mainnet when invreq_chain is absent
+// per the BOLT 12 reader rule.
+func getInvreqChain(ir *InvoiceRequest) [32]byte {
+ chain := bitcoinMainnetGenesisHash
+ ir.InvreqChain.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType80, [32]byte]) {
+ chain = r.Val
+ },
+ )
+
+ return chain
+}
+
+// ValidateInvoiceRequestRead validates an invoice request against the BOLT 12
+// reader requirements. It performs generic, stateless structural checks only.
+// Stateful or contextual checks (offer matching, path verification, unit-price
+// calculations) must be handled externally by the caller.
+//
+// Signature verification is NOT performed yet: the reader MUST also reject a
+// request whose Schnorr signature does not verify against invreq_payer_id, but
+// that check is deferred until the merkle/signing primitives land with the
+// 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 {
+
+ // A present-but-nil pubkey passes IsSome but would panic the codec on
+ // encode, so reject both pubkey fields.
+ if err := checkPubKeyNotNil(
+ ir.InvreqPayerID, "invreq_payer_id",
+ ); err != nil {
+ return err
+ }
+ if err := checkPubKeyNotNil(
+ ir.OfferIssuerID, "offer_issuer_id",
+ ); err != nil {
+ return err
+ }
+
+ // - MUST reject the invoice request if invreq_payer_id or
+ // invreq_metadata are not present.
+ if !ir.InvreqPayerID.IsSome() {
+ return ErrMissingPayerID
+ }
+ if !ir.InvreqMetadata.IsSome() {
+ return ErrMissingMetadata
+ }
+
+ // - MUST reject the invoice request if any non-signature TLV fields are
+ // outside the inclusive ranges: 0 to 159 and 1000000000 to 2999999999
+ //
+ // The signature range (240-1000) is excluded from the out-of-range
+ // check: it holds one or more signature TLV elements, so an unknown odd
+ // type there is a future optional signature we ignore ("it's ok to be
+ // odd") rather than reject. Unknown even types remain must-understand
+ // and are rejected everywhere, including inside the signature range.
+ for _, t := range sortedTypes(ir.decodedTLVs) {
+ if !bolt12InUnsignedRange(t) && !invreqAllowedRange(t) {
+ return fmt.Errorf("%w: type %d",
+ ErrOutOfRangeType, t)
+ }
+ if !isKnownInvreqTLVType(t) && t%2 == 0 {
+ return fmt.Errorf("%w: type %d",
+ ErrUnknownEvenType, t)
+ }
+ }
+
+ // - if invreq_features contains unknown *even* bits that are non-zero:
+ // - MUST reject the invoice request.
+ if err := checkFeatures(ir.InvreqFeatures); err != nil {
+ return err
+ }
+
+ // - if num_hops is 0 in any blinded_path in invreq_paths:
+ // - MUST reject the invoice request.
+ if err := checkBlindedPaths(ir.InvreqPaths); err != nil {
+ return err
+ }
+
+ // - if offer_issuer_id or offer_paths are present (response to an
+ // offer):
+ isResponse := ir.OfferIssuerID.IsSome() || ir.OfferPaths.IsSome()
+ if isResponse {
+ // NOT CHECKED HERE (need the offer store / arrival path /
+ // reply-path state, so the caller MUST do these):
+ // - MUST reject if the offer fields do not exactly match a
+ // valid, unexpired offer.
+ // - if offer_paths is present: MUST ignore the request unless
+ // it arrived via one of those paths; otherwise MUST ignore
+ // any request that arrived via a blinded path.
+ // - if invreq_metadata equals a previous request: MAY reply
+ // with the previous invoice; otherwise MUST NOT.
+ // - SHOULD send the invoice via the onionmsg_tlv reply_path.
+
+ // - if offer_quantity_max is present:
+ // - MUST reject the invoice request if there is no
+ // invreq_quantity field.
+ // - if offer_quantity_max is non-zero:
+ // - MUST reject the invoice request if invreq_quantity is
+ // zero, OR greater than offer_quantity_max.
+ // - otherwise (no offer_quantity_max):
+ // - MUST reject the invoice request if there is an
+ // invreq_quantity field.
+ if err := checkInvreqQuantity(ir); err != nil {
+ return err
+ }
+
+ // - if offer_amount is present: if invreq_amount is present,
+ // MUST reject when it is below the expected amount. The
+ // helper covers the native case; the currency-conversion
+ // case is deferred to the caller (see the helper doc).
+ if err := checkInvreqAmountMeetsOffer(ir); err != nil {
+ return err
+ }
+
+ if !ir.OfferAmount.IsSome() {
+ // - otherwise (no offer_amount):
+ // - MUST reject the invoice request if it does not
+ // contain invreq_amount.
+ if !ir.InvreqAmount.IsSome() {
+ return ErrMissingAmount
+ }
+ }
+ } else {
+ // - otherwise (no offer_issuer_id or offer_paths, not a
+ // response to our offer):
+
+ // - MUST reject the invoice request if any of the following
+ // are present: offer_chains, offer_features or
+ // offer_quantity_max.
+ if ir.OfferChains.IsSome() || ir.OfferFeatures.IsSome() ||
+ ir.OfferQuantityMax.IsSome() {
+
+ return ErrOfferFieldsOnSpontaneous
+ }
+
+ // - MUST reject the invoice request if there is an
+ // invreq_quantity field.
+ if err := checkInvreqQuantity(ir); err != nil {
+ return err
+ }
+
+ // - MUST reject the invoice request if invreq_amount is not
+ // present.
+ if !ir.InvreqAmount.IsSome() {
+ return ErrMissingAmount
+ }
+
+ // NOT CHECKED HERE (caller's responsibility if it replies):
+ // - MAY use offer_amount / offer_currency for informational
+ // display to the user.
+ // - if it sends an invoice in response: MUST use invreq_paths
+ // if present, otherwise MUST use invreq_payer_id as
+ // the node id to send to.
+ }
+
+ // - if invreq_chain is not present:
+ // - MUST reject the invoice request if bitcoin is not a supported
+ // chain.
+ // - otherwise:
+ // - MUST reject the invoice request if invreq_chain.chain is not a
+ // supported chain.
+ if getInvreqChain(ir) != activeChain {
+ return ErrUnsupportedChain
+ }
+
+ // - if invreq_bip_353_name is present:
+ // - MUST reject the invoice request if name or domain contain any
+ // bytes which are not 0-9, a-z, A-Z, -, _ or .
+ if err := checkBip353Name(ir.InvreqBip353Name); err != nil {
+ return err
+ }
+
+ // - MUST reject the invoice request if signature is not correct as
+ // detailed in Signature Calculation using the invreq_payer_id.
+ // TODO(bolt12): implement signature verification.
+ if !ir.Signature.IsSome() {
+ return ErrMissingSignature
+ }
+
+ return nil
+}
+
+// getInvoiceRequestOfferChains returns the chains an invoice request's mirrored
+// offer is valid for. If offer_chains is absent, the spec defaults to Bitcoin
+// mainnet.
+func getInvoiceRequestOfferChains(ir *InvoiceRequest) [][32]byte {
+ chains := fn.MapOptionZ(
+ ir.OfferChains.ValOpt(),
+ func(r ChainsRecord) [][32]byte { return r.Chains },
+ )
+
+ if len(chains) == 0 {
+ chains = [][32]byte{bitcoinMainnetGenesisHash}
+ }
+
+ return chains
+}
+
+// checkInvreqQuantity validates the spec coupling between offer_quantity_max
+// and invreq_quantity.
+func checkInvreqQuantity(ir *InvoiceRequest) error {
+ // Without offer_quantity_max the spec forbids invreq_quantity: the
+ // writer MUST NOT set it and the reader MUST reject a request that
+ // carries it.
+ if !ir.OfferQuantityMax.IsSome() {
+ if ir.InvreqQuantity.IsSome() {
+ return ErrQuantityWithoutMax
+ }
+
+ return nil
+ }
+
+ // offer_quantity_max is present, so invreq_quantity is mandatory. The
+ // spec separates "no invreq_quantity field" from "invreq_quantity is
+ // zero", so report them with distinct sentinels even though both
+ // reject.
+ if !ir.InvreqQuantity.IsSome() {
+ return ErrQuantityMissing
+ }
+
+ var qty uint64
+ ir.InvreqQuantity.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType86, TUint64]) {
+ qty = uint64(r.Val)
+ },
+ )
+ if qty == 0 {
+ return ErrQuantityZero
+ }
+
+ var maxQty uint64
+ ir.OfferQuantityMax.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType20, TUint64]) {
+ maxQty = uint64(r.Val)
+ },
+ )
+
+ // If maxQty is 0 (unlimited/unknown), we only enforce that the
+ // requested qty is greater than zero, bypassing the upper bound check.
+ if maxQty > 0 && qty > maxQty {
+ return ErrQuantityExceedsMax
+ }
+
+ return nil
+}
+
+// checkBip353Name validates the wire layout and alphabet of
+// invreq_bip_353_name. Both name and domain MUST contain only DNS-safe
+// characters per the BOLT 12 reader and writer requirements.
+func checkBip353Name(opt tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob]) error {
+ var (
+ data []byte
+ present bool
+ )
+ opt.WhenSome(func(r tlv.RecordT[tlv.TlvType91, tlv.Blob]) {
+ data = r.Val
+ present = true
+ })
+
+ // An absent field is a no-op. A present-but-empty field is malformed
+ // (it cannot carry name_len) and falls through to the length check
+ // below rather than being mistaken for absent.
+ if !present {
+ return nil
+ }
+
+ if len(data) < 1 {
+ return fmt.Errorf("%w: missing name_len", ErrInvalidBip353Name)
+ }
+ nameLen := int(data[0])
+ if nameLen == 0 {
+ return fmt.Errorf("%w: empty name", ErrInvalidBip353Name)
+ }
+
+ domainLenIdx := 1 + nameLen
+ if domainLenIdx >= len(data) {
+ return fmt.Errorf("%w: truncated before domain_len",
+ ErrInvalidBip353Name)
+ }
+
+ name := data[1:domainLenIdx]
+
+ domainStart := domainLenIdx + 1
+ domainLen := int(data[domainLenIdx])
+ if domainLen == 0 {
+ return fmt.Errorf("%w: empty domain", ErrInvalidBip353Name)
+ }
+ if domainStart+domainLen != len(data) {
+ return fmt.Errorf("%w: domain length mismatch",
+ ErrInvalidBip353Name)
+ }
+ domain := data[domainStart:]
+
+ if err := checkBip353Alphabet(name); err != nil {
+ return fmt.Errorf("%w: name: %w",
+ ErrInvalidBip353Name, err)
+ }
+
+ if err := checkBip353Alphabet(domain); err != nil {
+ return fmt.Errorf("%w: domain: %w",
+ ErrInvalidBip353Name, err)
+ }
+
+ return nil
+}
+
+// checkBip353Alphabet returns an error when any byte falls outside the BIP 353
+// alphabet.
+func checkBip353Alphabet(b []byte) error {
+ for i, c := range b {
+ switch {
+ case c >= '0' && c <= '9':
+ case c >= 'a' && c <= 'z':
+ case c >= 'A' && c <= 'Z':
+ case c == '-' || c == '_' || c == '.':
+ default:
+ return fmt.Errorf("byte %d (0x%02x) outside "+
+ "alphabet", i, c)
+ }
+ }
+
+ return nil
+}
+
// offerAllowedRange returns true if the TLV type falls within the allowed
// ranges for offer messages: 1-79 and 1000000000-1999999999.
func offerAllowedRange(typ tlv.Type) bool {
@@ -100,8 +773,20 @@ func offerAllowedRange(typ tlv.Type) bool {
// spec (even types 2-22).
func isKnownOfferTLVType(typ tlv.Type) bool {
switch typ {
- case 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22:
+ case offerChainsType,
+ offerMetadataType,
+ offerCurrencyType,
+ offerAmountType,
+ offerDescriptionType,
+ offerFeaturesType,
+ offerAbsoluteExpiryType,
+ offerPathsType,
+ offerIssuerType,
+ offerQuantityMaxType,
+ offerIssuerIDType:
+
return true
+
default:
return false
}
diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go
index 5e907bc..c31a060 100644
--- a/bolt12/validate_test.go
+++ b/bolt12/validate_test.go
@@ -669,3 +669,1014 @@ func addAmountAndDescription(o *Offer) {
),
)
}
+
+// validInvoiceRequest is the spec-minimal happy-path invoice request that
+// each table row mutates to isolate the rule under test.
+func validInvoiceRequest(t *testing.T) *InvoiceRequest {
+ t.Helper()
+
+ ir := &InvoiceRequest{}
+
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ ir.InvreqPayerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](privKey.PubKey()),
+ )
+
+ ir.InvreqMetadata = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ )
+
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
+ )
+
+ ir.Signature = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType240]([64]byte{0x01}),
+ )
+
+ return ir
+}
+
+// TestValidateInvoiceRequestWrite pins the BOLT 12 writer-side MUSTs so a
+// malformed or incomplete invoice request is rejected.
+func TestValidateInvoiceRequestWrite(t *testing.T) {
+ t.Parallel()
+
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ payerID := privKey.PubKey()
+
+ tests := []struct {
+ name string
+ mutate func(*InvoiceRequest)
+ wantErr error
+ }{
+ {
+ name: "missing payer_id",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqPayerID = tlv.OptionalRecordT[
+ tlv.TlvType88, *btcec.PublicKey]{}
+ },
+ wantErr: ErrMissingPayerID,
+ },
+ {
+ name: "present-but-nil payer_id",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqPayerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ wantErr: ErrNilPublicKey,
+ },
+ {
+ name: "present-but-nil offer_issuer_id",
+ mutate: func(ir *InvoiceRequest) {
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ wantErr: ErrNilPublicKey,
+ },
+ {
+ name: "missing description",
+ mutate: func(ir *InvoiceRequest) {
+ ir.OfferDescription = tlv.OptionalRecordT[
+ tlv.TlvType10, tlv.Blob]{}
+ },
+ wantErr: ErrMissingDescription,
+ },
+ {
+ name: "missing metadata",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqMetadata = tlv.OptionalRecordT[
+ tlv.TlvType0, tlv.Blob]{}
+ },
+ wantErr: ErrMissingMetadata,
+ },
+ {
+ name: "missing amount",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqAmount = tlv.OptionalRecordT[
+ tlv.TlvType82, TUint64]{}
+ },
+ wantErr: ErrMissingAmount,
+ },
+ {
+ name: "invalid UTF-8 in payer_note",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqPayerNote = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType89](
+ []byte{0xff},
+ ),
+ )
+ },
+ wantErr: ErrInvalidUTF8,
+ },
+ {
+ name: "empty blinded paths",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqPaths = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType90](
+ lnwire.BlindedPaths{Paths: nil},
+ ),
+ )
+ },
+ wantErr: ErrEmptyBlindedPaths,
+ },
+ {
+ name: "happy path",
+ mutate: func(*InvoiceRequest) {},
+ },
+ {
+ name: "spontaneous request carrying quantity",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](
+ TUint64(1),
+ ),
+ )
+ },
+ wantErr: ErrQuantityWithoutMax,
+ },
+ {
+ name: "spontaneous request offer quantity max",
+ mutate: func(ir *InvoiceRequest) {
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(10),
+ ),
+ )
+ },
+ wantErr: ErrOfferFieldsOnSpontaneous,
+ },
+ {
+ name: "spontaneous request offer quantity max dup",
+ mutate: func(ir *InvoiceRequest) {
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(10),
+ ),
+ )
+ },
+ wantErr: ErrOfferFieldsOnSpontaneous,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ ir := &InvoiceRequest{
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("description"),
+ ),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ payerID,
+ ),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](
+ 1000,
+ ),
+ ),
+ }
+
+ tc.mutate(ir)
+
+ err := ValidateInvoiceRequestWrite(ir)
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+ return
+ }
+ require.ErrorIs(t, err, tc.wantErr)
+ })
+ }
+}
+
+// TestValidateInvoiceRequestWriteAmountConstraints tests the writer constraints
+// on invreq_amount.
+func TestValidateInvoiceRequestWriteAmountConstraints(t *testing.T) {
+ t.Parallel()
+
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ baseRequest := func() *InvoiceRequest {
+ return &InvoiceRequest{
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("description"),
+ ),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ privKey.PubKey(),
+ ),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ }
+ }
+
+ // 1. Spontaneous request (not responding to an offer):
+ // - MUST set invreq_amount.
+ t.Run("spontaneous_amount_required", func(t *testing.T) {
+ ir := baseRequest()
+
+ // Absent invreq_amount -> invalid.
+ err := ValidateInvoiceRequestWrite(ir)
+ require.ErrorIs(t, err, ErrMissingAmount)
+
+ // Present invreq_amount -> valid.
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ })
+
+ // 2. Responding to an offer.
+ t.Run("response_to_offer", func(t *testing.T) {
+ baseResponseRequest := func() *InvoiceRequest {
+ ir := baseRequest()
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ privKey.PubKey(),
+ ),
+ )
+
+ return ir
+ }
+
+ // Case A: OfferAmount is absent.
+ // - MUST specify invreq_amount.
+ t.Run("offer_amount_absent", func(t *testing.T) {
+ ir := baseResponseRequest()
+
+ // InvreqAmount absent -> invalid.
+ err := ValidateInvoiceRequestWrite(ir)
+ require.ErrorIs(t, err, ErrMissingAmount)
+
+ // InvreqAmount present -> valid.
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ })
+
+ // Case B: OfferAmount present, OfferCurrency absent (Bitcoin).
+ t.Run("offer_amount_present_bitcoin", func(t *testing.T) {
+ ir := baseResponseRequest()
+ ir.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](1000),
+ )
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](TUint64(10)),
+ )
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86, TUint64](2),
+ )
+
+ // InvreqAmount is optional (MAY omit it).
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // If set, it MUST be >= OfferAmount * Quantity
+ // (1000 * 2 = 2000). InvreqAmount < expected ->
+ // invalid.
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](1999),
+ )
+ err := ValidateInvoiceRequestWrite(ir)
+ require.ErrorIs(t, err, ErrAmountBelowExpected)
+
+ // InvreqAmount >= expected -> valid.
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](2000),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ })
+
+ // Case C: OfferAmount present, OfferCurrency present
+ // (non-Bitcoin).
+ t.Run("offer_amount_present_non_bitcoin", func(t *testing.T) {
+ ir := baseResponseRequest()
+ ir.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](1000),
+ )
+ ir.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](
+ tlv.Blob("USD"),
+ ),
+ )
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](TUint64(10)),
+ )
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86, TUint64](2),
+ )
+
+ // InvreqAmount < OfferAmount * Quantity is allowed
+ // because currency conversion is checked dynamically
+ // at runtime, not statically inside
+ // ValidateInvoiceRequestWrite.
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](100),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ })
+ })
+}
+
+// TestValidateInvoiceRequestWriteChainConstraints tests the writer constraints
+// on invreq_chain.
+func TestValidateInvoiceRequestWriteChainConstraints(t *testing.T) {
+ t.Parallel()
+
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ testnetHash := [32]byte{1}
+ regtestHash := [32]byte{2}
+
+ // 1. Not responding to an offer: any invreq_chain is accepted.
+ t.Run("spontaneous_any_chain_accepted", func(t *testing.T) {
+ ir := &InvoiceRequest{
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("description"),
+ ),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ privKey.PubKey(),
+ ),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](1000),
+ ),
+ }
+
+ // Absent chain is OK.
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // Bitcoin chain is OK.
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ bitcoinMainnetGenesisHash,
+ ),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // Non-bitcoin chain is OK.
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](testnetHash),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+ })
+
+ // 2. Responding to an offer.
+ t.Run("response_to_offer", func(t *testing.T) {
+ baseRequest := func() *InvoiceRequest {
+ return &InvoiceRequest{
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ privKey.PubKey(),
+ ),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ privKey.PubKey(),
+ ),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](
+ []byte("metadata"),
+ ),
+ ),
+ InvreqAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82, TUint64](
+ 1000,
+ ),
+ ),
+ }
+ }
+
+ // Case A: OfferChains is absent (defaults to Bitcoin mainnet).
+ t.Run("offer_chains_absent", func(t *testing.T) {
+ ir := baseRequest()
+
+ // InvreqChain absent (valid, defaults to bitcoin).
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // InvreqChain == bitcoin (valid).
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ bitcoinMainnetGenesisHash,
+ ),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // InvreqChain != bitcoin (invalid).
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ testnetHash,
+ ),
+ )
+ require.ErrorIs(
+ t, ValidateInvoiceRequestWrite(ir),
+ ErrUnsupportedChain,
+ )
+ })
+
+ // Case B: OfferChains is present.
+ t.Run("offer_chains_present", func(t *testing.T) {
+ // Sub-case B1: OfferChains contains only Bitcoin.
+ ir := baseRequest()
+ ir.OfferChains = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](ChainsRecord{
+ Chains: [][32]byte{
+ bitcoinMainnetGenesisHash,
+ },
+ }),
+ )
+
+ // InvreqChain absent (valid, defaults to bitcoin).
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // InvreqChain == bitcoin (valid).
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ bitcoinMainnetGenesisHash,
+ ),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // InvreqChain == testnet (invalid, not in offer
+ // chains).
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ testnetHash,
+ ),
+ )
+ require.ErrorIs(
+ t, ValidateInvoiceRequestWrite(ir),
+ ErrUnsupportedChain,
+ )
+
+ // Sub-case B2: OfferChains contains only Testnet
+ // (not Bitcoin).
+ ir = baseRequest()
+ ir.OfferChains = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](ChainsRecord{
+ Chains: [][32]byte{testnetHash},
+ }),
+ )
+
+ // InvreqChain absent (invalid, defaults to bitcoin
+ // which is not in offer chains).
+ require.ErrorIs(
+ t, ValidateInvoiceRequestWrite(ir),
+ ErrUnsupportedChain,
+ )
+
+ // InvreqChain == testnet (valid, is in offer chains).
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ testnetHash,
+ ),
+ )
+ require.NoError(t, ValidateInvoiceRequestWrite(ir))
+
+ // InvreqChain == regtest (invalid, not in offer
+ // chains).
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](
+ regtestHash,
+ ),
+ )
+ require.ErrorIs(
+ t, ValidateInvoiceRequestWrite(ir),
+ ErrUnsupportedChain,
+ )
+ })
+ })
+}
+
+// TestValidateInvoiceRequestReadSentinels table-drives selected reader-side
+// validation branches in ValidateInvoiceRequestRead. Each row starts from a
+// minimal structurally valid invoice request and mutates one condition to
+// assert the corresponding sentinel error, or nil for accepted optional
+// unknown fields. Additional reader-side checks such as amount, chain and
+// BIP-353 validation are covered by focused tests below.
+func TestValidateInvoiceRequestReadSentinels(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ mutate func(*InvoiceRequest)
+ wantErr error
+ }{
+ {
+ name: "missing payer id",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqPayerID = tlv.OptionalRecordT[
+ tlv.TlvType88, *btcec.PublicKey,
+ ]{}
+ },
+ wantErr: ErrMissingPayerID,
+ },
+ {
+ name: "present-but-nil payer_id",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqPayerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ wantErr: ErrNilPublicKey,
+ },
+ {
+ name: "present-but-nil offer_issuer_id",
+ mutate: func(ir *InvoiceRequest) {
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ (*btcec.PublicKey)(nil),
+ ),
+ )
+ },
+ wantErr: ErrNilPublicKey,
+ },
+ {
+ name: "missing metadata",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqMetadata = tlv.OptionalRecordT[
+ tlv.TlvType0, tlv.Blob,
+ ]{}
+ },
+ wantErr: ErrMissingMetadata,
+ },
+ {
+ name: "missing signature",
+ mutate: func(ir *InvoiceRequest) {
+ ir.Signature = tlv.OptionalRecordT[
+ tlv.TlvType240, [64]byte,
+ ]{}
+ },
+ wantErr: ErrMissingSignature,
+ },
+ {
+ name: "missing amount",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqAmount = tlv.OptionalRecordT[
+ tlv.TlvType82, TUint64,
+ ]{}
+ ir.OfferAmount = tlv.OptionalRecordT[
+ tlv.TlvType8, TUint64,
+ ]{}
+ },
+ wantErr: ErrMissingAmount,
+ },
+ {
+ name: "quantity missing with quantity_max",
+ mutate: func(ir *InvoiceRequest) {
+ _, pub := bobKey()
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ pub,
+ ),
+ )
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(10),
+ ),
+ )
+ },
+ wantErr: ErrQuantityMissing,
+ },
+ {
+ name: "quantity present but zero with quantity_max",
+ mutate: func(ir *InvoiceRequest) {
+ _, pub := bobKey()
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ pub,
+ ),
+ )
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(10),
+ ),
+ )
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](
+ TUint64(0),
+ ),
+ )
+ },
+ wantErr: ErrQuantityZero,
+ },
+ {
+ name: "quantity exceeds max",
+ mutate: func(ir *InvoiceRequest) {
+ _, pub := bobKey()
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ pub,
+ ),
+ )
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(5),
+ ),
+ )
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](
+ TUint64(99),
+ ),
+ )
+ },
+ wantErr: ErrQuantityExceedsMax,
+ },
+ {
+ name: "invreq_quantity without offer_quantity_max",
+ mutate: func(ir *InvoiceRequest) {
+ _, pub := bobKey()
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](
+ pub,
+ ),
+ )
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](
+ TUint64(1),
+ ),
+ )
+ },
+ wantErr: ErrQuantityWithoutMax,
+ },
+ {
+ name: "spontaneous request carrying offer field " +
+ "(quantity max)",
+ mutate: func(ir *InvoiceRequest) {
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(10),
+ ),
+ )
+ },
+ wantErr: ErrOfferFieldsOnSpontaneous,
+ },
+ {
+ name: "spontaneous request carrying quantity",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](
+ TUint64(1),
+ ),
+ )
+ },
+ wantErr: ErrQuantityWithoutMax,
+ },
+ {
+ name: "out-of-range TLV in decoded extras",
+ mutate: func(ir *InvoiceRequest) {
+ ir.decodedTLVs = tlv.TypeMap{200: nil}
+ },
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "unknown even TLV type in range",
+ mutate: func(ir *InvoiceRequest) {
+ ir.decodedTLVs = tlv.TypeMap{158: nil}
+ },
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ name: "unknown even type 34 rejected",
+ mutate: func(ir *InvoiceRequest) {
+ ir.decodedTLVs = tlv.TypeMap{34: nil}
+ },
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ // An unknown odd type in the signature range
+ // (240-1000) is a future optional signature element
+ // and is ignored, not rejected.
+ name: "unknown odd TLV in signature range ignored",
+ mutate: func(ir *InvoiceRequest) {
+ ir.decodedTLVs = tlv.TypeMap{501: nil}
+ },
+ wantErr: nil,
+ },
+ {
+ // An unknown even type stays must-understand even
+ // inside the signature range.
+ name: "unknown even TLV in signature range rejected",
+ mutate: func(ir *InvoiceRequest) {
+ ir.decodedTLVs = tlv.TypeMap{500: nil}
+ },
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ name: "unknown even feature bit",
+ mutate: func(ir *InvoiceRequest) {
+ ir.InvreqFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType84](
+ *lnwire.NewRawFeatureVector(0),
+ ),
+ )
+ },
+ wantErr: ErrUnknownEvenFeature,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ ir := validInvoiceRequest(t)
+ tc.mutate(ir)
+
+ err := ValidateInvoiceRequestRead(
+ ir, bitcoinMainnetGenesisHash,
+ )
+ require.ErrorIs(t, err, tc.wantErr)
+ })
+ }
+}
+
+// TestValidateInvoiceRequestReadAmountBelowExpected pins the reader-side
+// mirror of the writer's expected-amount rule: when offer_amount is present
+// (native bitcoin, no offer_currency) a present invreq_amount below
+// offer_amount*invreq_quantity MUST be rejected. The amount check runs before
+// the signature verification, so an unsigned struct suffices to exercise it.
+func TestValidateInvoiceRequestReadAmountBelowExpected(t *testing.T) {
+ t.Parallel()
+
+ _, pub := bobKey()
+ ir := &InvoiceRequest{
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](pub),
+ ),
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](TUint64(1000)),
+ ),
+ OfferQuantityMax: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](TUint64(10)),
+ ),
+ InvreqQuantity: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](TUint64(2)),
+ ),
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](pub),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("m")),
+ ),
+ }
+
+ // expected = 1000 * 2 = 2000; 1999 is below.
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82](TUint64(1999)),
+ )
+ err := ValidateInvoiceRequestRead(ir, bitcoinMainnetGenesisHash)
+ require.ErrorIs(t, err, ErrAmountBelowExpected)
+}
+
+// TestValidateInvoiceRequestAmountOverflow pins the guard against an
+// offer_amount * invreq_quantity product that overflows uint64.
+func TestValidateInvoiceRequestAmountOverflow(t *testing.T) {
+ t.Parallel()
+
+ _, pub := bobKey()
+
+ newRequest := func() *InvoiceRequest {
+ ir := &InvoiceRequest{}
+ ir.OfferIssuerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](pub),
+ )
+ ir.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](TUint64(2)),
+ )
+
+ // quantity_max zero means unlimited, so the bound check does
+ // not cap the quantity below.
+ ir.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](TUint64(0)),
+ )
+ // offer_amount(2) * quantity(2^63) == 2^64, which truncates to
+ // zero on an unchecked uint64 multiply; an unguarded validator
+ // would then accept invreq_amount(1) as "at least zero".
+ ir.InvreqQuantity = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType86](
+ TUint64(1 << 63),
+ ),
+ )
+ ir.InvreqAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType82](TUint64(1)),
+ )
+ ir.InvreqPayerID = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](pub),
+ )
+ ir.InvreqMetadata = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType0](tlv.Blob("m")),
+ )
+
+ return ir
+ }
+
+ // The reader MUST reject the overflowing request.
+ readErr := ValidateInvoiceRequestRead(
+ newRequest(), bitcoinMainnetGenesisHash,
+ )
+ require.ErrorIs(t, readErr, ErrAmountBelowExpected)
+
+ // The writer MUST reject it too (same rule, both sides).
+ writeErr := ValidateInvoiceRequestWrite(newRequest())
+ require.ErrorIs(t, writeErr, ErrAmountBelowExpected)
+}
+
+// TestValidateInvoiceRequestReadChain pins the spec invreq_chain rule:
+// an absent invreq_chain defaults to Bitcoin mainnet and must be
+// rejected on a non-mainnet node, while a present invreq_chain that
+// disagrees with activeChain must also be rejected. The happy path
+// (matching chain) is already covered by TestValidateInvoiceRequestRead.
+func TestValidateInvoiceRequestReadChain(t *testing.T) {
+ t.Parallel()
+
+ var altChain [32]byte
+ for i := range altChain {
+ altChain[i] = 0xaa
+ }
+
+ t.Run("absent chain rejected on non-mainnet", func(t *testing.T) {
+ t.Parallel()
+
+ ir := validInvoiceRequest(t)
+ err := ValidateInvoiceRequestRead(ir, altChain)
+ require.ErrorIs(t, err, ErrUnsupportedChain)
+ })
+
+ t.Run("present chain mismatch rejected", func(t *testing.T) {
+ t.Parallel()
+
+ ir := validInvoiceRequest(t)
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80](altChain),
+ )
+ // The chain check runs before signature verification, so a
+ // mismatched chain is rejected regardless of the signature.
+ err := ValidateInvoiceRequestRead(
+ ir, bitcoinMainnetGenesisHash,
+ )
+ require.ErrorIs(t, err, ErrUnsupportedChain)
+ })
+}
+
+// bip353Blob assembles a name+domain pair into the wire layout expected
+// by invreq_bip_353_name (TLV 91).
+func bip353Blob(name, domain []byte) []byte {
+ out := make([]byte, 0, 2+len(name)+len(domain))
+ out = append(out, byte(len(name)))
+ out = append(out, name...)
+ out = append(out, byte(len(domain)))
+ out = append(out, domain...)
+
+ return out
+}
+
+// TestCheckBip353Name exercises the BIP 353 alphabet and structural
+// requirements directly so each rejection path is pinned independently
+// of the surrounding invoice-request validators.
+func TestCheckBip353Name(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ blob []byte
+ wantErr bool
+ }{
+ {
+ name: "happy path with allowed alphabet",
+ blob: bip353Blob(
+ []byte("alice.example-1_2"),
+ []byte("example.com"),
+ ),
+ wantErr: false,
+ },
+ {
+ name: "absent field is no-op",
+ blob: nil,
+ wantErr: false,
+ },
+ {
+ name: "present but empty rejected",
+ blob: []byte{},
+ wantErr: true,
+ },
+ {
+ name: "empty name rejected",
+ blob: []byte{
+ 0x00, 0x05, 'e', 'x', '.', 'c', 'o', 'm',
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty domain rejected",
+ blob: []byte{0x05, 'a', 'l', 'i', 'c', 'e', 0x00},
+ wantErr: true,
+ },
+ {
+ name: "both empty name and domain rejected",
+ blob: []byte{0x00, 0x00},
+ wantErr: true,
+ },
+ {
+ name: "name byte outside alphabet",
+ blob: bip353Blob(
+ []byte("alice@bob"), []byte("ex.com"),
+ ),
+ wantErr: true,
+ },
+ {
+ name: "domain byte outside alphabet",
+ blob: bip353Blob([]byte("alice"), []byte("ex com")),
+ wantErr: true,
+ },
+ {
+ name: "name truncated before domain_len",
+ blob: []byte{0x05, 'a', 'l', 'i'},
+ wantErr: true,
+ },
+ {
+ name: "domain length mismatch",
+ blob: []byte{0x01, 'a', 0x05, 'b'},
+ wantErr: true,
+ },
+ {
+ name: "control byte rejected in name",
+ blob: bip353Blob(
+ []byte{'a', 0x00, 'b'}, []byte("ex"),
+ ),
+ wantErr: true,
+ },
+ {
+ name: "domain length shorter than remaining " +
+ "bytes rejected",
+ blob: []byte{0x01, 'a', 0x01, 'b', 'c'},
+ wantErr: true,
+ },
+ {
+ name: "minimal valid name and domain",
+ blob: []byte{0x01, 'a', 0x01, 'b'},
+ wantErr: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var opt tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob]
+ if tc.blob != nil {
+ opt = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType91](
+ tc.blob,
+ ),
+ )
+ }
+
+ err := checkBip353Name(opt)
+ if tc.wantErr {
+ require.ErrorIs(t, err, ErrInvalidBip353Name)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
Why this scored 49/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.