Skip to content

offers: add a SQL-backed store for BOLT 12 offers - #11278

Merged
ziggie1984 merged 3 commits into
lightningnetwork:masterfrom
bitromortac:20260924-6dc2-dispatch
Oct 8, 2026
Merged

ziggie1984 merged 3 commits into
lightningnetwork:masterfrom
bitromortac:20260924-6dc2-dispatch

Conversation

@bitromortac

Copy link
Copy Markdown
Collaborator

This PR adds the offers package and the offers table. It is the storage base for the BOLT 12 receive flow. It depends on #11191, so only the last three commits are new here.

Offers get their own table and do not extend invoices. An offer is a long-lived template that can pay many invoices, and BOLT 11 has no such lifecycle. The table keeps only the encoded offer and the local state 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 sufficient because the payer copies every offer field into the invoice request, and the offer hash covers the whole offer range. A lookup by that hash therefore proves an exact match, and a spec extension needs no migration. For this reason the store has only an insert and a lookup by offer hash.

The store maps a duplicate offer to ErrOfferExists and a missing offer to ErrOfferNotFound. It sets the creation time from its own clock.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Sep 29, 2026
@github-actions

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 34 files | 2186 lines changed

🔴 Critical (7 files)
  • sqldb/migrations.go - registers a new database migration
  • sqldb/sqlc/migrations/000016_offers.up.sql - new schema migration (creates offers tables)
  • sqldb/sqlc/migrations/000016_offers.down.sql - migration rollback
  • sqldb/sqlc/models.go - generated DB models for new offers schema
  • sqldb/sqlc/querier.go - generated DB query interface
  • sqldb/sqlc/queries/offers.sql - new SQL queries backing the offers store
  • sqldb/sqlc/offers.sql.go - generated query implementation
🟡 Medium (12 files)
  • bolt12/bech32.go, bolt12/doc.go, bolt12/invoice.go, bolt12/invoice_error.go, bolt12/invoice_request.go, bolt12/offer.go, bolt12/offer_hash.go, bolt12/signature.go, bolt12/validate.go - BOLT12 offer/invoice encoding and validation logic
  • offers/create.go, offers/interface.go, offers/sql_store.go - new offers subsystem built on top of the SQL store
🟢 Low (15 files)
  • docs/release-notes/release-notes-0.22.0.md - release notes
  • 14 test-only files across bolt12/ and offers/

Analysis

This PR adds a new SQL-backed offers subsystem for BOLT12, including a new database migration (000016_offers.up.sql/.down.sql) and generated sqlc code under sqldb/. Database schema migrations are always classified CRITICAL regardless of size, since a faulty migration can corrupt or fail to open existing node databases. The change is also large (~2200 lines changed across 34 files, ~1200 lines excluding tests/generated code), which would independently trigger a severity bump. Reviewers should pay close attention to the migration's up/down correctness and the new offers schema/queries in addition to the BOLT12 parsing/validation changes in bolt12/*.


To override, add a severity-override-{critical,high,medium,low} label.

@saubyk saubyk added this to the v0.22.0 milestone Sep 29, 2026
@saubyk saubyk added this to lnd v0.22 Sep 29, 2026
@saubyk saubyk added the bolt12 label Sep 29, 2026

@ViktorT-11 ViktorT-11 left a comment

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.

Adding a few comments from my initial review. In general maybe we should consider merging this PR into a side branch instead of master until more code which uses this table exists, so that we have a clear definition of how it'll be used?

Other than that, this PR in general looks good 🔥

Comment on lines +2 to +6
INSERT INTO offers (
hash, encoded, is_disabled, created_at
) VALUES (
$1, $2, $3, $4
) RETURNING id;

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: Probably no need to insert is_disabled when creating an offer, as it defaults to FALSE & there shouldn't be any use case where we insert disabled offers unless I'm wrong?
Maybe we should also have a query to update that flag, but I assume we'll add that when that's implemented in the rest of the code base.

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.

Done, InsertOffer no longer sets is_disabled, and the store docs say that a new offer is always enabled. Yes, the update query for the flag comes with the change that disables offers. (F2)


-- The full bech32-encoded offer string (lno1...). This is the
-- authoritative source for all offer fields.
encoded TEXT NOT NULL,

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.

Hmmm I'm not sure if just having the encoded field for all other offer fields is the best choice here, as say for example we'd want to implement a use case where we'd want to filter all offers that expire before a certain timestamp. That'd then require that we decode all offers in the database before we'd be able to filter them.

