Skip to content
Merged
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
9 changes: 9 additions & 0 deletions docs/release-notes/release-notes-0.22.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@
requests, and invoices, and `ValidateInvoiceForPayment` to bundle the
payer-side invoice checks into one call.

* [BOLT 12 offer
store](https://github.com/lightningnetwork/lnd/pull/11278): add the
`offers` package with a SQL-backed store for BOLT 12 offers. The store
keeps the encoded offer, a disabled flag and the creation time. It finds
an offer by its hash. `CreateOffer` validates the parameters, encodes the
offer and stores it. The `offers` table migration is a development
migration for now, so a normal build does not change the database
schema.

## Testing

* [BOLT 12 spec test vectors](https://github.com/lightningnetwork/lnd/pull/11001):
Expand Down
179 changes: 179 additions & 0 deletions offers/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package offers

import (
"context"
"errors"
"fmt"

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

// ErrExpiryNotInFuture is returned when the absolute expiry is not after the
// creation time, so no payer could use the offer.
var ErrExpiryNotInFuture = errors.New("absolute expiry is not in the future")

// CreateOfferParams holds the fields of a new offer. Each field maps to a
// matching offer TLV record.
type CreateOfferParams struct {
// Identity specifies how the offer identifies the receiver. Left holds
// the issuer public key (offer_issuer_id), which reveals the node
// identity. Right holds blinded message paths (offer_paths), which
// preserve privacy.
Identity fn.Either[*btcec.PublicKey, []lnwire.BlindedPath]

// Description is the UTF-8 description of the payment purpose. Required
// when Amount is set.
Description string

// AmountMsat is the per-item amount in millisatoshis. Zero means no
// fixed amount (the payer must specify invreq_amount).
AmountMsat uint64

// AbsoluteExpiry is seconds since epoch after which the offer expires.

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.

Remark: offer_absolute_expiry expires the offer as a whole; lnwire.BlindedPath has no explicit per-path expiry field. When the later path-generation/receive flow is wired, we should make sure any implementation-defined blinded-path lifetime is at least as long as the offer lifetime (or rotate/replace the offer when its paths expire), otherwise an offer can remain formally valid while all of its contact paths are unusable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, thanks. CreateOffer only stores the offer_paths it gets. The receive flow that builds the paths will keep each path alive at least as long as the offer, have taken note of that. (F13)

// Zero leaves offer_absolute_expiry out, so the offer never expires.
AbsoluteExpiry uint64

// QuantityMax is the maximum items per invoice. None means the offer
// does not support quantity selection. Some(0) means unlimited
// quantity.
QuantityMax fn.Option[uint64]

// Chains specifies which blockchain networks this offer is valid for.
// When empty, the spec defaults to Bitcoin mainnet, so non-mainnet
// offers must set this.
Chains [][32]byte
}

// CreateOfferResult reports a created offer. Hash is the lookup key in the
// offer store.
type CreateOfferResult struct {
// Hash is the SHA256 hash of the TLV-encoded offer.
Hash [32]byte

// Encoded is the bech32-encoded offer string (lno1...).
Encoded string
}

// CreateOffer creates and stores a BOLT 12 offer. It returns the encoded string
// and the offer hash. It rejects an absolute expiry at or before the time of
// clk. Any other invalid parameter returns the bolt12 validation error,
// wrapped. It stores only an offer that the bolt12 reader accepts. The store
// sets the creation time from its own clock, so clk must be that same clock.
// Otherwise the expiry check and the stored creation time can disagree.
func CreateOffer(ctx context.Context, store Store, clk clock.Clock,

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.

nit: CreateOffer validates expiry with the clk passed here, while SQLStore.InsertOffer assigns CreatedAt from its own independently injected clock. Could we either source both values from the same clock/timestamp or document that the two clocks must agree? Otherwise a caller can persist an offer whose expiry is at or before its stored creation time even though this check passed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks! I kept the store clock, because the store owns the creation time, and documented that CreateOffer must get the clock of the store. (F10)

params CreateOfferParams) (*CreateOfferResult, error) {

// The codec cannot check the expiry, because it has no clock.
if params.AbsoluteExpiry > 0 &&
params.AbsoluteExpiry <= uint64(clk.Now().Unix()) {

return nil, ErrExpiryNotInFuture
}

offer := &bolt12.Offer{}

params.Identity.WhenLeft(func(key *btcec.PublicKey) {
offer.OfferIssuerID = tlv.SomeRecordT(
tlv.RecordT[tlv.TlvType22, *btcec.PublicKey]{
Val: key,
},
)
})
params.Identity.WhenRight(func(paths []lnwire.BlindedPath) {
offer.OfferPaths = tlv.SomeRecordT(
tlv.RecordT[
tlv.TlvType16, lnwire.BlindedPaths,
]{
Val: lnwire.BlindedPaths{
Paths: paths,
},
},
)
})

if len(params.Chains) > 0 {
Comment thread
bitromortac marked this conversation as resolved.
offer.OfferChains = tlv.SomeRecordT(
tlv.RecordT[tlv.TlvType2, bolt12.ChainsRecord]{
Val: bolt12.ChainsRecord{
Chains: params.Chains,
},
},
)
}

if params.Description != "" {
Comment thread
bitromortac marked this conversation as resolved.
offer.OfferDescription = tlv.SomeRecordT(
tlv.RecordT[tlv.TlvType10, tlv.Blob]{
Val: []byte(params.Description),
},
)
}

if params.AmountMsat > 0 {
amount := bolt12.TUint64(params.AmountMsat)
offer.OfferAmount = tlv.SomeRecordT(
tlv.RecordT[tlv.TlvType8, bolt12.TUint64]{
Val: amount,
},
)
}

if params.AbsoluteExpiry > 0 {
expiry := bolt12.TUint64(params.AbsoluteExpiry)
offer.OfferAbsoluteExpiry = tlv.SomeRecordT(
tlv.RecordT[tlv.TlvType14, bolt12.TUint64]{
Val: expiry,
},
)
}

params.QuantityMax.WhenSome(func(qty uint64) {
offer.OfferQuantityMax = tlv.SomeRecordT(
tlv.RecordT[tlv.TlvType20, bolt12.TUint64]{
Val: bolt12.TUint64(qty),
},
)
})

encoded, err := bolt12.EncodeOfferString(offer)
if err != nil {
return nil, fmt.Errorf("encode offer: %w", err)
}

// The writer accepts some offers that the reader rejects, for example
// a record above the P2P size limit. Decode the offer once, so that
// the store holds only an offer that a payer and this node can read.
readChain := [32]byte(*chaincfg.MainNetParams.GenesisHash)
if len(params.Chains) > 0 {
readChain = params.Chains[0]
}
_, err = bolt12.DecodeOfferString(encoded, clk.Now(), readChain)

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.

Would writer-side validation be cleaner than encoding and then decoding here? Specifically, we could reject more than maxOfferChains in ChainsRecord/validateOfferWrite and reject any Offer.AllRecords entry above tlv.MaxRecordSize during encoding. That would protect every EncodeOfferString caller, avoid selecting a synthetic readChain and reading the clock again, and return writer errors directly. The tradeoff is that future reader limits must also be mirrored, which boundary tests could pin. What do you think: would you prefer that approach here, or keep this guard and add the writer checks separately?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed that this isn't perfect. I think we should keep it for now and address it later, since reader and writer rules aren't really 100% symmetric and there are other cases as well.

if err != nil {
return nil, fmt.Errorf("decode offer: %w", err)
}

offerHash, err := bolt12.OfferHash(offer)
if err != nil {
return nil, fmt.Errorf("offer hash: %w", err)
}

// The encoded string carries every offer field.
_, err = store.InsertOffer(ctx, &Offer{
Hash: offerHash,
Encoded: encoded,
})
if err != nil {
return nil, fmt.Errorf("persist offer: %w", err)
}

return &CreateOfferResult{
Hash: offerHash,
Encoded: encoded,
}, nil
}
Loading
Loading