What changed, and why it matters
This commit adds a new helper function in LND's BOLT 12 implementation to build invoice requests from offers. It ensures all fields from an offer—including unknown future fields—are copied into the invoice request. The change is primarily a correctness/spec-compliance improvement and does not appear to fix an active security vulnerability. However, failing to copy all fields could previously have caused invoice requests to omit required offer data, potentially leading to payment failures or protocol non-compliance.
Review as a normal spec-compliance and robustness improvement. No urgent security action is indicated by the commit itself. If this commit is part of a larger release, consider whether related commits address any disclosed BOLT 12 security issue.
Security signals we found
New helper ensures BOLT 12 spec compliance: 'MUST copy all fields from the offer (including unknown fields)'
Unknown odd TLVs are carried forward via decodedTLVs sidecar, preventing silent omission of future offer fields
Validation added for required payer ID and metadata fields
No direct bug fix, CVE, or vulnerability disclosure referenced in commit or supplied materials
Evidence from the diff
The commit introduces NewInvoiceRequestFromOffer in bolt12/invoice_request.go, which constructs an InvoiceRequest by mirroring all typed fields from an Offer and cloning the offer’s decodedTLVs sidecar to preserve unknown odd TLVs. It also adds validation requiring payerID and metadata. The accompanying tests verify field mirroring and that unknown odd TLVs in the offer’s signed range survive into the invoice request. The commit is additive and includes tests; it does not modify existing behavior except by providing a new canonical constructor.
Changed components
bolt12/invoice_request.gobolt12/invoice_request_test.goInspect captured patch +189 / −0
diff --git a/bolt12/invoice_request.go b/bolt12/invoice_request.go
index 2825892..2c7b2b9 100644
--- a/bolt12/invoice_request.go
+++ b/bolt12/invoice_request.go
@@ -2,16 +2,29 @@ package bolt12
import (
"bytes"
+ "errors"
"fmt"
+ "maps"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)
+var (
+ // ErrMissingPayerID is returned when invreq_payer_id is absent.
+ ErrMissingPayerID = errors.New("missing invreq_payer_id")
+
+ // ErrMissingMetadata is returned when invreq_metadata is absent.
+ ErrMissingMetadata = errors.New("missing invreq_metadata")
+)
+
// InvoiceRequest represents a BOLT 12 invoice_request message. It mirrors offer
// fields from the original offer. It also adds payer-specific fields and a
// Schnorr signature.
+//
+// An invoice request should be constructed from an offer (e.g., using
+// NewInvoiceRequestFromOffer) unless it is a spontaneous invoice request.
type InvoiceRequest struct {
// OfferChains are the chains that the mirrored offer is valid for.
OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]
@@ -220,3 +233,68 @@ func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) {
return &ir, nil
}
+
+// NewInvoiceRequestFromOffer constructs a new InvoiceRequest by copying
+// (mirroring) all fields from the provided Offer. It assigns the payer ID and
+// payer metadata; the caller should subsequently sign the request.
+//
+// Per "MUST copy all fields from the offer (including unknown fields)", the
+// offer's unknown TLVs are carried via the decodedTLVs sidecar so they are
+// signed and mirrored into the invoice. Note that because unknown even TLV
+// types in the offer would have already been rejected by ValidateOfferRead, any
+// unknown TLVs mirrored here are guaranteed to be unknown odd TLVs ("it's ok to
+// be odd") which are safe to ignore and carry forward.
+//
+// chain is the genesis hash the payer intends to pay on. invreq_chain is set
+// only when chain is not Bitcoin mainnet (absent defaults to mainnet); writer
+// validation enforces that it is one of the offer's chains.
+func NewInvoiceRequestFromOffer(offer *Offer, payerID *btcec.PublicKey,
+ metadata []byte, chain [32]byte) (*InvoiceRequest, error) {
+
+ if payerID == nil {
+ return nil, ErrMissingPayerID
+ }
+ if len(metadata) == 0 {
+ return nil, ErrMissingMetadata
+ }
+
+ ir := &InvoiceRequest{
+ OfferChains: offer.OfferChains,
+ OfferMetadata: offer.OfferMetadata,
+ OfferCurrency: offer.OfferCurrency,
+ OfferAmount: offer.OfferAmount,
+ OfferDescription: offer.OfferDescription,
+ OfferFeatures: offer.OfferFeatures,
+ OfferAbsoluteExpiry: offer.OfferAbsoluteExpiry,
+ OfferPaths: offer.OfferPaths,
+ OfferIssuer: offer.OfferIssuer,
+ OfferQuantityMax: offer.OfferQuantityMax,
+ OfferIssuerID: offer.OfferIssuerID,
+
+ InvreqPayerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType88](payerID),
+ ),
+ InvreqMetadata: tlv.SomeRecordT(
+ tlv.RecordT[tlv.TlvType0, tlv.Blob]{
+ Val: metadata,
+ },
+ ),
+
+ // Carry the offer's unknown signed-range TLVs. Known offer
+ // types appear in the map with nil values and are skipped when
+ // the sidecar is merged, so this re-emits only the unknowns and
+ // never duplicates the typed fields copied above.
+ decodedTLVs: maps.Clone(offer.decodedTLVs),
+ }
+
+ // Set invreq_chain only for non-bitcoin chains; for bitcoin mainnet the
+ // spec says SHOULD omit, and an absent invreq_chain defaults back to
+ // mainnet on the read side.
+ if chain != bitcoinMainnetGenesisHash {
+ ir.InvreqChain = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType80, [32]byte](chain),
+ )
+ }
+
+ return ir, nil
+}
diff --git a/bolt12/invoice_request_test.go b/bolt12/invoice_request_test.go
index 2341a03..71eac71 100644
--- a/bolt12/invoice_request_test.go
+++ b/bolt12/invoice_request_test.go
@@ -1,8 +1,10 @@
package bolt12
import (
+ "bytes"
"testing"
+ "github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/tlv"
"github.com/stretchr/testify/require"
)
@@ -58,3 +60,112 @@ func TestInvoiceRequestRoundTrip(t *testing.T) {
require.NoError(t, err)
require.Equal(t, encoded, reencoded)
}
+
+// TestNewInvoiceRequestFromOffer tests the constructor for mirroring all offer
+// fields and properly assigning the payer ID and metadata.
+func TestNewInvoiceRequestFromOffer(t *testing.T) {
+ t.Parallel()
+
+ offer := validBobOffer(t)
+
+ // Add some optional offer fields for mirroring verification.
+ offer.OfferDescription = tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("description")),
+ )
+ offer.OfferAmount = tlv.SomeRecordT(
+ tlv.NewRecordT[tlv.TlvType8, TUint64](5000),
+ )
+
+ priv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ payerID := priv.PubKey()
+ metadata := []byte("payer-metadata")
+
+ ir, err := NewInvoiceRequestFromOffer(
+ offer, payerID, metadata, bitcoinMainnetGenesisHash,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, ir)
+
+ // Verify offer fields are copied exactly
+ require.Equal(t, offer.OfferIssuerID, ir.OfferIssuerID)
+ require.Equal(t, offer.OfferDescription, ir.OfferDescription)
+ require.Equal(t, offer.OfferAmount, ir.OfferAmount)
+
+ // Verify payer ID and metadata are set correctly
+ require.Equal(t, payerID, ir.InvreqPayerID.UnwrapOrFailV(t))
+ require.Equal(t, metadata, ir.InvreqMetadata.UnwrapOrFailV(t))
+
+ // For Bitcoin mainnet the spec says SHOULD omit invreq_chain.
+ require.False(t, ir.InvreqChain.IsSome())
+
+ // A non-bitcoin chain must be set explicitly so it does not default
+ // back to mainnet on the read side.
+ var altChain [32]byte
+ for i := range altChain {
+ altChain[i] = 0xab
+ }
+ irAlt, err := NewInvoiceRequestFromOffer(
+ offer, payerID, metadata, altChain,
+ )
+ require.NoError(t, err)
+ require.Equal(t, altChain, irAlt.InvreqChain.UnwrapOrFailV(t))
+}
+
+// TestNewInvoiceRequestFromOfferMirrorsUnknownFields verifies the writer
+// requirement "MUST copy all fields from the offer (including unknown fields)":
+// an unknown odd TLV in the offer's signed range must survive into the
+// constructed request's record set so it is signed and later mirrored into the
+// invoice.
+func TestNewInvoiceRequestFromOfferMirrorsUnknownFields(t *testing.T) {
+ t.Parallel()
+
+ _, pub := bobKey()
+
+ // Build a minimal valid offer, encode it, then splice in an unknown odd
+ // TLV (type 33, within the offer signed range) and decode it
+ // back so the unknown lands in the offer's decodedTLVs sidecar.
+ offer := &Offer{
+ OfferDescription: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType10](tlv.Blob("desc")),
+ ),
+ OfferIssuerID: tlv.SomeRecordT(
+ tlv.NewPrimitiveRecord[tlv.TlvType22](pub),
+ ),
+ }
+ encoded, err := offer.Encode()
+ require.NoError(t, err)
+
+ const unknownType = 33
+ unknownVal := []byte("xyz")
+ var extra bytes.Buffer
+ require.NoError(t, tlv.WriteVarInt(&extra, unknownType, &[8]byte{}))
+ require.NoError(t, tlv.WriteVarInt(
+ &extra, uint64(len(unknownVal)), &[8]byte{},
+ ))
+ extra.Write(unknownVal)
+
+ // TLV records are canonically ordered by type; type 33 sorts after the
+ // offer's existing types (10, 22), so appending keeps the stream
+ // sorted.
+ spliced := append(append([]byte{}, encoded...), extra.Bytes()...)
+
+ decodedOffer, err := decodeOffer(spliced)
+ require.NoError(t, err)
+
+ ir, err := NewInvoiceRequestFromOffer(
+ decodedOffer, pub, []byte("metadata"),
+ bitcoinMainnetGenesisHash,
+ )
+ require.NoError(t, err)
+
+ // The unknown field must appear in the request's canonical record set.
+ var found bool
+ for _, r := range ir.AllRecords() {
+ if r.Type() == unknownType {
+ found = true
+ }
+ }
+ require.True(t, found, "unknown offer TLV not mirrored into request")
+}
Why this scored 26/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.