Maybe we should not merge this PR to master yet before we've decided what exactly how we'd want the table definition to look like, so that it becomes much more flexible to change without doing a lot migrations?

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.

Good point. Because the migration is now a dev migration, I start with the encoded offer only, and later in this series we can see if other use cases emerge. With the encoded offer as the one source, BOLT 12 features that change the offer need no migration. If a use case such as an expiry filter emerges, we can denormalize then and add the typed column with its index. (F1)

Comment thread offers/create.go Outdated
identityErr = ErrMissingIssuerKey
}
})
params.Identity.WhenRight(func(paths []lnwire.BlindedPath) {

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.

The offer_paths identity branch has no test at all: neither a blinded-path offer round-tripping through the store nor the empty-paths rejection. Same for the Chains branch. Every row in TestCreateOffer uses the issuer key.

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.

Added, a blinded-path offer now stores offer_paths and no issuer id, an empty path list is rejected, and a two-chain offer stores offer_chains. (F6)

Comment thread offers/create.go Outdated
var (
// ErrMissingDescription is returned when the offer has an amount but no
// description.
ErrMissingDescription = errors.New("description required when amount " +

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.

This re-implements a rule validateOfferWrite already enforces through EncodeOfferString, and bolt12 has its own ErrMissingDescription for it. So the same rule yields two different sentinels depending on which layer catches it first. The empty-paths check below has the same shape with an ad hoc fmt.Errorf. Suggest dropping the pre-checks and wrapping the bolt12 errors, keeping only the checks the codec cannot make, like the expiry against the clock.

@bitromortac bitromortac Oct 7, 2026 •

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.

Nice! CreateOffer now only checks the expiry against the clock, and it returns the wrapped bolt12 error for everything else. The tests expect bolt12.ErrMissingDescription, bolt12.ErrNilPublicKey and bolt12.ErrEmptyBlindedPaths. (F5)

@ziggie1984

Copy link
Copy Markdown
Collaborator

Since the offers database design is still evolving and we expect additional schema and store work, could we keep this migration development-only for now?

We already have the established mechanism for this in sqldb/migrations_dev.go, gated by:

//go:build test_db_postgres || test_db_sqlite || test_native_sql

I suggest moving the 000016_offers MigrationConfig entry out of sqldb/migrations.go and into migrationAdditions in sqldb/migrations_dev.go. The production definition in migrations_prod.go can remain empty. This follows the approach previously used while developing the native SQL payments migrations.

Besides preventing normal master builds from applying an unfinished schema, this leaves the production migration sequence flexible. If another contributor adds a migration to master in parallel, we can rebase and renumber or reorder the development migrations before promoting them, preserving a clean chronological sequence without reserving production migration numbers prematurely.

This would let us continue developing and testing the complete offers persistence model under the SQL test build tags. Once its schema, queries, lifecycle operations, and integration are ready, we can promote the finalized migrations into migrations.go using the next available schema and global migration versions.

The SQL-backed offer tests should also be gated with test_db_sqlite || test_db_postgres; otherwise an ordinary untagged go test ./offers would use the production migration list and not create the offers table.

To be precise, the migration SQL and generated sqlc code will still be compiled or embedded, but the migration will only be registered and applied in builds using those development/test tags. Normal users on master therefore will not have their database schema changed yet.

@ViktorT-11

Copy link
Copy Markdown
Collaborator

Good idea, ACK on the idea of keeping the migration as a dev migration from my end.

@bitromortac

Copy link
Copy Markdown
Collaborator Author

Ok, nice suggestion, will use that.

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: lightningnetwork#11278 (comment)
@bitromortac

bitromortac commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thanks @ziggie1984 and @ViktorT-11! 000016_offers is now a dev migration in migrationAdditions, so only builds with the SQL test tags apply it, and a normal build does not change the database schema. The offer store tests run with test_db_sqlite or test_db_postgres. We still merge into master to avoid a long-lived side branch. The BOLT 12 sender and receiver features will be gated behind an experimental protocol flag, so the code lands in small reviewed steps, but users do not see it until it is ready.

@ViktorT-11 ViktorT-11 left a comment

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.

Nice, thanks for making this a dev migration. Since we can then modify the migration easily if we notice that we need anything extra, this is good to go IMO. LGTM 🔥

@ziggie1984 ziggie1984 left a comment

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.

The SQL schema and store paths look sound overall. I found one writer/reader consistency class to address before the offer creation flow is exposed: CreateOffer can persist offers that the local reader rejects, both through more than 32 chain entries and through an individual TLV record above tlv.MaxRecordSize. I also left three non-blocking nits about documenting the hash/encoding invariant, using consistent clocks for expiry and creation time, and reusing the PostgreSQL fixture across parallel tests.

Comment thread offers/interface.go
// store sets the creation time, and a new offer is always enabled. It
// returns ErrOfferExists if an offer with the same offer hash is
// already stored.
InsertOffer(ctx context.Context, offer *Offer) (int64, error)

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: Could we document that callers must provide an already validated offer and that Hash must be derived from the offer TLVs represented by Encoded? This makes the consistency invariant explicit for future callers of InsertOffer.

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.

Done, the InsertOffer doc now states that the caller passes a validated offer and that Hash must come from the offer TLVs in Encoded. CreateOffer meets both. (F11)

Comment thread offers/create.go
// 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.
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)


// newTestBaseDB creates a Postgres database for the offer store tests.
func newTestBaseDB(t *testing.T) *sqldb.BaseDB {
fixture := sqldb.NewTestPgFixture(

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: Every call to newTestBaseDB starts a fresh Postgres container. Since the TestCreateOffer subtests and the store tests all call this through newTestSQLStore and run in parallel, this package can launch roughly 16 Postgres containers concurrently. Could we share one fixture and create an isolated database per test, as the invoice SQL tests do, to keep CI resource usage bounded?

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.

Done. The package now starts one Postgres container per top-level test, and each subtest gets its own database, as in the invoice SQL tests. (F12)

Comment thread offers/create.go
Comment thread offers/create.go
Comment thread offers/create.go
// 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)

@ziggie1984 ziggie1984 left a comment

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.

Additional store-design consistency remarks.

Comment thread offers/sql_store.go
type SQLStore struct {
db BatchedSQLOfferQueries
clock 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: Could we add an explicit compile-time assertion here that *SQLStore implements Store? Both the invoice and payment SQL stores use this pattern, and it makes interface drift fail at compile time even when the SQL-tagged tests are not being run.

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.

Done, SQLStore now has the compile-time Store check. (F14)

Comment thread offers/sql_store.go
return 0, fmt.Errorf("insert offer: %w", err)
}

return id, nil

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.

InsertOffer returns the generated ID, but because the store also chooses CreatedAt internally, the caller object still has both ID and CreatedAt unset after a successful insert and differs from the persisted row. For comparison, AddInvoice documents updating AddIndex on the supplied invoice. Could we either update the offer with both persisted values or return the complete stored Offer?

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.

Done. InsertOffer now sets ID and CreatedAt on the offer, as AddInvoice does with AddIndex. The creation time is cut to microseconds before the insert, so the offer equals the stored row on SQLite and on Postgres, and the test checks that. (F15)

Comment thread offers/sql_store.go Outdated
CreatedAt: row.CreatedAt,
}

copy(offer.Hash[:], row.Hash)

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: Could we validate that row.Hash is exactly 32 bytes and return an error instead of silently copying it? The invoice converter uses lntypes.MakeHash for this. copy will otherwise zero-pad or truncate malformed database data. The exact-key lookup makes this difficult to hit today, but rejecting malformed rows would preserve the existing corruption-detection pattern.

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.

Done, marshalOffer now uses lntypes.MakeHash, so it rejects a stored hash that is not 32 bytes. A unit test covers the lengths around 32. (F16)

@GeorgeTsagk GeorgeTsagk left a comment

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.

LGTM

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.
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.
@bitromortac
bitromortac force-pushed the 20260924-6dc2-dispatch branch from 757e121 to 9449605 Compare October 8, 2026 10:40
@bitromortac
bitromortac requested a review from ziggie1984 October 8, 2026 10:43
Comment thread offers/create.go
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.

@ziggie1984 ziggie1984 left a comment

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.

LGTM

@ziggie1984
ziggie1984 enabled auto-merge October 8, 2026 14:00
@ziggie1984
ziggie1984 disabled auto-merge October 8, 2026 14:46
@ziggie1984
ziggie1984 merged commit a27ead0 into lightningnetwork:master Oct 8, 2026
83 of 86 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bolt12 severity-critical Requires expert review - security/consensus critical

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants