bolt12: validate Offer per BOLT 12 reader/writer requirements
What changed, and why it matters
This commit adds input validation to BOLT 12 offers in the LND Lightning node. BOLT 12 offers are payment requests that one node can present to another. Before this change, LND could accept or re-encode malformed offers that violated the protocol rules, such as offers with no usable payment path, expired offers, invalid currency codes, or hidden unknown required fields. The new code rejects these on both reading and writing, which helps prevent nodes from acting on bad offers or producing invalid ones.
Review where ValidateOfferRead is invoked in the call graph to ensure all inbound BOLT 12 offers are validated before use; confirm that Decode paths call it. Consider whether the nil issuer public key check in ValidateOfferWrite should also verify the point is on the curve, since the commit message mentions on-curve SEC1 compressed point validation but the diff only shows a nil check.
Security signals we found
New validation layer for externally supplied BOLT 12 offer messages
Rejects unknown even TLV types and feature bits (must-understand fields)
Rejects offers missing usable issuer identity or payment path
Rejects expired offers and zero-amount offers
Validates chain compatibility and defaults to Bitcoin mainnet
Validates UTF-8 and ISO 4217 currency codes
Wires validation into Encode so invalid offers are not re-serialized
Evidence from the diff
The commit introduces bolt12/validate.go with ValidateOfferRead and ValidateOfferWrite, and wires ValidateOfferWrite into Offer.Encode. The validators enforce BOLT 12 reader/writer requirements: allowed TLV type ranges, rejection of unknown even TLV types and feature bits, chain compatibility (defaulting to Bitcoin mainnet when offer_chains is absent), dependency rules between offer_amount/offer_description/offer_currency, positive non-zero amount, presence of issuer identity (offer_issuer_id or offer_paths), non-empty blinded paths with at least one hop per path, expiry check, UTF-8 validity, and ISO 4217 currency code validation. Tests cover happy paths and many failure modes. The commit message frames this as a defensive correctness improvement rather than a response to a disclosed vulnerability.
Changed components
bolt12/offer.gobolt12/validate.gobolt12/pure_tlv.gobolt12/validate_test.gobolt12/helpers_test.gogo.modInspect captured patch +1088 / −1
diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go
index f301ad2..78bbdfd 100644
--- a/bolt12/helpers_test.go
+++ b/bolt12/helpers_test.go
@@ -14,3 +14,11 @@ func bobKey() (*btcec.PrivateKey, *btcec.PublicKey) {
return priv, pub
}
+
+// aliceKey returns the deterministic spec test key for Alice, whose 32-byte
+// scalar is 0x41 repeated.
+func aliceKey() (*btcec.PrivateKey, *btcec.PublicKey) {
+ priv, pub := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x41}, 32))
+
+ return priv, pub
+}
diff --git a/bolt12/offer.go b/bolt12/offer.go
index c203fbb..dfe8cd6 100644
--- a/bolt12/offer.go
+++ b/bolt12/offer.go
@@ -97,6 +97,10 @@ func (o *Offer) allRecordProducers() []tlv.RecordProducer {
// Encode serialises the offer into a canonical TLV byte stream.
func (o *Offer) Encode() ([]byte, error) {
+ if err := ValidateOfferWrite(o); err != nil {
+ return nil, fmt.Errorf("validate offer: %w", err)
+ }
+
var buf bytes.Buffer
if err := lnwire.EncodePureTLVMessage(o, &buf); err != nil {
return nil, err
diff --git a/bolt12/pure_tlv.go b/bolt12/pure_tlv.go
index 93a00eb..d20022b 100644
--- a/bolt12/pure_tlv.go
+++ b/bolt12/pure_tlv.go
@@ -1,6 +1,8 @@
package bolt12
import (
+ "slices"
+
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)
@@ -36,3 +38,15 @@ func allRecordsFromTypeMap(producers []tlv.RecordProducer,
return lnwire.ProduceRecordsSorted(producers...)
}
+
+// sortedTypes returns the keys of tm in ascending order. Validators iterate the
+// result for deterministic out-of-range and unknown-even error messages.
+func sortedTypes(tm tlv.TypeMap) []tlv.Type {
+ out := make([]tlv.Type, 0, len(tm))
+ for t := range tm {
+ out = append(out, t)
+ }
+ slices.Sort(out)
+
+ return out
+}
diff --git a/bolt12/validate.go b/bolt12/validate.go
new file mode 100644
index 0000000..d6473b6
--- /dev/null
+++ b/bolt12/validate.go
@@ -0,0 +1,413 @@
+package bolt12
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "time"
+ "unicode/utf8"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/chaincfg"
+ "github.com/lightningnetwork/lnd/fn/v2"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+ "golang.org/x/text/currency"
+)
+
+var (
+ // ErrOutOfRangeType is returned when a TLV type falls outside the
+ // allowed offer ranges (1-79 and 1000000000-1999999999).
+ ErrOutOfRangeType = errors.New("TLV type outside allowed range")
+
+ // ErrUnknownEvenType is returned when an unknown even TLV type is
+ // present in an allowed range. Per BOLT 1, even types are
+ // must-understand: if the reader does not recognise the type, it MUST
+ // reject the message rather than silently ignoring the field.
+ ErrUnknownEvenType = errors.New("unknown even TLV type")
+
+ // ErrUnknownEvenFeature is returned when an unknown even feature
+ // bit is set.
+ ErrUnknownEvenFeature = errors.New("unknown even feature bit set")
+
+ // ErrMissingDescription is returned when offer_amount is set but
+ // offer_description is absent.
+ ErrMissingDescription = errors.New(
+ "offer_amount set without offer_description",
+ )
+
+ // ErrCurrencyWithoutAmount is returned when offer_currency is set
+ // but offer_amount is absent.
+ ErrCurrencyWithoutAmount = errors.New(
+ "offer_currency set without offer_amount",
+ )
+
+ // ErrZeroAmount is returned when offer_amount is set to zero. The spec
+ // requires a present offer_amount to be strictly greater than zero so
+ // that a zero-value cannot masquerade as "no minimum required".
+ ErrZeroAmount = errors.New("offer_amount must be greater than zero")
+
+ // ErrEmptyBlindedPaths is returned when a blinded paths field is
+ // present on a BOLT 12 message but its list of paths is empty. The
+ // spec writer requirements treat "present" as implying at least one
+ // usable path.
+ ErrEmptyBlindedPaths = errors.New("blinded paths field present but " +
+ "empty")
+
+ // ErrNoIssuerIdentity is returned when neither offer_issuer_id
+ // nor offer_paths is set.
+ ErrNoIssuerIdentity = errors.New(
+ "neither offer_issuer_id nor offer_paths set",
+ )
+
+ // ErrOfferExpired is returned when the current time is after
+ // offer_absolute_expiry.
+ ErrOfferExpired = errors.New("offer has expired")
+
+ // ErrEmptyChains is returned when offer_chains is present but
+ // contains no entries.
+ ErrEmptyChains = errors.New(
+ "offer_chains present but empty",
+ )
+
+ // ErrUnsupportedChain is returned when offer_chains does not
+ // contain our active chain.
+ ErrUnsupportedChain = errors.New(
+ "offer does not support our chain",
+ )
+
+ // ErrInvalidUTF8 is returned when a UTF-8 field contains invalid
+ // sequences.
+ ErrInvalidUTF8 = errors.New("invalid UTF-8")
+
+ // ErrInvalidCurrency is returned when offer_currency is not a valid ISO
+ // 4217 code.
+ ErrInvalidCurrency = errors.New("invalid offer_currency")
+)
+
+// 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 {
+ return (typ >= 1 && typ <= 79) ||
+ (typ >= 1000000000 && typ <= 1999999999)
+}
+
+// isKnownOfferTLVType returns true for TLV types that are defined in the offer
+// 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:
+ return true
+ default:
+ return false
+ }
+}
+
+// ValidateOfferRead validates an offer per the BOLT 12 offer reader
+// requirements. The now parameter is used for expiry checks and can be
+// overridden in tests. activeChain is required: per spec, absent offer_chains
+// defaults to Bitcoin mainnet, and the reader must reject offers that do not
+// list a chain it operates on. Pass the genesis hash of the chain the receiver
+// is willing to settle on.
+func ValidateOfferRead(o *Offer, now time.Time, activeChain [32]byte) error {
+ // Check TLV types are in allowed range and that unknown even types are
+ // rejected (even = must-understand).
+ for _, t := range sortedTypes(o.decodedTLVs) {
+ if !offerAllowedRange(t) {
+ return fmt.Errorf("%w: type %d", ErrOutOfRangeType, t)
+ }
+
+ if !isKnownOfferTLVType(t) && t%2 == 0 {
+ return fmt.Errorf("%w: type %d", ErrUnknownEvenType, t)
+ }
+ }
+
+ // Check for unknown even feature bits.
+ if err := checkFeatures(o.OfferFeatures); err != nil {
+ return err
+ }
+
+ // offer_chains present but empty.
+ var chainsEmpty bool
+ o.OfferChains.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) {
+ if len(r.Val.Chains) == 0 {
+ chainsEmpty = true
+ }
+ },
+ )
+ if chainsEmpty {
+ return ErrEmptyChains
+ }
+
+ // Validate the offer's chain against the active chain. An absent
+ // offer_chains TLV means "Bitcoin mainnet" per spec, normalised by
+ // getOfferChains.
+ offerChains := getOfferChains(o)
+ found := slices.Contains(offerChains, activeChain)
+ if !found {
+ return ErrUnsupportedChain
+ }
+
+ // offer_amount set requires offer_description.
+ hasAmount := o.OfferAmount.IsSome()
+ if hasAmount && !o.OfferDescription.IsSome() {
+ return ErrMissingDescription
+ }
+
+ // offer_amount, if set, must be strictly greater than zero.
+ if err := checkAmountPositive(o.OfferAmount); err != nil {
+ return err
+ }
+
+ // offer_currency requires offer_amount.
+ if o.OfferCurrency.IsSome() && !hasAmount {
+ return ErrCurrencyWithoutAmount
+ }
+
+ // Must have either offer_issuer_id or offer_paths.
+ if !o.OfferIssuerID.IsSome() && !o.OfferPaths.IsSome() {
+ return ErrNoIssuerIdentity
+ }
+
+ // Check blinded paths have at least one hop.
+ if err := checkBlindedPaths(o.OfferPaths); err != nil {
+ return err
+ }
+
+ // Expiry check. A present-but-zero offer_absolute_expiry is as a valid
+ // timestamp in the past, it doesn't have the special meaning of "no
+ // expiry".
+ var (
+ expiry uint64
+ hasExpiry bool
+ )
+ o.OfferAbsoluteExpiry.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType14, TUint64]) {
+ expiry = uint64(r.Val)
+ hasExpiry = true
+ },
+ )
+ if hasExpiry && uint64(now.Unix()) > expiry {
+ return ErrOfferExpired
+ }
+
+ // Validate UTF-8 fields.
+ if err := checkUTF8(o.OfferCurrency, "offer_currency"); err != nil {
+ return err
+ }
+
+ if err := checkUTF8(
+ o.OfferDescription, "offer_description",
+ ); err != nil {
+ return err
+ }
+
+ if err := checkUTF8(o.OfferIssuer, "offer_issuer"); err != nil {
+ return err
+ }
+
+ if err := checkISO4217(o.OfferCurrency); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// bitcoinMainnetGenesisHash is the genesis hash for Bitcoin mainnet, used as
+// the default when offer_chains is absent per the spec.
+var bitcoinMainnetGenesisHash = [32]byte(*chaincfg.MainNetParams.GenesisHash)
+
+// getOfferChains returns the chains an offer is valid for. If offer_chains is
+// absent, the spec defaults to Bitcoin mainnet.
+func getOfferChains(o *Offer) [][32]byte {
+ chains := fn.MapOptionZ(
+ o.OfferChains.ValOpt(),
+ func(r ChainsRecord) [][32]byte { return r.Chains },
+ )
+
+ if len(chains) == 0 {
+ chains = [][32]byte{bitcoinMainnetGenesisHash}
+ }
+
+ return chains
+}
+
+// ValidateOfferWrite validates an offer per the BOLT 12 offer writer
+// requirements.
+func ValidateOfferWrite(o *Offer) error {
+ // Writer MUST NOT set TLV fields outside allowed ranges. This check
+ // catches a decoded-then-mutated offer: a freshly-built struct has no
+ // decodedTLVs (Decode is the only writer of that field). The typed
+ // field set already excludes out-of-range types by construction, so a
+ // freshly-built offer cannot violate the range rule in the first place.
+ for _, t := range sortedTypes(o.decodedTLVs) {
+ if !offerAllowedRange(t) {
+ return fmt.Errorf("%w: type %d",
+ ErrOutOfRangeType, t)
+ }
+ }
+
+ // offer_amount requires offer_description.
+ if o.OfferAmount.IsSome() && !o.OfferDescription.IsSome() {
+ return ErrMissingDescription
+ }
+
+ // offer_amount, if set, must be strictly greater than zero.
+ if err := checkAmountPositive(o.OfferAmount); err != nil {
+ return err
+ }
+
+ // offer_currency requires offer_amount.
+ if o.OfferCurrency.IsSome() && !o.OfferAmount.IsSome() {
+ return ErrCurrencyWithoutAmount
+ }
+
+ // Without offer_paths, MUST set offer_issuer_id.
+ if !o.OfferPaths.IsSome() && !o.OfferIssuerID.IsSome() {
+ return ErrNoIssuerIdentity
+ }
+
+ // A present-but-nil offer_issuer_id would panic in SerializeCompressed
+ // at encode time, so reject it here.
+ if err := fn.MapOptionZ(
+ o.OfferIssuerID.ValOpt(),
+ func(pk *btcec.PublicKey) error {
+ if pk == nil {
+ return fmt.Errorf("nil issuer public key")
+ }
+
+ return nil
+ },
+ ); err != nil {
+ return err
+ }
+
+ // Defense in depth: writer-side mirrors of reader rejections for
+ // present-but-empty offer_chains and offer_paths.
+ var chainsEmpty bool
+ o.OfferChains.WhenSome(
+ func(r tlv.RecordT[tlv.TlvType2, ChainsRecord]) {
+ if len(r.Val.Chains) == 0 {
+ chainsEmpty = true
+ }
+ },
+ )
+ if chainsEmpty {
+ return ErrEmptyChains
+ }
+
+ if err := checkBlindedPaths(o.OfferPaths); err != nil {
+ return err
+ }
+
+ // Defense in depth: writer-side mirrors of the reader UTF-8 checks
+ // for offer_currency, offer_description, and offer_issuer.
+ if err := checkUTF8(o.OfferCurrency, "offer_currency"); err != nil {
+ return err
+ }
+
+ if err := checkUTF8(
+ o.OfferDescription, "offer_description",
+ ); err != nil {
+ return err
+ }
+
+ if err := checkUTF8(o.OfferIssuer, "offer_issuer"); err != nil {
+ return err
+ }
+
+ if err := checkISO4217(o.OfferCurrency); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// checkISO4217 verifies that offer_currency, if set, parses as an ISO 4217
+// code. The upstream parser is case-insensitive and rejects both malformed and
+// unrecognised codes.
+func checkISO4217[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob]) error {
+ return fn.MapOptionZ(opt.ValOpt(), func(data tlv.Blob) error {
+ if _, err := currency.ParseISO(string(data)); err != nil {
+ return fmt.Errorf("%w: %w", ErrInvalidCurrency, err)
+ }
+
+ return nil
+ })
+}
+
+// checkFeatures rejects any unknown even (must-understand) feature bit.
+func checkFeatures[T tlv.TlvType](
+ opt tlv.OptionalRecordT[T, lnwire.RawFeatureVector]) error {
+
+ return fn.MapOptionZ(
+ opt.ValOpt(),
+ func(fv lnwire.RawFeatureVector) error {
+ // nil catalogue: BOLT 12 defines no feature bits yet,
+ // so every set even bit is "unknown". Swap in a
+ // Bolt12Features map once the spec assigns bits.
+ wrapped := lnwire.NewFeatureVector(&fv, nil)
+ unknown := wrapped.UnknownRequiredFeatures()
+ if len(unknown) == 0 {
+ return nil
+ }
+
+ // Sort for deterministic errors.
+ slices.Sort(unknown)
+
+ return fmt.Errorf("%w: bit %d",
+ ErrUnknownEvenFeature, unknown[0])
+ },
+ )
+}
+
+// checkBlindedPaths walks each path in a blinded paths field and rejects empty
+// Paths slices and paths with zero hops.
+func checkBlindedPaths[T tlv.TlvType](
+ opt tlv.OptionalRecordT[T, lnwire.BlindedPaths]) error {
+
+ return fn.MapOptionZ(
+ opt.ValOpt(),
+ func(paths lnwire.BlindedPaths) error {
+ if len(paths.Paths) == 0 {
+ return ErrEmptyBlindedPaths
+ }
+
+ for i, p := range paths.Paths {
+ if len(p.Hops) == 0 {
+ return fmt.Errorf("%w: path %d",
+ lnwire.ErrEmptyBlindedPath, i)
+ }
+ }
+
+ return nil
+ },
+ )
+}
+
+// checkAmountPositive rejects an offer_amount that is present but zero.
+func checkAmountPositive[T tlv.TlvType](
+ opt tlv.OptionalRecordT[T, TUint64]) error {
+
+ return fn.MapOptionZ(opt.ValOpt(), func(v TUint64) error {
+ if v == 0 {
+ return ErrZeroAmount
+ }
+
+ return nil
+ })
+}
+
+// checkUTF8 validates that a blob field contains valid UTF-8.
+func checkUTF8[T tlv.TlvType](opt tlv.OptionalRecordT[T, tlv.Blob],
+ name string) error {
+
+ return fn.MapOptionZ(opt.ValOpt(), func(data tlv.Blob) error {
+ if !utf8.Valid(data) {
+ return fmt.Errorf("%w: %s", ErrInvalidUTF8, name)
+ }
+
+ return nil
+ })
+}
diff --git a/bolt12/validate_test.go b/bolt12/validate_test.go
new file mode 100644
index 0000000..2bf476d
--- /dev/null
+++ b/bolt12/validate_test.go
@@ -0,0 +1,648 @@
+package bolt12
+
+import (
+ "testing"
+ "time"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// validBobOffer is the spec-minimal happy-path offer that each table row
+// mutates to isolate the rule under test.
+func validBobOffer(t *testing.T) *Offer {
+ t.Helper()
+
+ _, pub := bobKey()
+
+ return &Offer{
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](pub),
+ ),
+ }
+}
+
+// TestValidateOfferWrite pins the BOLT 12 writer-side MUSTs that the codec can
+// enforce.
+func TestValidateOfferWrite(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ mutate func(*Offer)
+ wantErr error
+ }{
+ {
+ name: "happy path with issuer_id only",
+ mutate: func(*Offer) {},
+ wantErr: nil,
+ },
+ {
+ name: "amount without description",
+ mutate: func(o *Offer) {
+ o.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](
+ TUint64(1000),
+ ),
+ )
+ },
+ wantErr: ErrMissingDescription,
+ },
+ {
+ name: "currency without amount",
+ mutate: func(o *Offer) {
+ o.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](
+ tlv.Blob("USD"),
+ ),
+ )
+ },
+ wantErr: ErrCurrencyWithoutAmount,
+ },
+ {
+ name: "zero amount with description",
+ mutate: func(o *Offer) {
+ o.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](
+ TUint64(0),
+ ),
+ )
+ o.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("a tip"),
+ ),
+ )
+ },
+ wantErr: ErrZeroAmount,
+ },
+ {
+ name: "no issuer or paths",
+ mutate: func(o *Offer) {
+ o.OfferIssuerID = tlv.OptionalRecordT[
+ tlv.TlvType22, *btcec.PublicKey]{}
+ },
+ wantErr: ErrNoIssuerIdentity,
+ },
+ {
+ name: "empty offer_chains",
+ mutate: func(o *Offer) {
+ o.OfferChains = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](
+ ChainsRecord{
+ Chains: nil,
+ },
+ ),
+ )
+ },
+ wantErr: ErrEmptyChains,
+ },
+ {
+ name: "currency wrong length",
+ mutate: func(o *Offer) {
+ addAmountAndDescription(o)
+ o.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](
+ tlv.Blob("US"),
+ ),
+ )
+ },
+ wantErr: ErrInvalidCurrency,
+ },
+ {
+ name: "currency unknown ISO 4217 code",
+ mutate: func(o *Offer) {
+ addAmountAndDescription(o)
+ o.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](
+ tlv.Blob("ZZZ"),
+ ),
+ )
+ },
+ wantErr: ErrInvalidCurrency,
+ },
+ {
+ // Pins the docstring claim that ValidateOfferWrite's
+ // offerAllowedRange loop exists to catch a
+ // decoded-then-mutated offer with an out-of-range TLV
+ // resurfacing via decodedTLVs.
+ name: "out-of-range TLV in decoded extras",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 200: nil,
+ }
+ },
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "empty blinded paths list",
+ mutate: func(o *Offer) {
+ o.OfferPaths = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType16](
+ lnwire.BlindedPaths{
+ Paths: nil,
+ },
+ ),
+ )
+ },
+ wantErr: ErrEmptyBlindedPaths,
+ },
+ {
+ name: "blinded path with zero hops",
+ mutate: func(o *Offer) {
+ _, intro := aliceKey()
+ _, blinding := bobKey()
+ pk := lnwire.PubkeyIntro{
+ Pubkey: intro,
+ }
+ o.OfferPaths = tlv.SomeRecordT(
+ //nolint:ll
+ tlv.NewRecordT[tlv.TlvType16](
+ lnwire.BlindedPaths{
+ Paths: []lnwire.BlindedPath{{
+ IntroductionNode: pk,
+ BlindingPoint: blinding,
+ Hops: nil,
+ }},
+ },
+ ),
+ )
+ },
+ wantErr: lnwire.ErrEmptyBlindedPath,
+ },
+ {
+ name: "invalid UTF-8 in description",
+ mutate: func(o *Offer) {
+ addAmountAndDescription(o)
+ o.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("\xff\xff"),
+ ),
+ )
+ },
+ wantErr: ErrInvalidUTF8,
+ },
+ {
+ name: "invalid UTF-8 in issuer",
+ mutate: func(o *Offer) {
+ o.OfferIssuer = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType18](
+ tlv.Blob("\xff\x00"),
+ ),
+ )
+ },
+ wantErr: ErrInvalidUTF8,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(
+ tc.name,
+ func(t *testing.T) {
+ t.Parallel()
+
+ o := validBobOffer(t)
+ tc.mutate(o)
+
+ err := ValidateOfferWrite(o)
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+
+ return
+ }
+ require.ErrorIs(t, err, tc.wantErr)
+ },
+ )
+ }
+}
+
+// TestValidateOfferRead pins the BOLT 12 reader-side MUSTs so a malformed or
+// unsafe offer is rejected before any invoice request reaches the wire.
+func TestValidateOfferRead(t *testing.T) {
+ t.Parallel()
+
+ now := time.Unix(1_700_000_000, 0)
+
+ var nonBitcoin [32]byte
+ nonBitcoin[0] = 0x01
+
+ tests := []struct {
+ name string
+ mutate func(*Offer)
+ activeChain [32]byte
+ wantErr error
+ }{
+ {
+ name: "happy path on bitcoin mainnet",
+ mutate: func(*Offer) {},
+ activeChain: bitcoinMainnetGenesisHash,
+ },
+ {
+ name: "out-of-range TLV in decoded extras",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 200: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "unknown even TLV type in range rejected",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 24: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ name: "unknown even feature bit rejected",
+ mutate: func(o *Offer) {
+ o.OfferFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType12](
+ *lnwire.NewRawFeatureVector(0),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrUnknownEvenFeature,
+ },
+ {
+ name: "unknown odd feature bit ignored",
+ mutate: func(o *Offer) {
+ o.OfferFeatures = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType12](
+ *lnwire.NewRawFeatureVector(1),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "non-bitcoin chain rejected when " +
+ "offer_chains absent",
+ mutate: func(*Offer) {},
+ activeChain: nonBitcoin,
+ wantErr: ErrUnsupportedChain,
+ },
+ {
+ name: "explicit chain list missing active chain",
+ mutate: func(o *Offer) {
+ var c [32]byte
+ c[0] = 0xaa
+ o.OfferChains = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](
+ ChainsRecord{
+ Chains: [][32]byte{c},
+ },
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrUnsupportedChain,
+ },
+ {
+ name: "empty offer_chains list",
+ mutate: func(o *Offer) {
+ o.OfferChains = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](
+ ChainsRecord{
+ Chains: nil,
+ },
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrEmptyChains,
+ },
+ {
+ name: "amount without description",
+ mutate: func(o *Offer) {
+ o.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](
+ TUint64(1000),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrMissingDescription,
+ },
+ {
+ name: "currency without amount",
+ mutate: func(o *Offer) {
+ o.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](
+ tlv.Blob("USD"),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrCurrencyWithoutAmount,
+ },
+ {
+ name: "zero amount with description",
+ mutate: func(o *Offer) {
+ o.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](
+ TUint64(0),
+ ),
+ )
+ o.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("a tip"),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrZeroAmount,
+ },
+ {
+ name: "missing issuer and paths",
+ mutate: func(o *Offer) {
+ o.OfferIssuerID = tlv.OptionalRecordT[
+ tlv.TlvType22, *btcec.PublicKey]{}
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrNoIssuerIdentity,
+ },
+ {
+ name: "blinded path with zero hops",
+ mutate: func(o *Offer) {
+ _, intro := aliceKey()
+ _, blinding := bobKey()
+ pk := lnwire.PubkeyIntro{
+ Pubkey: intro,
+ }
+ o.OfferPaths = tlv.SomeRecordT(
+ //nolint:ll
+ tlv.NewRecordT[tlv.TlvType16](
+ lnwire.BlindedPaths{
+ Paths: []lnwire.BlindedPath{{
+ IntroductionNode: pk,
+ BlindingPoint: blinding,
+ Hops: nil,
+ }},
+ },
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: lnwire.ErrEmptyBlindedPath,
+ },
+ {
+ name: "expired offer",
+ mutate: func(o *Offer) {
+ expiry := uint64(now.Unix()) - 1
+ o.OfferAbsoluteExpiry = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType14](
+ TUint64(expiry),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrOfferExpired,
+ },
+ {
+ name: "currency wrong length",
+ mutate: func(o *Offer) {
+ addAmountAndDescription(o)
+ o.OfferCurrency = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType6](
+ tlv.Blob("US"),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrInvalidCurrency,
+ },
+ {
+ name: "TLV type boundary 0 - out of range",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 0: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "TLV type boundary 1 - valid and ignored (odd)",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 1: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "TLV type boundary 79 - valid and ignored (odd)",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 79: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "TLV type boundary 80 - out of range",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 80: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "TLV type boundary 999999999 - out of range",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 999999999: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "TLV type boundary 1000000000 - even and " +
+ "rejected",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 1000000000: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrUnknownEvenType,
+ },
+ {
+ name: "TLV type boundary 1000000001 - valid and " +
+ "ignored (odd)",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 1000000001: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "TLV type boundary 1999999999 - valid and " +
+ "ignored (odd)",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 1999999999: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "TLV type boundary 2000000000 - out of range",
+ mutate: func(o *Offer) {
+ o.decodedTLVs = tlv.TypeMap{
+ 2000000000: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrOutOfRangeType,
+ },
+ {
+ name: "invalid UTF-8 in description",
+ mutate: func(o *Offer) {
+ addAmountAndDescription(o)
+ o.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("\xff\xff"),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrInvalidUTF8,
+ },
+ {
+ name: "invalid UTF-8 in issuer",
+ mutate: func(o *Offer) {
+ o.OfferIssuer = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType18](
+ tlv.Blob("\xff\x00"),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrInvalidUTF8,
+ },
+ {
+ name: "quantity max = 0 (unlimited)",
+ mutate: func(o *Offer) {
+ o.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(0),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "quantity max > 0 (e.g. 5)",
+ mutate: func(o *Offer) {
+ o.OfferQuantityMax = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType20](
+ TUint64(5),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "now == expiry boundary (valid)",
+ mutate: func(o *Offer) {
+ expiry := uint64(now.Unix())
+ o.OfferAbsoluteExpiry = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType14](
+ TUint64(expiry),
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "symmetric explicit bitcoin chain list " +
+ "(inverted-default invariant)",
+ mutate: func(o *Offer) {
+ o.OfferChains = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType2](
+ ChainsRecord{
+ //nolint:ll
+ Chains: [][32]byte{
+ bitcoinMainnetGenesisHash,
+ },
+ },
+ ),
+ )
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: nil,
+ },
+ {
+ name: "multi-error determinism (sortedTypes contract " +
+ "returning first sorted error)",
+ mutate: func(o *Offer) {
+ // 24 is unknown even type (in range) -> returns
+ // ErrUnknownEvenType 200 is out of range type
+ // -> returns ErrOutOfRangeType Since 24 is
+ // sorted before 200, we must return
+ // ErrUnknownEvenType.
+ o.decodedTLVs = tlv.TypeMap{
+ 200: nil,
+ 24: nil,
+ }
+ },
+ activeChain: bitcoinMainnetGenesisHash,
+ wantErr: ErrUnknownEvenType,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(
+ tc.name,
+ func(t *testing.T) {
+ t.Parallel()
+
+ o := validBobOffer(t)
+ tc.mutate(o)
+
+ err := ValidateOfferRead(o, now, tc.activeChain)
+ if tc.wantErr == nil {
+ require.NoError(t, err)
+
+ return
+ }
+ require.ErrorIs(t, err, tc.wantErr)
+ },
+ )
+ }
+}
+
+// addAmountAndDescription satisfies the dependency rules so currency-shape rows
+// are not short-circuited before the ISO 4217 check runs.
+func addAmountAndDescription(o *Offer) {
+ o.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](
+ TUint64(1000),
+ ),
+ )
+ o.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](
+ tlv.Blob("a tip"),
+ ),
+ )
+}
diff --git a/go.mod b/go.mod
index 856b7fe..0cb9c9e 100644
--- a/go.mod
+++ b/go.mod
@@ -178,7 +178,7 @@ require (
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.32.0 // indirect
+ golang.org/x/text v0.32.0
golang.org/x/tools v0.39.0 // indirect
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
Why this scored 59/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.