From 9c5ad069f171900b9425b389c88d0a8df17ace72 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 1 Sep 2026 11:16:31 +0000 Subject: [PATCH 1/3] sqldb: add offer store schema Add the offers table migration and its generated sqlc queries. Offers are long-lived reusable templates, a lifecycle with no BOLT 11 analog, so they get their own table rather than extending invoices. The table keeps the encoded offer and the local state only. The lno1 string is the content, and it never changes after creation. The local state is the data that no offer or invoice request carries: the disabled flag and the creation time. No column holds a typed copy of an offer field. This is enough because the invoice request carries the offer terms. The payer copies every offer field into the request, and the offer hash covers the whole offer range. A lookup by that hash therefore proves an exact match, so a spec extension needs no migration. The hash is UNIQUE, which also indexes it for the lookup. The queries are the insert and the lookup by hash, the two the offer store calls. A table with one row per TLV record was also considered. It needs a query over many rows per offer, and it loses the column types. The migration is a development migration. It is in migrationAdditions, so only builds with the SQL test tags apply it, and a normal build does not change the user's schema. The offers schema can still change before the receive flow is complete. When it is final, the migration moves to the main line with the next free versions. Review: https://github.com/lightningnetwork/lnd/pull/11278#issuecomment-5941177626 --- sqldb/migrations_dev.go | 13 +++-- sqldb/sqlc/migrations/000016_offers.down.sql | 1 + sqldb/sqlc/migrations/000016_offers.up.sql | 21 ++++++++ sqldb/sqlc/models.go | 8 +++ sqldb/sqlc/offers.sql.go | 51 ++++++++++++++++++++ sqldb/sqlc/querier.go | 2 + sqldb/sqlc/queries/offers.sql | 11 +++++ 7 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 sqldb/sqlc/migrations/000016_offers.down.sql create mode 100644 sqldb/sqlc/migrations/000016_offers.up.sql create mode 100644 sqldb/sqlc/offers.sql.go create mode 100644 sqldb/sqlc/queries/offers.sql diff --git a/sqldb/migrations_dev.go b/sqldb/migrations_dev.go index 60c7b3c849f..3716d5271bd 100644 --- a/sqldb/migrations_dev.go +++ b/sqldb/migrations_dev.go @@ -5,6 +5,13 @@ package sqldb // migrationAdditions is a list of migrations that are added to the // migrationConfig slice. // -// NOTE: This should always be empty as all migrations are now included in the -// main line (see migrations.go). -var migrationAdditions []MigrationConfig +// NOTE: This holds migrations whose schema is still in development. Only the +// SQL test builds apply them. A migration moves to the main line (see +// migrations.go) with the next free versions when its schema is final. +var migrationAdditions = []MigrationConfig{ + { + Name: "000016_offers", + Version: 19, + SchemaVersion: 16, + }, +} diff --git a/sqldb/sqlc/migrations/000016_offers.down.sql b/sqldb/sqlc/migrations/000016_offers.down.sql new file mode 100644 index 00000000000..d47b5175cbc --- /dev/null +++ b/sqldb/sqlc/migrations/000016_offers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS offers; diff --git a/sqldb/sqlc/migrations/000016_offers.up.sql b/sqldb/sqlc/migrations/000016_offers.up.sql new file mode 100644 index 00000000000..31021c9ff07 --- /dev/null +++ b/sqldb/sqlc/migrations/000016_offers.up.sql @@ -0,0 +1,21 @@ +-- offers stores long-lived BOLT 12 offer templates. Each offer can generate +-- many invoices over its lifetime. +CREATE TABLE IF NOT EXISTS offers ( + -- Primary key for the offer record. + id INTEGER PRIMARY KEY, + + -- The SHA256 hash of the TLV-encoded offer, used as a unique external + -- identifier. 32 bytes. + hash BLOB NOT NULL UNIQUE, + + -- The full bech32-encoded offer string (lno1...). This is the + -- authoritative source for all offer fields. + encoded TEXT NOT NULL, + + -- Whether the offer has been administratively disabled. A disabled + -- offer rejects new invoice requests. + is_disabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Timestamp of when this offer was created. + created_at TIMESTAMP NOT NULL +); diff --git a/sqldb/sqlc/models.go b/sqldb/sqlc/models.go index ef9aa9006f9..159c041aee7 100644 --- a/sqldb/sqlc/models.go +++ b/sqldb/sqlc/models.go @@ -214,6 +214,14 @@ type MigrationTracker struct { MigrationTime time.Time } +type Offer struct { + ID int64 + Hash []byte + Encoded string + IsDisabled bool + CreatedAt time.Time +} + type Payment struct { ID int64 AmountMsat int64 diff --git a/sqldb/sqlc/offers.sql.go b/sqldb/sqlc/offers.sql.go new file mode 100644 index 00000000000..679b8844f2b --- /dev/null +++ b/sqldb/sqlc/offers.sql.go @@ -0,0 +1,51 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: offers.sql + +package sqlc + +import ( + "context" + "time" +) + +const getOfferByHash = `-- name: GetOfferByHash :one +SELECT id, hash, encoded, is_disabled, created_at +FROM offers +WHERE hash = $1 +` + +func (q *Queries) GetOfferByHash(ctx context.Context, hash []byte) (Offer, error) { + row := q.db.QueryRowContext(ctx, getOfferByHash, hash) + var i Offer + err := row.Scan( + &i.ID, + &i.Hash, + &i.Encoded, + &i.IsDisabled, + &i.CreatedAt, + ) + return i, err +} + +const insertOffer = `-- name: InsertOffer :one +INSERT INTO offers ( + hash, encoded, created_at +) VALUES ( + $1, $2, $3 +) RETURNING id +` + +type InsertOfferParams struct { + Hash []byte + Encoded string + CreatedAt time.Time +} + +func (q *Queries) InsertOffer(ctx context.Context, arg InsertOfferParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertOffer, arg.Hash, arg.Encoded, arg.CreatedAt) + var id int64 + err := row.Scan(&id) + return id, err +} diff --git a/sqldb/sqlc/querier.go b/sqldb/sqlc/querier.go index 9b95a669917..f3655d7b615 100644 --- a/sqldb/sqlc/querier.go +++ b/sqldb/sqlc/querier.go @@ -143,6 +143,7 @@ type Querier interface { GetNodesByBlockHeightRange(ctx context.Context, arg GetNodesByBlockHeightRangeParams) ([]GraphNode, error) GetNodesByIDs(ctx context.Context, ids []int64) ([]GraphNode, error) GetNodesByLastUpdateRange(ctx context.Context, arg GetNodesByLastUpdateRangeParams) ([]GraphNode, error) + GetOfferByHash(ctx context.Context, hash []byte) (Offer, error) GetPruneEntriesForHeights(ctx context.Context, heights []int64) ([]GraphPruneLog, error) GetPruneHashByHeight(ctx context.Context, blockHeight int64) ([]byte, error) GetPruneTip(ctx context.Context) (GraphPruneLog, error) @@ -198,6 +199,7 @@ type Querier interface { // is used because of the constraint in that query that requires a node update // to have a newer last_update than the existing node). InsertNodeMig(ctx context.Context, arg InsertNodeMigParams) (int64, error) + InsertOffer(ctx context.Context, arg InsertOfferParams) (int64, error) // Insert a new payment and return its ID. // When creating a payment we don't have a fail reason because we start the // payment process. diff --git a/sqldb/sqlc/queries/offers.sql b/sqldb/sqlc/queries/offers.sql new file mode 100644 index 00000000000..b9987509f4f --- /dev/null +++ b/sqldb/sqlc/queries/offers.sql @@ -0,0 +1,11 @@ +-- name: InsertOffer :one +INSERT INTO offers ( + hash, encoded, created_at +) VALUES ( + $1, $2, $3 +) RETURNING id; + +-- name: GetOfferByHash :one +SELECT * +FROM offers +WHERE hash = $1; From 2ffeebcf8835d4779fb2ae1fbfc06817eb40a6ac Mon Sep 17 00:00:00 2001 From: bitromortac Date: Tue, 14 Apr 2026 16:20:01 +0200 Subject: [PATCH 2/3] offers: add offer store with insert and lookup Introduce the offers package with a SQL-backed store for BOLT 12 offers. The store keeps the encoded offer and the local state that the offer string does not carry. It has an insert and a lookup by offer hash. CreateOffer validates the parameters, encodes the offer and inserts it. --- offers/create.go | 179 +++++++++++++++++++ offers/create_test.go | 323 +++++++++++++++++++++++++++++++++++ offers/interface.go | 57 +++++++ offers/marshal_test.go | 25 +++ offers/sql_store.go | 148 ++++++++++++++++ offers/sql_store_test.go | 127 ++++++++++++++ offers/test_postgres_test.go | 28 +++ offers/test_sqlite_test.go | 19 +++ 8 files changed, 906 insertions(+) create mode 100644 offers/create.go create mode 100644 offers/create_test.go create mode 100644 offers/interface.go create mode 100644 offers/marshal_test.go create mode 100644 offers/sql_store.go create mode 100644 offers/sql_store_test.go create mode 100644 offers/test_postgres_test.go create mode 100644 offers/test_sqlite_test.go diff --git a/offers/create.go b/offers/create.go new file mode 100644 index 00000000000..758d4a788bd --- /dev/null +++ b/offers/create.go @@ -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. + // 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, + 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 { + offer.OfferChains = tlv.SomeRecordT( + tlv.RecordT[tlv.TlvType2, bolt12.ChainsRecord]{ + Val: bolt12.ChainsRecord{ + Chains: params.Chains, + }, + }, + ) + } + + if params.Description != "" { + 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) + 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 +} diff --git a/offers/create_test.go b/offers/create_test.go new file mode 100644 index 00000000000..caf9ef6671e --- /dev/null +++ b/offers/create_test.go @@ -0,0 +1,323 @@ +//go:build test_db_sqlite || test_db_postgres + +package offers + +import ( + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/lightningnetwork/lnd/bolt12" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// issuerIdentity wraps a pubkey as a Left Either for CreateOfferParams. +func issuerIdentity(key *btcec.PublicKey) fn.Either[*btcec.PublicKey, + []lnwire.BlindedPath] { + + return fn.NewLeft[*btcec.PublicKey, []lnwire.BlindedPath](key) +} + +// testIssuerKey generates a deterministic private key for testing. +func testIssuerKey(t *testing.T) *btcec.PrivateKey { + t.Helper() + + // Use a fixed seed for deterministic tests. + var seed [32]byte + for i := range seed { + seed[i] = byte(i + 1) + } + + privKey, _ := btcec.PrivKeyFromBytes(seed[:]) + + return privKey +} + +// decodeStoredOffer looks up an offer by its offer hash and decodes the stored +// string. +func decodeStoredOffer(t *testing.T, store Store, + offerHash [32]byte) *bolt12.Offer { + + t.Helper() + + got, err := store.GetOfferByHash(t.Context(), offerHash) + require.NoError(t, err) + + // The time is before every expiry in these tests. + decoded, err := bolt12.DecodeOfferString( + got.Encoded, time.Unix(0, 0), + [32]byte(*chaincfg.MainNetParams.GenesisHash), + ) + require.NoError(t, err) + + return decoded +} + +// TestCreateOffer verifies that CreateOffer validates its parameters, and +// that a created offer is stored and decodes with the requested fields. +func TestCreateOffer(t *testing.T) { + t.Parallel() + + privKey := testIssuerKey(t) + identity := issuerIdentity(privKey.PubKey()) + + // The subtests share one database server, and each gets its own + // database. + fixture := newTestFixture(t) + + intro, err := lnwire.NewPubkeyIntro(privKey.PubKey()) + require.NoError(t, err) + path := lnwire.BlindedPath{ + IntroductionNode: intro, + BlindingPoint: privKey.PubKey(), + Hops: []lnwire.BlindedHop{{ + BlindedNodeID: privKey.PubKey(), + EncryptedData: []byte{1, 2, 3}, + }}, + } + + mainnet := [32]byte(*chaincfg.MainNetParams.GenesisHash) + testnet := [32]byte(*chaincfg.TestNet3Params.GenesisHash) + + // manyChains returns n distinct chains, with mainnet first, so that + // the stored offer decodes on mainnet. + manyChains := func(n int) [][32]byte { + chains := [][32]byte{mainnet} + for i := 1; i < n; i++ { + chains = append(chains, [32]byte{byte(i)}) + } + + return chains + } + now := uint64(testTime.Unix()) + later := uint64(testTime.Add(time.Hour).Unix()) + + testCases := []struct { + name string + params CreateOfferParams + wantErr error + + // check inspects the decoded offer of a successful case. + check func(t *testing.T, offer *bolt12.Offer) + }{{ + name: "fixed amount", + params: CreateOfferParams{ + Identity: identity, + Description: "coffee", + AmountMsat: 10000, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + amount := offer.OfferAmount.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(10000), amount) + require.True(t, offer.OfferQuantityMax.IsNone()) + require.True(t, offer.OfferAbsoluteExpiry.IsNone()) + }, + }, { + name: "any amount", + params: CreateOfferParams{ + Identity: identity, + Description: "tips", + }, + check: func(t *testing.T, offer *bolt12.Offer) { + require.True(t, offer.OfferAmount.IsNone()) + }, + }, { + name: "amount without description", + params: CreateOfferParams{ + Identity: identity, + AmountMsat: 10000, + }, + wantErr: bolt12.ErrMissingDescription, + }, { + name: "missing issuer key", + params: CreateOfferParams{ + Description: "test", + }, + wantErr: bolt12.ErrNilPublicKey, + }, { + name: "empty offer paths", + params: CreateOfferParams{ + Identity: fn.NewRight[*btcec.PublicKey]( + []lnwire.BlindedPath{}, + ), + Description: "test", + }, + wantErr: bolt12.ErrEmptyBlindedPaths, + }, { + name: "blinded paths", + params: CreateOfferParams{ + Identity: fn.NewRight[*btcec.PublicKey]( + []lnwire.BlindedPath{path}, + ), + Description: "private", + }, + check: func(t *testing.T, offer *bolt12.Offer) { + require.True(t, offer.OfferPaths.IsSome()) + require.True(t, offer.OfferIssuerID.IsNone()) + }, + }, { + name: "chains", + params: CreateOfferParams{ + Identity: identity, + Description: "multi-chain", + Chains: [][32]byte{mainnet, testnet}, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + require.True(t, offer.OfferChains.IsSome()) + }, + }, { + name: "chains at the decode limit", + params: CreateOfferParams{ + Identity: identity, + Description: "many chains", + Chains: manyChains(32), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + chains := offer.OfferChains.UnwrapOrFailV(t).Chains + require.Len(t, chains, 32) + }, + }, { + name: "chains above the decode limit", + params: CreateOfferParams{ + Identity: identity, + Description: "too many chains", + Chains: manyChains(33), + }, + wantErr: bolt12.ErrTooManyChains, + }, { + name: "description at the record size limit", + params: CreateOfferParams{ + Identity: identity, + Description: strings.Repeat("a", tlv.MaxRecordSize), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + desc := offer.OfferDescription.UnwrapOrFailV(t) + require.Len(t, desc, tlv.MaxRecordSize) + }, + }, { + name: "description above the record size limit", + params: CreateOfferParams{ + Identity: identity, + Description: strings.Repeat( + "a", tlv.MaxRecordSize+1, + ), + }, + wantErr: tlv.ErrRecordTooLarge, + }, { + name: "expiry in the future", + params: CreateOfferParams{ + Identity: identity, + Description: "limited time", + AmountMsat: 5000, + AbsoluteExpiry: later, + }, + check: func(t *testing.T, offer *bolt12.Offer) { + expiry := offer.OfferAbsoluteExpiry.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(later), expiry) + }, + }, { + name: "expiry before now", + params: CreateOfferParams{ + Identity: identity, + Description: "expired", + AmountMsat: 5000, + AbsoluteExpiry: now - 1, + }, + wantErr: ErrExpiryNotInFuture, + }, { + name: "expiry at now", + params: CreateOfferParams{ + Identity: identity, + Description: "expired", + AmountMsat: 5000, + AbsoluteExpiry: now, + }, + wantErr: ErrExpiryNotInFuture, + }, { + name: "quantity limit", + params: CreateOfferParams{ + Identity: identity, + Description: "stickers", + AmountMsat: 1000, + QuantityMax: fn.Some[uint64](10), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + qty := offer.OfferQuantityMax.UnwrapOrFailV(t) + require.Equal(t, bolt12.TUint64(10), qty) + }, + }, { + name: "unlimited quantity", + params: CreateOfferParams{ + Identity: identity, + Description: "stickers", + AmountMsat: 1000, + QuantityMax: fn.Some[uint64](0), + }, + check: func(t *testing.T, offer *bolt12.Offer) { + qty := offer.OfferQuantityMax.UnwrapOrFailV(t) + require.Zero(t, qty) + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t, fixture) + result, err := CreateOffer( + t.Context(), store, testClock, tc.params, + ) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + // The stored offer is the one CreateOffer returned. + got, err := store.GetOfferByHash( + t.Context(), result.Hash, + ) + require.NoError(t, err) + require.Equal(t, result.Encoded, got.Encoded) + + // An issuer key offer carries the key it was created + // with. + offer := decodeStoredOffer(t, store, result.Hash) + if tc.params.Identity.IsLeft() { + key := offer.OfferIssuerID.UnwrapOrFailV(t) + require.True(t, privKey.PubKey().IsEqual(key)) + require.True(t, offer.OfferPaths.IsNone()) + } + + tc.check(t, offer) + }) + } +} + +// TestCreateOfferDuplicate verifies that creating the same offer twice fails, +// because the offer hash is unique. +func TestCreateOfferDuplicate(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t, newTestFixture(t)) + ctx := t.Context() + privKey := testIssuerKey(t) + + params := CreateOfferParams{ + Identity: issuerIdentity(privKey.PubKey()), + Description: "coffee", + AmountMsat: 10000, + } + + _, err := CreateOffer(ctx, store, testClock, params) + require.NoError(t, err) + + // The same parameters produce the same offer hash. + _, err = CreateOffer(ctx, store, testClock, params) + require.ErrorIs(t, err, ErrOfferExists) +} diff --git a/offers/interface.go b/offers/interface.go new file mode 100644 index 00000000000..a03e96bbbdd --- /dev/null +++ b/offers/interface.go @@ -0,0 +1,57 @@ +package offers + +import ( + "context" + "errors" + "time" +) + +var ( + // ErrOfferNotFound is returned when no offer has the requested offer + // hash. + ErrOfferNotFound = errors.New("offer not found") + + // ErrOfferExists is returned when an offer with the same offer hash is + // already stored. + ErrOfferExists = errors.New("offer already exists") +) + +// Offer represents a persisted BOLT 12 offer. It keeps the encoded offer and +// the local state that the offer string does not carry. Code that needs an +// offer field decodes Encoded. +type Offer struct { + // ID is the database primary key. + ID int64 + + // Hash is the SHA256 hash of the TLV-encoded offer, used as a unique + // external identifier. + Hash [32]byte + + // Encoded is the full bech32-encoded offer string (lno1...). + Encoded string + + // IsDisabled indicates the offer has been administratively disabled. + IsDisabled bool + + // CreatedAt is the time the store persisted the offer. + CreatedAt time.Time +} + +// Store defines the interface for persisting and querying BOLT 12 offers. +type Store interface { + // InsertOffer persists a new offer and returns its database ID. The + // store sets the creation time, and a new offer is always enabled. On + // success it also sets ID and CreatedAt on offer to the stored values. + // It returns ErrOfferExists if an offer with the same offer hash is + // already stored. + // + // The store does not decode or validate the offer. The caller passes + // an offer that the bolt12 reader accepts, and Hash must be the offer + // hash of the TLV records in Encoded. CreateOffer meets both + // conditions. + InsertOffer(ctx context.Context, offer *Offer) (int64, error) + + // GetOfferByHash retrieves an offer by its 32-byte offer hash. It + // returns ErrOfferNotFound if no offer has that hash. + GetOfferByHash(ctx context.Context, offerHash [32]byte) (*Offer, error) +} diff --git a/offers/marshal_test.go b/offers/marshal_test.go new file mode 100644 index 00000000000..8428cb25897 --- /dev/null +++ b/offers/marshal_test.go @@ -0,0 +1,25 @@ +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb/sqlc" + "github.com/stretchr/testify/require" +) + +// TestMarshalOfferHashLength verifies that marshalOffer rejects a stored +// offer hash that is not 32 bytes long, and accepts one that is. +func TestMarshalOfferHashLength(t *testing.T) { + t.Parallel() + + for _, n := range []int{0, 31, 33} { + _, err := marshalOffer(sqlc.Offer{Hash: make([]byte, n)}) + require.Error(t, err, "length %d", n) + } + + hash := make([]byte, 32) + hash[0] = 1 + offer, err := marshalOffer(sqlc.Offer{Hash: hash}) + require.NoError(t, err) + require.Equal(t, [32]byte{1}, offer.Hash) +} diff --git a/offers/sql_store.go b/offers/sql_store.go new file mode 100644 index 00000000000..2b89ca024e4 --- /dev/null +++ b/offers/sql_store.go @@ -0,0 +1,148 @@ +package offers + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/lightningnetwork/lnd/sqldb/sqlc" +) + +// SQLOfferQueries is the interface that defines the set of operations that can +// be executed against the offers SQL database. +type SQLOfferQueries interface { + // InsertOffer inserts an offer row and returns its primary key. It + // fails with a unique constraint violation when the offer hash is + // already stored. + InsertOffer(ctx context.Context, + arg sqlc.InsertOfferParams) (int64, error) + + // GetOfferByHash returns the offer row with the given offer hash, or + // sql.ErrNoRows when no row has it. + GetOfferByHash(ctx context.Context, + offerHash []byte) (sqlc.Offer, error) +} + +// BatchedSQLOfferQueries combines the offer queries interface with batched +// transaction execution. +type BatchedSQLOfferQueries interface { + SQLOfferQueries + + sqldb.BatchedTx[SQLOfferQueries] +} + +// SQLStore is the SQL-backed implementation of the Store interface. +type SQLStore struct { + db BatchedSQLOfferQueries + clock clock.Clock +} + +// A compile-time check that SQLStore implements Store. +var _ Store = (*SQLStore)(nil) + +// NewSQLStore creates a new SQL-backed offer store. +func NewSQLStore(db BatchedSQLOfferQueries, clock clock.Clock) *SQLStore { + return &SQLStore{ + db: db, + clock: clock, + } +} + +// InsertOffer persists a new offer and returns its database ID. The store sets +// the creation time from its clock, and a new offer is always enabled. On +// success it also sets ID and CreatedAt on offer to the stored values. It +// returns ErrOfferExists if an offer with the same offer hash is already +// stored. +func (s *SQLStore) InsertOffer(ctx context.Context, offer *Offer) (int64, + error) { + + // Postgres keeps microseconds, so drop the rest. The time on offer + // then equals the stored time on every backend. + createdAt := s.clock.Now().UTC().Truncate(time.Microsecond) + + var id int64 + + err := s.db.ExecTx( + ctx, sqldb.WriteTxOpt(), + func(q SQLOfferQueries) error { + var err error + id, err = q.InsertOffer(ctx, sqlc.InsertOfferParams{ + Hash: offer.Hash[:], + Encoded: offer.Encoded, + CreatedAt: createdAt, + }) + + return err + }, + sqldb.NoOpReset, + ) + if err != nil { + var uniqueErr *sqldb.ErrSQLUniqueConstraintViolation + if errors.As(sqldb.MapSQLError(err), &uniqueErr) { + return 0, ErrOfferExists + } + + return 0, fmt.Errorf("insert offer: %w", err) + } + + offer.ID = id + offer.CreatedAt = createdAt + + return id, nil +} + +// GetOfferByHash retrieves an offer by its 32-byte offer hash. It returns +// ErrOfferNotFound if no offer has that hash. +func (s *SQLStore) GetOfferByHash(ctx context.Context, offerHash [32]byte) ( + *Offer, error) { + + var offer *Offer + + err := s.db.ExecTx( + ctx, sqldb.ReadTxOpt(), + func(q SQLOfferQueries) error { + row, err := q.GetOfferByHash(ctx, offerHash[:]) + if err != nil { + return err + } + + offer, err = marshalOffer(row) + + return err + }, + sqldb.NoOpReset, + ) + + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrOfferNotFound + + case err != nil: + return nil, fmt.Errorf("get offer by offer hash: %w", err) + } + + return offer, nil +} + +// marshalOffer converts a sqlc.Offer row to our domain Offer type. It returns +// an error if the stored hash is not 32 bytes long, which means the row is +// corrupt. +func marshalOffer(row sqlc.Offer) (*Offer, error) { + hash, err := lntypes.MakeHash(row.Hash) + if err != nil { + return nil, fmt.Errorf("offer %d: %w", row.ID, err) + } + + return &Offer{ + ID: row.ID, + Hash: hash, + Encoded: row.Encoded, + IsDisabled: row.IsDisabled, + CreatedAt: row.CreatedAt, + }, nil +} diff --git a/offers/sql_store_test.go b/offers/sql_store_test.go new file mode 100644 index 00000000000..740e74e5836 --- /dev/null +++ b/offers/sql_store_test.go @@ -0,0 +1,127 @@ +//go:build test_db_sqlite || test_db_postgres + +package offers + +import ( + "crypto/sha256" + "database/sql" + "testing" + "time" + + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/sqldb" + "github.com/stretchr/testify/require" +) + +// testTime is the time the test clock reports. It has no sub-second part, so +// it survives the database round trip unchanged. +var testTime = time.Unix(1735689600, 0).UTC() + +// testClock is the clock the tests give CreateOffer. It stands at testTime, +// like the clock of the test store. +var testClock = clock.NewTestClock(testTime) + +// newTestSQLStore creates an offer store on an isolated database of the kind +// that the build tags select. For Postgres, the database is in the container +// of fixture. +func newTestSQLStore(t *testing.T, fixture *sqldb.TestPgFixture) *SQLStore { + t.Helper() + + return newTestSQLStoreAt(t, fixture, testTime) +} + +// newTestSQLStoreAt creates a test offer store whose clock stands at now. +func newTestSQLStoreAt(t *testing.T, fixture *sqldb.TestPgFixture, + now time.Time) *SQLStore { + + t.Helper() + + db := newTestBaseDB(t, fixture) + + executor := sqldb.NewTransactionExecutor( + db, + func(tx *sql.Tx) SQLOfferQueries { + return db.WithTx(tx) + }, + ) + + return NewSQLStore(executor, clock.NewTestClock(now)) +} + +// testOffer returns an offer with a synthetic encoding and offer hash. +func testOffer() *Offer { + encoded := "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua" + + "9uve2rlrm9deu7xyfzrcgqyqs" + + return &Offer{ + Hash: sha256.Sum256([]byte(encoded)), + Encoded: encoded, + } +} + +// TestInsertAndGetOffer verifies that an offer round-trips through insert and +// lookup by offer hash, that the store sets the creation time, and that the +// inserted offer equals the stored row. +func TestInsertAndGetOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t, newTestFixture(t)) + ctx := t.Context() + offer := testOffer() + + id, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + require.Equal(t, id, offer.ID) + require.Equal(t, testTime, offer.CreatedAt) + + got, err := store.GetOfferByHash(ctx, offer.Hash) + require.NoError(t, err) + require.Equal(t, offer, got) +} + +// TestInsertOfferSubMicrosecond verifies that a clock with nanoseconds still +// gives an inserted offer that equals the stored row, because the store drops +// everything below one microsecond. +func TestInsertOfferSubMicrosecond(t *testing.T) { + t.Parallel() + + now := testTime.Add(123456789 * time.Nanosecond) + store := newTestSQLStoreAt(t, newTestFixture(t), now) + ctx := t.Context() + offer := testOffer() + + _, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + require.Equal(t, now.Truncate(time.Microsecond), offer.CreatedAt) + + got, err := store.GetOfferByHash(ctx, offer.Hash) + require.NoError(t, err) + require.Equal(t, offer, got) +} + +// TestInsertDuplicateOffer verifies that a second offer with the same offer +// hash is rejected with ErrOfferExists. +func TestInsertDuplicateOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t, newTestFixture(t)) + ctx := t.Context() + offer := testOffer() + + _, err := store.InsertOffer(ctx, offer) + require.NoError(t, err) + + _, err = store.InsertOffer(ctx, offer) + require.ErrorIs(t, err, ErrOfferExists) +} + +// TestGetUnknownOffer verifies that a lookup of an unknown offer hash returns +// ErrOfferNotFound. +func TestGetUnknownOffer(t *testing.T) { + t.Parallel() + + store := newTestSQLStore(t, newTestFixture(t)) + + _, err := store.GetOfferByHash(t.Context(), [32]byte{1}) + require.ErrorIs(t, err, ErrOfferNotFound) +} diff --git a/offers/test_postgres_test.go b/offers/test_postgres_test.go new file mode 100644 index 00000000000..51e1cd8d94f --- /dev/null +++ b/offers/test_postgres_test.go @@ -0,0 +1,28 @@ +//go:build test_db_postgres + +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// newTestFixture starts one Postgres container for a test and its subtests. +// The container stops when the test ends. +func newTestFixture(t *testing.T) *sqldb.TestPgFixture { + fixture := sqldb.NewTestPgFixture( + t, sqldb.DefaultPostgresFixtureLifetime, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + return fixture +} + +// newTestBaseDB creates an isolated Postgres database in the container of +// fixture. +func newTestBaseDB(t *testing.T, fixture *sqldb.TestPgFixture) *sqldb.BaseDB { + return sqldb.NewTestPostgresDB(t, fixture).BaseDB +} diff --git a/offers/test_sqlite_test.go b/offers/test_sqlite_test.go new file mode 100644 index 00000000000..abb791cb5da --- /dev/null +++ b/offers/test_sqlite_test.go @@ -0,0 +1,19 @@ +//go:build test_db_sqlite && !test_db_postgres + +package offers + +import ( + "testing" + + "github.com/lightningnetwork/lnd/sqldb" +) + +// newTestFixture returns nil, because SQLite needs no shared server. +func newTestFixture(_ *testing.T) *sqldb.TestPgFixture { + return nil +} + +// newTestBaseDB creates an isolated SQLite database. It ignores fixture. +func newTestBaseDB(t *testing.T, _ *sqldb.TestPgFixture) *sqldb.BaseDB { + return sqldb.NewTestSqliteDB(t).BaseDB +} From 9449605dd32e97bc41a0f2ceb1e59a8c8e3d5da3 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Mon, 28 Sep 2026 18:27:20 +0000 Subject: [PATCH 3/3] docs: add release note for the offer store Add the offer store to the BOLT 12 section of the 0.22.0 release notes. The PR link holds a placeholder until the PR number is known. --- docs/release-notes/release-notes-0.22.0.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 249ca6c8255..56972f0a4ab 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -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):