diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go index 2c7b2b970..0093e4a67 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 8803d9548..7b2e421c3 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 5e907bc2d..c31a060e4 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) + } + }) + } +}