Skip to content
Draft
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
}
281 changes: 281 additions & 0 deletions bolt12/invoice_request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
package bolt12

import (
"bytes"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/tlv"
)

// 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.
type InvoiceRequest struct {
// OfferChains are the chains that the mirrored offer is valid for.
OfferChains tlv.OptionalRecordT[tlv.TlvType2, ChainsRecord]

// OfferMetadata is the metadata from the mirrored offer.
OfferMetadata tlv.OptionalRecordT[tlv.TlvType4, tlv.Blob]

// OfferCurrency is the currency from the mirrored offer.
OfferCurrency tlv.OptionalRecordT[tlv.TlvType6, tlv.Blob]

// OfferAmount is the amount from the mirrored offer.
OfferAmount tlv.OptionalRecordT[tlv.TlvType8, TUint64]

// OfferDescription is the description from the mirrored offer.
OfferDescription tlv.OptionalRecordT[tlv.TlvType10, tlv.Blob]

// OfferFeatures are the features required by the mirrored offer.
OfferFeatures tlv.OptionalRecordT[
tlv.TlvType12, lnwire.RawFeatureVector,
]

// OfferAbsoluteExpiry is the absolute expiry from the mirrored offer.
OfferAbsoluteExpiry tlv.OptionalRecordT[tlv.TlvType14, TUint64]

// OfferPaths are the blinded paths from the mirrored offer.
OfferPaths tlv.OptionalRecordT[tlv.TlvType16, lnwire.BlindedPaths]

// OfferIssuer is the issuer name from the mirrored offer.
OfferIssuer tlv.OptionalRecordT[tlv.TlvType18, tlv.Blob]

// OfferQuantityMax is the maximum quantity allowed by the mirrored
// offer.
OfferQuantityMax tlv.OptionalRecordT[tlv.TlvType20, TUint64]

// OfferIssuerID is the public key of the offer issuer.
OfferIssuerID tlv.OptionalRecordT[tlv.TlvType22, *btcec.PublicKey]

// InvreqMetadata is a blob of metadata provided by the payer.
InvreqMetadata tlv.OptionalRecordT[tlv.TlvType0, tlv.Blob]

// InvreqChain is the chain that the payer is using for this request.
InvreqChain tlv.OptionalRecordT[tlv.TlvType80, [32]byte]

// InvreqAmount is the amount the payer is offering to pay.
InvreqAmount tlv.OptionalRecordT[tlv.TlvType82, TUint64]

// InvreqFeatures are the features provided by the payer.
InvreqFeatures tlv.OptionalRecordT[
tlv.TlvType84, lnwire.RawFeatureVector,
]

// InvreqQuantity is the quantity of the offer item being requested.
InvreqQuantity tlv.OptionalRecordT[tlv.TlvType86, TUint64]

// InvreqPayerID is the public key used by the payer to sign the
// request.
InvreqPayerID tlv.OptionalRecordT[tlv.TlvType88, [33]byte]

// InvreqPayerNote is an optional note from the payer.
InvreqPayerNote tlv.OptionalRecordT[tlv.TlvType89, tlv.Blob]

// InvreqPaths are the blinded paths the payer wants the invoice to be
// sent to.
InvreqPaths tlv.OptionalRecordT[tlv.TlvType90, lnwire.BlindedPaths]

// InvreqBip353Name is the BIP 353 name of the payer.
InvreqBip353Name tlv.OptionalRecordT[tlv.TlvType91, tlv.Blob]

// Signature is a BIP-340 Schnorr signature covering all fields.
Signature tlv.OptionalRecordT[tlv.TlvType240, [64]byte]

// decodedTLVs is the canonical TypeMap produced by the typed-
// stream pass that decoded this request. See Offer.decodedTLVs
// for the design rationale.
decodedTLVs tlv.TypeMap
}

// AllRecords returns the canonical sorted record list for this invoice
// request, merging the typed records with any extra signed-range fields
// that the decoder preserved.
//
// NOTE: this is part of the tlv.PureTLVMessage interface.
func (ir *InvoiceRequest) AllRecords() []tlv.Record {
return allRecordsFromTypeMap(
ir.allRecordProducers(), ir.decodedTLVs,
)
}

var _ lnwire.PureTLVMessage = (*InvoiceRequest)(nil)

