bolt12: add Offer message struct with TLV codec
What changed, and why it matters
This commit adds new code for encoding and decoding BOLT 12 'offers' in the LND Lightning node. It is a feature-introduction commit with no bug fix or security patch. There is no direct evidence in the commit or supplied references that this change fixes a vulnerability or introduces one.
No immediate security action required. Review the new BOLT 12 codec as part of normal code review, paying attention to canonicalization of TLV records and signature-range handling before it is used in production payment flows.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces the Offer message struct, a TLV codec, and supporting helpers for BOLT 12. It implements lnwire.PureTLVMessage, preserves unknown signed-range TLVs across encode/decode to keep offer_id stable, and adds a round-trip test. The code is purely additive and does not modify existing security-critical logic or disclose a vulnerability.
Changed components
bolt12/decode.gobolt12/helpers_test.gobolt12/offer.gobolt12/offer_test.gobolt12/pure_tlv.gobolt12/tlv_types.goInspect captured patch +317 / −0
diff --git a/bolt12/decode.go b/bolt12/decode.go
new file mode 100644
index 0000000..4307455
--- /dev/null
+++ b/bolt12/decode.go
@@ -0,0 +1,29 @@
+package bolt12
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// decodeStream runs a single typed-stream pass over data and returns the
+// canonical TypeMap. Records may be passed in any order; NewStream requires
+// them sorted, so SortRecords runs first.
+func decodeStream(data []byte, records ...tlv.Record) (tlv.TypeMap, error) {
+ tlv.SortRecords(records)
+
+ stream, err := tlv.NewStream(records...)
+ if err != nil {
+ return nil, fmt.Errorf("create stream: %w", err)
+ }
+
+ typeMap, err := stream.DecodeWithParsedTypesP2P(
+ bytes.NewReader(data),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("decode stream: %w", err)
+ }
+
+ return typeMap, nil
+}
diff --git a/bolt12/helpers_test.go b/bolt12/helpers_test.go
new file mode 100644
index 0000000..f301ad2
--- /dev/null
+++ b/bolt12/helpers_test.go
@@ -0,0 +1,16 @@
+package bolt12
+
+import (
+ "bytes"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+)
+
+// bobKey returns the deterministic spec test key for Bob, whose 32-byte scalar
+// is 0x42 repeated. Used across signature and round-trip tests so the same key
+// is not reconstructed in every callsite.
+func bobKey() (*btcec.PrivateKey, *btcec.PublicKey) {
+ priv, pub := btcec.PrivKeyFromBytes(bytes.Repeat([]byte{0x42}, 32))
+
+ return priv, pub
+}
diff --git a/bolt12/offer.go b/bolt12/offer.go
new file mode 100644
index 0000000..c203fbb
--- /dev/null
+++ b/bolt12/offer.go
@@ -0,0 +1,163 @@
+package bolt12
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// Offer represents a BOLT 12 offer message. An offer is a long-lived, reusable
+// payment template that can generate multiple invoices.
+type Offer struct {
+ // OfferChains specifies which chains this offer is valid for. If
+ // absent, bitcoin is implied.
+ OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]
+
+ // OfferMetadata is opaque data set by the offer creator for its own
+ // use.
+ OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob]
+
+ // OfferCurrency is the ISO 4217 currency code for the offer amount, if
+ // the amount is not in the chain's native unit.
+ OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob]
+
+ // OfferAmount is the amount expected per item, encoded as a tu64. The
+ // unit depends on OfferCurrency (msat if absent).
+ OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64]
+
+ // OfferDescription is a UTF-8 description of the purpose of the
+ // payment.
+ OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob]
+
+ // OfferFeatures is the feature bit vector for this offer.
+ OfferFeatures tlv.OptionalRecordT[tlv.TlvType12,
+ lnwire.RawFeatureVector]
+
+ // OfferAbsoluteExpiry is the time (seconds since epoch) after which the
+ // offer should not be used, encoded as a tu64.
+ OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64]
+
+ // OfferPaths contains one or more blinded paths to the offer issuer.
+ OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths]
+
+ // OfferIssuer is a UTF-8 string identifying the issuer.
+ OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob]
+
+ // OfferQuantityMax is the maximum number of items that can be requested
+ // in a single invoice, encoded as a tu64. A value of 0 means unlimited.
+ OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64]
+
+ // OfferIssuerID is the public key of the offer issuer. The codec
+ // parses the 33-byte SEC1 compressed point on decode, so a struct
+ // holding a key has already passed both the length and on-curve
+ // checks.
+ OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey]
+
+ // decodedTLVs is the canonical TypeMap produced by decoding this offer.
+ // Handled types map to nil; unhandled types map to their value bytes.
+ // Encoding and validation both derive their view from this single field
+ // so they cannot drift apart, and so signed-range extras the decoder
+ // did not understand are re-emitted on encode and preserve offer_id.
+ decodedTLVs tlv.TypeMap
+}
+
+var _ lnwire.PureTLVMessage = (*Offer)(nil)
+
+// AllRecords returns the canonical sorted record list for this offer, merging
+// the typed records with any extra signed-range fields that the decoder
+// preserved.
+func (o *Offer) AllRecords() []tlv.Record {
+ return allRecordsFromTypeMap(
+ o.allRecordProducers(), o.decodedTLVs,
+ )
+}
+
+// allRecordProducers returns record producers for every set optional field, in
+// declaration order.
+func (o *Offer) allRecordProducers() []tlv.RecordProducer {
+ var p []tlv.RecordProducer
+
+ lnwire.AddOpt(&p, o.OfferChains)
+ lnwire.AddOpt(&p, o.OfferMetadata)
+ lnwire.AddOpt(&p, o.OfferCurrency)
+ lnwire.AddOpt(&p, o.OfferAmount)
+ lnwire.AddOpt(&p, o.OfferDescription)
+ lnwire.AddOpt(&p, o.OfferFeatures)
+ lnwire.AddOpt(&p, o.OfferAbsoluteExpiry)
+ lnwire.AddOpt(&p, o.OfferPaths)
+ lnwire.AddOpt(&p, o.OfferIssuer)
+ lnwire.AddOpt(&p, o.OfferQuantityMax)
+ lnwire.AddOpt(&p, o.OfferIssuerID)
+
+ return p
+}
+
+// Encode serialises the offer into a canonical TLV byte stream.
+func (o *Offer) Encode() ([]byte, error) {
+ var buf bytes.Buffer
+ if err := lnwire.EncodePureTLVMessage(o, &buf); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+// decodeOffer parses a TLV byte stream into an Offer. Decoding is permissive —
+// the spec writer requirements are not enforced here, so callers that need a
+// valid offer must run ValidateOfferRead. Unknown TLVs are preserved on the
+// returned offer so a later Encode can re-emit signed-range extras and keep
+// offer_id stable.
+func decodeOffer(data []byte) (*Offer, error) {
+ var o Offer
+
+ // Prepare zero-valued records for all optional fields so the TLV
+ // decoder can populate them.
+ chains := tlv.ZeroRecordT[tlv.TlvType2, ChainsRecord]()
+ metadata := tlv.ZeroRecordT[tlv.TlvType4, tlv.Blob]()
+ currency := tlv.ZeroRecordT[tlv.TlvType6, tlv.Blob]()
+ amount := tlv.ZeroRecordT[tlv.TlvType8, TUint64]()
+ desc := tlv.ZeroRecordT[tlv.TlvType10, tlv.Blob]()
+ features := tlv.ZeroRecordT[tlv.TlvType12, lnwire.RawFeatureVector]()
+ expiry := tlv.ZeroRecordT[tlv.TlvType14, TUint64]()
+ paths := tlv.ZeroRecordT[tlv.TlvType16, lnwire.BlindedPaths]()
+ issuer := tlv.ZeroRecordT[tlv.TlvType18, tlv.Blob]()
+ qtyMax := tlv.ZeroRecordT[tlv.TlvType20, TUint64]()
+ issuerID := tlv.ZeroRecordT[tlv.TlvType22, *btcec.PublicKey]()
+
+ tm, err := decodeStream(
+ data,
+ chains.Record(),
+ metadata.Record(),
+ currency.Record(),
+ amount.Record(),
+ desc.Record(),
+ features.Record(),
+ expiry.Record(),
+ paths.Record(),
+ issuer.Record(),
+ qtyMax.Record(),
+ issuerID.Record(),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("decode offer: %w", err)
+ }
+
+ lnwire.SetOptFromMap(tm, &o.OfferChains, chains)
+ lnwire.SetOptFromMap(tm, &o.OfferMetadata, metadata)
+ lnwire.SetOptFromMap(tm, &o.OfferCurrency, currency)
+ lnwire.SetOptFromMap(tm, &o.OfferAmount, amount)
+ lnwire.SetOptFromMap(tm, &o.OfferDescription, desc)
+ lnwire.SetOptFromMap(tm, &o.OfferFeatures, features)
+ lnwire.SetOptFromMap(tm, &o.OfferAbsoluteExpiry, expiry)
+ lnwire.SetOptFromMap(tm, &o.OfferPaths, paths)
+ lnwire.SetOptFromMap(tm, &o.OfferIssuer, issuer)
+ lnwire.SetOptFromMap(tm, &o.OfferQuantityMax, qtyMax)
+ lnwire.SetOptFromMap(tm, &o.OfferIssuerID, issuerID)
+
+ o.decodedTLVs = tm
+
+ return &o, nil
+}
diff --git a/bolt12/offer_test.go b/bolt12/offer_test.go
new file mode 100644
index 0000000..2a9ae32
--- /dev/null
+++ b/bolt12/offer_test.go
@@ -0,0 +1,49 @@
+package bolt12
+
+import (
+ "testing"
+
+ "github.com/lightningnetwork/lnd/tlv"
+ "github.com/stretchr/testify/require"
+)
+
+// TestOfferRoundTrip pins encode→decode→re-encode for an Offer with a
+// representative subset of optional fields. A byte-identical re-encode is the
+// invariant that keeps offer_id stable across the codec boundary.
+func TestOfferRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ desc := tlv.Blob("coffee")
+ issuer := tlv.Blob("alice")
+ _, bobPub := bobKey()
+
+ o := &Offer{
+ OfferAmount: tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8](TUint64(1500)),
+ ),
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](desc),
+ ),
+ OfferIssuer: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType18](issuer),
+ ),
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](bobPub),
+ ),
+ }
+
+ encoded, err := o.Encode()
+ require.NoError(t, err)
+ require.NotEmpty(t, encoded)
+
+ decoded, err := decodeOffer(encoded)
+ require.NoError(t, err)
+
+ require.Equal(t, TUint64(1500), decoded.OfferAmount.UnwrapOrFailV(t))
+ require.Equal(t, desc, decoded.OfferDescription.UnwrapOrFailV(t))
+ require.Equal(t, issuer, decoded.OfferIssuer.UnwrapOrFailV(t))
+
+ reencoded, err := decoded.Encode()
+ require.NoError(t, err)
+ require.Equal(t, encoded, reencoded)
+}
diff --git a/bolt12/pure_tlv.go b/bolt12/pure_tlv.go
new file mode 100644
index 0000000..93a00eb
--- /dev/null
+++ b/bolt12/pure_tlv.go
@@ -0,0 +1,38 @@
+package bolt12
+
+import (
+ "github.com/lightningnetwork/lnd/lnwire"
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// bolt12InUnsignedRange reports whether a TLV type is excluded from the BOLT 12
+// Merkle tree. The spec reserves types 240-1000 for signature TLVs (the BIP-340
+// Schnorr signatures over the tree itself); every other allowed type sits in
+// the signed range.
+func bolt12InUnsignedRange(t tlv.Type) bool {
+ return t >= 240 && t <= 1000
+}
+
+// allRecordsFromTypeMap merges the typed-record producers with the signed-range
+// subset of the supplied TypeMap (preserved unknown TLVs) and returns the
+// canonical sorted record list. The signed-range subset is derived on demand
+// from the same TypeMap that drives the validators, so the two views cannot
+// drift apart.
+func allRecordsFromTypeMap(producers []tlv.RecordProducer,
+ tm tlv.TypeMap) []tlv.Record {
+
+ if len(tm) > 0 {
+ extra := lnwire.ExtraSignedFieldsFromTypeMapFn(
+ tm, bolt12InUnsignedRange,
+ )
+ if len(extra) > 0 {
+ producers = append(
+ producers, lnwire.RecordsAsProducers(
+ tlv.MapToRecords(extra),
+ )...,
+ )
+ }
+ }
+
+ return lnwire.ProduceRecordsSorted(producers...)
+}
diff --git a/bolt12/tlv_types.go b/bolt12/tlv_types.go
new file mode 100644
index 0000000..b956477
--- /dev/null
+++ b/bolt12/tlv_types.go
@@ -0,0 +1,22 @@
+package bolt12
+
+import (
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+// TUint64 is a uint64 that serializes using truncated encoding (tu64)
+// as required by BOLT 12. Leading zero bytes are omitted.
+type TUint64 uint64
+
+// Record returns a TLV record using truncated uint64 encoding.
+//
+// NOTE: This implements the tlv.RecordProducer interface.
+func (t *TUint64) Record() tlv.Record {
+ return tlv.MakeDynamicRecord(
+ 0, (*uint64)(t),
+ func() uint64 {
+ return tlv.SizeTUint64(uint64(*t))
+ },
+ tlv.ETUint64, tlv.DTUint64,
+ )
+}
Why this scored 12/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.