-
Notifications
You must be signed in to change notification settings - Fork 2.3k
bolt12+lnwire: add codec foundation with Offer message #10789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bitromortac
wants to merge
7
commits into
lightningnetwork:master
Choose a base branch
from
bitromortac:2604-bolt12-1a
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
466f0cb
lnwire: generalize pure TLV signed-range filtering
bitromortac d631d95
lnwire: add SetOptFromMap and AddOpt
bitromortac 3144228
lnwire: add bounded introNode BlindedPath codec
bitromortac 51111cf
multi: migrate OnionMessagePayload to lnwire.BlindedPath
bitromortac 3c03083
bolt12: add chains TLV subtype
bitromortac 0a9bb23
bolt12: add Offer message struct with TLV codec
bitromortac 753cfde
bolt12: validate Offer per BOLT 12 reader/writer requirements
bitromortac File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
|
erickcestari marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
|
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) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.