// allRecordProducers returns the set of records that are present.
func (ir *InvoiceRequest) allRecordProducers() []tlv.RecordProducer {
var p []tlv.RecordProducer

lnwire.AddOpt(&p, ir.InvreqMetadata)
lnwire.AddOpt(&p, ir.OfferChains)
lnwire.AddOpt(&p, ir.OfferMetadata)
lnwire.AddOpt(&p, ir.OfferCurrency)
lnwire.AddOpt(&p, ir.OfferAmount)
lnwire.AddOpt(&p, ir.OfferDescription)
lnwire.AddOpt(&p, ir.OfferFeatures)
lnwire.AddOpt(&p, ir.OfferAbsoluteExpiry)
lnwire.AddOpt(&p, ir.OfferPaths)
lnwire.AddOpt(&p, ir.OfferIssuer)
lnwire.AddOpt(&p, ir.OfferQuantityMax)
lnwire.AddOpt(&p, ir.OfferIssuerID)
lnwire.AddOpt(&p, ir.InvreqChain)
lnwire.AddOpt(&p, ir.InvreqAmount)
lnwire.AddOpt(&p, ir.InvreqFeatures)
lnwire.AddOpt(&p, ir.InvreqQuantity)
lnwire.AddOpt(&p, ir.InvreqPayerID)
lnwire.AddOpt(&p, ir.InvreqPayerNote)
lnwire.AddOpt(&p, ir.InvreqPaths)
lnwire.AddOpt(&p, ir.InvreqBip353Name)
lnwire.AddOpt(&p, ir.Signature)

return p
}

// Encode validates the invoice request per writer requirements and
// serialises it 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.
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
}

return buf.Bytes(), nil
}

// DecodeInvoiceRequest deserializes an invoice request from a TLV byte
// stream. Decoding is permissive: callers that need spec compliance must run
// ValidateInvoiceRequestRead.
func DecodeInvoiceRequest(data []byte) (*InvoiceRequest, error) {
var ir InvoiceRequest

invreqMetadata := tlv.ZeroRecordT[tlv.TlvType0, tlv.Blob]()
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]()
invreqChain := tlv.ZeroRecordT[tlv.TlvType80, [32]byte]()
invreqAmount := tlv.ZeroRecordT[tlv.TlvType82, TUint64]()
invreqFeatures := tlv.ZeroRecordT[
tlv.TlvType84, lnwire.RawFeatureVector,
]()
invreqQty := tlv.ZeroRecordT[tlv.TlvType86, TUint64]()
payerID := tlv.ZeroRecordT[tlv.TlvType88, [33]byte]()
payerNote := tlv.ZeroRecordT[tlv.TlvType89, tlv.Blob]()
invreqPaths := tlv.ZeroRecordT[tlv.TlvType90, lnwire.BlindedPaths]()
bip353 := tlv.ZeroRecordT[tlv.TlvType91, tlv.Blob]()
sig := tlv.ZeroRecordT[tlv.TlvType240, [64]byte]()

tm, err := decodeStream(
data,
invreqMetadata.Record(),
chains.Record(),
metadata.Record(),
currency.Record(),
amount.Record(),
desc.Record(),
features.Record(),
expiry.Record(),
paths.Record(),
issuer.Record(),
qtyMax.Record(),
issuerID.Record(),
invreqChain.Record(),
invreqAmount.Record(),
invreqFeatures.Record(),
invreqQty.Record(),
payerID.Record(),
payerNote.Record(),
invreqPaths.Record(),
bip353.Record(),
sig.Record(),
)
if err != nil {
return nil, fmt.Errorf("decode invoice request: %w", err)
}

lnwire.SetOptFromMap(tm, &ir.InvreqMetadata, invreqMetadata)
lnwire.SetOptFromMap(tm, &ir.OfferChains, chains)
lnwire.SetOptFromMap(tm, &ir.OfferMetadata, metadata)
lnwire.SetOptFromMap(tm, &ir.OfferCurrency, currency)
lnwire.SetOptFromMap(tm, &ir.OfferAmount, amount)
lnwire.SetOptFromMap(tm, &ir.OfferDescription, desc)
lnwire.SetOptFromMap(tm, &ir.OfferFeatures, features)
lnwire.SetOptFromMap(tm, &ir.OfferAbsoluteExpiry, expiry)
lnwire.SetOptFromMap(tm, &ir.OfferPaths, paths)
lnwire.SetOptFromMap(tm, &ir.OfferIssuer, issuer)
lnwire.SetOptFromMap(tm, &ir.OfferQuantityMax, qtyMax)
lnwire.SetOptFromMap(tm, &ir.OfferIssuerID, issuerID)
lnwire.SetOptFromMap(tm, &ir.InvreqChain, invreqChain)
lnwire.SetOptFromMap(tm, &ir.InvreqAmount, invreqAmount)
lnwire.SetOptFromMap(tm, &ir.InvreqFeatures, invreqFeatures)
lnwire.SetOptFromMap(tm, &ir.InvreqQuantity, invreqQty)
lnwire.SetOptFromMap(tm, &ir.InvreqPayerID, payerID)
lnwire.SetOptFromMap(tm, &ir.InvreqPayerNote, payerNote)
lnwire.SetOptFromMap(tm, &ir.InvreqPaths, invreqPaths)
lnwire.SetOptFromMap(tm, &ir.InvreqBip353Name, bip353)
lnwire.SetOptFromMap(tm, &ir.Signature, sig)

ir.decodedTLVs = tm

return &ir, 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 {
if !ir.OfferQuantityMax.IsSome() {
return nil
}

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 > 0 && qty > maxQty {
return ErrQuantityExceedsMax
}

return nil
}
Loading
Loading