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): 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 +} 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;