Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions bolt12/decode.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions bolt12/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Package bolt12 implements encoding, decoding, and validation for BOLT 12
// Offers, Invoice Requests, and Invoices. It provides a pure codec library
// with no LND daemon dependencies.
//
// BOLT 12 messages use TLV streams encoded with a checksumless bech32 variant
// and signed with BIP-340 Schnorr signatures over a Merkle tree of TLV fields.
//
// Human-readable prefixes:
// - lno: Offer
// - lnr: Invoice Request
// - lni: Invoice
//
// # Codec Contract
//
// Encode validates before serialising and refuses to emit bytes that would fail
// the writer requirements, invalid bytes are unrepresentable on the wire.
// Low-level decoders stay permissive so diagnostic and fuzz harnesses can
// inspect malformed input.
package bolt12
24 changes: 24 additions & 0 deletions bolt12/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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
}

// 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
}
167 changes: 167 additions & 0 deletions bolt12/offer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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) {
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
}

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) {
Comment thread
erickcestari marked this conversation as resolved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Should we set a maximum ceiling size for offers to avoid decoding huge offers?

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
}
Comment thread
erickcestari marked this conversation as resolved.
49 changes: 49 additions & 0 deletions bolt12/offer_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
erickcestari marked this conversation as resolved.
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)
}
52 changes: 52 additions & 0 deletions bolt12/pure_tlv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package bolt12

import (
"sort"

"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...)
}

// 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)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })

return out
}
Loading
Loading