Skip to content

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

Open
bitromortac wants to merge 17 commits into
lightningnetwork:masterfrom
bitromortac:20260924-6dc2-dispatch
Open

bitromortac wants to merge 17 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.

The invoice reader skipped the must-understand check for types 240 to 1000,
so an invoice carrying unknown even type 242 was accepted while the same type
on an invoice_request was rejected. The spec exempts that range from the
out-of-range rule only, and BOLT 1 still makes an unknown even type fatal.
Core Lightning grants the range no exception either, so this was a divergence
rather than a choice.

Finding F8.
lightningnetwork#10941 (comment)
The offer writer checked the allowed range but not the must-understand rule,
while the offer reader checks both. The state is only reachable for an offer
decoded and then mutated, since the typed field set cannot express an unknown
type, but the asymmetry made a reader of the code work out why one of the two
rules was missing.

Finding F5.
lightningnetwork#10789 (comment)
Decode capped the raw and the cleaned string length, which no other
implementation does and which the spec does not ask for. The cap protected
nothing: a decode allocates on the order of its input, each record is already
bounded by tlv.MaxRecordSize and every subtype decoder bounds itself against
the bytes present, and the input exists in the caller's memory before Decode
runs. What it could do is reject a spec-valid message once unknown odd fields
push a string past a limit chosen today.

Encode keeps its payload bound, since there we choose what to emit, and the
constant now says it is a writer policy. The caller bounds its own medium: the
onion-message envelope for an invoice_request and an invoice, the RPC or CLI
for a pasted or scanned offer string.

Finding F41.
lightningnetwork#11001 (comment)
The receiver identifies the offer an invoice request answers by hashing the
request's offer fields and looking the result up in its store, so that lookup
is the exact-match check the reader requirements ask for. The rule belongs in
the codec, which owns the encoding and the range predicate, rather than in
each caller.

The hash covers the offer ranges of any pure-TLV message, so one function
serves the offer, the invoice_request and the invoice. For an offer it equals
the hash of the whole encoding, since every offer TLV already sits in those
ranges.

Finding F40.
lightningnetwork#10941 (comment)
The invoice_request and invoice validators quote each spec bullet above the
check it authorises and run in the spec's order, so they can be read beside
the spec block. The two offer validators, which landed before that convention,
had none: 29 and 37 stanza lines now, against zero before.

The writer moves two checks to reach spec order, the chains rule ahead of the
amount rules and the nil-key guard down to the issuer stanza it belongs to.
Every check is otherwise the same one, and no test expectation changed, so no
input flipped to a different first error. Both functions now also state the
rules the codec cannot enforce and why, which is how the gaps stay visible.

Finding F38.
DecodeInvoiceString folds the reader gates into the decode, so a caller
that only wants to display an invoice it already validated when it stored
it has no entry point. Such a caller had to reach for the raw bech32
decode and parse the bytes itself, which accepts any of the three
prefixes.

DecodeInvoiceStringUnvalidated pins the lni prefix and skips the gates.
It is the one legitimate raw-decode caller, so the bech32 primitives can
leave the API next.

DecodeInvoiceString now delegates to it and adds the gates on top, so the
prefix check and the TLV decode have one home rather than two copies.

Finding F22.
Decode and Encode become decodeBech32 and encodeBech32. The bare names
read like the message codec they sit next to, and no caller outside the
package needs them: the string entry points fold bech32 into a validated
call, and a caller that wants a raw decode has
DecodeInvoiceStringUnvalidated.

The rename is mechanical. Four comments in bech32.go named the old
symbols, so they name the new ones now. The encoder docstring also stops
claiming its payload bound mirrors a reader limit, because the reader no
longer has one.
The verifiers go, because the readers call them and nothing verifies a
signature on its own. The writer validators go, because Encode is the
gate and already runs them. The reader validators that an exported entry
point folds in go too: DecodeOfferString for the offer, and
ValidateInvoiceForPayment for the invoice against its request.

What stays exported is what a caller that reaches the codec over an onion
message needs, because an invoice_request and an invoice arrive as raw
TLV rather than as strings.

Finding F22.
Three symbols are un-exported rather than deleted: the two
invoice_request string wrappers and UsableFallbackAddresses. Nothing
transports that message as a string today, and nothing dispatches an
on-chain fallback yet, so none of the three has a caller.

They stay because the wrappers carry the lnr prefix the spec defines, the
helper encodes the reader's MUST-ignore rules for fallback addresses, and
their tests exercise both.

Finding F22.
An invoice_request and an invoice reach a peer as raw TLV inside an onion
message, not as a bech32 string. Encode is permissive about the signature
by design, because a caller must encode before it can derive the Merkle
root it signs, so nothing enforced the writer-side MUST on the path the
bytes actually take. A caller that forgot to sign would find out only when
the remote peer rejected the message.

EncodeSigned is that entry point for both messages. It requires the
signature and verifies it against invreq_payer_id or invoice_node_id, and
EncodeInvoiceString now delegates to it, so the raw and string forms carry
one rule.
A signature over a message that breaks the writer requirements is
worthless, because the peer rejects the message on read. Running the write
validator before the Merkle root is derived keeps a key from signing bytes
no correct reader accepts, and it moves the check off the caller, which
previously had to encode once purely to trigger it.
EncodeSigned is the entry point for an invoice_request and an invoice that
leave the node, and the bech32 wrappers cover the string forms, so no
caller outside this package needs a serialisation that skips the signature
check. Un-exporting encode makes the writer-side requirement a property of
the API rather than a rule a caller has to remember.

Offer follows for consistency, since EncodeOfferString covers the only form
it travels in. InvoiceError keeps its exported Encode. It is unsigned, so
there is no check to skip, and raw TLV is the only form a receiver can send
it in.
Nothing transports an invoice request as a string. It reaches a peer as raw
TLV inside an onion message, so the bech32 encoder had no caller once
EncodeSigned took over the signature rule, leaving a tested function that
no code path could reach.

The decoder stays: another implementation may hand us an lnr string, and
the fuzz harness exercises that path. The three tests that pinned the
encoder's refusal to emit an invalid, unsigned or badly signed request move
to EncodeSigned, which is where that rule now lives.
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.
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.
@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.


-- 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?

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

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

@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.

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: In progress

Development

Successfully merging this pull request may close these issues.

5 participants